@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,35 @@
1
+ /**
2
+ * Substrio CLI: open command
3
+ *
4
+ * Usage:
5
+ * substrio open Open server URL in default browser
6
+ */
7
+
8
+ import { exec } from 'node:child_process';
9
+
10
+ const C = {
11
+ reset: '\x1b[0m', dim: '\x1b[2m', green: '\x1b[92m', cyan: '\x1b[96m',
12
+ };
13
+
14
+ export function cmdOpen(config) {
15
+ const url = config.url;
16
+ if (!url) {
17
+ console.error('\x1b[91m✗ Server URL not configured.\x1b[0m');
18
+ process.exit(1);
19
+ }
20
+
21
+ console.log(`${C.green}✓${C.reset} Opening ${C.cyan}${url}${C.reset}`);
22
+
23
+ // Platform-specific open command
24
+ const platform = process.platform;
25
+ let cmd;
26
+ if (platform === 'darwin') cmd = `open "${url}"`;
27
+ else if (platform === 'win32') cmd = `start "${url}"`;
28
+ else cmd = `xdg-open "${url}" 2>/dev/null || sensible-browser "${url}" 2>/dev/null || echo "Open ${url} in your browser"`;
29
+
30
+ exec(cmd, (err) => {
31
+ if (err) {
32
+ console.log(`${C.dim}Open ${url} in your browser.${C.reset}`);
33
+ }
34
+ });
35
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Substrio CLI: `substrio push`, a new version of the app, run in a sandbox.
3
+ *
4
+ * substrio push [-m "message"] [--branch NAME] [--no-build] [--dry-run] [--sandbox NAME]
5
+ * [--concurrency N] [--replace-direct | --keep-direct]
6
+ *
7
+ * --dry-run builds (unless --no-build), shows what the version would hold
8
+ * and stops without contacting the server.
9
+ *
10
+ * 1. Runs substrio.toml's [build] steps in order, their output streamed; the
11
+ * first that fails stops the push.
12
+ * 2. Collects the functions, rules, MCP tools and the site (its parts merged
13
+ * by path, later parts winning), and hashes every file. The collections
14
+ * the app declares (collections_dir, src/collections.mjs) go in the version
15
+ * too: their rules join the rules, their schemas ride as its "collections"
16
+ * member (a server whose versions do not take it refuses the push, naming
17
+ * that, before anything is sent).
18
+ * 3. POST /v1/_versions; uploads only the blobs the server lacks.
19
+ * 4. Runs the version in the sandbox (default dev), creating it if missing,
20
+ * and prints its address. `substrio promote` then makes it live. A function
21
+ * deployed directly in the sandbox (outside a version) that this would
22
+ * overwrite or delete refuses (409) unless --replace-direct or
23
+ * --keep-direct was given.
24
+ * The version carries the app's settings substrio.toml declares ([auth],
25
+ * plans_file; src/settings.mjs), so they go live with it: in the sandbox on
26
+ * push, in the app on promote. Their keys are checked against the server's
27
+ * operation schemas before step 3, so an unknown key sends nothing.
28
+ */
29
+
30
+ import { loadApp, makeLive, publishVersion, readManifest, runBuild } from '../apply.mjs';
31
+ import { DEFAULT_CONCURRENCY, formatSize } from '../blobs.mjs';
32
+ import { invokeOperation, loadOperations } from './op.mjs';
33
+ import { emit, fail } from '../cli.mjs';
34
+
35
+ const C = {
36
+ reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
37
+ red: '\x1b[91m', green: '\x1b[92m', yellow: '\x1b[93m', cyan: '\x1b[96m',
38
+ };
39
+
40
+ export const DEFAULT_SANDBOX = 'dev';
41
+
42
+
43
+ export function flag(args, names, fallback) {
44
+ for (const name of [].concat(names)) {
45
+ const i = args.indexOf(name);
46
+ if (i >= 0 && i + 1 < args.length) return args[i + 1];
47
+ }
48
+ return fallback;
49
+ }
50
+
51
+ /**
52
+ * --replace-direct / --keep-direct, as version.live.set's `direct`: replace
53
+ * or keep a function deployed directly (outside a version) since the target's
54
+ * live version was recorded, instead of refusing (the server's default).
55
+ * Only one may be given. Shared by push, promote and rollback.
56
+ */
57
+ export function directMode(args) {
58
+ const replace = args.includes('--replace-direct');
59
+ const keep = args.includes('--keep-direct');
60
+ if (replace && keep) fail('--replace-direct and --keep-direct cannot both be given');
61
+ return replace ? 'replace' : keep ? 'keep' : undefined;
62
+ }
63
+
64
+ // What a dry run shows of the version body: every function with its routes,
65
+ // the rules and tools, cron, triggers, queues and the site's size.
66
+ function printBody(body, app, collections) {
67
+ console.log(`\n${C.bold}Functions${C.reset}`);
68
+ if (body.functions.length === 0) console.log(` ${C.dim}none${C.reset}`);
69
+ for (const f of body.functions) {
70
+ const vars = f.vars ? Object.keys(f.vars).length : 0;
71
+ console.log(` ${C.bold}${f.name}${C.reset} ${C.dim}(${formatSize(f.size)}${f.main ? `, main ${f.main}` : ''}` +
72
+ `${vars ? `, ${vars} var${vars === 1 ? '' : 's'}` : ''})${C.reset}`);
73
+ for (const route of f.routes || []) console.log(` ${route}`);
74
+ }
75
+ if (app.rules) console.log(`${C.bold}Rules${C.reset} ${app.rules.path} ${C.dim}(${formatSize(app.rules.size)})${C.reset}`);
76
+ if (app.mcp) console.log(`${C.bold}MCP tools${C.reset} ${app.mcp.path} ${C.dim}(${formatSize(app.mcp.size)})${C.reset}`);
77
+ if (collections.length) {
78
+ console.log(`${C.bold}Collections${C.reset}`);
79
+ for (const c of collections) {
80
+ console.log(` ${c.name} ${C.dim}(${c.file}${c.schema ? ', schema' : ''}${c.rules ? ', rules' : ''}${c.indexes ? `, ${c.indexes.length} indexes` : ''}${c.aggregates ? `, ${c.aggregates.length} aggregates` : ''})${C.reset}`);
81
+ }
82
+ }
83
+ if (body.cron.length) {
84
+ console.log(`${C.bold}Cron${C.reset}`);
85
+ for (const c of body.cron) console.log(` ${c.function} at "${c.expression}"`);
86
+ }
87
+ if (body.triggers.length) {
88
+ console.log(`${C.bold}Triggers${C.reset}`);
89
+ for (const t of body.triggers) console.log(` ${t.function} on ${t.type} in ${t.collection}`);
90
+ }
91
+ if (body.queues.length) {
92
+ console.log(`${C.bold}Queues${C.reset}`);
93
+ for (const q of body.queues) console.log(` ${q.name}${q.consumer ? ` consumed by ${q.consumer}` : ''}`);
94
+ }
95
+ if (app.site) {
96
+ const bytes = app.site.files.reduce((sum, f) => sum + f.size, 0);
97
+ console.log(`${C.bold}Site${C.reset} ${app.site.files.length} files, ${formatSize(bytes)}` +
98
+ `${app.site.sidecars ? ` ${C.dim}(${app.site.sidecars} precompressed sidecars)${C.reset}` : ''}` +
99
+ `${app.site.replaced.length ? `, ${app.site.replaced.length} replaced by a later part` : ''}`);
100
+ }
101
+ }
102
+
103
+ /**
104
+ * The sandbox `name`, made if the app does not have it yet:
105
+ * {name, slug, hosts, live, pin}. A sandbox push makes is a dev environment:
106
+ * the app but for its own secrets, its outbound requests sent as the app's
107
+ * are ("outbound": "live"), so every part of a version is tried there.
108
+ */
109
+ export async function ensureSandbox(api, name, ops = null) {
110
+ const data = await invokeOperation(api, 'sandbox.list', undefined, { ops });
111
+ const found = (data.sandboxes || []).find((s) => s.name === name);
112
+ if (found) return found;
113
+ const made = await invokeOperation(api, 'sandbox.create', { name, outbound: 'live' }, { ops });
114
+ console.log(` ${C.dim}created sandbox ${name}${C.reset}`);
115
+ return made.sandbox;
116
+ }
117
+
118
+ export async function cmdPush(api, config, args) {
119
+ const cwd = process.cwd();
120
+ const message = flag(args, ['-m', '--message']);
121
+ const branch = flag(args, '--branch');
122
+ const sandbox = flag(args, '--sandbox', DEFAULT_SANDBOX);
123
+ const concurrency = Math.max(1, parseInt(flag(args, '--concurrency', DEFAULT_CONCURRENCY), 10));
124
+ const dryRun = args.includes('--dry-run');
125
+ const direct = directMode(args);
126
+
127
+ console.log(`\n${C.bold}${C.cyan}⚡ Substrio Push${C.reset}${dryRun ? ` ${C.yellow}(dry run)${C.reset}` : ''}`);
128
+ console.log(`${C.dim} Server: ${config.url}${C.reset}`);
129
+ console.log(`${C.dim} Tenant: ${config.tenant}${C.reset}\n`);
130
+
131
+ let loaded;
132
+ try {
133
+ const manifest = readManifest(config);
134
+ if (!args.includes('--no-build') && manifest.build.length > 0) {
135
+ runBuild(manifest.build, cwd, (step) =>
136
+ console.log(`${C.bold}== build${C.reset} ${C.dim}(${step.dir})${C.reset} ${step.run}`));
137
+ console.log('');
138
+ }
139
+ loaded = await loadApp(config, manifest, cwd, message);
140
+ } catch (err) {
141
+ fail(err);
142
+ }
143
+ // --branch is a request option of version.create, not a part of the app
144
+ // the manifest describes: the version's content (and so its hash) is the
145
+ // same whichever branch it is committed on.
146
+ if (branch) loaded.body.branch = branch;
147
+ const { app, settings, plans, collections, body, byHash } = loaded;
148
+ const total = [...byHash.values()].reduce((sum, f) => sum + f.size, 0);
149
+ const siteFiles = app.site?.files.length ?? 0;
150
+ console.log(` ${app.functions.length} function${app.functions.length === 1 ? '' : 's'}` +
151
+ `${app.rules ? ', rules' : ''}${app.mcp ? ', MCP tools' : ''}` +
152
+ `${collections.length ? `, ${collections.length} collection${collections.length === 1 ? '' : 's'}` : ''}` +
153
+ `, ${siteFiles} site file${siteFiles === 1 ? '' : 's'}` +
154
+ `, ${body.cron.length} cron, ${body.triggers.length} trigger${body.triggers.length === 1 ? '' : 's'}` +
155
+ `${body.queues.length ? `, ${body.queues.length} queue${body.queues.length === 1 ? '' : 's'}` : ''}` +
156
+ ` ${C.dim}(${byHash.size} blobs, ${formatSize(total)})${C.reset}`);
157
+ for (const s of settings) console.log(` ${s.label} [${s.table}] ${Object.keys(s.values).join(', ')}`);
158
+ if (plans !== null) console.log(` Plans from ${config.plans_file} (${plans.plans?.length ?? 0})`);
159
+ if (app.site?.replaced.length) {
160
+ const shown = app.site.replaced.slice(0, 5).join(', ');
161
+ const more = app.site.replaced.length > 5 ? ` and ${app.site.replaced.length - 5} more` : '';
162
+ console.log(` ${C.yellow}⚠ ${app.site.replaced.length} site path${app.site.replaced.length === 1 ? '' : 's'} given by more than one part; the later part wins: ${shown}${more}${C.reset}`);
163
+ }
164
+ if (app.site?.sidecars) console.log(` ${C.dim}precompressed: ${app.site.sidecars} .br/.gz sidecars made from the merged site${C.reset}`);
165
+
166
+ if (dryRun) {
167
+ printBody(body, app, collections);
168
+ console.log(`\n${C.yellow}⦿${C.reset} Dry run: nothing was sent to the server.\n`);
169
+ return;
170
+ }
171
+
172
+ // 1. The settings tables' keys, checked against the server's schemas; the
173
+ // version, the server answering with the blobs it lacks; only those uploaded.
174
+ const ops = await loadOperations(api);
175
+ const invoke = (name, input) => invokeOperation(api, name, input, { ops });
176
+ let published;
177
+ try {
178
+ published = await publishVersion(api, ops, loaded, invoke, concurrency, { carryCollections: true });
179
+ } catch (err) {
180
+ fail(err);
181
+ }
182
+ const { result, number, missing, uploaded } = published;
183
+ console.log(` uploaded ${uploaded.count} new file${uploaded.count === 1 ? '' : 's'} (${formatSize(uploaded.bytes)}), ` +
184
+ `${byHash.size - uploaded.count} already on the server`);
185
+ console.log(`\n${C.green}${C.bold}✓ Version ${number}${C.reset}` +
186
+ `${result.existed ? ` ${C.dim}(existed: the same content was already version ${number})${C.reset}` : ''}`);
187
+
188
+ // 3. Run it in the sandbox.
189
+ const box = await ensureSandbox(api, sandbox, ops);
190
+ const set = await makeLive(invoke, number, { sandbox, direct });
191
+ console.log(`${C.green}✓${C.reset} ${C.bold}${sandbox}${C.reset} runs version ${number}` +
192
+ `${set.previous ? ` ${C.dim}(was ${set.previous})${C.reset}` : ''}`);
193
+ for (const host of box.hosts || []) console.log(` ${C.cyan}${host}${C.reset}`);
194
+ if (!box.hosts?.length) console.log(` ${C.dim}(no address yet: the server has no domain for sandboxes)${C.reset}`);
195
+
196
+ console.log(`${C.dim} Make it live with: substrio promote${sandbox === DEFAULT_SANDBOX ? '' : ` ${number}`}${C.reset}\n`);
197
+ // --json: the version the server made, what was uploaded, the sandbox and what it runs.
198
+ emit({ version: result.version, existed: Boolean(result.existed), missing, uploaded, sandbox: box, live: set }, () => {});
199
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Substrio CLI: `substrio signup` — an outside developer or an AI agent with the
3
+ * CLI alone, no master key.
4
+ *
5
+ * Usage:
6
+ * substrio signup --url URL [--tenant SLUG] [--email E] [--code CODE | --password -]
7
+ * [--account NAME] [--save-profile NAME] [--print]
8
+ *
9
+ * SLUG is the identity app: an operator sets one up once (a tenant, then
10
+ * `platform.setting.set identity_tenant=SLUG`) and tells developers its
11
+ * slug; without it there is no self-serve signup (ask your operator, or pass
12
+ * the slug you were given).
13
+ *
14
+ * The email proves the person (NIGHT-ONBOARD): with no --code the server mails a
15
+ * six-digit code to E (auth.email.code_request). With a terminal the code is asked for
16
+ * at once; with none (an AI agent) the command says the exact next command, and
17
+ * `--code CODE` (or SUBSTRIO_SIGNUP_CODE) finishes: auth.email.code_verify proves the
18
+ * mailbox and signs the person in, account.create makes their account,
19
+ * account.key.create its first key. Reading the code from the mail is the one
20
+ * human step. --password - (the password read from stdin, never a plain argument,
21
+ * which a `ps` or the shell history would show) also sets a password (auth.register
22
+ * first); the email is proven by the code all the same. SUBSTRIO_SIGNUP_EMAIL, SUBSTRIO_SIGNUP_PASSWORD and
23
+ * SUBSTRIO_SIGNUP_ACCOUNT fill in the same fields for a run with no terminal.
24
+ *
25
+ * The key is saved to a profile exactly as `substrio login` saves one (same
26
+ * owner-only file, same writeProfile); "substrio app create" and "substrio key ..."
27
+ * take it from there. The key is shown only with --print; otherwise the
28
+ * command says where it went. A person with a second factor signs in with
29
+ * `substrio login`, never by a code alone.
30
+ */
31
+
32
+ import { CliError, Prompter, emitIssuedKey, emitLine, fail, globals } from '../cli.mjs';
33
+ import { listProfiles, writeProfile } from '../config.mjs';
34
+ import { SubstrioAPI, errorText } from '../api.mjs';
35
+ import { OpError, callOperation, loadOperations } from './op.mjs';
36
+
37
+ const C = {
38
+ reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m', red: '\x1b[91m', green: '\x1b[92m', cyan: '\x1b[96m',
39
+ };
40
+
41
+ const USAGE = 'substrio signup --url URL --tenant SLUG [--email E] [--code CODE | --password -] [--account NAME] [--save-profile NAME] [--print]';
42
+
43
+ // The whole of stdin, one trailing newline trimmed (never printed, never logged).
44
+ async function readStdin() {
45
+ const chunks = [];
46
+ for await (const chunk of process.stdin) chunks.push(chunk);
47
+ return Buffer.concat(chunks).toString('utf-8').replace(/\r?\n$/, '');
48
+ }
49
+
50
+ // A sharper hint than the server's own message, for the signup flow's three
51
+ // calls; null falls back to a generic one.
52
+ function hintFor(step, result) {
53
+ const message = errorText(result.data, result.status);
54
+ if (step === 'auth.register' && /already registered/i.test(message)) {
55
+ return 'an account with this email exists already; run "substrio login" instead';
56
+ }
57
+ if (step === 'auth.register' && /password/i.test(message)) {
58
+ return message; // the server's message is the rule itself
59
+ }
60
+ if ((step === 'account.create' || step === 'account.key.create') && result.status === 401) {
61
+ return 'the server has no identity app configured (an operator runs: substrio op platform.setting.set key=identity_tenant value=SLUG, on a tenant that exists), or --tenant named the wrong one';
62
+ }
63
+ if (step === 'account.app.create' && result.status === 409) {
64
+ return 'that app name is taken; choose another';
65
+ }
66
+ return null;
67
+ }
68
+
69
+ async function step(api, ops, name, input, headers) {
70
+ let result;
71
+ try {
72
+ result = await callOperation(api, ops, name, input, null, { headers });
73
+ } catch (err) {
74
+ if (err instanceof OpError) return fail(err, `substrio op ${name} --help shows its input`);
75
+ throw err;
76
+ }
77
+ if (!result.ok) {
78
+ fail(CliError.fromResponse(`${name}: ${errorText(result.data, result.status)}`, result.data, result.status), hintFor(name, result));
79
+ }
80
+ return result.data;
81
+ }
82
+
83
+ export async function cmdSignup(args) {
84
+ let url = null, tenant = globals.tenant || process.env.SUBSTRIO_TENANT || null;
85
+ let email = null, password = null, code = null, accountName = null, profileName = null;
86
+ const print = args.includes('--print');
87
+ for (let i = 0; i < args.length; i++) {
88
+ const arg = args[i];
89
+ if (arg === '--print') continue;
90
+ else if (arg === '--url' && args[i + 1]) url = args[++i];
91
+ else if (arg === '--email' && args[i + 1]) email = args[++i];
92
+ else if (arg === '--code' && args[i + 1]) code = args[++i];
93
+ else if (arg === '--password' && args[i + 1]) password = args[++i];
94
+ else if (arg === '--account' && args[i + 1]) accountName = args[++i];
95
+ else if (arg === '--save-profile' && args[i + 1]) profileName = args[++i];
96
+ else fail(`unexpected argument "${arg}"`, USAGE);
97
+ }
98
+ if (password !== null && password !== '-') {
99
+ fail('--password takes "-" (the password read from stdin), never a plain argument', `substrio signup --password - < password.txt, or SUBSTRIO_SIGNUP_PASSWORD`);
100
+ }
101
+ if (password === '-') password = await readStdin();
102
+ password = password ?? process.env.SUBSTRIO_SIGNUP_PASSWORD ?? null;
103
+ code = code ?? process.env.SUBSTRIO_SIGNUP_CODE ?? null;
104
+ email = email ?? process.env.SUBSTRIO_SIGNUP_EMAIL ?? null;
105
+ accountName = accountName ?? process.env.SUBSTRIO_SIGNUP_ACCOUNT ?? null;
106
+ if (password && code) fail('--code and --password are two ways in; pass one', USAGE);
107
+
108
+ const prompter = new Prompter();
109
+ try {
110
+ try {
111
+ if (!url) url = await prompter.ask(' Server URL (e.g. http://localhost:9000)', '', '--url');
112
+ if (!tenant) tenant = await prompter.ask(' Identity app (ask your operator for its slug)', '', '--tenant');
113
+ if (!email) email = await prompter.ask(' Email', '', '--email');
114
+ if (!accountName) accountName = await prompter.ask(' Account name', (email.split('@')[0] || 'account'));
115
+ } catch (err) {
116
+ fail(err, 'substrio signup --url URL --tenant SLUG --email E');
117
+ }
118
+ if (!url || !tenant || !email) fail(`Usage: ${USAGE}`);
119
+ url = url.replace(/\/+$/, '');
120
+ profileName = profileName || accountName;
121
+ if (listProfiles().some((p) => p.name === profileName)) {
122
+ fail(`A profile named "${profileName}" already exists; pass --save-profile with another name.`);
123
+ }
124
+ const api = new SubstrioAPI({ url, tenant, master_key: null });
125
+ let ops;
126
+ try {
127
+ ops = await loadOperations(api);
128
+ } catch (err) {
129
+ return fail(err, `nothing was created; check the server first: substrio status --url ${url}`);
130
+ }
131
+
132
+ // A password registers the person; either way the email is proven by the mailed code
133
+ // before an account is made (the server refuses account.create until it is).
134
+ if (password) await step(api, ops, 'auth.register', { email, password }, {});
135
+ let session;
136
+ {
137
+ if (!code) {
138
+ await step(api, ops, 'auth.email.code_request', { email }, {});
139
+ const next = `substrio signup --url ${url} --tenant ${tenant} --email ${email} --account ${JSON.stringify(accountName)} --code CODE`;
140
+ if (!prompter.interactive) {
141
+ emitLine({ sent: true, email, next }, () => {
142
+ console.error(`${C.green}✓${C.reset} A code is on its way to ${C.bold}${email}${C.reset}.`);
143
+ console.error(`${C.dim} Finish with: ${next}${C.reset}`);
144
+ });
145
+ return;
146
+ }
147
+ code = await prompter.ask(` The code mailed to ${email}`, '', '--code');
148
+ }
149
+ session = await step(api, ops, 'auth.email.code_verify', { email, code }, {});
150
+ if (!session.token) {
151
+ fail(`${email} has a second factor: a code alone signs no one in past it`, `substrio login --url ${url} --tenant ${tenant}`);
152
+ }
153
+ }
154
+ const bearer = { Authorization: `Bearer ${session.token}` };
155
+ const account = await step(api, ops, 'account.create', { name: accountName }, bearer);
156
+ const key = await step(api, ops, 'account.key.create', { id: account.id, name: 'cli', mode: 'live' }, bearer);
157
+ writeProfile(profileName, url, key.key, null);
158
+ emitIssuedKey({ account_id: account.id, account: account.name, profile: profileName, url, key: key.key }, print);
159
+ console.error(`${C.green}✓${C.reset} Account ${C.bold}${account.name}${C.reset} (id ${account.id}) created; its key is saved to profile ${C.bold}${profileName}${C.reset}.`);
160
+ console.error(`${C.dim} "substrio app create NAME" makes your first app; "substrio whoami" shows this profile.${C.reset}`);
161
+ } finally {
162
+ prompter.close();
163
+ }
164
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Substrio CLI: site command: deploy a tenant's website as a release
3
+ *
4
+ * Usage:
5
+ * substrio site deploy <dir> [--dry-run] [--concurrency N]
6
+ * substrio site releases
7
+ * substrio site activate <release-id> (also rolls back)
8
+ *
9
+ * deploy hashes every file under <dir>, sends the manifest, uploads only the
10
+ * files the server does not already hold, then activates the release. The
11
+ * server switches the site in one step, so visitors see the old site or the
12
+ * new one, never a mix.
13
+ */
14
+
15
+ import { statSync } from 'node:fs';
16
+
17
+ import { DEFAULT_CONCURRENCY, collect, formatSize, ignoredNames, uploadBlobs } from '../blobs.mjs';
18
+ import { ErrorCode, emit, fail } from '../cli.mjs';
19
+ import { cmdRegistry } from './groups-runtime.mjs';
20
+ import { callOrThrow, invokeOperation, loadOperations, runOperation } from './op.mjs';
21
+
22
+ const C = {
23
+ reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
24
+ red: '\x1b[91m', green: '\x1b[92m', yellow: '\x1b[93m', cyan: '\x1b[96m',
25
+ };
26
+
27
+ export async function cmdSite(api, config, args) {
28
+ const sub = args[0];
29
+ switch (sub) {
30
+ case 'deploy': return siteDeploy(api, config, args.slice(1));
31
+ case 'releases': case 'list': return runOperation(api, 'site.release.list', args.slice(1), { render: printReleases });
32
+ case 'activate': case 'rollback': return siteActivate(api, args[1]);
33
+ default:
34
+ // Any other site.* operation by its words (substrio site release create).
35
+ if (sub && !sub.startsWith('-')) return cmdRegistry(api, 'site', args);
36
+ fail(`Unknown subcommand: ${sub || '(none)'}`,
37
+ 'Usage: substrio site deploy <dir> | releases | activate <release-id>');
38
+ }
39
+ }
40
+
41
+
42
+ function flag(args, name, fallback) {
43
+ const i = args.indexOf(name);
44
+ return i >= 0 && i + 1 < args.length ? args[i + 1] : fallback;
45
+ }
46
+
47
+ async function siteDeploy(api, config, args) {
48
+ const dir = args.find((a) => !a.startsWith('--') && a !== flag(args, '--concurrency'));
49
+ if (!dir) fail('Site directory required.', 'Usage: substrio site deploy <dir>');
50
+ let isDir = false;
51
+ try { isDir = statSync(dir).isDirectory(); } catch { /* reported below */ }
52
+ if (!isDir) fail(`Not a directory: ${dir}`, null, ErrorCode.FILE_NOT_FOUND);
53
+
54
+ const dryRun = args.includes('--dry-run');
55
+ const concurrency = Math.max(1, parseInt(flag(args, '--concurrency', DEFAULT_CONCURRENCY), 10));
56
+
57
+ console.log(`\n${C.bold}${C.cyan}⚡ Substrio Site Deploy${C.reset}${dryRun ? ` ${C.yellow}(dry run)${C.reset}` : ''}`);
58
+ console.log(`${C.dim} Server: ${config.url}${C.reset}`);
59
+ console.log(`${C.dim} Tenant: ${config.tenant}${C.reset}\n`);
60
+
61
+ const files = collect(dir, ignoredNames(config));
62
+ if (files.length === 0) fail(`No files in ${dir}`);
63
+ const total = files.reduce((sum, f) => sum + f.size, 0);
64
+ console.log(` ${files.length} files, ${formatSize(total)}`);
65
+ if (dryRun) {
66
+ for (const f of files.slice(0, 20)) console.log(` ${C.dim}${f.path} (${formatSize(f.size)})${C.reset}`);
67
+ if (files.length > 20) console.log(` ${C.dim}... and ${files.length - 20} more${C.reset}`);
68
+ return;
69
+ }
70
+
71
+ let published;
72
+ try {
73
+ published = await publishSite(api, await loadOperations(api), files, concurrency);
74
+ } catch (err) {
75
+ fail(err);
76
+ }
77
+ const { release, activated } = published;
78
+ const id = release.id;
79
+ console.log(` ${uploadedLine(published, files.length)}`);
80
+
81
+ // --json prints the release, what was uploaded and the activation.
82
+ emit(published, () => {
83
+ console.log(`\n${C.green}${C.bold}✓ Release ${id} is live.${C.reset}`);
84
+ if (activated.previous) {
85
+ console.log(`${C.dim} Previous release: ${activated.previous} (roll back with: substrio site activate ${activated.previous})${C.reset}`);
86
+ }
87
+ console.log('');
88
+ });
89
+ }
90
+
91
+ /**
92
+ * A site release from collected files (blobs.mjs collect), the one way both `substrio site
93
+ * deploy` and `substrio deploy` publish a site: the manifest (the server answers with the
94
+ * hashes it lacks), only those blobs uploaded `concurrency` at a time, then the one switch.
95
+ * Throws the first refusal (a CliError, never the end of the run); prints nothing.
96
+ */
97
+ export async function publishSite(api, ops, files, concurrency = DEFAULT_CONCURRENCY) {
98
+ const manifest = { files: files.map(({ path, hash, size }) => ({ path, hash, size })) };
99
+ const release = await callOrThrow(api, ops, 'site.release.create', manifest);
100
+ const missing = new Set(release.missing);
101
+ const uploaded = await uploadBlobs(api, ops, missing, new Map(files.map((f) => [f.hash, f])), concurrency);
102
+ const activated = await callOrThrow(api, ops, 'site.release.activate', { id: release.release.id });
103
+ return { release: release.release, missing: release.missing, uploaded, activated };
104
+ }
105
+
106
+ export const uploadedLine = ({ missing, uploaded }, fileCount) =>
107
+ `uploaded ${missing.length} new file${missing.length === 1 ? '' : 's'} (${formatSize(uploaded.bytes)}), ${fileCount - missing.length} already on the server`;
108
+
109
+ // site.release.list answers { releases: [...] } (--json prints it whole).
110
+ function printReleases(data) {
111
+ if (!data.releases?.length) {
112
+ console.log(`${C.dim}No site releases yet.${C.reset}`);
113
+ return;
114
+ }
115
+ console.log(`\n${C.bold}Site releases (${data.releases.length})${C.reset}\n`);
116
+ console.log(` ${'RELEASE'.padEnd(34)}${'CREATED'.padEnd(22)}${'FILES'.padEnd(8)}SIZE`);
117
+ for (const r of data.releases) {
118
+ const created = new Date(r.created_at * 1000).toISOString().replace('T', ' ').slice(0, 19);
119
+ const marker = r.active ? `${C.green}● ` : ' ';
120
+ console.log(`${marker}${r.id.padEnd(34)}${C.reset}${created.padEnd(22)}${String(r.files).padEnd(8)}${formatSize(r.bytes)}`);
121
+ }
122
+ console.log('');
123
+ }
124
+
125
+ async function siteActivate(api, id) {
126
+ if (!id) fail('Release ID required.', 'Usage: substrio site activate <release-id>');
127
+ const data = await invokeOperation(api, 'site.release.activate', { id });
128
+ emit(data, () => console.log(`${C.green}✓${C.reset} Release ${C.bold}${data.active}${C.reset} is live${data.previous ? ` ${C.dim}(was ${data.previous})${C.reset}` : ''}.`));
129
+ }