@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.
- package/LICENSE +177 -0
- package/README.md +66 -2
- package/bin/substrio.mjs +91 -0
- package/package.json +44 -4
- package/src/api.mjs +107 -0
- package/src/apply.mjs +204 -0
- package/src/blobs.mjs +102 -0
- package/src/cli.mjs +405 -0
- package/src/collections.mjs +701 -0
- package/src/command-table.mjs +436 -0
- package/src/commands/bundle.mjs +369 -0
- package/src/commands/count.mjs +39 -0
- package/src/commands/deploy.mjs +488 -0
- package/src/commands/dev.mjs +246 -0
- package/src/commands/env.mjs +115 -0
- package/src/commands/files.mjs +172 -0
- package/src/commands/git.mjs +195 -0
- package/src/commands/groups-runtime.mjs +227 -0
- package/src/commands/groups.mjs +417 -0
- package/src/commands/history.mjs +374 -0
- package/src/commands/import.mjs +506 -0
- package/src/commands/init.mjs +175 -0
- package/src/commands/key.mjs +172 -0
- package/src/commands/login.mjs +167 -0
- package/src/commands/logs.mjs +285 -0
- package/src/commands/mcp-config.mjs +102 -0
- package/src/commands/op.mjs +265 -0
- package/src/commands/open.mjs +35 -0
- package/src/commands/push.mjs +199 -0
- package/src/commands/signup.mjs +164 -0
- package/src/commands/site.mjs +129 -0
- package/src/commands/skills.mjs +177 -0
- package/src/commands/status.mjs +78 -0
- package/src/commands/tenants.mjs +172 -0
- package/src/commands/test.mjs +167 -0
- package/src/commands/tools.mjs +63 -0
- package/src/commands/versions.mjs +293 -0
- package/src/config.mjs +569 -0
- package/src/documents.mjs +139 -0
- package/src/function-build.mjs +273 -0
- package/src/identity.mjs +248 -0
- package/src/jsonfile.mjs +22 -0
- package/src/live-move.mjs +267 -0
- package/src/local/args.mjs +58 -0
- package/src/local/reload.mjs +408 -0
- package/src/local/server.mjs +291 -0
- package/src/local/watch.mjs +175 -0
- package/src/manifest.mjs +591 -0
- package/src/ops.mjs +265 -0
- package/src/readers.mjs +416 -0
- package/src/sdk.mjs +26 -0
- package/src/settings.mjs +117 -0
- package/src/testing/app.mjs +507 -0
- package/src/testing/env.mjs +35 -0
- package/src/testing/index.mjs +11 -0
- 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
|
+
}
|