@vincentt-xr/cli 0.12.2 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -67,7 +67,8 @@ Project
67
67
  [--force] [--no-install]
68
68
  link <project-id> Link this directory to an existing project
69
69
  unlink Detach this directory from its project
70
- projects List the workspace's projects and their ids
70
+ projects List the workspace's projects, their ids, and where each
71
+ one lives on this machine
71
72
  [--json] [--org <s>]
72
73
 
73
74
  Dev loop
@@ -119,5 +120,23 @@ In a directory that is not linked to anything, `npx vincentt projects` lists the
119
120
  workspace's projects with their ids, which is what `vincentt link <project-id>`
120
121
  needs. `--json` prints the same rows for a program to read.
121
122
 
123
+ It also reports **where each project lives on this machine**. Any command run in
124
+ a linked folder records that folder against the project, so a project you have
125
+ worked on here is listed with its directory on an indented line under its row:
126
+
127
+ ```
128
+ 66f1c2a9e4b7 museum-ar-tour Museum AR Tour museum-ar-tour.vincentt.app
129
+ ↳ /Users/alice/code/museum-ar-tour
130
+ ```
131
+
132
+ A project this machine has no folder for reads `no folder known on this machine`
133
+ rather than leaving a gap, and a project checked out in several places lists
134
+ every one without picking. The folder is checked when it is read — it is only
135
+ reported if it still holds that project's binding — so a path that has moved or
136
+ been unbound is never handed back. Under `--json` every project carries a
137
+ `locations` array: empty when unknown, one member when located, several when the
138
+ project is in more than one place. Nothing about this leaves the machine, and
139
+ deleting `~/.vincentt/locations.ndjson` costs nothing but the shortcut.
140
+
122
141
  `vincentt` is a project devDependency in the scaffold, so invoke it as
123
142
  `npx vincentt <verb>` and it resolves to the local copy — no global install.
@@ -51,3 +51,22 @@ export declare function refusedFlag(args: string[]): string | undefined;
51
51
  * the worst kind for an identifier that decides which project a folder points at.
52
52
  */
53
53
  export declare function positionalArgs(args: string[]): string[];
54
+ /**
55
+ * Does this `create` invocation name a folder that `--dir` already points at?
56
+ *
57
+ * `--dir` says WHERE TO WORK; the positional says WHAT TO CREATE INSIDE it. They
58
+ * compose, so `--dir ~/apps/foo foo` writes `~/apps/foo/foo` — correct by the
59
+ * rules and almost never what was meant. The help said `--dir <path> (project
60
+ * dir)` under "Common options", which reads as *the project's own directory*
61
+ * rather than *the parent of it*, so an agent handed a folder path and a name
62
+ * passes both and gets a doubled path.
63
+ *
64
+ * Returns the resolved `--dir` when the collision is present, so the caller can
65
+ * name both correct forms; `undefined` otherwise.
66
+ *
67
+ * EXACT match on the last path segment only. A near miss (`foo-app` in `foo/`)
68
+ * is left alone: inferring intent from a similar name would refuse work that was
69
+ * never ambiguous. Trailing separators are normalised by `resolve` first, so
70
+ * `--dir ~/apps/foo/` behaves the same as without.
71
+ */
72
+ export declare function createDirCollision(dirFlag: string | undefined, name: string, resolve: (p: string) => string, basename: (p: string) => string): string | undefined;
package/dist/cli/args.js CHANGED
@@ -142,3 +142,27 @@ export function positionalArgs(args) {
142
142
  }
143
143
  return out;
144
144
  }
