@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,22 @@
1
+ /**
2
+ * Substrio CLI: a JSON file a project names (rules_file, mcp_file, a collection's
3
+ * file), read once and checked: it must exist and be JSON. The one owner of
4
+ * those two refusals, so every command words them alike.
5
+ */
6
+
7
+ import { existsSync, readFileSync } from 'node:fs';
8
+
9
+ import { CliError, ErrorCode } from './cli.mjs';
10
+
11
+ /**
12
+ * The parsed JSON of the file at `abs`; `what` names the kind of file ("rules_file")
13
+ * and `shown` the path the project knows it by. Throws a CliError otherwise.
14
+ */
15
+ export function readJsonFile(abs, what, shown) {
16
+ if (!existsSync(abs)) throw new CliError(`${what} ${shown} does not exist`, { code: ErrorCode.FILE_NOT_FOUND });
17
+ try {
18
+ return JSON.parse(readFileSync(abs, 'utf-8'));
19
+ } catch (err) {
20
+ throw new CliError(`${what} ${shown} is not valid JSON: ${err.message}`, { code: ErrorCode.INVALID_ARGUMENT });
21
+ }
22
+ }
@@ -0,0 +1,267 @@
1
+ /**
2
+ * The live move of an app between two servers (NIGHT-MIGRATE): the one owner of the algorithm,
3
+ * used by `substrio move --live` and by tests/integration/tenant_live_move.test.mjs.
4
+ *
5
+ * Made of what the platform has, in this order:
6
+ * 1. link: the destination holds the app read-only (a replica applies what its primary
7
+ * accepted and refuses local writes) and joins the source over SubstrioLink with the
8
+ * source's relay token and certificate pin (link_info), never its master key: it copies a
9
+ * snapshot, then follows the source's oplog from the point the copy began. The app's key
10
+ * ring is copied from the source to the destination (key_ring/set_key_ring) before the
11
+ * catch-up below, so the app's own key already works there the moment the flip happens;
12
+ * each server signs sessions with its own key, so when link_info's session_key_fingerprint
13
+ * differs between the two, every session ends (reported, never a refusal: MOVE-KEYS);
14
+ * 2. catch-up: the destination's in-order position for the source (its version_vector entry)
15
+ * reaches the source's head as read once, so the fence that follows is short;
16
+ * 3. fence: the source goes read-only; a client's write there is refused with 423
17
+ * TENANT_READ_ONLY and is retried where the app lives next;
18
+ * 4. drain: the destination's position reaches the source's head, read again until it stands
19
+ * still (a write that passed the gate just before the fence takes its number after it);
20
+ * 5. flip: the destination takes writes. The source stays linked as a read-only replica, so a
21
+ * move back needs no new link; when the destination's URL is known, the source is marked
22
+ * moved there and answers every request but a platform key's with 308 to it (MOVE-ROUTING).
23
+ * A drain or a flip that fails rolls back: the source takes writes again and the destination
24
+ * stays read-only, so the app is never without a primary and never has two.
25
+ *
26
+ * Every call needs each server's master key (the sync control route is master only).
27
+ *
28
+ * failover() promotes a replica when its primary no longer answers: the same status reads and
29
+ * the same flip, with no fence on a source that cannot be reached (see there).
30
+ */
31
+
32
+ export class LiveMoveError extends Error {
33
+ /** @param {string} message @param {{ rolledBack?: boolean }} [detail] */
34
+ constructor(message, { rolledBack = false } = {}) {
35
+ super(message);
36
+ this.name = 'LiveMoveError';
37
+ this.rolledBack = rolledBack;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * One server of a move.
43
+ * @typedef {object} MoveServer
44
+ * @property {string} address host:port the other server reaches it on (SubstrioLink)
45
+ * @property {string} [url] the URL clients reach it on: when the destination has one, the source is
46
+ * marked moved there after the flip
47
+ * @property {(method: string, path: string, body?: any, tenant?: string | null) =>
48
+ * Promise<{ status: number, json?: any, text?: string, bytes?: Buffer }>} call
49
+ * an authenticated request with this server's master key; `tenant` names the app in
50
+ * X-Substrio-Tenant, null for a platform call. `bytes`, when the implementation gives it, is
51
+ * the body exactly as received (MOVE-SITE reads a site file through it; `text` alone would
52
+ * corrupt one that is not valid UTF-8).
53
+ */
54
+
55
+ /**
56
+ * @typedef {object} LiveMoveOptions
57
+ * @property {boolean} [link=true] false when `to` already follows `from` (a move back)
58
+ * @property {number} [timeoutMs=60000] for the copy's catch-up and for the drain, each
59
+ * @property {(event: string, detail: object) => void} [onEvent] 'linked', 'sessions_end', 'caught_up', 'fenced', 'drained', 'flipped'
60
+ */
61
+
62
+ /**
63
+ * Moves app `slug` from `from` to `to` while it is written. Resolves to
64
+ * { fenceMs, head, sessionsEnd, site }: the time from the source refusing writes to the
65
+ * destination taking them, the source's last entry the destination holds, whether the two
66
+ * servers sign sessions differently (every session ended; always false on a move back,
67
+ * `link: false`), and the site pushed to the destination (MOVE-SITE; null when the app has none).
68
+ * @param {string} slug
69
+ * @param {MoveServer} from
70
+ * @param {MoveServer} to
71
+ * @param {LiveMoveOptions} [options]
72
+ */
73
+ export async function liveMove(slug, from, to, { link = true, timeoutMs = 60000, onEvent = () => {} } = {}) {
74
+ const { sync, status, readonly, take, mark, siteExport, pushSite } = calls(slug);
75
+
76
+ let sessionsEnd = false;
77
+ if (link) {
78
+ const made = await to.call('POST', '/v1/_tenants', { slug }, null);
79
+ if (made.status >= 300 && made.status !== 409)
80
+ throw new LiveMoveError(`making ${slug} on ${to.address}: ${made.status} ${made.text ?? ''}`.trim());
81
+ await readonly(to, true);
82
+ await sync(from, { action: 'set_node_config', advertise_address: from.address });
83
+ await sync(to, { action: 'set_node_config', advertise_address: to.address });
84
+ const info = await sync(from, { action: 'link_info' });
85
+ const destInfo = await sync(to, { action: 'link_info' });
86
+ await sync(to, { action: 'connect_peer', address: from.address, token: info.relay_token, pin: info.pin });
87
+ // Each server signs sessions with its own generated key: unless the two match, a session
88
+ // from the source is unreadable on the destination the moment it takes writes.
89
+ sessionsEnd = info.session_key_fingerprint !== destInfo.session_key_fingerprint;
90
+ onEvent('linked', {});
91
+ }
92
+
93
+ // The app's key ring, copied before the catch-up so the destination already checks exactly
94
+ // the keys the source does the moment the flip happens. Run on a move back too (link=false):
95
+ // a rotation may have happened since the ring was last copied there.
96
+ const ring = await sync(from, { action: 'key_ring' });
97
+ await sync(to, { action: 'set_key_ring', ring: ring.ring });
98
+ if (sessionsEnd) onEvent('sessions_end', {});
99
+
100
+ // MOVE-SITE: the source's site, carried before the flip (never replicated by the oplog/
101
+ // snapshot copy above). Read and pushed here, before catch-up, so a large site's transfer
102
+ // overlaps it rather than extending the fence.
103
+ let release = await siteExport(from);
104
+ let site = await pushSite(from, to, release);
105
+
106
+ // The source's node id, as the destination's version vector names it (8 hex digits).
107
+ const origin = ((await status(from)).node_id >>> 0).toString(16).padStart(8, '0');
108
+
109
+ const head = async () => BigInt((await status(from)).sync_stats.origin_seq);
110
+ // MOVE-IS-MESH: one call the destination answers the moment it holds the source's history to
111
+ // `target` and copies nothing (a snapshot, or the pull chains after it, holds nothing yet,
112
+ // whatever its version vector says), parked on its version vector: no status polling.
113
+ // `linked`: also until the source is a linked peer of the destination.
114
+ // The call itself is bounded too: a destination that stops answering (frozen, cut off) is a timeout here,
115
+ // which rolls the move back, never a wait without end.
116
+ const reach = async (target, what, linked = false) => {
117
+ const call = sync(to, { action: 'await_caught_up', origin, seq: Number(target), timeout_ms: timeoutMs, ...(linked ? { linked } : {}) });
118
+ let timer;
119
+ const silent = new Promise((done) => (timer = setTimeout(() => done(null), timeoutMs + 2000)));
120
+ const r = await Promise.race([call, silent]).finally(() => clearTimeout(timer));
121
+ if (r === null) {
122
+ call.catch(() => {}); // answered or refused later: nobody waits for it any more
123
+ throw new LiveMoveError(`timed out waiting for ${what}: ${to.address} did not answer`);
124
+ }
125
+ if (!r.held) throw new LiveMoveError(`timed out waiting for ${what}: ${r.at} of ${target}`);
126
+ };
127
+
128
+ // The link first: connect_peer returns before the connection is made (a TLS handshake across a WAN outlasts a
129
+ // whole drain), and until the source records, its head names nothing (0, or only the marker it takes when
130
+ // recording starts, which the destination reaches only by copying). The source turns recording on when it
131
+ // registers the destination, before it answers the link's auth, so a linked source records: one parked call.
132
+ await reach(0n, `${to.address} to link to ${from.address}`, true);
133
+ await reach(await head(), 'the copy to catch up');
134
+ onEvent('caught_up', {});
135
+
136
+ const fenceStart = process.hrtime.bigint();
137
+ await readonly(from, true);
138
+ onEvent('fenced', {});
139
+ let last = -1n;
140
+ try {
141
+ for (let now = await head(); now !== last; now = await head()) {
142
+ last = now;
143
+ await reach(now, `the drain to ${now}`);
144
+ }
145
+ onEvent('drained', { head: last });
146
+ // MOVE-SITE: a deploy to the source during the move (rare: the fence holds every other
147
+ // write) lands between the first export and here. The release id is the check; re-pushing
148
+ // only on a change keeps the common path at one export and one push.
149
+ const after = await siteExport(from);
150
+ if (after?.id !== release?.id) site = await pushSite(from, to, after);
151
+ await take(to);
152
+ } catch (err) {
153
+ // The app keeps exactly one primary: the source takes writes again.
154
+ const back = await from.call('PATCH', `/v1/_tenants/${encodeURIComponent(slug)}`, { readonly: false }, null);
155
+ throw new LiveMoveError(`${err.message}; rolled back: ${from.address} takes writes again (${back.status})`, {
156
+ rolledBack: back.status < 300,
157
+ });
158
+ }
159
+ const fenceMs = Number(process.hrtime.bigint() - fenceStart) / 1e6;
160
+ onEvent('flipped', { fenceMs });
161
+ // After the flip, never inside the rollback: the app has its one primary whatever this answers.
162
+ if (to.url) {
163
+ try {
164
+ await mark(from, to.url);
165
+ } catch (err) {
166
+ throw new LiveMoveError(`${to.address} takes writes; marking ${from.address} moved failed: ${err.message}`);
167
+ }
168
+ }
169
+ return { fenceMs, head: last, sessionsEnd, site };
170
+ }
171
+
172
+ /**
173
+ * Promotes `to`, a replica of `slug` that follows `from`, when `from` no longer answers.
174
+ * A source that answers within `probeMs` is not failed over: the call is a planned move instead
175
+ * (liveMove with the link already up), which loses nothing. Otherwise `to` must hold `from` as a
176
+ * peer (matched by address, or by `origin`, its node id in 8 hex digits) and must not be copying
177
+ * (a replica mid-copy lacks what the copy has not brought); then it takes writes.
178
+ * The source cannot be fenced: whatever it acknowledged past `held` (its last entry `to` holds in
179
+ * order) is on it alone, and it must be set read-only before it serves the app again; if it comes
180
+ * back still linked, those writes reach `to` over the link like any other.
181
+ * Resolves to { mode: 'move', fenceMs, head } or { mode: 'failover', held, peer }.
182
+ * @param {string} slug
183
+ * @param {MoveServer} from
184
+ * @param {MoveServer} to
185
+ * @param {LiveMoveOptions & { probeMs?: number, origin?: string }} [options]
186
+ */
187
+ export async function failover(slug, from, to, { probeMs = 2000, origin, ...moveOptions } = {}) {
188
+ if (await answers(from, slug, probeMs)) {
189
+ return { mode: 'move', ...(await liveMove(slug, from, to, { ...moveOptions, link: false })) };
190
+ }
191
+ const { status, take } = calls(slug);
192
+ const s = await status(to);
193
+ const peer = (s.peers ?? []).find((p) => (origin ? p.node_id === origin : p.address === from.address));
194
+ if (!peer) throw new LiveMoveError(`${to.address} does not follow ${from.address} for ${slug}: it holds no copy to promote`);
195
+ if (s.catching_up) {
196
+ throw new LiveMoveError(`${to.address} is still copying ${slug} from ${from.address}: promoting it would lose what the copy has not brought`);
197
+ }
198
+ const held = BigInt(s.version_vector?.[peer.node_id] ?? 0);
199
+ await take(to);
200
+ (moveOptions.onEvent ?? (() => {}))('promoted', { held });
201
+ return { mode: 'failover', held, peer };
202
+ }
203
+
204
+ // The calls of one move or failover of `slug`: every refusal a LiveMoveError naming the server.
205
+ function calls(slug) {
206
+ const must = async (server, method, path, body, tenant, what) => {
207
+ const r = await server.call(method, path, body, tenant);
208
+ if (r.status >= 300) throw new LiveMoveError(`${what} on ${server.address}: ${r.status} ${r.text ?? ''}`.trim());
209
+ return r.json;
210
+ };
211
+ const tenant = (server, body, what) => must(server, 'PATCH', `/v1/_tenants/${encodeURIComponent(slug)}`, body, null, what);
212
+ const sync = (server, body) => must(server, 'POST', '/v1/_sync', body, slug, body.action);
213
+ // MOVE-SITE: `server`'s active site release, in site.release.create's own manifest shape
214
+ // ({id, files: [{path, hash, size}]}), or null when the app has none. Reads the stored
215
+ // manifest only (substrio_site.cpp's SiteStore::active_release): no file's bytes.
216
+ const siteExport = async (server) => (await sync(server, { action: 'site_export' })).release;
217
+ // MOVE-SITE: pushes `release` (siteExport's shape) from `from` to `to` through the same op
218
+ // `substrio site deploy` uses (site.release.create, then site.blob.upload for each blob `to`
219
+ // lacks, then site.release.activate). A blob's bytes are read from `from` once, by the path
220
+ // its release names (its own static route: no second reader of a site file), and sent on;
221
+ // null `release`: nothing to push. Resolves to { release, files, bytes } for the report, or
222
+ // null.
223
+ const pushSite = async (from, to, release) => {
224
+ if (!release) return null;
225
+ const created = await must(to, 'POST', '/v1/_site/releases', { files: release.files }, slug, 'site.release.create');
226
+ for (const hash of created.missing) {
227
+ const file = release.files.find((f) => f.hash === hash);
228
+ const path = `/_/${file.path.split('/').map(encodeURIComponent).join('/')}`;
229
+ const read = await from.call('GET', path, undefined, slug);
230
+ if (read.status >= 300) throw new LiveMoveError(`reading ${file.path} on ${from.address}: ${read.status} ${read.text ?? ''}`.trim());
231
+ const bytes = read.bytes ?? Buffer.from(read.text ?? '', 'utf8');
232
+ await must(to, 'PUT', `/v1/_site/blobs/${hash}`, bytes, slug, `uploading ${file.path}`);
233
+ }
234
+ const activated = await must(to, 'POST', `/v1/_site/releases/${created.release.id}/activate`, {}, slug, 'site.release.activate');
235
+ return { release: activated.active, files: release.files.length, bytes: release.files.reduce((sum, f) => sum + f.size, 0) };
236
+ };
237
+ return {
238
+ sync,
239
+ status: (server) => must(server, 'GET', '/v1/_sync', undefined, slug, 'sync status'),
240
+ readonly: (server, value) => tenant(server, { readonly: value }, `readonly ${value}`),
241
+ // The server becomes the app's primary: it takes writes and is no longer marked moved.
242
+ take: (server) => tenant(server, { readonly: false, moved_to: null }, 'readonly false'),
243
+ // The old primary names where the app lives now (MOVE-ROUTING).
244
+ mark: (server, url) => tenant(server, { moved_to: url }, `moved_to ${url}`),
245
+ siteExport,
246
+ pushSite,
247
+ };
248
+ }
249
+
250
+ // Whether `server` answers a sync status read for `slug` within `ms` (a refused connection, an
251
+ // error answer and silence are all "no").
252
+ async function answers(server, slug, ms) {
253
+ let timer;
254
+ const silence = new Promise((done) => (timer = setTimeout(() => done(false), ms)));
255
+ const reply = server.call('GET', '/v1/_sync', undefined, slug).then((r) => r.status < 300, () => false);
256
+ try {
257
+ return await Promise.race([reply, silence]);
258
+ } finally {
259
+ clearTimeout(timer);
260
+ }
261
+ }
262
+
263
+ /** host:port of a server URL, with the scheme's port when the URL names none. */
264
+ export function addressOf(url) {
265
+ const u = new URL(url);
266
+ return `${u.hostname}:${u.port || (u.protocol === 'https:' ? 443 : 80)}`;
267
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Substrio CLI: the flags of `substrio dev` and `substrio test`, parsed once against a
3
+ * closed table. A flag the command does not take, or a value flag with no
4
+ * value, is a typed usage error; nothing is guessed.
5
+ */
6
+
7
+ import { CliError, ErrorCode } from '../cli.mjs';
8
+
9
+ /** How a flag takes its value. */
10
+ export const FlagKind = Object.freeze({
11
+ SWITCH: 'switch', // present or not
12
+ VALUE: 'value', // the next argument, or `--flag=value`
13
+ });
14
+
15
+ /**
16
+ * @param {string[]} args
17
+ * @param {Record<string, string>} table flag name -> FlagKind
18
+ * @returns {{flags: Map<string, string|true>, positional: string[]}}
19
+ */
20
+ export function parseFlags(args, table) {
21
+ const flags = new Map();
22
+ const positional = [];
23
+ for (let i = 0; i < args.length; i++) {
24
+ const arg = args[i];
25
+ if (!arg.startsWith('--')) {
26
+ positional.push(arg);
27
+ continue;
28
+ }
29
+ const equals = arg.indexOf('=');
30
+ const name = equals < 0 ? arg : arg.slice(0, equals);
31
+ const kind = table[name];
32
+ if (kind === undefined) {
33
+ throw new CliError(`Unknown flag ${name}`, { code: ErrorCode.INVALID_ARGUMENT, hint: `It takes ${Object.keys(table).join(', ')}` });
34
+ }
35
+ if (kind === FlagKind.SWITCH) {
36
+ if (equals >= 0) throw new CliError(`${name} takes no value`, { code: ErrorCode.INVALID_ARGUMENT });
37
+ flags.set(name, true);
38
+ continue;
39
+ }
40
+ const value = equals >= 0 ? arg.slice(equals + 1) : args[++i];
41
+ if (value === undefined || value === '') {
42
+ throw new CliError(`${name} needs a value`, { code: ErrorCode.INVALID_ARGUMENT });
43
+ }
44
+ flags.set(name, value);
45
+ }
46
+ return { flags, positional };
47
+ }
48
+
49
+ /** A whole number flag in [low, high], or `fallback` when absent. */
50
+ export function wholeNumber(flags, name, low, high, fallback) {
51
+ const text = flags.get(name);
52
+ if (text === undefined) return fallback;
53
+ const value = Number(text);
54
+ if (!Number.isInteger(value) || value < low || value > high) {
55
+ throw new CliError(`${name} must be a whole number from ${low} to ${high}`, { code: ErrorCode.INVALID_ARGUMENT });
56
+ }
57
+ return value;
58
+ }