msgctl 0.1.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. msg/__init__.py +2 -0
  2. msg/admin/__init__.py +0 -0
  3. msg/admin/backup_retirement.py +56 -0
  4. msg/admin/backups.py +341 -0
  5. msg/admin/custodial_check.py +77 -0
  6. msg/admin/diagnostics.py +750 -0
  7. msg/admin/market.py +91 -0
  8. msg/admin/market_check.py +341 -0
  9. msg/admin/money.py +322 -0
  10. msg/admin/preflight.py +142 -0
  11. msg/admin/recovery_replay.py +281 -0
  12. msg/admin/restore_database.py +37 -0
  13. msg/admin/root.py +347 -0
  14. msg/admin/rotation.py +197 -0
  15. msg/admin/token_delivery_check.py +54 -0
  16. msg/admin/upgrade_check.py +58 -0
  17. msg/application.py +163 -0
  18. msg/bootstrap.py +305 -0
  19. msg/cli.py +537 -0
  20. msg/client.py +725 -0
  21. msg/client_certificates.py +98 -0
  22. msg/client_content.py +43 -0
  23. msg/client_custodial.py +206 -0
  24. msg/client_market.py +104 -0
  25. msg/client_recovery.py +186 -0
  26. msg/client_secrets.py +58 -0
  27. msg/client_tokens.py +99 -0
  28. msg/client_upgrade.py +188 -0
  29. msg/config.py +318 -0
  30. msg/constants.py +11 -0
  31. msg/core/__init__.py +0 -0
  32. msg/core/batching.py +23 -0
  33. msg/core/codec.py +253 -0
  34. msg/core/contracts.py +128 -0
  35. msg/core/cursors.py +68 -0
  36. msg/core/email_address.py +33 -0
  37. msg/core/errors.py +22 -0
  38. msg/core/events.py +6 -0
  39. msg/core/execution_ports.py +20 -0
  40. msg/core/executor.py +193 -0
  41. msg/core/models.py +517 -0
  42. msg/core/packet.py +71 -0
  43. msg/core/permissions.py +14 -0
  44. msg/core/query.py +44 -0
  45. msg/core/read_query.py +60 -0
  46. msg/core/registry.py +173 -0
  47. msg/core/requests.py +58 -0
  48. msg/core/schema_policy.py +28 -0
  49. msg/core/schemas.py +30 -0
  50. msg/core/tags.py +21 -0
  51. msg/core/template_dsl.py +94 -0
  52. msg/core/text_patch.py +283 -0
  53. msg/core/tool_execution.py +28 -0
  54. msg/core/transfer.py +183 -0
  55. msg/daemon.py +255 -0
  56. msg/data/__init__.py +0 -0
  57. msg/data/bootstrap.json +72 -0
  58. msg/data/favicon.png +0 -0
  59. msg/data/logo-dark.svg +5 -0
  60. msg/data/logo.svg +5 -0
  61. msg/data/recovery-checkpoint.example.json +1 -0
  62. msg/data/recovery-checkpoint.schema.json +118 -0
  63. msg/data/shortcodes.json +1 -0
  64. msg/data/system/AGENTS.md +6 -0
  65. msg/data/system/rules/_index.md +17 -0
  66. msg/data/system/rules/auth.md +6 -0
  67. msg/data/system/rules/files.md +6 -0
  68. msg/data/system/rules/identity.md +6 -0
  69. msg/data/system/rules/protocol.md +6 -0
  70. msg/data/system/rules/read-write.md +6 -0
  71. msg/data/system/rules/recovery.md +6 -0
  72. msg/data/system/rules/security.md +10 -0
  73. msg/data/system/rules/topics.md +6 -0
  74. msg/extensions/__init__.py +1 -0
  75. msg/extensions/hosting.py +259 -0
  76. msg/extensions/keystore.py +96 -0
  77. msg/extensions/repositories.py +769 -0
  78. msg/extensions/rss.py +67 -0
  79. msg/extensions/ssh.py +264 -0
  80. msg/extensions/ssh_git.py +300 -0
  81. msg/extensions/tools.py +150 -0
  82. msg/hosting_runtime.py +138 -0
  83. msg/market/__init__.py +1 -0
  84. msg/market/arbitration.py +481 -0
  85. msg/market/delivery.py +338 -0
  86. msg/market/delivery_notifications.py +110 -0
  87. msg/market/delivery_targets.py +76 -0
  88. msg/market/email.py +100 -0
  89. msg/market/escrow.py +345 -0
  90. msg/market/orders.py +218 -0
  91. msg/market/policy.py +147 -0
  92. msg/market/rationale.py +91 -0
  93. msg/market/references.py +19 -0
  94. msg/market/targets.py +157 -0
  95. msg/plugins/__init__.py +28 -0
  96. msg/plugins/achievements.py +316 -0
  97. msg/plugins/batch.py +32 -0
  98. msg/plugins/bounty.py +366 -0
  99. msg/plugins/collaboration.py +261 -0
  100. msg/plugins/common.py +217 -0
  101. msg/plugins/communication.py +853 -0
  102. msg/plugins/content.py +855 -0
  103. msg/plugins/custodial_lifecycle.py +224 -0
  104. msg/plugins/delivery.py +230 -0
  105. msg/plugins/discovery.py +1266 -0
  106. msg/plugins/discussion.py +162 -0
  107. msg/plugins/extensions.py +10 -0
  108. msg/plugins/following.py +78 -0
  109. msg/plugins/hosting_capacity.py +52 -0
  110. msg/plugins/identity.py +1654 -0
  111. msg/plugins/money.py +221 -0
  112. msg/plugins/offers.py +183 -0
  113. msg/plugins/orders.py +273 -0
  114. msg/plugins/recovery.py +376 -0
  115. msg/plugins/schemas.py +5 -0
  116. msg/plugins/sharing.py +300 -0
  117. msg/plugins/store.py +249 -0
  118. msg/plugins/system.py +75 -0
  119. msg/plugins/transfer.py +249 -0
  120. msg/py.typed +0 -0
  121. msg/security/__init__.py +0 -0
  122. msg/security/age_keys.py +112 -0
  123. msg/security/authentication.py +141 -0
  124. msg/security/authorization.py +300 -0
  125. msg/security/backup_retirement.py +135 -0
  126. msg/security/capabilities.py +157 -0
  127. msg/security/certificates.py +183 -0
  128. msg/security/crypto.py +102 -0
  129. msg/security/custodial_migration.py +292 -0
  130. msg/security/custody_history.py +146 -0
  131. msg/security/network.py +81 -0
  132. msg/security/policy.py +67 -0
  133. msg/security/quarantine.py +19 -0
  134. msg/security/root_files.py +67 -0
  135. msg/security/rotation_journal.py +60 -0
  136. msg/security/sealed_box.py +70 -0
  137. msg/security/sharing_policy.py +29 -0
  138. msg/security/token_delivery.py +93 -0
  139. msg/security/vault.py +186 -0
  140. msg/storage/__init__.py +0 -0
  141. msg/storage/capacity.py +100 -0
  142. msg/storage/custodial_migration.py +22 -0
  143. msg/storage/git.py +435 -0
  144. msg/storage/ledger_migration.py +307 -0
  145. msg/storage/market_migration.py +49 -0
  146. msg/storage/postgres.py +663 -0
  147. msg/storage/query.py +39 -0
  148. msg/storage/read_only.py +126 -0
  149. msg/storage/session.py +403 -0
  150. msg/storage/sqlite.py +360 -0
  151. msg/storage/topic_event_migration.py +26 -0
  152. msg/storage/valkey_bus.py +45 -0
  153. msg/transports/__init__.py +0 -0
  154. msg/transports/client.py +183 -0
  155. msg/transports/dictionary.py +387 -0
  156. msg/transports/graphql.py +75 -0
  157. msg/transports/http.py +72 -0
  158. msg/transports/http_routes.py +1577 -0
  159. msg/transports/mcp.py +127 -0
  160. msg/transports/packet.py +42 -0
  161. msg/transports/read_tree_path.py +65 -0
  162. msg/transports/stdio.py +36 -0
  163. msg/transports/url_safety.py +123 -0
  164. msg/tui.py +368 -0
  165. msg/workers/__init__.py +1 -0
  166. msg/workers/effects.py +406 -0
  167. msg/workers/leases.py +19 -0
  168. msg/workers/mail.py +63 -0
  169. msg/workers/maintenance.py +310 -0
  170. msg/workers/sandbox.py +74 -0
  171. msg/workers/sandbox_child.py +189 -0
  172. msg/workers/webhook.py +160 -0
  173. msgctl-0.1.0a1.dist-info/METADATA +96 -0
  174. msgctl-0.1.0a1.dist-info/RECORD +176 -0
  175. msgctl-0.1.0a1.dist-info/WHEEL +4 -0
  176. msgctl-0.1.0a1.dist-info/entry_points.txt +4 -0
