@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.
- package/LICENSE +21 -0
- package/README.md +1055 -0
- package/bin/agent-discovery.mjs +71 -0
- package/bin/agent-icon.mjs +77 -0
- package/bin/agent-models.mjs +77 -0
- package/bin/agent-type.mjs +51 -0
- package/bin/agentdev.mjs +657 -0
- package/bin/app-route-script.mjs +59 -0
- package/bin/app-runtime-contract.mjs +2 -0
- package/bin/appdev-remote.mjs +346 -0
- package/bin/appdev.mjs +4446 -0
- package/bin/apps.mjs +5512 -0
- package/bin/capability-calls.mjs +437 -0
- package/bin/capsule-data.mjs +260 -0
- package/bin/client.mjs +189 -0
- package/bin/commands.mjs +1194 -0
- package/bin/dev-capsules.mjs +1599 -0
- package/bin/dev-contract.mjs +262 -0
- package/bin/dev-data.mjs +287 -0
- package/bin/dev-members.mjs +18 -0
- package/bin/dev-net.mjs +316 -0
- package/bin/dev-notification-popup.mjs +628 -0
- package/bin/dev-ports.mjs +567 -0
- package/bin/dev-server-binding.mjs +35 -0
- package/bin/dev-server-ops.mjs +1086 -0
- package/bin/dev-ui/IoskeleyMono-400.woff2 +0 -0
- package/bin/dev-ui/IoskeleyMono-600.woff2 +0 -0
- package/bin/dev-ui/OFL.txt +92 -0
- package/bin/dev-ui/agent-robot.webp +0 -0
- package/bin/dev-ui/app.js +5217 -0
- package/bin/dev-ui/highlight.js +195 -0
- package/bin/dev-ui/index.html +34 -0
- package/bin/dev-ui/style.css +3640 -0
- package/bin/devlint.mjs +112 -0
- package/bin/devserver.mjs +2127 -0
- package/bin/devtriggers.mjs +367 -0
- package/bin/endpoints.mjs +156 -0
- package/bin/errors.mjs +61 -0
- package/bin/files.mjs +169 -0
- package/bin/horizontal-capabilities/v1/contract.json +280 -0
- package/bin/http.mjs +500 -0
- package/bin/lint-manifests/justbash-commands.json +88 -0
- package/bin/lint-manifests/python-stdlib.json +295 -0
- package/bin/login-page.mjs +488 -0
- package/bin/schedules.mjs +664 -0
- package/bin/server-sandbox.mjs +204 -0
- package/bin/servicedev.mjs +425 -0
- package/bin/sync.mjs +357 -0
- package/bin/terminus.js +3666 -0
- package/bin/toolchain.mjs +125 -0
- package/bin/vendor/app-runtime-v1/app-host.json +124 -0
- package/bin/vendor/app-runtime-v1/capability-calls.json +412 -0
- package/bin/vendor/app-runtime-v1/doors.json +2867 -0
- package/bin/vendor/appd/node-harness.mjs +209 -0
- package/bin/vendor/appd/python-harness.py +12 -0
- package/bin/vendor/appd/server-protocol.json +84 -0
- package/bin/vendor/where.mjs +541 -0
- package/bin/versioning.mjs +72 -0
- package/bin/write-rules.mjs +398 -0
- 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
|
+
}
|