@terminus-ai/cli 0.0.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 (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1055 -0
  3. package/bin/agent-discovery.mjs +71 -0
  4. package/bin/agent-icon.mjs +77 -0
  5. package/bin/agent-models.mjs +77 -0
  6. package/bin/agent-type.mjs +51 -0
  7. package/bin/agentdev.mjs +657 -0
  8. package/bin/app-route-script.mjs +59 -0
  9. package/bin/app-runtime-contract.mjs +2 -0
  10. package/bin/appdev-remote.mjs +346 -0
  11. package/bin/appdev.mjs +4446 -0
  12. package/bin/apps.mjs +5512 -0
  13. package/bin/capability-calls.mjs +437 -0
  14. package/bin/capsule-data.mjs +260 -0
  15. package/bin/client.mjs +189 -0
  16. package/bin/commands.mjs +1194 -0
  17. package/bin/dev-capsules.mjs +1599 -0
  18. package/bin/dev-contract.mjs +262 -0
  19. package/bin/dev-data.mjs +287 -0
  20. package/bin/dev-members.mjs +18 -0
  21. package/bin/dev-net.mjs +316 -0
  22. package/bin/dev-notification-popup.mjs +628 -0
  23. package/bin/dev-ports.mjs +567 -0
  24. package/bin/dev-server-binding.mjs +35 -0
  25. package/bin/dev-server-ops.mjs +1086 -0
  26. package/bin/dev-ui/IoskeleyMono-400.woff2 +0 -0
  27. package/bin/dev-ui/IoskeleyMono-600.woff2 +0 -0
  28. package/bin/dev-ui/OFL.txt +92 -0
  29. package/bin/dev-ui/agent-robot.webp +0 -0
  30. package/bin/dev-ui/app.js +5217 -0
  31. package/bin/dev-ui/highlight.js +195 -0
  32. package/bin/dev-ui/index.html +34 -0
  33. package/bin/dev-ui/style.css +3640 -0
  34. package/bin/devlint.mjs +112 -0
  35. package/bin/devserver.mjs +2127 -0
  36. package/bin/devtriggers.mjs +367 -0
  37. package/bin/endpoints.mjs +156 -0
  38. package/bin/errors.mjs +61 -0
  39. package/bin/files.mjs +169 -0
  40. package/bin/horizontal-capabilities/v1/contract.json +280 -0
  41. package/bin/http.mjs +500 -0
  42. package/bin/lint-manifests/justbash-commands.json +88 -0
  43. package/bin/lint-manifests/python-stdlib.json +295 -0
  44. package/bin/login-page.mjs +488 -0
  45. package/bin/schedules.mjs +664 -0
  46. package/bin/server-sandbox.mjs +204 -0
  47. package/bin/servicedev.mjs +425 -0
  48. package/bin/sync.mjs +357 -0
  49. package/bin/terminus.js +3666 -0
  50. package/bin/toolchain.mjs +125 -0
  51. package/bin/vendor/app-runtime-v1/app-host.json +124 -0
  52. package/bin/vendor/app-runtime-v1/capability-calls.json +412 -0
  53. package/bin/vendor/app-runtime-v1/doors.json +2867 -0
  54. package/bin/vendor/appd/node-harness.mjs +209 -0
  55. package/bin/vendor/appd/python-harness.py +12 -0
  56. package/bin/vendor/appd/server-protocol.json +84 -0
  57. package/bin/vendor/where.mjs +541 -0
  58. package/bin/versioning.mjs +72 -0
  59. package/bin/write-rules.mjs +398 -0
  60. package/package.json +41 -0
@@ -0,0 +1,437 @@
1
+ /**
2
+ * The release-capability compiler, driven by the SDK's call table.
3
+ *
4
+ * `capability-calls.json` (the app-runtime conformance bundle, vendored under
5
+ * bin/vendor/app-runtime-v1/) says which literal SDK call sites compile which
6
+ * release capabilities, which automation steps do, and which server-code calls
7
+ * do. This module applies that table and nothing else: a new call or grant is
8
+ * a new row there, not a new regular expression here. What the table states
9
+ * only in prose (its `checks`, and the server.run `unless`) stays code, in the
10
+ * caller: the horizontal registry, the one version per capability, the
11
+ * declared egress templates, and the server entry's exports.
12
+ */
13
+
14
+ import { readFileSync } from "node:fs";
15
+
16
+ import { CliError } from "./client.mjs";
17
+
18
+ export const CAPABILITY_CALLS = JSON.parse(readFileSync(
19
+ new URL("./vendor/app-runtime-v1/capability-calls.json", import.meta.url),
20
+ "utf8",
21
+ ));
22
+
23
+ /** The release shell switches the table compiles (`shell.notifications`, from
24
+ * a notifications.create call or a notification.create step). They are the
25
+ * release's, never the author's: clone and pull write one into terminus.json
26
+ * only where the author wrote it themselves. */
27
+ export const COMPILED_SHELL_KEYS = new Set(
28
+ [...CAPABILITY_CALLS.calls, ...CAPABILITY_CALLS.workload_steps]
29
+ .flatMap((row) => [row.grant.capability, row.grant.also?.capability])
30
+ .filter((capability) => capability?.startsWith("shell."))
31
+ .map((capability) => capability.slice("shell.".length)),
32
+ );
33
+
34
+ const SOURCES = CAPABILITY_CALLS.sources;
35
+ const SOURCE_EXTENSIONS = new Set(SOURCES.extensions);
36
+ const SKIPPED_DIRECTORIES = new Set(SOURCES.skip_directories);
37
+ const SKIPPED_FILES = new RegExp(SOURCES.skip_files, "u");
38
+ const SDK_MODULE = "@terminus-ai/app-sdk";
39
+
40
+ /** Whether a package file is scanned for SDK call sites. */
41
+ export function capabilitySourceFile(filePath) {
42
+ const parts = filePath.replaceAll("\\", "/").split("/");
43
+ const basename = parts.at(-1) ?? "";
44
+ const dot = basename.lastIndexOf(".");
45
+ const extension = dot > 0 ? basename.slice(dot).toLowerCase() : "";
46
+ return SOURCE_EXTENSIONS.has(extension)
47
+ && !parts.some((part) => SKIPPED_DIRECTORIES.has(part))
48
+ && !SKIPPED_FILES.test(basename);
49
+ }
50
+
51
+ /** Remove comments while preserving string literals and byte offsets. This is
52
+ * deliberately a small lexical pass rather than a JavaScript parser: the
53
+ * compiler accepts only direct SDK calls with literal arguments. */
54
+ export function maskCodeComments(source) {
55
+ const output = [...source];
56
+ let quote = null;
57
+ let escaped = false;
58
+ for (let index = 0; index < source.length; index += 1) {
59
+ const char = source[index];
60
+ const next = source[index + 1];
61
+ if (quote) {
62
+ if (escaped) escaped = false;
63
+ else if (char === "\\") escaped = true;
64
+ else if (char === quote) quote = null;
65
+ continue;
66
+ }
67
+ if (char === '"' || char === "'" || char === "`") {
68
+ quote = char;
69
+ continue;
70
+ }
71
+ if (char === "/" && next === "/") {
72
+ output[index] = " ";
73
+ output[index + 1] = " ";
74
+ index += 2;
75
+ while (index < source.length && source[index] !== "\n") {
76
+ output[index] = " ";
77
+ index += 1;
78
+ }
79
+ index -= 1;
80
+ continue;
81
+ }
82
+ if (char === "/" && next === "*") {
83
+ output[index] = " ";
84
+ output[index + 1] = " ";
85
+ index += 2;
86
+ while (index < source.length && !(source[index] === "*" && source[index + 1] === "/")) {
87
+ if (source[index] !== "\n") output[index] = " ";
88
+ index += 1;
89
+ }
90
+ if (index < source.length) {
91
+ output[index] = " ";
92
+ output[index + 1] = " ";
93
+ index += 1;
94
+ }
95
+ }
96
+ }
97
+ return output.join("");
98
+ }
99
+
100
+ function escapeRegExp(value) {
101
+ return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
102
+ }
103
+
104
+ /** The names a surface is called by in one file: the surface itself, and any
105
+ * `import { surface as alias } from "@terminus-ai/app-sdk"` (beside a default
106
+ * import or not). A longer member path ending in a binding matches too
107
+ * (`TerminusSDK.net.fetch(...)`). */
108
+ export function sdkSurfaceBindings(code, surface) {
109
+ const bindings = new Set([surface]);
110
+ const imports = new RegExp(
111
+ `\\bimport\\s*(?:[A-Za-z_$][A-Za-z0-9_$]*\\s*,\\s*)?\\{([^}]*)\\}\\s*from\\s*(["'])${escapeRegExp(SDK_MODULE)}\\2`,
112
+ "gu",
113
+ );
114
+ const specifier = new RegExp(
115
+ `^(?:type\\s+)?${escapeRegExp(surface)}(?:\\s+as\\s+([A-Za-z_$][A-Za-z0-9_$]*))?$`,
116
+ "u",
117
+ );
118
+ for (const match of code.matchAll(imports)) {
119
+ for (const part of match[1].split(",")) {
120
+ const binding = specifier.exec(part.trim());
121
+ if (binding) bindings.add(binding[1] ?? surface);
122
+ }
123
+ }
124
+ return bindings;
125
+ }
126
+
127
+ /** The leading literal arguments of a call whose argument list starts at
128
+ * `start` (just past its `(`): single- or double-quoted strings (never a
129
+ * template literal) and integer literals, in order, up to the first argument
130
+ * that is not one. */
131
+ function leadingLiterals(code, start, wanted) {
132
+ const values = [];
133
+ let index = start;
134
+ const skipSpace = () => {
135
+ while (index < code.length && /\s/u.test(code[index])) index += 1;
136
+ };
137
+ while (values.length < wanted) {
138
+ skipSpace();
139
+ const char = code[index];
140
+ let value;
141
+ if (char === '"' || char === "'") {
142
+ const end = code.indexOf(char, index + 1);
143
+ if (end < 0) break;
144
+ const content = code.slice(index + 1, end);
145
+ if (/[\\\n\r]/u.test(content)) break;
146
+ value = { kind: "string", text: content };
147
+ index = end + 1;
148
+ } else if (/[0-9]/u.test(char ?? "")) {
149
+ const match = /^[0-9]+/u.exec(code.slice(index));
150
+ value = { kind: "integer", text: match[0] };
151
+ index += match[0].length;
152
+ } else {
153
+ break;
154
+ }
155
+ skipSpace();
156
+ if (code[index] !== "," && code[index] !== ")") break;
157
+ values.push(value);
158
+ if (code[index] === ")") break;
159
+ index += 1;
160
+ }
161
+ return values;
162
+ }
163
+
164
+ /** Replace `{name}` placeholders from the call's literals; an integer
165
+ * argument that fills a whole string substitutes as a number. */
166
+ function substitute(template, values) {
167
+ if (typeof template === "string") {
168
+ const whole = /^\{([a-z_]+)\}$/u.exec(template);
169
+ if (whole && Object.hasOwn(values, whole[1])) return values[whole[1]];
170
+ return template.replace(/\{([a-z_]+)\}/gu, (placeholder, name) => (
171
+ Object.hasOwn(values, name) ? String(values[name]) : placeholder
172
+ ));
173
+ }
174
+ if (Array.isArray(template)) return template.map((item) => substitute(item, values));
175
+ if (template && typeof template === "object") {
176
+ return Object.fromEntries(
177
+ Object.entries(template).map(([key, value]) => [key, substitute(value, values)]),
178
+ );
179
+ }
180
+ return template;
181
+ }
182
+
183
+ /** A path into the compiled release: `shell.<field>` is the release's own
184
+ * shell section (there is no `capabilities.shell`), everything else lives
185
+ * under capabilities. */
186
+ function grantTarget(capability) {
187
+ const [head, ...rest] = capability.split(".");
188
+ return head === "shell"
189
+ ? { section: "shell", path: rest }
190
+ : { section: "capabilities", path: [head, ...rest] };
191
+ }
192
+
193
+ /**
194
+ * What the call sites and steps compiled so far: plain JSON-shaped groups
195
+ * keyed the way the table merges them. `apply` is the one place a grant row
196
+ * is interpreted; `release()` turns the groups into the wire sections.
197
+ */
198
+ export class CompiledGrants {
199
+ constructor() {
200
+ this.groups = new Map();
201
+ this.declared = new Map();
202
+ }
203
+
204
+ group(target, make) {
205
+ const key = `${target.section}:${target.path.join(".")}`;
206
+ if (!this.groups.has(key)) this.groups.set(key, { target, value: make() });
207
+ return this.groups.get(key).value;
208
+ }
209
+
210
+ /** Apply one grant row for one call's literal values. `where` names the
211
+ * call site for a conflict message. */
212
+ apply(grant, values, where, mergeKey = null) {
213
+ const target = grantTarget(grant.capability);
214
+ if (grant.requires_declared !== undefined) {
215
+ const needed = this.declared.get(grant.capability) ?? [];
216
+ needed.push({ value: substitute(grant.requires_declared.match(/\{[a-z_]+\}/u)[0], values), where });
217
+ this.declared.set(grant.capability, needed);
218
+ return;
219
+ }
220
+ if (grant.set !== undefined) {
221
+ const holder = this.group(target, () => ({ set: undefined }));
222
+ holder.set = substitute(grant.set, values);
223
+ return;
224
+ }
225
+ if (grant.add !== undefined) {
226
+ const added = substitute(grant.add, values);
227
+ if (added && typeof added === "object") {
228
+ const sets = this.group(target, () => ({ fields: new Map() }));
229
+ for (const [field, value] of Object.entries(added)) {
230
+ if (!sets.fields.has(field)) sets.fields.set(field, new Set());
231
+ sets.fields.get(field).add(value);
232
+ }
233
+ } else {
234
+ this.group(target, () => ({ values: new Set() })).values.add(added);
235
+ }
236
+ return;
237
+ }
238
+ const entry = substitute(grant.entry ?? {}, values);
239
+ const by = grant.merge_by ?? mergeKey;
240
+ const key = grant.key !== undefined ? substitute(grant.key, values) : entry[by];
241
+ const keyed = this.group(target, () => ({ entries: new Map(), keyedObject: grant.key !== undefined }));
242
+ const existing = keyed.entries.get(key);
243
+ if (!existing) {
244
+ keyed.entries.set(key, structuredClone(entry));
245
+ } else {
246
+ for (const [field, value] of Object.entries(entry)) {
247
+ if (Array.isArray(value)) {
248
+ existing[field] = [...new Set([...(existing[field] ?? []), ...value])];
249
+ } else if (existing[field] === undefined) {
250
+ existing[field] = value;
251
+ } else if (JSON.stringify(existing[field]) !== JSON.stringify(value)) {
252
+ throw new CliError(
253
+ `${where}: ${grant.capability} '${key}' is used with both ${field} ${existing[field]} and ${value}`
254
+ + " — a package uses one",
255
+ );
256
+ }
257
+ }
258
+ }
259
+ if (grant.also) this.apply(grant.also, values, where, by);
260
+ }
261
+
262
+ /** Values a `requires_declared` grant collected for `capability`. */
263
+ required(capability) {
264
+ return (this.declared.get(capability) ?? []).map((item) => item.value);
265
+ }
266
+
267
+ /** The compiled groups, as the release's sections: sets sorted, keyed
268
+ * entries sorted by key with their array fields sorted. */
269
+ release() {
270
+ const capabilities = {};
271
+ const shell = {};
272
+ for (const { target, value } of this.groups.values()) {
273
+ let compiled;
274
+ if (value.values) compiled = [...value.values].sort();
275
+ else if (value.fields) {
276
+ compiled = Object.fromEntries([...value.fields].map(([field, set]) => [field, [...set].sort()]));
277
+ } else if (value.entries) {
278
+ const entries = [...value.entries].sort(([left], [right]) => (
279
+ String(left) < String(right) ? -1 : String(left) > String(right) ? 1 : 0
280
+ )).map(([key, entry]) => [key, Object.fromEntries(Object.entries(entry).map(
281
+ ([field, item]) => [field, Array.isArray(item) ? [...item].sort() : item],
282
+ ))]);
283
+ compiled = value.keyedObject ? Object.fromEntries(entries) : entries.map(([, entry]) => entry);
284
+ } else {
285
+ compiled = value.set;
286
+ }
287
+ let holder = target.section === "shell" ? shell : capabilities;
288
+ for (const segment of target.path.slice(0, -1)) {
289
+ holder[segment] ??= {};
290
+ holder = holder[segment];
291
+ }
292
+ holder[target.path.at(-1)] = compiled;
293
+ }
294
+ return { capabilities, shell };
295
+ }
296
+ }
297
+
298
+ /** What a call's non-literal arguments are called in a refusal. The table
299
+ * names each argument; these read the way a maker thinks of them. */
300
+ const LITERAL_HINTS = {
301
+ horizontal: "a literal capability id and integer version",
302
+ connectors: "a literal connector slug",
303
+ services: "a literal artifact address and operation id",
304
+ egress: "a literal template name",
305
+ collect: "a literal channel name",
306
+ server: "a literal op name so Terminus can compile its grant",
307
+ };
308
+
309
+ function literalRefusal(file, call, argument) {
310
+ const [surface] = call.split(".");
311
+ if (call === "horizontal.invoke" && argument === "operation") {
312
+ return new CliError(`${file}: horizontal.invoke needs a literal operation id so Terminus can compile its grant`);
313
+ }
314
+ return new CliError(`${file}: ${call} must use ${LITERAL_HINTS[surface] ?? "literal arguments"}`);
315
+ }
316
+
317
+ /**
318
+ * Every table call in one file: each literal call site is handed to `onCall`
319
+ * (for the prose checks) and then applies its grant; a call — or a bare
320
+ * reference to a method whose grant needs literals — without them fails the
321
+ * build. `skip(row)` leaves out the rows a check excludes for this file.
322
+ */
323
+ export function compileFileCalls(file, code, grants, { onCall = () => {}, skip = () => false } = {}) {
324
+ for (const row of CAPABILITY_CALLS.calls) {
325
+ if (skip(row)) continue;
326
+ const [surface, method] = row.call.split(".");
327
+ for (const binding of sdkSurfaceBindings(code, surface)) {
328
+ const prefix = `\\b${escapeRegExp(binding)}\\s*\\.\\s*${escapeRegExp(method)}`;
329
+ const calls = new RegExp(`${prefix}\\s*(?:<[^;()]*>)?\\s*\\(`, "gu");
330
+ const resolved = new Set();
331
+ for (const match of code.matchAll(calls)) {
332
+ const literals = leadingLiterals(code, match.index + match[0].length, row.literal_args.length);
333
+ const values = {};
334
+ for (const [position, argument] of row.literal_args.entries()) {
335
+ const literal = literals[position];
336
+ if (!literal || literal.kind !== argument.kind) {
337
+ throw literalRefusal(file, row.call, argument.name);
338
+ }
339
+ if (!new RegExp(argument.pattern, "u").test(literal.text)) {
340
+ if (row.call === "server.call") {
341
+ throw new CliError(
342
+ `${file}: server.call("${literal.text}") — op names are lowercase identifiers (a-z, 0-9, _ and -)`,
343
+ );
344
+ }
345
+ throw literalRefusal(file, row.call, argument.name);
346
+ }
347
+ values[argument.name] = argument.kind === "integer" ? Number(literal.text) : literal.text;
348
+ }
349
+ resolved.add(match.index);
350
+ onCall(row, values);
351
+ grants.apply(row.grant, values, file);
352
+ }
353
+ if (!row.literal_args.length) continue;
354
+ for (const match of code.matchAll(new RegExp(`${prefix}\\b`, "gu"))) {
355
+ if (!resolved.has(match.index)) throw literalRefusal(file, row.call, row.literal_args[0].name);
356
+ }
357
+ }
358
+ }
359
+ }
360
+
361
+ /** The automation steps the table compiles, with literal string params. A
362
+ * templated param (`{ $from }`) names nothing knowable, so it compiles no
363
+ * grant — the release's own validation refuses what it must. `skip` names
364
+ * actions the caller compiles itself (server.run, whose `unless` is prose). */
365
+ export function compileWorkloadSteps(workloads, grants, { skip = [] } = {}) {
366
+ const rows = new Map(CAPABILITY_CALLS.workload_steps.map((row) => [row.action, row]));
367
+ for (const automation of Array.isArray(workloads?.automations) ? workloads.automations : []) {
368
+ for (const step of Array.isArray(automation?.steps) ? automation.steps : []) {
369
+ const row = rows.get(step?.action);
370
+ if (!row || skip.includes(row.action)) continue;
371
+ const values = {};
372
+ const literal = Object.entries(row.params).every(([name, type]) => {
373
+ const value = step.params?.[name];
374
+ if (type === "string" && typeof value === "string") {
375
+ values[name] = value;
376
+ return true;
377
+ }
378
+ return false;
379
+ });
380
+ if (literal) grants.apply(row.grant, values, `automation '${automation.name}'`);
381
+ }
382
+ }
383
+ }
384
+
385
+ /** The background entry a `server.run` step compiles for an op the app never
386
+ * calls itself (the table's `unless` keeps a called op's interactive `{}`). */
387
+ export function serverRunEntry() {
388
+ return structuredClone(
389
+ CAPABILITY_CALLS.workload_steps.find((row) => row.action === "server.run").grant.entry,
390
+ );
391
+ }
392
+
393
+ const SERVER_RECORD_CALLS = CAPABILITY_CALLS.server_calls
394
+ .find((row) => row.call.startsWith("terminus.records."));
395
+
396
+ /** The server-code record calls: `terminus.records.<verb>("<scope>",
397
+ * "<collection>", …)`, literal both, each compiled into server.records. */
398
+ export function compileServerRecordCalls(sources) {
399
+ const [binding, verbs] = [
400
+ SERVER_RECORD_CALLS.call.split(".").slice(0, 2).join("."),
401
+ SERVER_RECORD_CALLS.call.split(".").at(-1).split("|"),
402
+ ];
403
+ const [scopeArgument, collectionArgument] = SERVER_RECORD_CALLS.literal_args;
404
+ const grants = new CompiledGrants();
405
+ const prefix = `\\b${binding.split(".").map(escapeRegExp).join("\\s*\\.\\s*")}\\s*\\.\\s*(${verbs.join("|")})`;
406
+ for (const source of sources) {
407
+ const code = maskCodeComments(source.code);
408
+ const resolved = new Set();
409
+ for (const match of code.matchAll(new RegExp(`${prefix}\\s*\\(`, "gu"))) {
410
+ const verb = match[1];
411
+ const literals = leadingLiterals(code, match.index + match[0].length, 2);
412
+ if (literals.length < 2 || literals.some((literal) => literal.kind !== "string")) continue;
413
+ const [scope, collection] = literals.map((literal) => literal.text);
414
+ if (!new RegExp(scopeArgument.pattern, "u").test(scope)) {
415
+ throw new CliError(
416
+ `${source.path}: terminus.records.${verb}("${scope}", …) — the scope is "global" or "installation"`,
417
+ );
418
+ }
419
+ if (!new RegExp(collectionArgument.pattern, "u").test(collection)) {
420
+ throw new CliError(
421
+ `${source.path}: terminus.records.${verb}(…, "${collection}") — collection names are lowercase identifiers`,
422
+ );
423
+ }
424
+ resolved.add(match.index);
425
+ grants.apply(SERVER_RECORD_CALLS.grant, { scope, collection }, source.path);
426
+ }
427
+ for (const match of code.matchAll(new RegExp(`${prefix}\\b`, "gu"))) {
428
+ if (!resolved.has(match.index)) {
429
+ throw new CliError(
430
+ `${source.path}: terminus.records.${match[1]} must name its scope and collection literally — `
431
+ + `terminus.records.${match[1]}("global", "scores", …) — so Terminus can compile its grant`,
432
+ );
433
+ }
434
+ }
435
+ }
436
+ return grants.release().capabilities.server?.records ?? null;
437
+ }
@@ -0,0 +1,260 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import {
3
+ copyFile,
4
+ cp,
5
+ mkdir,
6
+ readFile,
7
+ readdir,
8
+ rename,
9
+ rm,
10
+ writeFile,
11
+ } from "node:fs/promises";
12
+ import path from "node:path";
13
+ import { backup, DatabaseSync } from "node:sqlite";
14
+
15
+ import { readAppPackage } from "./apps.mjs";
16
+ import { CliError, commandUsageError, emitJson, parseFlags } from "./client.mjs";
17
+ import { devCapsuleDirectory } from "./dev-capsules.mjs";
18
+ import { DEFAULT_DEV_MEMBER } from "./dev-members.mjs";
19
+ import { DEV_DIRECTORY, exists, sha256 } from "./files.mjs";
20
+
21
+ // Plain `terminus dev` runs as one member, Alan — so that is whose capsule
22
+ // these commands read and write unless --member names somebody else.
23
+ function memberName(flags) {
24
+ return String(flags.member ?? DEFAULT_DEV_MEMBER).trim() || DEFAULT_DEV_MEMBER;
25
+ }
26
+
27
+ /** The members that have a local capsule for this app, for error messages. */
28
+ async function localMembers(context) {
29
+ const usersDir = path.join(context.projectDir, DEV_DIRECTORY, "users");
30
+ let names = [];
31
+ try {
32
+ names = await readdir(usersDir);
33
+ } catch {
34
+ return [];
35
+ }
36
+ const found = [];
37
+ for (const name of names.sort()) {
38
+ const devRoot = path.join(context.projectDir, DEV_DIRECTORY);
39
+ if (await exists(devCapsuleDirectory(devRoot, context.appId, name))) found.push(name);
40
+ }
41
+ return found;
42
+ }
43
+
44
+ async function missingCapsuleError(context) {
45
+ const members = await localMembers(context);
46
+ const hint = members.length
47
+ ? `members with local data: ${members.join(", ")} (pass --member NAME)`
48
+ : "run terminus dev first";
49
+ return new CliError(`no local capsule for member '${context.member}'; ${hint}`);
50
+ }
51
+
52
+ async function capsuleContext(flags) {
53
+ const projectDir = path.resolve(flags._[0] ?? ".");
54
+ // Only the package identity is needed — never read the tree's content.
55
+ const pkg = await readAppPackage(projectDir, { allowMissingUi: true, includeContent: false });
56
+ if (pkg.manifest.kind !== "app" || !pkg.id) {
57
+ throw new CliError("capsule commands require an app package with a canonical terminus.json id");
58
+ }
59
+ const member = memberName(flags);
60
+ const devRoot = path.join(projectDir, DEV_DIRECTORY);
61
+ return {
62
+ projectDir,
63
+ appId: pkg.id,
64
+ member,
65
+ directory: devCapsuleDirectory(devRoot, pkg.id, member),
66
+ };
67
+ }
68
+
69
+ function openCapsuleDatabase(directory) {
70
+ const file = path.join(directory, "data.sqlite");
71
+ try {
72
+ return new DatabaseSync(file, { readOnly: true });
73
+ } catch (error) {
74
+ throw new CliError(`cannot open capsule database: ${error.message}`);
75
+ }
76
+ }
77
+
78
+ async function summarizeCapsule(directory, { verifyObjects = false } = {}) {
79
+ let manifest;
80
+ try {
81
+ manifest = JSON.parse(await readFile(path.join(directory, "manifest.json"), "utf8"));
82
+ } catch (error) {
83
+ throw new CliError(`invalid capsule manifest: ${error.message}`);
84
+ }
85
+ if (manifest.capsule_format_version !== 1) {
86
+ throw new CliError(`unsupported capsule format ${manifest.capsule_format_version ?? "unknown"}`);
87
+ }
88
+
89
+ const db = openCapsuleDatabase(directory);
90
+ try {
91
+ const integrity = String(db.prepare("PRAGMA integrity_check").get().integrity_check);
92
+ if (integrity !== "ok") throw new CliError(`capsule SQLite integrity check failed: ${integrity}`);
93
+ const records = Number(db.prepare("SELECT COUNT(*) AS n FROM _terminus_records").get().n);
94
+ const changes = Number(db.prepare("SELECT COUNT(*) AS n FROM _terminus_changes").get().n);
95
+ const mutations = Number(db.prepare("SELECT COUNT(*) AS n FROM _terminus_mutations").get().n);
96
+ const objects = db.prepare(`
97
+ SELECT content_sha256, size_bytes FROM _terminus_object_refs ORDER BY content_sha256
98
+ `).all();
99
+ let objectBytes = 0;
100
+ const verified = new Set();
101
+ for (const object of objects) {
102
+ const hash = String(object.content_sha256);
103
+ objectBytes += Number(object.size_bytes);
104
+ if (!verifyObjects || verified.has(hash)) continue;
105
+ const file = path.join(directory, "objects", "sha256", hash.slice(0, 2), hash.slice(2, 4), hash);
106
+ let bytes;
107
+ try {
108
+ bytes = await readFile(file);
109
+ } catch {
110
+ throw new CliError(`capsule object ${hash} is missing`);
111
+ }
112
+ const actual = sha256(bytes);
113
+ if (actual !== hash) throw new CliError(`capsule object ${hash} failed its SHA-256 check`);
114
+ verified.add(hash);
115
+ }
116
+ return {
117
+ format: manifest.capsule_format_version,
118
+ app_id: manifest.app_id,
119
+ owner_user_id: manifest.owner_user_id,
120
+ records,
121
+ retained_changes: changes,
122
+ committed_mutations: mutations,
123
+ object_references: objects.length,
124
+ object_bytes: objectBytes,
125
+ integrity,
126
+ objects_verified: verifyObjects ? verified.size : null,
127
+ directory,
128
+ };
129
+ } finally {
130
+ db.close();
131
+ }
132
+ }
133
+
134
+ async function inspectCommand(flags) {
135
+ const context = await capsuleContext(flags);
136
+ if (!(await exists(context.directory))) throw await missingCapsuleError(context);
137
+ const summary = await summarizeCapsule(context.directory, { verifyObjects: flags.verify });
138
+ emitJson(flags, summary, () => {
139
+ console.log(`${summary.app_id} · ${summary.owner_user_id}`);
140
+ console.log(`Records: ${summary.records}`);
141
+ console.log(`Objects: ${summary.object_references} (${summary.object_bytes} bytes)`);
142
+ console.log(`SQLite integrity: ${summary.integrity}`);
143
+ if (flags.verify) console.log(`Verified object blobs: ${summary.objects_verified}`);
144
+ console.log(`Capsule: ${summary.directory}`);
145
+ });
146
+ }
147
+
148
+ async function exportCommand(flags) {
149
+ const context = await capsuleContext(flags);
150
+ if (!(await exists(context.directory))) throw await missingCapsuleError(context);
151
+ const target = path.resolve(
152
+ flags.output ?? flags._[1] ?? path.join(context.projectDir, `${context.member}-capsule`),
153
+ );
154
+ if (await exists(target)) throw new CliError(`export target already exists: ${target}`);
155
+
156
+ const staging = `${target}.tmp-${randomUUID()}`;
157
+ await mkdir(staging, { recursive: true });
158
+ try {
159
+ const sourceDb = openCapsuleDatabase(context.directory);
160
+ try {
161
+ await backup(sourceDb, path.join(staging, "data.sqlite"));
162
+ } finally {
163
+ sourceDb.close();
164
+ }
165
+ await copyFile(path.join(context.directory, "manifest.json"), path.join(staging, "manifest.json"));
166
+ const objects = path.join(context.directory, "objects");
167
+ if (await exists(objects)) await cp(objects, path.join(staging, "objects"), { recursive: true });
168
+ else await mkdir(path.join(staging, "objects"), { recursive: true });
169
+ const summary = await summarizeCapsule(staging, { verifyObjects: true });
170
+ const { directory: _directory, ...portableSummary } = summary;
171
+ await writeFile(
172
+ path.join(staging, "export.json"),
173
+ `${JSON.stringify({ exported_at: new Date().toISOString(), ...portableSummary }, null, 2)}\n`,
174
+ { mode: 0o600 },
175
+ );
176
+ await rename(staging, target);
177
+ const result = { ...summary, directory: target };
178
+ emitJson(flags, result, () => {
179
+ console.log(`Exported ${context.appId} / ${context.member} to ${target}`);
180
+ });
181
+ } catch (error) {
182
+ await rm(staging, { recursive: true, force: true });
183
+ throw error;
184
+ }
185
+ }
186
+
187
+ async function importCommand(flags) {
188
+ // One argument is the capsule (the app is the current folder); two are the
189
+ // app folder and then the capsule.
190
+ const [first, second] = flags._;
191
+ const capsule = second ?? first;
192
+ if (!capsule) throw commandUsageError("data", { sub: "import" });
193
+ const source = path.resolve(capsule);
194
+ const summary = await summarizeCapsule(source, { verifyObjects: true });
195
+ // Without --member, the capsule goes back to the member it came from.
196
+ const context = await capsuleContext({
197
+ ...flags,
198
+ _: second ? [first] : [],
199
+ member: flags.member ?? summary.owner_user_id,
200
+ });
201
+ if (summary.app_id !== context.appId) {
202
+ throw new CliError(`capsule belongs to ${summary.app_id}, not ${context.appId}`);
203
+ }
204
+ if (summary.owner_user_id !== context.member) {
205
+ throw new CliError(
206
+ `capsule belongs to member '${summary.owner_user_id}', not '${context.member}' (set --member)`,
207
+ );
208
+ }
209
+ if (await exists(context.directory)) {
210
+ const entries = await readdir(context.directory);
211
+ if (entries.length && !flags.force) {
212
+ throw new CliError("local capsule already exists; pass --force to replace it with a recoverable backup");
213
+ }
214
+ }
215
+
216
+ const parent = path.dirname(context.directory);
217
+ await mkdir(parent, { recursive: true });
218
+ const staging = path.join(parent, `.import-${randomUUID()}`);
219
+ await cp(source, staging, { recursive: true });
220
+ try {
221
+ await summarizeCapsule(staging, { verifyObjects: true });
222
+ let backupDirectory = null;
223
+ if (await exists(context.directory)) {
224
+ backupDirectory = `${context.directory}.backup-${new Date().toISOString().replaceAll(":", "-")}`;
225
+ await rename(context.directory, backupDirectory);
226
+ }
227
+ try {
228
+ await rename(staging, context.directory);
229
+ } catch (error) {
230
+ if (backupDirectory) await rename(backupDirectory, context.directory);
231
+ throw error;
232
+ }
233
+ const result = { ...summary, directory: context.directory, backup: backupDirectory };
234
+ emitJson(flags, result, () => {
235
+ console.log(`Imported ${context.appId} / ${context.member} into ${context.directory}`);
236
+ if (backupDirectory) console.log(`Previous capsule: ${backupDirectory}`);
237
+ });
238
+ } catch (error) {
239
+ await rm(staging, { recursive: true, force: true });
240
+ throw error;
241
+ }
242
+ }
243
+
244
+ export async function dataCommand(args) {
245
+ const [verb, ...rest] = args;
246
+ const flags = parseFlags(rest, "data");
247
+ switch (verb) {
248
+ case "inspect":
249
+ await inspectCommand(flags);
250
+ break;
251
+ case "export":
252
+ await exportCommand(flags);
253
+ break;
254
+ case "import":
255
+ await importCommand(flags);
256
+ break;
257
+ default:
258
+ throw commandUsageError("data", verb ? { reason: `'${verb}' is not a data command.` } : {});
259
+ }
260
+ }