@substrio/cli 0.0.0-stage → 0.1.0

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 (56) hide show
  1. package/LICENSE +177 -0
  2. package/README.md +66 -2
  3. package/bin/substrio.mjs +91 -0
  4. package/package.json +44 -4
  5. package/src/api.mjs +107 -0
  6. package/src/apply.mjs +204 -0
  7. package/src/blobs.mjs +102 -0
  8. package/src/cli.mjs +405 -0
  9. package/src/collections.mjs +701 -0
  10. package/src/command-table.mjs +436 -0
  11. package/src/commands/bundle.mjs +369 -0
  12. package/src/commands/count.mjs +39 -0
  13. package/src/commands/deploy.mjs +488 -0
  14. package/src/commands/dev.mjs +246 -0
  15. package/src/commands/env.mjs +115 -0
  16. package/src/commands/files.mjs +172 -0
  17. package/src/commands/git.mjs +195 -0
  18. package/src/commands/groups-runtime.mjs +227 -0
  19. package/src/commands/groups.mjs +417 -0
  20. package/src/commands/history.mjs +374 -0
  21. package/src/commands/import.mjs +506 -0
  22. package/src/commands/init.mjs +175 -0
  23. package/src/commands/key.mjs +172 -0
  24. package/src/commands/login.mjs +167 -0
  25. package/src/commands/logs.mjs +285 -0
  26. package/src/commands/mcp-config.mjs +102 -0
  27. package/src/commands/op.mjs +265 -0
  28. package/src/commands/open.mjs +35 -0
  29. package/src/commands/push.mjs +199 -0
  30. package/src/commands/signup.mjs +164 -0
  31. package/src/commands/site.mjs +129 -0
  32. package/src/commands/skills.mjs +177 -0
  33. package/src/commands/status.mjs +78 -0
  34. package/src/commands/tenants.mjs +172 -0
  35. package/src/commands/test.mjs +167 -0
  36. package/src/commands/tools.mjs +63 -0
  37. package/src/commands/versions.mjs +293 -0
  38. package/src/config.mjs +569 -0
  39. package/src/documents.mjs +139 -0
  40. package/src/function-build.mjs +273 -0
  41. package/src/identity.mjs +248 -0
  42. package/src/jsonfile.mjs +22 -0
  43. package/src/live-move.mjs +267 -0
  44. package/src/local/args.mjs +58 -0
  45. package/src/local/reload.mjs +408 -0
  46. package/src/local/server.mjs +291 -0
  47. package/src/local/watch.mjs +175 -0
  48. package/src/manifest.mjs +591 -0
  49. package/src/ops.mjs +265 -0
  50. package/src/readers.mjs +416 -0
  51. package/src/sdk.mjs +26 -0
  52. package/src/settings.mjs +117 -0
  53. package/src/testing/app.mjs +507 -0
  54. package/src/testing/env.mjs +35 -0
  55. package/src/testing/index.mjs +11 -0
  56. package/src/testing/types.mjs +55 -0
