@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.
Files changed (51) hide show
  1. package/MIGRATION.md +26 -0
  2. package/dist/cli/args.js +2 -0
  3. package/dist/cli/index.js +50 -8
  4. package/dist/cli/usage-text.js +10 -1
  5. package/dist/cli/verb-surface.d.ts +2 -1
  6. package/dist/cli/verb-surface.js +5 -0
  7. package/dist/dx/command.d.ts +74 -18
  8. package/dist/dx/command.js +137 -86
  9. package/dist/dx/consent.d.ts +25 -6
  10. package/dist/dx/consent.js +58 -25
  11. package/dist/dx/enablement.d.ts +63 -11
  12. package/dist/dx/enablement.js +93 -21
  13. package/dist/dx/journal.d.ts +3 -0
  14. package/dist/dx/journal.js +6 -1
  15. package/dist/dx/report.d.ts +4 -24
  16. package/dist/dx/report.js +32 -25
  17. package/dist/dx/skeleton.js +3 -0
  18. package/dist/editor/app.d.ts +61 -0
  19. package/dist/editor/app.js +180 -0
  20. package/dist/editor/copy.d.ts +22 -0
  21. package/dist/editor/copy.js +95 -0
  22. package/dist/editor/handoffs.d.ts +12 -0
  23. package/dist/editor/handoffs.js +93 -0
  24. package/dist/editor/launcher.d.ts +86 -0
  25. package/dist/editor/launcher.js +255 -0
  26. package/dist/editor/localFiles.d.ts +31 -0
  27. package/dist/editor/localFiles.js +100 -0
  28. package/dist/editor/reads.d.ts +47 -0
  29. package/dist/editor/reads.js +269 -0
  30. package/dist/editor/text.d.ts +7 -0
  31. package/dist/editor/text.js +58 -0
  32. package/dist/editor/tools.d.ts +5 -0
  33. package/dist/editor/tools.js +20 -0
  34. package/dist/lib/version.d.ts +28 -2
  35. package/dist/lib/version.js +30 -3
  36. package/dist/preview/command.js +2 -0
  37. package/dist/preview/copy.d.ts +5 -0
  38. package/dist/preview/copy.js +15 -0
  39. package/dist/preview/runner.d.ts +6 -0
  40. package/dist/preview/runner.js +1 -0
  41. package/dist/publish/command.js +5 -2
  42. package/dist/publish/copy.d.ts +6 -0
  43. package/dist/publish/copy.js +27 -6
  44. package/dist/publish/manifest.d.ts +11 -2
  45. package/dist/publish/manifest.js +45 -9
  46. package/dist/publish/upload.d.ts +10 -0
  47. package/dist/publish/upload.js +23 -0
  48. package/dist/shared/config.d.ts +6 -6
  49. package/dist/tunnel/client.d.ts +11 -3
  50. package/dist/tunnel/client.js +11 -8
  51. package/package.json +2 -2