msg/tui.py ADDED
@@ -0,0 +1,368 @@
1
+ """Small, line-oriented terminal navigator over the public read contracts.
2
+
3
+ The UI deliberately has no mutation command. It never interprets links or
4
+ resource text as terminal control sequences or as operations to execute.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ import shutil
10
+ import sys
11
+ import unicodedata
12
+ from collections.abc import Mapping
13
+ from urllib.parse import urlsplit
14
+
15
+ import httpx
16
+
17
+ from msg.core.errors import Failure
18
+
19
+
20
+ READ_OPERATIONS = frozenset({
21
+ 'discovery.get', 'discovery.read_query', 'discovery.lexical_search',
22
+ 'communication.inbox', 'communication.outbox', 'communication.following', 'discussion.thread',
23
+ 'identity.note_list', 'identity.note_get', 'identity.todo_list', 'identity.todo_get',
24
+ })
25
+ _CONTROL = re.compile(r'[\x00-\x1f\x7f-\x9f]')
26
+
27
+
28
+ def safe_text(value):
29
+ """Treat all server text as data, including ANSI/OSC and bidi controls."""
30
+ if value is None:
31
+ return ''
32
+ if not isinstance(value, str):
33
+ value = str(value)
34
+ return ''.join(' ' if _CONTROL.fullmatch(char) or char in '\u202a\u202b\u202c\u202d\u202e\u2066\u2067\u2068\u2069'
35
+ else char for char in value)
36
+
37
+
38
+ def terminal_lines(value, width):
39
+ """Preserve paragraphs and conservatively wrap wide/combining characters.
40
+
41
+ Emoji join sequences can occupy fewer cells than this conservative count;
42
+ we prefer an early wrap over writing beyond a narrow terminal's boundary.
43
+ """
44
+ for paragraph in ('' if value is None else str(value)).split('\n'):
45
+ line = ''
46
+ used = 0
47
+ for token in re.findall(r'[!-~]+|[^!-~]', safe_text(paragraph)):
48
+ # Keep a URL/error code/ASCII word intact when it fits on one line.
49
+ if token.isascii() and 1 < len(token) <= width and used + len(token) > width:
50
+ yield line.rstrip()
51
+ line, used = '', 0
52
+ for char in token:
53
+ cells = (0 if unicodedata.combining(char) or unicodedata.category(char) == 'Cf'
54
+ else 2 if unicodedata.east_asian_width(char) in {'W', 'F'} else 1)
55
+ if cells > width:
56
+ char, cells = '?', 1
57
+ if cells and used + cells > width:
58
+ yield line.rstrip()
59
+ line, used = '', 0
60
+ line += char
61
+ used += cells
62
+ yield line.rstrip()
63
+
64
+
65
+ HELP = ('命令:h Home,id 身份,topics 话题,following 关注,i Inbox,o Outbox,notes 笔记,'
66
+ 'todos 待办,files 文件,groups 组织,credentials 本地凭据,'
67
+ 's <scope> <terms> 搜索,t <id> 线程,r <id> 读取,'
68
+ 'n 下一页,retry 重新读取,q 退出。')
69
+
70
+
71
+ def service_origin(value):
72
+ """The connection URL may be misconfigured; never display its credentials."""
73
+ try:
74
+ parsed = urlsplit(value)
75
+ if parsed.scheme not in {'http', 'https'} or not parsed.hostname:
76
+ return '(未配置)'
77
+ host = '[' + parsed.hostname + ']' if ':' in parsed.hostname else parsed.hostname
78
+ port = ':' + str(parsed.port) if parsed.port is not None else ''
79
+ return parsed.scheme + '://' + host + port
80
+ except (TypeError, ValueError):
81
+ return '(无效地址)'
82
+
83
+
84
+ class TerminalUI:
85
+ def __init__(self, client, *, stdin=None, stdout=None, width=None):
86
+ self.client = client
87
+ self.stdin = stdin or sys.stdin
88
+ self.stdout = stdout or sys.stdout
89
+ self.width = width
90
+ self.page = None
91
+ self._last_read = None
92
+ self._read_context = None
93
+
94
+ def _context(self):
95
+ state = self.client.state
96
+ # Public identifiers only; do not inspect the token, signer or journals.
97
+ return (getattr(state, 'subject', None), getattr(state, 'server', None),
98
+ tuple(getattr(state, 'certificates', ())))
99
+
100
+ def _remember_read(self, function, *arguments):
101
+ self.page = None
102
+ self._last_read = (function, arguments)
103
+ self._read_context = self._context()
104
+
105
+ def _discard_stale_read(self):
106
+ if self._read_context is not None and self._read_context != self._context():
107
+ self.page = self._last_read = self._read_context = None
108
+ self._write('身份或服务已变化;请重新选择视图。')
109
+ return True
110
+ return False
111
+
112
+ def _write(self, value=''):
113
+ columns = self.width if self.width is not None else shutil.get_terminal_size((80, 24)).columns
114
+ width = max(1, min(columns, 160))
115
+ for line in terminal_lines(value, width):
116
+ self.stdout.write(line + '\n')
117
+ self.stdout.flush()
118
+
119
+ async def _read(self, operation, arguments):
120
+ if operation not in READ_OPERATIONS:
121
+ raise ValueError('TUI only supports read operations')
122
+ try:
123
+ result = await self.client.call(operation, arguments)
124
+ if self._discard_stale_read():
125
+ return None
126
+ except (Failure, OSError, TimeoutError, httpx.HTTPError) as exc:
127
+ self.page = None
128
+ code = exc.code if isinstance(exc, Failure) else 'transport_unavailable'
129
+ self._read_error(code)
130
+ return None
131
+ if result.status != 'ok':
132
+ self.page = None
133
+ self._read_error(getattr(getattr(result, 'error', None), 'code', None))
134
+ return None
135
+ if not isinstance(result.data, Mapping):
136
+ return self._invalid_response()
137
+ return result.data
138
+
139
+ def _invalid_response(self):
140
+ # A server/proxy that breaks the read contract must not crash the loop
141
+ # or leave the previous page selectable.
142
+ self.page = None
143
+ self._read_error('invalid_read_response')
144
+ return None
145
+
146
+ def _read_error(self, code):
147
+ # Exception text/URL/details may contain credentials. Only contract codes
148
+ # reach the terminal; a retry always re-enters the signed read contract.
149
+ if not isinstance(code, str) or not re.fullmatch(r'[a-z][a-z0-9_]{0,95}', code):
150
+ code = 'read_failed'
151
+ self._write('读取失败:' + code)
152
+ self._write('retry 重新读取;不自动提交 ACK 或其他写入。')
153
+
154
+ @staticmethod
155
+ def _item_id(item):
156
+ ref = item.get('ref') or item.get('resource')
157
+ for rid in (item.get('id'), ref.get('id') if isinstance(ref, Mapping) else None,
158
+ item.get('conversation_id')):
159
+ if isinstance(rid, str) and rid:
160
+ return rid
161
+ return None
162
+
163
+ def _show_items(self, items):
164
+ for index, item in enumerate(items, 1):
165
+ rid = self._item_id(item) or ''
166
+ name = item.get('title') or item.get('name') or item.get('path') or item.get('source') or rid
167
+ self._write(f'{index}. {name} {rid}')
168
+ snippet = item.get('snippet')
169
+ if isinstance(snippet, dict) and snippet.get('text'):
170
+ self._write(' ' + snippet['text'])
171
+
172
+ async def _show_page(self, operation, arguments, title):
173
+ # A cursor may become invalid after a grant changes. Never keep the
174
+ # previous page selectable after a failed permission recheck.
175
+ self._remember_read(self._show_page, operation, dict(arguments), title)
176
+ data = await self._read(operation, arguments)
177
+ if data is None:
178
+ return
179
+ items = data.get('items', [])
180
+ cursor = data.get('next_after_name') if operation == 'identity.todo_list' else data.get('cursor')
181
+ if (not isinstance(items, (list, tuple)) or not all(isinstance(item, Mapping) for item in items)
182
+ or not (cursor is None or isinstance(cursor, str))):
183
+ self._invalid_response()
184
+ return
185
+ self.page = {'operation': operation, 'arguments': arguments, 'cursor': cursor,
186
+ 'title': title, 'items': items}
187
+ self._write(title)
188
+ if items:
189
+ self._show_items(items)
190
+ else:
191
+ self._write('(空)')
192
+ if self.page['cursor']:
193
+ self._write('n 下一页')
194
+
195
+ async def home(self):
196
+ subject = getattr(self.client.state, 'subject', None)
197
+ self._write('msg ' + (subject or '未登录(公开内容)'))
198
+ await self._show_page('discovery.read_query', {'parent': '/', 'limit': 20}, 'Home')
199
+
200
+ def identity(self):
201
+ state = self.client.state
202
+ subject = getattr(state, 'subject', None)
203
+ self._write('身份:' + (subject or '未登录;可以浏览公开内容'))
204
+ self._write('服务:' + service_origin(getattr(state, 'server', '')))
205
+ if subject:
206
+ self._write('身份管理请使用 msg identity;TUI 不接收密钥或令牌。')
207
+
208
+ async def inbox(self):
209
+ await self.private_page('communication.inbox', {'limit': 20}, 'Inbox')
210
+
211
+ async def private_page(self, operation, arguments, title):
212
+ if not getattr(self.client.state, 'subject', None):
213
+ self.page = self._last_read = self._read_context = None
214
+ self._write(title + ' 需要已登录身份。')
215
+ return
216
+ await self._show_page(operation, arguments, title)
217
+
218
+ async def files(self):
219
+ subject = getattr(self.client.state, 'subject', None)
220
+ self.page = None
221
+ if not subject:
222
+ self._last_read = self._read_context = None
223
+ self._write('Files 需要已登录身份。')
224
+ return
225
+ self._remember_read(self.files)
226
+ profile = await self._read('discovery.get', {'id': subject, 'fields': ['path']})
227
+ if profile is not None and not (isinstance(profile.get('path'), str)
228
+ and profile['path'].startswith('/')):
229
+ self._invalid_response()
230
+ elif profile is not None:
231
+ await self._show_page('discovery.read_query',
232
+ {'parent': profile['path'] + '/files', 'limit': 20}, 'Files')
233
+
234
+ def credentials(self):
235
+ self.identity()
236
+ # Never inspect or stringify ClientState.data, signer, token or journals.
237
+ self._write('本地证书 ID(在线有效性由每次请求重新检查):')
238
+ for certificate in getattr(self.client.state, 'certificates', ()):
239
+ self._write(certificate)
240
+
241
+ async def search(self, scope, terms):
242
+ if not scope or not terms:
243
+ self._write('用法:s <scope> <terms>')
244
+ return
245
+ await self._show_page('discovery.lexical_search',
246
+ {'scope': scope, 'terms': terms, 'limit': 20,
247
+ 'snippet': True}, '搜索')
248
+
249
+ async def thread(self, rid):
250
+ if not rid:
251
+ self._write('用法:t <resource-id>')
252
+ return
253
+ await self._show_page('discussion.thread', {'id': rid, 'limit': 20}, '线程 ' + rid)
254
+
255
+ async def read(self, rid):
256
+ if not rid:
257
+ self._write('用法:r <resource-id>')
258
+ return
259
+ await self._document('discovery.get', {'id': rid}, rid)
260
+
261
+ async def _document(self, operation, arguments, title):
262
+ self._remember_read(self._document, operation, dict(arguments), title)
263
+ data = await self._read(operation, arguments)
264
+ if data is None:
265
+ return
266
+ self._write(data.get('path') or data.get('name') or title)
267
+ content = data.get('content')
268
+ if isinstance(content, str):
269
+ self._write(content)
270
+ elif operation == 'identity.todo_get':
271
+ self._write(data.get('title', ''))
272
+ self._write(data.get('description', ''))
273
+ self._write('状态:' + str(data.get('status', '')))
274
+ else:
275
+ self._write('(无文本正文)')
276
+ self._write('读取不会发送 ACK。')
277
+
278
+ async def next_page(self):
279
+ if self._discard_stale_read():
280
+ return
281
+ if not self.page or not self.page['cursor']:
282
+ self._write('没有下一页。')
283
+ return
284
+ current = self.page
285
+ arguments = ({'cursor': current['cursor']} if current['operation'] in
286
+ {'discovery.read_query', 'discovery.lexical_search', 'communication.following'} else
287
+ {**current['arguments'],
288
+ 'after_name' if current['operation'] == 'identity.todo_list' else 'cursor': current['cursor']})
289
+ await self._show_page(current['operation'], arguments, current['title'])
290
+
291
+ async def command(self, raw):
292
+ self._discard_stale_read()
293
+ command = raw.strip()
294
+ if command in {'q', 'quit', 'exit'}:
295
+ return False
296
+ if command in {'h', 'home', ''}:
297
+ await self.home()
298
+ elif command in {'id', 'identity'}:
299
+ self.identity()
300
+ elif command in {'credentials', 'creds'}:
301
+ self.credentials()
302
+ elif command == 'topics':
303
+ await self._show_page('discovery.read_query', {'type': 'topic', 'limit': 20}, 'Topics')
304
+ elif command == 'groups':
305
+ await self._show_page('discovery.read_query', {'type': 'organization', 'limit': 20}, 'Groups')
306
+ elif command in {'o', 'outbox'}:
307
+ await self.private_page('communication.outbox', {'limit': 20}, 'Outbox')
308
+ elif command == 'following':
309
+ await self.private_page('communication.following', {'limit': 20}, 'Following')
310
+ elif command == 'notes':
311
+ await self.private_page('identity.note_list', {}, 'Notes')
312
+ elif command == 'todos':
313
+ await self.private_page('identity.todo_list', {'limit': 20}, 'Todos')
314
+ elif command == 'files':
315
+ await self.files()
316
+ elif command == 'retry':
317
+ if self._last_read is None:
318
+ self._write('没有可重新读取的请求。')
319
+ else:
320
+ function, arguments = self._last_read
321
+ await function(*arguments)
322
+ elif command in {'i', 'inbox'}:
323
+ await self.inbox()
324
+ elif command in {'n', 'next'}:
325
+ await self.next_page()
326
+ elif command.startswith('s '):
327
+ parts = command.split(maxsplit=2)
328
+ await self.search(parts[1] if len(parts) > 1 else '',
329
+ parts[2] if len(parts) > 2 else '')
330
+ elif command.startswith('t '):
331
+ await self.thread(command[2:].strip())
332
+ elif command.startswith('r '):
333
+ await self.read(command[2:].strip())
334
+ elif command.isdecimal() and self.page:
335
+ index = int(command) - 1 if len(command) <= 6 else -1
336
+ if 0 <= index < len(self.page['items']):
337
+ item = self.page['items'][index]
338
+ if self.page['operation'] in {'identity.note_list', 'identity.todo_list'}:
339
+ name = item.get('name')
340
+ if isinstance(name, str) and name:
341
+ operation = self.page['operation'].replace('_list', '_get')
342
+ await self._document(operation, {'name': name}, name)
343
+ else:
344
+ self._write('该条目没有可读取的标识。')
345
+ elif rid := self._item_id(item):
346
+ await self.read(rid)
347
+ else:
348
+ self._write('该条目没有可读取的标识。')
349
+ else:
350
+ self._write('序号不在本页。')
351
+ else:
352
+ self._write(HELP)
353
+ return True
354
+
355
+ async def run(self):
356
+ self._write(HELP)
357
+ await self.home()
358
+ while True:
359
+ self.stdout.write('msg> ')
360
+ self.stdout.flush()
361
+ line = self.stdin.readline()
362
+ if not line or not await self.command(line):
363
+ break
364
+
365
+
366
+ async def run_tui(client, *, stdin=None, stdout=None, width=None):
367
+ """Run the read-only UI; the owner of ``client`` closes its transport."""
368
+ await TerminalUI(client, stdin=stdin, stdout=stdout, width=width).run()
@@ -0,0 +1 @@
1
+ """Trusted external effect workers."""