@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,246 @@
1
+ /**
2
+ * Substrio CLI: `substrio dev`, the app on a local server with hot reload.
3
+ *
4
+ * substrio dev [--attach URL] [--server PATH] [--port N] [--data DIR]
5
+ * [--debounce MS] [--no-build] [--no-logs] [--prune]
6
+ *
7
+ * Starts a substrio_server of its own on an OS-picked port, with its data in
8
+ * .substrio/dev of the project (or in --data DIR; either is made, owner-only, with
9
+ * its master.key and tenant.key, when it is missing, and kept on exit, so the
10
+ * data and the keys are the same on the next start; `substrio init` keeps .substrio/
11
+ * out of git). It reports its port on a pipe (substrio_server --ready-fd, a
12
+ * supervisor can use the same flag), so nothing polls /health. Or attaches to
13
+ * the server at --attach URL, with the key of the profile or SUBSTRIO_MASTER_KEY.
14
+ * Makes the app's tenant (or finds it again), applies substrio.toml and
15
+ * its files as one version, and then watches the app's directory with the OS
16
+ * file watcher: a saved function is redeployed alone, a changed substrio.toml,
17
+ * rules, cron, trigger or site as one new version, a changed build input runs
18
+ * its build step. A change the server refuses is reported, typed, with the
19
+ * server's diagnostics; the server keeps the last good version. The data
20
+ * survives every reload. The tenant's function logs stream to the terminal.
21
+ * Ctrl+C (or SIGTERM, SIGHUP) stops the watcher and the server; the data stays.
22
+ *
23
+ * A server declaration (index, expiry, aggregate) a collection's file no longer names is reported
24
+ * at each apply (drift) and left in place; --prune drops it instead. Never a field's undeclared
25
+ * adaptive key.
26
+ *
27
+ * It saves (and refreshes at each start, the port is new) the profile `<tenant>-dev`: this server's URL and the
28
+ * tenant's key, never made the default. `substrio --dev COMMAND` in the project's directory selects it, so
29
+ * `substrio invoke hello` from a second terminal reaches the dev server.
30
+ *
31
+ * Under --json every event is one JSON line ("ready" when the server is up,
32
+ * "watching" when saved files are seen from then on).
33
+ */
34
+
35
+ import { join, resolve } from 'node:path';
36
+
37
+ import { readApp } from '../apply.mjs';
38
+ import { CliError, ErrorCode, emitLine, fail } from '../cli.mjs';
39
+ import { aggregateCountLabel, indexCountLabel } from '../collections.mjs';
40
+ import { devDataDir, devTenantSlug, loadConfig, saveDevProfile } from '../config.mjs';
41
+ import { IdentityKind, appliedNames } from '../identity.mjs';
42
+ import { FlagKind, parseFlags, wholeNumber } from '../local/args.mjs';
43
+ import { Reloader, ReloadKind } from '../local/reload.mjs';
44
+ import { LocalServer, resolveServerBinary } from '../local/server.mjs';
45
+ import { AppWatcher, WatchPlan } from '../local/watch.mjs';
46
+ import { followLogs, printLogEntry } from './logs.mjs';
47
+
48
+ const C = {
49
+ reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
50
+ red: '\x1b[91m', green: '\x1b[92m', yellow: '\x1b[93m', cyan: '\x1b[96m',
51
+ };
52
+
53
+ export const DEV_FLAGS = Object.freeze({
54
+ '--attach': FlagKind.VALUE,
55
+ '--server': FlagKind.VALUE,
56
+ '--port': FlagKind.VALUE,
57
+ '--data': FlagKind.VALUE,
58
+ '--debounce': FlagKind.VALUE,
59
+ '--no-build': FlagKind.SWITCH,
60
+ '--no-logs': FlagKind.SWITCH,
61
+ '--prune': FlagKind.SWITCH,
62
+ });
63
+
64
+ const place = (d) => `${d.file}${d.line ? `:${d.line}${d.column ? `:${d.column}` : ''}` : ''}`;
65
+ const secs = (event) => `${event.ms} ms`;
66
+
67
+ /** One reload event as the lines a person reads (pure). */
68
+ export function describeReload(event) {
69
+ const k = ReloadKind;
70
+ switch (event.kind) {
71
+ case k.FUNCTION_DEPLOYED:
72
+ return [`${C.green}↻${C.reset} function ${C.bold}${event.name}${C.reset} deployed ${C.dim}(${secs(event)})${C.reset}`];
73
+ case k.FUNCTION_REMOVED:
74
+ return [`${C.green}↻${C.reset} function ${C.bold}${event.name}${C.reset} removed ${C.dim}(${secs(event)})${C.reset}`];
75
+ case k.FUNCTION_REFUSED: {
76
+ const lines = [`${C.red}✗${C.reset} function ${C.bold}${event.name}${C.reset} not deployed: the server keeps the last good version`];
77
+ for (const d of event.diagnostics ?? []) lines.push(` ${place(d)} ${d.severity}: ${d.message}`);
78
+ if (event.error) lines.push(` ${event.error.message}`);
79
+ if (event.esbuildHint) lines.push(` ${C.yellow}⚠${C.reset} this may need bundling: run \`npm install --save-dev esbuild\` and save again`);
80
+ return lines;
81
+ }
82
+ case k.VERSION_APPLIED:
83
+ return [`${C.green}↻${C.reset} version ${event.version} applied: ${event.units.join(', ')} ${C.dim}(${event.functions} functions, ${secs(event)})${C.reset}`];
84
+ case k.VERSION_REFUSED:
85
+ return [`${C.red}✗${C.reset} the server refused the change${event.units ? ` (${event.units.join(', ')})` : ''}: ${event.error.message}`,
86
+ ` it keeps what it runs; the change goes with the next one`];
87
+ case k.COLLECTIONS_APPLIED: {
88
+ const lines = [`${C.green}↻${C.reset} collections applied: ${event.collections.map((c) => `${C.bold}${c.collection}${C.reset}${c.created ? ' (made)' : ''}${c.schema ? ' (schema)' : ''}${indexCountLabel(c.indexes ?? 0)}${aggregateCountLabel(c.aggregates ?? 0)}`).join(', ')} ${C.dim}(${secs(event)})${C.reset}`];
89
+ for (const w of event.warnings ?? []) lines.push(` ${C.yellow}⚠ ${w.message}${C.reset}`);
90
+ return lines;
91
+ }
92
+ case k.COLLECTIONS_REFUSED:
93
+ return [`${C.red}✗${C.reset} the server refused a collection: ${event.error.message}`,
94
+ ` it keeps what it has; the change goes with the next save`];
95
+ case k.COLLECTIONS_DRIFT:
96
+ return event.drift.map((d) => `${C.yellow}⚠${C.reset} ${C.dim}${d.collection}: ${d.kind} ${d.label} is declared on the server but not in the file (substrio dev --prune drops it)${C.reset}`);
97
+ case k.COLLECTIONS_PRUNED:
98
+ return event.drift.map((d) => `${C.yellow}⊘${C.reset} ${C.dim}pruned ${d.collection}: ${d.kind} ${d.label}${C.reset}`);
99
+ case k.IDENTITY_APPLIED: {
100
+ const names = (kind) => appliedNames(event.applied, kind).map((n) => `${C.bold}${n}${C.reset}`).join(', ');
101
+ const parts = [['roles', IdentityKind.ROLE], ['auth providers', IdentityKind.PROVIDER]]
102
+ .filter(([, kind]) => appliedNames(event.applied, kind).length > 0).map(([label, kind]) => `${label} ${names(kind)}`);
103
+ return [`${C.green}↻${C.reset} ${parts.join('; ')} applied ${C.dim}(${secs(event)})${C.reset}`];
104
+ }
105
+ case k.IDENTITY_REFUSED:
106
+ return [`${C.red}✗${C.reset} the server refused a role or auth provider: ${event.error.message}`,
107
+ ` it keeps what it has; the change goes with the next apply`];
108
+ case k.BUILD_RAN:
109
+ return [`${C.green}↻${C.reset} built ${C.dim}${event.step.dir}: ${event.step.run} (${secs(event)})${C.reset}`];
110
+ case k.BUILD_FAILED:
111
+ return [`${C.red}✗${C.reset} build failed in ${event.step.dir}: ${event.error.message}`];
112
+ case k.CONFIG_REFUSED:
113
+ return [`${C.red}✗${C.reset} ${event.error.message}`, ` nothing changed on the server`];
114
+ case k.FAILED:
115
+ return [`${C.red}✗${C.reset} ${event.error?.message ?? 'failed'}`];
116
+ default:
117
+ return [];
118
+ }
119
+ }
120
+
121
+ /** The line of a reload event for --json: the kind and its fields, the error as the CLI reports one (pure). */
122
+ export function reloadRecord(event) {
123
+ const { error, step, ...rest } = event;
124
+ return {
125
+ event: event.kind, ...rest,
126
+ ...(step ? { step: { dir: step.dir, run: step.run } } : {}),
127
+ ...(error ? { error: error instanceof CliError ? error.toJSON() : { error: String(error.message ?? error) } } : {}),
128
+ };
129
+ }
130
+
131
+ export async function cmdDev(args) {
132
+ let parsed;
133
+ let app;
134
+ try {
135
+ parsed = parseFlags(args, DEV_FLAGS);
136
+ app = readApp(process.cwd());
137
+ } catch (err) {
138
+ fail(err);
139
+ }
140
+ const { flags } = parsed;
141
+ const cwd = process.cwd();
142
+ let debounceMs;
143
+ let port;
144
+ try {
145
+ debounceMs = wholeNumber(flags, '--debounce', 0, 60_000, 50);
146
+ port = wholeNumber(flags, '--port', 0, 65_535, 0);
147
+ } catch (err) {
148
+ fail(err);
149
+ }
150
+
151
+ const attach = flags.get('--attach');
152
+ const slug = devTenantSlug(app.config);
153
+ let server;
154
+ let tenant;
155
+ let profile = null;
156
+ try {
157
+ if (attach !== undefined) {
158
+ const config = loadConfig(cwd);
159
+ if (!config.master_key) {
160
+ throw new CliError('--attach needs a key: SUBSTRIO_MASTER_KEY or the profile', { code: ErrorCode.CREDENTIAL_REQUIRED });
161
+ }
162
+ server = LocalServer.attach({ url: attach, masterKey: config.master_key });
163
+ tenant = await server.tenant(slug, config.master_key);
164
+ } else {
165
+ server = await LocalServer.spawn({
166
+ binary: resolveServerBinary(flags.get('--server')),
167
+ port,
168
+ dataDir: flags.has('--data') ? resolve(flags.get('--data')) : devDataDir(cwd),
169
+ });
170
+ tenant = await server.ensureTenant(slug, join(server.dataDir, 'tenant.key'));
171
+ // The CLI reaches this server from another terminal with `substrio --dev` (or --profile NAME).
172
+ profile = saveDevProfile(slug, server.url, tenant.key);
173
+ }
174
+ } catch (err) {
175
+ await server?.stop();
176
+ fail(err);
177
+ }
178
+
179
+ const abort = new AbortController();
180
+ const ignored = server.dataDir ? [server.dataDir] : [];
181
+ const plan = new WatchPlan(cwd, app.config, app.manifest, ignored);
182
+ const reloader = new Reloader({
183
+ cwd, api: tenant.api, ops: tenant.ops, invoke: tenant.invoke, debounceMs, build: !flags.has('--no-build'), prune: flags.has('--prune'),
184
+ report: (event) => emitLine(reloadRecord(event), () => {
185
+ for (const line of describeReload(event)) console.log(line);
186
+ }),
187
+ onFunctionInputs: (name, absPaths) => plan.setFunctionInputs(name, absPaths),
188
+ });
189
+ const watcher = new AppWatcher(plan, (change) => reloader.note(change));
190
+
191
+ let stopping = null;
192
+ const stop = (code) => {
193
+ stopping ??= (async () => {
194
+ abort.abort();
195
+ watcher.close();
196
+ await reloader.close();
197
+ await server.stop();
198
+ return code;
199
+ })();
200
+ return stopping;
201
+ };
202
+ const done = new Promise((resolveDone) => {
203
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
204
+ process.on(signal, () => {
205
+ // A second signal does not wait for the first one's cleanup.
206
+ if (stopping !== null) process.exit(130);
207
+ void stop(0).then(resolveDone);
208
+ });
209
+ }
210
+ // The server ending on its own is the end of the session, typed.
211
+ void server.exited.then((ended) => {
212
+ if (ended === null || stopping !== null) return;
213
+ console.error(`${C.red}✗ the server ended (${ended.signal ?? `exit ${ended.code}`})${C.reset}`);
214
+ void stop(1).then(resolveDone);
215
+ });
216
+ });
217
+
218
+ const apiUrl = server.url;
219
+ emitLine({ event: 'ready', url: apiUrl, tenant: slug, profile, app: `http://${slug}.localhost:${server.port}`, data_dir: server.dataDir }, () => {
220
+ console.log(`\n${C.bold}${C.cyan}⚡ substrio dev${C.reset} ${C.dim}(${server.target})${C.reset}`);
221
+ console.log(` server ${apiUrl}`);
222
+ console.log(` app http://${slug}.localhost:${server.port}`);
223
+ if (server.dataDir) {
224
+ console.log(` data ${server.dataDir} ${C.dim}(kept on exit, owner-only, with master.key and tenant.key)${C.reset}`);
225
+ console.log(` key ${join(server.dataDir, 'tenant.key')} ${C.dim}(SUBSTRIO_URL=${apiUrl} SUBSTRIO_TENANT=${slug} SUBSTRIO_MASTER_KEY=$(cat it))${C.reset}`);
226
+ }
227
+ if (profile !== null) {
228
+ console.log(` profile ${profile} ${C.dim}(saved, not the default: "substrio --dev COMMAND" in this directory uses it)${C.reset}`);
229
+ }
230
+ console.log('');
231
+ });
232
+
233
+ // Armed before the first apply: the OS watcher takes a moment to arm, and a save landing in that gap would be
234
+ // lost. An event during the first apply marks the reloader dirty, and its drain loop applies it right after.
235
+ watcher.start();
236
+ await reloader.start();
237
+ // From here a saved file is seen: what a supervisor (or a test) waits for before it saves one.
238
+ emitLine({ event: 'watching' }, () => console.log(` ${C.dim}watching for changes${C.reset}\n`));
239
+ if (!flags.has('--no-logs')) {
240
+ followLogs(tenant.api, {}, (entry) => emitLine(entry, printLogEntry), abort.signal).catch((err) => {
241
+ if (!abort.signal.aborted) console.error(`${C.yellow}⚠ ${err.message}${C.reset}`);
242
+ });
243
+ }
244
+ const code = await done;
245
+ process.exitCode = code;
246
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Substrio CLI: env command
3
+ *
4
+ * Usage:
5
+ * substrio env pull Pull remote secrets → .env file
6
+ * substrio env push Push .env file → remote secrets
7
+ * substrio env host set|list|remove TEST-HOSTS: the app's environment hosts (env.host.*)
8
+ */
9
+
10
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
11
+
12
+ import { errorText } from '../api.mjs';
13
+ import { CliError, ErrorCode, emit, exitCodeFor, fail } from '../cli.mjs';
14
+ import { callOperation, failResult, loadOperations } from './op.mjs';
15
+ import { cmdRegistry } from './groups-runtime.mjs';
16
+
17
+ const C = {
18
+ reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
19
+ red: '\x1b[91m', green: '\x1b[92m', cyan: '\x1b[96m', yellow: '\x1b[93m',
20
+ };
21
+
22
+ export async function cmdEnv(api, args) {
23
+ const sub = args[0];
24
+
25
+ if (sub === 'pull') return envPull(api);
26
+ if (sub === 'push') return envPush(api);
27
+ // TEST-HOSTS: any env.* operation by its words (substrio env host set host=... environment=dev).
28
+ if (sub && !sub.startsWith('-')) return cmdRegistry(api, 'env', args);
29
+
30
+ fail(`Unknown subcommand: ${sub || '(none)'}`, 'Usage: substrio env pull|push | env host set|list|remove');
31
+ }
32
+
33
+ // secret.list answers { secrets: [{key, value: "***"}], count }; --json
34
+ // prints it whole once .env is written.
35
+ async function envPull(api) {
36
+ const result = await callOperation(api, await loadOperations(api), 'secret.list', undefined);
37
+ if (!result.ok) failResult(result);
38
+ const secrets = result.data?.secrets ?? [];
39
+
40
+ if (secrets.length === 0) {
41
+ console.log(`${C.dim}No secrets configured. .env file not created.${C.reset}`);
42
+ return emit(result.data, () => {});
43
+ }
44
+
45
+ const lines = [
46
+ '# Substrio Secrets (pulled via "substrio env pull")',
47
+ `# Note: Values are masked, set real values manually or use "substrio secret set"`,
48
+ '',
49
+ ...secrets.map((s) => `${s.key}=${s.value}`),
50
+ ];
51
+
52
+ writeFileSync('.env', lines.join('\n') + '\n', 'utf-8');
53
+ emit(result.data, () => {
54
+ console.log(`${C.green}✓${C.reset} Pulled ${secrets.length} secret(s) to ${C.bold}.env${C.reset}`);
55
+ console.log(`${C.yellow}⚠${C.reset} Values are masked. Edit .env with real values, then "substrio env push".`);
56
+ });
57
+ }
58
+
59
+ async function envPush(api) {
60
+ if (!existsSync('.env')) fail('No .env file found in current directory.', null, ErrorCode.FILE_NOT_FOUND);
61
+
62
+ const content = readFileSync('.env', 'utf-8');
63
+ const entries = [];
64
+
65
+ for (const line of content.split('\n')) {
66
+ const trimmed = line.trim();
67
+ if (!trimmed || trimmed.startsWith('#')) continue;
68
+
69
+ const eqIdx = trimmed.indexOf('=');
70
+ if (eqIdx === -1) continue;
71
+
72
+ const key = trimmed.slice(0, eqIdx).trim();
73
+ let value = trimmed.slice(eqIdx + 1).trim();
74
+
75
+ // Strip quotes
76
+ if ((value.startsWith('"') && value.endsWith('"')) ||
77
+ (value.startsWith("'") && value.endsWith("'"))) {
78
+ value = value.slice(1, -1);
79
+ }
80
+
81
+ // Skip masked values
82
+ if (value === '***' || value.endsWith('***')) {
83
+ continue;
84
+ }
85
+
86
+ entries.push({ key, value });
87
+ }
88
+
89
+ if (entries.length === 0) {
90
+ console.log(`${C.dim}No non-masked entries to push.${C.reset}`);
91
+ return emit({ pushed: [], failed: [] }, () => {});
92
+ }
93
+
94
+ // Each by secret.write; --json prints the keys pushed and, per key that
95
+ // failed, the server's typed error. A failure exits with its code.
96
+ const ops = await loadOperations(api);
97
+ const pushed = [];
98
+ const failed = [];
99
+ let firstFailure = null;
100
+ for (const { key, value } of entries) {
101
+ const result = await callOperation(api, ops, 'secret.write', { key, value });
102
+ if (result.ok) {
103
+ pushed.push(key);
104
+ console.log(` ${C.green}✓${C.reset} ${key}`);
105
+ } else {
106
+ const error = CliError.fromResponse(`${key}: ${errorText(result.data, result.status)}`, result.data, result.status);
107
+ firstFailure ??= error;
108
+ failed.push({ key, error: error.toJSON() });
109
+ console.log(` ${C.red}✗${C.reset} ${key}: ${error.message}`);
110
+ }
111
+ }
112
+
113
+ emit({ pushed, failed }, () => console.log(`\n${C.green}✓${C.reset} Pushed ${pushed.length} secret(s)${failed.length ? `, ${C.red}${failed.length} failed${C.reset}` : ''}`));
114
+ if (firstFailure) process.exitCode = exitCodeFor(firstFailure);
115
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Substrio CLI: files command
3
+ *
4
+ * Usage:
5
+ * substrio files ls [path] List files at path
6
+ * substrio files upload <local> [remote] Upload a file
7
+ * substrio files rm <path> Delete a file
8
+ * substrio files sync <local-dir> <prefix> [--concurrency N]
9
+ * Upload every file under the folder that the app's store
10
+ * does not hold at the same path with the same size; a file
11
+ * the server refuses (413) is reported and the sync goes on,
12
+ * exit 8 (partial)
13
+ */
14
+
15
+ import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
16
+ import { basename, join, relative, sep } from 'node:path';
17
+ import { setTimeout as wait } from 'node:timers/promises';
18
+
19
+ import { ErrorCode, ExitCode, emit, emitLine, errorText, fail } from '../cli.mjs';
20
+ import { cmdRegistry } from './groups-runtime.mjs';
21
+ import { FlagKind, parseFlags, wholeNumber } from '../local/args.mjs';
22
+ import { callOperation, failResult, invokeOperation, loadOperations, runOperation } from './op.mjs';
23
+
24
+ const C = {
25
+ reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
26
+ red: '\x1b[91m', green: '\x1b[92m', cyan: '\x1b[96m',
27
+ };
28
+
29
+ export async function cmdFiles(api, args) {
30
+ const sub = args[0];
31
+
32
+ switch (sub) {
33
+ case 'ls': case 'list': return runOperation(api, 'file.list', args.slice(1), { render: printFiles });
34
+ case 'upload': return filesUpload(api, args[1], args[2]);
35
+ case 'rm': case 'delete': return filesRm(api, args[1]);
36
+ case 'sync': return filesSync(api, args.slice(1));
37
+ default:
38
+ // Any other file.* operation by its words (substrio files download PATH).
39
+ if (sub && !sub.startsWith('-')) return cmdRegistry(api, 'files', args);
40
+ fail(`Unknown subcommand: ${sub || '(none)'}`, 'Usage: substrio files ls|upload|rm|sync, or a file.* operation');
41
+ }
42
+ }
43
+
44
+ // file.list answers { files: [{path, size, content_type, ...}], count }
45
+ // (--json prints it whole).
46
+ function printFiles(data) {
47
+ const files = Array.isArray(data.files) ? data.files : [];
48
+ if (files.length === 0) {
49
+ console.log(`${C.dim}No files found.${C.reset}`);
50
+ return;
51
+ }
52
+
53
+ console.log(`\n${C.bold}Files (${files.length})${C.reset}\n`);
54
+
55
+ const nameOf = (f) => String(f.path ?? '');
56
+ const nameW = Math.max(20, ...files.map((f) => nameOf(f).length));
57
+ console.log(` ${'NAME'.padEnd(nameW)} ${'SIZE'.padEnd(10)} TYPE`);
58
+ console.log(` ${'─'.repeat(nameW)} ${'─'.repeat(10)} ${'─'.repeat(15)}`);
59
+
60
+ for (const f of files) {
61
+ const size = f.size ? formatSize(f.size) : '-';
62
+ const type = f.content_type || '-';
63
+ console.log(` ${nameOf(f).padEnd(nameW)} ${size.padEnd(10)} ${type}`);
64
+ }
65
+ console.log('');
66
+ }
67
+
68
+ // file.upload takes the file's bytes as its body, named by X-Filename (its
69
+ // declared raw alternative to the JSON input);
70
+ // file.delete takes the file's path. --json prints the server's answer.
71
+ async function filesUpload(api, localPath, remoteName) {
72
+ if (!localPath) fail('Local file path required.', 'Usage: substrio files upload <local-path> [remote-name]');
73
+ if (!existsSync(localPath)) fail(`File not found: ${localPath}`, null, ErrorCode.FILE_NOT_FOUND);
74
+
75
+ const fileName = remoteName || basename(localPath);
76
+ const size = statSync(localPath).size;
77
+ const data = await invokeOperation(api, 'file.upload', {}, {
78
+ body: readFileSync(localPath),
79
+ headers: { 'Content-Type': 'application/octet-stream', 'X-Filename': fileName },
80
+ });
81
+ emit(data, () => console.log(`${C.green}✓${C.reset} Uploaded ${C.bold}${fileName}${C.reset} (${formatSize(size)})`));
82
+ }
83
+
84
+ const SYNC_USAGE = 'substrio files sync <local-dir> <prefix> [--concurrency N]';
85
+ // One listing page: the store answers up to this many paths a call.
86
+ const LIST_PAGE = 1000;
87
+ // A 429 or 503 with no Retry-After waits this long before the same upload is sent again.
88
+ const RETRY_MS = 1000;
89
+
90
+ /**
91
+ * Makes the app's store hold every file under `dir`, at `prefix` + its path below `dir` (with
92
+ * `/`): one paged file.list of the prefix (a path and its size each), then only the files missing
93
+ * there or of another size are uploaded, `--concurrency` at once (default 8), each as its own
94
+ * bytes typed by the server from its name. A 429 or 503 is sent again after the server's wait,
95
+ * each wait a visible retry line; any other refusal stops the run. Nothing is deleted. Under
96
+ * --json: one line per retry, then one final `done` line {uploaded, skipped, bytes, elapsed_ms}.
97
+ * Per file: 1 stat, 1 map lookup; an upload adds 1 read and 1 call.
98
+ */
99
+ async function filesSync(api, args) {
100
+ const { flags, positional } = parseFlags(args, { '--concurrency': FlagKind.VALUE });
101
+ const [dir, rawPrefix] = positional;
102
+ if (!dir || rawPrefix === undefined || positional.length > 2) fail('A folder and a prefix are required.', SYNC_USAGE);
103
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) fail(`Folder not found: ${dir}`, SYNC_USAGE, ErrorCode.FILE_NOT_FOUND);
104
+ const concurrency = wholeNumber(flags, '--concurrency', 1, 64, 8);
105
+ const prefix = rawPrefix === '' || rawPrefix.endsWith('/') ? rawPrefix : rawPrefix + '/';
106
+ const started = Date.now();
107
+ const ops = await loadOperations(api);
108
+
109
+ const stored = new Map();
110
+ for (let cursor; ;) {
111
+ const page = await invokeOperation(api, 'file.list', { prefix, limit: LIST_PAGE, ...(cursor ? { cursor } : {}) }, { ops });
112
+ for (const file of page.files ?? []) stored.set(file.path, file.size);
113
+ cursor = page.next_cursor;
114
+ if (!cursor) break;
115
+ }
116
+
117
+ const due = [];
118
+ let skipped = 0;
119
+ for (const entry of readdirSync(dir, { recursive: true, withFileTypes: true })) {
120
+ if (!entry.isFile()) continue;
121
+ const local = join(entry.parentPath ?? entry.path, entry.name);
122
+ const below = relative(dir, local);
123
+ const path = prefix + (sep === '/' ? below : below.split(sep).join('/'));
124
+ const size = statSync(local).size;
125
+ if (stored.get(path) === size) skipped += 1;
126
+ else due.push({ local, path });
127
+ }
128
+
129
+ // A file the server refuses for itself (too large, a type it will not store) is reported and the
130
+ // sync goes on; the run then exits PARTIAL. A refusal of the caller (401, 403) ends it at once.
131
+ let next = 0;
132
+ let bytes = 0;
133
+ const refused = [];
134
+ const upload = async ({ local, path }) => {
135
+ const body = readFileSync(local);
136
+ for (;;) {
137
+ const result = await callOperation(api, ops, 'file.upload', {}, null, {
138
+ body, headers: { 'Content-Type': 'application/octet-stream', 'X-Filename': path },
139
+ });
140
+ if (result.ok) { bytes += body.length; return; }
141
+ if (result.status === 401 || result.status === 403) failResult(result);
142
+ if (result.status !== 429 && result.status !== 503) {
143
+ const error = { error: errorText(result.data, result.status), code: result.data?.code ?? null, status: result.status };
144
+ refused.push({ path, size: body.length, ...error });
145
+ emitLine({ event: 'refused', path, size: body.length, ...error }, () => console.log(` ${C.red}✗${C.reset} ${path}: ${error.error}`));
146
+ return;
147
+ }
148
+ const waitMs = result.retryAfterMs ?? RETRY_MS;
149
+ emitLine({ event: 'retry', path, status: result.status, wait_ms: waitMs }, () => console.log(` ${C.dim}retry ${path}: HTTP ${result.status}, waiting ${waitMs}ms${C.reset}`));
150
+ await wait(waitMs);
151
+ }
152
+ };
153
+ const worker = async () => { while (next < due.length) await upload(due[next++]); };
154
+ await Promise.all(Array.from({ length: Math.min(concurrency, due.length) }, worker));
155
+
156
+ const report = { uploaded: due.length - refused.length, skipped, refused: refused.length, bytes, elapsed_ms: Date.now() - started };
157
+ emitLine({ event: 'done', ...report }, () => console.log(`${C.green}✓${C.reset} Synced ${C.bold}${dir}${C.reset} to ${C.bold}${prefix || '/'}${C.reset}: ` +
158
+ `${report.uploaded} uploaded (${formatSize(bytes)}), ${skipped} already there, ${refused.length} refused, ${(report.elapsed_ms / 1000).toFixed(1)} s`));
159
+ if (refused.length > 0) process.exitCode = ExitCode.PARTIAL;
160
+ }
161
+
162
+ async function filesRm(api, path) {
163
+ if (!path) fail('File path required.', 'Usage: substrio files rm <path>');
164
+ const data = await invokeOperation(api, 'file.delete', { path });
165
+ emit(data, () => console.log(`${C.green}✓${C.reset} Deleted ${C.bold}${path}${C.reset}`));
166
+ }
167
+
168
+ function formatSize(bytes) {
169
+ if (bytes < 1024) return `${bytes} B`;
170
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
171
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
172
+ }