@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
|
@@ -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
|
+
|