145
+ /**
146
+ * Does this `create` invocation name a folder that `--dir` already points at?
147
+ *
148
+ * `--dir` says WHERE TO WORK; the positional says WHAT TO CREATE INSIDE it. They
149
+ * compose, so `--dir ~/apps/foo foo` writes `~/apps/foo/foo` — correct by the
150
+ * rules and almost never what was meant. The help said `--dir <path> (project
151
+ * dir)` under "Common options", which reads as *the project's own directory*
152
+ * rather than *the parent of it*, so an agent handed a folder path and a name
153
+ * passes both and gets a doubled path.
154
+ *
155
+ * Returns the resolved `--dir` when the collision is present, so the caller can
156
+ * name both correct forms; `undefined` otherwise.
157
+ *
158
+ * EXACT match on the last path segment only. A near miss (`foo-app` in `foo/`)
159
+ * is left alone: inferring intent from a similar name would refuse work that was
160
+ * never ambiguous. Trailing separators are normalised by `resolve` first, so
161
+ * `--dir ~/apps/foo/` behaves the same as without.
162
+ */
163
+ export function createDirCollision(dirFlag, name, resolve, basename) {
164
+ if (dirFlag === undefined)
165
+ return undefined;
166
+ const resolved = resolve(dirFlag);
167
+ return basename(resolved) === name ? resolved : undefined;
168
+ }
package/dist/cli/index.js CHANGED
@@ -19,6 +19,7 @@ import { httpAuthBackend } from "../login/authBackend.js";
19
19
  import { loginCommand } from "../login/loginCommand.js";
20
20
  import { ensureLoggedIn } from "../login/ensure.js";
21
21
  import { clearMachineToken, loadMachineConfig, locateProjectBinding, resolveConfig, writeDxEnablement, MACHINE_CONFIG_PATH, } from "../shared/config.js";
22
+ import { fileLocationStore } from "../shared/locationStore.js";
22
23
  import { getMe, listProjects, ProjectUnreachableError } from "../lib/backend.js";
23
24
  import { listProjectsCommand } from "../lib/listProjects.js";
24
25
  import { initCommand } from "../lib/initCommand.js";
@@ -48,7 +49,7 @@ import { nudgeFor } from "../dx/nudge.js";
48
49
  import { FETCH_TIMEOUT_MS, isFresh, updateNotice } from "../dx/updateCheck.js";
49
50
  import { fetchLatest, loadCache, saveCache } from "../dx/updateCheckIo.js";
50
51
  import { verbOf } from "../dx/skeleton.js";
51
- import { createSourceFlagRefusal, fromFlagConflict, isHelpInvocation, isVersionInvocation, positionalArgs, refusedFlag, } from "./args.js";
52
+ import { createDirCollision, createSourceFlagRefusal, fromFlagConflict, isHelpInvocation, isVersionInvocation, positionalArgs, refusedFlag, } from "./args.js";
52
53
  import { parseSourceRef } from "../lib/sourceRef.js";
53
54
  import { printCreateSourceFlagRefusal, printFlagConflict, printSourceRefFailure, } from "../lib/sourceRefCopy.js";
54
55
  import { registerAfterScaffold } from "../lib/initFrom.js";
@@ -291,6 +292,34 @@ async function create() {
291
292
  process.exitCode = 1;
292
293
  return;
293
294
  }
295
+ // `--dir` says WHERE TO WORK; the positional says WHAT TO CREATE INSIDE it.
296
+ // They compose, so `--dir ~/apps/foo foo` writes `~/apps/foo/foo` — correct
297
+ // by the rules and almost never what was meant.
298
+ //
299
+ // Refused rather than silently resolved, because both readings are defensible
300
+ // and the CLI cannot tell them apart: someone who genuinely wants a nested
301
+ // `foo/foo` has a way to say so (below), and someone who does not gets the
302
+ // mistake at the moment it is cheap instead of after a scaffold and a
303
+ // registration.
304
+ //
305
+ // This fires on an EXACT match of the last path segment only. A near miss is
306
+ // left alone: guessing at intent from a similar name would refuse work that
307
+ // was never ambiguous.
308
+ const collided = createDirCollision(flag("--dir"), safe, path.resolve, path.basename);
309
+ if (collided !== undefined) {
310
+ const parent = path.dirname(collided);
311
+ print(`✗ That would create "${safe}" inside a folder already called "${safe}".`);
312
+ print("");
313
+ print(" --dir is where to work; the name is the folder to create inside it.");
314
+ print("");
315
+ print(` For ${path.join(parent, safe)}:`);
316
+ print(` vincentt create --dir ${parent} ${safe}`);
317
+ print("");
318
+ print(` For ${path.join(collided, safe)}, which is what this asked for:`);
319
+ print(` cd ${collided} && vincentt create ${safe}`);
320
+ process.exitCode = 1;
321
+ return;
322
+ }
294
323
  dir = safe;
