@vecteur/cli 0.7.1 → 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 +22 -4
- 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/mcp.js +1 -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/tui/view.js
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import stringWidth from "string-width";
|
|
2
|
+
import wrapAnsi from "wrap-ansi";
|
|
3
|
+
import { parseMarkdown } from "../markdown.js";
|
|
4
|
+
import { terminalText } from "../format.js";
|
|
5
|
+
const style = (value, code, color) => color ? `\x1b[${code}m${value}\x1b[0m` : value;
|
|
6
|
+
// One accent (the web's sky cyan, xterm 81), one secondary grey; amber only for attention.
|
|
7
|
+
const ACCENT = "38;5;81", DIM = "90", ATTENTION = "33";
|
|
8
|
+
const SPINNER = ["·", "✢", "✳", "✶", "✻", "✽", "✻", "✶", "✳", "✢"];
|
|
9
|
+
const printableAscii = /^[\x20-\x7e]*$/;
|
|
10
|
+
const measure = value => printableAscii.test(value) ? value.length : stringWidth(value);
|
|
11
|
+
const wrapped = new Map();
|
|
12
|
+
const wrap = (value, width, wordWrap = true) => {
|
|
13
|
+
width = Math.max(1, width);
|
|
14
|
+
const key = value.length <= 512 ? `${width}:${wordWrap}:${value}` : null;
|
|
15
|
+
if (key && wrapped.has(key)) return wrapped.get(key);
|
|
16
|
+
const lines = !value.includes("\n") && measure(value) <= width ? [value]
|
|
17
|
+
: wrapAnsi(value, width, { hard: true, trim: false, wordWrap }).split("\n");
|
|
18
|
+
if (key) {
|
|
19
|
+
if (wrapped.size === 128) wrapped.delete(wrapped.keys().next().value);
|
|
20
|
+
wrapped.set(key, lines);
|
|
21
|
+
}
|
|
22
|
+
return lines;
|
|
23
|
+
};
|
|
24
|
+
// Prose wraps at word boundaries and drops the break's leading space; the composer keeps every space.
|
|
25
|
+
const prose = (value, width) => wrapAnsi(value, Math.max(1, width), { hard: true, trim: true }).split("\n");
|
|
26
|
+
const pad = (value, width) => value + " ".repeat(Math.max(0, width - measure(value)));
|
|
27
|
+
|
|
28
|
+
function inline(tokens, color) {
|
|
29
|
+
return tokens.map(token => {
|
|
30
|
+
const href = token.href && /^https?:\/\//i.test(token.href) ? ` <${token.href}>` : "";
|
|
31
|
+
const value = token.text + href;
|
|
32
|
+
return token.kind === "strong" ? style(value, "1", color)
|
|
33
|
+
: token.kind === "em" ? style(value, "3", color)
|
|
34
|
+
: token.kind === "code" ? style(value, ACCENT, color)
|
|
35
|
+
: token.kind === "link" ? style(value, "4", color) : value;
|
|
36
|
+
}).join("");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const numeric = /^[-+−~≈<>≤≥±]?\s?\d[\d.,]*(?:\s?[^\s\d]\S*)?$/;
|
|
40
|
+
function table(block, width, color) {
|
|
41
|
+
const grid = [block.header, ...block.rows].map(row => row.map(cell => inline(cell, false)));
|
|
42
|
+
const columns = Math.max(...grid.map(row => row.length));
|
|
43
|
+
const widths = Array.from({ length: columns }, (_, i) => Math.max(1, ...grid.map(row => measure(row[i] ?? ""))));
|
|
44
|
+
if (widths.reduce((a, b) => a + b, 0) + 3 * (columns - 1) > width) {
|
|
45
|
+
// Too wide for the terminal: one record per row, never a clipped column.
|
|
46
|
+
const lines = [];
|
|
47
|
+
block.rows.forEach((row, index) => {
|
|
48
|
+
lines.push(style(`Record ${index + 1}`, "1", color));
|
|
49
|
+
row.forEach((cell, column) => lines.push(...wrap(`${style(grid[0]?.[column] || `Column ${column + 1}`, DIM, color)} ${inline(cell, color)}`, width)));
|
|
50
|
+
});
|
|
51
|
+
return lines;
|
|
52
|
+
}
|
|
53
|
+
const right = widths.map((_, i) => block.rows.length > 0 && block.rows.every(row => numeric.test(inline(row[i] ?? [], false).trim() || "0")));
|
|
54
|
+
const cell = (value, i) => right[i] ? " ".repeat(Math.max(0, widths[i] - measure(value))) + value : pad(value, widths[i]);
|
|
55
|
+
const bar = style(" │ ", DIM, color);
|
|
56
|
+
return [style(grid[0].map((value, i) => pad(value, widths[i])).join(" │ "), "1", color),
|
|
57
|
+
style(widths.map(w => "─".repeat(w)).join("─┼─"), DIM, color),
|
|
58
|
+
...grid.slice(1).map(row => widths.map((_, i) => cell(row[i] ?? "", i)).join(bar))];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Shared web grammar; only trusted renderer styling can emit terminal controls. */
|
|
62
|
+
export function markdownView(source, width, color = true) {
|
|
63
|
+
const lines = [];
|
|
64
|
+
for (const block of parseMarkdown(terminalText(source))) {
|
|
65
|
+
if (block.kind === "table") lines.push(...table(block, width, color));
|
|
66
|
+
else if (block.kind === "codeblock") {
|
|
67
|
+
if (block.language) lines.push(style(block.language, DIM, color));
|
|
68
|
+
lines.push(...wrap(block.text, width - 2, false).map(line => style("│ ", DIM, color) + line));
|
|
69
|
+
} else if (block.kind === "hr") {
|
|
70
|
+
lines.push(style("─".repeat(Math.min(width, 48)), DIM, color));
|
|
71
|
+
} else if (block.kind === "list") {
|
|
72
|
+
block.items.forEach((item, index) => {
|
|
73
|
+
const prefix = block.ordered ? `${index + 1}. ` : "• ";
|
|
74
|
+
prose(inline(item, color), width - prefix.length).forEach((line, i) => lines.push((i ? " ".repeat(prefix.length) : style(prefix, DIM, color)) + line));
|
|
75
|
+
});
|
|
76
|
+
} else {
|
|
77
|
+
let value = inline(block.inline, color);
|
|
78
|
+
if (block.kind === "heading") value = style(value, block.level <= 2 ? `1;${ACCENT}` : "1", color);
|
|
79
|
+
if (block.kind === "quote") lines.push(...prose(value, width - 2).map(line => style("│ ", DIM, color) + line));
|
|
80
|
+
else lines.push(...prose(value, width));
|
|
81
|
+
}
|
|
82
|
+
lines.push("");
|
|
83
|
+
}
|
|
84
|
+
while (lines.at(-1) === "") lines.pop();
|
|
85
|
+
return lines.join("\n");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// One terminal owns this cache. Cursor movement never rewraps unrelated paragraphs.
|
|
89
|
+
let composerText;
|
|
90
|
+
const composerLayouts = new Map();
|
|
91
|
+
function composerViewport(composer, cursor, width, height) {
|
|
92
|
+
if (composerText !== composer) { composerLayouts.clear(); composerText = composer; }
|
|
93
|
+
let composerLayout = composerLayouts.get(width);
|
|
94
|
+
if (!composerLayout) {
|
|
95
|
+
const paragraphs = [], lines = []; let offset = 0;
|
|
96
|
+
for (const text of composer.split("\n")) {
|
|
97
|
+
paragraphs.push({ text, offset, row: lines.length });
|
|
98
|
+
lines.push(...wrap(terminalText(text), width, false)); offset += text.length + 1;
|
|
99
|
+
}
|
|
100
|
+
composerLayout = { paragraphs, lines };
|
|
101
|
+
if (composerLayouts.size === 3) composerLayouts.delete(composerLayouts.keys().next().value);
|
|
102
|
+
composerLayouts.set(width, composerLayout);
|
|
103
|
+
}
|
|
104
|
+
const { paragraphs, lines } = composerLayout;
|
|
105
|
+
const paragraph = paragraphs.findLast(row => row.offset <= cursor) ?? paragraphs[0];
|
|
106
|
+
const local = cursor - paragraph.offset;
|
|
107
|
+
const before = terminalText(paragraph.text.slice(0, local));
|
|
108
|
+
const active = wrap(before + "▌" + terminalText(paragraph.text.slice(local)), width, false);
|
|
109
|
+
const cursorRow = paragraph.row + wrap(before + "▌", width, false).length - 1;
|
|
110
|
+
const start = Math.max(0, cursorRow - height + 1);
|
|
111
|
+
const next = paragraphs[paragraphs.indexOf(paragraph) + 1]?.row ?? lines.length;
|
|
112
|
+
const visible = start < paragraph.row ? lines.slice(start, paragraph.row) : [];
|
|
113
|
+
visible.push(...active.slice(Math.max(0, start - paragraph.row)), ...lines.slice(next, next + height));
|
|
114
|
+
return { lines: visible.slice(0, height), position: `Line ${paragraphs.indexOf(paragraph) + 1}/${paragraphs.length}`,
|
|
115
|
+
scrolling: lines.length > height };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** One rounded box, the same frame as the prompt: a label may sit in the top or bottom border. */
|
|
119
|
+
function box(rows, width, color, { top = "", bottom = "" } = {}) {
|
|
120
|
+
const inner = Math.max(1, width - 4);
|
|
121
|
+
const edge = (left, label, right) => {
|
|
122
|
+
const text = label ? `─ ${trimEnd(label, inner - 2)} ` : "";
|
|
123
|
+
return style(left + text + "─".repeat(Math.max(0, width - 2 - measure(text))) + right, DIM, color);
|
|
124
|
+
};
|
|
125
|
+
return [edge("╭", top, "╮"), ...rows.map(row => `${style("│", DIM, color)} ${row}${" ".repeat(Math.max(0, inner - measure(row)))} ${style("│", DIM, color)}`),
|
|
126
|
+
edge("╰", bottom, "╯")];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The once-printed welcome: one calm box, a single accent glyph, no ASCII logo. */
|
|
130
|
+
export function welcome(version, origin, columns = 80, color = true, channel, folder) {
|
|
131
|
+
const width = Math.max(20, columns - 1), inner = width - 4;
|
|
132
|
+
const label = `${version}${channel === "preview" ? " · Preview" : ""}`;
|
|
133
|
+
const where = [folder, origin === "https://vecteur.space" ? "" : origin].filter(Boolean).map(terminalText).join(" · ");
|
|
134
|
+
const head = `${style("▼", ACCENT, color)} ${style("Vecteur", "1", color)} ${style(label, DIM, color)}`;
|
|
135
|
+
const lines = measure(`▼ Vecteur ${label} · Laplace · Vecteur cloud`) <= inner
|
|
136
|
+
? [`${head} ${style("· Laplace · Vecteur cloud", DIM, color)}`] : [head];
|
|
137
|
+
if (where) lines.push(style(trimEnd(` ${where}`, inner), DIM, color));
|
|
138
|
+
return box(lines, width, color).join("\n") + "\n";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Display-cell truncation with an ellipsis; never more work than the visible width needs.
|
|
142
|
+
function trimEnd(value, max) {
|
|
143
|
+
const safe = terminalText(value);
|
|
144
|
+
if (measure(safe) <= max) return safe;
|
|
145
|
+
const chars = [...safe].slice(0, Math.max(0, max));
|
|
146
|
+
while (chars.length && measure(chars.join("")) > max - 1) chars.pop();
|
|
147
|
+
return chars.join("") + "…";
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function trimStart(value, max) {
|
|
151
|
+
const safe = terminalText(value);
|
|
152
|
+
if (measure(safe) <= max) return safe;
|
|
153
|
+
const chars = [...safe].slice(-Math.max(1, max));
|
|
154
|
+
while (chars.length && measure(chars.join("")) > max - 1) chars.shift();
|
|
155
|
+
return "…" + chars.join("");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Bottom-anchored live area: activity, focus, the prompt box, then one or two footer lines. */
|
|
159
|
+
export function richFrame(view, composer, cursor, { columns = 80, rows = 24, color = true, tick = null } = {}) {
|
|
160
|
+
const width = Math.max(20, columns - 1), budget = Math.max(1, rows - 3);
|
|
161
|
+
const active = !!view.run?.subject && !view.run.terminal;
|
|
162
|
+
const readOnly = view.project?.role === "VIEWER", signedOut = view.offline || !view.account;
|
|
163
|
+
const glyph = tick === null ? "●" : SPINNER[tick % SPINNER.length];
|
|
164
|
+
const runHint = readOnly ? "Read-only · /help · Ctrl+D detach"
|
|
165
|
+
: signedOut ? `${view.offline ? "/resume" : "/login"} · /help · Ctrl+D detach`
|
|
166
|
+
: `${view.run?.question ? "/answer" : "/steer · /answer-now"} · /help${width < 60 ? "\n" : " · "}Esc twice cancel · Ctrl+D detach`;
|
|
167
|
+
if (rows < 12) {
|
|
168
|
+
const focus = view.focus ?? view.prompt;
|
|
169
|
+
const choices = view.choices ?? focus?.options ?? view.palette ?? [];
|
|
170
|
+
const selected = Math.min(view.selected ?? view.paletteSelected ?? 0, Math.max(0, choices.length - 1));
|
|
171
|
+
const choice = choices[selected];
|
|
172
|
+
const content = focus?.title ?? (view.login ? `Sign in · Code ${view.login.user_code}` : view.issue?.text
|
|
173
|
+
?? (active ? `${view.run.elapsed ?? "Elapsed unavailable"} · ${view.status}` : view.status));
|
|
174
|
+
const status = wrap(terminalText(content), width).slice(0, 1);
|
|
175
|
+
if (choice) status.push(wrap(terminalText(`› ${selected + 1}/${choices.length} ${choice.label ?? '/' + choice.name}`), width)[0]);
|
|
176
|
+
else if (view.login) status.push(['› Open browser', '› Copy link', '› Work offline'][view.loginAction ?? 0]);
|
|
177
|
+
const draft = composerViewport(composer, cursor, width - 2, 1).lines[0];
|
|
178
|
+
const hint = focus || choices.length ? '↑↓ Enter choose · Esc back' : view.login ? 'Tab actions · Enter open · Esc offline'
|
|
179
|
+
: active ? runHint : '? keys · /help · Ctrl+D exit';
|
|
180
|
+
return [...status, `${style("›", ACCENT, color)} ${draft}`, ...wrap(style(hint, DIM, color), width).slice(0, 2)].slice(0, budget).join("\n");
|
|
181
|
+
}
|
|
182
|
+
if (rows < 8) return wrap(`${view.status} · Ctrl+D exit`, width).slice(0, budget).join("\n");
|
|
183
|
+
const project = view.projectLabel ?? view.project?.name ?? "New Project on first send";
|
|
184
|
+
// The steady footer names the Workspace, never a personal address; /status shows the account.
|
|
185
|
+
const workspaces = view.account?.workspaces ?? [];
|
|
186
|
+
const account = view.account ? (workspaces.find(row => row.id === view.project?.workspace_id) ?? workspaces[0])?.name ?? "Signed in"
|
|
187
|
+
: view.offline ? "Offline" : "Not signed in";
|
|
188
|
+
const compact = width < 60 || rows < 18;
|
|
189
|
+
const prompt = view.focus ?? view.prompt;
|
|
190
|
+
const compactFocus = rows < 24 && (view.focus || view.prompt || view.login || view.palette?.length || view.issue);
|
|
191
|
+
const compactRun = rows < 18 && active;
|
|
192
|
+
|
|
193
|
+
// Footer first: it is what keeps every state escapable, so the rest fits around it.
|
|
194
|
+
const hint = prompt ? prompt.multiple ? "Space toggle · ↑↓ move · Enter confirm · Esc back" : "↑↓ choose · Enter confirm · Esc back · Ctrl+D exit"
|
|
195
|
+
: view.login ? "Tab actions · Enter on empty activates · Esc offline"
|
|
196
|
+
: view.palette?.length ? "↑↓ choose · Tab complete · Enter open · Esc close"
|
|
197
|
+
: active ? runHint
|
|
198
|
+
: `${readOnly ? "Read-only · /project" : signedOut ? "Enter sign in" : view.busy ? "Please wait" : "Enter send"}`
|
|
199
|
+
+ (width < 60 ? " · /help · Ctrl+D exit" : " · Alt+Enter newline · / commands · ? keys · /help · Ctrl+D exit");
|
|
200
|
+
const execution = view.location && view.location !== "Vecteur cloud" ? view.location : "Cloud";
|
|
201
|
+
const context = `${view.lead ? `${view.lead} · ` : ""}${view.model} · ${execution}`;
|
|
202
|
+
const footer = wrap(style(hint, DIM, color), width - 1).slice(0, 2).map(line => " " + line);
|
|
203
|
+
const identity = trimEnd(`${project === "New Project on first send" ? "New Project" : project} · ${account}`, width);
|
|
204
|
+
const where = trimStart(view.folder ?? ".", Math.max(6, width - measure(identity) - measure(context) - 7));
|
|
205
|
+
const left = where === "." ? identity : `${identity} · ${where}`;
|
|
206
|
+
if (!compact && measure(left) + measure(context) + 4 <= width) {
|
|
207
|
+
footer.push(" " + style(left + " ".repeat(width - 2 - measure(left) - measure(context)) + context, DIM, color));
|
|
208
|
+
} else {
|
|
209
|
+
footer.push(" " + style(trimEnd(context, width - 1), DIM, color));
|
|
210
|
+
if (!compact && !compactFocus) footer.push(" " + style(trimEnd(left, width - 1), DIM, color));
|
|
211
|
+
}
|
|
212
|
+
if (view.origin && view.origin !== "https://vecteur.space") footer.push(" " + style(trimEnd(view.origin, width - 1), ACCENT, color));
|
|
213
|
+
|
|
214
|
+
// A breathing line between the scrollback and the live area when the window has room for it.
|
|
215
|
+
const lines = rows >= 18 && !compactFocus ? [""] : [];
|
|
216
|
+
const room = () => budget - footer.length - 3 - lines.length;
|
|
217
|
+
const line = (value, code, limit = 2) => {
|
|
218
|
+
const text = terminalText(value);
|
|
219
|
+
lines.push(...wrap(code ? style(text, code, color) : text, width).slice(0, Math.max(0, Math.min(limit, room()))));
|
|
220
|
+
};
|
|
221
|
+
// Activity: the one animated glyph, the current step and the server-anchored clock.
|
|
222
|
+
if (active && !compactFocus) {
|
|
223
|
+
const clock = view.run.elapsed ?? "Elapsed unavailable";
|
|
224
|
+
const doing = compactRun ? view.status : (view.run.progress || "Working").replace(/^Starting: (.+)$/, "$1") + "…";
|
|
225
|
+
const head = `${style(glyph, ACCENT, color)} ${terminalText(doing)}`, tail = style(terminalText(clock), DIM, color);
|
|
226
|
+
if (measure(`${glyph} ${terminalText(doing)} · ${terminalText(clock)}`) <= width) lines.push(`${head} ${style("·", DIM, color)} ${tail}`);
|
|
227
|
+
else if (compactRun) lines.push(wrap(head, width)[0], ` ${tail}`);
|
|
228
|
+
else lines.push(wrap(`${style(glyph, ACCENT, color)} ${tail} · ${terminalText(doing)}`, width)[0]);
|
|
229
|
+
if (!compactRun && view.status && view.status !== "Running") line(view.status, DIM, 1);
|
|
230
|
+
}
|
|
231
|
+
if (!compactFocus && !compactRun && view.run?.question) line(readOnly ? "Waiting for an editor to answer"
|
|
232
|
+
: signedOut ? "Question pending — reconnect to answer" : "Your answer is needed · /answer", `1;${ATTENTION}`, 1);
|
|
233
|
+
if (prompt) {
|
|
234
|
+
line(prompt.title, "1", compactFocus ? 1 : 3);
|
|
235
|
+
if (!prompt.typed) {
|
|
236
|
+
const choices = view.choices ?? prompt.options, selected = Math.min(view.selected ?? 0, Math.max(0, choices.length - 1));
|
|
237
|
+
const more = choices.length > 1 && !compactFocus ? 1 : 0;
|
|
238
|
+
const count = Math.max(1, Math.min(5, room() - more));
|
|
239
|
+
const offset = Math.max(0, selected - count + 1);
|
|
240
|
+
choices.slice(offset, offset + count).forEach((item, i) => {
|
|
241
|
+
const index = prompt.options.indexOf(item) + 1, current = i + offset === selected;
|
|
242
|
+
const checked = prompt.multiple ? `${composer.split(/[, ]+/).includes(String(index)) ? "[x]" : "[ ]"} ` : "";
|
|
243
|
+
lines.push(wrap(`${current ? style("›", ACCENT, color) : " "} ${index}. ${checked}${current ? style(terminalText(item.label), ACCENT, color) : terminalText(item.label)}`, width)[0]);
|
|
244
|
+
});
|
|
245
|
+
if (!choices.length) line("No matching choices; clear the filter", ATTENTION, 1);
|
|
246
|
+
if (choices.length > count && more) line(`${selected + 1}/${choices.length} · ↑↓ more choices`, DIM, 1);
|
|
247
|
+
}
|
|
248
|
+
} else if (view.login) {
|
|
249
|
+
line(`Approve code ${view.login.user_code} in your browser`, "1", 1);
|
|
250
|
+
if (!compactFocus) line(view.login.verification_uri_complete, ACCENT, 2);
|
|
251
|
+
const actions = ["Open browser", "Copy link", "Work offline"];
|
|
252
|
+
line(compactFocus ? `› ${actions[view.loginAction ?? 0]} (${(view.loginAction ?? 0) + 1}/3)`
|
|
253
|
+
: actions.map((text, i) => `${i === view.loginAction ? "› " : ""}${text}`).join(" · "), undefined, compactFocus ? 1 : 2);
|
|
254
|
+
} else if (view.palette?.length) {
|
|
255
|
+
const selected = Math.min(view.paletteSelected ?? 0, view.palette.length - 1);
|
|
256
|
+
const count = Math.max(1, Math.min(6, room()));
|
|
257
|
+
const offset = Math.max(0, selected - count + 1);
|
|
258
|
+
const name = Math.min(16, Math.max(...view.palette.map(row => row.name.length + 1)));
|
|
259
|
+
view.palette.slice(offset, offset + count).forEach((row, i) => {
|
|
260
|
+
const current = i + offset === selected, label = pad(`/${row.name}`, name);
|
|
261
|
+
lines.push(wrap(`${current ? style("›", ACCENT, color) : " "} ${current ? style(label, ACCENT, color) : label} ${style(terminalText(row.disabled || row.description), DIM, color)}`, width)[0]);
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
const compactAttachments = rows < 24 && view.attachments?.length;
|
|
265
|
+
if (view.attachments?.length && !compactAttachments) line(`${view.attachments.length} file(s) selected · uploaded only on send`, DIM, 1);
|
|
266
|
+
if (view.issue && !prompt) line(view.issue.text + (view.issue.count > 1 ? ` (${view.issue.count} attempts)` : ""), ATTENTION, Math.max(1, Math.min(3, room())));
|
|
267
|
+
if (!active && !view.issue && !prompt && !view.login && !view.palette?.length) line(view.status, DIM, 1);
|
|
268
|
+
const maxDraft = Math.max(1, Math.min(6, budget - lines.length - footer.length - 2));
|
|
269
|
+
const draft = composerViewport(composer, cursor, width - 6, maxDraft);
|
|
270
|
+
const selection = compactAttachments ? `${view.attachments.length} file(s) · upload on send` : "";
|
|
271
|
+
const placeholder = prompt ? (prompt.typed ? "Type your answer…" : "Filter or type a choice number…")
|
|
272
|
+
: readOnly ? "Read-only Project — draft here…" : view.offline ? "Draft offline…"
|
|
273
|
+
: !view.account ? "Sign in to send — draft here…"
|
|
274
|
+
: active ? "Draft your next question…" : "Ask Laplace…";
|
|
275
|
+
lines.push(...box(draft.lines.map((value, index) => `${index ? " " : style("›", ACCENT, color) + " "}${composer ? value : style("▌" + placeholder, DIM, color)}`),
|
|
276
|
+
width, color, { top: selection, bottom: draft.scrolling ? draft.position : "" }));
|
|
277
|
+
lines.push(...footer);
|
|
278
|
+
return lines.slice(-budget).join("\n");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const indent = (text, first, rest = " ") => text.split("\n").map((line, i) => (i ? (line ? rest : "") : first) + line).join("\n");
|
|
282
|
+
|
|
283
|
+
/** One scrollback block per message: the ask, the answer with its record, or a quiet notice. */
|
|
284
|
+
export function richMessage(message, columns, color, { stepsShown = false } = {}) {
|
|
285
|
+
const width = Math.max(20, columns - 1);
|
|
286
|
+
if (message.kind === "user") {
|
|
287
|
+
return "\n" + indent(wrap(terminalText(message.text), width - 2).join("\n"), style("›", ACCENT, color) + " ") + "\n";
|
|
288
|
+
}
|
|
289
|
+
if (message.kind === "step") return style(trimEnd(` ⎿ ${message.text}`, width), DIM, color);
|
|
290
|
+
if (message.kind === "cost") {
|
|
291
|
+
const display = message.value?.display ?? {};
|
|
292
|
+
const parts = [["Used", display.used], ["Limit", display.limit], ["Remaining", display.remaining]].filter(([, v]) => v !== null && v !== undefined);
|
|
293
|
+
return style(trimEnd(` Usage this UTC month · ${parts.length ? parts.map(([k, v]) => `${k} ${v}`).join(" · ") : "no exact amount available"} · /usage`, width), DIM, color);
|
|
294
|
+
}
|
|
295
|
+
if (message.kind !== "answer") return wrap(terminalText(message.text), width - 2).map(line => " " + line).join("\n");
|
|
296
|
+
const run = message.sections;
|
|
297
|
+
if (!run) return "\n" + indent(markdownView(message.text, width - 2, color), style("●", ACCENT, color) + " ") + "\n";
|
|
298
|
+
const out = [];
|
|
299
|
+
out.push(run.answer !== undefined ? indent(markdownView(run.answer, width - 2, color), style("●", ACCENT, color) + " ")
|
|
300
|
+
: style("●", ATTENTION, color) + " No answer was published for this Run.");
|
|
301
|
+
const section = (title, rows) => { if (rows.length) out.push("", " " + style(title, DIM, color), ...rows); };
|
|
302
|
+
const one = text => text.split("\n").map(part => part.trim()).filter(Boolean).join(" · ");
|
|
303
|
+
section("Steps", run.tasks.slice(stepsShown).map(task => style(trimEnd(` ⎿ ${one(task.replace(/^\s*[▸·]\s*/, ""))}`, width), DIM, color)));
|
|
304
|
+
// Evidence rows are never truncated: quantity, unit and origin wrap with a hanging indent.
|
|
305
|
+
const full = (row, code = DIM) => prose(terminalText(row.trim()), width - 4).map((part, i) => (i ? " " : " ") + style(part, code, color));
|
|
306
|
+
section("Inputs and origins", run.origins.flatMap(row => full(row)));
|
|
307
|
+
section("Assumptions", run.assumptions.flatMap(row => full(row)));
|
|
308
|
+
section("Transfer tables", run.tables.flatMap(rows => rows.flatMap(row => full(row))));
|
|
309
|
+
// Up to three files, readable names before digest-named ones; numbers stay the /download numbers.
|
|
310
|
+
const numbered = run.artifacts.map((row, i) => ({ ...row, number: i + 1 }));
|
|
311
|
+
const digest = row => /^[0-9a-f]{32,}\./.test(row.artifact.filename);
|
|
312
|
+
const shown = [...numbered.filter(row => !digest(row)), ...numbered.filter(digest)].slice(0, 3);
|
|
313
|
+
const more = run.artifacts.length - shown.length;
|
|
314
|
+
section("Files", shown.flatMap(({ artifact, number }) => prose(`${terminalText(artifact.filename)} ${style(`· ${artifact.kind} · /download ${number}`, DIM, color)}`, width - 4)
|
|
315
|
+
.map((part, i) => ` ${i ? " " : style("↓", ACCENT, color)} ${part}`))
|
|
316
|
+
.concat(more > 0 ? [style(` … ${more} more · /files`, DIM, color)] : []));
|
|
317
|
+
out.push("", ...full(run.tail.map(terminalText).join(" · "), run.state === "ok" ? DIM : ATTENTION));
|
|
318
|
+
return "\n" + out.join("\n") + "\n";
|
|
319
|
+
}
|
package/src/update.js
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { constants, accessSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, readdirSync, readlinkSync, symlinkSync, renameSync, unlinkSync, writeFileSync, openSync, closeSync, fsyncSync, statfsSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve, delimiter } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
5
|
+
import { execFile, spawn } from "node:child_process";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { CliError, VecteurClient, resolveOrigin } from "./client.js";
|
|
8
|
+
import { preferences, savePreferences } from "./preferences.js";
|
|
9
|
+
import { terminalText } from "./format.js";
|
|
10
|
+
import { credentialsPath } from "./credentials.js";
|
|
11
|
+
import { writeStore, readStore } from "./store.js";
|
|
12
|
+
|
|
13
|
+
const run = promisify(execFile), REGISTRY = "https://registry.npmjs.org", PACKAGE = "@vecteur/cli";
|
|
14
|
+
const ownRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
15
|
+
const manifest = root => JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
|
16
|
+
const quote = value => "'" + value.replaceAll("'", "'\\''") + "'";
|
|
17
|
+
const fail = (code, text) => { throw new CliError(code, text); };
|
|
18
|
+
const MAX_BYTES = 512 * 1024 * 1024;
|
|
19
|
+
const official = origin => origin === "https://vecteur.space";
|
|
20
|
+
const versionCompare = (a, b) => {
|
|
21
|
+
const aa = a.split(".").map(Number), bb = b.split(".").map(Number);
|
|
22
|
+
for (let i = 0; i < 3; i++) if (aa[i] !== bb[i]) return aa[i] - bb[i];
|
|
23
|
+
return 0;
|
|
24
|
+
};
|
|
25
|
+
function owned(path, directory = false) {
|
|
26
|
+
const stat = lstatSync(path);
|
|
27
|
+
return (!directory || stat.isDirectory()) && !stat.isSymbolicLink()
|
|
28
|
+
&& (!process.getuid || stat.uid === process.getuid()) && !(stat.mode & 0o022);
|
|
29
|
+
}
|
|
30
|
+
function pathBinary(env) {
|
|
31
|
+
for (const dir of (env.PATH ?? "").split(delimiter)) {
|
|
32
|
+
const file = resolve(dir, process.platform === "win32" ? "vecteur.cmd" : "vecteur");
|
|
33
|
+
try { accessSync(file, constants.X_OK); return file; } catch {}
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
/** Inspect paths only. Never execute the other PATH binary, even for its version. */
|
|
38
|
+
export function installation({ entry = process.argv[1], env = process.env, root = ownRoot } = {}) {
|
|
39
|
+
const launcher = resolve(entry), realEntry = realpathSync(launcher), pkg = manifest(root);
|
|
40
|
+
const pointer = lstatSync(launcher);
|
|
41
|
+
const identity = [pointer.dev, pointer.ino, pointer.mtimeMs, pointer.size];
|
|
42
|
+
let path = pathBinary(env), pathVersion = null;
|
|
43
|
+
try { const other = manifest(resolve(dirname(realpathSync(path)), "..")); if (other.name === PACKAGE) pathVersion = other.version; } catch {}
|
|
44
|
+
const parent = dirname(launcher);
|
|
45
|
+
// The pointer is outside every immutable npm generation. A direct JS path is never replaced.
|
|
46
|
+
const prefix = parent.endsWith("/node_modules/.bin") ? resolve(parent, "../..")
|
|
47
|
+
: parent.endsWith("/bin") ? dirname(parent) : null;
|
|
48
|
+
let reason = "Direct script or unmanaged installation; use its package manager";
|
|
49
|
+
if (launcher.includes("/.vecteur-updates/generation-")) reason = "Launch this installation through its original launcher; retained generation files stay immutable";
|
|
50
|
+
else if (process.platform === "win32") reason = "Windows launcher recovery has not been qualified; use npm explicitly";
|
|
51
|
+
else if (process.env.NODE_OPTIONS || process.env.NODE_PATH || process.execArgv.length) reason = "Custom Node options/loaders: automatic replacement is unavailable";
|
|
52
|
+
else if (prefix && lstatSync(launcher).isSymbolicLink() && realEntry === join(realpathSync(root), "bin/vecteur.js")) {
|
|
53
|
+
try {
|
|
54
|
+
if (!owned(parent, true) || !owned(prefix, true) || !owned(realEntry) || !owned(root, true)) throw new Error();
|
|
55
|
+
accessSync(parent, constants.W_OK); accessSync(prefix, constants.W_OK);
|
|
56
|
+
reason = "";
|
|
57
|
+
} catch { reason = "Installation is not a writable, user-owned npm prefix"; }
|
|
58
|
+
}
|
|
59
|
+
const key = createHash("sha256").update(launcher).digest("hex");
|
|
60
|
+
return { launcher, realEntry, identity, root, prefix, version: pkg.version, channel: pkg.vecteur?.channel ?? "unknown", node: process.execPath,
|
|
61
|
+
path, pathVersion, reason, qualified: !reason, key };
|
|
62
|
+
}
|
|
63
|
+
export function installationText(info) {
|
|
64
|
+
return [`Running: ${info.launcher} (${info.version})`, `Resolved: ${info.realEntry}`, `Node: ${info.node}`,
|
|
65
|
+
info.path && info.path !== info.launcher ? `PATH: ${info.path} (${info.pathVersion ?? "version unknown"}); updating it does not change the running installation.` : "PATH selects this launcher.",
|
|
66
|
+
info.pathVersion && info.pathVersion !== info.version ? `To use the PATH installation: ${quote(info.path)}` : "",
|
|
67
|
+
info.reason || "Update layout: npm launcher with retained immutable generations"].filter(Boolean).join("\n");
|
|
68
|
+
}
|
|
69
|
+
function manualRecovery(plan) {
|
|
70
|
+
const { info, release } = plan;
|
|
71
|
+
if (info.pathVersion && info.pathVersion === (release.cli?.version ?? release.requiredVersion) && info.path !== info.launcher) return `Launch the matching installed CLI: ${quote(info.path)}`;
|
|
72
|
+
if (!release.cli) return "Use /offline to keep drafting; this older origin supplies no structured update pin.";
|
|
73
|
+
if (!info.prefix) return `Unmanaged checkout: use its build/package instructions. Offline launch: ${quote(info.launcher)} --offline`;
|
|
74
|
+
const global = dirname(info.launcher).endsWith("/bin");
|
|
75
|
+
return `Manual npm recovery for this prefix (close its clients first): npm install ${global ? "--global " : ""}--prefix ${quote(info.prefix)} --ignore-scripts --registry ${REGISTRY} ${PACKAGE}@${release.cli.version}`;
|
|
76
|
+
}
|
|
77
|
+
const prefKey = (info, origin) => `${info.key}:${origin}`;
|
|
78
|
+
export async function updatePreference(session) {
|
|
79
|
+
const info = installation({ env: session.env });
|
|
80
|
+
const mode = await session.choose(`Updates for ${info.launcher} at ${session.origin}`, [
|
|
81
|
+
{ label: "Ask before installing (default)", value: "ask" },
|
|
82
|
+
{ label: "Automatically install verified upgrades when idle", value: "auto" },
|
|
83
|
+
{ label: "Off — check only with /update", value: "off" },
|
|
84
|
+
]);
|
|
85
|
+
const prefs = preferences(session.env);
|
|
86
|
+
savePreferences({ updates: { ...prefs.updates, [prefKey(info, session.origin)]: mode } }, session.env);
|
|
87
|
+
session.message(`Updates: ${mode}${mode === "auto" && info.reason ? `; deferred: ${info.reason}` : ""}.`);
|
|
88
|
+
}
|
|
89
|
+
export async function checkUpdate({ origin, env = process.env, release, force = false } = {}) {
|
|
90
|
+
const info = installation({ env });
|
|
91
|
+
origin = resolveOrigin(env, undefined, origin);
|
|
92
|
+
const cache = join(dirname(credentialsPath(env)), `release-${createHash("sha256").update(origin).digest("hex")}.json`);
|
|
93
|
+
if (!release && !force) {
|
|
94
|
+
const saved = readStore(cache);
|
|
95
|
+
if (saved?.origin === origin && saved.at <= Date.now() && Date.now() - saved.at < 86_400_000) release = saved.release;
|
|
96
|
+
}
|
|
97
|
+
release ??= await new VecteurClient({ origin, timeout: 5_000 }).readiness();
|
|
98
|
+
if (release.compatible) writeStore(cache, { origin, at: Date.now(), release });
|
|
99
|
+
else { try { unlinkSync(cache); } catch (error) { if (error.code !== "ENOENT") throw error; } }
|
|
100
|
+
const cli = release.cli;
|
|
101
|
+
const reason = !official(origin) ? "Custom origin: executable acquisition is disabled"
|
|
102
|
+
: !cli?.integrity || cli.channel !== "stable" ? "This origin has no published, integrity-pinned stable package"
|
|
103
|
+
: info.reason;
|
|
104
|
+
return { info, origin, release, reason, mode: preferences(env).updates[prefKey(info, origin)] ?? "ask",
|
|
105
|
+
needed: !!cli && (!release.compatible || cli.version !== info.version),
|
|
106
|
+
upgrade: !!cli && versionCompare(cli.version, info.version) > 0 };
|
|
107
|
+
}
|
|
108
|
+
function cleanEnvironment(directory) {
|
|
109
|
+
// npm sees no Vecteur token, npm token, user config, custom loader or proxy.
|
|
110
|
+
return { PATH: process.env.PATH, HOME: directory, TMPDIR: directory, SystemRoot: process.env.SystemRoot,
|
|
111
|
+
npm_config_registry: REGISTRY, npm_config_userconfig: join(directory, "empty.npmrc"),
|
|
112
|
+
npm_config_globalconfig: join(directory, "empty-global.npmrc"), npm_config_cache: join(directory, "cache"),
|
|
113
|
+
npm_config_ignore_scripts: "true", npm_config_umask: "077", npm_config_audit: "false", npm_config_fund: "false" };
|
|
114
|
+
}
|
|
115
|
+
function syncDirectory(path) { const fd = openSync(path, "r"); try { fsyncSync(fd); } finally { closeSync(fd); } }
|
|
116
|
+
function unchanged(info) {
|
|
117
|
+
const stat = lstatSync(info.launcher);
|
|
118
|
+
return stat.isSymbolicLink() && JSON.stringify([stat.dev, stat.ino, stat.mtimeMs, stat.size]) === JSON.stringify(info.identity)
|
|
119
|
+
&& realpathSync(info.launcher) === info.realEntry;
|
|
120
|
+
}
|
|
121
|
+
export function inspectGeneration(directory) {
|
|
122
|
+
const root = realpathSync(directory); let bytes = 0;
|
|
123
|
+
function visit(path) {
|
|
124
|
+
for (const name of readdirSync(path)) {
|
|
125
|
+
const file = join(path, name), stat = lstatSync(file);
|
|
126
|
+
if (stat.isSymbolicLink()) {
|
|
127
|
+
const target = realpathSync(file);
|
|
128
|
+
if (!target.startsWith(root + "/")) fail("update_closure", "A generation symlink escapes its dependency closure");
|
|
129
|
+
} else if (stat.isDirectory()) visit(file);
|
|
130
|
+
else if (!stat.isFile() || !owned(file)) fail("update_closure", "Unsafe package file ownership or type");
|
|
131
|
+
else bytes += stat.size;
|
|
132
|
+
if (bytes > MAX_BYTES) fail("update_storage", "Update generation exceeds 512 MiB; existing launchers are unchanged");
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
visit(root); return bytes;
|
|
136
|
+
}
|
|
137
|
+
export function activateLauncher(launcher, target) {
|
|
138
|
+
const temporary = `${launcher}.${randomUUID()}.tmp`;
|
|
139
|
+
try { symlinkSync(target, temporary); renameSync(temporary, launcher); syncDirectory(dirname(launcher)); }
|
|
140
|
+
finally { try { unlinkSync(temporary); } catch (error) { if (error.code !== "ENOENT") throw error; } }
|
|
141
|
+
}
|
|
142
|
+
/** The lock serializes pointer writers; readers retain their original real module paths forever. */
|
|
143
|
+
export async function stageUpdate(plan, { signal, progress = () => {} } = {}) {
|
|
144
|
+
if (plan.reason) fail("update_unavailable", plan.reason);
|
|
145
|
+
const { info, release } = plan, cli = release.cli;
|
|
146
|
+
const directory = join(info.prefix, ".vecteur-updates");
|
|
147
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
148
|
+
if (!owned(directory, true)) fail("update_permissions", "Update directory must be private and owned by you");
|
|
149
|
+
const lock = join(directory, "install.lock");
|
|
150
|
+
let fd;
|
|
151
|
+
try { fd = openSync(lock, "wx", 0o600); }
|
|
152
|
+
catch { fail("update_locked", `Another updater owns ${lock}; inspect it after a crash before removing it`); }
|
|
153
|
+
writeFileSync(fd, JSON.stringify({ pid: process.pid, launcher: info.launcher })); fsyncSync(fd);
|
|
154
|
+
let stage;
|
|
155
|
+
try {
|
|
156
|
+
if (!unchanged(info)) fail("update_changed", "Another process changed this launcher; check again");
|
|
157
|
+
// Never delete a possibly-live generation to make room.
|
|
158
|
+
if (readdirSync(directory).filter(name => name.startsWith("generation-")).length >= 5) fail("update_storage", `Five generations retained in ${directory}; inspect inactive generations before another update`);
|
|
159
|
+
const retained = readdirSync(directory).filter(name => name.startsWith("generation-")).reduce((n, name) => n + inspectGeneration(join(directory, name)), 0);
|
|
160
|
+
const disk = statfsSync(directory);
|
|
161
|
+
if (retained > 2 * MAX_BYTES || disk.bavail * disk.bsize < MAX_BYTES) fail("update_storage", "Not enough space for a verified generation and rollback; old bytes retained");
|
|
162
|
+
stage = join(directory, `generation-${cli.version}-${randomUUID()}`);
|
|
163
|
+
mkdirSync(stage, { mode: 0o700 });
|
|
164
|
+
writeFileSync(join(stage, "empty.npmrc"), "", { mode: 0o600 }); writeFileSync(join(stage, "empty-global.npmrc"), "", { mode: 0o600 });
|
|
165
|
+
const env = cleanEnvironment(stage);
|
|
166
|
+
const npm = async args => (await run("npm", args, { cwd: stage, env, signal, timeout: 180_000, maxBuffer: 8 * 1024 * 1024 })).stdout;
|
|
167
|
+
const npmVersion = (await npm(["--version"])).trim();
|
|
168
|
+
if (!/^12\.\d+\.\d+$/.test(npmVersion) || (await npm(["config", "get", "include-attestations"])).trim() !== "false") fail("update_npm_unsupported", "This adapter requires npm 12 with verified attestation output; update npm explicitly through your Node manager");
|
|
169
|
+
const metadataSignal = AbortSignal.any([...(signal ? [signal] : []), AbortSignal.timeout(10_000)]);
|
|
170
|
+
const metadataResponse = await fetch(`${REGISTRY}/@vecteur%2fcli/${cli.version}`, { redirect: "error", signal: metadataSignal });
|
|
171
|
+
if (!metadataResponse.ok) fail("update_unpublished", "The required CLI package is not published");
|
|
172
|
+
let metadataBytes = 0; const chunks = [];
|
|
173
|
+
for await (const chunk of metadataResponse.body) {
|
|
174
|
+
metadataBytes += chunk.length;
|
|
175
|
+
if (metadataBytes > 2 * 1024 * 1024) fail("update_metadata", "Registry metadata exceeds the size limit");
|
|
176
|
+
chunks.push(chunk);
|
|
177
|
+
}
|
|
178
|
+
const metadata = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
179
|
+
if (metadata.name !== PACKAGE || metadata.version !== cli.version || metadata.dist?.integrity !== cli.integrity
|
|
180
|
+
|| !metadata.dist.attestations || !metadata.dist.signatures?.length) fail("update_provenance", "Published package lacks matching integrity, signatures or provenance; installation kept");
|
|
181
|
+
writeFileSync(join(stage, "package.json"), JSON.stringify({ private: true, dependencies: { [PACKAGE]: cli.version } }), { mode: 0o600 });
|
|
182
|
+
progress("Resolving the signed npm dependency closure");
|
|
183
|
+
await npm(["install", "--package-lock-only", "--ignore-scripts", "--no-audit", "--no-fund", "--registry", REGISTRY]);
|
|
184
|
+
const lockfile = JSON.parse(readFileSync(join(stage, "package-lock.json"), "utf8"));
|
|
185
|
+
const packages = Object.entries(lockfile.packages ?? {}).filter(([path]) => path);
|
|
186
|
+
if (!packages.length || packages.length > 200) fail("update_closure", "Unexpected dependency closure");
|
|
187
|
+
for (const [path, item] of packages) {
|
|
188
|
+
if (!path.startsWith("node_modules/") || path.includes("..") || item.link || !/^https:\/\/registry\.npmjs\.org\//.test(item.resolved ?? "")
|
|
189
|
+
|| !/^sha512-[A-Za-z0-9+/]{86}==$/.test(item.integrity ?? "")) fail("update_closure", "Dependency closure contains an untrusted source or missing integrity");
|
|
190
|
+
}
|
|
191
|
+
if (lockfile.packages["node_modules/@vecteur/cli"]?.integrity !== cli.integrity) fail("update_integrity", "Resolved package differs from the origin pin");
|
|
192
|
+
progress("Downloading and verifying package signatures and provenance");
|
|
193
|
+
await npm(["ci", "--ignore-scripts", "--no-audit", "--no-fund", "--registry", REGISTRY]);
|
|
194
|
+
const audit = JSON.parse(await npm(["audit", "signatures", "--json", "--include-attestations", "--registry", REGISTRY]));
|
|
195
|
+
if (audit.invalid?.length || audit.missing?.length || !audit.verified?.some(row => row.name === PACKAGE && row.version === cli.version && row.registry?.replace(/\/$/, "") === REGISTRY && row.attestations)) fail("update_provenance", "npm did not verify this CLI's release provenance");
|
|
196
|
+
inspectGeneration(join(stage, "node_modules"));
|
|
197
|
+
const root = join(stage, "node_modules/@vecteur/cli"), pkg = manifest(root);
|
|
198
|
+
if (pkg.name !== PACKAGE || pkg.version !== cli.version || pkg.bin?.vecteur !== "bin/vecteur.js"
|
|
199
|
+
|| pkg.vecteur?.updateProtocol !== 1 || pkg.vecteur?.channel !== cli.channel) fail("update_package", "Staged package has an unexpected entrypoint or release channel");
|
|
200
|
+
const source = readFileSync(join(root, "src/contract.js"), "utf8");
|
|
201
|
+
if (!source.includes(`export const OWNER_CONTRACT_IDENTITY = ${JSON.stringify(cli.contract)};`)) fail("update_contract", "Staged package does not match the origin contract");
|
|
202
|
+
const entry = join(root, "bin/vecteur.js");
|
|
203
|
+
// This is the first execution of newly acquired code, after npm's verification.
|
|
204
|
+
const probe = await run(process.execPath, [entry, "--version"], { cwd: stage, env, signal, timeout: 10_000 });
|
|
205
|
+
if (probe.stdout.trim() !== `vecteur ${cli.version}`) fail("update_version", "Staged CLI did not pass its version/Node check");
|
|
206
|
+
const ready = await new VecteurClient({ origin: plan.origin, timeout: 5_000 }).readiness(signal);
|
|
207
|
+
if (ready.contract !== cli.contract || ready.cli?.version !== cli.version || ready.cli?.integrity !== cli.integrity) fail("update_release_changed", "The origin changed its release during download; check again");
|
|
208
|
+
const receipt = { origin: plan.origin, launcher: info.launcher, previous: info.realEntry, target: entry, version: cli.version, contract: cli.contract };
|
|
209
|
+
// Recovery is self-contained and independent of the replacement or any npm dependency.
|
|
210
|
+
const recovery = join(directory, "recover.mjs");
|
|
211
|
+
receipt.recovery = recovery;
|
|
212
|
+
writeStore(join(directory, "activation.json"), receipt);
|
|
213
|
+
writeFileSync(recovery, `import {readFileSync,symlinkSync,renameSync,realpathSync,openSync,closeSync,writeFileSync,fsyncSync,unlinkSync} from 'node:fs';
|
|
214
|
+
import {dirname,join} from 'node:path';
|
|
215
|
+
import {randomUUID} from 'node:crypto';
|
|
216
|
+
${syncDirectory.toString()}
|
|
217
|
+
${activateLauncher.toString()}
|
|
218
|
+
${rollbackLauncher.toString()}
|
|
219
|
+
const r=JSON.parse(readFileSync(new URL('./activation.json',import.meta.url)));rollbackLauncher(r);console.log('Restored '+r.previous);
|
|
220
|
+
`, { mode: 0o600 });
|
|
221
|
+
if (signal?.aborted) fail("update_cancelled", "Update cancelled before activation");
|
|
222
|
+
if (!unchanged(info)) fail("update_changed", "Launcher changed during staging; original bytes retained");
|
|
223
|
+
progress("Activating the verified CLI; previous package bytes remain available");
|
|
224
|
+
activateLauncher(info.launcher, entry);
|
|
225
|
+
return { ...receipt, recovery, directory };
|
|
226
|
+
} catch (error) {
|
|
227
|
+
// No node_modules tree is ever overwritten; failures before pointer activation are inert.
|
|
228
|
+
if (error instanceof CliError) throw error;
|
|
229
|
+
throw new CliError("update_failed", `Update did not complete; original package bytes retained. ${error.code ?? error.name}`);
|
|
230
|
+
} finally { closeSync(fd); unlinkSync(lock); }
|
|
231
|
+
}
|
|
232
|
+
export async function interactiveUpdate(session, { check = false, automatic = false } = {}) {
|
|
233
|
+
if (session.offline) { session.message("Offline: no update request was made. /resume reconnects."); return; }
|
|
234
|
+
if (session.busy || session.prompt || session.loginChallenge || session.run?.open || session.registry?.state().pendingRun || session.registry?.state().pendingCreate) {
|
|
235
|
+
if (!automatic) session.message("Update deferred until the Run, login or unresolved submission is finished; your draft stays here.");
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const plan = await checkUpdate({ origin: session.origin, env: session.env, release: automatic ? session.release : undefined, force: !automatic });
|
|
239
|
+
if (session.closed || session.offline) return;
|
|
240
|
+
if (automatic && plan.mode === "off") return;
|
|
241
|
+
if (check || !plan.needed || plan.reason) {
|
|
242
|
+
if (!automatic || plan.needed) session.message(`${installationText(plan.info)}\n${plan.release.cli || plan.release.requiredVersion ? `Origin requires ${plan.release.cli?.version ?? plan.release.requiredVersion}.` : "Origin does not publish update metadata."}\n${plan.reason || (plan.needed ? "Update available." : "This installation matches the origin.")}\n${plan.reason ? manualRecovery(plan) : ""}`);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
let action = automatic && plan.mode === "auto" && plan.upgrade && plan.release.compatible && plan.info.channel === plan.release.cli.channel ? "once" : await session.choose(
|
|
246
|
+
`${plan.upgrade ? "Update" : "Switch"} this installation ${plan.info.version} → ${plan.release.cli.version}? Draft saved; pending files must be reselected.`, [
|
|
247
|
+
{ label: "Update and continue", value: "once" }, { label: "Keep this installation updated automatically", value: "auto" },
|
|
248
|
+
{ label: "Keep drafting here", value: "later" },
|
|
249
|
+
]);
|
|
250
|
+
if (action === "later") return;
|
|
251
|
+
if (action === "auto") {
|
|
252
|
+
const prefs = preferences(session.env);
|
|
253
|
+
savePreferences({ updates: { ...prefs.updates, [prefKey(plan.info, session.origin)]: "auto" } }, session.env);
|
|
254
|
+
}
|
|
255
|
+
await session.transition(async () => {
|
|
256
|
+
if (session.run?.open || session.offline || session.registry?.state().pendingRun || session.registry?.state().pendingCreate) fail("update_deferred", "Session changed; update deferred");
|
|
257
|
+
session.registry?.saveDraft(session.draft);
|
|
258
|
+
const receipt = await stageUpdate(plan, { signal: session.lifecycle.signal, progress: text => { session.status = text; session.change(); } });
|
|
259
|
+
session.restart = { receipt, args: ["--origin", session.origin, ...(session.noBrowser ? ["--no-browser"] : []), ...(session.terminalFlags ?? [])], env: session.env, cwd: session.cwd };
|
|
260
|
+
session.message(`Verified CLI ${receipt.version} activated. Restarting; no ask is sent. Recovery: ${quote(process.execPath)} ${quote(receipt.recovery)}`);
|
|
261
|
+
session.close();
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
export function rollbackLauncher(receipt) {
|
|
265
|
+
const lock = join(dirname(receipt.recovery), "install.lock");
|
|
266
|
+
let fd;
|
|
267
|
+
try { fd = openSync(lock, "wx", 0o600); }
|
|
268
|
+
catch { throw new Error(`Recovery deferred: another updater owns ${lock}`); }
|
|
269
|
+
try {
|
|
270
|
+
writeFileSync(fd, JSON.stringify({ pid: process.pid, launcher: receipt.launcher })); fsyncSync(fd);
|
|
271
|
+
if (realpathSync(receipt.launcher) !== receipt.target) throw new Error("Recovery refused: another process changed the launcher");
|
|
272
|
+
activateLauncher(receipt.launcher, receipt.previous);
|
|
273
|
+
} finally { closeSync(fd); unlinkSync(lock); }
|
|
274
|
+
}
|
|
275
|
+
export async function restartUpdate({ receipt, args, env, cwd }) {
|
|
276
|
+
const nonce = randomUUID();
|
|
277
|
+
const child = spawn(process.execPath, [receipt.launcher, ...args], { cwd,
|
|
278
|
+
env: { ...env, VECTEUR_UPDATE_SESSION: nonce }, stdio: ["inherit", "inherit", "inherit", "ipc"] });
|
|
279
|
+
return new Promise(resolveExit => {
|
|
280
|
+
let ready = false, finished = false, killTimer;
|
|
281
|
+
const timer = setTimeout(() => {
|
|
282
|
+
if (ready) return;
|
|
283
|
+
child.kill("SIGTERM");
|
|
284
|
+
killTimer = setTimeout(() => { child.kill("SIGKILL"); finish(1); }, 2_000);
|
|
285
|
+
}, 20_000);
|
|
286
|
+
child.on("message", value => {
|
|
287
|
+
if (!finished && !killTimer && value?.type === "vecteur-ready" && value.nonce === nonce && value.version === receipt.version
|
|
288
|
+
&& value.contract === receipt.contract && value.origin === receipt.origin) {
|
|
289
|
+
ready = true; clearTimeout(timer); child.disconnect();
|
|
290
|
+
process.stderr.write(`Updated to Vecteur ${receipt.version}; origin compatibility confirmed.\n`);
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
const finish = code => {
|
|
294
|
+
if (finished) return; finished = true; clearTimeout(timer); clearTimeout(killTimer);
|
|
295
|
+
if (!ready) {
|
|
296
|
+
try { rollbackLauncher(receipt); process.stderr.write(`Restart was not verified; previous launcher restored. Run ${quote(receipt.launcher)} --offline to recover your draft.\n`); }
|
|
297
|
+
catch (error) { process.stderr.write(terminalText(`${error.message}; recovery: ${quote(process.execPath)} ${quote(receipt.recovery)}\n`)); }
|
|
298
|
+
}
|
|
299
|
+
resolveExit(ready ? code ?? 1 : 1);
|
|
300
|
+
};
|
|
301
|
+
child.once("error", () => finish(1)); child.once("exit", finish);
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
export async function updateCommand(options) {
|
|
305
|
+
const plan = await checkUpdate({ origin: options.origin, force: true });
|
|
306
|
+
process.stdout.write(terminalText(`${installationText(plan.info)}\n${plan.release.cli || plan.release.requiredVersion ? `Origin requires ${plan.release.cli?.version ?? plan.release.requiredVersion}.` : "No release metadata."}\n${plan.reason || "Verified update can be staged with --yes."}\n${plan.reason ? manualRecovery(plan) : ""}\n`));
|
|
307
|
+
if (options.check) return;
|
|
308
|
+
if (!options.yes) fail("update_confirmation", "Use vecteur update --check or explicitly pass --yes to change this launcher");
|
|
309
|
+
if (plan.needed) {
|
|
310
|
+
const receipt = await stageUpdate(plan);
|
|
311
|
+
process.stdout.write(terminalText(`Activated ${receipt.version}. Launch ${quote(receipt.launcher)}. Recovery: ${quote(process.execPath)} ${quote(receipt.recovery)}\n`));
|
|
312
|
+
}
|
|
313
|
+
}
|