@substrio/cli 0.0.0-stage → 0.1.1

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,436 @@
1
+ /**
2
+ * Substrio CLI: the command table. Every command is one entry here: its names,
3
+ * the credentials it needs, its usage lines and how it runs. Top-level help,
4
+ * `substrio COMMAND --help` and dispatch are all generated from this table, and
5
+ * the registry groups (data, collections, webhooks, ...) come from GROUPS in
6
+ * commands/groups.mjs, so no command, usage line or operation is written
7
+ * twice.
8
+ */
9
+
10
+ import { EXIT_CODE_HELP, fail } from './cli.mjs';
11
+ import { GROUPS, GROUP_ALIASES, GroupFlow, entryOf, registryGroupsOf, resolveGroup } from './commands/groups.mjs';
12
+
13
+ const importCmd = (mod) => import(`./commands/${mod}`);
14
+
15
+ /** What a command needs before it runs. */
16
+ export const Needs = Object.freeze({
17
+ NOTHING: 'nothing', // no config (init, login)
18
+ CONFIG: 'config', // the resolved config, no server
19
+ URL: 'url', // a server URL, no key
20
+ KEY: 'key', // a server URL and a key
21
+ });
22
+
23
+ const C = {
24
+ reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m', red: '\x1b[91m', green: '\x1b[92m', cyan: '\x1b[96m',
25
+ };
26
+
27
+ // Hand-written commands: flows that are more than one registry operation
28
+ // (build, push, promote with backup, site upload, bundles) or that work on
29
+ // local files and profiles. Anything that is one operation is a group
30
+ // command (below) or `substrio op`.
31
+ const FLOWS = [
32
+ {
33
+ names: ['init'], section: 'START', needs: Needs.NOTHING,
34
+ usage: [['init [--url URL] [--tenant SLUG] [--yes]', 'Scaffold a project (substrio.toml holds no key; the key lives in a profile)']],
35
+ run: async ({ args }) => (await importCmd('init.mjs')).cmdInit(args),
36
+ },
37
+ {
38
+ names: ['signup'], section: 'START', needs: Needs.NOTHING,
39
+ usage: [
40
+ ['signup --url URL --tenant SLUG [--email E] [--code CODE | --password -] [--account NAME] [--save-profile NAME] [--print]',
41
+ 'An outside developer or agent, no master key: the email is proven by a mailed code (without --code it sends one and says the next command), then an account and its first key, saved as a profile like "substrio login" does'],
42
+ ],
43
+ run: async ({ args }) => (await importCmd('signup.mjs')).cmdSignup(args),
44
+ },
45
+ {
46
+ names: ['login'], section: 'START', needs: Needs.NOTHING,
47
+ usage: [
48
+ ['login [--url URL] --key - [--tenant SLUG] [--profile NAME] [--default]', 'Save a profile; the key from stdin, the tenant found from it, the server https://mcp.substrio.com unless --url names another. Without --profile it is the default; with --profile the default changes only with --default (or when there is none)'],
49
+ ['login --offline --url URL --tenant SLUG --key - [--profile NAME] [--default]', 'Saves the profile with no round trip (the server need not be up yet); --tenant is required, the profile is marked unchecked until a login without --offline saves it again'],
50
+ ['login --list | --remove NAME', 'List or remove saved profiles'],
51
+ ],
52
+ run: async ({ args }) => (await importCmd('login.mjs')).cmdLogin(args),
53
+ },
54
+ {
55
+ names: ['whoami'], section: 'START', needs: Needs.CONFIG,
56
+ usage: [['whoami', 'Tenant, server and profile in use, and where each came from']],
57
+ run: async ({ config }) => (await importCmd('status.mjs')).cmdWhoami(config),
58
+ },
59
+ {
60
+ names: ['status'], section: 'START', needs: Needs.URL,
61
+ usage: [['status', 'Server health and config']],
62
+ run: async ({ api, config }) => (await importCmd('status.mjs')).cmdStatus(api, config),
63
+ },
64
+ {
65
+ names: ['open'], section: 'START', needs: Needs.CONFIG,
66
+ usage: [['open', 'Open the server in a browser']],
67
+ run: async ({ config }) => (await importCmd('open.mjs')).cmdOpen(config),
68
+ },
69
+ {
70
+ names: ['skills'], section: 'START', needs: Needs.URL,
71
+ usage: [
72
+ ['skills list', "The server's agent skills: name, title, description"],
73
+ ['skills show NAME', "One skill's markdown"],
74
+ ['skills install [NAME...] [--for claude|cursor|codex|agents-md|all] [--dir DIR]',
75
+ 'Write skills (default: every one) where each client reads its own (default --for all, --dir the project root)'],
76
+ ],
77
+ run: async ({ api, args }) => (await importCmd('skills.mjs')).cmdSkills(api, args),
78
+ },
79
+ {
80
+ names: ['key'], section: 'START', needs: (args) => (args[0] === 'export' ? Needs.CONFIG : Needs.KEY),
81
+ usage: [
82
+ ['key rotate [--overlap 1h] [--expires-in 90d] [--print]', 'Replace the tenant key; saved to the profile'],
83
+ ['key list', 'The tenant keys: ids, created, expires, state'],
84
+ ['key revoke ID', 'End the previous key overlap now'],
85
+ ['key export --env FILE [--var NAME] [--force]', 'Write NAME=KEY (default SUBSTRIO_KEY) of the key in use to FILE, owner-only, never printed; for an MCP client config'],
86
+ ],
87
+ run: async ({ api, config, args }) => (await importCmd('key.mjs')).cmdKey(api, config, args),
88
+ },
89
+ {
90
+ names: ['tenants', 'tenant', 'apps', 'app'], section: 'APPS', needs: Needs.KEY,
91
+ usage: [
92
+ ['tenants list | show SLUG', 'Every tenant; one tenant (platform-admin key)'],
93
+ ['tenants create SLUG [--save-profile N] [--print]', 'Create a tenant; its key is saved to profile N'],
94
+ ['tenants delete SLUG --yes', 'Delete a tenant and all its data'],
95
+ ],
96
+ run: async ({ api, config, args }) => (await importCmd('tenants.mjs')).cmdTenants(api, config, args),
97
+ },
98
+ {
99
+ names: ['pull'], section: 'APPS', needs: Needs.KEY,
100
+ usage: [['pull (--for PROFILE | --seal-to PUBKEY) [--out FILE]', "Stream the app's bundle, sealed to one server's key"]],
101
+ run: async ({ config, args }) => (await importCmd('bundle.mjs')).cmdPull(config, args),
102
+ },
103
+ {
104
+ names: ['push-bundle'], section: 'APPS', needs: Needs.NOTHING,
105
+ usage: [['push-bundle FILE --to PROFILE [--slug S] [--replace]', "Stream a bundle into PROFILE's server as an app"]],
106
+ run: async ({ args }) => (await importCmd('bundle.mjs')).cmdPushBundle(args),
107
+ },
108
+ {
109
+ names: ['move'], section: 'APPS', needs: Needs.KEY,
110
+ usage: [
111
+ ['move --to PROFILE [--slug S] [--replace] [--save-profile NAME]', "Pipe the app, sealed, into PROFILE's server"],
112
+ ['move --live --to PROFILE', "Move the app to PROFILE's server while it takes writes: copy, follow, fence, drain, flip (both profiles hold their server's master key; a failed drain rolls back)"],
113
+ ['move --failover --to PROFILE', "PROFILE's server, a replica of the app, takes its writes because this profile's server no longer answers (one that answers is moved live instead)"],
114
+ ],
115
+ run: async ({ config, args }) => (await importCmd('bundle.mjs')).cmdMove(config, args),
116
+ },
117
+ {
118
+ names: ['push'], section: 'SHIP', needs: (args) => (args.includes('--dry-run') ? Needs.CONFIG : Needs.KEY),
119
+ usage: [['push [-m MSG] [--branch NAME] [--no-build] [--dry-run] [--sandbox NAME]', 'Build, upload a new version (functions, rules, collections, site), run it in dev; --branch commits it on that branch of the history instead of main']],
120
+ run: async ({ api, config, args }) => (await importCmd('push.mjs')).cmdPush(api, config, args),
121
+ },
122
+ {
123
+ names: ['dev'], section: 'SHIP', needs: Needs.NOTHING,
124
+ usage: [['dev [--attach URL] [--server PATH] [--port N] [--data DIR] [--no-build] [--no-logs] [--prune]', 'The app on a local server with hot reload: a saved function redeploys alone, logs stream. Data and keys stay in .substrio/dev (or --data DIR, made when missing). The server reports its port on a pipe (substrio_server --ready-fd). It saves the profile TENANT-dev (never the default): substrio --dev COMMAND uses it from a second terminal. Reports a server declaration the files no longer name (drift); --prune drops it']],
125
+ run: async ({ args }) => (await importCmd('dev.mjs')).cmdDev(args),
126
+ },
127
+ {
128
+ names: ['test'], section: 'SHIP', needs: Needs.NOTHING,
129
+ usage: [['test [FILES...] [--server PATH] [--attach URL] [--no-build] [--filter NAME] [--timeout MS] [--keep]', 'Run the app\'s tests (test/*.test.mjs) on a local server, each test on a tenant of its own']],
130
+ run: async ({ args }) => (await importCmd('test.mjs')).cmdTest(args),
131
+ },
132
+ {
133
+ names: ['promote'], section: 'SHIP', needs: Needs.KEY,
134
+ usage: [['promote [N] [--no-backup]', 'Make N live (default: what dev runs); the data is backed up first']],
135
+ run: async ({ api, args }) => (await importCmd('versions.mjs')).cmdPromote(api, args),
136
+ },
137
+ {
138
+ names: ['rollback'], section: 'SHIP', needs: Needs.KEY,
139
+ usage: [['rollback [--no-backup]', 'Make the version before live live again']],
140
+ run: async ({ api, args }) => (await importCmd('versions.mjs')).cmdRollback(api, args),
141
+ },
142
+ {
143
+ names: ['restore'], section: 'SHIP', needs: Needs.KEY,
144
+ usage: [['restore [BACKUP_ID] [--yes] [--wait S]', "Put the data back from a promote's backup"]],
145
+ run: async ({ api, args }) => (await importCmd('versions.mjs')).cmdRestore(api, args),
146
+ },
147
+ {
148
+ names: ['versions', 'version'], section: 'SHIP', needs: Needs.KEY,
149
+ usage: [['versions', 'List versions: which is live, what each sandbox runs, each backup']],
150
+ run: async ({ api, args }) => (await importCmd('versions.mjs')).cmdVersions(api, args),
151
+ },
152
+ {
153
+ names: ['diff'], section: 'SHIP', needs: Needs.KEY,
154
+ usage: [['diff [A] [B]', 'What changed (default: live to dev)']],
155
+ run: async ({ api, args }) => (await importCmd('versions.mjs')).cmdDiff(api, args),
156
+ },
157
+ // VERSIONING: the app's own history in the words a developer who knows git
158
+ // already types. Each is one call to one operation (commands/history.mjs).
159
+ {
160
+ names: ['branch', 'branches'], section: 'HISTORY', needs: Needs.KEY,
161
+ usage: [
162
+ ['branch', 'The app\'s branches: the version each points at, when it last moved, whether it is protected (branch.list)'],
163
+ ['branch create NAME [--from REF]', 'Make a branch at REF (a branch name or a version id; default: the main branch\'s head)'],
164
+ ['branch delete NAME [--yes]', 'Free a branch\'s name; its versions and its log are kept (substrio ref restore branch NAME puts it back)'],
165
+ ['branch protect NAME [--off] [--checks A,B]', 'A commit or merge onto NAME needs those checks green; --off unprotects it'],
166
+ ],
167
+ run: async ({ api, args }) => (await importCmd('history.mjs')).cmdBranch(api, args),
168
+ },
169
+ {
170
+ names: ['log'], section: 'HISTORY', needs: Needs.KEY,
171
+ usage: [['log [PATH] [--branch NAME] [--limit N]', 'The commits of a branch, newest first; with PATH, only the commits that changed that one unit (version.log). Function logs are substrio logs']],
172
+ run: async ({ api, args }) => (await importCmd('history.mjs')).cmdLog(api, args),
173
+ },
174
+ {
175
+ names: ['tag', 'tags'], section: 'HISTORY', needs: Needs.KEY,
176
+ usage: [
177
+ ['tag', 'The app\'s tags: the version each marks (tag.list)'],
178
+ ['tag NAME [VERSION] [--branch NAME]', 'Mark a version with a name that never moves again (default: the head of main, or of --branch)'],
179
+ ],
180
+ run: async ({ api, args }) => (await importCmd('history.mjs')).cmdTag(api, args),
181
+ },
182
+ {
183
+ names: ['merge'], section: 'HISTORY', needs: Needs.KEY,
184
+ usage: [
185
+ ['merge FROM [--into NAME]', 'Work out the merge of FROM into a branch (default: main) and keep it as that branch\'s draft; publishes nothing (merge.plan)'],
186
+ ['merge status [--into NAME]', 'The merge in hand: both sides, every unit it touched and what is still conflicted (merge.get)'],
187
+ ['merge resolve PATH (--file F | --text T | -) [--into NAME]', 'Settle one conflicted unit with these bytes (merge.resolve)'],
188
+ ['merge commit [-m MSG] [--into NAME] [--checks-green]', 'Publish the merge as one commit on the branch (merge.commit)'],
189
+ ['merge abort [--into NAME] [--yes]', 'Throw the merge in hand away; the branch never moved (merge.abort)'],
190
+ ],
191
+ run: async ({ api, args }) => (await importCmd('history.mjs')).cmdMerge(api, args),
192
+ },
193
+ {
194
+ names: ['gc'], section: 'HISTORY', needs: Needs.KEY,
195
+ usage: [
196
+ ['gc [--yes]', 'Erase every object of the app\'s history no branch, tag, environment, ref log or merge reaches (version.gc)'],
197
+ ['gc --dry-run | --status', 'What a collection would take, erasing nothing; or where the last one got to (version.gc.status)'],
198
+ ],
199
+ run: async ({ api, args }) => (await importCmd('history.mjs')).cmdGc(api, args),
200
+ },
201
+ {
202
+ names: ['git'], section: 'HISTORY', needs: Needs.CONFIG,
203
+ usage: [
204
+ ['git export DIR [--data DIR] [--server PATH] [--owner ID]', "The app's whole history as commits in a git repository (made when DIR is empty). Not an HTTP call: it opens the app's database file, so --data names it and the app has to be on this machine"],
205
+ ['git import DIR [--data DIR] [--server PATH] [--author ID]', "A git repository's commits as the app's history (git fast-export piped into the server)"],
206
+ ],
207
+ run: async ({ config, args }) => (await importCmd('git.mjs')).cmdGit(config, args),
208
+ },
209
+ {
210
+ names: ['deploy'], section: 'SHIP', needs: Needs.KEY,
211
+ usage: [['deploy [NAME...] [--dry-run] [--prune]', "Make the declared collections (collections/NAME.json: schema, rules, indexes, expire, aggregates), deploy functions (routes, vars from substrio.toml) and set the rules. Reports a server declaration the files no longer name (drift); --prune drops it. With one or more NAME, only those functions are built and replaced: nothing else is read or sent (--prune, --functions and --assets are refused with names)"]],
212
+ run: async ({ api, config, args }) => (await importCmd('deploy.mjs')).cmdDeploy(api, config, args),
213
+ },
214
+ {
215
+ names: ['site'], section: 'SHIP', needs: Needs.KEY,
216
+ usage: [
217
+ ['site deploy DIR', 'Publish a website as a release (uploads only changes)'],
218
+ ['site releases | activate RELEASE_ID', 'List releases; serve an earlier one'],
219
+ ],
220
+ run: async ({ api, config, args }) => (await importCmd('site.mjs')).cmdSite(api, config, args),
221
+ },
222
+ {
223
+ names: ['env'], section: 'SHIP', needs: Needs.KEY,
224
+ usage: [['env pull | push', 'Sync secrets with a .env file']],
225
+ run: async ({ api, args }) => (await importCmd('env.mjs')).cmdEnv(api, args),
226
+ },
227
+ {
228
+ names: ['files', 'file'], section: 'SHIP', needs: Needs.KEY,
229
+ usage: [
230
+ ['files ls [PATH] | upload LOCAL [REMOTE] | rm PATH', 'Static files'],
231
+ ['files sync LOCAL_DIR PREFIX [--concurrency N]', 'Upload every file of a folder the store lacks or holds at another size, N at a time'],
232
+ ],
233
+ run: async ({ api, args }) => (await importCmd('files.mjs')).cmdFiles(api, args),
234
+ },
235
+ {
236
+ // "log" is the app's own history (version.log), as git spells it; the
237
+ // function logs keep the plural they always had.
238
+ names: ['logs'], section: 'SHIP', needs: Needs.KEY,
239
+ usage: [
240
+ ['logs [--limit N] [--since D|T] [--until D|T] [--function NAME] [--request-id ID] [--level LEVEL]',
241
+ 'Cloud function logs; D is a duration ago (30s, 5m, 1h) or T a Unix-second/ISO timestamp'],
242
+ ['logs --follow (--for D | --lines N | --until-match TEXT)',
243
+ 'Tails them live over a WebSocket until the given stop is met (ends exit 0); required with no terminal or under --json; on a terminal with none given, Ctrl+C stops it'],
244
+ ],
245
+ run: async ({ api, args }) => (await importCmd('logs.mjs')).cmdLogs(api, args),
246
+ },
247
+ {
248
+ names: ['op', 'call'], section: 'ANY OPERATION', needs: Needs.KEY,
249
+ usage: [
250
+ ['op list [FILTER]', 'Every operation of the server: name, route, summary'],
251
+ ['op NAME --help', 'Its routes and input schema (read from the server)'],
252
+ ["op NAME [key=value ...] [--data '{...}' | --file F]", 'Call it; prints JSON'],
253
+ ],
254
+ help: async ({ api, args }) => {
255
+ if (!args[0] || args[0] === 'list' || args[0] === 'ls') return false;
256
+ await (await importCmd('op.mjs')).showOperationHelp(api, args[0]);
257
+ return true;
258
+ },
259
+ run: async ({ api, args }) => (await importCmd('op.mjs')).cmdOp(api, args),
260
+ },
261
+ // Aliases onto one operation each.
262
+ operationAlias(['invoke'], 'function.invoke', 'invoke NAME [key=value ...] [--data \'{...}\']', 'Call a cloud function; --data or key=value is its JSON event (none: {})', [], { body: 'event', emptyBody: {} }),
263
+ operationAlias(['undo'], 'undo', 'undo TOKEN', 'Undo a change by its undo_token (null means nothing to undo); prints the token that redoes it', ['token']),
264
+ ];
265
+
266
+ // A top-level command that is one registry operation: bare words fill the
267
+ // route's path parameters, or `fields` when given. A field given neither as a
268
+ // bare word, key=value nor --data/--file prints the usage and runs nothing.
269
+ // `body` names the input field that is the whole body (as for data insert): --data, --file and key=value make that
270
+ // field's value; `emptyBody` is what it is when none is given (an operation whose body may be empty).
271
+ function operationAlias(names, operation, syntax, summary, fields = [], { body = null, emptyBody = null } = {}) {
272
+ return {
273
+ names, section: 'ANY OPERATION', needs: Needs.KEY, operation,
274
+ usage: [[syntax, `${summary} (${operation}; substrio op ${operation} --help for its input)`]],
275
+ run: async ({ api, args }) => {
276
+ const { parseOpArgs, splitPositional } = await import('./ops.mjs');
277
+ const { positional, rest } = splitPositional(args);
278
+ const whole = rest.some((a) => a === '--data' || a === '--file');
279
+ const missing = fields.slice(positional.length)
280
+ .filter((field) => !rest.some((a) => a.startsWith(`${field}=`)));
281
+ if (missing.length && !whole) {
282
+ fail(`Usage: substrio ${names[0]} ${fields.map((f) => `<${f}>`).join(' ')}`, `substrio ${names[0]} --help for more`);
283
+ }
284
+ const named = fields.slice(0, positional.length).map((field, i) => `${field}==${positional[i]}`);
285
+ const given = [...named, ...rest];
286
+ // Arguments that do not parse are runOperation's own error to report.
287
+ const noInput = body !== null && emptyBody !== null && (() => { try { return parseOpArgs(given).input === undefined; } catch { return false; } })();
288
+ return (await importCmd('op.mjs')).runOperation(api, operation, noInput ? [...given, '--data', JSON.stringify(emptyBody)] : given, {
289
+ positional: positional.slice(fields.length), body,
290
+ });
291
+ },
292
+ };
293
+ }
294
+
295
+ // The registry groups a command runs, as help shows them: "kv.*, ai.*".
296
+ const registryText = (command) => registryGroupsOf(command).map((g) => `${g}.*`).join(', ');
297
+
298
+ // The usage line for running a command's registry operations by their words.
299
+ const registryUsage = (command) => [`${command} WORD ... [ARG ...] [key=value ...]`, `any ${registryText(command)} operation by its words (${registryGroupsOf(command)[0]}.a.b: ${command} a b)`];
300
+
301
+ // What a flow of a group needs, when it is not a call to the server (the others need a URL and a key).
302
+ const FLOW_NEEDS = Object.freeze({ [GroupFlow.MCP_CONFIG]: Needs.URL });
303
+
304
+ // What the words of a group command need: a flow's own, else a server URL and a key.
305
+ function groupNeeds(group, args) {
306
+ let flow = null;
307
+ try {
308
+ ({ flow } = resolveGroup(group, args));
309
+ } catch {
310
+ // Unknown words are the command's to report, once it runs.
311
+ }
312
+ return FLOW_NEEDS[flow] ?? Needs.KEY;
313
+ }
314
+
315
+ // One entry per registry group, generated from GROUPS.
316
+ function groupCommand(group) {
317
+ const table = GROUPS[group];
318
+ const aliases = Object.entries(GROUP_ALIASES).filter(([, g]) => g === group).map(([a]) => a);
319
+ const operations = [...new Set(Object.values(table).map((e) => entryOf(e).operation))];
320
+ const subs = Object.keys(table);
321
+ return {
322
+ names: [group, ...aliases], section: 'REGISTRY GROUPS', needs: (args) => groupNeeds(group, args), group,
323
+ usage: [
324
+ ...Object.entries(table).map(([sub, e]) => {
325
+ const { operation, trailing, note } = entryOf(e);
326
+ return [`${group} ${sub}${trailing.map((t) => ` ${t.toUpperCase()}`).join('')} [ARG ...] [key=value ...]`, note ?? operation];
327
+ }),
328
+ ...(registryGroupsOf(group).length ? [registryUsage(group)] : []),
329
+ ],
330
+ summary: [`${group} ${subs.length ? subs.join('|') : 'WORD ...'}`, registryText(group) || operations.join(', ')],
331
+ help: async ({ api, args }) => {
332
+ if (!args[0]) return false;
333
+ let { operation, flow } = resolveGroup(group, args); // throws on an unknown subcommand
334
+ if (flow !== null && !operation) return false; // a word of the CLI's own: its usage line is its help
335
+ if (!operation) ({ operation } = await (await importCmd('groups-runtime.mjs')).findRegistryOperation(api, group, args));
336
+ await (await importCmd('op.mjs')).showOperationHelp(api, operation);
337
+ return true;
338
+ },
339
+ run: async ({ api, config, args, command }) => (await importCmd('groups-runtime.mjs')).cmdGroup(api, command, args, { config }),
340
+ };
341
+ }
342
+
343
+ // A flow that also runs registry groups (tenants: tenant.*, app.*) says so
344
+ // in its usage; its words run them when they are not its own.
345
+ for (const flow of FLOWS) {
346
+ if (registryGroupsOf(flow.names[0]).length && !flow.operation) flow.usage.push(registryUsage(flow.names[0]));
347
+ }
348
+
349
+ /** Every command, flows first, then the registry groups. */
350
+ export const COMMANDS = [...FLOWS, ...Object.keys(GROUPS).map(groupCommand)];
351
+
352
+ /** The entry for a command name, or null. */
353
+ export function findCommand(name) {
354
+ return COMMANDS.find((c) => c.names.includes(name)) ?? null;
355
+ }
356
+
357
+ /** What `entry` needs for these arguments. */
358
+ export function needsOf(entry, args) {
359
+ return typeof entry.needs === 'function' ? entry.needs(args) : entry.needs;
360
+ }
361
+
362
+ const GLOBAL_FLAGS = [
363
+ ['--help, -h', 'Usage of any command; nothing runs'],
364
+ ['--json', 'Results as JSON on stdout; an error as JSON on stderr'],
365
+ ['--yes, -y', 'Confirm a destructive command'],
366
+ ['--profile NAME', 'Use a saved profile (SUBSTRIO_PROFILE)'],
367
+ ['--tenant SLUG', 'Use this tenant (SUBSTRIO_TENANT)'],
368
+ ['--dev', 'Use the dev server of "substrio dev" in this directory (its profile TENANT-dev)'],
369
+ ['--', 'End of options: the rest are arguments, even when they start with - (substrio undo -- -abc)'],
370
+ ];
371
+
372
+ function rows(lines, width) {
373
+ return lines.map(([syntax, text]) => ` ${C.green}${`substrio ${syntax}`.padEnd(width)}${C.reset} ${C.dim}${text}${C.reset}`).join('\n');
374
+ }
375
+
376
+ /**
377
+ * Every GROUPS command word that destroys data or cannot be undone
378
+ * (entryOf's own destructive, so a new one is listed the moment it is
379
+ * marked); needs --yes, or a prompt on a terminal. tenants delete and
380
+ * restore are their own flows, not from this table, documented in their own
381
+ * usage line above (both already show --yes).
382
+ */
383
+ function destructiveRows() {
384
+ const rows = [];
385
+ for (const [command, table] of Object.entries(GROUPS)) {
386
+ for (const sub of Object.keys(table)) {
387
+ const { destructive, operation } = entryOf(table[sub], sub);
388
+ if (destructive) rows.push([`${command} ${sub}`, operation]);
389
+ }
390
+ }
391
+ return rows;
392
+ }
393
+
394
+ /** The top-level help text. */
395
+ export function topHelp(version) {
396
+ const sections = new Map();
397
+ const add = (section, lines) => {
398
+ if (!sections.has(section)) sections.set(section, []);
399
+ sections.get(section).push(...lines);
400
+ };
401
+ for (const c of COMMANDS) add(c.section, c.summary ? [c.summary] : c.usage);
402
+ // Every registry group under a named command, flows and aliases too.
403
+ for (const c of FLOWS) {
404
+ if (registryGroupsOf(c.names[0]).length) add('REGISTRY GROUPS', [[`${c.names[0]} ...`, registryText(c.names[0])]]);
405
+ }
406
+ const out = [`\n${C.bold}${C.cyan}Substrio CLI${C.reset} ${C.dim}v${version}${C.reset}\n`, `${C.bold}USAGE${C.reset}\n substrio <command> [options] substrio <command> --help\n`];
407
+ for (const [section, lines] of sections) out.push(`${C.bold}${section}${C.reset}\n${rows(lines, 58)}\n`);
408
+ out.push(`${C.bold}GLOBAL FLAGS${C.reset}\n${GLOBAL_FLAGS.map(([f, t]) => ` ${f.padEnd(20)}${C.dim}${t}${C.reset}`).join('\n')}\n`);
409
+ out.push(`${C.bold}EXIT CODES${C.reset}\n${EXIT_CODE_HELP.map(([n, t]) => ` ${String(n).padEnd(20)}${C.dim}${t}${C.reset}`).join('\n')}\n`);
410
+ out.push(`${C.bold}DESTRUCTIVE${C.reset} (need --yes, or a prompt on a terminal)\n` +
411
+ `${rows(destructiveRows(), 28)}\n ${C.green}substrio tenants delete, substrio restore${C.reset} ${C.dim}(their own usage above)${C.reset}\n`);
412
+ out.push(`${C.bold}CONFIGURATION${C.reset}
413
+ ~/.substrio/config.toml Saved profiles (url, key, tenant), from "substrio login"; owner-only
414
+ substrio.toml Project config (profile, tenant, functions_dir, [auth]); never a key
415
+ SUBSTRIO_URL, SUBSTRIO_TENANT, SUBSTRIO_MASTER_KEY, SUBSTRIO_PROFILE Environment overrides
416
+
417
+ ${C.bold}EXAMPLES${C.reset}
418
+ substrio login --url http://localhost:9000 --tenant demo --profile local --key - < key.txt
419
+ substrio init --yes
420
+ substrio deploy
421
+ substrio invoke hello --data '{"name": "world"}'
422
+ substrio data query users where.age:='{"$gt":30}' limit=10
423
+ `);
424
+ return out.join('\n');
425
+ }
426
+
427
+ /** One command's help text. */
428
+ export function commandHelp(entry) {
429
+ const lines = [`\n${C.bold}substrio ${entry.names[0]}${C.reset}${entry.names.length > 1 ? ` ${C.dim}(also ${entry.names.slice(1).join(', ')})${C.reset}` : ''}\n`];
430
+ lines.push(rows(entry.usage, 58));
431
+ if (entry.group || entry.names[0] === 'op') {
432
+ lines.push(`\n ${C.dim}substrio ${entry.names[0]} ${entry.group ? 'SUB' : 'NAME'} --help shows the operation's routes and input schema.${C.reset}`);
433
+ }
434
+ lines.push(`\n${C.bold}Global flags${C.reset}\n${GLOBAL_FLAGS.map(([f, t]) => ` ${f.padEnd(20)}${C.dim}${t}${C.reset}`).join('\n')}\n`);
435
+ return lines.join('\n');
436
+ }