@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,293 @@
1
+ /**
2
+ * Substrio CLI: the app's versions and which one is live.
3
+ *
4
+ * substrio versions [--json] List versions; live and sandbox markers, the
5
+ * backup taken when each was made live, and any
6
+ * function deployed outside a version
7
+ * substrio promote [N] [--no-backup] [--replace-direct | --keep-direct]
8
+ * Make N live (default: what dev runs); the
9
+ * app's data is backed up first. A function
10
+ * deployed directly (outside a version) that
11
+ * this would overwrite or delete refuses
12
+ * (409) unless one of the two flags is given
13
+ * substrio rollback [--no-backup] [--replace-direct | --keep-direct]
14
+ * Make the version before live live again
15
+ * substrio restore [ID] [--yes] [--wait S] Put the data back from the backup a
16
+ * promote took (default: the latest) and make
17
+ * the version live then live again
18
+ * substrio diff [A] [B] What changed from A to B (default: live to dev)
19
+ *
20
+ * A and B are version numbers, "live", or a sandbox name (the version it runs).
21
+ */
22
+
23
+ import { CliError, ErrorCode, confirmDestructive, emit, fail, globals } from '../cli.mjs';
24
+ import { cmdRegistry } from './groups-runtime.mjs';
25
+ import { errorText } from '../api.mjs';
26
+ import { callOperation, failResult, invokeOperation, loadOperations } from './op.mjs';
27
+ import { DEFAULT_SANDBOX, directMode } from './push.mjs';
28
+
29
+ const C = {
30
+ reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
31
+ red: '\x1b[91m', green: '\x1b[92m', yellow: '\x1b[93m', cyan: '\x1b[96m',
32
+ };
33
+
34
+
35
+ // version.list: {live, rollback_to, versions, sandboxes: [{name, live}],
36
+ // history: [{version, backup_id}] newest first, history[0] live}.
37
+ const listing = (api) => invokeOperation(api, 'version.list', undefined);
38
+
39
+ /**
40
+ * A version reference as a number: digits name a version, "live" the live
41
+ * one, anything else a sandbox (the version it runs). Pure over a listing.
42
+ */
43
+ export function resolveVersion(ref, listed) {
44
+ if (/^\d+$/.test(String(ref))) return parseInt(ref, 10);
45
+ if (ref === 'live') {
46
+ if (!listed.live) throw new Error('No version is live yet');
47
+ return listed.live;
48
+ }
49
+ const sandbox = (listed.sandboxes || []).find((s) => s.name === ref);
50
+ if (!sandbox) throw new Error(`"${ref}" is not a version number, "live" or a sandbox of this app`);
51
+ if (!sandbox.live) throw new CliError(`Sandbox ${ref} runs no version yet (substrio push runs one there)`, { code: ErrorCode.UNKNOWN });
52
+ return sandbox.live;
53
+ }
54
+
55
+ function resolveOrFail(ref, listed) {
56
+ try {
57
+ return resolveVersion(ref, listed);
58
+ } catch (err) {
59
+ return fail(err);
60
+ }
61
+ }
62
+
63
+ const when = (seconds) => new Date(seconds * 1000).toISOString().replace('T', ' ').slice(0, 16);
64
+
65
+ /**
66
+ * The rows `substrio versions` shows, newest version first (pure over a
67
+ * GET /v1/_versions listing): each version with where it runs and, when it
68
+ * is in the live history, the backup taken when it was last made live
69
+ * (history is newest first, so the first entry for a version wins; null when
70
+ * that promote took none, undefined when the version was never live).
71
+ */
72
+ export function versionRows(listed) {
73
+ const runsIn = new Map();
74
+ const mark = (n, label) => { if (n) runsIn.set(n, [...(runsIn.get(n) || []), label]); };
75
+ mark(listed.live, 'live');
76
+ for (const s of listed.sandboxes || []) mark(s.live, s.name);
77
+ const backups = new Map();
78
+ for (const entry of listed.history || []) {
79
+ if (!backups.has(entry.version)) backups.set(entry.version, entry.backup_id ?? null);
80
+ }
81
+ return [...(listed.versions || [])].sort((a, b) => b.number - a.number).map((v) => ({
82
+ ...v,
83
+ live: v.number === listed.live,
84
+ runs: runsIn.get(v.number) || [],
85
+ backup_id: backups.has(v.number) ? backups.get(v.number) : undefined,
86
+ }));
87
+ }
88
+
89
+ // One line per target (the app, each sandbox) with a function deployed
90
+ // directly (outside a version): its name and the kind of change. Pure over a
91
+ // listing; [] when none.
92
+ export function directChangeSummaryLines(listed) {
93
+ const line = (target, entries) => entries.length
94
+ ? [`direct changes on ${target}: ${entries.map((c) => `${c.name} (${c.change})`).join(', ')}`]
95
+ : [];
96
+ return [
97
+ ...line('the app', listed.direct_changes || []),
98
+ ...(listed.sandboxes || []).flatMap((s) => line(s.name, s.direct_changes || [])),
99
+ ];
100
+ }
101
+
102
+ export async function cmdVersions(api, args = []) {
103
+ // A version.* operation by its words (substrio versions get 3).
104
+ if (args[0] && !args[0].startsWith('-')) return cmdRegistry(api, 'versions', args);
105
+ const listed = await listing(api);
106
+ if (globals.json) return emit(listed);
107
+ const rows = versionRows(listed);
108
+ if (rows.length === 0) {
109
+ console.log(`${C.dim}No versions yet. Make one with: substrio push -m "..."${C.reset}`);
110
+ return;
111
+ }
112
+ // The backup column, sized to the ids it shows; "-" for a live entry
113
+ // whose promote took none.
114
+ const backupText = (row) => (row.backup_id === undefined ? '' : row.backup_id ?? '-');
115
+ const backupWidth = Math.max('BACKUP'.length, ...rows.map((r) => backupText(r).length)) + 2;
116
+
117
+ console.log(`\n${C.bold}Versions (${rows.length})${C.reset}\n`);
118
+ console.log(` ${'VERSION'.padEnd(9)}${'CREATED (UTC)'.padEnd(18)}${'FUNCS'.padEnd(7)}${'SITE'.padEnd(8)}${'RUNS IN'.padEnd(16)}` +
119
+ `${'BACKUP'.padEnd(backupWidth)}MESSAGE`);
120
+ for (const v of rows) {
121
+ const marker = v.live ? `${C.green}● ` : ' ';
122
+ console.log(`${marker}${String(v.number).padEnd(9)}${v.live ? C.reset : ''}${when(v.created_at).padEnd(18)}` +
123
+ // A commit not yet read as a version (manifest pending) has no counts until it is.
124
+ `${String(v.functions ?? '-').padEnd(7)}${String(v.site_files ?? '-').padEnd(8)}${v.runs.join(', ').padEnd(16)}` +
125
+ `${backupText(v).padEnd(backupWidth)}${v.message || ''}`);
126
+ }
127
+ if (listed.rollback_to) console.log(`\n${C.dim} substrio rollback makes version ${listed.rollback_to} live${C.reset}`);
128
+ for (const line of directChangeSummaryLines(listed)) console.log(`${C.yellow}⚠ ${line}${C.reset}`);
129
+ console.log('');
130
+ }
131
+
132
+ // POST /v1/_live's body: the version, "backup": false for --no-backup (a
133
+ // promote otherwise backs the app's data up first), and "direct" for
134
+ // --replace-direct/--keep-direct (over the server's default "refuse").
135
+ export function liveBody(version, args) {
136
+ const body = args.includes('--no-backup') ? { version, backup: false } : { version };
137
+ const direct = directMode(args);
138
+ if (direct) body.direct = direct;
139
+ return body;
140
+ }
141
+
142
+ // What a promote or rollback says about the backup it took.
143
+ function backupNote(set) {
144
+ return set.backup_id
145
+ ? `\n${C.dim} Backup ${set.backup_id} taken first; undo the data with: substrio restore ${set.backup_id}${C.reset}`
146
+ : `\n${C.dim} No backup taken.${C.reset}`;
147
+ }
148
+
149
+ // One direct_changes entry as "name (change: parts)".
150
+ function directChangeLine(c) {
151
+ return `${c.name} ${C.dim}(${c.change}${c.parts?.length ? `: ${c.parts.join(', ')}` : ''})${C.reset}`;
152
+ }
153
+
154
+ // version.live.set's 409 DIRECT_CHANGES: the functions deployed directly
155
+ // that this promote or rollback would overwrite or delete, as a table, with
156
+ // how to proceed. Prints nothing under --json (the error body carries
157
+ // direct_changes already, through the normal failResult/CliError path).
158
+ function printDirectConflicts(body) {
159
+ const entries = body.direct_changes || [];
160
+ console.error(`${C.red}✗ ${entries.length} function${entries.length === 1 ? '' : 's'} deployed outside a version would be overwritten or deleted:${C.reset}`);
161
+ for (const c of entries) console.error(` ${directChangeLine(c)}`);
162
+ console.error(`${C.dim} Rerun with --replace-direct to apply the version anyway, or --keep-direct to leave ${entries.length === 1 ? 'it' : 'them'} as deployed.${C.reset}`);
163
+ }
164
+
165
+ // version.live.set, with a readable conflict table on 409 DIRECT_CHANGES
166
+ // in place of the raw error body (any other failure: the normal failResult).
167
+ async function liveSet(api, body) {
168
+ const result = await callOperation(api, await loadOperations(api), 'version.live.set', body);
169
+ if (!result.ok) {
170
+ if (result.status === 409 && result.data?.code === 'DIRECT_CHANGES') {
171
+ if (!globals.json) printDirectConflicts(result.data);
172
+ fail(CliError.fromResponse(`${result.request.method} ${result.request.path}: ${errorText(result.data, result.status)}`,
173
+ result.data, result.status));
174
+ }
175
+ failResult(result);
176
+ }
177
+ return result.data;
178
+ }
179
+
180
+ export async function cmdPromote(api, args) {
181
+ const ref = args.find((a) => !a.startsWith('-'));
182
+ let number;
183
+ if (ref) {
184
+ number = /^\d+$/.test(ref) ? parseInt(ref, 10) : resolveOrFail(ref, await listing(api));
185
+ } else {
186
+ const listed = await listing(api);
187
+ const dev = (listed.sandboxes || []).find((s) => s.name === DEFAULT_SANDBOX);
188
+ if (!dev?.live) fail(`The ${DEFAULT_SANDBOX} sandbox runs no version`, 'Run substrio push first, or name one: substrio promote N', ErrorCode.UNKNOWN);
189
+ number = dev.live;
190
+ }
191
+ const set = await liveSet(api, liveBody(number, args));
192
+ emit(set, () => console.log(`${C.green}✓${C.reset} Version ${C.bold}${set.live}${C.reset} is live` +
193
+ `${set.previous ? ` ${C.dim}(was ${set.previous}; undo with: substrio rollback)${C.reset}` : ''}.` +
194
+ backupNote(set)));
195
+ }
196
+
197
+ export async function cmdRollback(api, args = []) {
198
+ const set = await liveSet(api, liveBody('previous', args));
199
+ emit(set, () => console.log(`${C.green}✓${C.reset} Rolled back: version ${C.bold}${set.live}${C.reset} is live` +
200
+ `${set.previous ? ` ${C.dim}(was ${set.previous})${C.reset}` : ''}.` + backupNote(set)));
201
+ }
202
+
203
+ // How long `substrio restore` waits for a busy database (409), in seconds, and
204
+ // the first and longest pause between tries, in milliseconds.
205
+ export const RESTORE_WAIT_SECONDS = 15;
206
+ const RESTORE_FIRST_PAUSE_MS = 100;
207
+ const RESTORE_LONGEST_PAUSE_MS = 2000;
208
+
209
+ /**
210
+ * Runs version.restore until it is not busy (409) or `waitSeconds` pass, the
211
+ * pause doubling from RESTORE_FIRST_PAUSE_MS. `ops` is the server's catalog.
212
+ * Returns the last callOperation result.
213
+ */
214
+ export async function restoreWithRetry(api, ops, input, waitSeconds = RESTORE_WAIT_SECONDS, sleep = (ms) => new Promise((r) => setTimeout(r, ms))) {
215
+ const deadline = Date.now() + waitSeconds * 1000;
216
+ let pause = RESTORE_FIRST_PAUSE_MS;
217
+ for (;;) {
218
+ const result = await callOperation(api, ops, 'version.restore', input);
219
+ if (result.status !== 409 || Date.now() + pause > deadline) return result;
220
+ await sleep(pause);
221
+ pause = Math.min(pause * 2, RESTORE_LONGEST_PAUSE_MS);
222
+ }
223
+ }
224
+
225
+ export async function cmdRestore(api, args) {
226
+ const id = args.find((a, i) => !a.startsWith('-') && args[i - 1] !== '--wait');
227
+ const waitAt = args.indexOf('--wait');
228
+ const wait = waitAt >= 0 ? Number(args[waitAt + 1]) : RESTORE_WAIT_SECONDS;
229
+ if (!Number.isFinite(wait) || wait < 0) fail('--wait takes a number of seconds');
230
+ const what = id ? `backup ${id}` : 'the backup the latest promote took';
231
+
232
+ await confirmDestructive(`Replacing the app's live data with ${what}`, `substrio restore${id ? ` ${id}` : ''} --yes`);
233
+
234
+ const result = await restoreWithRetry(api, await loadOperations(api), id ? { backup_id: id } : {}, wait);
235
+ if (!result.ok) {
236
+ if (result.status === 409) {
237
+ fail(CliError.fromResponse(errorText(result.data, result.status), result.data, result.status),
238
+ `The database stayed busy for ${wait}s; try again, or wait longer with --wait N`);
239
+ }
240
+ failResult(result);
241
+ }
242
+ const data = result.data;
243
+ emit(data, () => {
244
+ console.log(`${C.green}✓${C.reset} Restored the app's data from backup ${C.bold}${data.backup_id}${C.reset}.`);
245
+ console.log(data.live
246
+ ? ` Version ${C.bold}${data.live}${C.reset} is live${data.previous && data.previous !== data.live ? ` ${C.dim}(was ${data.previous})${C.reset}` : ''}.`
247
+ : ` ${C.dim}The backup came before the first promote; the live version is unchanged${data.previous ? ` (${data.previous})` : ''}.${C.reset}`);
248
+ });
249
+ }
250
+
251
+ function names(list, item = (x) => x) {
252
+ return list.map(item).join(', ');
253
+ }
254
+
255
+ export async function cmdDiff(api, args) {
256
+ const refs = args.filter((a) => !a.startsWith('-'));
257
+ const listed = await listing(api);
258
+ const [fromRef, toRef] = refs.length >= 2 ? refs : ['live', refs[0] || DEFAULT_SANDBOX];
259
+ const from = resolveOrFail(fromRef, listed);
260
+ const to = resolveOrFail(toRef, listed);
261
+ const d = await invokeOperation(api, 'version.diff', { from, to });
262
+ if (globals.json) return emit(d);
263
+
264
+ console.log(`\n${C.bold}Version ${d.from} → ${d.to}${C.reset} ${C.dim}(${fromRef} → ${toRef})${C.reset}\n`);
265
+ const lines = [];
266
+ const add = (sign, label, text) => lines.push(` ${sign === '+' ? C.green : sign === '-' ? C.red : C.yellow}${sign}${C.reset} ${label} ${text}`);
267
+ const fns = d.functions || {};
268
+ for (const n of fns.added || []) add('+', 'function', n);
269
+ for (const n of fns.removed || []) add('-', 'function', n);
270
+ for (const c of fns.changed || []) {
271
+ const what = ['source', 'main', 'routes', 'vars'].filter((k) => c[k]);
272
+ add('~', 'function', `${c.name} (${what.join(', ')})`);
273
+ }
274
+ if (d.rules) add('~', 'rules', '');
275
+ if (d.mcp) add('~', 'MCP tools', '');
276
+ const site = d.site || {};
277
+ for (const [sign, key] of [['+', 'added'], ['-', 'removed'], ['~', 'changed']]) {
278
+ const paths = site[key] || [];
279
+ if (paths.length === 0) continue;
280
+ const shown = paths.slice(0, 10).join(', ');
281
+ add(sign, `site (${paths.length})`, paths.length > 10 ? `${shown} and ${paths.length - 10} more` : shown);
282
+ }
283
+ const schedule = (c) => `${c.function} at "${c.expression}"`;
284
+ const trigger = (t) => `${t.function} on ${t.type} in ${t.collection}`;
285
+ if (d.cron?.added?.length) add('+', 'cron', names(d.cron.added, schedule));
286
+ if (d.cron?.removed?.length) add('-', 'cron', names(d.cron.removed, schedule));
287
+ if (d.triggers?.added?.length) add('+', 'triggers', names(d.triggers.added, trigger));
288
+ if (d.triggers?.removed?.length) add('-', 'triggers', names(d.triggers.removed, trigger));
289
+
290
+ if (lines.length === 0) console.log(` ${C.dim}No changes.${C.reset}`);
291
+ for (const line of lines) console.log(line);
292
+ console.log('');
293
+ }