@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,488 @@
1
+ /**
2
+ * Substrio CLI: `substrio deploy` command
3
+ *
4
+ * Scans functions/ directory and deploys each .js file (function.deploy),
5
+ * with what substrio.toml declares for it:
6
+ *
7
+ * [functions.api]
8
+ * routes = ["ANY /api/*", "POST /emails"]
9
+ * main = "handler" # optional entry point
10
+ *
11
+ * [functions.api.vars] # the function's env
12
+ * API_BASE_URL = "https://api.example.com"
13
+ *
14
+ * A declared function without a file fails the deploy. First the collections
15
+ * the app declares (collections/NAME.json, or substrio.toml's collections_dir;
16
+ * src/collections.mjs) are made and their schemas set, and the roles ([roles.NAME])
17
+ * and sign-in providers ([auth.providers.NAME]) it declares are made or updated
18
+ * (src/identity.mjs), and when it declares a [permissions] table, the tenant's
19
+ * permission catalog (permissions.set, append only; docs/permissions.md) -
20
+ * before the functions, whose routes may declare "auth:<name>" against it. Then,
21
+ * when substrio.toml names a rules_file or a collection declares rules, the tenant's security
22
+ * rules are set from them together (security.rules.set), and when it names an mcp_file, the tools the tenant
23
+ * serves to AI agents at /mcp (mcp.tools.set), and when it names a
24
+ * plans_file, the tenant's plans (plans.set), and when it declares [apps.offer] or [apps.use], the functions
25
+ * it offers other apps and the ones of other apps it calls (apps.calls.set), and each settings table it declares
26
+ * ([auth], [login]) by its own operation (auth.settings.set). Then public/ (public_dir) is published
27
+ * as a site release (site.mjs publishSite). After the collections are applied, a server declaration
28
+ * (index, aggregate or expiry) the files no longer name is reported
29
+ * (drift), left in place; --prune drops it with index.drop. --json
30
+ * prints a typed report: each function, step and total, a failed step
31
+ * with the server's typed error, and the run exits with the first
32
+ * failure's exit code.
33
+ */
34
+
35
+ import { readdirSync, readFileSync, existsSync } from 'node:fs';
36
+ import { join, resolve } from 'node:path';
37
+
38
+ import { errorText } from '../api.mjs';
39
+ import { collect, ignoredNames } from '../blobs.mjs';
40
+ import { CliError, ErrorCode, ExitCode, emit, exitCodeFor, fail } from '../cli.mjs';
41
+ import { aggregateCountLabel, applyCollections, collectionDrift, declaredCollections, declaredRules, indexCountLabel, pruneDrift } from '../collections.mjs';
42
+ import { buildFunction } from '../function-build.mjs';
43
+ import { IdentityKind, appliedNames, applyIdentity, declaredIdentity, hasIdentity, unsetLine } from '../identity.mjs';
44
+ import { callOrThrow, loadOperations } from './op.mjs';
45
+ import { publishSite, uploadedLine } from './site.mjs';
46
+ import { declaredFunction, declaredLedger, declaredPermissions, withSiteVars } from '../manifest.mjs';
47
+ import { declaredPlans, declaredSettings } from '../settings.mjs';
48
+
49
+ const C = {
50
+ reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
51
+ red: '\x1b[91m', green: '\x1b[92m', yellow: '\x1b[93m', cyan: '\x1b[96m',
52
+ };
53
+
54
+ /**
55
+ * @param {import('../api.mjs').SubstrioAPI} api
56
+ * @param {object} config
57
+ * @param {string[]} args
58
+ */
59
+ export async function cmdDeploy(api, config, args) {
60
+ const cwd = process.cwd();
61
+ const functionsDir = resolve(cwd, config.functions_dir || 'functions');
62
+ const publicDir = resolve(cwd, config.public_dir || 'public');
63
+ const dryRun = args.includes('--dry-run');
64
+ const prune = args.includes('--prune');
65
+
66
+ const onlyFunctions = args.includes('--functions') || args.includes('-f');
67
+ const onlyAssets = args.includes('--assets') || args.includes('-a');
68
+ // Positional arguments: the function names to replace. With any given,
69
+ // only those functions are built and sent; no other step runs or reads its file.
70
+ const names = args.filter((a) => !a.startsWith('-'));
71
+ const named = names.length > 0;
72
+ if (named && prune) fail('substrio deploy NAME... --prune: pruning is a whole-project decision, not one for named functions', null, ErrorCode.INVALID_ARGUMENT);
73
+ if (named && (onlyFunctions || onlyAssets)) fail('substrio deploy NAME... --functions/--assets: ambiguous with named functions', null, ErrorCode.INVALID_ARGUMENT);
74
+ const deployAll = !onlyFunctions && !onlyAssets && !named;
75
+
76
+ console.log(`\n${C.bold}${C.cyan}⚡ Substrio Deploy${C.reset}${dryRun ? ` ${C.yellow}(dry run)${C.reset}` : ''}`);
77
+ console.log(`${C.dim} Server: ${config.url}${C.reset}`);
78
+ console.log(`${C.dim} Tenant: ${config.tenant}${C.reset}\n`);
79
+
80
+ let totalDeployed = 0;
81
+ let totalFailed = 0;
82
+ // What --json prints: each function, the rules, MCP tools and plans set,
83
+ // the static assets counted, and the totals.
84
+ const report = { dry_run: dryRun, collections: null, identity: null, functions: [], rules: null, mcp_tools: null, app_calls: null, plans: null, permissions: null, ledger: null, assets: null, site: null };
85
+ const run = new DeployRun(api);
86
+
87
+ // What the app declares of its collections and rules, read (and checked) before anything is
88
+ // sent. Named mode skips this: it reads nothing beyond the named functions' own files.
89
+ let collections = [];
90
+ let rules = null;
91
+ let identity = { roles: [], providers: [], unset: [] };
92
+ let permissions = null;
93
+ let ledger = null;
94
+ let settings = [];
95
+ if (!named) {
96
+ try {
97
+ collections = declaredCollections(config, cwd);
98
+ rules = declaredRules(config, cwd, collections);
99
+ identity = declaredIdentity(config);
100
+ permissions = declaredPermissions(config);
101
+ ledger = declaredLedger(config);
102
+ settings = declaredSettings(config);
103
+ } catch (err) {
104
+ fail(err);
105
+ }
106
+ }
107
+
108
+ // Collections: made and their schemas set, before the functions and rules that use them.
109
+ if ((deployAll || onlyFunctions) && collections.length > 0) {
110
+ if (dryRun) {
111
+ console.log(`${C.dim}⦿ collections from ${config.collections_dir || 'collections'}/: would apply ${collections.map((c) => c.name + indexCountLabel(c.indexes?.length ?? 0) + aggregateCountLabel(c.aggregates?.length ?? 0)).join(', ')}${C.reset}\n`);
112
+ } else {
113
+ const applied = [];
114
+ try {
115
+ report.collections = { ok: true, ...await applyCollections((name, input) => run.call(name, input), collections, applied) };
116
+ console.log(`${C.green}✓${C.reset} ${C.bold}Collections${C.reset} ${C.dim}(${applied.map((a) => a.collection + indexCountLabel(a.indexes) + aggregateCountLabel(a.aggregates)).join(', ')})${C.reset}`);
117
+ const drift = await collectionDrift((name, input) => run.call(name, input), collections);
118
+ if (drift.length === 0) {
119
+ console.log(`${C.dim} up to date: no server declaration the files no longer name${C.reset}`);
120
+ } else if (prune) {
121
+ await pruneDrift((name, input) => run.call(name, input), drift);
122
+ report.collections.pruned = drift;
123
+ for (const d of drift) console.log(`${C.yellow}⊘${C.reset} ${C.dim}pruned ${d.collection}: ${d.kind} ${d.label}${C.reset}`);
124
+ } else {
125
+ report.collections.drift = drift;
126
+ for (const d of drift) console.log(`${C.yellow}⚠${C.reset} ${C.dim}${d.collection}: ${d.kind} ${d.label} is declared on the server but not in the file (substrio deploy --prune drops it)${C.reset}`);
127
+ }
128
+ } catch (err) {
129
+ if (!DeployRun.isStepFailure(err)) throw err;
130
+ report.collections = { ...run.failed(err), applied };
131
+ console.log(`${C.red}✗${C.reset} ${C.bold}Collections${C.reset}: ${C.red}${err.message}${C.reset}`);
132
+ totalFailed++;
133
+ }
134
+ }
135
+ console.log('');
136
+ }
137
+
138
+ // Roles and sign-in providers: before the rules and functions that name them. An optional provider's
139
+ // variable that is not set is named on a line of its own and in the report, set or dry run alike.
140
+ if ((deployAll || onlyFunctions) && identity.unset.length > 0) {
141
+ for (const entry of identity.unset) console.log(`${C.yellow}⦿${C.reset} ${C.dim}${unsetLine(entry)}${C.reset}`);
142
+ if (!hasIdentity(identity)) {
143
+ report.identity = { ok: true, applied: [], unset: identity.unset };
144
+ console.log('');
145
+ }
146
+ }
147
+ if ((deployAll || onlyFunctions) && hasIdentity(identity)) {
148
+ if (dryRun) {
149
+ console.log(`${C.dim}⦿ roles and auth providers from substrio.toml: would apply ${[...identity.roles.map((r) => r.name), ...identity.providers.map((p) => p.provider)].join(', ')}${C.reset}\n`);
150
+ } else {
151
+ const applied = [];
152
+ try {
153
+ report.identity = { ok: true, ...await applyIdentity((name, input) => run.call(name, input), identity, applied), unset: identity.unset };
154
+ const label = (title, kind) => (appliedNames(applied, kind).length ? `${C.green}✓${C.reset} ${C.bold}${title}${C.reset} ${C.dim}(${appliedNames(applied, kind).join(', ')})${C.reset}` : null);
155
+ for (const line of [label('Roles', IdentityKind.ROLE), label('Auth providers', IdentityKind.PROVIDER)]) if (line) console.log(line);
156
+ } catch (err) {
157
+ if (!DeployRun.isStepFailure(err)) throw err;
158
+ report.identity = { ...run.failed(err), applied };
159
+ console.log(`${C.red}✗${C.reset} ${C.bold}Roles and auth providers${C.reset}: ${C.red}${err.message}${C.reset}`);
160
+ totalFailed++;
161
+ }
162
+ console.log('');
163
+ }
164
+ }
165
+
166
+ // Permission catalog (UM-2a): append only against what the tenant has
167
+ // stored; a deploy that declares none leaves it exactly as it was. Before
168
+ // the functions, whose routes may declare "auth:<name>" against it (UM-2c).
169
+ if ((deployAll || onlyFunctions) && permissions !== null) {
170
+ totalFailed += await sendDeclared(run, report, {
171
+ key: 'permissions', operation: 'permissions.set', body: permissions,
172
+ would: `set ${permissions.names.length} name(s)`, summary: `${permissions.names.length} name(s)`, dryRun,
173
+ });
174
+ }
175
+
176
+ // Ledger catalog: append only against what the tenant has stored; a
177
+ // deploy that declares neither list leaves it exactly as it was.
178
+ if ((deployAll || onlyFunctions) && ledger !== null) {
179
+ totalFailed += await sendDeclared(run, report, {
180
+ key: 'ledger', operation: 'ledger.catalog.set', body: ledger,
181
+ would: `declare ${ledger.kinds?.length ?? 0} kind(s), ${ledger.categories?.length ?? 0} categor(ies)`,
182
+ summary: `${ledger.kinds?.length ?? 0} kind(s), ${ledger.categories?.length ?? 0} categor(ies)`, dryRun,
183
+ });
184
+ }
185
+
186
+ // Deploy Functions (or, named, only the functions asked for)
187
+ if (deployAll || onlyFunctions || named) {
188
+ if (!existsSync(functionsDir) && !named) {
189
+ console.log(`${C.yellow}⚠ No functions/ directory found. Skipping function deployment.${C.reset}`);
190
+ } else {
191
+ const files = existsSync(functionsDir) ? readdirSync(functionsDir).filter(f => f.endsWith('.js')) : [];
192
+ const found = new Set(files.map(f => f.replace(/\.js$/, '')));
193
+
194
+ if (named) {
195
+ const known = new Set([...found, ...Object.keys(config.functions)]);
196
+ const unknown = names.filter((n) => !known.has(n));
197
+ if (unknown.length > 0) {
198
+ fail(`no function ${unknown.join(', ')} in this project (functions: ${[...known].sort().join(', ') || 'none'})`,
199
+ null, ErrorCode.FILE_NOT_FOUND);
200
+ }
201
+ } else {
202
+ const missing = Object.keys(config.functions).filter(name => !found.has(name));
203
+ if (missing.length > 0) {
204
+ fail(`substrio.toml declares ${missing.join(', ')} but ${config.functions_dir}/ has no ${missing.map(n => n + '.js').join(', ')}`,
205
+ null, ErrorCode.FILE_NOT_FOUND);
206
+ }
207
+ }
208
+
209
+ const toDeploy = named ? names : files.map(f => f.replace(/\.js$/, ''));
210
+ if (toDeploy.length === 0) {
211
+ console.log(`${C.dim} No .js files in ${config.functions_dir}/. Skipping.${C.reset}`);
212
+ } else {
213
+ console.log(`${C.bold}Functions${C.reset} ${C.dim}(${toDeploy.length} file${toDeploy.length > 1 ? 's' : ''})${C.reset}`);
214
+
215
+ for (const name of toDeploy) {
216
+ const filePath = join(functionsDir, `${name}.js`);
217
+ let built;
218
+ try {
219
+ built = await buildFunction({ entry: filePath, projectDir: cwd });
220
+ } catch (err) {
221
+ // Its exit code is exitCodeFor's, the same one a server refusal of this
222
+ // function would give: run.failed keeps the first failure for that.
223
+ report.functions.push({ name, ...(err instanceof CliError ? run.failed(err) : { ok: false, error: { error: String(err.message ?? err) } }) });
224
+ console.log(` ${C.red}✗${C.reset} ${C.bold}${name}${C.reset}: ${C.red}${err.message}${C.reset}`);
225
+ totalFailed++;
226
+ continue;
227
+ }
228
+ const sizeKB = (built.bytes / 1024).toFixed(1);
229
+ const bundled = built.inputs.length > 1 ? `, ${built.inputs.length} files bundled` : '';
230
+
231
+ if (dryRun) {
232
+ console.log(` ${C.dim}⦿${C.reset} ${C.bold}${name}${C.reset} ${C.dim}(${sizeKB} KB${bundled}): would deploy${C.reset}`);
233
+ report.functions.push({ name, size_bytes: built.bytes, inputs: built.inputs, ok: true, dry_run: true });
234
+ totalDeployed++;
235
+ continue;
236
+ }
237
+
238
+ const step = await run.send('function.deploy', deployment(name, built.code, config.functions[name], cwd, publicDir));
239
+ report.functions.push({ name, size_bytes: built.bytes, inputs: built.inputs, ...step });
240
+ if (step.ok) {
241
+ const hash = step.result?.content_hash ? `, content_hash ${step.result.content_hash}` : '';
242
+ console.log(` ${C.green}✓${C.reset} ${C.bold}${name}${C.reset} ${C.dim}(${sizeKB} KB${bundled}${hash})${C.reset}`);
243
+ totalDeployed++;
244
+ } else {
245
+ console.log(` ${C.red}✗${C.reset} ${C.bold}${name}${C.reset}: ${C.red}${errorText(step.error, step.error.status)}${C.reset}`);
246
+ if (esbuildHint(built, step)) console.log(` ${C.yellow}⚠${C.reset} ${C.dim}this may need bundling: run \`npm install --save-dev esbuild\` and deploy again${C.reset}`);
247
+ totalFailed++;
248
+ }
249
+ }
250
+ }
251
+ }
252
+ }
253
+
254
+ // Security rules
255
+ if ((deployAll || onlyFunctions) && rules !== null) {
256
+ const from = rules.sources.join(' + ');
257
+ if (dryRun) {
258
+ console.log(`\n${C.dim}⦿ rules from ${from}: would set${C.reset}`);
259
+ } else {
260
+ report.rules = await run.send('security.rules.set', rules.body);
261
+ if (report.rules.ok) {
262
+ console.log(`\n${C.green}✓${C.reset} ${C.bold}rules${C.reset} ${C.dim}from ${from}${C.reset}`);
263
+ } else {
264
+ console.log(`\n${C.red}✗${C.reset} ${C.bold}rules${C.reset}: ${C.red}${errorText(report.rules.error, report.rules.error.status)}${C.reset}`);
265
+ totalFailed++;
266
+ }
267
+ }
268
+ }
269
+
270
+ // MCP tools: after the functions whose routes they name (docs/mcp.md)
271
+ if ((deployAll || onlyFunctions) && config.mcp_file) {
272
+ const mcpPath = resolve(cwd, config.mcp_file);
273
+ const mcp = JSON.parse(readFileSync(mcpPath, 'utf-8'));
274
+ if (dryRun) {
275
+ console.log(`\n${C.dim}⦿ MCP tools from ${config.mcp_file}: would set ${mcp.tools?.length ?? 0}${C.reset}`);
276
+ } else {
277
+ report.mcp_tools = await run.send('mcp.tools.set', mcp);
278
+ if (report.mcp_tools.ok) {
279
+ console.log(`\n${C.green}✓${C.reset} ${C.bold}MCP tools${C.reset} ${C.dim}(${report.mcp_tools.result?.tools?.length ?? 0}) from ${config.mcp_file}${C.reset}`);
280
+ } else {
281
+ console.log(`\n${C.red}✗${C.reset} ${C.bold}MCP tools${C.reset}: ${C.red}${errorText(report.mcp_tools.error, report.mcp_tools.error.status)}${C.reset}`);
282
+ totalFailed++;
283
+ }
284
+ }
285
+ }
286
+
287
+ // Calls between apps: after the functions an offer names (substrio_app_calls.h). The server
288
+ // validates the whole document and keeps the word in force when it refuses it.
289
+ if ((deployAll || onlyFunctions) && !named && config.apps) {
290
+ const word = { offer: config.apps.offer ?? {}, use: config.apps.use ?? {} };
291
+ // APP-QUERY: the reads it offers ([apps.query.<name>]: collection, where, select, to, limit) and the ones
292
+ // of other apps it makes ([apps.use_query]); sent only when declared. After the collections, whose
293
+ // indexes an offered read names (the server refuses a where field that is not declared indexed).
294
+ if (config.apps.query !== undefined) word.query = config.apps.query;
295
+ if (config.apps.use_query !== undefined) word.use_query = config.apps.use_query;
296
+ if (dryRun) {
297
+ console.log(`\n${C.dim}⦿ App calls: would offer ${Object.keys(word.offer).length}, use ${Object.keys(word.use).length}${C.reset}`);
298
+ } else {
299
+ report.app_calls = await run.send('apps.calls.set', word);
300
+ if (report.app_calls.ok) {
301
+ console.log(`\n${C.green}✓${C.reset} ${C.bold}app calls${C.reset} ${C.dim}(offer ${Object.keys(word.offer).length}, use ${Object.keys(word.use).length})${C.reset}`);
302
+ // An app named that does not exist yet: kept (deploy order is free), each call refused until it does.
303
+ for (const w of report.app_calls.result?.warnings ?? []) {
304
+ console.log(`${C.yellow}⚠${C.reset} ${C.dim}${w.code}: app '${w.app}' does not exist yet${C.reset}`);
305
+ }
306
+ } else {
307
+ console.log(`\n${C.red}✗${C.reset} ${C.bold}app calls${C.reset}: ${C.red}${errorText(report.app_calls.error, report.app_calls.error.status)}${C.reset}`);
308
+ totalFailed++;
309
+ }
310
+ }
311
+ }
312
+
313
+ // Plans: the tenant's counters, per-period caps and feature flags (substrio_plans.h).
314
+ // Named mode reads no file but its own functions', so this is skipped too.
315
+ let plans = null;
316
+ try {
317
+ plans = named ? null : declaredPlans(config, cwd);
318
+ } catch (err) {
319
+ console.log(`\n${C.red}✗${C.reset} ${C.bold}Plans${C.reset}: ${C.red}${err.message}${C.reset}`);
320
+ const error = new CliError(`${config.plans_file}: ${err.message}`);
321
+ report.plans = run.failed(error);
322
+ totalFailed++;
323
+ }
324
+ if ((deployAll || onlyFunctions) && plans !== null) {
325
+ if (dryRun) {
326
+ console.log(`\n${C.dim}⦿ Plans from ${config.plans_file}: would set ${plans.plans?.length ?? 0}${C.reset}`);
327
+ } else {
328
+ report.plans = await run.send('plans.set', plans);
329
+ if (report.plans.ok) {
330
+ console.log(`\n${C.green}✓${C.reset} ${C.bold}Plans${C.reset} ${C.dim}(${report.plans.result?.plans?.length ?? 0}) from ${config.plans_file}${C.reset}`);
331
+ } else {
332
+ console.log(`\n${C.red}✗${C.reset} ${C.bold}Plans${C.reset}: ${C.red}${errorText(report.plans.error, report.plans.error.status)}${C.reset}`);
333
+ totalFailed++;
334
+ }
335
+ }
336
+ }
337
+
338
+ // Settings tables ([auth], [login]; src/settings.mjs): each set by its own operation, as a
339
+ // pushed version's go live. After the functions, which a setting may name (email_function).
340
+ // A table the file does not declare leaves the target's settings as they are.
341
+ if (deployAll || onlyFunctions) totalFailed += await sendSettings(run, report, settings, dryRun);
342
+
343
+ // The site: public/ published as a release by site.mjs's publishSite (the one way `substrio site
344
+ // deploy` publishes too), so the server holds the files and a deploy to any server carries the
345
+ // site. Only the files the server lacks are uploaded; the switch is one rename on the server.
346
+ if (deployAll || onlyAssets) {
347
+ const files = existsSync(publicDir) ? collect(publicDir, ignoredNames(config)) : [];
348
+ report.assets = files.length;
349
+ if (files.length > 0) {
350
+ console.log(`\n${C.bold}Site${C.reset} ${C.dim}(${files.length} file${files.length > 1 ? 's' : ''} in ${config.public_dir || 'public'}/)${C.reset}`);
351
+ if (dryRun) {
352
+ console.log(` ${C.dim}⦿ would publish a release${C.reset}`);
353
+ } else {
354
+ report.site = await run.site(files);
355
+ if (report.site.ok) {
356
+ console.log(` ${C.green}✓${C.reset} release ${C.bold}${report.site.result.release.id}${C.reset} live: ${uploadedLine(report.site.result, files.length)}`);
357
+ } else {
358
+ console.log(` ${C.red}✗${C.reset} site: ${C.red}${errorText(report.site.error, report.site.error.status)}${C.reset}`);
359
+ totalFailed++;
360
+ }
361
+ }
362
+ }
363
+ }
364
+
365
+ // Summary
366
+ console.log('');
367
+ if (dryRun) {
368
+ console.log(`${C.yellow}⦿${C.reset} Dry run: ${totalDeployed} function(s) would be deployed.`);
369
+ } else if (totalFailed === 0 && totalDeployed > 0) {
370
+ console.log(`${C.green}${C.bold}✓ Deployed ${totalDeployed} function${totalDeployed > 1 ? 's' : ''} successfully.${C.reset}`);
371
+ } else if (totalFailed > 0) {
372
+ console.log(`${C.yellow}⚠ ${totalDeployed} deployed, ${totalFailed} failed.${C.reset}`);
373
+ } else if (totalDeployed === 0) {
374
+ console.log(`${C.dim}Nothing to deploy.${C.reset}`);
375
+ }
376
+ console.log('');
377
+ // --json: the typed report. A deploy that left anything undone fails with
378
+ // its first failure's exit code, so scripts and CI stop on it.
379
+ emit({ ...report, deployed: totalDeployed, failed: totalFailed }, () => {});
380
+ if (totalFailed > 0) process.exitCode = run.firstFailure ? exitCodeFor(run.firstFailure) : ExitCode.FAILURE;
381
+ }
382
+
383
+ /** The deploy's calls: the server's catalog, read once, and the first failure. */
384
+ class DeployRun {
385
+ constructor(api) {
386
+ this.api = api;
387
+ this.ops = null;
388
+ this.firstFailure = null;
389
+ }
390
+
391
+ /**
392
+ * The operation `name` with `input`: the server's data, or a thrown CliError
393
+ * (the server's typed error; a name or input that does not fit).
394
+ */
395
+ async call(name, input) {
396
+ this.ops ??= await loadOperations(this.api);
397
+ return callOrThrow(this.api, this.ops, name, input);
398
+ }
399
+
400
+ /**
401
+ * One step: the operation `name` with `input`. Returns {ok, result} or
402
+ * {ok: false, error} (the server's typed error), the first failure kept.
403
+ */
404
+ async send(name, input) {
405
+ try {
406
+ return { ok: true, result: await this.call(name, input) };
407
+ } catch (err) {
408
+ if (!DeployRun.isStepFailure(err)) throw err;
409
+ return this.failed(err);
410
+ }
411
+ }
412
+
413
+ /** The site's release (site.mjs publishSite) as one step: {ok, result} or {ok: false, error}. */
414
+ async site(files) {
415
+ this.ops ??= await loadOperations(this.api);
416
+ try {
417
+ return { ok: true, result: await publishSite(this.api, this.ops, files) };
418
+ } catch (err) {
419
+ if (!DeployRun.isStepFailure(err)) throw err;
420
+ return this.failed(err);
421
+ }
422
+ }
423
+
424
+ /** Whether `err` is a step the server or the input refused (a typed CliError), not a connection that could not be made. */
425
+ static isStepFailure(err) {
426
+ return err instanceof CliError && err.code !== ErrorCode.NETWORK_ERROR;
427
+ }
428
+
429
+ /** A failed step's report entry; the first one is the run's exit code. */
430
+ failed(error) {
431
+ this.firstFailure ??= error;
432
+ return { ok: false, error: error.toJSON() };
433
+ }
434
+ }
435
+
436
+ /**
437
+ * The settings tables substrio.toml declares (declaredSettings), each sent as its own
438
+ * operation through sendDeclared, its result on report[member]. Returns how many failed.
439
+ */
440
+ export async function sendSettings(run, report, settings, dryRun, log = console.log) {
441
+ let failed = 0;
442
+ for (const setting of settings) {
443
+ const count = Object.keys(setting.values).length;
444
+ failed += await sendDeclared(run, report, {
445
+ key: setting.member, operation: setting.operation, body: setting.values,
446
+ would: `set ${count} setting(s)`, summary: `${setting.label}, ${count} setting(s)`, dryRun,
447
+ }, log);
448
+ }
449
+ return failed;
450
+ }
451
+
452
+ /**
453
+ * One table of substrio.toml that a deploy sends as one operation (permissions, ledger).
454
+ * A dry run prints what it would do and sends nothing. Returns 1 when the send failed,
455
+ * else 0, and leaves the operation's result on report[key].
456
+ */
457
+ export async function sendDeclared(run, report, { key, operation, body, would, summary, dryRun }, log = console.log) {
458
+ if (dryRun) {
459
+ log(`${C.dim}⦿ ${key} from substrio.toml: would ${would}${C.reset}\n`);
460
+ return 0;
461
+ }
462
+ report[key] = await run.send(operation, body);
463
+ if (report[key].ok) {
464
+ log(`${C.green}✓${C.reset} ${C.bold}${key}${C.reset} ${C.dim}(${summary}) from substrio.toml${C.reset}\n`);
465
+ return 0;
466
+ }
467
+ log(`${C.red}✗${C.reset} ${C.bold}${key}${C.reset}: ${C.red}${errorText(report[key].error, report[key].error.status)}${C.reset}\n`);
468
+ return 1;
469
+ }
470
+
471
+ // The deploy request for one function: its source plus the routes, entry point
472
+ // and vars substrio.toml declares for it. Only what is declared is sent, so a
473
+ // function substrio.toml says nothing about keeps what it had.
474
+ function deployment(name, script, declared = {}, cwd, siteDir) {
475
+ // VERSION-VALUES: a deploy makes no version, so site_vars are filled here from the site folder this
476
+ // deploy publishes, as the server fills them from a version's own site.
477
+ return { name, script, ...withSiteVars(declaredFunction(name, declared, cwd), siteDir) };
478
+ }
479
+
480
+ // Whether `step`'s failure of `built` looks like the server refusing an import it cannot resolve:
481
+ // no esbuild was ever asked to bundle this function (so an npm import, if any, is still in the
482
+ // source as sent), and the server's refusal is the generic code deploy.mjs's run.send also uses
483
+ // for every other way a source fails to become a handler. The server has no field of its own
484
+ // naming this case; this is the honest best signal without reading the source again to guess.
485
+ function esbuildHint(built, step) {
486
+ return built.esbuildAvailable === false && step.error.code === ErrorCode.INVALID_ARGUMENT;
487
+ }
488
+