295
324
  }
296
325
  const interactive = Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY);
@@ -367,6 +396,7 @@ async function projects() {
367
396
  return resolveOrgForCommand(cfg, { flag: flag("--org") });
368
397
  },
369
398
  list: listProjects,
399
+ store: fileLocationStore,
370
400
  }, { json: has("--json") });
371
401
  }
372
402
  /**
@@ -21,11 +21,19 @@ export const USAGE_TEXT = `Vincentt CLI — local-first AR dev loop\n\n` +
21
21
  ` [--from <owner>/<repo>@<tag>] Clone a gallery template\n` +
22
22
  ` and register it [--name <s>] [--slug <s>]\n` +
23
23
  ` create [name] Register a project and reserve its address; scaffolds first\n` +
24
- ` if the folder is empty [--name <s>] [--slug <s>]\n` +
24
+ ` if the folder is empty. The name is a NEW folder created\n` +
25
+ ` inside the working dir, not the working dir itself:\n` +
26
+ ` vincentt create my-app -> ./my-app\n` +
27
+ ` vincentt create --dir ~/code my-app -> ~/code/my-app\n` +
28
+ ` Omit the name to use the current folder as-is.\n` +
29
+ ` [--name <s>] [--slug <s>]\n` +
25
30
  ` [--org <s>] [--preview] [--force] [--no-install]\n` +
26
31
  ` link <project-id> Link this folder to an existing project\n` +
27
32
  ` unlink Disconnect this folder from its project\n` +
28
- ` projects List the workspace's projects and their ids [--json]\n` +
33
+ ` projects List the workspace's projects, their ids, and where each\n` +
34
+ ` one lives on this machine [--json]\n` +
35
+ ` A project this machine has no folder for says\n` +
36
+ ` "no folder known on this machine" rather than leaving a gap.\n` +
29
37
  ` [--org <s>] to list a workspace other than the active one\n\n` +
30
38
  `Dev loop\n` +
31
39
  ` publish Upload the built dist; go live at <slug>.vincentt.app\n` +
@@ -93,5 +101,6 @@ export const USAGE_TEXT = `Vincentt CLI — local-first AR dev loop\n\n` +
93
101
  ` disable it, and is shown on \`vincentt dx\` and\n` +
94
102
  ` \`vincentt version\` while on.\n` +
95
103
  ` dx disable Turn that off again.\n\n` +
96
- `Common options: --dir <path> (project dir, default cwd)\n` +
104
+ `Common options: --dir <path> (where to work, default cwd — for \`create\` this is\n` +
105
+ ` the PARENT of the new folder, not the folder itself)\n` +
97
106
  ` --allow-custom-api honor a binding's apiUrl override\n`;
@@ -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;
@@ -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
+ }
@@ -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";
@@ -137,8 +138,12 @@ export async function locateProjectBinding(cwd) {
137
138
  let dir = path.resolve(cwd);
138
139
  for (;;) {
139
140
  const binding = await readJsonIfExists(projectBindingPath(dir));
140
- if (binding)
141
+ if (binding) {
142
+ // The ONE success return, so every verb that resolves a binding records
143
+ // its folder without a per-verb checklist. Best-effort and synchronous.
144
+ recordLocation(binding.projectId, dir);
141
145
  return { binding, dir, path: projectBindingPath(dir) };
146
+ }
142
147
  if (await isGitRoot(dir))
143
148
  return undefined;
144
149
  const parent = path.dirname(dir);
@@ -165,6 +170,10 @@ export async function writeProjectBinding(cwd, binding) {
165
170
  const p = projectBindingPath(cwd);
166
171
  await fs.writeFile(p, JSON.stringify(binding, null, 2) + "\n", "utf8");
167
172
  await ensureGitignored(cwd, `${BINDING_DIR}/`);
173
+ // `create` and `link` WRITE a binding rather than resolve one, so they never
174
+ // pass through the resolver's hook. Between the two, every way a folder comes
175
+ // to name a project records that folder.
176
+ recordLocation(binding.projectId, path.resolve(cwd));
168
177
  return p;
169
178
  }
170
179
  /** 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;
@@ -0,0 +1,216 @@
1
+ /**
2
+ * The location store — which folder a project was last worked in, on THIS
3
+ * machine, never transmitted.
4
+ *
5
+ * The binding points folder → project. This points project → folder, so a
6
+ * project can be resumed by name from anywhere on the machine it was worked on.
7
+ * It is written as a byproduct of resolving or creating a binding: there is no
8
+ * command to run and nothing for the creator to remember.
9
+ *
10
+ * It lives beside the journal and reuses its file mechanics deliberately —
11
+ * append-only NDJSON, O_APPEND atomicity in place of a lock, skip-malformed on
12
+ * read, never-throws on write, 0700/0600 with the explicit post-write chmod, and
13
+ * the same Windows caveat (chmodSync toggles the read-only bit there and nothing
14
+ * else; config.json holds the PAT in the same directory with the same exposure,
15
+ * so hardening this file alone would imply a boundary the more valuable file
16
+ * does not have).
17
+ *
18
+ * It deviates from the journal in exactly two places, and both have the same
19
+ * reason: a lost journal line costs a slightly-wrong friction report, and a lost
20
+ * entry here costs the creator their shortcut, which is the feature.
21
+ *
22
+ * 1. Compaction happens ON READ by temp-file-plus-rename, not on every write
23
+ * by whole-file rewrite. The journal's rewrite is not atomic and two
24
+ * trimming processes can lose entries.
25
+ * 2. The writer TERMINATES DEFENSIVELY. The journal appends `entry + "\n"` and
26
+ * trusts the previous write; a process killed mid-append leaves an
27
+ * unterminated fragment, and the next append concatenates onto it, making
28
+ * one unparseable line that destroys BOTH the torn entry and a good entry
29
+ * that was never at risk.
30
+ *
31
+ * It holds a path where the journal may not (B-F7-1) for a narrow reason: the
32
+ * journal is a candidate for transmission and describes Vincentt; this file is
33
+ * never transmitted, by any command, on any schedule, in any report, on any
34
+ * failure path, and describes only the creator's own filesystem. Any future
35
+ * proposal to send it is a re-groom, not an increment.
36
+ */
37
+ import { randomBytes } from "node:crypto";
38
+ import fs from "node:fs";
39
+ import os from "node:os";
40
+ import path from "node:path";
41
+ const LOCATIONS_FILE = "locations.ndjson";
42
+ /**
43
+ * An entry is a DISTINCT FOLDER, not a run, so the bound is far smaller than the
44
+ * journal's 2000. 500 folders is beyond any real creator and holds the file
45
+ * under ~60 KB.
46
+ */
47
+ export const MAX_ENTRIES = 500;
48
+ /**
49
+ * Resolved per call rather than captured at module load — the same discipline
50
+ * and the same reason as `journalPath()`: the home directory is the seam tests
51
+ * move, and a frozen path silently writes the developer's real one.
52
+ */
53
+ export function locationsPath() {
54
+ return path.join(os.homedir(), ".vincentt", LOCATIONS_FILE);
55
+ }
56
+ function isEntry(value) {
57
+ if (typeof value !== "object" || value === null)
58
+ return false;
59
+ const e = value;
60
+ return typeof e.p === "string" && e.p !== "" && typeof e.d === "string" && e.d !== "";
61
+ }
62
+ /**
63
+ * Read oldest-first. Malformed lines are skipped, and an unreadable file yields
64
+ * an empty list — a store that FAILS is indistinguishable from a store that is
65
+ * EMPTY, which is the correct blast radius: a fresh install is exactly that
66
+ * state and is fully supported.
67
+ */
68
+ export function readLocations() {
69
+ let raw;
70
+ try {
71
+ raw = fs.readFileSync(locationsPath(), "utf8");
72
+ }
73
+ catch {
74
+ return [];
75
+ }
76
+ const out = [];
77
+ for (const line of raw.split("\n")) {
78
+ if (!line.trim())
79
+ continue;
80
+ try {
81
+ const parsed = JSON.parse(line);
82
+ if (isEntry(parsed))
83
+ out.push({ p: parsed.p, d: parsed.d, t: parsed.t ?? "" });
84
+ }
85
+ catch {
86
+ // A torn line from a killed process is not a reason to lose the rest.
87
+ }
88
+ }
89
+ return out;
90
+ }
91
+ /**
92
+ * Does the file end in a newline, or is there nothing there yet?
93
+ *
94
+ * Two syscalls against the page cache (`statSync` for the size, a one-byte read
95
+ * at `size - 1`), skipped entirely when the file is absent or empty. Writing
96
+ * `"\n" + line` unconditionally was rejected: it makes a leading blank line the
97
+ * normal case the reader must then tolerate, hiding the torn-write signal
98
+ * instead of repairing it.
99
+ */
100
+ export function needsLeadingNewline(file) {
101
+ let size;
102
+ try {
103
+ size = fs.statSync(file).size;
104
+ }
105
+ catch {
106
+ return false;
107
+ }
108
+ if (size === 0)
109
+ return false;
110
+ let fd;
111
+ try {
112
+ fd = fs.openSync(file, "r");
113
+ const buf = Buffer.alloc(1);
114
+ const read = fs.readSync(fd, buf, 0, 1, size - 1);
115
+ return read === 1 && buf[0] !== 0x0a;
116
+ }
117
+ catch {
118
+ return false;
119
+ }
120
+ finally {
121
+ if (fd !== undefined) {
122
+ try {
123
+ fs.closeSync(fd);
124
+ }
125
+ catch {
126
+ // Nothing to salvage; the append below is best-effort anyway.
127
+ }
128
+ }
129
+ }
130
+ }
131
+ /**
132
+ * Append one entry. Never throws, for the journal's stated reason:
133
+ * instrumentation that breaks the tool it measures is worse than none. Every
134
+ * failure mode here (a read-only home, a full disk, a directory where the file
135
+ * should be) is one the creator would rather have silently ignored than turned
136
+ * into a failed `publish`.
137
+ *
138
+ * `locationsPath()` is INSIDE the try because it calls `os.homedir()`, which can
139
+ * itself throw on a hostile environment.
140
+ */
141
+ export function appendLocation(entry) {
142
+ try {
143
+ const file = locationsPath();
144
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
145
+ const prefix = needsLeadingNewline(file) ? "\n" : "";
146
+ fs.appendFileSync(file, prefix + JSON.stringify(entry) + "\n", {
147
+ encoding: "utf8",
148
+ mode: 0o600,
149
+ });
150
+ // `mode` on appendFile only applies when it CREATES the file, so a store
151
+ // that predates this (or a looser umask) would keep its old perms.
152
+ fs.chmodSync(file, 0o600);
153
+ }
154
+ catch {
155
+ // A shortcut index that can fail a publish is not worth having.
156
+ }
157
+ }
158
+ /**
159
+ * Collapse duplicate `(p, d)` pairs to their newest `t`, then keep the newest
160
+ * MAX_ENTRIES. Returned oldest-first, which is the order the file holds.
161
+ */
162
+ export function compactEntries(entries) {
163
+ const newest = new Map();
164
+ for (const e of entries) {
165
+ const key = `${e.p}\0${e.d}`;
166
+ const seen = newest.get(key);
167
+ if (!seen || (e.t ?? "") >= (seen.t ?? ""))
168
+ newest.set(key, e);
169
+ }
170
+ const all = [...newest.values()].sort((a, b) => (a.t ?? "").localeCompare(b.t ?? ""));
171
+ return all.slice(-MAX_ENTRIES);
172
+ }
173
+ /**
174
+ * Rewrite the store with the survivors, ATOMICALLY: a unique temp file at 0600,
175
+ * chmod'ed explicitly, renamed over the target, and removed if the rename fails.
176
+ * The discipline `persistMachineConfig` already uses for the credential file.
177
+ *
178
+ * Rewrite-with-survivors rather than delete-lines, so a crash leaves either the
179
+ * original file or the fully compacted one and never a truncated store — which
180
+ * would be indistinguishable from an empty one BY DESIGN, and therefore
181
+ * invisible at every behavioral tier.
182
+ *
183
+ * An append landing inside the rename window is lost. That is accepted and
184
+ * self-healing (the folder that appended is a folder a verb just ran in), and it
185
+ * is the one place the no-lock choice has a visible cost.
186
+ *
187
+ * Never throws: it runs AFTER the answer has printed, and a compaction that
188
+ * fails must not delay or break it.
189
+ */
190
+ export function compactLocations(keep) {
191
+ const file = locationsPath();
192
+ const tmp = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
193
+ try {
194
+ const body = keep.map((e) => JSON.stringify(e)).join("\n");
195
+ fs.writeFileSync(tmp, body === "" ? "" : body + "\n", {
196
+ encoding: "utf8",
197
+ mode: 0o600,
198
+ });
199
+ fs.chmodSync(tmp, 0o600);
200
+ fs.renameSync(tmp, file);
201
+ }
202
+ catch {
203
+ try {
204
+ fs.rmSync(tmp, { force: true });
205
+ }
206
+ catch {
207
+ // A stray temp file is not worth failing a listing over.
208
+ }
209
+ }
210
+ }
211
+ /** The real store, reading and writing the creator's own home directory. */
212
+ export const fileLocationStore = {
213
+ read: readLocations,
214
+ append: appendLocation,
215
+ compact: compactLocations,
216
+ };
@@ -0,0 +1,33 @@
1
+ import { type LocationStore } from "./locationStore.js";
2
+ export interface RecordLocationDeps {
3
+ /** Injectable for tests; defaults to the real home-directory store. */
4
+ append?: LocationStore["append"];
5
+ /**
6
+ * Resolve a path through its symlinks. Injectable following
7
+ * `ProjectCommandDeps.realpath`, so the case-twin and symlink arms are
8
+ * testable on a Linux runner that cannot reproduce either natively.
9
+ */
10
+ realpath?: (dir: string) => string;
11
+ now?: () => Date;
12
+ }
13
+ /**
14
+ * Record that `projectId` lives in `dir`.
15
+ *
16
+ * `d` is the REALPATH-resolved directory, falling back to the unresolved path on
17
+ * failure exactly as `create` and `workingTreeKey` already do. A symlinked path,
18
+ * a macOS case-insensitive twin, or a bind mount otherwise yields two different
19
+ * `d` strings for one directory, which neither the dedup set nor compaction
20
+ * collapses — and the listing then reports one folder twice as an ambiguity that
21
+ * does not exist, asking the creator to choose between two spellings of the same
22
+ * place. Falling back rather than dropping keeps an unresolvable folder recorded.
23
+ *
24
+ * The dedup key's SHAPE is unchanged — `${projectId}\0${dir}` — but `dir` is now
25
+ * guaranteed canonical, which is what makes the key correct rather than merely
26
+ * well-formed. A folder rebound from project A to project B still legitimately
27
+ * holds two entries, because the key includes the project.
28
+ *
29
+ * Never throws.
30
+ */
31
+ export declare function recordLocation(projectId: string, dir: string, deps?: RecordLocationDeps): void;
32
+ /** Test seam only — the dedup set is process-lifetime state. */
33
+ export declare function resetLocationDedupForTest(): void;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The write hook — one binding resolution or creation becomes one store entry.
3
+ *
4
+ * It hangs off `locateProjectBinding`'s success return and off
5
+ * `writeProjectBinding`, which are the only two ways a folder comes to name a
6
+ * project. A hook per verb was rejected: it creates the class of bug where a
7
+ * newly added verb silently does not record, and one hook in the one resolver
8
+ * makes "any command that successfully resolves a binding records that folder"
9
+ * true by construction rather than by a checklist somebody has to keep current.
10
+ *
11
+ * SYNCHRONOUS on purpose, inside async callers. The CLI's top-level handler
12
+ * calls `process.exit(1)` and the feedback watch calls `process.exit(0)` on
13
+ * SIGINT; both abandon a pending async write, so an async store would lose
14
+ * exactly the runs worth recording.
15
+ */
16
+ import fs from "node:fs";
17
+ import { appendLocation } from "./locationStore.js";
18
+ /**
19
+ * Deduplicated per PROCESS, not per call. `locateProjectBinding` runs more than
20
+ * once per invocation on several paths — `publish` reaches it via
21
+ * `resolveConfig` and via its own `binding` dep — so without this the file grows
22
+ * with internal resolution calls rather than with distinct folders touched.
23
+ */
24
+ const written = new Set();
25
+ function defaultRealpath(dir) {
26
+ return fs.realpathSync(dir);
27
+ }
28
+ /**
29
+ * Record that `projectId` lives in `dir`.
30
+ *
31
+ * `d` is the REALPATH-resolved directory, falling back to the unresolved path on
32
+ * failure exactly as `create` and `workingTreeKey` already do. A symlinked path,
33
+ * a macOS case-insensitive twin, or a bind mount otherwise yields two different
34
+ * `d` strings for one directory, which neither the dedup set nor compaction
35
+ * collapses — and the listing then reports one folder twice as an ambiguity that
36
+ * does not exist, asking the creator to choose between two spellings of the same
37
+ * place. Falling back rather than dropping keeps an unresolvable folder recorded.
38
+ *
39
+ * The dedup key's SHAPE is unchanged — `${projectId}\0${dir}` — but `dir` is now
40
+ * guaranteed canonical, which is what makes the key correct rather than merely
41
+ * well-formed. A folder rebound from project A to project B still legitimately
42
+ * holds two entries, because the key includes the project.
43
+ *
44
+ * Never throws.
45
+ */
46
+ export function recordLocation(projectId, dir, deps = {}) {
47
+ try {
48
+ if (!projectId || !dir)
49
+ return;
50
+ const resolve = deps.realpath ?? defaultRealpath;
51
+ let resolved;
52
+ try {
53
+ resolved = resolve(dir);
54
+ }
55
+ catch {
56
+ resolved = dir;
57
+ }
58
+ const key = `${projectId}\0${resolved}`;
59
+ if (written.has(key))
60
+ return;
61
+ written.add(key);
62
+ const append = deps.append ?? appendLocation;
63
+ append({
64
+ p: projectId,
65
+ d: resolved,
66
+ t: (deps.now ? deps.now() : new Date()).toISOString(),
67
+ });
68
+ }
69
+ catch {
70
+ // A shortcut index must never break the command it rides on.
71
+ }
72
+ }
73
+ /** Test seam only — the dedup set is process-lifetime state. */
74
+ export function resetLocationDedupForTest() {
75
+ written.clear();
76
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincentt-xr/cli",
3
- "version": "0.12.2",
3
+ "version": "0.13.0",
4
4
  "description": "Vincentt CLI — the vincentt command-line dev loop (login, create, publish, preview, diagnostics, feedback, relay)",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",