@substrio/cli 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/LICENSE +177 -0
  2. package/README.md +66 -2
  3. package/bin/substrio.mjs +91 -0
  4. package/package.json +44 -4
  5. package/src/api.mjs +107 -0
  6. package/src/apply.mjs +204 -0
  7. package/src/blobs.mjs +102 -0
  8. package/src/cli.mjs +405 -0
  9. package/src/collections.mjs +701 -0
  10. package/src/command-table.mjs +436 -0
  11. package/src/commands/bundle.mjs +369 -0
  12. package/src/commands/count.mjs +39 -0
  13. package/src/commands/deploy.mjs +488 -0
  14. package/src/commands/dev.mjs +246 -0
  15. package/src/commands/env.mjs +115 -0
  16. package/src/commands/files.mjs +172 -0
  17. package/src/commands/git.mjs +195 -0
  18. package/src/commands/groups-runtime.mjs +227 -0
  19. package/src/commands/groups.mjs +417 -0
  20. package/src/commands/history.mjs +374 -0
  21. package/src/commands/import.mjs +506 -0
  22. package/src/commands/init.mjs +175 -0
  23. package/src/commands/key.mjs +172 -0
  24. package/src/commands/login.mjs +167 -0
  25. package/src/commands/logs.mjs +285 -0
  26. package/src/commands/mcp-config.mjs +102 -0
  27. package/src/commands/op.mjs +265 -0
  28. package/src/commands/open.mjs +35 -0
  29. package/src/commands/push.mjs +199 -0
  30. package/src/commands/signup.mjs +164 -0
  31. package/src/commands/site.mjs +129 -0
  32. package/src/commands/skills.mjs +177 -0
  33. package/src/commands/status.mjs +78 -0
  34. package/src/commands/tenants.mjs +172 -0
  35. package/src/commands/test.mjs +167 -0
  36. package/src/commands/tools.mjs +63 -0
  37. package/src/commands/versions.mjs +293 -0
  38. package/src/config.mjs +569 -0
  39. package/src/documents.mjs +139 -0
  40. package/src/function-build.mjs +273 -0
  41. package/src/identity.mjs +248 -0
  42. package/src/jsonfile.mjs +22 -0
  43. package/src/live-move.mjs +267 -0
  44. package/src/local/args.mjs +58 -0
  45. package/src/local/reload.mjs +408 -0
  46. package/src/local/server.mjs +291 -0
  47. package/src/local/watch.mjs +175 -0
  48. package/src/manifest.mjs +591 -0
  49. package/src/ops.mjs +265 -0
  50. package/src/readers.mjs +416 -0
  51. package/src/sdk.mjs +26 -0
  52. package/src/settings.mjs +117 -0
  53. package/src/testing/app.mjs +507 -0
  54. package/src/testing/env.mjs +35 -0
  55. package/src/testing/index.mjs +11 -0
  56. package/src/testing/types.mjs +55 -0
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Substrio CLI: the documents in a file or text, for `substrio data import`.
3
+ *
4
+ * The form is decided once: by `--format`, else the file's extension (.ndjson
5
+ * and .jsonl are NDJSON, .json is a JSON array or {"documents": [...]}, .csv
6
+ * is CSV), else (stdin, inline text, an odd extension) the whole input is
7
+ * parsed as one JSON value first and, when that fails, as NDJSON. NDJSON,
8
+ * JSON and CSV are read a chunk at a time (src/readers.mjs): a file of any
9
+ * size is never loaded whole.
10
+ *
11
+ * Every document comes out as {position, raw, endOffset} (`raw`, a Buffer,
12
+ * is that document's own JSON text) or {position, error, endOffset,
13
+ * violations?}: nothing is skipped unseen. `endOffset` is the byte offset in
14
+ * the input just past that document, for a resumed import (--resume). The
15
+ * AUTO fallback (stdin, inline text or an odd extension with no --format)
16
+ * parses the whole input as one JSON value first, already holding every
17
+ * element as a JS value; it is the one path that re-serializes to produce
18
+ * `raw`, since nothing here has JSON text for it to begin with.
19
+ */
20
+
21
+ import { readFileSync } from 'node:fs';
22
+ import { extname } from 'node:path';
23
+
24
+ import { CliError, ErrorCode } from './cli.mjs';
25
+ import {
26
+ byteChunksOf, csvDocuments, documentAt, documentOfLine, jsonArrayDocuments, ndjsonDocuments, unreadable,
27
+ } from './readers.mjs';
28
+
29
+ export { documentAt, documentOfLine };
30
+
31
+ /** How the documents of an input are laid out. */
32
+ export const DocumentForm = Object.freeze({
33
+ NDJSON: 'ndjson', // one JSON object per line
34
+ JSON: 'json', // one JSON value: an array, or {"documents": [...]}
35
+ CSV: 'csv', // RFC 4180, a header row required
36
+ AUTO: 'auto', // decided by parsing the input
37
+ });
38
+
39
+ const FORM_BY_EXTENSION = new Map([
40
+ ['.ndjson', DocumentForm.NDJSON], ['.jsonl', DocumentForm.NDJSON], ['.json', DocumentForm.JSON], ['.csv', DocumentForm.CSV],
41
+ ]);
42
+
43
+ /** The form a file name decides (pure): by its extension, else AUTO. */
44
+ export function formOfFile(path) {
45
+ return FORM_BY_EXTENSION.get(extname(path).toLowerCase()) ?? DocumentForm.AUTO;
46
+ }
47
+
48
+ // The elements of a parsed JSON value that is the whole input, or null when it is a single object that is not a wrapper.
49
+ function elementsOf(value, form) {
50
+ const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
51
+ if (Array.isArray(value)) return value;
52
+ if (isObj(value) && Object.hasOwn(value, 'documents')) {
53
+ if (!Array.isArray(value.documents)) throw new CliError('"documents" must be an array of objects', { code: ErrorCode.INVALID_ARGUMENT });
54
+ return value.documents;
55
+ }
56
+ if (form === DocumentForm.JSON) {
57
+ throw new CliError('a .json file holds an array of objects or {"documents": [...]}; for one object per line use .ndjson',
58
+ { code: ErrorCode.INVALID_ARGUMENT });
59
+ }
60
+ return null;
61
+ }
62
+
63
+ // `found` ({position, document, size} or {position, error, violations?}) as the uniform raw-bytes shape:
64
+ // the AUTO path already holds a JS value, so this is the one place that re-serializes it to JSON text.
65
+ function toRawItem(found, endOffset) {
66
+ if (found.document === undefined) return { position: found.position, error: found.error, endOffset, ...(found.violations ? { violations: found.violations } : {}) };
67
+ return { position: found.position, raw: Buffer.from(JSON.stringify(found.document), 'utf8'), endOffset };
68
+ }
69
+
70
+ // The documents of `text` in `form` (AUTO's legacy whole-parse path), as an iterable; endOffset is the byte length.
71
+ function* documentsOfText(text, form) {
72
+ const body = text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text;
73
+ const bytes = Buffer.byteLength(text, 'utf8');
74
+ if (form !== DocumentForm.NDJSON) {
75
+ let parsed;
76
+ try {
77
+ parsed = JSON.parse(body);
78
+ } catch (err) {
79
+ // Not one JSON value: NDJSON, unless the form said JSON.
80
+ if (form === DocumentForm.JSON) throw new CliError(`not valid JSON (${err.message})`, { code: ErrorCode.INVALID_ARGUMENT });
81
+ }
82
+ const elements = parsed === undefined ? null : elementsOf(parsed, form);
83
+ if (elements !== null) {
84
+ for (let i = 0; i < elements.length; i++) yield toRawItem(documentAt(i + 1, elements[i]), bytes);
85
+ return;
86
+ }
87
+ }
88
+ const lines = body.split('\n');
89
+ for (let i = 0; i < lines.length; i++) {
90
+ const found = documentOfLine(i + 1, lines[i]);
91
+ if (found !== null) yield toRawItem(found, bytes);
92
+ }
93
+ }
94
+
95
+ /**
96
+ * The documents of a source, in order, one at a time. `source` is {path}
97
+ * ("-" is stdin) or {text}. `options.form` overrides the extension (one of
98
+ * DocumentForm); `options.types` (a Map) and `options.emptyAs` are CSV's;
99
+ * `options.resumeOffset` continues a read from that byte offset (--resume;
100
+ * refused for stdin). Throws a CliError when the input as a whole cannot be
101
+ * read as documents (a missing file, a .json that is not JSON, a truncated
102
+ * JSON array); a single bad line, element or row comes out as
103
+ * {position, error, endOffset, violations?}.
104
+ */
105
+ export async function* readDocuments(source, options = {}) {
106
+ const { form: requestedForm, types = null, emptyAs = undefined, resumeOffset = 0 } = options;
107
+ if (source.text !== undefined) {
108
+ if (resumeOffset > 0) throw new CliError('--resume needs a file, not inline text', { code: ErrorCode.INVALID_ARGUMENT });
109
+ const form = requestedForm ?? DocumentForm.AUTO;
110
+ if (form === DocumentForm.CSV) { yield* csvDocuments(byteChunksOf(source), { types, emptyAs }); return; }
111
+ if (form === DocumentForm.JSON || form === DocumentForm.NDJSON) {
112
+ if (form === DocumentForm.JSON) { yield* jsonArrayDocuments(byteChunksOf(source)); return; }
113
+ yield* ndjsonDocuments(byteChunksOf(source));
114
+ return;
115
+ }
116
+ yield* documentsOfText(source.text, DocumentForm.AUTO);
117
+ return;
118
+ }
119
+ const form = requestedForm ?? (source.path === '-' ? DocumentForm.AUTO : formOfFile(source.path));
120
+ if (form === DocumentForm.NDJSON) {
121
+ yield* ndjsonDocuments(byteChunksOf(source, resumeOffset), { baseOffset: resumeOffset });
122
+ return;
123
+ }
124
+ if (form === DocumentForm.JSON) {
125
+ yield* jsonArrayDocuments(byteChunksOf(source, resumeOffset), { baseOffset: resumeOffset, resuming: resumeOffset > 0 });
126
+ return;
127
+ }
128
+ if (form === DocumentForm.CSV) {
129
+ yield* csvDocuments(byteChunksOf(source), { types, emptyAs, resumeOffset });
130
+ return;
131
+ }
132
+ let text;
133
+ try {
134
+ text = readFileSync(source.path === '-' ? 0 : source.path, 'utf8');
135
+ } catch (err) {
136
+ throw unreadable(source.path, err);
137
+ }
138
+ yield* documentsOfText(text, form);
139
+ }
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Substrio CLI: the one owner of turning a function's source on disk into the
3
+ * bytes the server is sent, for every path that reaches a tenant (substrio
4
+ * deploy, substrio push, substrio dev's first apply, its version-wide apply and its
5
+ * per-save redeploy: manifest.mjs's collectApp calls buildFunction for every
6
+ * function it collects, and nothing else reads a function file for upload).
7
+ *
8
+ * The server's loader has no resolver (a function is one file, able to
9
+ * import only the runtime's builtin modules). Whether a function needs
10
+ * bundling is never decided by reading its source here (no hand-rolled
11
+ * scanner of JavaScript: esbuild already parses the file to bundle it, and
12
+ * the server's function.check/function.deploy already parse it, without
13
+ * running it, to refuse what they do not accept): esbuild, when it resolves
14
+ * from the project or the CLI's own install, is always asked to build the
15
+ * entry with bundling on; its own metafile says whether anything beyond the
16
+ * entry was pulled in. When nothing was (every import was a runtime builtin,
17
+ * left external, or there was no import at all), the bytes sent are the
18
+ * entry file's own, unchanged. When esbuild does not resolve, the entry
19
+ * file's own bytes are sent regardless: a function with no import beyond the
20
+ * builtins still deploys with no bundler installed, and a function that does
21
+ * need one is refused by the server, which the caller turns into one hint
22
+ * naming the fix (deploy.mjs, local/reload.mjs).
23
+ */
24
+
25
+ import { spawnSync } from 'node:child_process';
26
+ import { createRequire } from 'node:module';
27
+ import { mkdirSync, readFileSync, statSync } from 'node:fs';
28
+ import { join, relative } from 'node:path';
29
+ import { pathToFileURL } from 'node:url';
30
+
31
+ import { CliError, ErrorCode } from './cli.mjs';
32
+ import { configDir } from './config.mjs';
33
+
34
+ /**
35
+ * The runtime's builtin modules (docs/runtime/index.md, "Builtin modules"),
36
+ * exactly as that table's specifier column lists them; a function may import
37
+ * or require only these, and the bare Node name (without "node:") works too,
38
+ * except substrio:env, which has no bare form. The test function-build.test.mjs
39
+ * fails when this drifts from the docs table.
40
+ */
41
+ export const BUILTIN_MODULES = Object.freeze([
42
+ 'substrio:env',
43
+ 'node:buffer',
44
+ 'node:crypto',
45
+ 'node:events',
46
+ 'node:util',
47
+ 'node:url',
48
+ 'node:path',
49
+ 'node:path/posix',
50
+ 'node:querystring',
51
+ 'node:string_decoder',
52
+ 'node:assert',
53
+ 'node:assert/strict',
54
+ ]);
55
+
56
+ const BARE_BUILTINS = new Set(
57
+ BUILTIN_MODULES.filter((spec) => spec.startsWith('node:')).map((spec) => spec.slice('node:'.length))
58
+ );
59
+ const BUILTIN_SET = new Set(BUILTIN_MODULES);
60
+
61
+ /** Whether `specifier` is one the runtime provides without bundling, in either form it accepts. */
62
+ export function isBuiltinSpecifier(specifier) {
63
+ return BUILTIN_SET.has(specifier) || BARE_BUILTINS.has(specifier);
64
+ }
65
+
66
+ // esbuild's own `external` glob needs every spelling the runtime accepts, so a builtin import
67
+ // is never pulled into the bundle.
68
+ const EXTERNAL_BUILTINS = Object.freeze([...BUILTIN_MODULES, ...BARE_BUILTINS]);
69
+
70
+ // The bundler the CLI declares (package.json `dependencies.esbuild`): an install that left it out
71
+ // (--omit, a copied CLI, a failed platform binary) gets it here, once a process and only when no
72
+ // resolution finds it, at the declared range, into the CLI's own tools folder under the profiles
73
+ // directory (always the user's to write, unlike a global install). SUBSTRIO_NO_INSTALL=1 turns
74
+ // it off (an offline machine); the deploy then sends the entry file alone, as before.
75
+ const DECLARED_ESBUILD = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8')).dependencies.esbuild;
76
+ export const toolsDir = () => join(configDir(), 'tools');
77
+ let installTried = false;
78
+
79
+ function resolveFrom(base) {
80
+ try {
81
+ return createRequire(base).resolve('esbuild');
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
87
+ // The tools folder's own esbuild only, <tools>/node_modules/esbuild: 1 read of its package.json
88
+ // and 1 stat of its entry, no ancestor walk. The folder sits under the profiles directory, and
89
+ // Node's walk up from it would take any esbuild above it (~/node_modules) for the CLI's own.
90
+ function resolveFromTools() {
91
+ const at = join(toolsDir(), 'node_modules', 'esbuild');
92
+ try {
93
+ const entry = join(at, JSON.parse(readFileSync(join(at, 'package.json'), 'utf-8')).main ?? 'index.js');
94
+ return statSync(entry).isFile() ? entry : null;
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Installs the declared esbuild into toolsDir() with npm (its output on stderr, so --json stays
102
+ * whole); true when npm says it did. `run` is spawnSync's seam for a test.
103
+ */
104
+ export function installEsbuild({ dir = toolsDir(), run = spawnSync, log = (line) => process.stderr.write(line) } = {}) {
105
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
106
+ log(`installing esbuild@${DECLARED_ESBUILD} (the CLI's bundler) into ${dir}\n`);
107
+ const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
108
+ const r = run(npm, ['install', '--prefix', dir, '--no-audit', '--no-fund', '--no-package-lock', '--loglevel=error', `esbuild@${DECLARED_ESBUILD}`],
109
+ { stdio: ['ignore', 2, 2] });
110
+ return r.status === 0;
111
+ }
112
+
113
+ // esbuild's path, resolved from the project first (Node's walk: a workspace's hoisted
114
+ // node_modules counts), then the CLI's own install, then the CLI's tools folder (its own
115
+ // node_modules only), installing it there when none has it: the real resolution, used whenever a call
116
+ // site states none of its own. Exported so a test whose subject is bundling with a real,
117
+ // project-resolved esbuild can ask for it by name, regardless of the suite's own mode.
118
+ // `own` and `install` are seams for a test; the process-wide once applies to the real installer.
119
+ export function resolveEsbuildReal(projectDir, { own = import.meta.url, install = installEsbuild } = {}) {
120
+ const found = resolveFrom(join(projectDir, 'package.json')) ?? resolveFrom(own) ?? resolveFromTools();
121
+ if (found || process.env.SUBSTRIO_NO_INSTALL === '1') return found;
122
+ if (install === installEsbuild) {
123
+ if (installTried) return null;
124
+ installTried = true;
125
+ }
126
+ return install() ? resolveFromTools() : null;
127
+ }
128
+
129
+ let defaultResolveEsbuild = resolveEsbuildReal;
130
+
131
+ /**
132
+ * Test-only seam: every buildFunction call that states no `resolveEsbuild` of its own uses this
133
+ * one for the rest of the process. Nothing under src/ calls it; a test either passes
134
+ * `resolveEsbuild` to one call, or sets this once (test/helpers/esbuild-mode.mjs, loaded with
135
+ * `--import` before any production code runs) for every call that does not.
136
+ */
137
+ export function setDefaultEsbuildResolver(resolveEsbuild) {
138
+ defaultResolveEsbuild = resolveEsbuild ?? resolveEsbuildReal;
139
+ }
140
+
141
+ // esbuild's own JS API, at the path `resolveEsbuild(projectDir)` names, or not loaded at all when
142
+ // it names none. Dynamically imported, so a project with nothing to bundle never loads it.
143
+ async function loadEsbuild(projectDir, resolveEsbuild) {
144
+ const path = resolveEsbuild(projectDir);
145
+ if (!path) return null;
146
+ const mod = await import(pathToFileURL(path).href);
147
+ return mod.default ?? mod;
148
+ }
149
+
150
+ function place(d) {
151
+ return d.file ? `${d.file}:${d.line}:${d.column}` : '';
152
+ }
153
+
154
+ // esbuild's own diagnostics, in function.check's shape (severity, message, file, line, column):
155
+ // the one shape a local build failure and the server's refusal of a source both report through.
156
+ function buildFailedError(name, err) {
157
+ const diagnostics = (err.errors ?? [{ text: err.message }]).map((m) => ({
158
+ severity: 'error',
159
+ message: m.text,
160
+ file: m.location?.file ?? null,
161
+ line: m.location?.line ?? null,
162
+ column: m.location?.column ?? null,
163
+ }));
164
+ const message = `function ${name}: ${diagnostics.map((d) => `${place(d)} ${d.message}`.trim()).join('; ')}`;
165
+ const error = new CliError(message, { code: ErrorCode.INVALID_ARGUMENT });
166
+ error.diagnostics = diagnostics;
167
+ return error;
168
+ }
169
+
170
+ // Each of `inputs` (relative to `cwd`), as its mtime and size, joined: changes whenever a file's
171
+ // content or presence changes, without reading a byte of it. `buildFunction`'s cache key. `newest` is
172
+ // the latest mtime among them.
173
+ function inputsSignature(cwd, inputs) {
174
+ let newest = 0;
175
+ const text = inputs.map((rel) => {
176
+ try {
177
+ const s = statSync(join(cwd, rel));
178
+ if (s.mtimeMs > newest) newest = s.mtimeMs;
179
+ return `${s.mtimeMs}:${s.size}`;
180
+ } catch {
181
+ return 'gone';
182
+ }
183
+ }).join('|');
184
+ return { text, newest };
185
+ }
186
+
187
+ // A file changed this close to the signature can change again with the same size inside one timestamp
188
+ // tick (up to 10 ms on Linux, 1 s or 2 s on older filesystems), so its mtime cannot prove it unchanged:
189
+ // such an entry is never trusted and the next build bundles again (git's racily clean rule).
190
+ const RACY_MS = 2000;
191
+
192
+ /**
193
+ * Builds one function's source into what the server runs: the one owner every path that reaches a
194
+ * tenant calls (module docstring above).
195
+ *
196
+ * @param {object} args
197
+ * @param {string} args.entry the function's source file, absolute
198
+ * @param {string} args.projectDir the project's root, absolute (where esbuild, and the packages a
199
+ * function imports, are resolved from)
200
+ * @param {Map<string, {signature: string, result: object}>} [args.cache] keyed by `entry`: a build
201
+ * whose `inputs` still match their signature on disk is returned as before, nothing read or run
202
+ * again (substrio dev's Reloader keeps one across applies; substrio deploy and substrio push pass none, so
203
+ * each of their builds is current).
204
+ * @returns {Promise<{code: string, inputs: string[], bytes: number, warnings: object[], esbuildAvailable: boolean}>}
205
+ * `inputs` are relative to `projectDir`. `code` is the entry file's own bytes, unchanged, when
206
+ * esbuild does not resolve, or resolves but finds nothing beyond the entry to bundle; otherwise
207
+ * the bundle esbuild wrote. `esbuildAvailable` is false only when esbuild does not resolve at
208
+ * all, the one local fact a caller has for turning the server's refusal of an unbundled import
209
+ * into a hint (deploy.mjs, local/reload.mjs): the server has no field naming that case.
210
+ * @param {(projectDir: string) => string | null} [args.resolveEsbuild] where esbuild's own path is,
211
+ * or null to use none; the real resolution (project first, then the CLI's own install) unless a
212
+ * test states its own, directly or through setDefaultEsbuildResolver.
213
+ */
214
+ export async function buildFunction({ entry, projectDir, cache, resolveEsbuild = defaultResolveEsbuild }) {
215
+ const name = basenameNoExt(entry);
216
+ const entryRelative = relative(projectDir, entry);
217
+
218
+ const cached = cache?.get(entry);
219
+ if (cached && cached.trusted && inputsSignature(projectDir, cached.result.inputs).text === cached.signature) {
220
+ return cached.result;
221
+ }
222
+
223
+ const esbuild = await loadEsbuild(projectDir, resolveEsbuild);
224
+ let result;
225
+ if (!esbuild) {
226
+ const source = readFileSync(entry, 'utf-8');
227
+ result = { code: source, inputs: [entryRelative], bytes: Buffer.byteLength(source, 'utf-8'), warnings: [], esbuildAvailable: false };
228
+ } else {
229
+ let built;
230
+ try {
231
+ built = await esbuild.build({
232
+ entryPoints: [entry],
233
+ bundle: true,
234
+ write: false,
235
+ platform: 'neutral',
236
+ target: 'es2022',
237
+ mainFields: ['module', 'main'],
238
+ conditions: ['import', 'default'],
239
+ external: EXTERNAL_BUILTINS,
240
+ metafile: true,
241
+ legalComments: 'none',
242
+ minify: false,
243
+ sourcemap: false,
244
+ absWorkingDir: projectDir,
245
+ logLevel: 'silent',
246
+ });
247
+ } catch (err) {
248
+ throw buildFailedError(name, err);
249
+ }
250
+ const inputs = Object.keys(built.metafile.inputs).sort();
251
+ if (inputs.length === 1 && inputs[0] === entryRelative) {
252
+ // Nothing beyond the entry was pulled in (every import was a builtin, left external, or
253
+ // there was none): the bytes sent are the entry file's own, not esbuild's rewrite of them.
254
+ const source = readFileSync(entry, 'utf-8');
255
+ result = { code: source, inputs, bytes: Buffer.byteLength(source, 'utf-8'), warnings: [], esbuildAvailable: true };
256
+ } else {
257
+ const [output] = built.outputFiles;
258
+ const warnings = built.warnings.map((w) => ({ text: w.text, location: w.location && place(w.location) }));
259
+ result = { code: output.text, inputs, bytes: Buffer.byteLength(output.text, 'utf-8'), warnings, esbuildAvailable: true };
260
+ }
261
+ }
262
+
263
+ if (cache) {
264
+ const signature = inputsSignature(projectDir, result.inputs);
265
+ cache.set(entry, { signature: signature.text, trusted: signature.newest < Date.now() - RACY_MS, result });
266
+ }
267
+ return result;
268
+ }
269
+
270
+ function basenameNoExt(file) {
271
+ const base = file.slice(file.lastIndexOf('/') + 1);
272
+ return base.endsWith('.js') ? base.slice(0, -3) : base;
273
+ }
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Substrio CLI: the roles and sign-in providers an app declares in substrio.toml.
3
+ *
4
+ * [roles.staff] role.create / role.update, by name
5
+ * description = "Venue staff"
6
+ * inherits = ["editor"] one role (the server's roles inherit one); a name or a one-item array
7
+ * grants = ["data.read"] optional, as role.create takes them
8
+ * denies = ["data.delete"]
9
+ *
10
+ * [auth.providers.google] auth.provider.update, by provider name
11
+ * client_id = "1234.apps.googleusercontent.com"
12
+ * client_secret_env = "GOOGLE_CLIENT_SECRET" the NAME of an environment variable; the secret itself
13
+ * is never written in the file (client_secret is refused)
14
+ * redirect_uri, scopes, provider_url, enabled optional, as auth.provider.update takes them
15
+ * client_id_env, redirect_uri_env, scopes_env, provider_url_env
16
+ * a value that differs between environments (the callback of a
17
+ * development site and of the live one): the NAME of the variable
18
+ * that holds it, in place of the value (never both)
19
+ * optional = true a variable that is not set leaves its key as it is on the server,
20
+ * and a provider whose client id is not set is skipped; each is
21
+ * reported (`unset`), never silent. Without it a variable that is
22
+ * not set refuses the config
23
+ *
24
+ * Applied idempotently by `substrio deploy` and `substrio dev`: a role is created, or
25
+ * updated when it exists; a provider is set (the server keeps a secret that is
26
+ * not sent again). A role or provider the file does not name is left alone.
27
+ * The secret is read from the environment when the file is read and goes to
28
+ * the server only; it is in no output, no error and no fingerprint (a hash
29
+ * stands for it). Nothing here talks to the server except applyIdentity,
30
+ * through the `invoke` it is given.
31
+ */
32
+
33
+ import { sha256 } from './blobs.mjs';
34
+ import { CliError, ErrorCode } from './cli.mjs';
35
+
36
+ /** The HTTP status of "that role exists already" (role.create). */
37
+ const EXISTS = 409;
38
+
39
+ /** What a declared item is. */
40
+ export const IdentityKind = Object.freeze({
41
+ ROLE: 'role',
42
+ PROVIDER: 'provider',
43
+ });
44
+
45
+ /** The keys of one [roles.NAME] table. */
46
+ export const RoleKey = Object.freeze({
47
+ DESCRIPTION: 'description',
48
+ INHERITS: 'inherits',
49
+ GRANTS: 'grants',
50
+ DENIES: 'denies',
51
+ });
52
+ const ROLE_KEYS = new Set(Object.values(RoleKey));
53
+
54
+ /** The keys of one [auth.providers.NAME] table. */
55
+ export const ProviderKey = Object.freeze({
56
+ CLIENT_ID: 'client_id',
57
+ CLIENT_SECRET_ENV: 'client_secret_env',
58
+ REDIRECT_URI: 'redirect_uri',
59
+ SCOPES: 'scopes',
60
+ PROVIDER_URL: 'provider_url',
61
+ ENABLED: 'enabled',
62
+ OPTIONAL: 'optional',
63
+ });
64
+ /** A value key read from the environment: `<key>_env` names the variable that holds it. */
65
+ const FROM_ENV = '_env';
66
+ /** The value keys of a provider, in the order they are sent; the secret is only ever read from the environment. */
67
+ const VALUE_KEYS = Object.freeze([ProviderKey.CLIENT_ID, 'client_secret', ProviderKey.REDIRECT_URI, ProviderKey.SCOPES, ProviderKey.PROVIDER_URL]);
68
+ const PROVIDER_KEYS = new Set([...Object.values(ProviderKey), ...VALUE_KEYS.map((key) => key + FROM_ENV)]);
69
+
70
+ /** The key a provider's secret must not be written under; the reference replaces it. */
71
+ export const INLINE_SECRET_KEY = 'client_secret';
72
+ /** The [auth] key that holds the provider tables (settings.mjs leaves it out of the auth settings). */
73
+ export const PROVIDERS_KEY = 'providers';
74
+
75
+ const isTable = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
76
+ const invalid = (message) => new CliError(`substrio.toml: ${message}`, { code: ErrorCode.INVALID_ARGUMENT });
77
+
78
+ // A table's keys must all be known; names the first that is not and what the table takes.
79
+ function checkKeys(where, table, known) {
80
+ const unknown = Object.keys(table).find((key) => !known.has(key));
81
+ if (unknown !== undefined) throw invalid(`${where} has unknown key "${unknown}"; it takes ${[...known].join(', ')}`);
82
+ }
83
+
84
+ function checkText(where, key, value) {
85
+ if (typeof value !== 'string' || value === '') throw invalid(`${where}: ${key} must be a non-empty string`);
86
+ return value;
87
+ }
88
+
89
+ /**
90
+ * The value `key` of `table` takes: written in the file, or read from the environment variable its `<key>_env` names
91
+ * (`env`). undefined when neither is written, null when the variable is not set. Both written, or a variable name
92
+ * that is not a non-empty string, throws `fail(message)`. The one reading of a `<key>_env` key: the provider tables
93
+ * here and the settings tables (settings.mjs).
94
+ */
95
+ export function valueOrEnv(where, table, key, env, fail = invalid) {
96
+ const named = key + FROM_ENV;
97
+ if (table[named] === undefined) return table[key];
98
+ if (table[key] !== undefined) throw fail(`${where}: ${key} and ${named} are both written; write one`);
99
+ const variable = table[named];
100
+ if (typeof variable !== 'string' || variable === '') throw fail(`${where}: ${named} must be a non-empty string`);
101
+ const value = env[variable];
102
+ return typeof value === 'string' && value !== '' ? value : null;
103
+ }
104
+
105
+ /** The refusal of a `<key>_env` whose variable is not set; it names the variable, never a value. */
106
+ export const unsetMessage = (where, table, key) => `${where}: ${key}${FROM_ENV} names ${table[key + FROM_ENV]}, which is not set in the environment`;
107
+
108
+ function checkList(where, key, value) {
109
+ if (!Array.isArray(value) || value.some((item) => typeof item !== 'string' || item === '')) {
110
+ throw invalid(`${where}: ${key} must be an array of non-empty strings`);
111
+ }
112
+ return value;
113
+ }
114
+
115
+ // The one role a role inherits: a name, or an array of one name (the server's roles inherit one role).
116
+ function inheritedRole(where, value) {
117
+ if (typeof value === 'string') return checkText(where, RoleKey.INHERITS, value);
118
+ const names = checkList(where, RoleKey.INHERITS, value);
119
+ if (names.length > 1) {
120
+ throw invalid(`${where}: inherits names ${names.length} roles (${names.join(', ')}); a role inherits one role, and the server has no more`);
121
+ }
122
+ return names[0] ?? null;
123
+ }
124
+
125
+ /**
126
+ * The roles substrio.toml declares, in file order (pure): [{name, description?, inherits?, grants?, denies?}],
127
+ * only the keys written. Throws a CliError naming the table at fault.
128
+ */
129
+ export function declaredRoles(config) {
130
+ const roles = config.roles;
131
+ if (roles === undefined || roles === null) return [];
132
+ if (!isTable(roles)) throw invalid('[roles] must be a table of [roles.NAME] tables');
133
+ return Object.entries(roles).map(([name, table]) => {
134
+ const where = `[roles.${name}]`;
135
+ if (!isTable(table)) throw invalid(`${where} must be a table`);
136
+ checkKeys(where, table, ROLE_KEYS);
137
+ const role = { name };
138
+ if (table[RoleKey.DESCRIPTION] !== undefined) {
139
+ if (typeof table[RoleKey.DESCRIPTION] !== 'string') throw invalid(`${where}: description must be a string`);
140
+ role.description = table[RoleKey.DESCRIPTION];
141
+ }
142
+ if (table[RoleKey.INHERITS] !== undefined) {
143
+ const inherits = inheritedRole(where, table[RoleKey.INHERITS]);
144
+ if (inherits !== null) role.inherits = inherits;
145
+ }
146
+ if (table[RoleKey.GRANTS] !== undefined) role.grants = checkList(where, RoleKey.GRANTS, table[RoleKey.GRANTS]);
147
+ if (table[RoleKey.DENIES] !== undefined) role.denies = checkList(where, RoleKey.DENIES, table[RoleKey.DENIES]);
148
+ return role;
149
+ });
150
+ }
151
+
152
+ /**
153
+ * The providers substrio.toml's [auth.providers.NAME] tables declare, in file order:
154
+ * [{provider, client_id, client_secret?, redirect_uri?, scopes?, provider_url?, enabled?}], each value written in the
155
+ * file or read from the environment variable its `<key>_env` names (`env`, process.env by default; the secret only
156
+ * so). A variable that is not set refuses the config, or, in an `optional` table, leaves its key out and is pushed
157
+ * onto `unset` as {provider, variables, skipped} (skipped: the client id was one of them, so the provider is not
158
+ * sent). Throws a CliError naming the table at fault; the secret itself is never in a message.
159
+ */
160
+ export function declaredProviders(config, env = process.env, unset = []) {
161
+ const providers = config.auth?.[PROVIDERS_KEY];
162
+ if (providers === undefined || providers === null) return [];
163
+ if (!isTable(providers)) throw invalid(`[auth.${PROVIDERS_KEY}] must be a table of [auth.${PROVIDERS_KEY}.NAME] tables`);
164
+ return Object.entries(providers).flatMap(([name, table]) => {
165
+ const where = `[auth.${PROVIDERS_KEY}.${name}]`;
166
+ if (!isTable(table)) throw invalid(`${where} must be a table`);
167
+ if (Object.hasOwn(table, INLINE_SECRET_KEY)) {
168
+ throw invalid(`${where}: ${INLINE_SECRET_KEY} must not be written in the file; name the environment variable that holds it with ${ProviderKey.CLIENT_SECRET_ENV}`);
169
+ }
170
+ checkKeys(where, table, PROVIDER_KEYS);
171
+ const optional = table[ProviderKey.OPTIONAL] ?? false;
172
+ if (typeof optional !== 'boolean') throw invalid(`${where}: optional must be true or false`);
173
+ const provider = { provider: name };
174
+ const missing = [];
175
+ for (const key of VALUE_KEYS) {
176
+ const value = valueOrEnv(where, table, key, env);
177
+ if (value === undefined) continue;
178
+ if (value !== null) provider[key] = checkText(where, key, value);
179
+ else if (optional) missing.push(table[key + FROM_ENV]);
180
+ else throw invalid(unsetMessage(where, table, key));
181
+ }
182
+ const skipped = provider.client_id === undefined;
183
+ if (missing.length > 0) unset.push({ provider: name, variables: missing, skipped });
184
+ if (skipped) {
185
+ if (missing.length > 0) return [];
186
+ throw invalid(`${where}: client_id must be a non-empty string`);
187
+ }
188
+ if (table[ProviderKey.ENABLED] !== undefined) {
189
+ if (typeof table[ProviderKey.ENABLED] !== 'boolean') throw invalid(`${where}: enabled must be true or false`);
190
+ provider.enabled = table[ProviderKey.ENABLED];
191
+ }
192
+ return [provider];
193
+ });
194
+ }
195
+
196
+ /**
197
+ * Everything the project declares of roles and providers: {roles, providers, unset}; each empty when none
198
+ * (`unset`: the variables an optional provider named that were not set, declaredProviders).
199
+ */
200
+ export function declaredIdentity(config, env = process.env) {
201
+ const unset = [];
202
+ return { roles: declaredRoles(config), providers: declaredProviders(config, env, unset), unset };
203
+ }
204
+
205
+ /** The line that reports one entry of `unset`; it names variables, never a value. */
206
+ export const unsetLine = ({ provider, variables, skipped }) =>
207
+ `auth provider ${provider}: ${variables.join(', ')} not set${skipped ? ', so it is not set this time' : ', so the server keeps what it has for it'}`;
208
+
209
+ /** Whether `identity` declares anything. */
210
+ export const hasIdentity = (identity) => identity.roles.length > 0 || identity.providers.length > 0;
211
+
212
+ /**
213
+ * What stands for `identity` when it is compared with an earlier one (pure): the declaration with each
214
+ * secret replaced by its hash, so a changed secret is a change and no secret is held anywhere else.
215
+ */
216
+ export function identityFingerprint(identity) {
217
+ return JSON.stringify({
218
+ roles: identity.roles,
219
+ providers: identity.providers.map(({ client_secret: secret, ...rest }) => ({ ...rest, secret: secret === undefined ? null : sha256(secret) })),
220
+ });
221
+ }
222
+
223
+ /**
224
+ * Applies `identity` with `invoke(name, input)` (throws a CliError on a failed call): each role created, or
225
+ * updated when it exists, then each provider set. Idempotent. `done` receives what was applied as it goes, so a
226
+ * failure keeps its progress. Returns {applied: [{kind, name, created}]}.
227
+ */
228
+ export async function applyIdentity(invoke, identity, done = []) {
229
+ for (const role of identity.roles) {
230
+ let created = true;
231
+ try {
232
+ await invoke('role.create', role);
233
+ } catch (err) {
234
+ if (!(err instanceof CliError && err.status === EXISTS)) throw err;
235
+ created = false;
236
+ await invoke('role.update', role);
237
+ }
238
+ done.push({ kind: IdentityKind.ROLE, name: role.name, created });
239
+ }
240
+ for (const provider of identity.providers) {
241
+ await invoke('auth.provider.update', provider);
242
+ done.push({ kind: IdentityKind.PROVIDER, name: provider.provider, created: false });
243
+ }
244
+ return { applied: done };
245
+ }
246
+
247
+ /** The names of one kind among `applied`, for the line that reports them. */
248
+ export const appliedNames = (applied, kind) => applied.filter((a) => a.kind === kind).map((a) => a.name);