@vincentt-xr/cli 0.12.3 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -1
- package/dist/cli/index.js +43 -19
- package/dist/cli/usage-text.js +4 -1
- package/dist/dx/command.d.ts +79 -15
- package/dist/dx/command.js +173 -22
- package/dist/dx/consent.d.ts +77 -0
- package/dist/dx/consent.js +197 -0
- package/dist/dx/enablement.d.ts +35 -1
- package/dist/dx/enablement.js +56 -8
- 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/login/loopback.d.ts +72 -0
- package/dist/login/loopback.js +119 -0
- package/dist/login/pkceLogin.d.ts +1 -0
- package/dist/login/pkceLogin.js +41 -45
- package/dist/shared/config.d.ts +8 -0
- package/dist/shared/config.js +18 -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
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ACCOUNT half of unattended-send authorization.
|
|
3
|
+
*
|
|
4
|
+
* `vincentt dx enable` used to require a TTY, which dead-ends the flow this feature is
|
|
5
|
+
* actually for: a creator drives the CLI through a coding agent, the agent has no
|
|
6
|
+
* terminal, and the command refuses. Consent therefore moves to the console, where a
|
|
7
|
+
* human can actually read the disclosure and click — the CLI opens a browser and waits.
|
|
8
|
+
*
|
|
9
|
+
* The grant is PER-ACCOUNT, not per-machine: one decision covers every machine the
|
|
10
|
+
* creator signs in on. That is a wider blast radius than the local flag, which is why
|
|
11
|
+
* the console page carries the full disclosure (including that an agent may compose a
|
|
12
|
+
* note the user has not read) and why the visibility line names the console as the
|
|
13
|
+
* source — a creator who wants it off needs to know that clearing a local flag is not
|
|
14
|
+
* enough.
|
|
15
|
+
*
|
|
16
|
+
* Every call is isolated behind ConsentBackend so the command layer is unit-testable
|
|
17
|
+
* with no network.
|
|
18
|
+
*/
|
|
19
|
+
import { randomBytes } from "node:crypto";
|
|
20
|
+
import { waitForLoopbackCallback } from "../login/loopback.js";
|
|
21
|
+
import { browserPage } from "../login/pkceLogin.js";
|
|
22
|
+
import { stateMatches } from "../login/pkce.js";
|
|
23
|
+
import { getFreePort } from "../preview/net.js";
|
|
24
|
+
import { authFailureMessage } from "../shared/authError.js";
|
|
25
|
+
import { clientHeaders } from "../shared/clientHeader.js";
|
|
26
|
+
import { readAccountConsent } from "./enablement.js";
|
|
27
|
+
async function safeText(res) {
|
|
28
|
+
try {
|
|
29
|
+
return (await res.text()).slice(0, 300);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return "";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Unwrap the `meEnvelope` GET /me and both /me/dx-consent verbs return.
|
|
37
|
+
*
|
|
38
|
+
* The envelope is `{ me: {...}, csrfToken }`, but a bare user object is tolerated for the
|
|
39
|
+
* same reason normalizeMe tolerates it: a contract wording change should not silently
|
|
40
|
+
* turn a granted account into a refusal. Note the tolerance is on the SHAPE ONLY — the
|
|
41
|
+
* consent VALUE is still read `=== true`, so a resilient unwrap never becomes a lenient
|
|
42
|
+
* authorization read.
|
|
43
|
+
*/
|
|
44
|
+
function unwrapMe(raw) {
|
|
45
|
+
const envelope = (raw ?? {});
|
|
46
|
+
return envelope.me && typeof envelope.me === "object" ? envelope.me : envelope;
|
|
47
|
+
}
|
|
48
|
+
/** The real HTTP-backed ConsentBackend, pointed at a Vincentt API base URL. */
|
|
49
|
+
export function httpConsentBackend() {
|
|
50
|
+
const strip = (u) => u.replace(/\/$/, "");
|
|
51
|
+
return {
|
|
52
|
+
async read(apiUrl, pat, now) {
|
|
53
|
+
const res = await fetch(`${strip(apiUrl)}/me`, {
|
|
54
|
+
headers: { ...clientHeaders(), authorization: `Bearer ${pat}` },
|
|
55
|
+
});
|
|
56
|
+
if (!res.ok) {
|
|
57
|
+
throw new Error(authFailureMessage("Could not read your account", res.status, await safeText(res)));
|
|
58
|
+
}
|
|
59
|
+
return readAccountConsent(unwrapMe(await res.json()), now);
|
|
60
|
+
},
|
|
61
|
+
async grant(apiUrl, pat, now) {
|
|
62
|
+
const res = await fetch(`${strip(apiUrl)}/me/dx-consent`, {
|
|
63
|
+
method: "POST",
|
|
64
|
+
headers: { ...clientHeaders(), authorization: `Bearer ${pat}` },
|
|
65
|
+
});
|
|
66
|
+
if (!res.ok) {
|
|
67
|
+
throw new Error(authFailureMessage("Could not record consent", res.status, await safeText(res)));
|
|
68
|
+
}
|
|
69
|
+
return readAccountConsent(unwrapMe(await res.json()), now);
|
|
70
|
+
},
|
|
71
|
+
async revoke(apiUrl, pat) {
|
|
72
|
+
const res = await fetch(`${strip(apiUrl)}/me/dx-consent`, {
|
|
73
|
+
method: "DELETE",
|
|
74
|
+
headers: { ...clientHeaders(), authorization: `Bearer ${pat}` },
|
|
75
|
+
});
|
|
76
|
+
if (!res.ok) {
|
|
77
|
+
throw new Error(authFailureMessage("Could not revoke consent", res.status, await safeText(res)));
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The console URL the browser opens for the consent decision:
|
|
84
|
+
* `<console>/cli-dx-consent?redirect_uri=<loopback>&state=…`.
|
|
85
|
+
*
|
|
86
|
+
* Mirrors buildCliAuthUrl deliberately, including the param NAMES — the console's page
|
|
87
|
+
* reads exactly `redirect_uri` and `state`, and it validates that redirect_uri is a
|
|
88
|
+
* 127.0.0.1 loopback. No PKCE material rides here: unlike login, nothing is being
|
|
89
|
+
* redeemed. The callback carries only a decision, and `state` is what binds that
|
|
90
|
+
* decision to this CLI invocation.
|
|
91
|
+
*/
|
|
92
|
+
export function buildConsentUrl(consoleBase, redirectUri, state) {
|
|
93
|
+
const base = /^https?:\/\//i.test(consoleBase)
|
|
94
|
+
? consoleBase
|
|
95
|
+
: `${/^(localhost|127\.0\.0\.1)(:|\/|$)/i.test(consoleBase) ? "http" : "https"}://${consoleBase}`;
|
|
96
|
+
const u = new URL("/cli-dx-consent", base);
|
|
97
|
+
u.searchParams.set("redirect_uri", redirectUri);
|
|
98
|
+
u.searchParams.set("state", state);
|
|
99
|
+
return u.toString();
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Interpret the loopback callback (`?state=…&result=granted|denied`).
|
|
103
|
+
*
|
|
104
|
+
* State is verified constant-time FIRST, before `result` is even read, so a stray or
|
|
105
|
+
* forged callback can never grant consent — the same discipline as parseCallback, and
|
|
106
|
+
* for a higher-stakes decision: here the forged value would not be a code that still has
|
|
107
|
+
* to survive a PKCE exchange, it would be the answer itself.
|
|
108
|
+
*
|
|
109
|
+
* Anything that is not exactly "granted" reads as DENIED rather than as an error, so an
|
|
110
|
+
* unrecognized result can only ever fail closed.
|
|
111
|
+
*/
|
|
112
|
+
export function parseConsentCallback(params, expectedState, stateMatches) {
|
|
113
|
+
if (!stateMatches(expectedState, params.get("state"))) {
|
|
114
|
+
return {
|
|
115
|
+
ok: false,
|
|
116
|
+
reason: "state_mismatch",
|
|
117
|
+
message: "State mismatch — ignoring an unexpected callback.",
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
const result = params.get("result");
|
|
121
|
+
if (result === null) {
|
|
122
|
+
return {
|
|
123
|
+
ok: false,
|
|
124
|
+
reason: "missing_result",
|
|
125
|
+
message: "The browser returned no answer.",
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
return { ok: true, decision: result === "granted" ? "granted" : "denied" };
|
|
129
|
+
}
|
|
130
|
+
/** Thrown when the human never answers in the browser. */
|
|
131
|
+
export class ConsentTimeoutError extends Error {
|
|
132
|
+
constructor() {
|
|
133
|
+
super("Timed out waiting for an answer in the browser (5 minutes).");
|
|
134
|
+
this.name = "ConsentTimeoutError";
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Open the console's consent page and wait for the human's answer.
|
|
139
|
+
*
|
|
140
|
+
* The mechanics are the SHARED loopback helper, not a second copy of it: listener up
|
|
141
|
+
* before the browser, `state` verified constant-time on every callback, a stray callback
|
|
142
|
+
* answered but not accepted, and a bounded wait. The only thing that differs from login
|
|
143
|
+
* is what comes back — a decision instead of an auth code.
|
|
144
|
+
*
|
|
145
|
+
* Nothing is written here. This function reports what the human said; persisting it is
|
|
146
|
+
* the command layer's job, so the "denied writes nothing" rule lives in one place.
|
|
147
|
+
*/
|
|
148
|
+
export async function runConsentInBrowser(opts) {
|
|
149
|
+
// 24 bytes of CSPRNG, matching the login flow's state. This is the only thing binding
|
|
150
|
+
// the browser's answer to this invocation, so it is minted per call and never reused.
|
|
151
|
+
const state = (opts.mintState ?? defaultState)();
|
|
152
|
+
return waitForLoopbackCallback({
|
|
153
|
+
pickPort: opts.pickPort ?? getFreePort,
|
|
154
|
+
buildUrl: (redirectUri) => buildConsentUrl(opts.consoleUrl, redirectUri, state),
|
|
155
|
+
openBrowser: opts.openBrowser ?? defaultOpenBrowser,
|
|
156
|
+
timeoutMs: opts.timeoutMs ?? 5 * 60_000,
|
|
157
|
+
onTimeout: () => new ConsentTimeoutError(),
|
|
158
|
+
onProgress: opts.onProgress,
|
|
159
|
+
parse: (params) => {
|
|
160
|
+
const result = parseConsentCallback(params, state, stateMatches);
|
|
161
|
+
if (result.ok) {
|
|
162
|
+
const granted = result.decision === "granted";
|
|
163
|
+
return {
|
|
164
|
+
ok: true,
|
|
165
|
+
value: result.decision,
|
|
166
|
+
page: {
|
|
167
|
+
status: 200,
|
|
168
|
+
html: browserPage(granted, granted ? "Reports allowed" : "Not enabled", "Return to your terminal — you can close this tab."),
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
ok: false,
|
|
174
|
+
// A state mismatch keeps waiting: it is a stray or forged callback, and letting
|
|
175
|
+
// it end the flow would let anyone who can reach the loopback port cancel a
|
|
176
|
+
// legitimate consent round trip.
|
|
177
|
+
retry: result.reason === "state_mismatch",
|
|
178
|
+
message: result.message,
|
|
179
|
+
page: {
|
|
180
|
+
status: 400,
|
|
181
|
+
html: browserPage(false, "Something went wrong", result.message),
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
function defaultState() {
|
|
188
|
+
return randomBytes(24)
|
|
189
|
+
.toString("base64")
|
|
190
|
+
.replace(/\+/g, "-")
|
|
191
|
+
.replace(/\//g, "_")
|
|
192
|
+
.replace(/=+$/, "");
|
|
193
|
+
}
|
|
194
|
+
function defaultOpenBrowser(url) {
|
|
195
|
+
// Lazy import keeps this module importable in tests that never open a browser.
|
|
196
|
+
void Promise.all([import("node:child_process"), import("../login/pkceLogin.js")]).then(([{ spawn }, { launchBrowser }]) => launchBrowser(url, spawn));
|
|
197
|
+
}
|
package/dist/dx/enablement.d.ts
CHANGED
|
@@ -10,10 +10,22 @@
|
|
|
10
10
|
* raise.
|
|
11
11
|
*/
|
|
12
12
|
export declare const DX_UNATTENDED_SUNSET: number;
|
|
13
|
+
/**
|
|
14
|
+
* WHERE the grant was made. Consent can now be given in the console (per-ACCOUNT, so it
|
|
15
|
+
* covers every machine the creator signs in on) or at this terminal (per-MACHINE).
|
|
16
|
+
*
|
|
17
|
+
* The source is carried rather than inferred because the two have different blast radii
|
|
18
|
+
* and different places to revoke. A creator reading `ENABLED` on a machine they never
|
|
19
|
+
* ran `dx enable` on has no way to act on it without being told the grant lives in their
|
|
20
|
+
* account — they would go looking for a local flag that is not there.
|
|
21
|
+
*/
|
|
22
|
+
export type EnablementSource = "console" | "machine";
|
|
13
23
|
export interface Enablement {
|
|
14
24
|
enabled: boolean;
|
|
15
25
|
/** The raw ISO8601 grant time. Present only when `enabled` is true. */
|
|
16
26
|
at?: string;
|
|
27
|
+
/** Where the grant came from. Present only when `enabled` is true. */
|
|
28
|
+
source?: EnablementSource;
|
|
17
29
|
}
|
|
18
30
|
/**
|
|
19
31
|
* Decide enablement from an already-parsed config object. Pure, and STRICT:
|
|
@@ -26,6 +38,18 @@ export interface Enablement {
|
|
|
26
38
|
* that reads a creator's "off" as "on" is inverted, not merely loose.
|
|
27
39
|
*/
|
|
28
40
|
export declare function readEnablement(cfg: unknown, now: number): Enablement;
|
|
41
|
+
/**
|
|
42
|
+
* Decide enablement from the ACCOUNT's `/me` fields, with the same strictness as the
|
|
43
|
+
* local read.
|
|
44
|
+
*
|
|
45
|
+
* `=== true` is not defensive noise here either: this value crosses the wire, so it
|
|
46
|
+
* arrives as whatever JSON the server (or anything between) actually sent. A truthiness
|
|
47
|
+
* read would treat the string "false" — the exact shape a serializer bug or a hand-rolled
|
|
48
|
+
* proxy produces — as consent. The sunset applies identically: past it, an account grant
|
|
49
|
+
* stops being honored too, or "forgetting the sunset turns the feature off" would only be
|
|
50
|
+
* true of the local half.
|
|
51
|
+
*/
|
|
52
|
+
export declare function readAccountConsent(raw: unknown, now: number): Enablement;
|
|
29
53
|
/**
|
|
30
54
|
* Read enablement from the real machine config.
|
|
31
55
|
*
|
|
@@ -42,10 +66,20 @@ export declare function grantedOn(at: string): string;
|
|
|
42
66
|
/**
|
|
43
67
|
* The one visibility line, in the single pinned shape every human surface uses:
|
|
44
68
|
*
|
|
45
|
-
* Unattended send: ENABLED since 2026-04-01 (145 days)
|
|
69
|
+
* Unattended send: ENABLED since 2026-04-01 (145 days) · granted in the console
|
|
70
|
+
* Unattended send: ENABLED since 2026-04-01 (145 days) · granted on this machine
|
|
46
71
|
*
|
|
47
72
|
* Returns undefined when not enabled. Nothing is printed in that case, on
|
|
48
73
|
* purpose: a line that prints unconditionally is a line readers learn to skip,
|
|
49
74
|
* which would cost exactly the attention the enabled line needs.
|
|
75
|
+
*
|
|
76
|
+
* The AGE stays exactly where it was — it remains the entire mitigation for having no
|
|
77
|
+
* expiry, and the source is appended AFTER it rather than woven in, so the number a
|
|
78
|
+
* reader is meant to notice is not pushed off the front of the line.
|
|
79
|
+
*
|
|
80
|
+
* The source clause answers the question the age raises: having seen "145 days", the
|
|
81
|
+
* creator's next move is to turn it off, and that is a different place depending on
|
|
82
|
+
* where it was granted. A line that says ENABLED without saying where sends someone
|
|
83
|
+
* looking for a local flag that may not exist.
|
|
50
84
|
*/
|
|
51
85
|
export declare function enablementLine(e: Enablement, now: number): string | undefined;
|
package/dist/dx/enablement.js
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Durable authorization for unattended `vincentt dx --send`.
|
|
3
3
|
*
|
|
4
|
-
* `--send` transmits only when the session is interactive OR this
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* `--send` transmits only when the session is interactive, OR this MACHINE has been
|
|
5
|
+
* enabled via `vincentt dx enable`, OR the creator's ACCOUNT carries the grant they
|
|
6
|
+
* gave in the console. `enable` collects that consent in the browser by default and
|
|
7
|
+
* at the terminal when there is one; either way a person answers, never the caller.
|
|
8
|
+
*
|
|
9
|
+
* The machine half is two fields in ~/.vincentt/config.json, set by `enable`, cleared
|
|
10
|
+
* by `disable` and by `logout`. The account half is read from `/me` and cleared by
|
|
11
|
+
* `disable` or in the console — NOT by `logout`, since signing out of one machine is
|
|
12
|
+
* not a decision about the account. Neither has an expiry: both stay on until the
|
|
13
|
+
* creator turns them off.
|
|
9
14
|
*
|
|
10
15
|
* Because there is no expiry, VISIBILITY is the entire mitigation, and the AGE is
|
|
11
16
|
* the load-bearing half of it: `ENABLED` alone reads as configuration, while
|
|
@@ -50,7 +55,35 @@ export function readEnablement(cfg, now) {
|
|
|
50
55
|
return DISABLED;
|
|
51
56
|
if (now >= DX_UNATTENDED_SUNSET)
|
|
52
57
|
return DISABLED;
|
|
53
|
-
|
|
58
|
+
// The local flag is by definition a grant made on THIS machine — `dx enable` is the
|
|
59
|
+
// only writer, whether it took the browser path or the terminal one. An account grant
|
|
60
|
+
// never lands in this file; it is read from /me and reported separately.
|
|
61
|
+
return { enabled: true, at: dxSendEnabledAt, source: "machine" };
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Decide enablement from the ACCOUNT's `/me` fields, with the same strictness as the
|
|
65
|
+
* local read.
|
|
66
|
+
*
|
|
67
|
+
* `=== true` is not defensive noise here either: this value crosses the wire, so it
|
|
68
|
+
* arrives as whatever JSON the server (or anything between) actually sent. A truthiness
|
|
69
|
+
* read would treat the string "false" — the exact shape a serializer bug or a hand-rolled
|
|
70
|
+
* proxy produces — as consent. The sunset applies identically: past it, an account grant
|
|
71
|
+
* stops being honored too, or "forgetting the sunset turns the feature off" would only be
|
|
72
|
+
* true of the local half.
|
|
73
|
+
*/
|
|
74
|
+
export function readAccountConsent(raw, now) {
|
|
75
|
+
if (typeof raw !== "object" || raw === null)
|
|
76
|
+
return DISABLED;
|
|
77
|
+
const { dxReportsConsent, dxReportsConsentAt } = raw;
|
|
78
|
+
if (dxReportsConsent !== true)
|
|
79
|
+
return DISABLED;
|
|
80
|
+
if (typeof dxReportsConsentAt !== "string")
|
|
81
|
+
return DISABLED;
|
|
82
|
+
if (!Number.isFinite(Date.parse(dxReportsConsentAt)))
|
|
83
|
+
return DISABLED;
|
|
84
|
+
if (now >= DX_UNATTENDED_SUNSET)
|
|
85
|
+
return DISABLED;
|
|
86
|
+
return { enabled: true, at: dxReportsConsentAt, source: "console" };
|
|
54
87
|
}
|
|
55
88
|
/**
|
|
56
89
|
* Read enablement from the real machine config.
|
|
@@ -83,15 +116,30 @@ export function grantedOn(at) {
|
|
|
83
116
|
/**
|
|
84
117
|
* The one visibility line, in the single pinned shape every human surface uses:
|
|
85
118
|
*
|
|
86
|
-
* Unattended send: ENABLED since 2026-04-01 (145 days)
|
|
119
|
+
* Unattended send: ENABLED since 2026-04-01 (145 days) · granted in the console
|
|
120
|
+
* Unattended send: ENABLED since 2026-04-01 (145 days) · granted on this machine
|
|
87
121
|
*
|
|
88
122
|
* Returns undefined when not enabled. Nothing is printed in that case, on
|
|
89
123
|
* purpose: a line that prints unconditionally is a line readers learn to skip,
|
|
90
124
|
* which would cost exactly the attention the enabled line needs.
|
|
125
|
+
*
|
|
126
|
+
* The AGE stays exactly where it was — it remains the entire mitigation for having no
|
|
127
|
+
* expiry, and the source is appended AFTER it rather than woven in, so the number a
|
|
128
|
+
* reader is meant to notice is not pushed off the front of the line.
|
|
129
|
+
*
|
|
130
|
+
* The source clause answers the question the age raises: having seen "145 days", the
|
|
131
|
+
* creator's next move is to turn it off, and that is a different place depending on
|
|
132
|
+
* where it was granted. A line that says ENABLED without saying where sends someone
|
|
133
|
+
* looking for a local flag that may not exist.
|
|
91
134
|
*/
|
|
92
135
|
export function enablementLine(e, now) {
|
|
93
136
|
if (!e.enabled || e.at === undefined)
|
|
94
137
|
return undefined;
|
|
95
138
|
const days = ageInDays(e.at, now);
|
|
96
|
-
|
|
139
|
+
const where = e.source === "console"
|
|
140
|
+
? " · granted in the console"
|
|
141
|
+
: e.source === "machine"
|
|
142
|
+
? " · granted on this machine"
|
|
143
|
+
: "";
|
|
144
|
+
return `Unattended send: ENABLED since ${grantedOn(e.at)} (${days} ${days === 1 ? "day" : "days"})${where}`;
|
|
97
145
|
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { ProjectView } from "./backend.js";
|
|
2
2
|
import type { ResolvedConfig } from "../shared/config.js";
|
|
3
|
+
import { type LocationStore } from "../shared/locationStore.js";
|
|
4
|
+
/** The unknown case, printed rather than omitted. Repeated verbatim in the scaffolded agent contract. */
|
|
5
|
+
export declare const NO_LOCATION = "no folder known on this machine";
|
|
3
6
|
export interface ListProjectsDeps {
|
|
4
7
|
print: (line: string) => void;
|
|
5
8
|
/** The machine's API host + PAT, or undefined when nothing is signed in. */
|
|
@@ -16,6 +19,14 @@ export interface ListProjectsDeps {
|
|
|
16
19
|
message: string;
|
|
17
20
|
}>;
|
|
18
21
|
list: (cfg: ResolvedConfig, orgSlug: string) => Promise<ProjectView[]>;
|
|
22
|
+
/**
|
|
23
|
+
* The machine's location store. REQUIRED as a dep, not optional: without it
|
|
24
|
+
* this command acquires a hard dependency on the real home directory and the
|
|
25
|
+
* header's claim above stops being true.
|
|
26
|
+
*/
|
|
27
|
+
store?: LocationStore;
|
|
28
|
+
/** The verification predicate, injectable so the drop-or-report decision needs no filesystem. */
|
|
29
|
+
verify?: (dir: string, projectId: string) => boolean;
|
|
19
30
|
}
|
|
20
31
|
export interface ListProjectsOptions {
|
|
21
32
|
json?: boolean;
|
|
@@ -38,4 +49,4 @@ export declare function listProjectsCommand(deps: ListProjectsDeps, opts?: ListP
|
|
|
38
49
|
* so a client that assembled `<slug>.vincentt.app` itself would print the wrong
|
|
39
50
|
* address on staging and dev.
|
|
40
51
|
*/
|
|
41
|
-
export declare function projectRows(projects: ProjectView[]): string[];
|
|
52
|
+
export declare function projectRows(projects: ProjectView[], locations?: Map<string, string[]>): string[];
|
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;
|