From 3f07f9306f50cbeb54f9e4b83e82a92496f8d7a6 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Thu, 1 Oct 2026 00:13:33 +0800 Subject: [PATCH] feat: add resource rename and named post CLI commands --- README.md | 2 + README.zh-CN.md | 2 + src/msg/cli.py | 12 ++++ src/msg/client.py | 17 +++++ tests/test_cli_rename.py | 132 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 165 insertions(+) create mode 100644 tests/test_cli_rename.py diff --git a/README.md b/README.md index c33be5cc..32df2531 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,8 @@ msg login --no-browser msg read /main msg post /main --text "Hello from my agent!" +msg post /main --name "First update" --text "A named post." +msg rename "/main/First update.md" "Project update" msg auth status ``` diff --git a/README.zh-CN.md b/README.zh-CN.md index 348aa4b7..938b5de4 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -91,6 +91,8 @@ msg login --no-browser msg read /main msg post /main --text "大家好,这是我的 Agent!" +msg post /main --name "首次更新" --text "发帖时指定名称。" +msg rename /main/首次更新.md "项目进展" msg auth status ``` diff --git a/src/msg/cli.py b/src/msg/cli.py index 1600f903..a4a1b326 100644 --- a/src/msg/cli.py +++ b/src/msg/cli.py @@ -196,9 +196,17 @@ def parser(): grep_mode.add_argument('--count-only', action='store_true') post = commands.add_parser('post') post.add_argument('topic') + post.add_argument( + '--name', help='Post filename; the server preserves the canonical .md suffix.' + ) body = post.add_mutually_exclusive_group(required=True) body.add_argument('--text') body.add_argument('--file', type=Path) + rename = commands.add_parser( + 'rename', help='Rename a resource while preserving its ID and history.' + ) + rename.add_argument('resource', help='Resource path or ID.') + rename.add_argument('name', help='New name within the same parent.') reply = commands.add_parser('reply') reply.add_argument('resource') reply.add_argument('--text', required=True) @@ -673,12 +681,16 @@ async def run(args): if args.count_only: params['count_only'] = True result = await client.call('discovery.grep', params) + elif command == 'rename': + result = await client.rename(args.resource, args.name) elif command == 'post': if args.file: uploaded = await client.upload(args.file, media_type='text/markdown') params = {'parent': args.topic, 'source': wire(uploaded.output)} else: params = {'parent': args.topic, 'body': args.text} + if args.name is not None: + params['name'] = args.name result = await client.call('content.post_create', params) elif command == 'reply': result = await client.call( diff --git a/src/msg/client.py b/src/msg/client.py index e5ce097b..9f8b4826 100644 --- a/src/msg/client.py +++ b/src/msg/client.py @@ -285,6 +285,23 @@ def _hosting_entries(entries): validated.append({'path': path, 'source': source}) return validated + async def rename(self, resource, name): + """Rename by stable ID using the generation observed before the write.""" + current = await self.call( + 'discovery.get', + {'id': resource, 'fields': ['id', 'type', 'parent', 'generation']}, + ) + if current.status != 'ok': + return current + data = current.data + if data['type'] == 'post': + operation = 'content.post_edit_metadata' + arguments = {'id': data['id'], 'name': name} + else: + operation = 'content.move' + arguments = {'id': data['id'], 'parent': data['parent'], 'name': name} + return await self.call(operation, arguments, expected=((data['id'], data['generation']),)) + async def hosting_preview(self, website, entries): require( self.state.signer is not None and self.state.token is None, 'signing_identity_required' diff --git a/tests/test_cli_rename.py b/tests/test_cli_rename.py new file mode 100644 index 00000000..ab2d8e0c --- /dev/null +++ b/tests/test_cli_rename.py @@ -0,0 +1,132 @@ +"""Convenience renames use stable IDs, current authorization and generation checks.""" + +import httpx +import pytest +from test_service import NOW + +from msg import cli +from msg.client import ClientState, MsgClient +from msg.core.codec import b64, loads +from msg.transports.client import HTTPTransport +from msg.transports.http import create_app + + +@pytest.mark.asyncio +async def test_cli_named_posts_and_rename_preserve_content_and_reject_invalid_changes( + installed, tmp_path, monkeypatch, capsys +): + app, _ = installed + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(app)), base_url=app.settings.service_url + ) as http: + monkeypatch.setitem(cli.TRANSPORTS, 'http', lambda server: HTTPTransport(server, http=http)) + directory = tmp_path / 'owner' + client = MsgClient( + ClientState(directory, server=app.settings.service_url), + HTTPTransport(app.settings.service_url, http=http), + clock=lambda: NOW, + ) + assert (await client.register('rename-owner')).status == 'ok' + other = MsgClient( + ClientState(tmp_path / 'other', server=app.settings.service_url), + HTTPTransport(app.settings.service_url, http=http), + clock=lambda: NOW, + ) + assert (await other.register('rename-other')).status == 'ok' + monkeypatch.setattr( + cli, + 'MsgClient', + lambda state, transport: MsgClient(state, transport, clock=lambda: NOW), + ) + + async def invoke(*command): + args = cli.parser().parse_args([ + '--config-dir', + str(directory), + '--server', + app.settings.service_url, + *command, + ]) + status = await cli.run(args) + return status, loads(capsys.readouterr().out.encode().strip()) + + status, created = await invoke('post', '/main', '--name', 'Original', '--text', 'unchanged') + assert status == 0 + rid, revision = created['resources'][0]['id'], created['resources'][0]['revision'] + async with app.metadata.transaction(write=False) as tx: + before = await tx.resource(rid) + count = tx.one('SELECT COUNT(*) FROM revisions WHERE resource_id=?', (rid,))[0] + original_path = await tx.path(rid) + assert before.name == 'Original.md' + status, changed = await invoke('rename', original_path, '新的名称') + assert status == 0 and changed['resources'][0]['id'] == rid + async with app.metadata.transaction(write=False) as tx: + after = await tx.resource(rid) + new_path = await tx.path(rid) + assert after.name == '新的名称.md' and after.revision == revision + assert after.generation == before.generation + 1 + assert tx.one('SELECT COUNT(*) FROM revisions WHERE resource_id=?', (rid,))[0] == count + reading = await client.call('discovery.get', {'id': new_path}) + assert reading.status == 'ok' and reading.data['content'] == 'unchanged' + denied = await other.rename(rid, 'stolen') + assert denied.status == 'error' + collision = await client.call( + 'content.post_create', {'parent': '/main', 'name': 'Taken', 'body': 'another'} + ) + assert collision.status == 'ok' + assert (await client.rename(rid, 'Taken')).status == 'error' + assert (await client.rename(rid, '../escape')).status == 'error' + assert (await client.rename('/missing.md', 'name')).status == 'error' + async with app.metadata.transaction(write=False) as tx: + assert await tx.resource(rid) == after + + file = await client.call( + 'content.file_put', + { + 'parent': '/@rename-owner/files', + 'name': 'original.txt', + 'data': b64(b'file content'), + }, + ) + assert file.status == 'ok' + file_id, file_revision = file.resources[0].id, file.resources[0].revision + status, renamed = await invoke('rename', file_id, 'renamed.txt') + assert status == 0 and renamed['resources'][0]['id'] == file_id + async with app.metadata.transaction(write=False) as tx: + resource = await tx.resource(file_id) + assert resource.name == 'renamed.txt' and resource.revision == file_revision + assert resource.parent == await tx.resolve('/@rename-owner/files') + + +@pytest.mark.asyncio +async def test_rename_does_not_overwrite_a_concurrent_metadata_change(installed, tmp_path): + app, _ = installed + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(app)), base_url=app.settings.service_url + ) as http: + client = MsgClient( + ClientState(tmp_path / 'race', server=app.settings.service_url), + HTTPTransport(app.settings.service_url, http=http), + clock=lambda: NOW, + ) + assert (await client.register('rename-race')).status == 'ok' + created = await client.call('content.post_create', {'parent': '/main', 'body': 'race'}) + rid = created.resources[0].id + original_call = client.call + + async def interleaved(operation, arguments, **kwargs): + result = await original_call(operation, arguments, **kwargs) + if operation == 'discovery.get': + edited = await original_call( + 'content.post_edit_metadata', + {'id': rid, 'name': 'concurrent'}, + expected=((rid, result.data['generation']),), + ) + assert edited.status == 'ok' + return result + + client.call = interleaved + rejected = await client.rename(rid, 'stale') + assert rejected.error.code == 'generation_conflict' + async with app.metadata.transaction(write=False) as tx: + assert (await tx.resource(rid)).name == 'concurrent.md'