@vecteur/cli 0.8.0 → 0.9.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.
- package/README.md +125 -0
- package/bin/vecteur.js +71 -19
- package/package.json +23 -3
- package/src/blocks.js +40 -0
- package/src/browser.js +18 -0
- package/src/client.js +216 -81
- package/src/commands.js +161 -0
- package/src/contract.js +18 -1
- package/src/credentials.js +56 -63
- package/src/diagnostics.js +39 -0
- package/src/download.js +33 -2
- package/src/folders.js +270 -0
- package/src/format.js +39 -29
- package/src/input.js +148 -0
- package/src/markdown.js +226 -0
- package/src/preferences.js +29 -0
- package/src/session.js +689 -0
- package/src/store.js +82 -0
- package/src/terminal.js +335 -0
- package/src/tui/view.js +319 -0
- package/src/update.js +313 -0
- package/src/vendors.js +44 -0
package/src/folders.js
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { readFileSync, realpathSync, statSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, isAbsolute, join, relative, sep, parse } from "node:path";
|
|
5
|
+
import { credentialsPath } from "./credentials.js";
|
|
6
|
+
import { CliError } from "./errors.js";
|
|
7
|
+
import { readStore, withStoreLock, writeStore } from "./store.js";
|
|
8
|
+
|
|
9
|
+
export const UNSIGNED_ACCOUNT = "local:unsigned";
|
|
10
|
+
|
|
11
|
+
const MAX_DRAFT_BYTES = 256 * 1024;
|
|
12
|
+
const DRAFT_LIFETIME_MS = 30 * 24 * 60 * 60 * 1_000;
|
|
13
|
+
const identity = path => {
|
|
14
|
+
const real = realpathSync(path);
|
|
15
|
+
const stat = statSync(real, { bigint: true });
|
|
16
|
+
if (!stat.isDirectory()) throw new CliError("folder_invalid", "The session needs a directory");
|
|
17
|
+
return { path: real, device: String(stat.dev), inode: String(stat.ino) };
|
|
18
|
+
};
|
|
19
|
+
export function broadRoot(path, home, { platform = process.platform, mounts, stat = statSync } = {}) {
|
|
20
|
+
const folder = realpathSync(path);
|
|
21
|
+
if (folder === realpathSync(home)) return "home";
|
|
22
|
+
if (folder === parse(folder).root) return "filesystem root";
|
|
23
|
+
if (platform === "linux") {
|
|
24
|
+
const text = mounts ?? readFileSync("/proc/self/mountinfo", "utf8");
|
|
25
|
+
const roots = text.split("\n").map(line => line.split(" ")[4]?.replace(/\\([0-7]{3})/g,
|
|
26
|
+
(_, octal) => String.fromCharCode(parseInt(octal, 8))));
|
|
27
|
+
if (roots.includes(folder)) return "mount root";
|
|
28
|
+
}
|
|
29
|
+
return stat(folder).dev !== stat(dirname(folder)).dev ? "mount root" : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const contains = (root, child) => {
|
|
33
|
+
const path = relative(root, child);
|
|
34
|
+
return path === "" || (!isAbsolute(path) && path !== ".." && !path.startsWith(`..${sep}`));
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function readRegistry(file) {
|
|
38
|
+
const value = readStore(file);
|
|
39
|
+
if (value === null) return { version: 1, folders: {} };
|
|
40
|
+
if (value.version !== 1) throw new CliError("store_version_unsupported",
|
|
41
|
+
"Folder registry version is unsupported; install the matching CLI. Existing bytes were preserved.");
|
|
42
|
+
if (!value.folders || typeof value.folders !== "object" || Array.isArray(value.folders)
|
|
43
|
+
|| Object.values(value.folders).some(row => !row || typeof row !== "object"
|
|
44
|
+
|| typeof row.origin !== "string" || typeof row.accountId !== "string"
|
|
45
|
+
|| typeof row.folder?.path !== "string" || typeof row.folder?.device !== "string"
|
|
46
|
+
|| typeof row.folder?.inode !== "string")
|
|
47
|
+
|| (value.logins !== undefined && (!value.logins || typeof value.logins !== "object" || Array.isArray(value.logins)
|
|
48
|
+
|| Object.entries(value.logins).some(([key, id]) => !/^[a-f0-9]{64}$/.test(key) || typeof id !== "string" || !id)))) {
|
|
49
|
+
throw new CliError("store_unreadable", "Folder registry is invalid; existing bytes were preserved");
|
|
50
|
+
}
|
|
51
|
+
for (const row of Object.values(value.folders)) {
|
|
52
|
+
row.drafts ??= {};
|
|
53
|
+
if (row.draft && row.draftAt > Date.now() - DRAFT_LIFETIME_MS) {
|
|
54
|
+
row.drafts.legacy ??= { text: row.draft, at: row.draftAt, revision: String(row.draftAt) };
|
|
55
|
+
}
|
|
56
|
+
delete row.draft; delete row.draftAt;
|
|
57
|
+
if (!row.drafts || typeof row.drafts !== "object" || Array.isArray(row.drafts)
|
|
58
|
+
|| Object.values(row.drafts).some(draft => !draft || typeof draft.text !== "string"
|
|
59
|
+
|| typeof draft.revision !== "string" || !Number.isFinite(draft.at))) {
|
|
60
|
+
throw new CliError("store_unreadable", "Saved drafts are invalid; existing bytes were preserved");
|
|
61
|
+
}
|
|
62
|
+
for (const [id, draft] of Object.entries(row.drafts)) {
|
|
63
|
+
if (!draft.text || draft.at <= Date.now() - DRAFT_LIFETIME_MS) delete row.drafts[id];
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
/** Metadata only: a Project binding is never a local filesystem grant. */
|
|
71
|
+
export class FolderRegistry {
|
|
72
|
+
constructor({ origin, accountId, cwd = process.cwd(), env = process.env }) {
|
|
73
|
+
if (!origin || !accountId) throw new CliError("folder_identity_invalid", "Origin and account are required");
|
|
74
|
+
this.origin = origin;
|
|
75
|
+
this.accountId = accountId;
|
|
76
|
+
this.folder = identity(cwd);
|
|
77
|
+
this.home = env.HOME || homedir();
|
|
78
|
+
this.file = join(dirname(credentialsPath(env)), "folders.json");
|
|
79
|
+
this.key = createHash("sha256").update(JSON.stringify([origin, accountId, this.folder.path])).digest("hex");
|
|
80
|
+
this.lastDraft = ""; this.draftRef = null;
|
|
81
|
+
try {
|
|
82
|
+
const newest = this.state().drafts[0];
|
|
83
|
+
if (newest) this.selectDraft(newest.id);
|
|
84
|
+
} catch (error) { if (error.code !== "folder_replaced") throw error; }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
static forCredential({ origin, token, env = process.env, cwd = process.cwd() }) {
|
|
88
|
+
const file = join(dirname(credentialsPath(env)), "folders.json");
|
|
89
|
+
const accountId = readRegistry(file).logins?.[createHash("sha256").update(JSON.stringify([origin, token])).digest("hex")];
|
|
90
|
+
return typeof accountId === "string" && accountId
|
|
91
|
+
? new FolderRegistry({ origin, accountId, cwd, env }) : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
requireAccount() {
|
|
95
|
+
if (this.accountId === UNSIGNED_ACCOUNT) throw new CliError("login_required", "Unsigned drafts cannot bind a Project or authorize work");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
rememberLogin(token) {
|
|
99
|
+
this.requireAccount();
|
|
100
|
+
withStoreLock(this.file, () => {
|
|
101
|
+
const store = this.#read();
|
|
102
|
+
store.logins ??= {};
|
|
103
|
+
store.logins[createHash("sha256").update(JSON.stringify([this.origin, token])).digest("hex")] = this.accountId;
|
|
104
|
+
writeStore(this.file, store);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
#read() { return readRegistry(this.file); }
|
|
109
|
+
|
|
110
|
+
#update(change) {
|
|
111
|
+
return withStoreLock(this.file, () => {
|
|
112
|
+
const store = this.#read();
|
|
113
|
+
const row = store.folders[this.key] ?? { origin: this.origin, accountId: this.accountId, folder: this.folder };
|
|
114
|
+
if (row.folder.device !== this.folder.device || row.folder.inode !== this.folder.inode) {
|
|
115
|
+
throw new CliError("folder_replaced", "This directory was replaced. Unlink its old identity before continuing.");
|
|
116
|
+
}
|
|
117
|
+
const result = change(row, store);
|
|
118
|
+
store.folders[this.key] = row;
|
|
119
|
+
writeStore(this.file, store);
|
|
120
|
+
return result;
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
binding() {
|
|
125
|
+
if (this.accountId === UNSIGNED_ACCOUNT) return null;
|
|
126
|
+
const entries = Object.values(this.#read().folders).filter(row =>
|
|
127
|
+
row.origin === this.origin && row.accountId === this.accountId && (row.projectId || row.unlinked)
|
|
128
|
+
&& contains(row.folder.path, this.folder.path));
|
|
129
|
+
entries.sort((a, b) => b.folder.path.length - a.folder.path.length);
|
|
130
|
+
const row = entries[0];
|
|
131
|
+
if (!row || row.unlinked) return null;
|
|
132
|
+
let current;
|
|
133
|
+
try { current = identity(row.folder.path); } catch { return { ...row, state: "folder_missing" }; }
|
|
134
|
+
return { ...row, state: current.device === row.folder.device && current.inode === row.folder.inode
|
|
135
|
+
? "bound" : "folder_replaced" };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
broadRoot() { return broadRoot(this.folder.path, this.home); }
|
|
139
|
+
|
|
140
|
+
bind(projectId, { confirmHome = false, confirmBroad = confirmHome } = {}) {
|
|
141
|
+
this.requireAccount();
|
|
142
|
+
if (typeof projectId !== "string" || !projectId) throw new CliError("project_invalid", "Choose a Project first");
|
|
143
|
+
const broad = this.broadRoot();
|
|
144
|
+
if (broad && !confirmBroad) {
|
|
145
|
+
throw new CliError(broad === "home" ? "home_binding_confirmation" : "root_binding_confirmation",
|
|
146
|
+
`Linking this ${broad} applies to its descendants. Type ${this.folder.path} to confirm.`);
|
|
147
|
+
}
|
|
148
|
+
return this.#update(row => {
|
|
149
|
+
if (row.pendingCreate || row.pendingRun) {
|
|
150
|
+
throw new CliError("operation_unresolved", "Resolve the pending submission before relinking");
|
|
151
|
+
}
|
|
152
|
+
row.projectId = projectId;
|
|
153
|
+
delete row.unlinked;
|
|
154
|
+
delete row.pendingCreate;
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
unlink() {
|
|
159
|
+
return withStoreLock(this.file, () => {
|
|
160
|
+
const store = this.#read();
|
|
161
|
+
const row = store.folders[this.key];
|
|
162
|
+
if (row?.pendingCreate || row?.pendingRun) throw new CliError("operation_unresolved",
|
|
163
|
+
"Reconcile the pending submission before unlinking; its identity must survive");
|
|
164
|
+
store.folders[this.key] = { ...row, origin: this.origin, accountId: this.accountId, folder: this.folder, unlinked: true };
|
|
165
|
+
delete store.folders[this.key].projectId;
|
|
166
|
+
writeStore(this.file, store);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
reserveCreation(name, workspaceId) {
|
|
171
|
+
this.requireAccount();
|
|
172
|
+
return this.#update(row => {
|
|
173
|
+
if (row.projectId) return { projectId: row.projectId };
|
|
174
|
+
// Both first senders share the already frozen payload, even if their asks differ.
|
|
175
|
+
row.pendingCreate ??= { key: randomUUID(), name, workspaceId };
|
|
176
|
+
return structuredClone(row.pendingCreate);
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
completeCreation(key, projectId) {
|
|
181
|
+
this.#update(row => {
|
|
182
|
+
if (row.projectId === projectId && !row.pendingCreate) return;
|
|
183
|
+
if (row.pendingCreate?.key !== key || typeof projectId !== "string" || !projectId) {
|
|
184
|
+
throw new CliError("creation_mismatch", "Project creation identity changed");
|
|
185
|
+
}
|
|
186
|
+
row.projectId = projectId;
|
|
187
|
+
delete row.unlinked;
|
|
188
|
+
delete row.pendingCreate;
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
state() {
|
|
193
|
+
const row = this.#read().folders[this.key];
|
|
194
|
+
if (row && (row.folder.device !== this.folder.device || row.folder.inode !== this.folder.inode)) {
|
|
195
|
+
throw new CliError("folder_replaced", "Directory identity changed; /unlink acknowledges the new folder. Pending submissions must be resolved from the original folder.");
|
|
196
|
+
}
|
|
197
|
+
const drafts = Object.entries(row?.drafts ?? {}).map(([id, value]) => ({ id, ...value }))
|
|
198
|
+
.sort((a, b) => b.at - a.at);
|
|
199
|
+
return { draft: this.lastDraft, drafts,
|
|
200
|
+
pendingCreate: row?.pendingCreate ?? null, pendingRun: row?.pendingRun ?? null };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
transferDraftTo(destination, text) {
|
|
204
|
+
destination.requireAccount();
|
|
205
|
+
if (this.accountId !== UNSIGNED_ACCOUNT || destination.origin !== this.origin
|
|
206
|
+
|| destination.folder.path !== this.folder.path) throw new CliError("draft_scope_invalid", "Draft transfer scope differs");
|
|
207
|
+
this.saveDraft(text);
|
|
208
|
+
destination.newDraft(); destination.saveDraft(text);
|
|
209
|
+
this.saveDraft(""); // Conditional revision consumption preserves a concurrent newer source edit.
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
newDraft() { this.draftRef = null; this.lastDraft = ""; }
|
|
213
|
+
|
|
214
|
+
selectDraft(id) {
|
|
215
|
+
const selected = this.state().drafts.find(row => row.id === id);
|
|
216
|
+
if (!selected) throw new CliError("draft_unavailable", "This saved draft changed; restart to reload it");
|
|
217
|
+
this.draftRef = { id, revision: selected.revision }; this.lastDraft = selected.text;
|
|
218
|
+
return selected.text;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
saveDraft(text) {
|
|
222
|
+
if (typeof text !== "string" || Buffer.byteLength(text) > MAX_DRAFT_BYTES) {
|
|
223
|
+
throw new CliError("draft_too_large", `Draft exceeds ${MAX_DRAFT_BYTES} bytes; attach a file instead`);
|
|
224
|
+
}
|
|
225
|
+
if (text === this.lastDraft) return;
|
|
226
|
+
const next = this.#update(row => {
|
|
227
|
+
row.drafts ??= {};
|
|
228
|
+
const matches = this.draftRef && row.drafts[this.draftRef.id]?.revision === this.draftRef.revision;
|
|
229
|
+
if (!text) {
|
|
230
|
+
if (matches) delete row.drafts[this.draftRef.id];
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
if (!matches && Object.keys(row.drafts).length >= 20) throw new CliError("drafts_full",
|
|
234
|
+
"20 saved drafts already exist. Recover and clear an older draft before saving another.");
|
|
235
|
+
const id = matches ? this.draftRef.id : randomUUID(), revision = randomUUID();
|
|
236
|
+
row.drafts[id] = { text, revision, at: Date.now() };
|
|
237
|
+
return { id, revision };
|
|
238
|
+
});
|
|
239
|
+
this.draftRef = next; this.lastDraft = text;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
reserveRun(projectId, body, draftRef = this.draftRef) {
|
|
243
|
+
this.requireAccount();
|
|
244
|
+
return this.#update(row => {
|
|
245
|
+
if (row.pendingRun) throw new CliError("submission_pending", "Another submission needs reconciliation before a new ask");
|
|
246
|
+
row.pendingRun = { key: randomUUID(), projectId, body: structuredClone(body), draftRef: structuredClone(draftRef) };
|
|
247
|
+
return structuredClone(row.pendingRun);
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
finishRunAdmission(key) {
|
|
252
|
+
this.#update(row => {
|
|
253
|
+
if (!row.pendingRun) return; // Another terminal may have persisted the same validated ACK.
|
|
254
|
+
if (row.pendingRun?.key !== key) throw new CliError("submission_mismatch", "Submission identity changed");
|
|
255
|
+
const ref = row.pendingRun.draftRef;
|
|
256
|
+
if (ref && row.drafts?.[ref.id]?.revision === ref.revision) delete row.drafts[ref.id];
|
|
257
|
+
if (ref && this.draftRef?.id === ref.id && this.draftRef.revision === ref.revision) {
|
|
258
|
+
this.lastDraft = ""; this.draftRef = null;
|
|
259
|
+
}
|
|
260
|
+
delete row.pendingRun;
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
rejectRunAdmission(key) {
|
|
265
|
+
this.#update(row => {
|
|
266
|
+
if (row.pendingRun?.key !== key) throw new CliError("submission_mismatch", "Submission identity changed");
|
|
267
|
+
delete row.pendingRun;
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
package/src/format.js
CHANGED
|
@@ -4,6 +4,10 @@ import {
|
|
|
4
4
|
} from "./contract.js";
|
|
5
5
|
import { CliError } from "./errors.js";
|
|
6
6
|
|
|
7
|
+
/** Terminal presentation only; exact copy and JSON paths retain the original bytes. */
|
|
8
|
+
export const terminalText = value => String(value ?? "").replace(/[\x00-\x08\x0b\x0c\x0d\x0e-\x1f\x7f-\x9f]/g,
|
|
9
|
+
char => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`).replace(/\t/g, " ");
|
|
10
|
+
|
|
7
11
|
export function canonicalJson(value) {
|
|
8
12
|
validateNumbers(value);
|
|
9
13
|
return `${JSON.stringify(sortValue(value))}\n`;
|
|
@@ -241,11 +245,11 @@ export function assumptionLines(events) {
|
|
|
241
245
|
});
|
|
242
246
|
}
|
|
243
247
|
|
|
244
|
-
function shellQuote(value) {
|
|
248
|
+
export function shellQuote(value) {
|
|
245
249
|
return `'${String(value).replaceAll("'", `'"'"'`)}'`;
|
|
246
250
|
}
|
|
247
251
|
|
|
248
|
-
function safeArtifactName(artifact) {
|
|
252
|
+
export function safeArtifactName(artifact) {
|
|
249
253
|
const leaf = artifact.filename.replaceAll("\\", "/").split("/").at(-1);
|
|
250
254
|
if (!leaf || leaf === "." || leaf === ".." || leaf.startsWith("-") || leaf.startsWith(".")) {
|
|
251
255
|
return `artifact-${artifact.cas_ref.slice("sha256:".length, "sha256:".length + 12)}`;
|
|
@@ -345,7 +349,8 @@ export function currentTransferTables(events) {
|
|
|
345
349
|
});
|
|
346
350
|
}
|
|
347
351
|
|
|
348
|
-
|
|
352
|
+
/** One projection of a Run record; plain text and the rich terminal both render these sections. */
|
|
353
|
+
export function runSections(value, { projectId, currentTransferIds } = {}) {
|
|
349
354
|
const terminal = value.terminal === "" ? null : terminalPresentation(value);
|
|
350
355
|
const origins = value.events.flatMap(event => event.kind === "step_finished"
|
|
351
356
|
? inputLines(event.payload?.step?.inputs ?? event.payload?.inputs) : []);
|
|
@@ -353,42 +358,47 @@ export function humanRun(value, { projectId, cost, currentTransferIds } = {}) {
|
|
|
353
358
|
if (!artifacts.every(validateArtifact)) {
|
|
354
359
|
throw new CliError("artifact_invalid", "Run carries malformed artifact metadata");
|
|
355
360
|
}
|
|
356
|
-
const
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
currentTransferIds?.has(projection.projection_id));
|
|
360
|
-
const lines = [];
|
|
361
|
-
if (terminal?.answer !== undefined) lines.push(terminal.answer, "");
|
|
362
|
-
if (tasks.length) lines.push("Steps:", ...tasks, "");
|
|
363
|
-
if (origins.length) lines.push("Inputs and origins:", ...origins, "");
|
|
364
|
-
if (assumptions.length) lines.push("Assumptions:", ...assumptions, "");
|
|
365
|
-
for (const projection of currentTables) {
|
|
361
|
+
const project = projectId ? ` --project ${shellQuote(projectId)}` : "";
|
|
362
|
+
const tables = currentTransferTables(value.events).filter(projection =>
|
|
363
|
+
currentTransferIds?.has(projection.projection_id)).map(projection => {
|
|
366
364
|
if (typeof projection.search_status !== "string" || projection.origin !== "unattributed"
|
|
367
365
|
|| typeof projection.validation_status !== "string" || typeof projection.withheld !== "boolean") {
|
|
368
366
|
throw new CliError("projection_invalid", "Transfer table metadata is malformed");
|
|
369
367
|
}
|
|
370
|
-
|
|
368
|
+
return [`Transfer table: ${plainLine(projection.search_status)} · input origins ${projection.origin}`
|
|
371
369
|
+ ` · validation ${plainLine(projection.validation_status)}`
|
|
372
|
-
+ ` · path ${projection.withheld ? "withheld" : "shown"}
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
370
|
+
+ ` · path ${projection.withheld ? "withheld" : "shown"}`,
|
|
371
|
+
` vecteur projection get ${shellQuote(value.run_id)} ${shellQuote(projection.projection_id)}`
|
|
372
|
+
+ ` --output ${shellQuote("transfer-table.json")}${project}`];
|
|
373
|
+
});
|
|
374
|
+
const tail = [`Run: ${plainLine(value.run_id)}`];
|
|
375
|
+
if (terminal) {
|
|
376
|
+
tail.push(`Terminal: ${terminal.state}`);
|
|
377
|
+
if (terminal.defect !== undefined) tail.push(`Cause: ${terminal.defect}`);
|
|
378
|
+
tail.push(...terminal.recovery.map(recoveryLine));
|
|
376
379
|
}
|
|
377
|
-
|
|
380
|
+
return { answer: terminal?.answer, state: terminal?.state ?? "", tasks: taskLines(value.events), origins,
|
|
381
|
+
assumptions: assumptionLines(value.events), tables, artifacts: artifacts.map(artifact => ({ artifact,
|
|
382
|
+
command: `vecteur artifact get ${shellQuote(artifact.cas_ref)} --output ${shellQuote(safeArtifactName(artifact))}${project}` })),
|
|
383
|
+
tail };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export function humanRun(value, { projectId, cost, currentTransferIds } = {}) {
|
|
387
|
+
const run = runSections(value, { projectId, currentTransferIds });
|
|
388
|
+
const lines = [];
|
|
389
|
+
if (run.answer !== undefined) lines.push(run.answer, "");
|
|
390
|
+
if (run.tasks.length) lines.push("Steps:", ...run.tasks, "");
|
|
391
|
+
if (run.origins.length) lines.push("Inputs and origins:", ...run.origins, "");
|
|
392
|
+
if (run.assumptions.length) lines.push("Assumptions:", ...run.assumptions, "");
|
|
393
|
+
for (const table of run.tables) lines.push(...table, "");
|
|
394
|
+
if (run.artifacts.length) {
|
|
378
395
|
lines.push("Artifacts:");
|
|
379
|
-
for (const artifact of artifacts) {
|
|
380
|
-
lines.push(` ${artifact.filename} (${artifact.kind}, ${artifact.id})`);
|
|
381
|
-
const project = projectId ? ` --project ${shellQuote(projectId)}` : "";
|
|
382
|
-
lines.push(` vecteur artifact get ${shellQuote(artifact.cas_ref)} --output ${shellQuote(safeArtifactName(artifact))}${project}`);
|
|
396
|
+
for (const { artifact, command } of run.artifacts) {
|
|
397
|
+
lines.push(` ${artifact.filename} (${artifact.kind}, ${artifact.id})`, ` ${command}`);
|
|
383
398
|
}
|
|
384
399
|
lines.push("");
|
|
385
400
|
}
|
|
386
|
-
lines.push(
|
|
387
|
-
if (terminal) {
|
|
388
|
-
lines.push(`Terminal: ${terminal.state}`);
|
|
389
|
-
if (terminal.defect !== undefined) lines.push(`Cause: ${terminal.defect}`);
|
|
390
|
-
lines.push(...terminal.recovery.map(recoveryLine));
|
|
391
|
-
}
|
|
401
|
+
lines.push(...run.tail);
|
|
392
402
|
if (cost) lines.push("", humanCost(cost).trimEnd());
|
|
393
403
|
return `${lines.join("\n")}\n`;
|
|
394
404
|
}
|
package/src/input.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { StringDecoder } from "node:string_decoder";
|
|
3
|
+
|
|
4
|
+
const START = "\x1b[200~", END = "\x1b[201~";
|
|
5
|
+
const MAX_BYTES = 256 * 1024;
|
|
6
|
+
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
7
|
+
|
|
8
|
+
/** Stream decoder shared by both terminal views; paste is always one edit, never a send. */
|
|
9
|
+
export class ComposerInput extends EventEmitter {
|
|
10
|
+
constructor() {
|
|
11
|
+
super(); this.text = ""; this.cursor = 0; this.pending = ""; this.pasting = false;
|
|
12
|
+
this.decoder = new StringDecoder("utf8"); this.pasted = "";
|
|
13
|
+
this.undo = []; this.killed = ""; this.focusEpoch = 0;
|
|
14
|
+
}
|
|
15
|
+
set(text) { this.text = text; this.cursor = text.length; this.emit("change", this.text); }
|
|
16
|
+
checkpoint() {
|
|
17
|
+
if (this.undo.at(-1)?.text === this.text) return;
|
|
18
|
+
this.undo.push({ text: this.text, cursor: this.cursor });
|
|
19
|
+
if (this.undo.length > 12) this.undo.shift();
|
|
20
|
+
}
|
|
21
|
+
restore() {
|
|
22
|
+
const previous = this.undo.pop();
|
|
23
|
+
if (previous) { Object.assign(this, previous); this.emit("change", this.text); }
|
|
24
|
+
}
|
|
25
|
+
lineStart() { return this.cursor === 0 ? 0 : this.text.lastIndexOf("\n", this.cursor - 1) + 1; }
|
|
26
|
+
lineEnd() { const end = this.text.indexOf("\n", this.cursor); return end < 0 ? this.text.length : end; }
|
|
27
|
+
replace(from, to, value = "", cut = false) {
|
|
28
|
+
this.checkpoint(); if (cut) this.killed = this.text.slice(from, to);
|
|
29
|
+
this.text = this.text.slice(0, from) + value + this.text.slice(to); this.cursor = from + value.length;
|
|
30
|
+
this.emit("change", this.text);
|
|
31
|
+
}
|
|
32
|
+
word(direction) {
|
|
33
|
+
if (direction < 0) return this.text.slice(0, this.cursor).search(/[^\s]*\s*$/u);
|
|
34
|
+
return this.cursor + (/^\s*\S*/u.exec(this.text.slice(this.cursor))?.[0].length ?? 0);
|
|
35
|
+
}
|
|
36
|
+
vertical(direction) {
|
|
37
|
+
const start = this.lineStart(), end = this.lineEnd(), column = this.cursor - start;
|
|
38
|
+
if (direction < 0 && start > 0) {
|
|
39
|
+
const previous = this.text.lastIndexOf("\n", start - 2) + 1;
|
|
40
|
+
this.cursor = Math.min(start - 1, previous + column);
|
|
41
|
+
} else if (direction > 0 && end < this.text.length) {
|
|
42
|
+
const nextEnd = this.text.indexOf("\n", end + 1);
|
|
43
|
+
this.cursor = Math.min(nextEnd < 0 ? this.text.length : nextEnd, end + 1 + column);
|
|
44
|
+
} else { this.emit("history", direction); return; }
|
|
45
|
+
// Clamp vertical movement to a grapheme boundary, never inside a surrogate/emoji.
|
|
46
|
+
const boundary = [...[...graphemes.segment(this.text)].map(row => row.index), this.text.length].findLast(index => index <= this.cursor);
|
|
47
|
+
this.cursor = boundary ?? 0;
|
|
48
|
+
}
|
|
49
|
+
boundary(direction) {
|
|
50
|
+
if (this.segmentedText !== this.text) {
|
|
51
|
+
this.segmentedText = this.text;
|
|
52
|
+
this.boundaries = [...graphemes.segment(this.text)].map(item => item.index);
|
|
53
|
+
this.boundaries.push(this.text.length);
|
|
54
|
+
}
|
|
55
|
+
if (direction < 0) return this.boundaries.findLast(index => index < this.cursor) ?? 0;
|
|
56
|
+
return this.boundaries.find(index => index > this.cursor) ?? this.text.length;
|
|
57
|
+
}
|
|
58
|
+
insert(text) {
|
|
59
|
+
const value = this.text.slice(0, this.cursor) + text + this.text.slice(this.cursor);
|
|
60
|
+
if (Buffer.byteLength(value) > MAX_BYTES) { this.emit("notice", "Draft is too large; attach a file instead."); return; }
|
|
61
|
+
this.checkpoint();
|
|
62
|
+
this.text = value; this.cursor += text.length; this.emit("change", this.text);
|
|
63
|
+
}
|
|
64
|
+
feed(bytes) {
|
|
65
|
+
clearTimeout(this.escapeTimer);
|
|
66
|
+
const epoch = this.focusEpoch;
|
|
67
|
+
this.pending += typeof bytes === "string" ? bytes : this.decoder.write(bytes);
|
|
68
|
+
while (this.pending) {
|
|
69
|
+
if (this.pasting) {
|
|
70
|
+
const end = this.pending.indexOf(END);
|
|
71
|
+
if (end < 0) {
|
|
72
|
+
const take = Math.max(0, this.pending.length - END.length + 1);
|
|
73
|
+
this.pasted += this.pending.slice(0, take); this.pending = this.pending.slice(take);
|
|
74
|
+
if (Buffer.byteLength(this.pasted) > MAX_BYTES) { this.pasted = ""; this.pasteOverflow = true; }
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
this.pasted += this.pending.slice(0, end); this.pending = this.pending.slice(end + END.length);
|
|
78
|
+
if (!this.pasteOverflow) this.insert(this.pasted.replace(/\r\n?/g, "\n"));
|
|
79
|
+
else this.emit("notice", "Paste exceeds the draft limit; attach the file instead.");
|
|
80
|
+
this.pasted = ""; this.pasting = false; this.pasteOverflow = false; continue;
|
|
81
|
+
}
|
|
82
|
+
if (this.pending.startsWith(START)) { this.pasting = true; this.pending = this.pending.slice(START.length); continue; }
|
|
83
|
+
if (this.pending.startsWith("\x1b")) {
|
|
84
|
+
if (this.pending.startsWith("\x1b\x1b")) {
|
|
85
|
+
// Escape followed by an arrow/Alt-Enter keeps that encoded key intact.
|
|
86
|
+
const encodedTail = /^\x1b\x1b(?:\[|\r|\n)/.test(this.pending);
|
|
87
|
+
this.pending = this.pending.slice(encodedTail ? 1 : 2);
|
|
88
|
+
if (epoch === this.focusEpoch) {
|
|
89
|
+
this.emit("cancel"); if (!encodedTail && epoch === this.focusEpoch) this.emit("cancel");
|
|
90
|
+
}
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (this.pending.startsWith("\x1b\r") || this.pending.startsWith("\x1b\n")) {
|
|
94
|
+
this.pending = this.pending.slice(2); this.insert("\n"); continue;
|
|
95
|
+
}
|
|
96
|
+
if (/^\x1b[bf]/.test(this.pending)) {
|
|
97
|
+
const direction = this.pending[1] === "b" ? -1 : 1;
|
|
98
|
+
this.pending = this.pending.slice(2); this.cursor = this.word(direction); this.emit("change", this.text); continue;
|
|
99
|
+
}
|
|
100
|
+
const escape = /^\x1b\[[0-?]*[ -/]*[@-~]/.exec(this.pending)?.[0];
|
|
101
|
+
if (escape) {
|
|
102
|
+
this.pending = this.pending.slice(escape.length);
|
|
103
|
+
if (this.handleKey?.(escape)) continue;
|
|
104
|
+
if (escape === "\x1b[D") this.cursor = this.boundary(-1);
|
|
105
|
+
else if (escape === "\x1b[C") this.cursor = this.boundary(1);
|
|
106
|
+
else if (["\x1b[H", "\x1b[1~"].includes(escape)) this.cursor = this.lineStart();
|
|
107
|
+
else if (["\x1b[F", "\x1b[4~"].includes(escape)) this.cursor = this.lineEnd();
|
|
108
|
+
else if (escape === "\x1b[3~") this.replace(this.cursor, this.boundary(1));
|
|
109
|
+
else if (escape === "\x1b[A") this.vertical(-1);
|
|
110
|
+
else if (escape === "\x1b[B") this.vertical(1);
|
|
111
|
+
else if (escape === "\x1b[13;2u" || escape === "\x1b[27;2;13~") this.insert("\n");
|
|
112
|
+
this.emit("change", this.text); continue;
|
|
113
|
+
}
|
|
114
|
+
this.escapeTimer = setTimeout(() => {
|
|
115
|
+
if (this.pending === "\x1b") { this.pending = ""; if (epoch === this.focusEpoch) this.emit("cancel"); }
|
|
116
|
+
else { this.pending = ""; this.emit("notice", "Unsupported terminal key sequence"); }
|
|
117
|
+
}, 40);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const char = String.fromCodePoint(this.pending.codePointAt(0)); this.pending = this.pending.slice(char.length);
|
|
121
|
+
if (epoch !== this.focusEpoch && /[\r\x03\x04]/.test(char)) continue;
|
|
122
|
+
if (this.handleKey?.(char)) continue;
|
|
123
|
+
if (char === "\r") {
|
|
124
|
+
if (this.text[this.cursor - 1] === "\\") this.replace(this.cursor - 1, this.cursor, "\n");
|
|
125
|
+
else this.emit("submit", this.text);
|
|
126
|
+
}
|
|
127
|
+
else if (char === "\n") this.insert("\n");
|
|
128
|
+
else if (char === "\x03") this.emit("interrupt");
|
|
129
|
+
else if (char === "\x04") { if (!this.text) this.emit("exit"); else this.replace(this.cursor, this.boundary(1)); }
|
|
130
|
+
else if (char === "\x0f") this.emit("details");
|
|
131
|
+
else if (char === "\x15") this.replace(this.lineStart(), this.cursor, "", true);
|
|
132
|
+
else if (char === "\x0b") this.replace(this.cursor, this.lineEnd(), "", true);
|
|
133
|
+
else if (char === "\x17") this.replace(this.word(-1), this.cursor, "", true);
|
|
134
|
+
else if (char === "\x19") this.insert(this.killed);
|
|
135
|
+
else if (char === "\x1f") this.restore();
|
|
136
|
+
else if (char === "\x12") this.emit("search");
|
|
137
|
+
else if (char === "\x0c") this.emit("redraw");
|
|
138
|
+
else if (char === "\x01") { this.cursor = this.lineStart(); this.emit("change", this.text); }
|
|
139
|
+
else if (char === "\x05") { this.cursor = this.lineEnd(); this.emit("change", this.text); }
|
|
140
|
+
else if (char === "\x7f" || char === "\b") {
|
|
141
|
+
const next = this.boundary(-1);
|
|
142
|
+
this.replace(next, this.cursor);
|
|
143
|
+
} else if (char === "\t") { if (this.listenerCount("complete")) this.emit("complete"); else this.insert(" "); }
|
|
144
|
+
else if (char >= " " && char !== "\x7f") this.insert(char);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
close() { clearTimeout(this.escapeTimer); }
|
|
148
|
+
}
|