@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,591 @@
1
+ /**
2
+ * Substrio CLI: what `substrio push` ships, read from substrio.toml.
3
+ *
4
+ * [[build.steps]] # run in order, each in its dir, by sh
5
+ * dir = "functions"
6
+ * run = "npm ci && npm run build"
7
+ *
8
+ * [site]
9
+ * ignore = [".DS_Store"] # file names left out (default: OS metadata)
10
+ * # .br/.gz beside each compressible file of the merged site, made in
11
+ * # memory; true, or a table overriding PRECOMPRESS_DEFAULTS. Before the
12
+ * # first [[site.parts]], or it lands in that part.
13
+ * precompress = { brotli = true, gzip = true, min_bytes = 1024 }
14
+ * [[site.parts]] # merged in order; a later part's file
15
+ * from = "web/dist" # replaces an earlier one at the same path
16
+ * to = "/"
17
+ *
18
+ * [[cron]]
19
+ * function = "cleanup"
20
+ * expression = "0 3 * * *"
21
+ *
22
+ * [[triggers]]
23
+ * type = "insert"
24
+ * collection = "users"
25
+ * function = "onUser"
26
+ *
27
+ * [queues.emails] # a durable queue (docs/runtime/queues.md): the
28
+ * consumer = "sendEmail" # manifest's queue entry, named by its table
29
+ * dead_letter_queue = "emails_dead"
30
+ * max_retries = 5
31
+ *
32
+ * Functions, rules and MCP tools come from what `substrio deploy` already reads:
33
+ * every .js file in functions_dir, [functions.<name>] routes, main and vars,
34
+ * rules_file and mcp_file, and the collections_dir (src/collections.mjs), whose
35
+ * collections' own rules join the rules. The functions here are pure (no network), so the
36
+ * version body a push sends can be checked without a server.
37
+ */
38
+
39
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
40
+ import { extname, join, relative, resolve } from 'node:path';
41
+ import { brotliCompressSync, constants as zlib, gzipSync } from 'node:zlib';
42
+
43
+ import { collect, hashFile, ignoredNames, sha256 } from './blobs.mjs';
44
+ import { CliError } from './cli.mjs';
45
+ import { DEFAULT_PUBLIC_DIR } from './config.mjs';
46
+ import { declaredCollections, declaredRules, rulesBlob } from './collections.mjs';
47
+ import { buildFunction } from './function-build.mjs';
48
+ import { readJsonFile } from './jsonfile.mjs';
49
+
50
+ function table(value, what) {
51
+ if (value === undefined || value === null) return {};
52
+ if (typeof value !== 'object' || Array.isArray(value)) throw new Error(`${what} must be a table`);
53
+ return value;
54
+ }
55
+
56
+ function list(value, what) {
57
+ if (value === undefined || value === null) return [];
58
+ if (!Array.isArray(value)) throw new Error(`${what} must be an array of tables ([[${what}]])`);
59
+ return value.map((entry, i) => table(entry, `${what}[${i}]`));
60
+ }
61
+
62
+ function text(entry, key, what, fallback) {
63
+ const value = entry[key];
64
+ if (value === undefined && fallback !== undefined) return fallback;
65
+ if (typeof value !== 'string' || value === '') throw new Error(`${what} needs a string ${key}`);
66
+ return value;
67
+ }
68
+
69
+ // A site mount point as a relative posix prefix: "" for the root, else
70
+ // "dir/sub/". No ".." or "." segments.
71
+ // An app that declares no [site] ships public_dir as its site, one part at the root: the folder
72
+ // substrio deploy publishes, so a version (substrio push) carries the same site a deploy does.
73
+ // None when the folder is not there.
74
+ export function publicSiteOf(config, cwd) {
75
+ const from = config.public_dir || DEFAULT_PUBLIC_DIR;
76
+ let isDir = false;
77
+ try { isDir = statSync(resolve(cwd, from)).isDirectory(); } catch { /* no folder: no site */ }
78
+ return isDir ? { parts: [{ from, to: '/', prefix: '' }], precompress: null } : null;
79
+ }
80
+
81
+ function mountPrefix(to, what) {
82
+ const segments = to.split('/').filter(Boolean);
83
+ if (segments.some((s) => s === '..' || s === '.')) throw new Error(`${what} to must not hold "." or ".." segments`);
84
+ return segments.length ? `${segments.join('/')}/` : '';
85
+ }
86
+
87
+ // [site] precompress: the settings scripts/precompress.ts used. Extensions
88
+ // worth compressing (fonts and images already are), files under min_bytes
89
+ // left alone, and a compressed copy kept only when it saves at least
90
+ // min_saving_percent of the file.
91
+ export const PRECOMPRESS_DEFAULTS = Object.freeze({
92
+ brotli: true,
93
+ gzip: true,
94
+ min_bytes: 1024,
95
+ min_saving_percent: 10,
96
+ types: ['.html', '.css', '.js', '.mjs', '.json', '.svg', '.xml', '.txt', '.map', '.webmanifest'],
97
+ });
98
+
99
+ // `precompress = true`, or a table overriding some defaults; absent or false
100
+ // means none. Returns null or {brotli, gzip, min_bytes, min_saving_percent,
101
+ // types: Set}.
102
+ function precompressOf(value) {
103
+ if (value === undefined || value === false) return null;
104
+ const given = value === true ? {} : table(value, 'site.precompress');
105
+ const known = Object.keys(PRECOMPRESS_DEFAULTS);
106
+ const unknown = Object.keys(given).filter((k) => !known.includes(k));
107
+ if (unknown.length) throw new Error(`site.precompress has no ${unknown.join(', ')} (it takes ${known.join(', ')})`);
108
+ const out = { ...PRECOMPRESS_DEFAULTS, ...given };
109
+ for (const k of ['brotli', 'gzip']) {
110
+ if (typeof out[k] !== 'boolean') throw new Error(`site.precompress.${k} must be true or false`);
111
+ }
112
+ for (const k of ['min_bytes', 'min_saving_percent']) {
113
+ if (!Number.isInteger(out[k]) || out[k] < 0) throw new Error(`site.precompress.${k} must be a whole number`);
114
+ }
115
+ if (out.min_saving_percent > 100) throw new Error('site.precompress.min_saving_percent must be at most 100');
116
+ if (!Array.isArray(out.types) || out.types.some((t) => typeof t !== 'string' || !t.startsWith('.'))) {
117
+ throw new Error('site.precompress.types must be an array of extensions such as ".html"');
118
+ }
119
+ return { ...out, types: new Set(out.types.map((t) => t.toLowerCase())) };
120
+ }
121
+
122
+ // The encodings precompress writes, as [suffix, compress].
123
+ const ENCODINGS = [
124
+ ['brotli', '.br', (body) => brotliCompressSync(body, {
125
+ params: {
126
+ [zlib.BROTLI_PARAM_QUALITY]: zlib.BROTLI_MAX_QUALITY,
127
+ [zlib.BROTLI_PARAM_SIZE_HINT]: body.length,
128
+ },
129
+ })],
130
+ ['gzip', '.gz', (body) => gzipSync(body, { level: zlib.Z_BEST_COMPRESSION })],
131
+ ];
132
+
133
+ /**
134
+ * The .br and .gz sidecars of a merged site, made in memory, with the site's
135
+ * own files. Any .br or .gz a part shipped beside a file of the site is
136
+ * dropped first, so a sidecar always belongs to the file that won the merge.
137
+ * Returns {files (sorted by path), sidecars: count}.
138
+ */
139
+ export function precompressSite(files, settings) {
140
+ const encodingSuffixes = ENCODINGS.map(([, suffix]) => suffix);
141
+ const paths = new Set(files.map((f) => f.path));
142
+ const kept = files.filter((f) => {
143
+ const suffix = encodingSuffixes.find((s) => f.path.endsWith(s));
144
+ return !suffix || !paths.has(f.path.slice(0, -suffix.length));
145
+ });
146
+ const out = [...kept];
147
+ let sidecars = 0;
148
+ for (const file of kept) {
149
+ if (!settings.types.has(extname(file.path).toLowerCase()) || file.size < settings.min_bytes) continue;
150
+ const body = file.data ?? readFileSync(file.abs);
151
+ for (const [name, suffix, compress] of ENCODINGS) {
152
+ if (!settings[name]) continue;
153
+ const data = compress(body);
154
+ if (data.length * 100 > body.length * (100 - settings.min_saving_percent)) continue;
155
+ out.push({ path: file.path + suffix, data, size: data.length, hash: sha256(data) });
156
+ sidecars++;
157
+ }
158
+ }
159
+ out.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
160
+ return { files: out, sidecars };
161
+ }
162
+
163
+ /**
164
+ * The push tables of a loaded config (src/config.mjs), checked and normalized:
165
+ * { build: [{dir, run}], site: {parts: [{from, to, prefix}]} | null,
166
+ * cron: [{function, expression}], triggers: [{type, collection, function}],
167
+ * queues: [{name, consumer?, dead_letter_queue?, <settings>}] }.
168
+ * Throws with the table at fault.
169
+ */
170
+ export function manifestOf(config) {
171
+ // [build] steps = [...], [[build.steps]], or [[build]] tables alike.
172
+ const build = Array.isArray(config.build)
173
+ ? list(config.build, 'build')
174
+ : list(table(config.build, 'build').steps, 'build.steps');
175
+ const steps = build.map((step, i) => ({
176
+ dir: text(step, 'dir', `build step ${i + 1}`, '.'),
177
+ run: text(step, 'run', `build step ${i + 1}`),
178
+ }));
179
+
180
+ let site = null;
181
+ if (config.site !== undefined && config.site !== null) {
182
+ const declared = table(config.site, 'site');
183
+ const parts = list(declared.parts, 'site.parts').map((part, i) => {
184
+ const what = `site part ${i + 1}`;
185
+ const extra = Object.keys(part).filter((k) => k !== 'from' && k !== 'to');
186
+ if (extra.length) {
187
+ throw new Error(`${what} takes from and to, not ${extra.join(', ')} (a [site] key goes before the first [[site.parts]])`);
188
+ }
189
+ const to = text(part, 'to', what, '/');
190
+ return { from: text(part, 'from', what), to, prefix: mountPrefix(to, what) };
191
+ });
192
+ if (parts.length === 0) throw new Error('[site] needs at least one [[site.parts]] with a from directory');
193
+ if (declared.ignore !== undefined && !Array.isArray(declared.ignore)) {
194
+ throw new Error('site.ignore must be an array of file names');
195
+ }
196
+ site = { parts, precompress: precompressOf(declared.precompress) };
197
+ }
198
+
199
+ const cron = list(config.cron, 'cron').map((entry, i) => ({
200
+ function: text(entry, 'function', `cron ${i + 1}`),
201
+ expression: text(entry, 'expression', `cron ${i + 1}`),
202
+ }));
203
+ const triggers = list(config.triggers, 'triggers').map((entry, i) => ({
204
+ type: text(entry, 'type', `trigger ${i + 1}`),
205
+ collection: text(entry, 'collection', `trigger ${i + 1}`),
206
+ function: text(entry, 'function', `trigger ${i + 1}`),
207
+ }));
208
+
209
+ return { build: steps, site, cron, triggers, queues: queuesOf(config.queues) };
210
+ }
211
+
212
+ /**
213
+ * [queues.<name>] tables as the version manifest's `queues` array
214
+ * ([{name, consumer, dead_letter_queue, <settings>}], declaration order). The
215
+ * fields are the manifest's own; the server (substrio_queue.h) owns which exist and
216
+ * their ranges, so each is passed as written and refused there with its name.
217
+ * A name with a "." cannot be a table header: it would nest.
218
+ */
219
+ function queuesOf(declared) {
220
+ return Object.entries(table(declared, 'queues')).map(([name, entry]) => {
221
+ const what = `queues.${name}`;
222
+ const fields = table(entry, what);
223
+ if ('name' in fields) throw new Error(`${what} is named by its table; remove its name`);
224
+ for (const [key, value] of Object.entries(fields)) {
225
+ if (typeof value !== 'string' && !Number.isInteger(value)) {
226
+ throw new Error(`${what}.${key} must be a string or a whole number`);
227
+ }
228
+ }
229
+ return { name, ...fields };
230
+ });
231
+ }
232
+
233
+ /**
234
+ * functions.<name>.routes_file: a JSON file, relative to the project file, of
235
+ * the function's route specs (the same strings `routes` takes), read and
236
+ * parsed once so the request carries the resolved array and the server never
237
+ * knows a routes_file existed. An entry already resolved (no routes_file left)
238
+ * comes back as it is, with no file read. `routes` and `routes_file` both
239
+ * given is refused, naming the function; a bad file is refused naming the
240
+ * file, and, for one entry, its position.
241
+ * Called by declaredFunction, the one place a function's declaration becomes
242
+ * a request, so no command can send a function without its routes file
243
+ * (`substrio deploy` once did: every route answered 404); collectApp calls it
244
+ * first so one apply reads the file once.
245
+ */
246
+ function resolveDeclaredRoutes(cwd, name, declared) {
247
+ if (declared.routes_file === undefined) return declared;
248
+ if (declared.routes !== undefined) {
249
+ throw new Error(`functions.${name} declares both routes and routes_file; remove one`);
250
+ }
251
+ const file = declared.routes_file;
252
+ const parsed = readJsonFile(resolve(cwd, file), 'routes_file', file);
253
+ if (!Array.isArray(parsed)) throw new Error(`routes_file ${file} must be an array of strings`);
254
+ parsed.forEach((route, i) => {
255
+ if (typeof route !== 'string') throw new Error(`routes_file ${file}[${i}] must be a string`);
256
+ });
257
+ const { routes_file, ...rest } = declared;
258
+ return { ...rest, routes: parsed };
259
+ }
260
+
261
+ // A path prefix: a leading slash, no trailing slash, no whitespace, query or fragment.
262
+ const ROUTE_PREFIX = /^\/[^\s?#]*[^\s/?#]$/;
263
+
264
+ /**
265
+ * functions.<name>.route_prefix: one path every route of the function is served under (an app
266
+ * whose clients call `/api/v1/...` while its route table names `/users/me`). Applied here, once
267
+ * per route at deploy, so the server only ever sees whole routes: nothing per request. A route is
268
+ * `METHOD /path[ GATE]`; the prefix goes between the method and the path (`GET /` becomes
269
+ * `GET /api/v1`). An entry with no space is left as it is, for the server to refuse by name.
270
+ */
271
+ function prefixedRoutes(name, prefix, routes) {
272
+ if (typeof prefix !== 'string' || !ROUTE_PREFIX.test(prefix)) {
273
+ throw new Error(`functions.${name}.route_prefix must be a path like "/api/v1" (a leading slash, no trailing slash)`);
274
+ }
275
+ return routes.map((route) => {
276
+ const space = route.indexOf(' ');
277
+ if (space === -1) return route;
278
+ const path = route.slice(space + 1);
279
+ const bare = path === '/' || path.startsWith('/ ');
280
+ return route.slice(0, space + 1) + prefix + (bare ? path.slice(1) : path);
281
+ });
282
+ }
283
+
284
+ /**
285
+ * What substrio.toml declares for one function beyond its source: routes
286
+ * (given inline or through routes_file, relative to `cwd`, under route_prefix
287
+ * when one is declared), main and vars, only those given, checked. Shared with
288
+ * `substrio deploy`. vars_env (VERSION-VALUES): names whose values are read from
289
+ * this run's environment into vars, so a value that differs per place an app is
290
+ * deployed to is still the version's own (promote carries it, nothing is set
291
+ * outside the version); a name the environment does not hold is refused, by
292
+ * name, and a name in both vars and vars_env is refused (one owner a value).
293
+ */
294
+ export function declaredFunction(name, declared = {}, cwd = process.cwd(), env = process.env) {
295
+ declared = resolveDeclaredRoutes(cwd, name, declared);
296
+ const out = {};
297
+ if (declared.routes !== undefined) {
298
+ if (!Array.isArray(declared.routes) || declared.routes.some((r) => typeof r !== 'string')) {
299
+ throw new Error(`functions.${name}.routes must be an array of strings`);
300
+ }
301
+ out.routes = declared.route_prefix === undefined
302
+ ? declared.routes : prefixedRoutes(name, declared.route_prefix, declared.routes);
303
+ } else if (declared.route_prefix !== undefined) {
304
+ throw new Error(`functions.${name}.route_prefix needs routes or routes_file`);
305
+ }
306
+ if (declared.main !== undefined) out.main = String(declared.main);
307
+ if (declared.vars !== undefined) {
308
+ if (typeof declared.vars !== 'object' || Array.isArray(declared.vars)) {
309
+ throw new Error(`functions.${name}.vars must be a table`);
310
+ }
311
+ out.vars = Object.fromEntries(
312
+ Object.entries(declared.vars).map(([key, value]) => [key, String(value)]));
313
+ }
314
+ if (declared.vars_env !== undefined) {
315
+ if (!Array.isArray(declared.vars_env) || declared.vars_env.some((k) => typeof k !== 'string' || k === '')) {
316
+ throw new Error(`functions.${name}.vars_env must be an array of variable names`);
317
+ }
318
+ out.vars ??= {};
319
+ for (const key of declared.vars_env) {
320
+ if (Object.hasOwn(out.vars, key)) throw new Error(`functions.${name}: ${key} is in both vars and vars_env`);
321
+ const value = env[key];
322
+ if (value === undefined) throw new Error(`functions.${name}.vars_env: ${key} is not set in the environment`);
323
+ out.vars[key] = value;
324
+ }
325
+ }
326
+ // VERSION-VALUES: {NAME: site path}, filled on the server from the version's own site (NAME and NAME_HASH).
327
+ if (declared.site_vars !== undefined) {
328
+ const entries = typeof declared.site_vars === 'object' && !Array.isArray(declared.site_vars)
329
+ ? Object.entries(declared.site_vars) : null;
330
+ if (!entries || entries.some(([, path]) => typeof path !== 'string' || path === '')) {
331
+ throw new Error(`functions.${name}.site_vars must be a table of names to site paths`);
332
+ }
333
+ for (const [key] of entries) {
334
+ if (out.vars && (Object.hasOwn(out.vars, key) || Object.hasOwn(out.vars, `${key}${SITE_VAR_HASH_SUFFIX}`))) {
335
+ throw new Error(`functions.${name}: site var ${key} is also in vars`);
336
+ }
337
+ }
338
+ out.site_vars = Object.fromEntries(entries);
339
+ }
340
+ // APP-MCP: which routes are the app's MCP tools (every one when absent): false, or a table keyed by the
341
+ // route as declared, each false or {name, description, inputSchema, ...}. The server checks the entries.
342
+ if (declared.tools !== undefined) {
343
+ const table = typeof declared.tools === 'object' && declared.tools !== null && !Array.isArray(declared.tools);
344
+ if (declared.tools !== false && declared.tools !== true && !table) {
345
+ throw new Error(`functions.${name}.tools must be false or a table keyed by a declared route`);
346
+ }
347
+ out.tools = declared.tools;
348
+ }
349
+ return out;
350
+ }
351
+
352
+ // A site var's hash value: NAME + this, the file's SHA-256 in its first 16 hex (substrio_versions.h's
353
+ // SITE_VAR_HASH_SUFFIX and SITE_VAR_HASH_HEX).
354
+ export const SITE_VAR_HASH_SUFFIX = '_HASH';
355
+ const SITE_VAR_HASH_HEX = 16;
356
+
357
+ /**
358
+ * A declared function for a deploy that makes no version (`substrio deploy`, a local stack): its site_vars
359
+ * filled from the site folder on disk as the server fills them from a version's own site (NAME the file's
360
+ * text, NAME_HASH its SHA-256's first 16 hex), so a function reads the same values either way.
361
+ */
362
+ export function withSiteVars(declared, siteDir) {
363
+ if (!declared.site_vars) return declared;
364
+ const { site_vars: siteVars, ...rest } = declared;
365
+ const vars = { ...(rest.vars ?? {}) };
366
+ for (const [key, path] of Object.entries(siteVars)) {
367
+ const file = resolve(siteDir, path);
368
+ if (!existsSync(file)) throw new Error(`site var ${key}: ${path} is not in the site (${siteDir})`);
369
+ const bytes = readFileSync(file);
370
+ vars[key] = bytes.toString('utf8');
371
+ vars[`${key}${SITE_VAR_HASH_SUFFIX}`] = sha256(bytes).slice(0, SITE_VAR_HASH_HEX);
372
+ }
373
+ return { ...rest, vars };
374
+ }
375
+
376
+ /**
377
+ * substrio.toml's [permissions] table, as permissions.set's body:
378
+ * {names, all?, implies?}. null when the project declares none. Only the
379
+ * shape is checked here (names and implies values are string arrays, all a
380
+ * string); compile_permission_catalog on the server makes every declaration
381
+ * error (a bad name, too many names, an unknown implies name, append-only
382
+ * against the stored names).
383
+ *
384
+ * [permissions]
385
+ * names = ["VIEW_EVENTS", "EDIT_EVENTS", "FULL_ACCESS"]
386
+ * all = "FULL_ACCESS"
387
+ * [permissions.implies]
388
+ * EDIT_EVENTS = ["VIEW_EVENTS"]
389
+ *
390
+ * The ONE loader `substrio deploy` and `substrio push` both read, so a table
391
+ * resolved for one is resolved the same way for the other (EV-SX4).
392
+ */
393
+ export function declaredPermissions(config) {
394
+ if (config.permissions === undefined || config.permissions === null) return null;
395
+ const declared = table(config.permissions, 'permissions');
396
+ const extra = Object.keys(declared).filter((k) => k !== 'names' && k !== 'all' && k !== 'implies');
397
+ if (extra.length) throw new Error(`permissions takes names, all, implies, not ${extra.join(', ')}`);
398
+ if (!Array.isArray(declared.names) || declared.names.some((n) => typeof n !== 'string')) {
399
+ throw new Error('permissions.names must be an array of strings');
400
+ }
401
+ const body = { names: declared.names };
402
+ if (declared.all !== undefined) body.all = text(declared, 'all', 'permissions');
403
+ if (declared.implies !== undefined) {
404
+ const implies = table(declared.implies, 'permissions.implies');
405
+ for (const [key, value] of Object.entries(implies)) {
406
+ if (!Array.isArray(value) || value.some((v) => typeof v !== 'string')) {
407
+ throw new Error(`permissions.implies.${key} must be an array of strings`);
408
+ }
409
+ }
410
+ body.implies = implies;
411
+ }
412
+ return body;
413
+ }
414
+
415
+ /**
416
+ * substrio.toml's [ledger] table, as ledger.catalog.set's body: {kinds?,
417
+ * categories?}. null when the project declares none, or declares neither
418
+ * list. A list left out of the body is read by the operation as "stands"
419
+ * (the stored list for that part of the catalog is unchanged).
420
+ *
421
+ * [ledger]
422
+ * kinds = ["HOST", "AGENT"]
423
+ * categories = ["SALE", "REFUND"]
424
+ *
425
+ * The ONE loader `substrio deploy` and `substrio push` both read, so a table
426
+ * resolved for one is resolved the same way for the other (EV-SX4).
427
+ */
428
+ export function declaredLedger(config) {
429
+ if (config.ledger === undefined || config.ledger === null) return null;
430
+ const declared = table(config.ledger, 'ledger');
431
+ const extra = Object.keys(declared).filter((k) => k !== 'kinds' && k !== 'categories');
432
+ if (extra.length) throw new Error(`ledger takes kinds, categories, not ${extra.join(', ')}`);
433
+ const body = {};
434
+ if (declared.kinds !== undefined) {
435
+ if (!Array.isArray(declared.kinds) || declared.kinds.some((k) => typeof k !== 'string')) {
436
+ throw new Error('ledger.kinds must be an array of strings');
437
+ }
438
+ body.kinds = declared.kinds;
439
+ }
440
+ if (declared.categories !== undefined) {
441
+ if (!Array.isArray(declared.categories) || declared.categories.some((c) => typeof c !== 'string')) {
442
+ throw new Error('ledger.categories must be an array of strings');
443
+ }
444
+ body.categories = declared.categories;
445
+ }
446
+ if (body.kinds === undefined && body.categories === undefined) return null;
447
+ return body;
448
+ }
449
+
450
+ // A JSON file as a blob; it must parse, so a broken file stops the push here.
451
+ function jsonBlob(cwd, file, what) {
452
+ const abs = resolve(cwd, file);
453
+ readJsonFile(abs, what, file);
454
+ return { path: file, ...hashFile(abs) };
455
+ }
456
+
457
+ /**
458
+ * Every file of the app, from disk, hashed:
459
+ * { functions: [{name, path, data, inputs, size, hash, esbuildAvailable, declared, buildError?}],
460
+ * rules, mcp, collections: [{name, file, schema, rules, indexes}],
461
+ * site: {files: [{path, abs, size, hash}], replaced: [path]} | null }.
462
+ * A function's `data` is what buildFunction (function-build.mjs, the one
463
+ * owner of a function's bytes) sent back for it, already bundled where
464
+ * bundling was needed; `hash` and `size` are of those bytes, not the file's.
465
+ * A function whose build failed locally (a syntax error, an unresolvable
466
+ * import) is not left out and does not fail the other functions: `data` is
467
+ * its entry file's own bytes (what every path sent before a bundler existed),
468
+ * and `buildError` is the thrown CliError (its `diagnostics`, in function.check's
469
+ * shape, are the caller's to report instead of sending function.check at all;
470
+ * manifest.mjs never calls the server itself).
471
+ * `inputs` are every file (relative to `cwd`) the upload was built from, the
472
+ * entry included: what a change to any of them invalidates.
473
+ * `rules` is rules_file as it is, or, when collections declare rules of their
474
+ * own, the merged rules made in memory.
475
+ * Site parts are merged by target path in order: a later part's file replaces
476
+ * an earlier one at the same path, and `replaced` names each such path.
477
+ *
478
+ * @param {Map<string, object>} [functionCache] forwarded to buildFunction for
479
+ * every function (substrio dev's Reloader keeps one across applies).
480
+ * @param {(projectDir: string) => string | null} [resolveEsbuild] forwarded to
481
+ * buildFunction (its own default is the real resolution); tests state their own.
482
+ */
483
+ export async function collectApp(config, manifest, cwd = process.cwd(), functionCache, resolveEsbuild) {
484
+ const functionsDir = resolve(cwd, config.functions_dir || 'functions');
485
+ const declared = config.functions || {};
486
+ const files = existsSync(functionsDir)
487
+ ? readdirSync(functionsDir).filter((f) => f.endsWith('.js')).sort()
488
+ : [];
489
+ const found = new Set(files.map((f) => f.replace(/\.js$/, '')));
490
+ const missing = Object.keys(declared).filter((name) => !found.has(name));
491
+ if (missing.length > 0) {
492
+ throw new Error(`substrio.toml declares ${missing.join(', ')} but ${config.functions_dir}/ has no ` +
493
+ `${missing.map((n) => `${n}.js`).join(', ')} (did the build run?)`);
494
+ }
495
+ const functions = await Promise.all(files.map(async (file) => {
496
+ const name = file.replace(/\.js$/, '');
497
+ const entry = join(functionsDir, file);
498
+ // routes_file is read and parsed once here, whether or not the build below
499
+ // fails, so a function's declared routes are always the resolved array.
500
+ const fnDeclared = resolveDeclaredRoutes(cwd, name, declared[name] || {});
501
+ let built;
502
+ try {
503
+ built = await buildFunction({ entry, projectDir: cwd, cache: functionCache, resolveEsbuild });
504
+ } catch (err) {
505
+ if (!(err instanceof CliError)) throw err;
506
+ // A function that does not build locally still gets to the rest of the app: the bytes
507
+ // sent on its behalf are its own, exactly what every path sent before a bundler existed
508
+ // (the server, or the caller here, is the one to refuse it; collectApp never does).
509
+ const data = readFileSync(entry);
510
+ return {
511
+ name, path: `${config.functions_dir}/${file}`,
512
+ data, size: data.length, hash: sha256(data), inputs: [relative(cwd, entry)],
513
+ esbuildAvailable: true, declared: fnDeclared, buildError: err,
514
+ };
515
+ }
516
+ const data = Buffer.from(built.code, 'utf-8');
517
+ return {
518
+ name, path: `${config.functions_dir}/${file}`,
519
+ data, size: built.bytes, hash: sha256(data), inputs: built.inputs,
520
+ esbuildAvailable: built.esbuildAvailable, declared: fnDeclared,
521
+ };
522
+ }));
523
+
524
+ const collections = declaredCollections(config, cwd);
525
+ const declaredRulesOf = declaredRules(config, cwd, collections);
526
+ let rules = null;
527
+ if (declaredRulesOf !== null) {
528
+ rules = declaredRulesOf.merged ? rulesBlob(declaredRulesOf) : jsonBlob(cwd, config.rules_file, 'rules_file');
529
+ }
530
+ const mcp = config.mcp_file ? jsonBlob(cwd, config.mcp_file, 'mcp_file') : null;
531
+
532
+ let site = null;
533
+ const declaredSite = manifest.site ?? publicSiteOf(config, cwd);
534
+ if (declaredSite) {
535
+ const ignore = ignoredNames(config);
536
+ const byPath = new Map();
537
+ const replaced = [];
538
+ for (const part of declaredSite.parts) {
539
+ const root = resolve(cwd, part.from);
540
+ let isDir = false;
541
+ try { isDir = statSync(root).isDirectory(); } catch { /* reported below */ }
542
+ if (!isDir) throw new Error(`site part ${part.from} is not a directory (did the build run?)`);
543
+ for (const file of collect(root, ignore)) {
544
+ const path = part.prefix + file.path;
545
+ if (byPath.has(path)) replaced.push(path);
546
+ byPath.set(path, { ...file, path });
547
+ }
548
+ }
549
+ let merged = [...byPath.values()].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
550
+ let sidecars = 0;
551
+ if (declaredSite.precompress) ({ files: merged, sidecars } = precompressSite(merged, declaredSite.precompress));
552
+ site = { files: merged, replaced, sidecars };
553
+ }
554
+
555
+ return { functions, rules, mcp, collections, site };
556
+ }
557
+
558
+ /**
559
+ * The POST /v1/_versions body for a collected app (pure):
560
+ * {message?, functions: [{name, hash, size, main?, routes?, vars?}],
561
+ * rules, mcp, site: {files: [{path, hash, size}]} | null, cron, triggers, queues}.
562
+ */
563
+ export function versionBody(app, manifest, message) {
564
+ const blob = (b) => (b ? { hash: b.hash, size: b.size } : null);
565
+ const body = {};
566
+ if (message !== undefined && message !== null) body.message = String(message);
567
+ // A function names its source by hash and size on itself
568
+ // (substrio_versions.cpp parse_function).
569
+ body.functions = app.functions.map((f) => ({
570
+ name: f.name,
571
+ ...blob(f),
572
+ ...declaredFunction(f.name, f.declared),
573
+ }));
574
+ body.rules = blob(app.rules);
575
+ body.mcp = blob(app.mcp);
576
+ body.site = app.site ? { files: app.site.files.map(({ path, hash, size }) => ({ path, hash, size })) } : null;
577
+ body.cron = manifest.cron.map((c) => ({ function: c.function, expression: c.expression }));
578
+ body.triggers = manifest.triggers.map((t) => ({ type: t.type, collection: t.collection, function: t.function }));
579
+ body.queues = manifest.queues.map((q) => ({ ...q }));
580
+ return body;
581
+ }
582
+
583
+ // Every blob of a collected app by hash, for the uploader.
584
+ export function blobsByHash(app) {
585
+ const byHash = new Map();
586
+ for (const f of app.functions) byHash.set(f.hash, f);
587
+ if (app.rules) byHash.set(app.rules.hash, app.rules);
588
+ if (app.mcp) byHash.set(app.mcp.hash, app.mcp);
589
+ for (const f of app.site?.files || []) byHash.set(f.hash, f);
590
+ return byHash;
591
+ }