@vincentt-xr/cli 0.22.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.
@@ -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
+ }
@@ -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>;