Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
2 changes: 2 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
12 changes: 12 additions & 0 deletions src/msg/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand Down
17 changes: 17 additions & 0 deletions src/msg/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
132 changes: 132 additions & 0 deletions tests/test_cli_rename.py
Original file line number Diff line number Diff line change
@@ -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'
Loading