@vincentt-xr/cli 0.12.3 → 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 +20 -1
- package/dist/cli/index.js +2 -0
- package/dist/cli/usage-text.js +4 -1
- package/dist/lib/listProjects.d.ts +12 -1
- package/dist/lib/listProjects.js +99 -8
- package/dist/lib/locations.d.ts +59 -0
- package/dist/lib/locations.js +117 -0
- package/dist/shared/config.js +10 -1
- package/dist/shared/locationStore.d.ts +82 -0
- package/dist/shared/locationStore.js +216 -0
- package/dist/shared/recordLocation.d.ts +33 -0
- package/dist/shared/recordLocation.js +76 -0
- package/package.json +1 -1
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
|
|
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.
|
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";
|
|
@@ -395,6 +396,7 @@ async function projects() {
|
|
|
395
396
|
return resolveOrgForCommand(cfg, { flag: flag("--org") });
|
|
396
397
|
},
|
|
397
398
|
list: listProjects,
|
|
399
|
+
store: fileLocationStore,
|
|
398
400
|
}, { json: has("--json") });
|
|
399
401
|
}
|
|
400
402
|
/**
|
package/dist/cli/usage-text.js
CHANGED
|
@@ -30,7 +30,10 @@ export const USAGE_TEXT = `Vincentt CLI — local-first AR dev loop\n\n` +
|
|
|
30
30
|
` [--org <s>] [--preview] [--force] [--no-install]\n` +
|
|
31
31
|
` link <project-id> Link this folder to an existing project\n` +
|
|
32
32
|
` unlink Disconnect this folder from its project\n` +
|
|
33
|
-
` projects List the workspace's projects and
|
|
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` +
|
|
34
37
|
` [--org <s>] to list a workspace other than the active one\n\n` +
|
|
35
38
|
`Dev loop\n` +
|
|
36
39
|
` publish Upload the built dist; go live at <slug>.vincentt.app\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[];
|
package/dist/lib/listProjects.js
CHANGED
|
@@ -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
|
-
//
|
|
14
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
144
|
+
const out = [];
|
|
145
|
+
for (const p of projects) {
|
|
74
146
|
const state = p.publishState === "live" && p.liveUrl ? p.liveUrl : p.publishState;
|
|
75
|
-
|
|
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
|
+
}
|
package/dist/shared/config.js
CHANGED
|
@@ -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