@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.
- 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
package/src/manifest.mjs
ADDED
|
@@ -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
|
+
}
|