@vincentt-xr/cli 0.21.0 → 0.23.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/MIGRATION.md +26 -0
- package/dist/cli/args.js +2 -0
- package/dist/cli/index.js +50 -8
- package/dist/cli/usage-text.js +10 -1
- package/dist/cli/verb-surface.d.ts +2 -1
- package/dist/cli/verb-surface.js +5 -0
- package/dist/dx/command.d.ts +74 -18
- package/dist/dx/command.js +137 -86
- package/dist/dx/consent.d.ts +25 -6
- package/dist/dx/consent.js +58 -25
- package/dist/dx/enablement.d.ts +63 -11
- package/dist/dx/enablement.js +93 -21
- package/dist/dx/journal.d.ts +3 -0
- package/dist/dx/journal.js +6 -1
- package/dist/dx/report.d.ts +4 -24
- package/dist/dx/report.js +32 -25
- package/dist/dx/skeleton.js +3 -0
- package/dist/editor/app.d.ts +61 -0
- package/dist/editor/app.js +180 -0
- package/dist/editor/copy.d.ts +22 -0
- package/dist/editor/copy.js +95 -0
- package/dist/editor/handoffs.d.ts +12 -0
- package/dist/editor/handoffs.js +93 -0
- package/dist/editor/launcher.d.ts +86 -0
- package/dist/editor/launcher.js +255 -0
- package/dist/editor/localFiles.d.ts +31 -0
- package/dist/editor/localFiles.js +100 -0
- package/dist/editor/reads.d.ts +47 -0
- package/dist/editor/reads.js +269 -0
- package/dist/editor/text.d.ts +7 -0
- package/dist/editor/text.js +58 -0
- package/dist/editor/tools.d.ts +5 -0
- package/dist/editor/tools.js +20 -0
- package/dist/lib/version.d.ts +28 -2
- package/dist/lib/version.js +30 -3
- package/dist/preview/command.js +2 -0
- package/dist/preview/copy.d.ts +5 -0
- package/dist/preview/copy.js +15 -0
- package/dist/preview/runner.d.ts +6 -0
- package/dist/preview/runner.js +1 -0
- package/dist/publish/command.js +5 -2
- package/dist/publish/copy.d.ts +6 -0
- package/dist/publish/copy.js +27 -6
- package/dist/publish/manifest.d.ts +11 -2
- package/dist/publish/manifest.js +45 -9
- package/dist/publish/upload.d.ts +10 -0
- package/dist/publish/upload.js +23 -0
- package/dist/shared/config.d.ts +6 -6
- package/dist/tunnel/client.d.ts +11 -3
- package/dist/tunnel/client.js +11 -8
- package/package.json +2 -2
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type MacSystem } from "./app.js";
|
|
2
|
+
import { type PidRecord } from "./localFiles.js";
|
|
3
|
+
export declare const SELECTION_SCHEMA = "vincentt.editor-selection/1";
|
|
4
|
+
export declare const STATE_SCHEMA = "vincentt.editor-state/1";
|
|
5
|
+
export interface ReadParams {
|
|
6
|
+
system: Pick<MacSystem, "verifyPid" | "startTime">;
|
|
7
|
+
/** The designated requirement a running copy must meet. */
|
|
8
|
+
requirement: string;
|
|
9
|
+
isAlive?: (pid: number) => boolean;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* True when the code `pid` is running meets the requirement, from any path. Checked on the
|
|
13
|
+
* running process itself, so no path the OS reports for it is trusted.
|
|
14
|
+
*/
|
|
15
|
+
export declare function isVerifiedEditor(pid: number, p: ReadParams): Promise<boolean>;
|
|
16
|
+
/**
|
|
17
|
+
* True when the process now holding `pid` was already running when the record was written. A
|
|
18
|
+
* process that started after `startedAt` reused the pid of the editor that wrote it.
|
|
19
|
+
*/
|
|
20
|
+
export declare function startedBeforeRecord(processStart: number | undefined, rec: PidRecord): boolean;
|
|
21
|
+
/** The editor recorded in `pid`, when that process is live, is that record's process, and is a verified editor. */
|
|
22
|
+
export declare function runningEditor(root: string, p: ReadParams): Promise<PidRecord | undefined>;
|
|
23
|
+
export type SchemaMajor = "known" | "foreign" | {
|
|
24
|
+
newer: string;
|
|
25
|
+
};
|
|
26
|
+
/** Whether `schema` is `<family>/1`, a later major of the family, or something else. */
|
|
27
|
+
export declare function schemaMajor(schema: unknown, family: string): SchemaMajor;
|
|
28
|
+
/** The selection as the agent may read it: an allowlist of fields, every string sanitised. */
|
|
29
|
+
export declare function shapeSelection(sel: unknown): Record<string, unknown> | null;
|
|
30
|
+
export interface ReadResult {
|
|
31
|
+
stdout: string[];
|
|
32
|
+
stderr: string[];
|
|
33
|
+
exitCode: number;
|
|
34
|
+
/** For the f7 journal: why the read exited 1. */
|
|
35
|
+
reason?: "not_running" | "too_new";
|
|
36
|
+
}
|
|
37
|
+
export declare function selectionRead(root: string, json: boolean, p: ReadParams): Promise<ReadResult>;
|
|
38
|
+
export declare function humanSelection(sel: Record<string, unknown> | null): string[];
|
|
39
|
+
/**
|
|
40
|
+
* `app` as the agent may read it: exactly `{version, lastOpen}`, each null unless it is one of
|
|
41
|
+
* the shapes the app writes. `trustLastOpen` false nulls `lastOpen` whatever the file says.
|
|
42
|
+
*/
|
|
43
|
+
export declare function shapeApp(app: unknown, trustLastOpen: boolean): {
|
|
44
|
+
version: string | null;
|
|
45
|
+
lastOpen: string | null;
|
|
46
|
+
};
|
|
47
|
+
export declare function statusRead(root: string, p: ReadParams): Promise<ReadResult>;
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// `vincentt editor --selection [--json]` and `--status --json`: read the editor's local files
|
|
2
|
+
// and print them. A read flag never talks to the running app; when none runs for the folder it
|
|
3
|
+
// says so on stderr, still prints one JSON object on stdout, and exits 1. A file in a schema
|
|
4
|
+
// major this CLI does not know is refused whole ("too new"), never printed in part.
|
|
5
|
+
import { pidAlive } from "./app.js";
|
|
6
|
+
import { NOT_RUNNING, tooNew } from "./copy.js";
|
|
7
|
+
import { realpath } from "node:fs/promises";
|
|
8
|
+
import { lastHandoff } from "./handoffs.js";
|
|
9
|
+
import { parsePidFile, readLocalFile, readLocalFileStat, } from "./localFiles.js";
|
|
10
|
+
import { agentSafe, agentSafeValue } from "./text.js";
|
|
11
|
+
export const SELECTION_SCHEMA = "vincentt.editor-selection/1";
|
|
12
|
+
export const STATE_SCHEMA = "vincentt.editor-state/1";
|
|
13
|
+
/**
|
|
14
|
+
* True when the code `pid` is running meets the requirement, from any path. Checked on the
|
|
15
|
+
* running process itself, so no path the OS reports for it is trusted.
|
|
16
|
+
*/
|
|
17
|
+
export async function isVerifiedEditor(pid, p) {
|
|
18
|
+
const verdict = await p.system.verifyPid(pid, p.requirement).catch(() => undefined);
|
|
19
|
+
return verdict?.ok === true;
|
|
20
|
+
}
|
|
21
|
+
/** `ps` reports whole seconds and the app writes whole seconds. */
|
|
22
|
+
const START_SLACK_MS = 1_000;
|
|
23
|
+
/**
|
|
24
|
+
* True when the process now holding `pid` was already running when the record was written. A
|
|
25
|
+
* process that started after `startedAt` reused the pid of the editor that wrote it.
|
|
26
|
+
*/
|
|
27
|
+
export function startedBeforeRecord(processStart, rec) {
|
|
28
|
+
return processStart !== undefined && processStart <= rec.startedAt + START_SLACK_MS;
|
|
29
|
+
}
|
|
30
|
+
/** The editor recorded in `pid`, when that process is live, is that record's process, and is a verified editor. */
|
|
31
|
+
export async function runningEditor(root, p) {
|
|
32
|
+
const text = await readLocalFile(root, "pid").catch(() => undefined);
|
|
33
|
+
const rec = text === undefined ? undefined : parsePidFile(text);
|
|
34
|
+
if (rec === undefined || !(p.isAlive ?? pidAlive)(rec.pid))
|
|
35
|
+
return undefined;
|
|
36
|
+
const start = await p.system.startTime(rec.pid).catch(() => undefined);
|
|
37
|
+
if (!startedBeforeRecord(start, rec))
|
|
38
|
+
return undefined;
|
|
39
|
+
return (await isVerifiedEditor(rec.pid, p)) ? rec : undefined;
|
|
40
|
+
}
|
|
41
|
+
/** Whether `schema` is `<family>/1`, a later major of the family, or something else. */
|
|
42
|
+
export function schemaMajor(schema, family) {
|
|
43
|
+
if (typeof schema !== "string")
|
|
44
|
+
return "foreign";
|
|
45
|
+
const m = /^(.+)\/(\d+)$/.exec(schema);
|
|
46
|
+
if (m === null || m[1] !== family)
|
|
47
|
+
return "foreign";
|
|
48
|
+
const major = Number(m[2]);
|
|
49
|
+
if (major === 1)
|
|
50
|
+
return "known";
|
|
51
|
+
return major > 1 ? { newer: schema } : "foreign";
|
|
52
|
+
}
|
|
53
|
+
const SELECTION_FAMILY = "vincentt.editor-selection";
|
|
54
|
+
const STATE_FAMILY = "vincentt.editor-state";
|
|
55
|
+
async function readJson(root, name) {
|
|
56
|
+
return (await readJsonStat(root, name))?.value;
|
|
57
|
+
}
|
|
58
|
+
async function readJsonStat(root, name) {
|
|
59
|
+
const file = await readLocalFileStat(root, name).catch(() => undefined);
|
|
60
|
+
if (file === undefined)
|
|
61
|
+
return undefined;
|
|
62
|
+
try {
|
|
63
|
+
const v = JSON.parse(file.text);
|
|
64
|
+
return typeof v === "object" && v !== null && !Array.isArray(v)
|
|
65
|
+
? { value: v, mtimeMs: file.mtimeMs }
|
|
66
|
+
: undefined;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* A snapshot belongs to the running editor when it carries the pid record's session and, when it
|
|
74
|
+
* names a pid, that pid.
|
|
75
|
+
*/
|
|
76
|
+
function sameEditor(file, rec) {
|
|
77
|
+
if (typeof file.session !== "string" || file.session !== rec.session)
|
|
78
|
+
return false;
|
|
79
|
+
return typeof file.pid !== "number" || file.pid === rec.pid;
|
|
80
|
+
}
|
|
81
|
+
/** Fields the CLI states itself; file content never supplies them. */
|
|
82
|
+
const RESERVED_STATE_KEYS = ["schema", "running", "pid", "session"];
|
|
83
|
+
const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
|
84
|
+
/** A string field, sanitised; anything that is not a string reads as empty. */
|
|
85
|
+
const str = (v, max) => typeof v === "string" ? agentSafe(v, max) : "";
|
|
86
|
+
/** The selection as the agent may read it: an allowlist of fields, every string sanitised. */
|
|
87
|
+
export function shapeSelection(sel) {
|
|
88
|
+
if (typeof sel !== "object" || sel === null)
|
|
89
|
+
return null;
|
|
90
|
+
const s = sel;
|
|
91
|
+
const src = (typeof s.source === "object" && s.source !== null ? s.source : {});
|
|
92
|
+
const values = Array.isArray(s.values) ? s.values : [];
|
|
93
|
+
return {
|
|
94
|
+
objectKey: str(s.objectKey, 400),
|
|
95
|
+
label: str(s.label, 200),
|
|
96
|
+
type: str(s.type, 80),
|
|
97
|
+
kind: str(s.kind, 20),
|
|
98
|
+
source: {
|
|
99
|
+
file: str(src.file, 400),
|
|
100
|
+
line: num(src.line) ?? null,
|
|
101
|
+
column: num(src.column) ?? null,
|
|
102
|
+
},
|
|
103
|
+
values: values.slice(0, 256).map((raw) => {
|
|
104
|
+
const v = (typeof raw === "object" && raw !== null ? raw : {});
|
|
105
|
+
const out = {
|
|
106
|
+
path: str(v.path, 200),
|
|
107
|
+
type: str(v.type, 20),
|
|
108
|
+
value: agentSafeValue(v.value),
|
|
109
|
+
};
|
|
110
|
+
if (typeof v.unit === "string")
|
|
111
|
+
out.unit = agentSafe(v.unit, 10);
|
|
112
|
+
out.editable =
|
|
113
|
+
v.editable === true
|
|
114
|
+
? true
|
|
115
|
+
: agentSafeValue(v.editable ?? { readonly: "unsupported" }, 40);
|
|
116
|
+
if (typeof v.decl === "string")
|
|
117
|
+
out.decl = agentSafe(v.decl, 200);
|
|
118
|
+
if (num(v.sharedBy) !== undefined)
|
|
119
|
+
out.sharedBy = num(v.sharedBy);
|
|
120
|
+
return out;
|
|
121
|
+
}),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
function tooNewResult(schema) {
|
|
125
|
+
return { stdout: [], stderr: tooNew(schema), exitCode: 1, reason: "too_new" };
|
|
126
|
+
}
|
|
127
|
+
export async function selectionRead(root, json, p) {
|
|
128
|
+
const file = await readJson(root, "selection.json");
|
|
129
|
+
const major = schemaMajor(file?.schema, SELECTION_FAMILY);
|
|
130
|
+
if (typeof major === "object")
|
|
131
|
+
return tooNewResult(major.newer);
|
|
132
|
+
const rec = await runningEditor(root, p);
|
|
133
|
+
if (rec === undefined) {
|
|
134
|
+
return {
|
|
135
|
+
stdout: json
|
|
136
|
+
? [JSON.stringify({ schema: SELECTION_SCHEMA, running: false, selection: null })]
|
|
137
|
+
: [],
|
|
138
|
+
stderr: [NOT_RUNNING],
|
|
139
|
+
exitCode: 1,
|
|
140
|
+
reason: "not_running",
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
const selection = file && major === "known" && sameEditor(file, rec)
|
|
144
|
+
? shapeSelection(file.selection)
|
|
145
|
+
: null;
|
|
146
|
+
if (json) {
|
|
147
|
+
return {
|
|
148
|
+
stdout: [JSON.stringify({ schema: SELECTION_SCHEMA, running: true, selection })],
|
|
149
|
+
stderr: [],
|
|
150
|
+
exitCode: 0,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
return { stdout: humanSelection(selection), stderr: [], exitCode: 0 };
|
|
154
|
+
}
|
|
155
|
+
function display(v) {
|
|
156
|
+
if (Array.isArray(v))
|
|
157
|
+
return v.map(display).join(", ");
|
|
158
|
+
if (v !== null && typeof v === "object") {
|
|
159
|
+
return Object.entries(v)
|
|
160
|
+
.map(([k, x]) => `${k}: ${display(x)}`)
|
|
161
|
+
.join(", ");
|
|
162
|
+
}
|
|
163
|
+
return String(v);
|
|
164
|
+
}
|
|
165
|
+
export function humanSelection(sel) {
|
|
166
|
+
if (sel === null)
|
|
167
|
+
return ["Nothing is selected in the editor."];
|
|
168
|
+
const src = sel.source;
|
|
169
|
+
const kind = sel.kind === "sdk" ? " (Vincentt SDK)" : "";
|
|
170
|
+
const out = [
|
|
171
|
+
`${sel.label} ${sel.type}${kind} ${src.file}${src.line !== null ? `:${src.line}` : ""}`,
|
|
172
|
+
];
|
|
173
|
+
for (const v of sel.values) {
|
|
174
|
+
const unit = typeof v.unit === "string" ? ` ${v.unit}` : "";
|
|
175
|
+
const ro = v.editable === true
|
|
176
|
+
? ""
|
|
177
|
+
: ` (read-only: ${display(v.editable?.readonly ?? "unsupported")})`;
|
|
178
|
+
out.push(` ${String(v.path).padEnd(26)} ${display(v.value)}${unit}${ro}`);
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
const APP_VERSION = /^\d+\.\d+\.\d+([-+][0-9A-Za-z.-]{1,32})?$/;
|
|
183
|
+
const LAST_OPEN = /^(opened|awaiting_trust|cancelled|refused:[A-Za-z0-9_-]{1,16})$/;
|
|
184
|
+
/**
|
|
185
|
+
* `app` as the agent may read it: exactly `{version, lastOpen}`, each null unless it is one of
|
|
186
|
+
* the shapes the app writes. `trustLastOpen` false nulls `lastOpen` whatever the file says.
|
|
187
|
+
*/
|
|
188
|
+
export function shapeApp(app, trustLastOpen) {
|
|
189
|
+
const a = (typeof app === "object" && app !== null ? app : {});
|
|
190
|
+
const version = typeof a.version === "string" && APP_VERSION.test(a.version) ? a.version : null;
|
|
191
|
+
const lastOpen = trustLastOpen && typeof a.lastOpen === "string" && LAST_OPEN.test(a.lastOpen)
|
|
192
|
+
? a.lastOpen
|
|
193
|
+
: null;
|
|
194
|
+
return { version, lastOpen };
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* A state file no running editor vouches for is believed about the last open only when it
|
|
198
|
+
* changed after this machine's last hand-off of this folder: a committed or copied state.json
|
|
199
|
+
* predates it, and a folder never handed over has no open to report. A time in the future (an
|
|
200
|
+
* unpacked archive keeps whatever mtime it was given) is not after anything.
|
|
201
|
+
*/
|
|
202
|
+
async function lastOpenTrusted(root, mtimeMs) {
|
|
203
|
+
const project = await realpath(root).catch(() => undefined);
|
|
204
|
+
if (project === undefined)
|
|
205
|
+
return false;
|
|
206
|
+
const at = await lastHandoff(project).catch(() => undefined);
|
|
207
|
+
return at !== undefined && mtimeMs > at && mtimeMs <= Date.now() + 5_000;
|
|
208
|
+
}
|
|
209
|
+
function appField(file, trustLastOpen) {
|
|
210
|
+
return file !== undefined && file.app !== undefined
|
|
211
|
+
? { app: shapeApp(file.app, trustLastOpen) }
|
|
212
|
+
: {};
|
|
213
|
+
}
|
|
214
|
+
export async function statusRead(root, p) {
|
|
215
|
+
const read = await readJsonStat(root, "state.json");
|
|
216
|
+
const file = read?.value;
|
|
217
|
+
const major = schemaMajor(file?.schema, STATE_FAMILY);
|
|
218
|
+
if (typeof major === "object")
|
|
219
|
+
return tooNewResult(major.newer);
|
|
220
|
+
const known = major === "known" ? file : undefined;
|
|
221
|
+
const rec = await runningEditor(root, p);
|
|
222
|
+
if (known && rec && known.running === true && sameEditor(known, rec)) {
|
|
223
|
+
// The running, verified editor wrote this file. Sanitised first, so a key that only
|
|
224
|
+
// becomes `pid` once its control characters are stripped is removed too.
|
|
225
|
+
const shaped = agentSafeValue(known, 400);
|
|
226
|
+
for (const k of RESERVED_STATE_KEYS)
|
|
227
|
+
delete shaped[k];
|
|
228
|
+
if (known.app !== undefined)
|
|
229
|
+
shaped.app = shapeApp(known.app, true);
|
|
230
|
+
return {
|
|
231
|
+
stdout: [
|
|
232
|
+
JSON.stringify({ schema: STATE_SCHEMA, running: true, pid: rec.pid, ...shaped }),
|
|
233
|
+
],
|
|
234
|
+
stderr: [],
|
|
235
|
+
exitCode: 0,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
const trusted = known !== undefined &&
|
|
239
|
+
read !== undefined &&
|
|
240
|
+
(await lastOpenTrusted(root, read.mtimeMs));
|
|
241
|
+
if (rec === undefined) {
|
|
242
|
+
// A refused or cancelled open leaves no running editor behind, so the outcome of the last
|
|
243
|
+
// hand-off is reported here too, when the file is believed.
|
|
244
|
+
return {
|
|
245
|
+
stdout: [
|
|
246
|
+
JSON.stringify({
|
|
247
|
+
schema: STATE_SCHEMA,
|
|
248
|
+
running: false,
|
|
249
|
+
...appField(known, trusted),
|
|
250
|
+
}),
|
|
251
|
+
],
|
|
252
|
+
stderr: [NOT_RUNNING],
|
|
253
|
+
exitCode: 1,
|
|
254
|
+
reason: "not_running",
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
return {
|
|
258
|
+
stdout: [
|
|
259
|
+
JSON.stringify({
|
|
260
|
+
schema: STATE_SCHEMA,
|
|
261
|
+
running: true,
|
|
262
|
+
pid: rec.pid,
|
|
263
|
+
...appField(known, trusted),
|
|
264
|
+
}),
|
|
265
|
+
],
|
|
266
|
+
stderr: [],
|
|
267
|
+
exitCode: 0,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare function isUnsafeChar(ch: string): boolean;
|
|
2
|
+
/** Unsafe characters removed and the result capped at `max` code points. */
|
|
3
|
+
export declare function agentSafe(s: string, max: number): string;
|
|
4
|
+
/** Nesting kept in a value an agent reads; anything deeper is dropped (null). */
|
|
5
|
+
export declare const MAX_DEPTH = 8;
|
|
6
|
+
/** A JSON value with every string (keys included) made agent-safe, depth- and size-capped. */
|
|
7
|
+
export declare function agentSafeValue(v: unknown, max?: number, depth?: number): unknown;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Text that reaches the creator's terminal or their agent from the editor's files. The app
|
|
2
|
+
// already strips these characters (B-F21-8); the CLI strips them again because it is the last
|
|
3
|
+
// step before a shell, and a stale or planted file never passed the app.
|
|
4
|
+
// C0 and C1 controls, DEL, the Arabic letter mark, zero-width characters and direction marks,
|
|
5
|
+
// line and paragraph separators, bidi embeddings, overrides and isolates, the word joiner,
|
|
6
|
+
// invisible operators, the byte order mark, the soft hyphen, the combining grapheme joiner,
|
|
7
|
+
// the Mongolian vowel separator, variation selectors and tag characters. Code points rather
|
|
8
|
+
// than a character class, so no bidi character sits in source.
|
|
9
|
+
const UNSAFE = [
|
|
10
|
+
[0x0000, 0x001f],
|
|
11
|
+
[0x007f, 0x009f],
|
|
12
|
+
[0x00ad, 0x00ad],
|
|
13
|
+
[0x034f, 0x034f],
|
|
14
|
+
[0x061c, 0x061c],
|
|
15
|
+
[0x180e, 0x180e],
|
|
16
|
+
[0x200b, 0x200f],
|
|
17
|
+
[0x2028, 0x2029],
|
|
18
|
+
[0x2060, 0x2064],
|
|
19
|
+
[0xfe00, 0xfe0f],
|
|
20
|
+
[0xfeff, 0xfeff],
|
|
21
|
+
[0x202a, 0x202e],
|
|
22
|
+
[0x2066, 0x2069],
|
|
23
|
+
[0xe0000, 0xe007f],
|
|
24
|
+
[0xe0100, 0xe01ef],
|
|
25
|
+
];
|
|
26
|
+
export function isUnsafeChar(ch) {
|
|
27
|
+
const cp = ch.codePointAt(0) ?? 0;
|
|
28
|
+
return UNSAFE.some(([lo, hi]) => cp >= lo && cp <= hi);
|
|
29
|
+
}
|
|
30
|
+
// CSI and OSC sequences, removed whole so no `[31m` residue is left behind.
|
|
31
|
+
// eslint-disable-next-line no-control-regex
|
|
32
|
+
const ANSI_G = /\u001b\[[0-?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)?|\u009b[0-?]*[ -/]*[@-~]/g;
|
|
33
|
+
/** Unsafe characters removed and the result capped at `max` code points. */
|
|
34
|
+
export function agentSafe(s, max) {
|
|
35
|
+
return Array.from(s.replace(ANSI_G, ""))
|
|
36
|
+
.filter((ch) => !isUnsafeChar(ch))
|
|
37
|
+
.slice(0, max)
|
|
38
|
+
.join("");
|
|
39
|
+
}
|
|
40
|
+
/** Nesting kept in a value an agent reads; anything deeper is dropped (null). */
|
|
41
|
+
export const MAX_DEPTH = 8;
|
|
42
|
+
/** A JSON value with every string (keys included) made agent-safe, depth- and size-capped. */
|
|
43
|
+
export function agentSafeValue(v, max = 200, depth = 0) {
|
|
44
|
+
if (typeof v === "string")
|
|
45
|
+
return agentSafe(v, max);
|
|
46
|
+
if (depth >= MAX_DEPTH && v !== null && typeof v === "object")
|
|
47
|
+
return null;
|
|
48
|
+
if (Array.isArray(v))
|
|
49
|
+
return v.slice(0, 64).map((x) => agentSafeValue(x, max, depth + 1));
|
|
50
|
+
if (v !== null && typeof v === "object") {
|
|
51
|
+
const out = {};
|
|
52
|
+
for (const [k, x] of Object.entries(v).slice(0, 256)) {
|
|
53
|
+
out[agentSafe(k, 80)] = agentSafeValue(x, max, depth + 1);
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
return v;
|
|
58
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export type Tool = "codesign" | "open" | "sw_vers" | "plutil" | "lsappinfo" | "ps";
|
|
2
|
+
/** Where each tool lives, most likely first. Never a relative path; none off macOS. */
|
|
3
|
+
export declare function toolCandidates(tool: Tool, platform?: NodeJS.Platform): string[];
|
|
4
|
+
/** The tool's absolute path, or undefined when it is not where the OS keeps it. */
|
|
5
|
+
export declare function toolPath(tool: Tool, platform?: NodeJS.Platform, exists?: (p: string) => boolean): string | undefined;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// The macOS tools the launcher runs, by absolute path only. A bare name would be resolved from
|
|
2
|
+
// PATH, so a project (or anything earlier on PATH) could plant a `codesign` that answers "this
|
|
3
|
+
// app is signed by Vincentt".
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
const DARWIN = {
|
|
6
|
+
ps: ["/bin/ps"],
|
|
7
|
+
codesign: ["/usr/bin/codesign"],
|
|
8
|
+
open: ["/usr/bin/open"],
|
|
9
|
+
sw_vers: ["/usr/bin/sw_vers"],
|
|
10
|
+
plutil: ["/usr/bin/plutil"],
|
|
11
|
+
lsappinfo: ["/usr/bin/lsappinfo"],
|
|
12
|
+
};
|
|
13
|
+
/** Where each tool lives, most likely first. Never a relative path; none off macOS. */
|
|
14
|
+
export function toolCandidates(tool, platform = process.platform) {
|
|
15
|
+
return platform === "darwin" ? DARWIN[tool] : [];
|
|
16
|
+
}
|
|
17
|
+
/** The tool's absolute path, or undefined when it is not where the OS keeps it. */
|
|
18
|
+
export function toolPath(tool, platform = process.platform, exists = existsSync) {
|
|
19
|
+
return toolCandidates(tool, platform).find((p) => exists(p));
|
|
20
|
+
}
|
package/dist/lib/version.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Enablement } from "../dx/enablement.js";
|
|
1
|
+
import type { AccountRead, Enablement, EnablementSource, EnablementVia } from "../dx/enablement.js";
|
|
2
2
|
export interface VersionReport {
|
|
3
3
|
cliVersion: string;
|
|
4
4
|
nodeVersion: string;
|
|
@@ -9,10 +9,13 @@ export interface VersionReport {
|
|
|
9
9
|
ignoredBindingApiUrl?: string;
|
|
10
10
|
/** True when the resolved host is the built-in fallback (nothing configured). */
|
|
11
11
|
isDefault: boolean;
|
|
12
|
-
/** True when
|
|
12
|
+
/** True when DX reports may be sent unattended, from the account-first answer. */
|
|
13
13
|
dxSendEnabled: boolean;
|
|
14
14
|
/** ISO8601 UTC of the grant. Present only when enabled. */
|
|
15
15
|
dxSendEnabledAt?: string;
|
|
16
|
+
/** Where that answer came from, and where the account grant was made. */
|
|
17
|
+
dxSource?: EnablementSource;
|
|
18
|
+
dxVia?: EnablementVia;
|
|
16
19
|
/** The instant the age is computed against, so renderVersion stays pure. */
|
|
17
20
|
dxAgeAsOf: number;
|
|
18
21
|
}
|
|
@@ -50,4 +53,27 @@ export declare function renderVersion(r: VersionReport): string[];
|
|
|
50
53
|
export declare function versionReport(cwd: string, opts: {
|
|
51
54
|
cliVersion: string;
|
|
52
55
|
allowCustomApi?: boolean;
|
|
56
|
+
/** The account read; absent reports this machine's flag only. */
|
|
57
|
+
consent?: VersionConsentDeps;
|
|
53
58
|
}): Promise<VersionReport>;
|
|
59
|
+
/** The account bound on `version` (f7 Scenario 3c). */
|
|
60
|
+
export declare const VERSION_ACCOUNT_TIMEOUT_MS = 3000;
|
|
61
|
+
export interface VersionConsentDeps {
|
|
62
|
+
auth: () => Promise<{
|
|
63
|
+
apiUrl: string;
|
|
64
|
+
pat: string;
|
|
65
|
+
} | undefined>;
|
|
66
|
+
readAccount: (auth: {
|
|
67
|
+
apiUrl: string;
|
|
68
|
+
pat: string;
|
|
69
|
+
}) => Promise<AccountRead>;
|
|
70
|
+
clearLocal: () => Promise<unknown>;
|
|
71
|
+
local?: () => Promise<Enablement>;
|
|
72
|
+
timeoutMs?: number;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The enablement `version` shows: the account's answer when it can be read within the
|
|
76
|
+
* bound, else this machine's flag. Never throws and never waits past the bound, because
|
|
77
|
+
* `version` is the command people run when something is already broken.
|
|
78
|
+
*/
|
|
79
|
+
export declare function versionEnablement(now: number, consent: VersionConsentDeps | undefined): Promise<Enablement>;
|
package/dist/lib/version.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// misconfiguration. So this reports the resolved host AND its provenance, using
|
|
8
8
|
// the same decideApiUrl the real verbs use; a version command that re-derived
|
|
9
9
|
// the URL could disagree with them.
|
|
10
|
-
import { enablementLine, loadEnablement } from "../dx/enablement.js";
|
|
10
|
+
import { enablementLine, loadEnablement, resolveConsentView } from "../dx/enablement.js";
|
|
11
11
|
import { decideApiUrl, loadMachineConfig, locateProjectBinding, } from "../shared/config.js";
|
|
12
12
|
/**
|
|
13
13
|
* Decide what to report. Pure, so the transcript is testable without a home
|
|
@@ -45,6 +45,8 @@ export function buildVersionReport(env) {
|
|
|
45
45
|
isDefault,
|
|
46
46
|
dxSendEnabled: enablement.enabled,
|
|
47
47
|
dxSendEnabledAt: enablement.enabled ? enablement.at : undefined,
|
|
48
|
+
dxSource: enablement.enabled ? enablement.source : undefined,
|
|
49
|
+
dxVia: enablement.enabled ? enablement.via : undefined,
|
|
48
50
|
dxAgeAsOf: env.now ?? Date.now(),
|
|
49
51
|
};
|
|
50
52
|
}
|
|
@@ -68,7 +70,7 @@ export function renderVersion(r) {
|
|
|
68
70
|
// exactly when a forgotten enablement is most likely to be discovered. Printed
|
|
69
71
|
// only when enabled — see the note in dx/enablement.ts on why there is no `off`
|
|
70
72
|
// counterpart.
|
|
71
|
-
const dx = enablementLine({ enabled: r.dxSendEnabled, at: r.dxSendEnabledAt }, r.dxAgeAsOf);
|
|
73
|
+
const dx = enablementLine({ enabled: r.dxSendEnabled, at: r.dxSendEnabledAt, source: r.dxSource, via: r.dxVia }, r.dxAgeAsOf);
|
|
72
74
|
if (dx !== undefined)
|
|
73
75
|
out.push("", ` ${dx}`);
|
|
74
76
|
out.push("");
|
|
@@ -106,7 +108,32 @@ export async function versionReport(cwd, opts) {
|
|
|
106
108
|
allowCustomApi: opts.allowCustomApi,
|
|
107
109
|
nodeVersion: process.version,
|
|
108
110
|
cliVersion: opts.cliVersion,
|
|
109
|
-
enablement: await
|
|
111
|
+
enablement: await versionEnablement(now, opts.consent),
|
|
110
112
|
now,
|
|
111
113
|
});
|
|
112
114
|
}
|
|
115
|
+
/** The account bound on `version` (f7 Scenario 3c). */
|
|
116
|
+
export const VERSION_ACCOUNT_TIMEOUT_MS = 3_000;
|
|
117
|
+
/**
|
|
118
|
+
* The enablement `version` shows: the account's answer when it can be read within the
|
|
119
|
+
* bound, else this machine's flag. Never throws and never waits past the bound, because
|
|
120
|
+
* `version` is the command people run when something is already broken.
|
|
121
|
+
*/
|
|
122
|
+
export async function versionEnablement(now, consent) {
|
|
123
|
+
const local = consent?.local ?? (() => loadEnablement(now));
|
|
124
|
+
if (consent === undefined)
|
|
125
|
+
return local();
|
|
126
|
+
try {
|
|
127
|
+
const view = await resolveConsentView({
|
|
128
|
+
auth: await consent.auth().catch(() => undefined),
|
|
129
|
+
readAccount: consent.readAccount,
|
|
130
|
+
local,
|
|
131
|
+
clearLocal: consent.clearLocal,
|
|
132
|
+
timeoutMs: consent.timeoutMs ?? VERSION_ACCOUNT_TIMEOUT_MS,
|
|
133
|
+
});
|
|
134
|
+
return view.shown;
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return local().catch(() => ({ enabled: false }));
|
|
138
|
+
}
|
|
139
|
+
}
|
package/dist/preview/command.js
CHANGED
|
@@ -338,6 +338,8 @@ export function endingLines(reason, orgName, refusal = {}) {
|
|
|
338
338
|
return previewTooOldLines(refusal);
|
|
339
339
|
case "auth_expired":
|
|
340
340
|
return copy.endedAuthExpired();
|
|
341
|
+
case "protocol_violation":
|
|
342
|
+
return copy.endedProtocolViolation();
|
|
341
343
|
case "egress_cap":
|
|
342
344
|
return copy.egressHard();
|
|
343
345
|
case "stopped":
|
package/dist/preview/copy.d.ts
CHANGED
|
@@ -219,6 +219,11 @@ export declare function endedAuthExpired(): string[];
|
|
|
219
219
|
* link, and a silent re-mint would break every link already shared.
|
|
220
220
|
*/
|
|
221
221
|
export declare function endedAuthzLapsed(): string[];
|
|
222
|
+
/**
|
|
223
|
+
* The edge broke the agreed tunnel wire. Only an edge bug causes it, so the
|
|
224
|
+
* remedy is a newer CLI or a later attempt, never anything in the project.
|
|
225
|
+
*/
|
|
226
|
+
export declare function endedProtocolViolation(): string[];
|
|
222
227
|
/**
|
|
223
228
|
* e. Abandoned — this machine stopped checking in (the 30-minute heartbeat-
|
|
224
229
|
* silence flip, `D-Abandoned-sessions-are-judged-on-heartbeat-silence-alone`).
|
package/dist/preview/copy.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
// product, so f1's "a token is never printed" extends to it verbatim.
|
|
19
19
|
/** Flow L13 — the start block. */
|
|
20
20
|
import { escapeControl, terminalSafe } from "../lib/terminalText.js";
|
|
21
|
+
import { INSTALL_COMMAND } from "../shared/upgradeError.js";
|
|
21
22
|
export function startBlock(i) {
|
|
22
23
|
const out = [
|
|
23
24
|
"",
|
|
@@ -477,6 +478,20 @@ export function endedAuthzLapsed() {
|
|
|
477
478
|
"",
|
|
478
479
|
];
|
|
479
480
|
}
|
|
481
|
+
/**
|
|
482
|
+
* The edge broke the agreed tunnel wire. Only an edge bug causes it, so the
|
|
483
|
+
* remedy is a newer CLI or a later attempt, never anything in the project.
|
|
484
|
+
*/
|
|
485
|
+
export function endedProtocolViolation() {
|
|
486
|
+
return [
|
|
487
|
+
"",
|
|
488
|
+
"✗ Preview stopped: the preview server sent an invalid reply. Update the CLI or try again later.",
|
|
489
|
+
"",
|
|
490
|
+
` Upgrade: ${INSTALL_COMMAND}`,
|
|
491
|
+
" Then try again: vincentt preview",
|
|
492
|
+
"",
|
|
493
|
+
];
|
|
494
|
+
}
|
|
480
495
|
/**
|
|
481
496
|
* e. Abandoned — this machine stopped checking in (the 30-minute heartbeat-
|
|
482
497
|
* silence flip, `D-Abandoned-sessions-are-judged-on-heartbeat-silence-alone`).
|
package/dist/preview/runner.d.ts
CHANGED
|
@@ -45,6 +45,12 @@ export type PreviewEndReason = "stopped"
|
|
|
45
45
|
* both. No further platform call is made, the DELETE included.
|
|
46
46
|
*/
|
|
47
47
|
| "upgrade_required"
|
|
48
|
+
/**
|
|
49
|
+
* The edge broke the agreed tunnel wire (a malformed 101 caps header, a
|
|
50
|
+
* CONTROL frame this connection cannot read, or a fatal OPEN). Final: a redial reaches the
|
|
51
|
+
* same edge, so the tunnel client has already stopped dialling.
|
|
52
|
+
*/
|
|
53
|
+
| "protocol_violation"
|
|
48
54
|
/**
|
|
49
55
|
* The 30-minute heartbeat-silence flip. NEVER reported as `hard_max`: a
|
|
50
56
|
* session ended at minute 30 has not reached its 12-hour ceiling, and saying
|
package/dist/preview/runner.js
CHANGED
|
@@ -256,6 +256,7 @@ export async function startPreview(opts) {
|
|
|
256
256
|
if (reason !== null)
|
|
257
257
|
ended(reason);
|
|
258
258
|
},
|
|
259
|
+
onProtocolViolation: () => ended("protocol_violation"),
|
|
259
260
|
onRefused: (_reason, minimum, declared) => ended("upgrade_required", {
|
|
260
261
|
minimum: parseMinimum(minimum),
|
|
261
262
|
declared: parseDeclared(declared),
|
package/dist/publish/command.js
CHANGED
|
@@ -15,7 +15,7 @@ import { buildManifest, ManifestError } from "./manifest.js";
|
|
|
15
15
|
import { checkBounds } from "./bounds.js";
|
|
16
16
|
import { classifyDist } from "./classify.js";
|
|
17
17
|
import { createProgress } from "./progress.js";
|
|
18
|
-
import { UploadExpired, UploadFailed, UploadTargetRejected, uploadAll, } from "./upload.js";
|
|
18
|
+
import { UploadExpired, UploadFailed, UploadTargetRejected, UploadChecksumMismatch, uploadAll, } from "./upload.js";
|
|
19
19
|
import { escapeTerminal } from "../lib/terminalText.js";
|
|
20
20
|
/** Re-opens after an expired upload link, per publish, before giving up. */
|
|
21
21
|
export const MAX_REOPENS = 2;
|
|
@@ -342,7 +342,8 @@ async function runUpload(deps, cfg, i) {
|
|
|
342
342
|
}
|
|
343
343
|
const failedPath = err instanceof UploadFailed ||
|
|
344
344
|
err instanceof UploadExpired ||
|
|
345
|
-
err instanceof UploadTargetRejected
|
|
345
|
+
err instanceof UploadTargetRejected ||
|
|
346
|
+
err instanceof UploadChecksumMismatch
|
|
346
347
|
? escapeTerminal(err.path)
|
|
347
348
|
: "a file";
|
|
348
349
|
deps.print("");
|
|
@@ -563,6 +564,8 @@ function outputRefusal(err, output, buildCommand) {
|
|
|
563
564
|
switch (err.reason) {
|
|
564
565
|
case "secret_path":
|
|
565
566
|
return copy.secretFiles(err.paths, output);
|
|
567
|
+
case "path_escaped":
|
|
568
|
+
return copy.escapedFiles(err.paths, output);
|
|
566
569
|
case "credential_file":
|
|
567
570
|
return copy.credentialFiles(err.paths, output);
|
|
568
571
|
case "file_unreadable":
|
package/dist/publish/copy.d.ts
CHANGED
|
@@ -176,6 +176,12 @@ export declare function outputOutside(shown: string): string[];
|
|
|
176
176
|
export declare function outputInHidden(shown: string, hidden: string): string[];
|
|
177
177
|
/** Flow U1 — hidden names or symlinks that would leak. After the header and reading line. */
|
|
178
178
|
export declare function secretFiles(paths: Offender[], output?: OutputLabel): string[];
|
|
179
|
+
/**
|
|
180
|
+
* Flow U1 — files whose folder resolved outside the output after the walk: a
|
|
181
|
+
* folder was swapped for a link while publish read it. The whole publish is
|
|
182
|
+
* refused, never just the file. After the header and reading line.
|
|
183
|
+
*/
|
|
184
|
+
export declare function escapedFiles(paths: Offender[], output?: OutputLabel): string[];
|
|
179
185
|
/** Flow U1 — credential files, from the early checks or the hash step. After the header and reading line. */
|
|
180
186
|
export declare function credentialFiles(paths: Offender[], output?: OutputLabel): string[];
|
|
181
187
|
/** Flow U1 — a file that changed, became a link, or could not be read while checked or hashed. */
|