@vincentt-xr/cli 0.12.3 → 0.14.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,117 @@
1
+ /**
2
+ * The read half of the location store: verify a candidate folder, and join the
3
+ * store against the listing's API rows.
4
+ *
5
+ * Kept out of `listProjects.ts` so the predicate is a pure function a human can
6
+ * reproduce with one `cat` and no CLI — which is the whole triage story for the
7
+ * report that will actually arrive ("it says it does not know where my project
8
+ * is, and I am looking at the folder").
9
+ */
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ /**
13
+ * At most this many folder checks per listing. 64 reads is ~1-3 ms against an
14
+ * API call the listing already spent 50-200 ms on. The ceiling exists so a
15
+ * pathological store — an agent looping `create` in generated folders — cannot
16
+ * turn a listing into a filesystem walk, and it FAILS TOWARD "unknown", never
17
+ * toward "here is a path we did not check", which is the only direction the
18
+ * verify-at-read rule permits.
19
+ */
20
+ export const VERIFY_LIMIT = 64;
21
+ /**
22
+ * Does `dir` still hold a binding naming `projectId`?
23
+ *
24
+ * ENOENT → dead (deleted, moved, drive unmounted, unbound)
25
+ * present but unparseable → dead
26
+ * present with no projectId → dead
27
+ * projectId !== the entry's → dead (bound to a different project)
28
+ * otherwise → live
29
+ *
30
+ * A DIRECT read, not `locateProjectBinding`'s upward walk. The store recorded
31
+ * the directory that HOLDS the file, and walking upward from it would let a
32
+ * PARENT repo's binding validate a child folder the store should have dropped —
33
+ * answering with a plausible-but-wrong path, which is precisely what
34
+ * verify-at-read exists to prevent.
35
+ *
36
+ * Exactly one `readFile` of an ~80-byte file. Not a `stat` then a read: ENOENT
37
+ * is the negative answer.
38
+ */
39
+ export function verifyLocation(dir, projectId) {
40
+ let raw;
41
+ try {
42
+ raw = fs.readFileSync(path.join(dir, ".vincentt", "project.json"), "utf8");
43
+ }
44
+ catch {
45
+ return false;
46
+ }
47
+ try {
48
+ const parsed = JSON.parse(raw);
49
+ if (typeof parsed !== "object" || parsed === null)
50
+ return false;
51
+ return parsed.projectId === projectId;
52
+ }
53
+ catch {
54
+ return false;
55
+ }
56
+ }
57
+ /**
58
+ * Join the store against the ids in this listing, verify newest-first, and
59
+ * report what survives.
60
+ *
61
+ * Verification is SCOPED TO THE JOIN: entries for projects in another workspace
62
+ * are not read, not touched, and not dropped. That is a correctness rule before
63
+ * it is a cost one — dropping an entry because you listed a DIFFERENT workspace
64
+ * would be a bug, and scoping to the join makes it unrepresentable.
65
+ *
66
+ * Candidates are sorted by `t` DESCENDING before the ceiling applies, and the
67
+ * ordering is part of the contract. Store order (append order, oldest first) is
68
+ * the implementation a reader falls into without deciding, and its failure is
69
+ * the worst shape available: a creator with 70 stale entries and one live one
70
+ * spends all 64 reads on the oldest dead ones, is told their project has no
71
+ * known folder, and then compaction drops those 64 so the NEXT listing succeeds
72
+ * — an intermittent failure that heals itself, which is the hardest possible
73
+ * triage. Newest-first fails as "you have not touched this folder in a very long
74
+ * time", which is diagnosable.
75
+ *
76
+ * Presentation order and verification order are the same sort, applied once.
77
+ */
78
+ export function joinLocations(entries, projectIds, verify = verifyLocation) {
79
+ // Exact equality on the FULL project id — never a prefix or a substring. Ids
80
+ // are 12-char opaque hex, and a truncating join looks reasonable to a future
81
+ // reader while silently leaking another tenant's folder into this listing.
82
+ const inScope = new Set(projectIds);
83
+ const candidates = [];
84
+ const untouched = [];
85
+ for (const e of entries) {
86
+ if (inScope.has(e.p))
87
+ candidates.push(e);
88
+ else
89
+ untouched.push(e);
90
+ }
91
+ const ordered = [...candidates].sort((a, b) => (b.t ?? "").localeCompare(a.t ?? ""));
92
+ const byProject = new Map();
93
+ const live = [];
94
+ let reads = 0;
95
+ const dead = new Set();
96
+ for (const e of ordered) {
97
+ if (reads >= VERIFY_LIMIT)
98
+ break;
99
+ reads += 1;
100
+ if (verify(e.d, e.p)) {
101
+ live.push(e);
102
+ const at = byProject.get(e.p);
103
+ if (at)
104
+ at.push(e.d);
105
+ else
106
+ byProject.set(e.p, [e.d]);
107
+ }
108
+ else {
109
+ dead.add(e);
110
+ }
111
+ }
112
+ // Beyond the ceiling an entry is neither reported nor dropped: it was not
113
+ // checked, and dropping what you did not check is the one thing the ceiling
114
+ // must not do.
115
+ const survivors = entries.filter((e) => !dead.has(e));
116
+ return { byProject, survivors, dropped: dead.size > 0 };
117
+ }
@@ -0,0 +1,72 @@
1
+ /** Thrown when the wait elapses with no answer from the browser. */
2
+ export declare class LoopbackTimeoutError extends Error {
3
+ constructor(message: string);
4
+ }
5
+ /** Thrown when the callback is a terminal failure (a cancel, a malformed answer). */
6
+ export declare class LoopbackCallbackError extends Error {
7
+ constructor(message: string);
8
+ }
9
+ /**
10
+ * What the caller's parser decided about one callback.
11
+ *
12
+ * `retry` is the third state and the reason this is not a boolean: a state mismatch is
13
+ * a stray or forged callback, and the right response is to answer it and KEEP WAITING
14
+ * for the genuine one. Treating it as a failure would let anyone who can reach the
15
+ * loopback port cancel a legitimate consent flow.
16
+ */
17
+ export type CallbackDecision<T> = {
18
+ ok: true;
19
+ value: T;
20
+ page: LoopbackPage;
21
+ } | {
22
+ ok: false;
23
+ retry: boolean;
24
+ message: string;
25
+ page: LoopbackPage;
26
+ };
27
+ export interface LoopbackPage {
28
+ status: number;
29
+ html: string;
30
+ }
31
+ export interface LoopbackWaitOptions<T> {
32
+ /** Pick a free loopback port. Injected so tests never race a real ephemeral picker. */
33
+ pickPort: () => Promise<number>;
34
+ /** Build the console URL the browser opens, given the loopback redirect_uri. */
35
+ buildUrl: (redirectUri: string) => string;
36
+ /** Launch the browser. A no-op fake in tests; never fatal in production. */
37
+ openBrowser: (url: string) => void;
38
+ /** Interpret one callback's query params. Must verify `state` itself. */
39
+ parse: (params: URLSearchParams) => CallbackDecision<T>;
40
+ /** Abort if the round trip does not complete in time. */
41
+ timeoutMs: number;
42
+ /** Built when the wait times out, so each flow words its own transcript. */
43
+ onTimeout: () => Error;
44
+ /**
45
+ * Built when a callback is a TERMINAL failure. Injected for the same reason as
46
+ * onTimeout: each flow keeps its own error type, so callers can keep matching on
47
+ * theirs rather than on a shared one this extraction happened to introduce.
48
+ */
49
+ onCallbackError?: (message: string) => Error;
50
+ /** Narration (the "→ Opening…" / printed-URL / "Waiting…" lines). */
51
+ onProgress?: (line: string) => void;
52
+ /** The loopback path the console redirects to. Defaults to /callback. */
53
+ path?: string;
54
+ }
55
+ /**
56
+ * Run one loopback round trip and resolve with whatever `parse` extracted.
57
+ *
58
+ * Ordering is security-load-bearing and is the reason this function exists at all:
59
+ * 1. pick the port and START LISTENING,
60
+ * 2. only then open the browser + print the fallback URL,
61
+ * 3. verify `state` on every callback before reading anything else from it,
62
+ * 4. bound the wait.
63
+ */
64
+ export declare function waitForLoopbackCallback<T>(opts: LoopbackWaitOptions<T>): Promise<T>;
65
+ /**
66
+ * Verify `state` constant-time and hand back the remaining params.
67
+ *
68
+ * State is checked FIRST, always, before any other field is read, so a forged or stray
69
+ * callback can never drive a decision. Shared with pkce.parseCallback's discipline; kept
70
+ * here as a helper so a caller writing its own `parse` cannot forget it.
71
+ */
72
+ export declare function verifiedState(params: URLSearchParams, expected: string): boolean;
@@ -0,0 +1,119 @@
1
+ // The browser round-trip primitive: start a loopback listener on 127.0.0.1, open the
2
+ // browser at a console URL, and wait for the console to redirect back with a verified
3
+ // `state`.
4
+ //
5
+ // EXTRACTED from pkceLogin.ts rather than copied. `vincentt login` (PKCE) and
6
+ // `vincentt dx enable` (account consent) are two flows with the same security-critical
7
+ // skeleton, and the skeleton is exactly the part that must not drift between them: the
8
+ // listener comes up BEFORE the browser (a browser that redirects to a port nobody is
9
+ // listening on loses the answer), `state` is verified constant-time on every callback,
10
+ // a mismatch is answered but NOT accepted, and the wait is bounded. Two copies of that
11
+ // would mean a fix applied to one and not the other — and the un-fixed one is a consent
12
+ // gate.
13
+ //
14
+ // Every external boundary stays injected (port picker, listen, browser open, clock) so
15
+ // callers are unit-testable with no real socket.
16
+ import { createServer } from "node:http";
17
+ import { stateMatches } from "./pkce.js";
18
+ /** Thrown when the wait elapses with no answer from the browser. */
19
+ export class LoopbackTimeoutError extends Error {
20
+ constructor(message) {
21
+ super(message);
22
+ this.name = "LoopbackTimeoutError";
23
+ }
24
+ }
25
+ /** Thrown when the callback is a terminal failure (a cancel, a malformed answer). */
26
+ export class LoopbackCallbackError extends Error {
27
+ constructor(message) {
28
+ super(message);
29
+ this.name = "LoopbackCallbackError";
30
+ }
31
+ }
32
+ /**
33
+ * Run one loopback round trip and resolve with whatever `parse` extracted.
34
+ *
35
+ * Ordering is security-load-bearing and is the reason this function exists at all:
36
+ * 1. pick the port and START LISTENING,
37
+ * 2. only then open the browser + print the fallback URL,
38
+ * 3. verify `state` on every callback before reading anything else from it,
39
+ * 4. bound the wait.
40
+ */
41
+ export function waitForLoopbackCallback(opts) {
42
+ const callbackPath = opts.path ?? "/callback";
43
+ return new Promise((resolve, reject) => {
44
+ void (async () => {
45
+ let port;
46
+ try {
47
+ port = await opts.pickPort();
48
+ }
49
+ catch (err) {
50
+ reject(err);
51
+ return;
52
+ }
53
+ const redirectUri = `http://127.0.0.1:${port}${callbackPath}`;
54
+ const url = opts.buildUrl(redirectUri);
55
+ const progress = opts.onProgress ?? (() => { });
56
+ const server = createServer((req, res) => {
57
+ // A received request always carries a url; the base makes it absolute for parsing.
58
+ const parsed = new URL(req.url, `http://127.0.0.1:${port}`);
59
+ if (parsed.pathname !== callbackPath) {
60
+ res.writeHead(404, { "content-type": "text/plain" });
61
+ res.end("Not found");
62
+ return;
63
+ }
64
+ const decision = opts.parse(parsed.searchParams);
65
+ res.writeHead(decision.page.status, {
66
+ "content-type": "text/html; charset=utf-8",
67
+ });
68
+ res.end(decision.page.html);
69
+ if (decision.ok) {
70
+ server.close();
71
+ resolve(decision.value);
72
+ }
73
+ else if (!decision.retry) {
74
+ server.close();
75
+ reject(opts.onCallbackError?.(decision.message) ??
76
+ new LoopbackCallbackError(decision.message));
77
+ }
78
+ // retry === true: answered, still listening for the genuine callback.
79
+ });
80
+ server.on("error", reject);
81
+ server.listen(port, "127.0.0.1", () => {
82
+ for (const line of progressLines(url))
83
+ progress(line);
84
+ opts.openBrowser(url);
85
+ });
86
+ const timer = setTimeout(() => {
87
+ server.close();
88
+ reject(opts.onTimeout());
89
+ }, opts.timeoutMs);
90
+ timer.unref();
91
+ })();
92
+ });
93
+ }
94
+ /**
95
+ * The narration, including the printed URL.
96
+ *
97
+ * The URL is printed on EVERY run, not only when the open fails, because the CLI cannot
98
+ * tell whether a browser actually appeared — `open`/`xdg-open` exits 0 on a headless box
99
+ * with nothing to show. On the agent-driven flow this whole feature exists for, the
100
+ * printed URL is frequently the only way the human ever reaches the page.
101
+ */
102
+ function progressLines(url) {
103
+ return [
104
+ "→ Opening your browser…",
105
+ ` If it doesn't open, visit: ${url}`,
106
+ "",
107
+ " Waiting for you to answer in the browser…",
108
+ ];
109
+ }
110
+ /**
111
+ * Verify `state` constant-time and hand back the remaining params.
112
+ *
113
+ * State is checked FIRST, always, before any other field is read, so a forged or stray
114
+ * callback can never drive a decision. Shared with pkce.parseCallback's discipline; kept
115
+ * here as a helper so a caller writing its own `parse` cannot forget it.
116
+ */
117
+ export function verifiedState(params, expected) {
118
+ return stateMatches(expected, params.get("state"));
119
+ }
@@ -43,6 +43,7 @@ type SpawnLike = (cmd: string, args: string[], opts: {
43
43
  };
44
44
  /** Launch the browser via an injected spawn; a throw is swallowed (fallback = the printed URL). */
45
45
  export declare function launchBrowser(url: string, spawn: SpawnLike, platform?: string): void;
46
+ export declare function browserPage(ok: boolean, heading: string, body: string): string;
46
47
  /**
47
48
  * Run the loopback + PKCE exchange, persist the PAT, and confirm identity via /me.
48
49
  * Resolves with the confirmed identity + the config path; rejects with LoginTimeoutError
@@ -11,11 +11,11 @@
11
11
  // Every external boundary is injected (getFreePort, listen, openBrowser, backend, clock/
12
12
  // timeout, config write) so the whole flow is unit-testable against a stub backend + fake
13
13
  // browser + temp config dir, with no real socket or network.
14
- import { createServer } from "node:http";
15
14
  import { getFreePort } from "../preview/net.js";
16
15
  import { writeMachineConfig } from "../shared/config.js";
17
16
  import { makePkceMaterial, buildCliAuthUrl, parseCallback, } from "./pkce.js";
18
17
  import { httpAuthBackend } from "./authBackend.js";
18
+ import { waitForLoopbackCallback } from "./loopback.js";
19
19
  /** Thrown when the wait elapses with no approval; drives the L4 timeout transcript. */
20
20
  export class LoginTimeoutError extends Error {
21
21
  constructor() {
@@ -57,7 +57,10 @@ function defaultOpenBrowser(url) {
57
57
  // AuthLayout auth-card is mirrored here token-for-token: --color-sand page, white
58
58
  // --color-surface card (rounded-2xl, p-7), the terminal glyph on --color-primary-faint,
59
59
  // and the light/dark palette. A subtle green/red tints only the glyph on ok/failure.
60
- function browserPage(ok, heading, body) {
60
+ //
61
+ // Exported so `dx enable`'s consent round trip renders the SAME page rather than growing
62
+ // a second, drifting copy of the console's card.
63
+ export function browserPage(ok, heading, body) {
61
64
  // The terminal glyph the console uses on the Grant-CLI screen (icons.terminal).
62
65
  const glyph = '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
63
66
  'stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
@@ -102,51 +105,44 @@ export async function runPkceLogin(opts) {
102
105
  ((apiUrl, pat) => writeMachineConfig({ apiUrl, pat, consoleUrl: opts.consoleUrl }));
103
106
  const progress = opts.onProgress ?? (() => { });
104
107
  const material = makePkceMaterial();
105
- const port = await pickPort();
106
- const redirectUri = `http://127.0.0.1:${port}/callback`;
107
- const authUrl = buildCliAuthUrl(opts.consoleUrl, redirectUri, material);
108
- // (4) await the loopback callback; verify state; hand back the auth_code.
109
- const code = await new Promise((resolve, reject) => {
110
- const server = createServer((req, res) => {
111
- // A received request always carries a url; the base makes it absolute for parsing.
112
- const url = new URL(req.url, `http://127.0.0.1:${port}`);
113
- if (url.pathname !== "/callback") {
114
- res.writeHead(404, { "content-type": "text/plain" });
115
- res.end("Not found");
116
- return;
117
- }
118
- const result = parseCallback(url.searchParams, material.state);
119
- res.writeHead(result.ok ? 200 : 400, {
120
- "content-type": "text/html; charset=utf-8",
121
- });
122
- res.end(result.ok
123
- ? browserPage(true, "You're all set", "Return to your terminal — you can close this tab.")
124
- : browserPage(false, "Sign-in failed", result.message));
108
+ // (1)–(4): listener up first, browser second, state verified on every callback,
109
+ // bounded wait. The mechanics live in waitForLoopbackCallback, shared with
110
+ // `dx enable` so the two flows cannot drift apart.
111
+ const code = await waitForLoopbackCallback({
112
+ pickPort,
113
+ buildUrl: (redirectUri) => buildCliAuthUrl(opts.consoleUrl, redirectUri, material),
114
+ openBrowser,
115
+ timeoutMs: opts.timeoutMs ?? 5 * 60_000,
116
+ onTimeout: () => new LoginTimeoutError(),
117
+ onCallbackError: (message) => new LoginCallbackError(message),
118
+ onProgress: (line) => {
119
+ // The login transcript says "sign in" where the generic narration says
120
+ // "answer" — same lines, this flow's words.
121
+ progress(line
122
+ .replace("→ Opening your browser…", "→ Opening your browser to sign in…")
123
+ .replace(" Waiting for you to answer in the browser…", " Waiting for you to approve access in the browser…"));
124
+ },
125
+ parse: (params) => {
126
+ const result = parseCallback(params, material.state);
125
127
  if (result.ok) {
126
- server.close();
127
- resolve(result.code);
128
- }
129
- else if (result.reason !== "state_mismatch") {
130
- // A cancel is a real, terminal failure. A state mismatch is a stray/forged
131
- // callback — answer it but keep waiting for the genuine one (or the timeout).
132
- server.close();
133
- reject(new LoginCallbackError(result.message));
128
+ return {
129
+ ok: true,
130
+ value: result.code,
131
+ page: {
132
+ status: 200,
133
+ html: browserPage(true, "You're all set", "Return to your terminal — you can close this tab."),
134
+ },
135
+ };
134
136
  }
135
- });
136
- server.on("error", reject);
137
- // (1) listener up FIRST; only then (3) open the browser + print the fallback.
138
- server.listen(port, "127.0.0.1", () => {
139
- progress("→ Opening your browser to sign in…");
140
- progress(` If it doesn't open, visit: ${authUrl}`);
141
- progress("");
142
- progress(" Waiting for you to approve access in the browser…");
143
- openBrowser(authUrl);
144
- });
145
- const timer = setTimeout(() => {
146
- server.close();
147
- reject(new LoginTimeoutError());
148
- }, opts.timeoutMs ?? 5 * 60_000);
149
- timer.unref();
137
+ // A cancel is a real, terminal failure. A state mismatch is a stray/forged
138
+ // callback — answer it but keep waiting for the genuine one (or the timeout).
139
+ return {
140
+ ok: false,
141
+ retry: result.reason === "state_mismatch",
142
+ message: result.message,
143
+ page: { status: 400, html: browserPage(false, "Sign-in failed", result.message) },
144
+ };
145
+ },
150
146
  });
151
147
  // (5) redeem the code for the raw PAT; (6) persist then confirm identity.
152
148
  const pat = await backend.exchange(code, material.codeVerifier);
@@ -56,6 +56,14 @@ export declare function writeDxEnablement(next: {
56
56
  * other field by design, which would leave a disconnected machine still armed to
57
57
  * transmit for whoever signs in next — a creator who disconnects a machine has
58
58
  * disconnected it.
59
+ *
60
+ * The ACCOUNT-WIDE grant is deliberately NOT revoked here, and that asymmetry is the
61
+ * point: the local flag is a statement about this machine, so disconnecting the machine
62
+ * retracts it, while the account grant is a separate deliberate decision that lives in
63
+ * the console and covers machines this one knows nothing about. Signing out of a laptop
64
+ * is not a statement about a desktop. `vincentt dx disable` is the verb that revokes the
65
+ * account grant; `logout` only ever clears local state, which also keeps it free of a
66
+ * network call that could fail and strand a sign-out.
59
67
  */
60
68
  export declare function clearMachineToken(): Promise<{
61
69
  path: string;
@@ -12,6 +12,7 @@ import { randomBytes } from "node:crypto";
12
12
  import { promises as fs } from "node:fs";
13
13
  import os from "node:os";
14
14
  import path from "node:path";
15
+ import { recordLocation } from "./recordLocation.js";
15
16
  export const MACHINE_CONFIG_PATH = path.join(os.homedir(), ".vincentt", "config.json");
16
17
  const BINDING_DIR = ".vincentt";
17
18
  const BINDING_FILE = "project.json";
@@ -111,6 +112,14 @@ export async function writeDxEnablement(next) {
111
112
  * other field by design, which would leave a disconnected machine still armed to
112
113
  * transmit for whoever signs in next — a creator who disconnects a machine has
113
114
  * disconnected it.
115
+ *
116
+ * The ACCOUNT-WIDE grant is deliberately NOT revoked here, and that asymmetry is the
117
+ * point: the local flag is a statement about this machine, so disconnecting the machine
118
+ * retracts it, while the account grant is a separate deliberate decision that lives in
119
+ * the console and covers machines this one knows nothing about. Signing out of a laptop
120
+ * is not a statement about a desktop. `vincentt dx disable` is the verb that revokes the
121
+ * account grant; `logout` only ever clears local state, which also keeps it free of a
122
+ * network call that could fail and strand a sign-out.
114
123
  */
115
124
  export async function clearMachineToken() {
116
125
  const current = await loadMachineConfig();
@@ -137,8 +146,12 @@ export async function locateProjectBinding(cwd) {
137
146
  let dir = path.resolve(cwd);
138
147
  for (;;) {
139
148
  const binding = await readJsonIfExists(projectBindingPath(dir));
140
- if (binding)
149
+ if (binding) {
150
+ // The ONE success return, so every verb that resolves a binding records
151
+ // its folder without a per-verb checklist. Best-effort and synchronous.
152
+ recordLocation(binding.projectId, dir);
141
153
  return { binding, dir, path: projectBindingPath(dir) };
154
+ }
142
155
  if (await isGitRoot(dir))
143
156
  return undefined;
144
157
  const parent = path.dirname(dir);
@@ -165,6 +178,10 @@ export async function writeProjectBinding(cwd, binding) {
165
178
  const p = projectBindingPath(cwd);
166
179
  await fs.writeFile(p, JSON.stringify(binding, null, 2) + "\n", "utf8");
167
180
  await ensureGitignored(cwd, `${BINDING_DIR}/`);
181
+ // `create` and `link` WRITE a binding rather than resolve one, so they never
182
+ // pass through the resolver's hook. Between the two, every way a folder comes
183
+ // to name a project records that folder.
184
+ recordLocation(binding.projectId, path.resolve(cwd));
168
185
  return p;
169
186
  }
170
187
  /** Remove the per-tree binding so the directory can be re-created/rebound. */
@@ -0,0 +1,82 @@
1
+ export interface LocationEntry {
2
+ /** The project id — the join key against the listing's API rows. */
3
+ p: string;
4
+ /**
5
+ * The absolute directory holding `.vincentt/project.json`, REALPATH-resolved.
6
+ * Not the cwd: the cwd may be a subdirectory of it.
7
+ */
8
+ d: string;
9
+ /** ISO8601 UTC, when the entry was last written. The presentation tiebreak. */
10
+ t: string;
11
+ }
12
+ /**
13
+ * An entry is a DISTINCT FOLDER, not a run, so the bound is far smaller than the
14
+ * journal's 2000. 500 folders is beyond any real creator and holds the file
15
+ * under ~60 KB.
16
+ */
17
+ export declare const MAX_ENTRIES = 500;
18
+ /**
19
+ * Resolved per call rather than captured at module load — the same discipline
20
+ * and the same reason as `journalPath()`: the home directory is the seam tests
21
+ * move, and a frozen path silently writes the developer's real one.
22
+ */
23
+ export declare function locationsPath(): string;
24
+ /** The store's whole surface, so the listing can be driven with no filesystem. */
25
+ export interface LocationStore {
26
+ read(): LocationEntry[];
27
+ append(entry: LocationEntry): void;
28
+ compact(keep: LocationEntry[]): void;
29
+ }
30
+ /**
31
+ * Read oldest-first. Malformed lines are skipped, and an unreadable file yields
32
+ * an empty list — a store that FAILS is indistinguishable from a store that is
33
+ * EMPTY, which is the correct blast radius: a fresh install is exactly that
34
+ * state and is fully supported.
35
+ */
36
+ export declare function readLocations(): LocationEntry[];
37
+ /**
38
+ * Does the file end in a newline, or is there nothing there yet?
39
+ *
40
+ * Two syscalls against the page cache (`statSync` for the size, a one-byte read
41
+ * at `size - 1`), skipped entirely when the file is absent or empty. Writing
42
+ * `"\n" + line` unconditionally was rejected: it makes a leading blank line the
43
+ * normal case the reader must then tolerate, hiding the torn-write signal
44
+ * instead of repairing it.
45
+ */
46
+ export declare function needsLeadingNewline(file: string): boolean;
47
+ /**
48
+ * Append one entry. Never throws, for the journal's stated reason:
49
+ * instrumentation that breaks the tool it measures is worse than none. Every
50
+ * failure mode here (a read-only home, a full disk, a directory where the file
51
+ * should be) is one the creator would rather have silently ignored than turned
52
+ * into a failed `publish`.
53
+ *
54
+ * `locationsPath()` is INSIDE the try because it calls `os.homedir()`, which can
55
+ * itself throw on a hostile environment.
56
+ */
57
+ export declare function appendLocation(entry: LocationEntry): void;
58
+ /**
59
+ * Collapse duplicate `(p, d)` pairs to their newest `t`, then keep the newest
60
+ * MAX_ENTRIES. Returned oldest-first, which is the order the file holds.
61
+ */
62
+ export declare function compactEntries(entries: LocationEntry[]): LocationEntry[];
63
+ /**
64
+ * Rewrite the store with the survivors, ATOMICALLY: a unique temp file at 0600,
65
+ * chmod'ed explicitly, renamed over the target, and removed if the rename fails.
66
+ * The discipline `persistMachineConfig` already uses for the credential file.
67
+ *
68
+ * Rewrite-with-survivors rather than delete-lines, so a crash leaves either the
69
+ * original file or the fully compacted one and never a truncated store — which
70
+ * would be indistinguishable from an empty one BY DESIGN, and therefore
71
+ * invisible at every behavioral tier.
72
+ *
73
+ * An append landing inside the rename window is lost. That is accepted and
74
+ * self-healing (the folder that appended is a folder a verb just ran in), and it
75
+ * is the one place the no-lock choice has a visible cost.
76
+ *
77
+ * Never throws: it runs AFTER the answer has printed, and a compaction that
78
+ * fails must not delay or break it.
79
+ */
80
+ export declare function compactLocations(keep: LocationEntry[]): void;
81
+ /** The real store, reading and writing the creator's own home directory. */
82
+ export declare const fileLocationStore: LocationStore;