@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
package/src/apply.mjs ADDED
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Substrio CLI: applying the app. The one owner of what `substrio push`, `substrio dev`
3
+ * and `substrio test` share: read substrio.toml's manifest, run the build, collect and
4
+ * hash the app's files, make a version of them and make it live, or deploy one
5
+ * function on its own.
6
+ *
7
+ * Nothing here prints or exits. A failure is a typed CliError, so the push
8
+ * reports it and exits, and a long-running `substrio dev` reports it and goes on.
9
+ * The server calls go through an `invoke(name, input)` the caller passes in:
10
+ * `substrio push` hands in the one that ends the run on a failed call, `substrio dev`
11
+ * the one that throws (serverInvoker).
12
+ */
13
+
14
+ import { spawnSync } from 'node:child_process';
15
+ import { existsSync, readFileSync } from 'node:fs';
16
+ import { join, resolve } from 'node:path';
17
+
18
+ import { errorText } from './api.mjs';
19
+ import { DEFAULT_CONCURRENCY, uploadBlobs } from './blobs.mjs';
20
+ import { CliError, ErrorCode } from './cli.mjs';
21
+ import { OpError, callOperation } from './commands/op.mjs';
22
+ import { loadConfig, parseTOML } from './config.mjs';
23
+ import { COLLECTIONS_MEMBER, checkVersionCarriesCollections, versionCollectionSteps } from './collections.mjs';
24
+ import { blobsByHash, collectApp, declaredFunction, declaredLedger, declaredPermissions, manifestOf, versionBody, withSiteVars } from './manifest.mjs';
25
+ import { carrySettings, checkSettings, declaredPlans, declaredSettings } from './settings.mjs';
26
+
27
+ /** What the server says of a source it checked (function.check): the severity of a diagnostic. */
28
+ export const Severity = Object.freeze({
29
+ ERROR: 'error',
30
+ WARNING: 'warning',
31
+ INFO: 'info',
32
+ });
33
+
34
+ /**
35
+ * An `invoke(name, input)` that throws: the server's data on success, else a
36
+ * CliError holding the server's typed error (its code and HTTP status), and an
37
+ * OpError of a name or input that does not fit as a CliError of INVALID_ARGUMENT.
38
+ */
39
+ export function serverInvoker(api, ops) {
40
+ return async (name, input) => {
41
+ let result;
42
+ try {
43
+ result = await callOperation(api, ops, name, input);
44
+ } catch (err) {
45
+ if (err instanceof OpError) throw new CliError(err.message, { code: ErrorCode.INVALID_ARGUMENT });
46
+ throw err;
47
+ }
48
+ if (!result.ok) {
49
+ throw CliError.fromResponse(`${result.request.method} ${result.request.path}: ${errorText(result.data, result.status)}`,
50
+ result.data, result.status);
51
+ }
52
+ return result.data;
53
+ };
54
+ }
55
+
56
+ /** substrio.toml's push tables, checked; throws a CliError naming the table at fault. */
57
+ export function readManifest(config) {
58
+ try {
59
+ return manifestOf(config);
60
+ } catch (err) {
61
+ throw new CliError(`substrio.toml: ${err.message}`);
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Runs each build step through the shell in its directory, its output on this
67
+ * process's stdout and stderr. `announce(step)` is called before each. A step
68
+ * that fails is the project's failure (ErrorCode.UNKNOWN), not a usage error.
69
+ */
70
+ export function runBuild(steps, cwd, announce = () => {}) {
71
+ for (const step of steps) {
72
+ announce(step);
73
+ const result = spawnSync(step.run, { cwd: resolve(cwd, step.dir), shell: true, stdio: 'inherit' });
74
+ if (result.error) {
75
+ throw new CliError(`Build step in ${step.dir} could not start: ${result.error.message}`, { code: ErrorCode.UNKNOWN });
76
+ }
77
+ if (result.status !== 0) {
78
+ throw new CliError(`Build step in ${step.dir} failed (${result.signal || `exit ${result.status}`}): ${step.run}`,
79
+ { code: ErrorCode.UNKNOWN });
80
+ }
81
+ }
82
+ }
83
+
84
+ /**
85
+ * The app on disk as one version would hold it: the files collected and
86
+ * hashed, the settings and plans substrio.toml declares, and the POST
87
+ * /v1/_versions body that carries them. The declared collections are in
88
+ * `collections` and not in the body: publishVersion puts them there when the
89
+ * version is to carry them.
90
+ * `functionCache` is forwarded to collectApp (substrio dev's Reloader keeps one
91
+ * across applies; substrio push passes none, so every function is built fresh).
92
+ * `resolveEsbuild` is forwarded to collectApp, then to every buildFunction call it makes.
93
+ * Returns {app, settings, plans, collections, body, byHash}; throws a CliError.
94
+ */
95
+ export async function loadApp(config, manifest, cwd, message, functionCache, resolveEsbuild) {
96
+ let app;
97
+ try {
98
+ app = await collectApp(config, manifest, cwd, functionCache, resolveEsbuild);
99
+ } catch (err) {
100
+ throw err instanceof CliError ? err : new CliError(err.message);
101
+ }
102
+ let settings;
103
+ let plans;
104
+ let permissions;
105
+ let ledger;
106
+ try {
107
+ settings = declaredSettings(config);
108
+ plans = declaredPlans(config, cwd);
109
+ permissions = declaredPermissions(config);
110
+ ledger = declaredLedger(config);
111
+ } catch (err) {
112
+ throw new CliError(`substrio.toml: ${err.message}`);
113
+ }
114
+ const body = carrySettings(versionBody(app, manifest, message), settings, plans);
115
+ // [permissions] and [ledger] ride the version: the target declares them first when it
116
+ // goes live (append only), so a promote switches them with the code that uses them.
117
+ if (permissions !== null) body.permissions = permissions;
118
+ if (ledger !== null) body.ledger = ledger;
119
+ return { app, settings, plans, collections: app.collections, body, byHash: blobsByHash(app) };
120
+ }
121
+
122
+ /**
123
+ * Makes a version of a loaded app on the server: the settings' keys checked
124
+ * against its schemas, version.create, and only the blobs it lacks uploaded.
125
+ * With `carryCollections` the declared collections go in the version too (its
126
+ * "collections" member), which a server whose version.create does not take
127
+ * refuses before anything is sent; without it they are the caller's to apply
128
+ * (applyCollections).
129
+ * Returns {result (version.create's answer), number, missing, uploaded}.
130
+ */
131
+ export async function publishVersion(api, ops, loaded, invoke, concurrency = DEFAULT_CONCURRENCY, { carryCollections = false } = {}) {
132
+ if (loaded.settings.length > 0) {
133
+ try {
134
+ checkSettings(loaded.settings, ops);
135
+ } catch (err) {
136
+ throw new CliError(`substrio.toml: ${err.message}`);
137
+ }
138
+ }
139
+ let body = loaded.body;
140
+ if (carryCollections && loaded.collections.length > 0) {
141
+ checkVersionCarriesCollections(ops);
142
+ body = { ...body, [COLLECTIONS_MEMBER]: versionCollectionSteps(ops, loaded.collections) };
143
+ }
144
+ const result = await invoke('version.create', body);
145
+ const missing = result.missing || [];
146
+ const uploaded = await uploadBlobs(api, ops, missing, loaded.byHash, concurrency);
147
+ return { result, number: result.version.number, missing, uploaded };
148
+ }
149
+
150
+ /**
151
+ * Runs version `number` where it should: in sandbox `sandbox`, or, with none,
152
+ * on the app itself (`backup` false skips the data backup a promote takes, which
153
+ * a development server has no use for). `direct` is "replace" or "keep", over
154
+ * the server's default "refuse", for a function deployed directly (outside a
155
+ * version) that this would overwrite or delete. Returns version.live.set's answer.
156
+ */
157
+ export function makeLive(invoke, number, { sandbox = null, backup = true, direct = undefined } = {}) {
158
+ return invoke('version.live.set', {
159
+ version: number,
160
+ ...(sandbox === null ? { backup } : { sandbox }),
161
+ ...(direct ? { direct } : {}),
162
+ });
163
+ }
164
+
165
+ /**
166
+ * The request function.check and function.deploy take for one function of the app, outside a version:
167
+ * its site_vars filled from `siteDir` as the server fills them from a version's site (withSiteVars).
168
+ */
169
+ export function functionRequest({ name, script, declared, siteDir }) {
170
+ const own = declaredFunction(name, declared);
171
+ if (own.site_vars && !siteDir) throw new Error(`functions.${name}.site_vars: no site folder to read them from`);
172
+ return { name, script, ...withSiteVars(own, siteDir) };
173
+ }
174
+
175
+ /**
176
+ * Deploys one function on its own: its source is checked first, so a source that
177
+ * does not compile is refused with the server's diagnostics (severity, message,
178
+ * file, line, column) and nothing changes. Returns {ok: true, deployed} or
179
+ * {ok: false, diagnostics}.
180
+ */
181
+ export async function deployFunction(invoke, fn) {
182
+ const request = functionRequest(fn);
183
+ const checked = await invoke('function.check', request);
184
+ if (!checked.ok) return { ok: false, diagnostics: checked.diagnostics };
185
+ return { ok: true, deployed: await invoke('function.deploy', request), diagnostics: checked.diagnostics };
186
+ }
187
+
188
+ /**
189
+ * substrio.toml and the config it makes (loadConfig), read strictly: a substrio.toml that
190
+ * does not parse is a typed error here, where loadConfig would warn and carry on
191
+ * with defaults. Returns {config, manifest}.
192
+ */
193
+ export function readApp(cwd) {
194
+ const file = join(cwd, 'substrio.toml');
195
+ if (existsSync(file)) {
196
+ try {
197
+ parseTOML(readFileSync(file, 'utf-8'));
198
+ } catch (err) {
199
+ throw new CliError(`substrio.toml: ${err.message}`);
200
+ }
201
+ }
202
+ const config = loadConfig(cwd);
203
+ return { config, manifest: readManifest(config) };
204
+ }
package/src/blobs.mjs ADDED
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Substrio CLI: content-addressed files, shared by `substrio site deploy` and
3
+ * `substrio push`.
4
+ *
5
+ * A file is named by the SHA-256 of its bytes. The server answers a manifest
6
+ * with the hashes it lacks, and only those are uploaded, a few at a time.
7
+ */
8
+
9
+ import { createHash } from 'node:crypto';
10
+ import { readdirSync, readFileSync } from 'node:fs';
11
+ import { join, relative, sep } from 'node:path';
12
+
13
+ import { errorText } from './api.mjs';
14
+ import { CliError, ErrorCode } from './cli.mjs';
15
+ import { callOperation } from './commands/op.mjs';
16
+
17
+ // Files never published: OS metadata. substrio.toml replaces the list with
18
+ // [site] ignore (an array of file names) or site_ignore (a comma-separated
19
+ // string).
20
+ export const DEFAULT_IGNORE = ['.DS_Store', 'Thumbs.db'];
21
+ export const DEFAULT_CONCURRENCY = 8;
22
+
23
+ export function formatSize(bytes) {
24
+ if (bytes < 1024) return `${bytes} B`;
25
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
26
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
27
+ }
28
+
29
+ export function sha256(data) {
30
+ return createHash('sha256').update(data).digest('hex');
31
+ }
32
+
33
+ // The file names a site leaves out, from substrio.toml.
34
+ export function ignoredNames(config) {
35
+ const listed = config.site?.ignore;
36
+ if (Array.isArray(listed)) return new Set(listed.map(String));
37
+ const configured = config.site_ignore;
38
+ if (typeof configured === 'string') {
39
+ return new Set(configured.split(',').map((s) => s.trim()).filter(Boolean));
40
+ }
41
+ return new Set(DEFAULT_IGNORE);
42
+ }
43
+
44
+ // One file on disk as { abs, size, hash }.
45
+ export function hashFile(abs) {
46
+ const data = readFileSync(abs);
47
+ return { abs, size: data.length, hash: sha256(data) };
48
+ }
49
+
50
+ // Every file under root, as { path (posix, relative), abs, size, hash }.
51
+ export function collect(root, ignore) {
52
+ const files = [];
53
+ const walk = (dir) => {
54
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
55
+ if (ignore.has(entry.name)) continue;
56
+ const abs = join(dir, entry.name);
57
+ if (entry.isDirectory()) walk(abs);
58
+ else if (entry.isFile()) {
59
+ files.push({ path: relative(root, abs).split(sep).join('/'), ...hashFile(abs) });
60
+ }
61
+ }
62
+ };
63
+ walk(root);
64
+ return files;
65
+ }
66
+
67
+ /**
68
+ * Uploads the blobs the server lacks, `concurrency` at a time, each by the
69
+ * registry's site.blob.upload (its bytes the body). `ops` is the server's
70
+ * catalog (loadOperations).
71
+ * @param {import('./api.mjs').SubstrioAPI} api
72
+ * @param {object[]} ops
73
+ * @param {Iterable<string>} missing hashes the server asked for
74
+ * @param {Map<string, {abs?: string, data?: Buffer, size: number, path?: string}>} byHash
75
+ * @returns {Promise<{count: number, bytes: number}>}
76
+ * Throws on the first failed upload (a CliError with the server's error) or
77
+ * a hash the manifest did not send.
78
+ */
79
+ export async function uploadBlobs(api, ops, missing, byHash, concurrency = DEFAULT_CONCURRENCY) {
80
+ const queue = [...missing];
81
+ let bytes = 0;
82
+ let count = 0;
83
+ const worker = async () => {
84
+ for (let hash = queue.shift(); hash; hash = queue.shift()) {
85
+ const file = byHash.get(hash);
86
+ if (!file) throw new CliError(`The server asked for ${hash}, which this upload does not hold`, { code: ErrorCode.UNKNOWN });
87
+ // A file made in memory (a precompressed sidecar) carries its bytes.
88
+ const result = await callOperation(api, ops, 'site.blob.upload', { hash }, 'PUT', {
89
+ body: file.data ?? readFileSync(file.abs),
90
+ headers: { 'Content-Type': 'application/octet-stream' },
91
+ });
92
+ if (!result.ok) {
93
+ throw CliError.fromResponse(`Upload of ${file.path || file.abs} failed: ${errorText(result.data, result.status)}`,
94
+ result.data, result.status);
95
+ }
96
+ bytes += file.size;
97
+ count++;
98
+ }
99
+ };
100
+ await Promise.all(Array.from({ length: Math.min(Math.max(1, concurrency), queue.length) }, worker));
101
+ return { count, bytes };
102
+ }