@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
package/src/jsonfile.mjs
ADDED
|
@@ -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
|
+
}
|