@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
package/bin/sync.mjs ADDED
@@ -0,0 +1,357 @@
1
+ /**
2
+ * A working copy's memory of the draft it matches — what git keeps as
3
+ * `origin/main`. Terminus keeps every draft commit; this file is how a folder
4
+ * knows which one it last matched, so that:
5
+ *
6
+ * - push refuses when the draft has commits the folder has not seen, the
7
+ * way git refuses a push that is not a fast-forward;
8
+ * - pull merges instead of replacing: a file changed only in the draft is
9
+ * brought in, one changed only here is kept, and one changed on both
10
+ * sides is merged line by line or named as a conflict;
11
+ * - status can say ahead, behind, or both, and list what changed here.
12
+ *
13
+ * It lives at `.terminus/sync.json`, which every package walk skips, beside a
14
+ * `.gitignore` that keeps it out of the developer's own repository. Each file
15
+ * it tracks is recorded twice: as the folder had it after the sync (`local`)
16
+ * and as the draft stores it (`draft`). They differ only where the CLI
17
+ * rewrites a file on the way in — terminus.json is written in its canonical
18
+ * form — and keeping both is what stops that rewrite from reading as an edit
19
+ * on either side.
20
+ *
21
+ * Pure data and text: no network, no Terminus API. apps.mjs does the I/O.
22
+ */
23
+
24
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
25
+ import path from "node:path";
26
+
27
+ import { TERMINUS_DIRECTORY } from "./files.mjs";
28
+
29
+ export const SYNC_DIRECTORY = TERMINUS_DIRECTORY;
30
+ const SYNC_FILE = "sync.json";
31
+ const FORMAT = 1;
32
+
33
+ export function syncRecordPath(dir) {
34
+ return path.join(dir, SYNC_DIRECTORY, SYNC_FILE);
35
+ }
36
+
37
+ /**
38
+ * The folder's record, or null when it has none (or one this CLI cannot read).
39
+ *
40
+ * A file whose two sides agree is written with one hash, so reading fills
41
+ * the other back in: every caller sees both, and only the files the CLI
42
+ * rewrote on the way in cost a second line.
43
+ */
44
+ export async function readSyncRecord(dir) {
45
+ try {
46
+ const record = JSON.parse(await readFile(syncRecordPath(dir), "utf8"));
47
+ if (!record || record.format !== FORMAT || !Array.isArray(record.files)) return null;
48
+ return {
49
+ ...record,
50
+ files: record.files.map((file) => ({ ...file, local: file.local ?? file.draft })),
51
+ };
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Write the record. `source` says what the folder follows: `"draft"` (one of
59
+ * your creations; `commit` is the draft commit it matches) or `"release"` (a
60
+ * read-only copy of someone else's open-source work; `release` is the
61
+ * version it matches).
62
+ */
63
+ export async function writeSyncRecord(dir, record) {
64
+ const folder = path.join(dir, SYNC_DIRECTORY);
65
+ await mkdir(folder, { recursive: true });
66
+ // Git reads nested ignore files, so this one keeps the whole folder out of
67
+ // the developer's repository without touching their own .gitignore.
68
+ await writeFile(path.join(folder, ".gitignore"), "*\n");
69
+ const files = [...(record.files ?? [])]
70
+ .sort((left, right) => entryKey(left).localeCompare(entryKey(right)))
71
+ // The folder holds what the draft holds, except where the CLI writes a
72
+ // file in canonical form; only that difference is worth recording.
73
+ .map(({ local, ...file }) => (local === file.draft ? file : { ...file, local }));
74
+ await writeFile(
75
+ syncRecordPath(dir),
76
+ `${JSON.stringify({ format: FORMAT, ...record, files }, null, 2)}\n`,
77
+ );
78
+ }
79
+
80
+ /** A file's identity: its plane and its path, as everywhere in Terminus. */
81
+ export function entryKey(entry) {
82
+ return `${entry.role ?? ""}\u0000${entry.path}`;
83
+ }
84
+
85
+ export function byKey(entries) {
86
+ return new Map((entries ?? []).map((entry) => [entryKey(entry), entry]));
87
+ }
88
+
89
+ /**
90
+ * The files a working copy holds, out of a package's full material list: an
91
+ * app's authoring (source) plane — its build is derived, and rebuilt, not
92
+ * merged — and everything for an agent or a service, whose files are the
93
+ * package. A draft made on the web is single-plane: its files carry no
94
+ * role, and all of them are the working copy.
95
+ */
96
+ export function trackedEntries(manifest) {
97
+ const materials = manifest?.materials ?? [];
98
+ const source = materials.filter((material) => material.role === "source");
99
+ const chosen = manifest?.kind === "agent" || !source.length ? materials : source;
100
+ return chosen.map((material) => ({
101
+ path: material.path,
102
+ ...(material.role ? { role: material.role } : {}),
103
+ sha256: material.sha256 ?? null,
104
+ ...(material.encoding ? { encoding: material.encoding } : {}),
105
+ }));
106
+ }
107
+
108
+ /**
109
+ * What changed in the folder since it last matched the draft: each tracked
110
+ * file compared with the record's `local` side.
111
+ */
112
+ export function localChanges(record, entries) {
113
+ const before = new Map((record?.files ?? []).map((file) => [entryKey(file), file]));
114
+ const now = byKey(entries);
115
+ const changes = [];
116
+ for (const [key, entry] of now) {
117
+ const was = before.get(key);
118
+ if (!was) changes.push({ path: entry.path, role: entry.role, status: "added" });
119
+ else if (was.local !== entry.sha256) changes.push({ path: entry.path, role: entry.role, status: "modified" });
120
+ }
121
+ for (const [key, was] of before) {
122
+ if (!now.has(key)) changes.push({ path: was.path, role: was.role, status: "deleted" });
123
+ }
124
+ return changes.sort((left, right) => left.path.localeCompare(right.path));
125
+ }
126
+
127
+ /* ── Lines ──────────────────────────────────────────────────────────────── */
128
+
129
+ /** Beyond this many line pairs a file is compared whole, not line by line. */
130
+ const LINE_BUDGET = 4_000_000;
131
+
132
+ export function splitLines(text) {
133
+ if (text === "") return [];
134
+ const lines = text.split("\n");
135
+ // A trailing newline ends the last line rather than starting an empty one.
136
+ if (lines.at(-1) === "") lines.pop();
137
+ return lines;
138
+ }
139
+
140
+ function joinLines(lines, trailingNewline) {
141
+ if (!lines.length) return "";
142
+ return lines.join("\n") + (trailingNewline ? "\n" : "");
143
+ }
144
+
145
+ /**
146
+ * The longest common subsequence of two line lists, as matched index pairs
147
+ * [[i, j], …] in order. Common prefixes and suffixes are matched first, so
148
+ * the quadratic part only sees the region that actually changed; null when
149
+ * even that is too large to compare line by line.
150
+ */
151
+ export function matchLines(left, right) {
152
+ let start = 0;
153
+ while (start < left.length && start < right.length && left[start] === right[start]) start += 1;
154
+ let leftEnd = left.length;
155
+ let rightEnd = right.length;
156
+ while (leftEnd > start && rightEnd > start && left[leftEnd - 1] === right[rightEnd - 1]) {
157
+ leftEnd -= 1;
158
+ rightEnd -= 1;
159
+ }
160
+ const n = leftEnd - start;
161
+ const m = rightEnd - start;
162
+ if (n * m > LINE_BUDGET) return null;
163
+ const pairs = [];
164
+ for (let index = 0; index < start; index += 1) pairs.push([index, index]);
165
+ if (n && m) {
166
+ // lengths[i][j] = LCS of left[start+i..] and right[start+j..]
167
+ const width = m + 1;
168
+ const lengths = new Uint32Array((n + 1) * width);
169
+ for (let i = n - 1; i >= 0; i -= 1) {
170
+ for (let j = m - 1; j >= 0; j -= 1) {
171
+ lengths[i * width + j] = left[start + i] === right[start + j]
172
+ ? lengths[(i + 1) * width + j + 1] + 1
173
+ : Math.max(lengths[(i + 1) * width + j], lengths[i * width + j + 1]);
174
+ }
175
+ }
176
+ let i = 0;
177
+ let j = 0;
178
+ while (i < n && j < m) {
179
+ if (left[start + i] === right[start + j]) {
180
+ pairs.push([start + i, start + j]);
181
+ i += 1;
182
+ j += 1;
183
+ } else if (lengths[(i + 1) * width + j] >= lengths[i * width + j + 1]) {
184
+ i += 1;
185
+ } else {
186
+ j += 1;
187
+ }
188
+ }
189
+ }
190
+ for (let offset = 0; offset < left.length - leftEnd; offset += 1) {
191
+ pairs.push([leftEnd + offset, rightEnd + offset]);
192
+ }
193
+ return pairs;
194
+ }
195
+
196
+ /**
197
+ * A line diff as operations: { kind: "same" | "remove" | "add", line }.
198
+ * Null when the texts are too large to compare line by line.
199
+ */
200
+ export function diffLines(beforeText, afterText) {
201
+ const before = splitLines(beforeText);
202
+ const after = splitLines(afterText);
203
+ const pairs = matchLines(before, after);
204
+ if (!pairs) return null;
205
+ const operations = [];
206
+ let i = 0;
207
+ let j = 0;
208
+ for (const [pi, pj] of [...pairs, [before.length, after.length]]) {
209
+ while (i < pi) operations.push({ kind: "remove", line: before[i++] });
210
+ while (j < pj) operations.push({ kind: "add", line: after[j++] });
211
+ if (pi < before.length) {
212
+ operations.push({ kind: "same", line: before[pi] });
213
+ i = pi + 1;
214
+ j = pj + 1;
215
+ }
216
+ }
217
+ return operations;
218
+ }
219
+
220
+ /**
221
+ * The operations grouped into git-style hunks with `context` unchanged lines
222
+ * around each change: [{ header: "@@ -a,b +c,d @@", lines: ["+x", "-y", " z"] }].
223
+ */
224
+ export function hunks(operations, context = 3) {
225
+ const out = [];
226
+ let oldLine = 1;
227
+ let newLine = 1;
228
+ const positions = operations.map((operation) => {
229
+ const at = { operation, oldLine, newLine };
230
+ if (operation.kind !== "add") oldLine += 1;
231
+ if (operation.kind !== "remove") newLine += 1;
232
+ return at;
233
+ });
234
+ const changed = positions
235
+ .map((position, index) => (position.operation.kind === "same" ? -1 : index))
236
+ .filter((index) => index >= 0);
237
+ let index = 0;
238
+ while (index < changed.length) {
239
+ const first = changed[index];
240
+ let last = first;
241
+ while (index + 1 < changed.length && changed[index + 1] - last <= context * 2 + 1) {
242
+ index += 1;
243
+ last = changed[index];
244
+ }
245
+ index += 1;
246
+ const from = Math.max(0, first - context);
247
+ const to = Math.min(positions.length - 1, last + context);
248
+ const slice = positions.slice(from, to + 1);
249
+ const oldCount = slice.filter((position) => position.operation.kind !== "add").length;
250
+ const newCount = slice.filter((position) => position.operation.kind !== "remove").length;
251
+ const oldStart = oldCount ? slice.find((position) => position.operation.kind !== "add").oldLine : slice[0].oldLine - 1;
252
+ const newStart = newCount ? slice.find((position) => position.operation.kind !== "remove").newLine : slice[0].newLine - 1;
253
+ out.push({
254
+ header: `@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`,
255
+ lines: slice.map(({ operation }) => (
256
+ `${operation.kind === "add" ? "+" : operation.kind === "remove" ? "-" : " "}${operation.line}`
257
+ )),
258
+ });
259
+ }
260
+ return out;
261
+ }
262
+
263
+ /* ── Merging ────────────────────────────────────────────────────────────── */
264
+
265
+ export const CONFLICT_START = "<<<<<<< yours";
266
+ export const CONFLICT_SPLIT = "=======";
267
+ export const CONFLICT_END = ">>>>>>> draft";
268
+
269
+ /** Whether a text still holds a conflict a pull wrote into it. */
270
+ export function hasConflictMarkers(text) {
271
+ const lines = text.split("\n");
272
+ return lines.includes(CONFLICT_START) && lines.includes(CONFLICT_END);
273
+ }
274
+
275
+ function sameLines(left, right) {
276
+ return left.length === right.length && left.every((line, index) => line === right[index]);
277
+ }
278
+
279
+ /**
280
+ * Three-way merge of texts: what both sides started from, yours, and the
281
+ * draft's. Regions only one side changed take that side; regions both changed
282
+ * the same way are taken once; regions both changed differently become a
283
+ * conflict, written between git-style markers. Null when the texts are too
284
+ * large to merge line by line.
285
+ */
286
+ export function mergeText(baseText, oursText, theirsText) {
287
+ const base = splitLines(baseText);
288
+ const ours = splitLines(oursText);
289
+ const theirs = splitLines(theirsText);
290
+ const toOurs = matchLines(base, ours);
291
+ const toTheirs = matchLines(base, theirs);
292
+ if (!toOurs || !toTheirs) return null;
293
+ const ourIndex = new Map(toOurs);
294
+ const theirIndex = new Map(toTheirs);
295
+ const out = [];
296
+ let conflicts = 0;
297
+ let b = 0;
298
+ let o = 0;
299
+ let t = 0;
300
+ for (;;) {
301
+ // The next base line both sides kept is an anchor both agree on.
302
+ let anchor = b;
303
+ while (anchor < base.length && !(ourIndex.has(anchor) && theirIndex.has(anchor))) anchor += 1;
304
+ const oursEnd = anchor < base.length ? ourIndex.get(anchor) : ours.length;
305
+ const theirsEnd = anchor < base.length ? theirIndex.get(anchor) : theirs.length;
306
+ const was = base.slice(b, anchor);
307
+ const mine = ours.slice(o, oursEnd);
308
+ const draft = theirs.slice(t, theirsEnd);
309
+ if (sameLines(mine, was)) out.push(...draft);
310
+ else if (sameLines(draft, was) || sameLines(mine, draft)) out.push(...mine);
311
+ else {
312
+ conflicts += 1;
313
+ out.push(CONFLICT_START, ...mine, CONFLICT_SPLIT, ...draft, CONFLICT_END);
314
+ }
315
+ if (anchor >= base.length) break;
316
+ out.push(base[anchor]);
317
+ b = anchor + 1;
318
+ o = oursEnd + 1;
319
+ t = theirsEnd + 1;
320
+ }
321
+ // Text files end with a newline; the merge keeps one when either side has it.
322
+ const trailing = oursText.endsWith("\n") || theirsText.endsWith("\n");
323
+ return { text: joinLines(out, trailing), conflicts };
324
+ }
325
+
326
+ function canonical(value) {
327
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
328
+ if (value && typeof value === "object") {
329
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`;
330
+ }
331
+ return JSON.stringify(value);
332
+ }
333
+
334
+ /**
335
+ * Three-way merge of two JSON objects key by key — how terminus.json merges,
336
+ * because markers inside JSON would leave a package the CLI cannot read. A
337
+ * key both sides changed differently keeps yours and is named.
338
+ */
339
+ export function mergeObjects(base, ours, theirs) {
340
+ const merged = {};
341
+ const conflicts = [];
342
+ const keys = new Set([...Object.keys(base ?? {}), ...Object.keys(ours ?? {}), ...Object.keys(theirs ?? {})]);
343
+ for (const key of keys) {
344
+ const was = canonical(base?.[key]);
345
+ const mine = canonical(ours?.[key]);
346
+ const draft = canonical(theirs?.[key]);
347
+ let value;
348
+ if (mine === draft || draft === was) value = ours?.[key];
349
+ else if (mine === was) value = theirs?.[key];
350
+ else {
351
+ value = ours?.[key];
352
+ conflicts.push(key);
353
+ }
354
+ if (value !== undefined) merged[key] = value;
355
+ }
356
+ return { merged, conflicts };
357
+ }