@@ -0,0 +1,417 @@
1
+ /**
2
+ * Substrio CLI: command groups, each subcommand a registry operation run through
3
+ * `substrio op` (runOperation). This table is the CLI's only map from words to
4
+ * operations: routes, methods and input schemas come from the server's
5
+ * catalog (GET /v1/_openapi), and help and usage are generated from here.
6
+ * Bare words fill the route's path parameters in order; key=value, --data,
7
+ * --file and --raw work as in `substrio op`.
8
+ *
9
+ * PURE: data only, nothing that touches the network, crypto or the file
10
+ * system. `command-table.mjs` imports this module unconditionally (it builds
11
+ * `substrio --help`/`substrio --version` before any command runs), so it stays this
12
+ * light; a group's actual run (a server call, a file write) is
13
+ * `./groups-runtime.mjs`, loaded only when a group command runs.
14
+ *
15
+ * An entry is an operation name, or { operation, trailing, method, defaults }:
16
+ * trailing the last bare words become these input fields (after the
17
+ * route's path parameters); a value of "-" is read from stdin
18
+ * method which of the operation's routes (secret.write: PUT or POST)
19
+ * defaults input fields used when the caller gives none
20
+ * text trailing values stay text (else JSON when they parse, as key=value)
21
+ * render prints a successful result as text (JSON under --json or --raw)
22
+ * body the input field that is the operation's whole body (data insert:
23
+ * "document"): --data, --file and key=value make that value, so
24
+ * --data is the document itself, with no wrapper
25
+ * flow a command of its own for the words (GroupFlow), not one call
26
+ * note what help says of the words in place of the operation's name
27
+ * valueFlags the flags of a flow that take a value, so their value is not a bare word
28
+ * destructive computed (entryOf), not given: the words destroy data or
29
+ * cannot be undone, from the operation's own trailing word
30
+ * (or the sub-command's, for one whose operation name differs,
31
+ * such as domains remove naming tenant.domain.detach)
32
+ *
33
+ * Every registry group (the first word of an operation's name) belongs to
34
+ * one command (REGISTRY_GROUPS): `substrio COMMAND WORD ...` runs GROUP.WORD...
35
+ * (the longest such name the server has; hyphens read as underscores) when
36
+ * WORD is not one of the command's own, so no operation is left without a
37
+ * command. Groups with no words of their own have an empty table here.
38
+ *
39
+ * No key or token is ever printed, except the invite link `invite` returns:
40
+ * `accounts keys create` writes the new key to --out FILE (owner-only).
41
+ */
42
+
43
+ const C = {
44
+ reset: '\x1b[0m', bold: '\x1b[1m', red: '\x1b[91m', dim: '\x1b[2m', green: '\x1b[92m', cyan: '\x1b[96m',
45
+ };
46
+
47
+ // One sandbox: its name, slug, the version it runs and its addresses.
48
+ function showSandbox(s) {
49
+ console.log(` ${C.bold}${s.name}${C.reset} ${C.dim}(${s.slug})${C.reset} ` +
50
+ `${s.live ? `runs version ${s.live}` : `${C.dim}runs no version yet${C.reset}`}`);
51
+ for (const host of s.hosts || []) console.log(` ${C.cyan}${host}${C.reset}`);
52
+ }
53
+
54
+ // The declared permission catalog (permissions.get's shape): one row per
55
+ // name, its bit and the names its closure implies (itself excluded); the all
56
+ // name, if one closes to every bit, named last. docs/permissions.md.
57
+ function showPermissionCatalog({ names = [], all = null }) {
58
+ if (!names.length) {
59
+ console.log(`${C.dim}No permissions declared. substrio deploy sets them from substrio.toml's [permissions] table.${C.reset}`);
60
+ return;
61
+ }
62
+ console.log(`\n${C.bold}Permissions (${names.length})${C.reset}\n`);
63
+ for (const n of names) {
64
+ console.log(` ${C.bold}${n.name}${C.reset} ${C.dim}bit ${n.bit}${n.implies.length ? `, implies ${n.implies.join(', ')}` : ''}${C.reset}`);
65
+ }
66
+ console.log(all ? `\n${C.dim}all: ${all}${C.reset}\n` : '');
67
+ }
68
+
69
+ // One user's permissions (user.permissions.get/set's shape): granted (the
70
+ // stored raw names) and effective (granted, closed over implies).
71
+ function showUserPermissions({ granted = [], effective = [] }) {
72
+ console.log(`${C.bold}Granted:${C.reset} ${granted.length ? granted.join(', ') : `${C.dim}none${C.reset}`}`);
73
+ console.log(`${C.bold}Effective:${C.reset} ${effective.length ? effective.join(', ') : `${C.dim}none${C.reset}`}`);
74
+ }
75
+
76
+ // One user's memberships (user.memberships.get's shape): the one
77
+ // organization's names (collection and id given, {names}), or every
78
+ // organization held (user_id alone, {memberships: [{organization, names}]}).
79
+ function showUserMemberships(data) {
80
+ if (Array.isArray(data.memberships)) {
81
+ if (!data.memberships.length) {
82
+ console.log(`${C.dim}No memberships held.${C.reset}`);
83
+ return;
84
+ }
85
+ for (const { organization, names = [] } of data.memberships) {
86
+ console.log(`${C.bold}${organization}${C.reset}: ${names.length ? names.join(', ') : `${C.dim}none${C.reset}`}`);
87
+ }
88
+ return;
89
+ }
90
+ const names = data.names || [];
91
+ console.log(`${C.bold}Names:${C.reset} ${names.length ? names.join(', ') : `${C.dim}none${C.reset}`}`);
92
+ }
93
+
94
+ const SANDBOX = { defaults: { name: 'dev' } };
95
+ // create and reset answer {sandbox}: shown as text.
96
+ const SANDBOX_SHOWN = { ...SANDBOX, render: (data) => showSandbox(data.sandbox) };
97
+
98
+ function showSandboxes({ sandboxes = [] }) {
99
+ if (!sandboxes.length) {
100
+ console.log(`${C.dim}No sandboxes. substrio push makes dev; substrio sandbox create NAME makes another.${C.reset}`);
101
+ return;
102
+ }
103
+ console.log(`\n${C.bold}Sandboxes (${sandboxes.length})${C.reset}\n`);
104
+ for (const s of sandboxes) showSandbox(s);
105
+ console.log('');
106
+ }
107
+
108
+ /** The group words that run a flow of their own, not one operation call. */
109
+ export const GroupFlow = Object.freeze({
110
+ IMPORT: 'import', // data import: a file of documents, in batches (commands/import.mjs)
111
+ COUNT: 'count', // data count: query.count, or data.query with count when a where filter is given (commands/count.mjs)
112
+ MCP_CONFIG: 'mcp-config', // mcp config: the MCP client config of the profile's app (commands/mcp-config.mjs)
113
+ TOOL_INSTALL: 'tool-install', // tools install: hash the file once, stream it once as the PUT body (commands/tools.mjs)
114
+ SECRET_SET: 'secret-set', // secrets set: the value from stdin, --from-file or --from-env, never a bare word (groups-runtime.mjs)
115
+ USER_PERMISSIONS_GRANT: 'user-permissions-grant', // permissions grant USER_ID NAME ...: a variable number of trailing names, not one fixed field (groups-runtime.mjs)
116
+ USER_PERMISSIONS_REVOKE: 'user-permissions-revoke', // permissions revoke USER_ID NAME ...: likewise (groups-runtime.mjs)
117
+ USER_MEMBERSHIPS_GRANT: 'user-memberships-grant', // memberships grant USER_ID NAME ... --in COLLECTION:ID: likewise, plus --in (groups-runtime.mjs)
118
+ USER_MEMBERSHIPS_REVOKE: 'user-memberships-revoke', // memberships revoke USER_ID NAME ... --in COLLECTION:ID: likewise (groups-runtime.mjs)
119
+ USER_MEMBERSHIPS_SHOW: 'user-memberships-show', // memberships show USER_ID [--in COLLECTION:ID]: user.memberships.get is a GET, its fields travel in the query string (groups-runtime.mjs)
120
+ });
121
+
122
+ // command -> subcommand -> operation. The first subcommand is the default;
123
+ // aliases share an operation. A two-word subcommand ("keys create") wins
124
+ // over its first word alone.
125
+ export const GROUPS = {
126
+ data: {
127
+ list: 'data.list', get: 'data.get',
128
+ insert: {
129
+ operation: 'data.insert', body: 'document',
130
+ note: `data.insert: --data '{...}' (or --file F, or key=value) is the document itself, no wrapper`,
131
+ },
132
+ update: 'data.update',
133
+ delete: 'data.delete', rm: 'data.delete', query: 'data.query',
134
+ count: {
135
+ operation: 'query.count', flow: GroupFlow.COUNT,
136
+ note: 'query.count: where.FIELD=VALUE (or --data \'{"where":{...}}\') filters it as in data query',
137
+ },
138
+ aggregate: 'query.aggregate',
139
+ import: {
140
+ operation: 'data.import', flow: GroupFlow.IMPORT,
141
+ valueFlags: ['--format', '--types', '--empty-as', '--batch-bytes', '--batch-docs', '--concurrency', '--checkpoint', '--on-conflict'],
142
+ note: 'data.import: --file F (NDJSON .ndjson/.jsonl, a JSON array, {"documents":[...]} or --format csv; - is stdin), streamed and sent in batches (--batch-bytes, --batch-docs, --concurrency); --checkpoint and --resume survive a crash; each rejected document is reported',
143
+ },
144
+ export: 'data.export',
145
+ },
146
+ collections: {
147
+ list: 'collection.list', create: { operation: 'collection.create', trailing: ['name'] },
148
+ delete: 'collection.delete', rm: 'collection.delete', stats: 'collection.stats', schema: 'schema.get',
149
+ },
150
+ functions: {
151
+ list: 'function.list', get: 'function.get', invoke: 'function.invoke', delete: 'function.delete',
152
+ rm: 'function.delete', toggle: 'function.toggle', source: 'function.source.read', logs: 'function.logs',
153
+ },
154
+ secrets: {
155
+ list: 'secret.list',
156
+ set: {
157
+ flow: GroupFlow.SECRET_SET, valueFlags: ['--from-file', '--from-env'],
158
+ note: 'secrets set KEY: the value from stdin (or -), --from-file PATH, or --from-env NAME; never a plain argument',
159
+ },
160
+ delete: { operation: 'secret.delete', trailing: ['key'] },
161
+ rm: { operation: 'secret.delete', trailing: ['key'] },
162
+ },
163
+ cron: {
164
+ list: 'schedule.list', create: 'schedule.create', update: 'schedule.update',
165
+ enable: { operation: 'schedule.update', defaults: { enabled: true } },
166
+ disable: { operation: 'schedule.update', defaults: { enabled: false } },
167
+ delete: 'schedule.delete', rm: 'schedule.delete',
168
+ },
169
+ triggers: {
170
+ list: 'trigger.list', create: 'trigger.create', update: 'trigger.update',
171
+ enable: { operation: 'trigger.update', defaults: { enabled: true } },
172
+ disable: { operation: 'trigger.update', defaults: { enabled: false } },
173
+ delete: 'trigger.delete', rm: 'trigger.delete',
174
+ },
175
+ domains: {
176
+ list: 'tenant.domain.list', add: { operation: 'tenant.domain.attach', trailing: ['domain'] },
177
+ remove: 'tenant.domain.detach', rm: 'tenant.domain.detach',
178
+ },
179
+ rules: { get: 'security.rules.get', set: 'security.rules.set', delete: 'security.rules.delete' },
180
+ permissions: {
181
+ get: { operation: 'permissions.get', render: showPermissionCatalog },
182
+ set: 'permissions.set',
183
+ grant: {
184
+ flow: GroupFlow.USER_PERMISSIONS_GRANT,
185
+ note: "permissions grant USER_ID NAME ...: grants that user those declared permissions (user.permissions.set); effective at their next sign in",
186
+ },
187
+ revoke: {
188
+ flow: GroupFlow.USER_PERMISSIONS_REVOKE,
189
+ note: "permissions revoke USER_ID NAME ...: revokes those declared permissions (user.permissions.set); ends the user's sessions now if any was actually held",
190
+ },
191
+ show: {
192
+ operation: 'user.permissions.get', render: showUserPermissions,
193
+ note: "permissions show USER_ID: that user's granted and effective declared permission names",
194
+ },
195
+ },
196
+ memberships: {
197
+ grant: {
198
+ flow: GroupFlow.USER_MEMBERSHIPS_GRANT, valueFlags: ['--in'],
199
+ note: 'memberships grant USER_ID NAME ... --in COLLECTION:ID: grants that user those declared permissions in the named organization (user.memberships.set); the gate reads it from the next request',
200
+ },
201
+ revoke: {
202
+ flow: GroupFlow.USER_MEMBERSHIPS_REVOKE, valueFlags: ['--in'],
203
+ note: 'memberships revoke USER_ID NAME ... --in COLLECTION:ID: revokes those declared permissions in the named organization (user.memberships.set); holds from the next request, no session is ended',
204
+ },
205
+ show: {
206
+ flow: GroupFlow.USER_MEMBERSHIPS_SHOW, valueFlags: ['--in'], render: showUserMemberships,
207
+ note: 'memberships show USER_ID [--in COLLECTION:ID]: that organization\'s held names, or every organization held when --in is omitted',
208
+ },
209
+ },
210
+ sandbox: {
211
+ list: { operation: 'sandbox.list', render: showSandboxes },
212
+ create: { operation: 'sandbox.create', trailing: ['name'], ...SANDBOX_SHOWN },
213
+ reset: { operation: 'sandbox.reset', ...SANDBOX_SHOWN },
214
+ delete: { operation: 'sandbox.delete', ...SANDBOX },
215
+ rm: { operation: 'sandbox.delete', ...SANDBOX },
216
+ outbox: { operation: 'sandbox.outbox', ...SANDBOX },
217
+ allow: { operation: 'sandbox.allow.list', ...SANDBOX },
218
+ 'allow add': { operation: 'sandbox.allow.add', ...SANDBOX },
219
+ 'allow remove': { operation: 'sandbox.allow.remove', ...SANDBOX },
220
+ },
221
+ webhooks: {
222
+ list: 'webhook.list', get: 'webhook.get', show: 'webhook.get', create: 'webhook.create',
223
+ update: 'webhook.update', delete: 'webhook.delete', rm: 'webhook.delete', logs: 'webhook.logs',
224
+ },
225
+ views: {
226
+ list: 'view.list', data: 'view.get', get: 'view.get', create: 'view.create',
227
+ update: 'view.update', delete: 'view.delete', rm: 'view.delete', refresh: 'view.refresh',
228
+ },
229
+ backups: {
230
+ list: 'backup.list', get: 'backup.get', show: 'backup.get', create: 'backup.create',
231
+ restore: 'backup.restore', delete: 'backup.delete', rm: 'backup.delete',
232
+ },
233
+ accounts: {
234
+ list: 'account.list', show: 'account.get', get: 'account.get',
235
+ members: 'account.member.list', keys: 'account.key.list', apps: 'account.app.list',
236
+ 'keys list': 'account.key.list', 'keys create': 'account.key.create', 'keys revoke': 'account.key.revoke',
237
+ invite: 'account.invite.create', invites: 'account.invite.list', revoke: 'account.invite.revoke',
238
+ role: 'account.member.set', remove: 'account.member.remove',
239
+ 'attach-app': { operation: 'account.app.attach', trailing: ['account_id'] },
240
+ },
241
+ platform: {
242
+ settings: 'platform.settings.get', 'settings get': 'platform.settings.get',
243
+ 'settings set': { operation: 'platform.setting.set', trailing: ['value'] },
244
+ },
245
+ tools: {
246
+ list: 'tool.list',
247
+ remove: { operation: 'tool.remove', trailing: ['name', 'version'] },
248
+ install: {
249
+ flow: GroupFlow.TOOL_INSTALL,
250
+ note: "tools install NAME FILE|-: streams the file once to hash it (sha256) and once as the PUT body (- is stdin, buffered to an owner-only temp file first, removed after)",
251
+ },
252
+ },
253
+ ledger: {
254
+ catalog: 'ledger.catalog.get', declare: 'ledger.catalog.set',
255
+ post: 'ledger.post', balance: 'ledger.balance', entries: 'ledger.entries',
256
+ entry: { operation: 'ledger.entry.get', trailing: ['id'] },
257
+ clear: { operation: 'ledger.entry.clear', trailing: ['id'] },
258
+ void: { operation: 'ledger.entry.void', trailing: ['id'] },
259
+ reverse: { operation: 'ledger.entry.reverse', trailing: ['id'] },
260
+ 'clear-due': 'ledger.clear_due', import: 'ledger.import',
261
+ },
262
+ // Registry groups run by their words alone (substrio kv get KEY: kv.get).
263
+ agent: {}, ai: {}, audit: {}, auth: {}, batch: {}, cluster: {}, documentation: {}, edge: {}, graph: {}, job: {}, kv: {},
264
+ // The app history: ref.* runs by its words (substrio ref log branch main).
265
+ // branch.*, tag.*, merge.* and version.* have commands of their own
266
+ // (commands/history.mjs, commands/versions.mjs), which run any operation
267
+ // of their group that those commands have no word for.
268
+ ref: {},
269
+ // The history tree worked on as a project (MCP-PLAIN-SHIP): substrio source tree main, source search main
270
+ // --pattern X, source edit and source move by their words.
271
+ source: {},
272
+ live_view: {}, log_alert: {}, log_drain: {},
273
+ // mcp.* runs by its words; config is the CLI's own word, for the client side of the same endpoint.
274
+ mcp: { config: { flow: GroupFlow.MCP_CONFIG, valueFlags: ['--client', '--name', '--env'], note: 'a ready MCP client config for this profile\'s app (--client claude|cursor|generic); the key as an environment variable reference' } },
275
+ // host.* and node_label.* (the operator's host manifest) run by their words; host remove and
276
+ // node_label delete are gated by their own trailing word, as every other.
277
+ host: {}, node_label: {},
278
+ // TEST-HOSTS: root_domain.* (the platform admin's roots) runs by its words: substrio root_domain add
279
+ // domain=onstax.com. env.host.* runs through the env command (commands/env.mjs), beside its pull and push.
280
+ root_domain: {},
281
+ // BILLING, FEATURED-APPS, MCP-MODULES, PLATFORM-UPGRADES: their operations run by their words: substrio billing
282
+ // cart create, substrio featured enable, substrio package add, substrio release list.
283
+ billing: {}, featured: {}, package: {}, release: {},
284
+ mesh: {}, meta: {}, namespace: {}, oauth: {}, object: {}, observability: {},
285
+ oidc: {}, plans: {}, queue: {}, rag: {}, rate_limit: {}, sql: {}, index: {},
286
+ realtime: {}, role: {}, route: {}, search: {}, settings: {}, sync: {}, system: {}, thread: {}, user: {},
287
+ };
288
+
289
+ // Registry group -> the command that runs its operations: a group above, a
290
+ // flow (tenants, files, site, versions) or an operation alias (undo).
291
+ export const REGISTRY_GROUPS = Object.freeze({
292
+ account: 'accounts', agent: 'agent', ai: 'ai', app: 'tenants', audit: 'audit', auth: 'auth', backup: 'backups', batch: 'batch',
293
+ cluster: 'cluster', collection: 'collections', data: 'data', documentation: 'documentation', edge: 'edge', file: 'files',
294
+ function: 'functions', graph: 'graph', host: 'host', index: 'index', job: 'job', kv: 'kv', ledger: 'ledger', live_view: 'live_view',
295
+ log_alert: 'log_alert', log_drain: 'log_drain', mcp: 'mcp', mesh: 'mesh', meta: 'meta', namespace: 'namespace', node_label: 'node_label', oauth: 'oauth',
296
+ root_domain: 'root_domain', env: 'env', apps: 'tenants', billing: 'billing', featured: 'featured', package: 'package',
297
+ release: 'release',
298
+ object: 'object', observability: 'observability', oidc: 'oidc', permissions: 'permissions', plans: 'plans', queue: 'queue',
299
+ platform: 'platform', query: 'data', rag: 'rag', rate_limit: 'rate_limit', realtime: 'realtime',
300
+ branch: 'branch', tag: 'tag', ref: 'ref', merge: 'merge',
301
+ role: 'role', route: 'route', sandbox: 'sandbox', schedule: 'cron', schema: 'collections',
302
+ search: 'search', secret: 'secrets', source: 'source', security: 'rules', settings: 'settings', site: 'site',
303
+ sql: 'sql', sync: 'sync', system: 'system', tenant: 'tenants', thread: 'thread', tool: 'tools', trigger: 'triggers', undo: 'undo',
304
+ user: 'user', version: 'versions', view: 'views', webhook: 'webhooks',
305
+ });
306
+
307
+ /** The registry groups `command` runs (pure). */
308
+ export function registryGroupsOf(command) {
309
+ return Object.keys(REGISTRY_GROUPS).filter((group) => REGISTRY_GROUPS[group] === command);
310
+ }
311
+
312
+ // The other names people type for a group.
313
+ export const GROUP_ALIASES = {
314
+ webhook: 'webhooks', view: 'views', backup: 'backups', account: 'accounts',
315
+ collection: 'collections', fn: 'functions', function: 'functions', secret: 'secrets',
316
+ trigger: 'triggers', domain: 'domains', rule: 'rules', permission: 'permissions', membership: 'memberships', sandboxes: 'sandbox', tool: 'tools',
317
+ };
318
+
319
+ /** The group a command name names, or null. */
320
+ export function groupOf(command) {
321
+ if (Object.hasOwn(GROUPS, command)) return command;
322
+ return GROUP_ALIASES[command] ?? null;
323
+ }
324
+
325
+ // Trailing verbs that destroy data or cannot be undone (from the registry's
326
+ // own operation names: delete, revoke, remove, drop, purge, reset, clear,
327
+ // restore cover every SUBSTRIO_OP whose DESC says so, checked against the whole
328
+ // registry, not maintained by hand per command).
329
+ const DESTRUCTIVE_WORDS = new Set(['delete', 'rm', 'remove', 'revoke', 'drop', 'purge', 'reset', 'clear', 'restore']);
330
+ const lastWord = (name) => name.split(/[ .]/).pop();
331
+
332
+ /** Whether the registry operation `name` destroys data or cannot be undone (pure, by its own trailing word). */
333
+ export function isDestructiveOperation(name) {
334
+ return DESTRUCTIVE_WORDS.has(lastWord(name));
335
+ }
336
+
337
+ /** An entry in its full form (pure); `sub` (the table key) decides `destructive` when the operation's own name does not say so (domains remove: tenant.domain.detach). */
338
+ export function entryOf(entry, sub = null) {
339
+ const plain = { operation: null, trailing: [], method: null, defaults: {}, text: false, render: null, body: null, flow: null, note: null, valueFlags: [] };
340
+ const full = typeof entry === 'string' ? { ...plain, operation: entry } : { ...plain, ...entry };
341
+ const destructive = full.operation !== null && (isDestructiveOperation(full.operation) || (sub !== null && DESTRUCTIVE_WORDS.has(lastWord(sub))));
342
+ return { ...full, destructive };
343
+ }
344
+
345
+ const isWord = (arg) => typeof arg === 'string' && !arg.startsWith('-') && !arg.includes('=');
346
+
347
+ /**
348
+ * The operation the leading words of `args` name in `groups`, the longest
349
+ * name in `names` (a Set), and the arguments after those words; null when
350
+ * none (pure).
351
+ */
352
+ export function registryOperation(names, groups, args) {
353
+ let count = 0;
354
+ while (count < args.length && isWord(args[count])) count++;
355
+ const words = args.slice(0, count).map((w) => w.replace(/-/g, '_'));
356
+ for (let used = count; used >= 0; used--) {
357
+ for (const group of groups) {
358
+ const name = [group, ...words.slice(0, used)].join('.');
359
+ if (names.has(name)) return { operation: name, rest: args.slice(used) };
360
+ }
361
+ }
362
+ return null;
363
+ }
364
+
365
+ /**
366
+ * The operation and arguments a group command runs (pure): {operation,
367
+ * method, defaults, render, body, flow, positional, rest, stdinField,
368
+ * destructive}. operation is null when the words are none of the group's
369
+ * own: a registry operation (cmdRegistry, in groups-runtime.mjs) then.
370
+ */
371
+ export function resolveGroup(group, args) {
372
+ const table = GROUPS[group];
373
+ let words = 0;
374
+ if (isWord(args[0]) && isWord(args[1]) && table[`${args[0]} ${args[1]}`]) words = 2;
375
+ else if (isWord(args[0]) && table[args[0]]) words = 1;
376
+ else if ((isWord(args[0]) || !Object.keys(table).length) && registryGroupsOf(group).length) return { operation: null, flow: null, destructive: false };
377
+ else if (isWord(args[0])) {
378
+ throw new Error(`Unknown ${group} command: ${args[0]}. Use ${Object.keys(table).join(', ')}`);
379
+ }
380
+ const sub = words ? args.slice(0, words).join(' ') : Object.keys(table)[0];
381
+ const { operation, trailing, method, defaults, text, render, body, flow, valueFlags, destructive } = entryOf(table[sub], sub);
382
+ const { positional, rest } = splitPositional(args.slice(words), valueFlags);
383
+ let stdinField = null;
384
+ if (trailing.length && positional.length >= trailing.length) {
385
+ const fields = positional.splice(positional.length - trailing.length);
386
+ fields.forEach((value, i) => {
387
+ if (value === '-') stdinField = trailing[i];
388
+ else rest.push(`${trailing[i]}${text ? '==' : '='}${value}`);
389
+ });
390
+ }
391
+ return { operation, method, defaults, render, body, flow, positional, rest, stdinField, destructive };
392
+ }
393
+
394
+ // A wrapper's arguments split in two (pure), the same split ops.mjs's own
395
+ // splitPositional makes: bare words (and "-") that fill path parameters, and
396
+ // everything else (key=value, flags, a flow's own valueFlags). Kept here,
397
+ // not imported from ops.mjs, so this module stays free of the SDK's own
398
+ // load (sdk.mjs's top-level await): command-table.mjs needs only this and
399
+ // resolveGroup/entryOf at --version/--help, neither of which runs a route.
400
+ const VALUE_FLAGS = new Set(['--data', '--file', '--method', '-X']);
401
+ function splitPositional(args, valueFlags = []) {
402
+ const positional = [];
403
+ const rest = [];
404
+ for (let i = 0; i < args.length; i++) {
405
+ const arg = args[i];
406
+ if (arg === '--') {
407
+ positional.push(...args.slice(i + 1));
408
+ break;
409
+ }
410
+ if (VALUE_FLAGS.has(arg) || valueFlags.includes(arg)) {
411
+ rest.push(arg);
412
+ if (i + 1 < args.length) rest.push(args[++i]);
413
+ } else if ((arg.startsWith('-') && arg !== '-') || /^[^=:]+?(:=|==|=)/.test(arg)) rest.push(arg);
414
+ else positional.push(arg);
415
+ }
416
+ return { positional, rest };
417
+ }