@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,197 @@
1
+ /**
2
+ * The ACCOUNT half of unattended-send authorization.
3
+ *
4
+ * `vincentt dx enable` used to require a TTY, which dead-ends the flow this feature is
5
+ * actually for: a creator drives the CLI through a coding agent, the agent has no
6
+ * terminal, and the command refuses. Consent therefore moves to the console, where a
7
+ * human can actually read the disclosure and click — the CLI opens a browser and waits.
8
+ *
9
+ * The grant is PER-ACCOUNT, not per-machine: one decision covers every machine the
10
+ * creator signs in on. That is a wider blast radius than the local flag, which is why
11
+ * the console page carries the full disclosure (including that an agent may compose a
12
+ * note the user has not read) and why the visibility line names the console as the
13
+ * source — a creator who wants it off needs to know that clearing a local flag is not
14
+ * enough.
15
+ *
16
+ * Every call is isolated behind ConsentBackend so the command layer is unit-testable
17
+ * with no network.
18
+ */
19
+ import { randomBytes } from "node:crypto";
20
+ import { waitForLoopbackCallback } from "../login/loopback.js";
21
+ import { browserPage } from "../login/pkceLogin.js";
22
+ import { stateMatches } from "../login/pkce.js";
23
+ import { getFreePort } from "../preview/net.js";
24
+ import { authFailureMessage } from "../shared/authError.js";
25
+ import { clientHeaders } from "../shared/clientHeader.js";
26
+ import { readAccountConsent } from "./enablement.js";
27
+ async function safeText(res) {
28
+ try {
29
+ return (await res.text()).slice(0, 300);
30
+ }
31
+ catch {
32
+ return "";
33
+ }
34
+ }
35
+ /**
36
+ * Unwrap the `meEnvelope` GET /me and both /me/dx-consent verbs return.
37
+ *
38
+ * The envelope is `{ me: {...}, csrfToken }`, but a bare user object is tolerated for the
39
+ * same reason normalizeMe tolerates it: a contract wording change should not silently
40
+ * turn a granted account into a refusal. Note the tolerance is on the SHAPE ONLY — the
41
+ * consent VALUE is still read `=== true`, so a resilient unwrap never becomes a lenient
42
+ * authorization read.
43
+ */
44
+ function unwrapMe(raw) {
45
+ const envelope = (raw ?? {});
46
+ return envelope.me && typeof envelope.me === "object" ? envelope.me : envelope;
47
+ }
48
+ /** The real HTTP-backed ConsentBackend, pointed at a Vincentt API base URL. */
49
+ export function httpConsentBackend() {
50
+ const strip = (u) => u.replace(/\/$/, "");
51
+ return {
52
+ async read(apiUrl, pat, now) {
53
+ const res = await fetch(`${strip(apiUrl)}/me`, {
54
+ headers: { ...clientHeaders(), authorization: `Bearer ${pat}` },
55
+ });
56
+ if (!res.ok) {
57
+ throw new Error(authFailureMessage("Could not read your account", res.status, await safeText(res)));
58
+ }
59
+ return readAccountConsent(unwrapMe(await res.json()), now);
60
+ },
61
+ async grant(apiUrl, pat, now) {
62
+ const res = await fetch(`${strip(apiUrl)}/me/dx-consent`, {
63
+ method: "POST",
64
+ headers: { ...clientHeaders(), authorization: `Bearer ${pat}` },
65
+ });
66
+ if (!res.ok) {
67
+ throw new Error(authFailureMessage("Could not record consent", res.status, await safeText(res)));
68
+ }
69
+ return readAccountConsent(unwrapMe(await res.json()), now);
70
+ },
71
+ async revoke(apiUrl, pat) {
72
+ const res = await fetch(`${strip(apiUrl)}/me/dx-consent`, {
73
+ method: "DELETE",
74
+ headers: { ...clientHeaders(), authorization: `Bearer ${pat}` },
75
+ });
76
+ if (!res.ok) {
77
+ throw new Error(authFailureMessage("Could not revoke consent", res.status, await safeText(res)));
78
+ }
79
+ },
80
+ };
81
+ }
82
+ /**
83
+ * The console URL the browser opens for the consent decision:
84
+ * `<console>/cli-dx-consent?redirect_uri=<loopback>&state=…`.
85
+ *
86
+ * Mirrors buildCliAuthUrl deliberately, including the param NAMES — the console's page
87
+ * reads exactly `redirect_uri` and `state`, and it validates that redirect_uri is a
88
+ * 127.0.0.1 loopback. No PKCE material rides here: unlike login, nothing is being
89
+ * redeemed. The callback carries only a decision, and `state` is what binds that
90
+ * decision to this CLI invocation.
91
+ */
92
+ export function buildConsentUrl(consoleBase, redirectUri, state) {
93
+ const base = /^https?:\/\//i.test(consoleBase)
94
+ ? consoleBase
95
+ : `${/^(localhost|127\.0\.0\.1)(:|\/|$)/i.test(consoleBase) ? "http" : "https"}://${consoleBase}`;
96
+ const u = new URL("/cli-dx-consent", base);
97
+ u.searchParams.set("redirect_uri", redirectUri);
98
+ u.searchParams.set("state", state);
99
+ return u.toString();
100
+ }
101
+ /**
102
+ * Interpret the loopback callback (`?state=…&result=granted|denied`).
103
+ *
104
+ * State is verified constant-time FIRST, before `result` is even read, so a stray or
105
+ * forged callback can never grant consent — the same discipline as parseCallback, and
106
+ * for a higher-stakes decision: here the forged value would not be a code that still has
107
+ * to survive a PKCE exchange, it would be the answer itself.
108
+ *
109
+ * Anything that is not exactly "granted" reads as DENIED rather than as an error, so an
110
+ * unrecognized result can only ever fail closed.
111
+ */
112
+ export function parseConsentCallback(params, expectedState, stateMatches) {
113
+ if (!stateMatches(expectedState, params.get("state"))) {
114
+ return {
115
+ ok: false,
116
+ reason: "state_mismatch",
117
+ message: "State mismatch — ignoring an unexpected callback.",
118
+ };
119
+ }
120
+ const result = params.get("result");
121
+ if (result === null) {
122
+ return {
123
+ ok: false,
124
+ reason: "missing_result",
125
+ message: "The browser returned no answer.",
126
+ };
127
+ }
128
+ return { ok: true, decision: result === "granted" ? "granted" : "denied" };
129
+ }
130
+ /** Thrown when the human never answers in the browser. */
131
+ export class ConsentTimeoutError extends Error {
132
+ constructor() {
133
+ super("Timed out waiting for an answer in the browser (5 minutes).");
134
+ this.name = "ConsentTimeoutError";
135
+ }
136
+ }
137
+ /**
138
+ * Open the console's consent page and wait for the human's answer.
139
+ *
140
+ * The mechanics are the SHARED loopback helper, not a second copy of it: listener up
141
+ * before the browser, `state` verified constant-time on every callback, a stray callback
142
+ * answered but not accepted, and a bounded wait. The only thing that differs from login
143
+ * is what comes back — a decision instead of an auth code.
144
+ *
145
+ * Nothing is written here. This function reports what the human said; persisting it is
146
+ * the command layer's job, so the "denied writes nothing" rule lives in one place.
147
+ */
148
+ export async function runConsentInBrowser(opts) {
149
+ // 24 bytes of CSPRNG, matching the login flow's state. This is the only thing binding
150
+ // the browser's answer to this invocation, so it is minted per call and never reused.
151
+ const state = (opts.mintState ?? defaultState)();
152
+ return waitForLoopbackCallback({
153
+ pickPort: opts.pickPort ?? getFreePort,
154
+ buildUrl: (redirectUri) => buildConsentUrl(opts.consoleUrl, redirectUri, state),
155
+ openBrowser: opts.openBrowser ?? defaultOpenBrowser,
156
+ timeoutMs: opts.timeoutMs ?? 5 * 60_000,
157
+ onTimeout: () => new ConsentTimeoutError(),
158
+ onProgress: opts.onProgress,
159
+ parse: (params) => {
160
+ const result = parseConsentCallback(params, state, stateMatches);
161
+ if (result.ok) {
162
+ const granted = result.decision === "granted";
163
+ return {
164
+ ok: true,
165
+ value: result.decision,
166
+ page: {
167
+ status: 200,
168
+ html: browserPage(granted, granted ? "Reports allowed" : "Not enabled", "Return to your terminal — you can close this tab."),
169
+ },
170
+ };
171
+ }
172
+ return {
173
+ ok: false,
174
+ // A state mismatch keeps waiting: it is a stray or forged callback, and letting
175
+ // it end the flow would let anyone who can reach the loopback port cancel a
176
+ // legitimate consent round trip.
177
+ retry: result.reason === "state_mismatch",
178
+ message: result.message,
179
+ page: {
180
+ status: 400,
181
+ html: browserPage(false, "Something went wrong", result.message),
182
+ },
183
+ };
184
+ },
185
+ });
186
+ }
187
+ function defaultState() {
188
+ return randomBytes(24)
189
+ .toString("base64")
190
+ .replace(/\+/g, "-")
191
+ .replace(/\//g, "_")
192
+ .replace(/=+$/, "");
193
+ }
194
+ function defaultOpenBrowser(url) {
195
+ // Lazy import keeps this module importable in tests that never open a browser.
196
+ void Promise.all([import("node:child_process"), import("../login/pkceLogin.js")]).then(([{ spawn }, { launchBrowser }]) => launchBrowser(url, spawn));
197
+ }
@@ -10,10 +10,22 @@
10
10
  * raise.
11
11
  */
12
12
  export declare const DX_UNATTENDED_SUNSET: number;
13
+ /**
14
+ * WHERE the grant was made. Consent can now be given in the console (per-ACCOUNT, so it
15
+ * covers every machine the creator signs in on) or at this terminal (per-MACHINE).
16
+ *
17
+ * The source is carried rather than inferred because the two have different blast radii
18
+ * and different places to revoke. A creator reading `ENABLED` on a machine they never
19
+ * ran `dx enable` on has no way to act on it without being told the grant lives in their
20
+ * account — they would go looking for a local flag that is not there.
21
+ */
22
+ export type EnablementSource = "console" | "machine";
13
23
  export interface Enablement {
14
24
  enabled: boolean;
15
25
  /** The raw ISO8601 grant time. Present only when `enabled` is true. */
16
26
  at?: string;
27
+ /** Where the grant came from. Present only when `enabled` is true. */
28
+ source?: EnablementSource;
17
29
  }
18
30
  /**
19
31
  * Decide enablement from an already-parsed config object. Pure, and STRICT:
@@ -26,6 +38,18 @@ export interface Enablement {
26
38
  * that reads a creator's "off" as "on" is inverted, not merely loose.
27
39
  */
28
40
  export declare function readEnablement(cfg: unknown, now: number): Enablement;
41
+ /**
42
+ * Decide enablement from the ACCOUNT's `/me` fields, with the same strictness as the
43
+ * local read.
44
+ *
45
+ * `=== true` is not defensive noise here either: this value crosses the wire, so it
46
+ * arrives as whatever JSON the server (or anything between) actually sent. A truthiness
47
+ * read would treat the string "false" — the exact shape a serializer bug or a hand-rolled
48
+ * proxy produces — as consent. The sunset applies identically: past it, an account grant
49
+ * stops being honored too, or "forgetting the sunset turns the feature off" would only be
50
+ * true of the local half.
51
+ */
52
+ export declare function readAccountConsent(raw: unknown, now: number): Enablement;
29
53
  /**
30
54
  * Read enablement from the real machine config.
31
55
  *
@@ -42,10 +66,20 @@ export declare function grantedOn(at: string): string;
42
66
  /**
43
67
  * The one visibility line, in the single pinned shape every human surface uses:
44
68
  *
45
- * Unattended send: ENABLED since 2026-04-01 (145 days)
69
+ * Unattended send: ENABLED since 2026-04-01 (145 days) · granted in the console
70
+ * Unattended send: ENABLED since 2026-04-01 (145 days) · granted on this machine
46
71
  *
47
72
  * Returns undefined when not enabled. Nothing is printed in that case, on
48
73
  * purpose: a line that prints unconditionally is a line readers learn to skip,
49
74
  * which would cost exactly the attention the enabled line needs.
75
+ *
76
+ * The AGE stays exactly where it was — it remains the entire mitigation for having no
77
+ * expiry, and the source is appended AFTER it rather than woven in, so the number a
78
+ * reader is meant to notice is not pushed off the front of the line.
79
+ *
80
+ * The source clause answers the question the age raises: having seen "145 days", the
81
+ * creator's next move is to turn it off, and that is a different place depending on
82
+ * where it was granted. A line that says ENABLED without saying where sends someone
83
+ * looking for a local flag that may not exist.
50
84
  */
51
85
  export declare function enablementLine(e: Enablement, now: number): string | undefined;
@@ -1,11 +1,16 @@
1
1
  /**
2
2
  * Durable authorization for unattended `vincentt dx --send`.
3
3
  *
4
- * `--send` transmits only when the session is interactive OR this machine has
5
- * been explicitly enabled by a person, at a terminal, via `vincentt dx enable`.
6
- * The state is two fields in ~/.vincentt/config.json, set by `enable`, cleared by
7
- * `disable` and by `logout`, and it has NO expiry — it stays on until the creator
8
- * turns it off.
4
+ * `--send` transmits only when the session is interactive, OR this MACHINE has been
5
+ * enabled via `vincentt dx enable`, OR the creator's ACCOUNT carries the grant they
6
+ * gave in the console. `enable` collects that consent in the browser by default and
7
+ * at the terminal when there is one; either way a person answers, never the caller.
8
+ *
9
+ * The machine half is two fields in ~/.vincentt/config.json, set by `enable`, cleared
10
+ * by `disable` and by `logout`. The account half is read from `/me` and cleared by
11
+ * `disable` or in the console — NOT by `logout`, since signing out of one machine is
12
+ * not a decision about the account. Neither has an expiry: both stay on until the
13
+ * creator turns them off.
9
14
  *
10
15
  * Because there is no expiry, VISIBILITY is the entire mitigation, and the AGE is
11
16
  * the load-bearing half of it: `ENABLED` alone reads as configuration, while
@@ -50,7 +55,35 @@ export function readEnablement(cfg, now) {
50
55
  return DISABLED;
51
56
  if (now >= DX_UNATTENDED_SUNSET)
52
57
  return DISABLED;
53
- return { enabled: true, at: dxSendEnabledAt };
58
+ // The local flag is by definition a grant made on THIS machine — `dx enable` is the
59
+ // only writer, whether it took the browser path or the terminal one. An account grant
60
+ // never lands in this file; it is read from /me and reported separately.
61
+ return { enabled: true, at: dxSendEnabledAt, source: "machine" };
62
+ }
63
+ /**
64
+ * Decide enablement from the ACCOUNT's `/me` fields, with the same strictness as the
65
+ * local read.
66
+ *
67
+ * `=== true` is not defensive noise here either: this value crosses the wire, so it
68
+ * arrives as whatever JSON the server (or anything between) actually sent. A truthiness
69
+ * read would treat the string "false" — the exact shape a serializer bug or a hand-rolled
70
+ * proxy produces — as consent. The sunset applies identically: past it, an account grant
71
+ * stops being honored too, or "forgetting the sunset turns the feature off" would only be
72
+ * true of the local half.
73
+ */
74
+ export function readAccountConsent(raw, now) {
75
+ if (typeof raw !== "object" || raw === null)
76
+ return DISABLED;
77
+ const { dxReportsConsent, dxReportsConsentAt } = raw;
78
+ if (dxReportsConsent !== true)
79
+ return DISABLED;
80
+ if (typeof dxReportsConsentAt !== "string")
81
+ return DISABLED;
82
+ if (!Number.isFinite(Date.parse(dxReportsConsentAt)))
83
+ return DISABLED;
84
+ if (now >= DX_UNATTENDED_SUNSET)
85
+ return DISABLED;
86
+ return { enabled: true, at: dxReportsConsentAt, source: "console" };
54
87
  }
55
88
  /**
56
89
  * Read enablement from the real machine config.
@@ -83,15 +116,30 @@ export function grantedOn(at) {
83
116
  /**
84
117
  * The one visibility line, in the single pinned shape every human surface uses:
85
118
  *
86
- * Unattended send: ENABLED since 2026-04-01 (145 days)
119
+ * Unattended send: ENABLED since 2026-04-01 (145 days) · granted in the console
120
+ * Unattended send: ENABLED since 2026-04-01 (145 days) · granted on this machine
87
121
  *
88
122
  * Returns undefined when not enabled. Nothing is printed in that case, on
89
123
  * purpose: a line that prints unconditionally is a line readers learn to skip,
90
124
  * which would cost exactly the attention the enabled line needs.
125
+ *
126
+ * The AGE stays exactly where it was — it remains the entire mitigation for having no
127
+ * expiry, and the source is appended AFTER it rather than woven in, so the number a
128
+ * reader is meant to notice is not pushed off the front of the line.
129
+ *
130
+ * The source clause answers the question the age raises: having seen "145 days", the
131
+ * creator's next move is to turn it off, and that is a different place depending on
132
+ * where it was granted. A line that says ENABLED without saying where sends someone
133
+ * looking for a local flag that may not exist.
91
134
  */
92
135
  export function enablementLine(e, now) {
93
136
  if (!e.enabled || e.at === undefined)
94
137
  return undefined;
95
138
  const days = ageInDays(e.at, now);
96
- return `Unattended send: ENABLED since ${grantedOn(e.at)} (${days} ${days === 1 ? "day" : "days"})`;
139
+ const where = e.source === "console"
140
+ ? " · granted in the console"
141
+ : e.source === "machine"
142
+ ? " · granted on this machine"
143
+ : "";
144
+ return `Unattended send: ENABLED since ${grantedOn(e.at)} (${days} ${days === 1 ? "day" : "days"})${where}`;
97
145
  }
@@ -1,5 +1,8 @@
1
1
  import type { ProjectView } from "./backend.js";
2
2
  import type { ResolvedConfig } from "../shared/config.js";
3
+ import { type LocationStore } from "../shared/locationStore.js";
4
+ /** The unknown case, printed rather than omitted. Repeated verbatim in the scaffolded agent contract. */
5
+ export declare const NO_LOCATION = "no folder known on this machine";
3
6
  export interface ListProjectsDeps {
4
7
  print: (line: string) => void;
5
8
  /** The machine's API host + PAT, or undefined when nothing is signed in. */
@@ -16,6 +19,14 @@ export interface ListProjectsDeps {
16
19
  message: string;
17
20
  }>;
18
21
  list: (cfg: ResolvedConfig, orgSlug: string) => Promise<ProjectView[]>;
22
+ /**
23
+ * The machine's location store. REQUIRED as a dep, not optional: without it
24
+ * this command acquires a hard dependency on the real home directory and the
25
+ * header's claim above stops being true.
26
+ */
27
+ store?: LocationStore;
28
+ /** The verification predicate, injectable so the drop-or-report decision needs no filesystem. */
29
+ verify?: (dir: string, projectId: string) => boolean;
19
30
  }
20
31
  export interface ListProjectsOptions {
21
32
  json?: boolean;
@@ -38,4 +49,4 @@ export declare function listProjectsCommand(deps: ListProjectsDeps, opts?: ListP
38
49
  * so a client that assembled `<slug>.vincentt.app` itself would print the wrong
39
50
  * address on staging and dev.
40
51
  */
41
- export declare function projectRows(projects: ProjectView[]): string[];
52
+ export declare function projectRows(projects: ProjectView[], locations?: Map<string, string[]>): string[];
@@ -10,8 +10,21 @@
10
10
  // were bad: guess an id, tell the creator to open the console and copy one, or
11
11
  // fail. `link` needs an id, and until now nothing printed one.
12
12
  //
13
- // The IO boundary (print, backend, org) is injected, so every branch is testable
14
- // with no API, no HOME, and no network.
13
+ // It also answers WHERE, for a project this machine has worked on before, by
14
+ // joining the machine-local location store on the project id. The store is a
15
+ // hint pointing at a hint: every candidate folder is verified against the
16
+ // binding it claims to hold before it is reported, and the server re-authorizes
17
+ // on every request regardless. It is the ONLY reader of the store in the
18
+ // product.
19
+ //
20
+ // The IO boundary (print, backend, org, store, verify) is injected, so every
21
+ // branch is testable with no API, no HOME, and no network.
22
+ import { compactEntries, fileLocationStore, } from "../shared/locationStore.js";
23
+ import { joinLocations, verifyLocation } from "./locations.js";
24
+ /** The unknown case, printed rather than omitted. Repeated verbatim in the scaffolded agent contract. */
25
+ export const NO_LOCATION = "no folder known on this machine";
26
+ /** The row's own two spaces, plus four. A CONSTANT — never aligned to a column. */
27
+ const CONTINUATION_INDENT = " ";
15
28
  /**
16
29
  * The columns are `id`, `slug`, `name` and where it stands.
17
30
  *
@@ -32,13 +45,32 @@ export async function listProjectsCommand(deps, opts = {}) {
32
45
  return 1;
33
46
  }
34
47
  const projects = await deps.list(cfg, org.org.slug);
48
+ // The store is not consulted when there is nothing to join against: a creator
49
+ // with no projects must not learn that a location feature exists, and the
50
+ // location is a property of a project ROW.
51
+ const located = projects.length === 0
52
+ ? { byProject: new Map(), compact: () => { } }
53
+ : readLocationsFor(deps, projects);
35
54
  if (opts.json) {
36
55
  // `{projects: [...]}` — the server's own envelope, holding the SAME
37
56
  // `ProjectView` every other verb in this CLI parses a project into. It is a
38
57
  // projection of the wire row (the API also sends version/timestamp fields),
39
58
  // and it is deliberately the shared one rather than a listing-specific
40
59
  // shape: a second projection here would be a second thing that can drift.
41
- deps.print(JSON.stringify({ projects }, null, 2));
60
+ //
61
+ // `locations` is COMPOSED alongside the parsed row rather than added to
62
+ // `ProjectView`. That type's contract is "what the API returned", and
63
+ // putting a purely local filesystem fact inside it is how a never-transmitted
64
+ // value comes to look transmittable to the next reader — the property this
65
+ // whole design rests on. It is the LAST key for the same reason: the object
66
+ // is the API row plus one local fact, and the reading order says where the
67
+ // wire ends.
68
+ const rows = projects.map((p) => ({
69
+ ...p,
70
+ locations: located.byProject.get(p.id) ?? [],
71
+ }));
72
+ deps.print(JSON.stringify({ projects: rows }, null, 2));
73
+ located.compact();
42
74
  return 0;
43
75
  }
44
76
  if (projects.length === 0) {
@@ -52,12 +84,51 @@ export async function listProjectsCommand(deps, opts = {}) {
52
84
  }
53
85
  deps.print(`${projects.length} project${projects.length === 1 ? "" : "s"} in ${org.org.name}`);
54
86
  deps.print("");
55
- for (const line of projectRows(projects))
87
+ for (const line of projectRows(projects, located.byProject))
56
88
  deps.print(line);
57
89
  deps.print("");
58
90
  deps.print(" Link a folder to one: vincentt link <project-id>");
91
+ // AFTER the answer has printed, never before: a compaction that fails must
92
+ // not delay or break it.
93
+ located.compact();
59
94
  return 0;
60
95
  }
96
+ /**
97
+ * Read the store, verify what this listing joins against, and hand back both the
98
+ * answer and a deferred compaction.
99
+ *
100
+ * Nothing here throws: a store that cannot be read yields no locations, every
101
+ * project reports unknown, and that is BYTE-IDENTICAL to a fresh install — which
102
+ * is the correct blast radius for a broken shortcut index.
103
+ */
104
+ function readLocationsFor(deps, projects) {
105
+ const store = deps.store ?? fileLocationStore;
106
+ const verify = deps.verify ?? verifyLocation;
107
+ try {
108
+ const entries = store.read();
109
+ const joined = joinLocations(entries, projects.map((p) => p.id), verify);
110
+ return {
111
+ byProject: joined.byProject,
112
+ compact: () => {
113
+ try {
114
+ const next = compactEntries(joined.survivors);
115
+ // A read that dropped nothing and is already inside the bound has
116
+ // nothing to rewrite, and a rename window nobody needs is a window an
117
+ // append can be lost in.
118
+ if (!joined.dropped && next.length === entries.length)
119
+ return;
120
+ store.compact(next);
121
+ }
122
+ catch {
123
+ // The answer is already printed.
124
+ }
125
+ },
126
+ };
127
+ }
128
+ catch {
129
+ return { byProject: new Map(), compact: () => { } };
130
+ }
131
+ }
61
132
  /**
62
133
  * Aligned on the widest id and slug so the block reads as a table in a terminal
63
134
  * and stays greppable in an agent's transcript.
@@ -67,11 +138,31 @@ export async function listProjectsCommand(deps, opts = {}) {
67
138
  * so a client that assembled `<slug>.vincentt.app` itself would print the wrong
68
139
  * address on staging and dev.
69
140
  */
70
- export function projectRows(projects) {
141
+ export function projectRows(projects, locations = new Map()) {
71
142
  const idWidth = Math.max(...projects.map((p) => p.id.length));
72
143
  const slugWidth = Math.max(...projects.map((p) => p.slug.length));
73
- return projects.map((p) => {
144
+ const out = [];
145
+ for (const p of projects) {
74
146
  const state = p.publishState === "live" && p.liveUrl ? p.liveUrl : p.publishState;
75
- return ` ${p.id.padEnd(idWidth)} ${p.slug.padEnd(slugWidth)} ${p.name} ${state}`;
76
- });
147
+ out.push(` ${p.id.padEnd(idWidth)} ${p.slug.padEnd(slugWidth)} ${p.name} ${state}`);
148
+ // EVERY row carries exactly one non-empty `↳` block. That regularity is the
149
+ // design: the reader learns the shape once and never has to decide whether a
150
+ // missing line means UNKNOWN or NOT PRINTED — and it makes an older CLI,
151
+ // which prints no `↳` at all, a visible difference rather than an ambiguity.
152
+ //
153
+ // Nothing marks one of several as preferred: no `(newest)`, no bullet, no
154
+ // count, no bold. Ordering is most-recently-worked-in first and is
155
+ // presentation only, deliberately not printed, because a printed "newest" is
156
+ // the hint that becomes a pick.
157
+ //
158
+ // The path is never truncated. A wrapped path is ugly; a path a creator
159
+ // cannot copy is a bug report.
160
+ const dirs = locations.get(p.id) ?? [];
161
+ if (dirs.length === 0)
162
+ out.push(`${CONTINUATION_INDENT}↳ ${NO_LOCATION}`);
163
+ else
164
+ for (const d of dirs)
165
+ out.push(`${CONTINUATION_INDENT}↳ ${d}`);
166
+ }
167
+ return out;
77
168
  }
@@ -0,0 +1,59 @@
1
+ import type { LocationEntry } from "../shared/locationStore.js";
2
+ /**
3
+ * At most this many folder checks per listing. 64 reads is ~1-3 ms against an
4
+ * API call the listing already spent 50-200 ms on. The ceiling exists so a
5
+ * pathological store — an agent looping `create` in generated folders — cannot
6
+ * turn a listing into a filesystem walk, and it FAILS TOWARD "unknown", never
7
+ * toward "here is a path we did not check", which is the only direction the
8
+ * verify-at-read rule permits.
9
+ */
10
+ export declare const VERIFY_LIMIT = 64;
11
+ /**
12
+ * Does `dir` still hold a binding naming `projectId`?
13
+ *
14
+ * ENOENT → dead (deleted, moved, drive unmounted, unbound)
15
+ * present but unparseable → dead
16
+ * present with no projectId → dead
17
+ * projectId !== the entry's → dead (bound to a different project)
18
+ * otherwise → live
19
+ *
20
+ * A DIRECT read, not `locateProjectBinding`'s upward walk. The store recorded
21
+ * the directory that HOLDS the file, and walking upward from it would let a
22
+ * PARENT repo's binding validate a child folder the store should have dropped —
23
+ * answering with a plausible-but-wrong path, which is precisely what
24
+ * verify-at-read exists to prevent.
25
+ *
26
+ * Exactly one `readFile` of an ~80-byte file. Not a `stat` then a read: ENOENT
27
+ * is the negative answer.
28
+ */
29
+ export declare function verifyLocation(dir: string, projectId: string): boolean;
30
+ export interface JoinResult {
31
+ /** Verified folders, newest first, keyed by project id. */
32
+ byProject: Map<string, string[]>;
33
+ /** Every entry that should survive compaction, oldest-first as the file holds it. */
34
+ survivors: LocationEntry[];
35
+ /** Whether anything was dropped, so a clean read can skip the rewrite. */
36
+ dropped: boolean;
37
+ }
38
+ /**
39
+ * Join the store against the ids in this listing, verify newest-first, and
40
+ * report what survives.
41
+ *
42
+ * Verification is SCOPED TO THE JOIN: entries for projects in another workspace
43
+ * are not read, not touched, and not dropped. That is a correctness rule before
44
+ * it is a cost one — dropping an entry because you listed a DIFFERENT workspace
45
+ * would be a bug, and scoping to the join makes it unrepresentable.
46
+ *
47
+ * Candidates are sorted by `t` DESCENDING before the ceiling applies, and the
48
+ * ordering is part of the contract. Store order (append order, oldest first) is
49
+ * the implementation a reader falls into without deciding, and its failure is
50
+ * the worst shape available: a creator with 70 stale entries and one live one
51
+ * spends all 64 reads on the oldest dead ones, is told their project has no
52
+ * known folder, and then compaction drops those 64 so the NEXT listing succeeds
53
+ * — an intermittent failure that heals itself, which is the hardest possible
54
+ * triage. Newest-first fails as "you have not touched this folder in a very long
55
+ * time", which is diagnosable.
56
+ *
57
+ * Presentation order and verification order are the same sort, applied once.
58
+ */
59
+ export declare function joinLocations(entries: LocationEntry[], projectIds: string[], verify?: (dir: string, projectId: string) => boolean): JoinResult;