@@ -0,0 +1,95 @@
1
+ // The `vincentt editor` screens (features/f21-visual-editor/mock/cli.md, C1 to C3). After a
2
+ // non-zero exit the last line states what did not happen.
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { agentSafe } from "./text.js";
6
+ export const DOWNLOAD_URL = "https://vincentt.studio/editor";
7
+ export const NOTHING_OPENED = "Nothing was opened.";
8
+ export const NOT_RUNNING = "✗ No editor is running for this folder.";
9
+ /** `~/Downloads/Vincentt Editor.app` for a path under the home folder. */
10
+ export function tildePath(p, home = os.homedir()) {
11
+ const safe = agentSafe(p, 400);
12
+ if (home && (safe === home || safe.startsWith(home + "/"))) {
13
+ return "~" + safe.slice(home.length);
14
+ }
15
+ return safe;
16
+ }
17
+ export function sent(name) {
18
+ return `✓ Sent ${agentSafe(name, 80)} to Vincentt Editor.`;
19
+ }
20
+ export function notInstalled() {
21
+ return [
22
+ `✗ Vincentt Editor is not installed. Get it at ${DOWNLOAD_URL}`,
23
+ NOTHING_OPENED,
24
+ ];
25
+ }
26
+ /** `app` is the refused bundle; the copy names the folder it was found in. */
27
+ export function badSignature(app) {
28
+ return [
29
+ `✗ The Vincentt Editor in ${tildePath(path.dirname(app))} is not signed by Vincentt, so the CLI will not open it.`,
30
+ ` Delete it and download it again from ${DOWNLOAD_URL}`,
31
+ NOTHING_OPENED,
32
+ ];
33
+ }
34
+ /** The copy on disk passes, but the one running from the same place does not. */
35
+ export function runningCopyNotSigned() {
36
+ return [
37
+ "✗ The Vincentt Editor that is running is not signed by Vincentt, so the CLI will not open it.",
38
+ " Quit the running Vincentt Editor, then run vincentt editor again.",
39
+ NOTHING_OPENED,
40
+ ];
41
+ }
42
+ export function notMacos() {
43
+ return ["✗ Vincentt Editor runs on macOS only for now.", NOTHING_OPENED];
44
+ }
45
+ export function macosTooOld(version) {
46
+ return [
47
+ `✗ Vincentt Editor needs macOS 14 or later. This Mac has macOS ${agentSafe(version, 20)}.`,
48
+ NOTHING_OPENED,
49
+ ];
50
+ }
51
+ export function otherCopyRunning(bundlePath) {
52
+ return [
53
+ `✗ Another Vincentt Editor is running from ${tildePath(bundlePath)}.`,
54
+ " Quit the other Vincentt Editor first, then run vincentt editor again.",
55
+ NOTHING_OPENED,
56
+ ];
57
+ }
58
+ /** A flag the launcher had before it handed off to the app (`--port`, `--dev-url`). */
59
+ export function removedFlag(flag) {
60
+ return [
61
+ `✗ ${flag} was removed. Vincentt Editor starts the preview itself.`,
62
+ " Run: vincentt editor",
63
+ NOTHING_OPENED,
64
+ ];
65
+ }
66
+ export function unknownFlag(flag) {
67
+ return [
68
+ `✗ vincentt editor has no option ${agentSafe(flag, 40)}.`,
69
+ " Run: vincentt editor (or --selection --json, or --status --json)",
70
+ NOTHING_OPENED,
71
+ ];
72
+ }
73
+ export function strayArgument() {
74
+ return [
75
+ "✗ vincentt editor takes no folder name. Run it inside the project, or pass --dir <folder>.",
76
+ NOTHING_OPENED,
77
+ ];
78
+ }
79
+ export function bothReads() {
80
+ return ["✗ --selection and --status cannot be used together. Run one, then the other."];
81
+ }
82
+ export function openFailed() {
83
+ return [
84
+ "✗ macOS could not hand the folder to Vincentt Editor.",
85
+ " Open Vincentt Editor from Applications, then run vincentt editor again.",
86
+ NOTHING_OPENED,
87
+ ];
88
+ }
89
+ /** A local file written in a schema major this CLI does not know. */
90
+ export function tooNew(schema) {
91
+ return [
92
+ `✗ Vincentt Editor wrote ${agentSafe(schema, 80)}, which is newer than this CLI reads.`,
93
+ " Update the CLI: npm install -g @vincentt-xr/cli@latest",
94
+ ];
95
+ }
@@ -0,0 +1,12 @@
1
+ /** Hand-off time (epoch ms) by project key. */
2
+ export type Handoffs = Record<string, number>;
3
+ /** Resolved per call: the home folder is the seam tests move. */
4
+ export declare function handoffsPath(): string;
5
+ /** The key a canonical project path is recorded under. */
6
+ export declare function projectKey(project: string): string;
7
+ /** Every recorded hand-off; empty when the file is absent, a link or unreadable. */
8
+ export declare function readHandoffs(file?: string): Promise<Handoffs>;
9
+ /** The last hand-off of `project` (a canonical path), or undefined when there was none. */
10
+ export declare function lastHandoff(project: string, file?: string): Promise<number | undefined>;
11
+ /** Records a hand-off of `project`, keeping the newest entries. Best-effort: never throws. */
12
+ export declare function recordHandoff(project: string, at: number, file?: string): Promise<void>;
@@ -0,0 +1,93 @@
1
+ // When this CLI last handed each project folder to Vincentt Editor, kept in
2
+ // ~/.vincentt/editor-handoffs.json. It is the CLI's own evidence that an open happened: a
3
+ // project's `.vincentt/editor/state.json` can be committed and cloned, so an `app.lastOpen`
4
+ // in it is believed only when the file changed after this machine's last hand-off.
5
+ //
6
+ // The file holds no folder paths: each entry is the SHA-256 of the canonical project path
7
+ // mapped to the hand-off time. It is the CLI's, never the project's: written through a fresh
8
+ // 0600 temp file and a rename, never through a link, size-capped, newest entries only.
9
+ import { createHash } from "node:crypto";
10
+ import { constants as fsc, promises as fs } from "node:fs";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+ const FILE = "editor-handoffs.json";
14
+ const MAX_ENTRIES = 50;
15
+ const READ_CAP = 64 << 10;
16
+ const KEY = /^[0-9a-f]{64}$/;
17
+ /** Resolved per call: the home folder is the seam tests move. */
18
+ export function handoffsPath() {
19
+ return path.join(os.homedir(), ".vincentt", FILE);
20
+ }
21
+ /** The key a canonical project path is recorded under. */
22
+ export function projectKey(project) {
23
+ return createHash("sha256").update(project).digest("hex");
24
+ }
25
+ function parse(text) {
26
+ let raw;
27
+ try {
28
+ raw = JSON.parse(text);
29
+ }
30
+ catch {
31
+ return {};
32
+ }
33
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
34
+ return {};
35
+ const out = {};
36
+ for (const [k, v] of Object.entries(raw)) {
37
+ if (KEY.test(k) && typeof v === "number" && Number.isFinite(v))
38
+ out[k] = v;
39
+ }
40
+ return out;
41
+ }
42
+ /** Every recorded hand-off; empty when the file is absent, a link or unreadable. */
43
+ export async function readHandoffs(file = handoffsPath()) {
44
+ let handle;
45
+ try {
46
+ handle = await fs.open(file, fsc.O_RDONLY | (fsc.O_NOFOLLOW ?? 0) | fsc.O_NONBLOCK);
47
+ }
48
+ catch {
49
+ return {};
50
+ }
51
+ try {
52
+ const st = await handle.stat();
53
+ if (!st.isFile() || st.size > READ_CAP)
54
+ return {};
55
+ const buf = Buffer.alloc(st.size);
56
+ const { bytesRead } = await handle.read(buf, 0, buf.length, 0);
57
+ return parse(buf.subarray(0, bytesRead).toString("utf8"));
58
+ }
59
+ finally {
60
+ await handle.close();
61
+ }
62
+ }
63
+ /** The last hand-off of `project` (a canonical path), or undefined when there was none. */
64
+ export async function lastHandoff(project, file = handoffsPath()) {
65
+ return (await readHandoffs(file))[projectKey(project)];
66
+ }
67
+ /** Records a hand-off of `project`, keeping the newest entries. Best-effort: never throws. */
68
+ export async function recordHandoff(project, at, file = handoffsPath()) {
69
+ try {
70
+ const dir = path.dirname(file);
71
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
72
+ const st = await fs.lstat(dir);
73
+ if (!st.isDirectory())
74
+ return;
75
+ const all = { ...(await readHandoffs(file)), [projectKey(project)]: at };
76
+ const kept = Object.fromEntries(Object.entries(all)
77
+ .sort((a, b) => a[1] - b[1])
78
+ .slice(-MAX_ENTRIES));
79
+ const tmp = path.join(dir, `.${FILE}.${process.pid}.tmp`);
80
+ await fs.rm(tmp, { force: true });
81
+ const handle = await fs.open(tmp, fsc.O_WRONLY | fsc.O_CREAT | fsc.O_EXCL | (fsc.O_NOFOLLOW ?? 0), 0o600);
82
+ try {
83
+ await handle.writeFile(JSON.stringify(kept) + "\n");
84
+ }
85
+ finally {
86
+ await handle.close();
87
+ }
88
+ await fs.rename(tmp, file);
89
+ }
90
+ catch {
91
+ // A missing record only makes `--status` report `lastOpen: null`, the safe direction.
92
+ }
93
+ }
@@ -0,0 +1,86 @@
1
+ import { type MacSystem } from "./app.js";
2
+ export { EDITOR_REQUIREMENT } from "./app.js";
3
+ export declare const OPEN_SCHEMA = "vincentt.editor-open/1";
4
+ /** Why the launcher did not hand the folder over; the first four are `--json`'s `reason`. */
5
+ export type LaunchReason = "not_installed" | "bad_signature" | "unsupported_os" | "other_copy_running" | "open_failed";
6
+ /** Every reason the f7 journal may record for `editor` (B-F7-1: a closed set, no values). */
7
+ export type EditorReason = LaunchReason | "bad_arguments" | "not_running" | "too_new";
8
+ /**
9
+ * Fixed in this module. Tests pass replacements through `editorCommand`'s last argument; no
10
+ * environment variable, config key or project file changes any of them.
11
+ */
12
+ export interface LauncherParams {
13
+ platform: NodeJS.Platform;
14
+ /** Searched in order; the first folder holding the app decides, pass or fail. */
15
+ searchRoots: readonly string[];
16
+ requirement: string;
17
+ bundleName: string;
18
+ bundleId: string;
19
+ /** The macOS product version; read from the OS when absent. */
20
+ osVersion?: string;
21
+ system: MacSystem;
22
+ }
23
+ export type LauncherOverrides = Partial<Omit<LauncherParams, "system">> & {
24
+ system?: Partial<MacSystem>;
25
+ };
26
+ export declare function defaultParams(): LauncherParams;
27
+ export type EditorArgs = {
28
+ kind: "launch";
29
+ json: boolean;
30
+ } | {
31
+ kind: "selection";
32
+ json: boolean;
33
+ } | {
34
+ kind: "status";
35
+ } | {
36
+ kind: "refuse";
37
+ lines: string[];
38
+ };
39
+ /** `args` is everything after `editor`. */
40
+ export declare function parseEditorArgs(args: readonly string[]): EditorArgs;
41
+ export interface EditorIo {
42
+ /** stdout, one line. */
43
+ out: (line: string) => void;
44
+ /** stderr, one line. */
45
+ err: (line: string) => void;
46
+ }
47
+ export interface EditorRun {
48
+ exitCode: 0 | 1;
49
+ reason?: EditorReason;
50
+ }
51
+ /**
52
+ * `args` is everything after `editor`; `root` is the project root resolved as for every verb.
53
+ * `overrides` replaces module parameters, for tests only.
54
+ */
55
+ export declare function editorCommand(args: readonly string[], root: string, io: EditorIo, overrides?: LauncherOverrides): Promise<EditorRun>;
56
+ /** The module parameters a test may replace, flat. */
57
+ export interface VerbParams extends Partial<Omit<LauncherParams, "system">> {
58
+ open?: MacSystem["open"] | ((app: string, folder: string) => void);
59
+ runningCopies?: MacSystem["runningCopies"];
60
+ verify?: MacSystem["verify"];
61
+ verifyPid?: MacSystem["verifyPid"];
62
+ startTime?: MacSystem["startTime"];
63
+ }
64
+ export interface VerbResult extends EditorRun {
65
+ stdout: string[];
66
+ stderr: string[];
67
+ }
68
+ /**
69
+ * The verb as the CLI runs it: the project root is resolved from `cwd` as for every verb (the
70
+ * binding's folder, else `cwd`), and the output is returned rather than printed.
71
+ */
72
+ export declare function editorVerb(args: readonly string[], cwd: string, params?: VerbParams): Promise<VerbResult>;
73
+ type Found = {
74
+ kind: "found";
75
+ app: string;
76
+ } | {
77
+ kind: "refused";
78
+ app: string;
79
+ detail: string;
80
+ };
81
+ /**
82
+ * The app in the first search root that holds anything under its name; never the next one. A
83
+ * link there is refused (codesign would verify wherever it points), and the copy must resolve
84
+ * to a bundle directly inside that root; the resolved path is the one verified and opened.
85
+ */
86
+ export declare function findApp(searchRoots: readonly string[], bundleName: string): Promise<Found | undefined>;
@@ -0,0 +1,255 @@
1
+ // `vincentt editor [--json] | --selection [--json] | --status --json`
2
+ // (features/f21-visual-editor/implementation.md, Launcher; transcripts in mock/cli.md C1 to C3).
3
+ //
4
+ // Not long-running and makes no network call: it checks the OS, finds the installed app in
5
+ // /Applications then ~/Applications, verifies its designated requirement, refuses when another
6
+ // copy is running from a different path, hands the project folder over with `open -a` and
7
+ // exits. It never waits on the app; what the app did with the folder is `app.lastOpen` in
8
+ // `--status --json`. Exit codes 0 and 1 only.
9
+ import { lstat, realpath } from "node:fs/promises";
10
+ import path from "node:path";
11
+ import { defaultSearchRoots, EDITOR_BUNDLE_ID, EDITOR_BUNDLE_NAME, EDITOR_REQUIREMENT, macosMajor, macSystem, MIN_MACOS_MAJOR, samePath, } from "./app.js";
12
+ import { locateProjectBinding } from "../shared/config.js";
13
+ import * as copy from "./copy.js";
14
+ import { recordHandoff } from "./handoffs.js";
15
+ import { agentSafe } from "./text.js";
16
+ import { selectionRead, statusRead } from "./reads.js";
17
+ export { EDITOR_REQUIREMENT } from "./app.js";
18
+ export const OPEN_SCHEMA = "vincentt.editor-open/1";
19
+ export function defaultParams() {
20
+ return {
21
+ platform: process.platform,
22
+ searchRoots: defaultSearchRoots(),
23
+ requirement: EDITOR_REQUIREMENT,
24
+ bundleName: EDITOR_BUNDLE_NAME,
25
+ bundleId: EDITOR_BUNDLE_ID,
26
+ system: macSystem(),
27
+ };
28
+ }
29
+ /** Flags the launcher had while it owned the session; each names its own removal (C1e). */
30
+ const REMOVED_FLAGS = ["--port", "--dev-url"];
31
+ const BOOLEAN = new Set(["--json", "--selection", "--status", "--allow-custom-api"]);
32
+ const WITH_VALUE = new Set(["--dir"]);
33
+ /** `args` is everything after `editor`. */
34
+ export function parseEditorArgs(args) {
35
+ const names = args.filter((a) => a.startsWith("-")).map((a) => a.split("=")[0]);
36
+ const removed = names.find((n) => REMOVED_FLAGS.includes(n));
37
+ if (removed !== undefined)
38
+ return { kind: "refuse", lines: copy.removedFlag(removed) };
39
+ const seen = new Set();
40
+ for (let i = 0; i < args.length; i++) {
41
+ const a = args[i];
42
+ if (!a.startsWith("-"))
43
+ return { kind: "refuse", lines: copy.strayArgument() };
44
+ const name = a.split("=")[0];
45
+ if (WITH_VALUE.has(name)) {
46
+ if (!a.includes("="))
47
+ i++;
48
+ continue;
49
+ }
50
+ if (!BOOLEAN.has(a))
51
+ return { kind: "refuse", lines: copy.unknownFlag(name) };
52
+ seen.add(a);
53
+ }
54
+ const json = seen.has("--json");
55
+ if (seen.has("--selection") && seen.has("--status"))
56
+ return { kind: "refuse", lines: copy.bothReads() };
57
+ if (seen.has("--status"))
58
+ return { kind: "status" };
59
+ if (seen.has("--selection"))
60
+ return { kind: "selection", json };
61
+ return { kind: "launch", json };
62
+ }
63
+ /**
64
+ * `args` is everything after `editor`; `root` is the project root resolved as for every verb.
65
+ * `overrides` replaces module parameters, for tests only.
66
+ */
67
+ export async function editorCommand(args, root, io, overrides = {}) {
68
+ const parsed = parseEditorArgs(args);
69
+ if (parsed.kind === "refuse") {
70
+ parsed.lines.forEach(has(args, "--json") ? io.err : io.out);
71
+ return { exitCode: 1, reason: "bad_arguments" };
72
+ }
73
+ const base = defaultParams();
74
+ const p = {
75
+ ...base,
76
+ ...overrides,
77
+ system: { ...base.system, ...overrides.system },
78
+ };
79
+ if (parsed.kind === "launch")
80
+ return launch(root, parsed.json, io, p);
81
+ const read = parsed.kind === "status"
82
+ ? await statusRead(root, p)
83
+ : await selectionRead(root, parsed.json, p);
84
+ read.stdout.forEach(io.out);
85
+ read.stderr.forEach(io.err);
86
+ return { exitCode: read.exitCode === 0 ? 0 : 1, reason: read.reason };
87
+ }
88
+ function has(args, flag) {
89
+ return args.includes(flag);
90
+ }
91
+ /**
92
+ * The verb as the CLI runs it: the project root is resolved from `cwd` as for every verb (the
93
+ * binding's folder, else `cwd`), and the output is returned rather than printed.
94
+ */
95
+ export async function editorVerb(args, cwd, params = {}) {
96
+ const { open, runningCopies, verify, verifyPid, startTime, ...rest } = params;
97
+ const system = {};
98
+ if (open)
99
+ system.open = async (app, folder) => void (await open(app, folder));
100
+ if (runningCopies)
101
+ system.runningCopies = runningCopies;
102
+ if (verify)
103
+ system.verify = verify;
104
+ if (verifyPid)
105
+ system.verifyPid = verifyPid;
106
+ if (startTime)
107
+ system.startTime = startTime;
108
+ const root = (await locateProjectBinding(cwd))?.dir ?? cwd;
109
+ const stdout = [];
110
+ const stderr = [];
111
+ const run = await editorCommand(args, root, { out: (l) => stdout.push(l), err: (l) => stderr.push(l) }, { ...rest, system });
112
+ return { ...run, stdout, stderr };
113
+ }
114
+ async function launch(root, json, io, p) {
115
+ const outcome = await decide(root, p);
116
+ const say = json ? io.err : io.out;
117
+ // Paths and codesign's words come from the file system and other programs: every string an
118
+ // agent reads is sanitised like the editor's own files.
119
+ const safe = (s) => agentSafe(s, 400);
120
+ if (outcome.handedOff) {
121
+ if (json) {
122
+ io.out(JSON.stringify({
123
+ schema: OPEN_SCHEMA,
124
+ handedOff: true,
125
+ project: safe(outcome.project),
126
+ app: safe(outcome.app),
127
+ appVersion: outcome.appVersion === null ? null : agentSafe(outcome.appVersion, 40),
128
+ }));
129
+ }
130
+ say(copy.sent(path.basename(outcome.project)));
131
+ return { exitCode: 0 };
132
+ }
133
+ if (json) {
134
+ io.out(JSON.stringify({
135
+ schema: OPEN_SCHEMA,
136
+ handedOff: false,
137
+ reason: outcome.reason,
138
+ ...(outcome.app === undefined ? {} : { app: safe(outcome.app) }),
139
+ ...(outcome.detail === undefined ? {} : { detail: safe(outcome.detail) }),
140
+ }));
141
+ }
142
+ outcome.lines.forEach(say);
143
+ return { exitCode: 1, reason: outcome.reason };
144
+ }
145
+ async function decide(root, p) {
146
+ if (p.platform !== "darwin")
147
+ return { handedOff: false, reason: "unsupported_os", lines: copy.notMacos() };
148
+ // Before the app is looked for, so nobody is sent to download an app that will not open.
149
+ const version = p.osVersion ?? (await p.system.osVersion().catch(() => undefined));
150
+ const major = macosMajor(version);
151
+ if (version !== undefined && major !== undefined && major < MIN_MACOS_MAJOR) {
152
+ return {
153
+ handedOff: false,
154
+ reason: "unsupported_os",
155
+ lines: copy.macosTooOld(version),
156
+ detail: `macOS ${version}`,
157
+ };
158
+ }
159
+ const found = await findApp(p.searchRoots, p.bundleName);
160
+ if (found === undefined)
161
+ return { handedOff: false, reason: "not_installed", lines: copy.notInstalled() };
162
+ if (found.kind === "refused") {
163
+ return {
164
+ handedOff: false,
165
+ reason: "bad_signature",
166
+ lines: copy.badSignature(found.app),
167
+ app: found.app,
168
+ detail: found.detail,
169
+ };
170
+ }
171
+ const app = found.app;
172
+ const verdict = await p.system.verify(app, p.requirement);
173
+ if (!verdict.ok) {
174
+ return {
175
+ handedOff: false,
176
+ reason: "bad_signature",
177
+ lines: copy.badSignature(app),
178
+ app,
179
+ detail: verdict.detail,
180
+ };
181
+ }
182
+ // `open -a` delivers the folder to whichever copy is running, so every running copy is
183
+ // checked: one at another path is refused, and one at this path must be running code that
184
+ // meets the requirement (the bundle on disk can differ from what was launched).
185
+ const copies = await p.system.runningCopies(p.bundleId).catch(() => undefined);
186
+ if (copies === undefined) {
187
+ return {
188
+ handedOff: false,
189
+ reason: "open_failed",
190
+ lines: copy.openFailed(),
191
+ app,
192
+ detail: "could not list running copies",
193
+ };
194
+ }
195
+ for (const running of copies) {
196
+ if (!(await samePath(running.bundlePath, app))) {
197
+ return {
198
+ handedOff: false,
199
+ reason: "other_copy_running",
200
+ lines: copy.otherCopyRunning(running.bundlePath),
201
+ app,
202
+ detail: running.bundlePath,
203
+ };
204
+ }
205
+ const live = await p.system.verifyPid(running.pid, p.requirement).catch(() => ({
206
+ ok: false,
207
+ detail: "codesign could not check the running app",
208
+ }));
209
+ if (!live.ok) {
210
+ return {
211
+ handedOff: false,
212
+ reason: "bad_signature",
213
+ lines: copy.runningCopyNotSigned(),
214
+ app,
215
+ detail: live.detail,
216
+ };
217
+ }
218
+ }
219
+ const project = await realpath(root).catch(() => path.resolve(root));
220
+ const { version: appVersion } = await p.system
221
+ .bundleInfo(app)
222
+ .catch(() => ({ version: undefined }));
223
+ // Taken before `open`: the app can write state.json before `open` returns, and a state file
224
+ // is believed only when it changed after this time.
225
+ const at = Date.now();
226
+ try {
227
+ await p.system.open(app, project);
228
+ }
229
+ catch {
230
+ return { handedOff: false, reason: "open_failed", lines: copy.openFailed(), app };
231
+ }
232
+ await recordHandoff(project, at);
233
+ return { handedOff: true, project, app, appVersion: appVersion ?? null };
234
+ }
235
+ /**
236
+ * The app in the first search root that holds anything under its name; never the next one. A
237
+ * link there is refused (codesign would verify wherever it points), and the copy must resolve
238
+ * to a bundle directly inside that root; the resolved path is the one verified and opened.
239
+ */
240
+ export async function findApp(searchRoots, bundleName) {
241
+ for (const dir of searchRoots) {
242
+ const candidate = path.join(dir, bundleName);
243
+ const st = await lstat(candidate).catch(() => undefined);
244
+ if (st === undefined)
245
+ continue;
246
+ if (st.isSymbolicLink())
247
+ return { kind: "refused", app: candidate, detail: "the app is a link" };
248
+ const real = await realpath(candidate).catch(() => undefined);
249
+ const root = await realpath(dir).catch(() => undefined);
250
+ if (real === undefined || root === undefined || path.dirname(real) !== root)
251
+ return { kind: "refused", app: candidate, detail: "the app is not in its folder" };
252
+ return { kind: "found", app: real };
253
+ }
254
+ return undefined;
255
+ }
@@ -0,0 +1,31 @@
1
+ export declare const EDITOR_DIR = ".vincentt/editor";
2
+ export declare class UnsafeLocalFile extends Error {
3
+ readonly rel: string;
4
+ constructor(rel: string, why: string);
5
+ }
6
+ /**
7
+ * `.vincentt` and `.vincentt/editor` are real folders or absent. A link or another file type in
8
+ * their place throws, naming it.
9
+ */
10
+ export declare function checkEditorDir(root: string): Promise<"absent" | "ok">;
11
+ /**
12
+ * One local file's text, or undefined when it does not exist. Opened no-follow and
13
+ * non-blocking (a FIFO cannot park the read), then checked on the open descriptor.
14
+ */
15
+ export declare function readLocalFile(root: string, name: "pid" | "selection.json" | "state.json"): Promise<string | undefined>;
16
+ /** As `readLocalFile`, with the file's modification time from the same descriptor. */
17
+ export declare function readLocalFileStat(root: string, name: "pid" | "selection.json" | "state.json"): Promise<{
18
+ text: string;
19
+ mtimeMs: number;
20
+ } | undefined>;
21
+ export interface PidRecord {
22
+ pid: number;
23
+ session: string;
24
+ /** When the app wrote the record, epoch ms. */
25
+ startedAt: number;
26
+ }
27
+ /**
28
+ * The app writes `{"pid","session","startedAt"}`. A record missing any of them (a bare pid, a
29
+ * null session, an unparseable time) is not a record.
30
+ */
31
+ export declare function parsePidFile(text: string): PidRecord | undefined;
@@ -0,0 +1,100 @@
1
+ // The editor's local files under `.vincentt/editor/`, as the CLI reads them. The app writes
2
+ // them; the CLI only reads `pid`, `selection.json` and `state.json`, and reads the way the app
3
+ // writes: never through a link, only from a regular file, size-capped (B-F21-8).
4
+ import { constants as fsc, promises as fs } from "node:fs";
5
+ import path from "node:path";
6
+ export const EDITOR_DIR = ".vincentt/editor";
7
+ const READ_CAP = 4 << 20;
8
+ export class UnsafeLocalFile extends Error {
9
+ rel;
10
+ constructor(rel, why) {
11
+ super(`${rel} ${why}`);
12
+ this.rel = rel;
13
+ this.name = "UnsafeLocalFile";
14
+ }
15
+ }
16
+ /**
17
+ * `.vincentt` and `.vincentt/editor` are real folders or absent. A link or another file type in
18
+ * their place throws, naming it.
19
+ */
20
+ export async function checkEditorDir(root) {
21
+ for (const rel of [".vincentt", EDITOR_DIR]) {
22
+ let st;
23
+ try {
24
+ st = await fs.lstat(path.join(root, ...rel.split("/")));
25
+ }
26
+ catch {
27
+ return "absent";
28
+ }
29
+ if (st.isSymbolicLink())
30
+ throw new UnsafeLocalFile(rel, "is a link");
31
+ if (!st.isDirectory())
32
+ throw new UnsafeLocalFile(rel, "is not a folder");
33
+ }
34
+ return "ok";
35
+ }
36
+ /**
37
+ * One local file's text, or undefined when it does not exist. Opened no-follow and
38
+ * non-blocking (a FIFO cannot park the read), then checked on the open descriptor.
39
+ */
40
+ export async function readLocalFile(root, name) {
41
+ return (await readLocalFileStat(root, name))?.text;
42
+ }
43
+ /** As `readLocalFile`, with the file's modification time from the same descriptor. */
44
+ export async function readLocalFileStat(root, name) {
45
+ if ((await checkEditorDir(root)) === "absent")
46
+ return undefined;
47
+ const rel = `${EDITOR_DIR}/${name}`;
48
+ const flags = fsc.O_RDONLY |
49
+ (fsc.O_NOFOLLOW ?? 0) |
50
+ (process.platform === "win32" ? 0 : fsc.O_NONBLOCK);
51
+ let handle;
52
+ try {
53
+ handle = await fs.open(path.join(root, ...rel.split("/")), flags);
54
+ }
55
+ catch (err) {
56
+ const code = err.code;
57
+ if (code === "ENOENT")
58
+ return undefined;
59
+ if (code === "ELOOP" || code === "EMLINK")
60
+ throw new UnsafeLocalFile(rel, "is a link");
61
+ throw err;
62
+ }
63
+ try {
64
+ const st = await handle.stat();
65
+ if (!st.isFile())
66
+ throw new UnsafeLocalFile(rel, "is not a regular file");
67
+ if (st.size > READ_CAP)
68
+ throw new UnsafeLocalFile(rel, "is too large");
69
+ const buf = Buffer.alloc(Math.min(st.size, READ_CAP));
70
+ const { bytesRead } = await handle.read(buf, 0, buf.length, 0);
71
+ return { text: buf.subarray(0, bytesRead).toString("utf8"), mtimeMs: st.mtimeMs };
72
+ }
73
+ finally {
74
+ await handle.close();
75
+ }
76
+ }
77
+ /**
78
+ * The app writes `{"pid","session","startedAt"}`. A record missing any of them (a bare pid, a
79
+ * null session, an unparseable time) is not a record.
80
+ */
81
+ export function parsePidFile(text) {
82
+ let raw;
83
+ try {
84
+ raw = JSON.parse(text.trim());
85
+ }
86
+ catch {
87
+ return undefined;
88
+ }
89
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
90
+ return undefined;
91
+ const o = raw;
92
+ if (typeof o.pid !== "number" || !Number.isInteger(o.pid) || o.pid <= 0)
93
+ return undefined;
94
+ if (typeof o.session !== "string" || o.session === "")
95
+ return undefined;
96
+ const startedAt = typeof o.startedAt === "string" ? Date.parse(o.startedAt) : NaN;
97
+ if (!Number.isFinite(startedAt))
98
+ return undefined;
99
+ return { pid: o.pid, session: o.session, startedAt };
100
+ }