@vincentt-xr/cli 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,129 @@
1
+ /**
2
+ * The DX journal — one line per `vincentt` invocation, on the creator's own
3
+ * machine, never transmitted.
4
+ *
5
+ * This is instrumentation without transmission. The file is the creator's, the
6
+ * way a shell history is: they can read it, delete it, or turn it off. Nothing
7
+ * here contacts the network, and nothing outside this module may make it. That
8
+ * property is what keeps the journal clear of the diagnostics-retention rule
9
+ * (B19) — there is no platform side to it.
10
+ *
11
+ * What it records is deliberately narrow: the SHAPE of a run (verb, flag names,
12
+ * exit code, duration), never its CONTENT (no flag values, no paths, no error
13
+ * messages, no output). A slug, a project id, or a token typed on a command
14
+ * line must never reach this file — see `skeleton()`.
15
+ *
16
+ * Writes are best-effort and synchronous. Best-effort because instrumentation
17
+ * that breaks the tool it measures is worse than no instrumentation; synchronous
18
+ * because the CLI's top-level handler calls `process.exit(1)`, which abandons a
19
+ * pending async write.
20
+ */
21
+ import fs from "node:fs";
22
+ import os from "node:os";
23
+ import path from "node:path";
24
+ import { createHash } from "node:crypto";
25
+ import { skeleton, verbOf } from "./skeleton.js";
26
+ const JOURNAL_FILE = "dx.ndjson";
27
+ /** Keep the journal bounded: a rolling window, trimmed on write. */
28
+ const MAX_ENTRIES = 2000;
29
+ /**
30
+ * Beside the machine config, so the journal shares the home the PAT already
31
+ * lives in — and so a run made outside any project (`login`, `version`) is still
32
+ * recorded, which a per-project file could not do.
33
+ *
34
+ * Resolved per call rather than captured at module load: the home directory is
35
+ * the seam tests move, and a frozen path would silently write to the real one.
36
+ */
37
+ export function journalPath() {
38
+ return path.join(os.homedir(), ".vincentt", JOURNAL_FILE);
39
+ }
40
+ /** `VINCENTT_DX_JOURNAL=0` turns recording off. A local file still needs an off switch. */
41
+ export function journalEnabled(env = process.env) {
42
+ return env.VINCENTT_DX_JOURNAL !== "0";
43
+ }
44
+ /**
45
+ * A stable, non-reversible handle for "the folder this run happened in". Enough
46
+ * to group runs by project — which is what makes the one-session-per-tree
47
+ * friction visible — without recording anyone's directory layout.
48
+ */
49
+ export function workingTreeId(cwd) {
50
+ return createHash("sha256").update(path.resolve(cwd)).digest("hex").slice(0, 12);
51
+ }
52
+ function classify(err) {
53
+ if (err === undefined || err === null)
54
+ return null;
55
+ return err instanceof Error ? err.name : "unknown";
56
+ }
57
+ /**
58
+ * Trim to the newest MAX_ENTRIES. Reads the whole file, which is fine at this
59
+ * bound (a few hundred KB at worst) and keeps the writer a single atomic
60
+ * rewrite rather than a partial in-place edit.
61
+ */
62
+ function trim(file) {
63
+ const lines = fs
64
+ .readFileSync(file, "utf8")
65
+ .split("\n")
66
+ .filter((l) => l.trim() !== "");
67
+ if (lines.length <= MAX_ENTRIES)
68
+ return;
69
+ fs.writeFileSync(file, lines.slice(-MAX_ENTRIES).join("\n") + "\n", "utf8");
70
+ }
71
+ export function buildEntry(input) {
72
+ return {
73
+ ts: new Date(input.startedAt).toISOString(),
74
+ v: input.cliVersion,
75
+ verb: verbOf(input.argv),
76
+ skel: skeleton(input.argv),
77
+ exit: input.exitCode,
78
+ ms: Math.max(0, input.endedAt - input.startedAt),
79
+ err: classify(input.error),
80
+ wt: workingTreeId(input.cwd),
81
+ };
82
+ }
83
+ /**
84
+ * Append one entry. Never throws: a journal that can break a publish is a
85
+ * liability, and every failure mode here (read-only home, full disk, a
86
+ * concurrent writer) is one the creator would rather have silently ignored than
87
+ * turned into a failed command.
88
+ */
89
+ export function record(input) {
90
+ if (!journalEnabled())
91
+ return;
92
+ try {
93
+ const file = journalPath();
94
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
95
+ fs.appendFileSync(file, JSON.stringify(buildEntry(input)) + "\n", {
96
+ encoding: "utf8",
97
+ mode: 0o600,
98
+ });
99
+ // `mode` on appendFile only applies when it CREATES the file, so a journal
100
+ // that predates this (or a looser umask) would keep its old perms.
101
+ fs.chmodSync(file, 0o600);
102
+ trim(file);
103
+ }
104
+ catch {
105
+ // Instrumentation must not break the tool it measures.
106
+ }
107
+ }
108
+ /** Read the journal oldest-first. Malformed lines are skipped, not fatal. */
109
+ export function readJournal() {
110
+ let raw;
111
+ try {
112
+ raw = fs.readFileSync(journalPath(), "utf8");
113
+ }
114
+ catch {
115
+ return [];
116
+ }
117
+ const out = [];
118
+ for (const line of raw.split("\n")) {
119
+ if (!line.trim())
120
+ continue;
121
+ try {
122
+ out.push(JSON.parse(line));
123
+ }
124
+ catch {
125
+ // A torn line from a killed process is not a reason to lose the rest.
126
+ }
127
+ }
128
+ return out;
129
+ }
@@ -0,0 +1,7 @@
1
+ import type { JournalEntry } from "./journal.js";
2
+ /**
3
+ * The nudge fires only on a cluster the CURRENT run just completed, so it
4
+ * appears at the moment of friction rather than as a standing complaint about
5
+ * something that happened this morning.
6
+ */
7
+ export declare function nudgeFor(entries: JournalEntry[], now: number): string[];
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The nudge: a one-line hint, printed after a command that just completed a
3
+ * friction cluster, telling the creator the report exists.
4
+ *
5
+ * It is here because a command nobody remembers produces no data. A creator
6
+ * building an AR app is not going to think "I should audit my tooling" — they
7
+ * are going to swear at the fourth preview and keep going. The moment the
8
+ * friction happens is the only moment the report is cheap to ask for.
9
+ *
10
+ * It is a HINT and never an action. Nothing is sent, nothing is queued, and the
11
+ * creator still has to type the command — which is what keeps `--send` an
12
+ * explicit act rather than a dialog they clicked through while annoyed.
13
+ */
14
+ import { findClusters } from "./clusters.js";
15
+ /** How far back a nudge looks. A cluster older than this is not "right now". */
16
+ const WINDOW_MS = 15 * 60 * 1000;
17
+ /**
18
+ * The nudge fires only on a cluster the CURRENT run just completed, so it
19
+ * appears at the moment of friction rather than as a standing complaint about
20
+ * something that happened this morning.
21
+ */
22
+ export function nudgeFor(entries, now) {
23
+ if (entries.length === 0)
24
+ return [];
25
+ const recent = entries.filter((e) => {
26
+ const t = Date.parse(e.ts);
27
+ return Number.isFinite(t) && now - t <= WINDOW_MS;
28
+ });
29
+ if (recent.length === 0)
30
+ return [];
31
+ const last = recent[recent.length - 1];
32
+ const clusters = findClusters(recent);
33
+ // Only a cluster the LAST entry belongs to. A cluster the caller already
34
+ // worked past is not friction they are feeling now, and nagging about it is
35
+ // how a hint becomes noise a reader learns to skip.
36
+ const live = clusters.find((c) => c.skeleton === last.skel && c.entries[c.entries.length - 1].ts === last.ts);
37
+ if (live === undefined)
38
+ return [];
39
+ const what = live.kind === "repetition"
40
+ ? `${live.entries.length} runs of \`${live.skeleton}\` in a few minutes.`
41
+ : `\`${live.skeleton}\` failed ${live.errors}× just now.`;
42
+ // STATES A FACT, NEVER AN INSTRUCTION.
43
+ //
44
+ // This line lands in an agent's transcript, because in the agent-driven loop
45
+ // the CLI's stdout IS the agent's input. "Run `vincentt dx --send` to report
46
+ // this" is a directive an autonomous caller will simply obey — and a report
47
+ // an agent decided to send is automation wearing consent's clothes, not the
48
+ // opt-in this feature is built around. Naming that a command exists is
49
+ // information; telling a caller to transmit is not.
50
+ //
51
+ // Note it names `vincentt dx` (which only READS, locally) and never the
52
+ // sending form.
53
+ return ["", ` ! ${what}`, " `vincentt dx` shows friction from your recent runs."];
54
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * `vincentt dx --send` — the one place anything from the journal leaves the
3
+ * machine.
4
+ *
5
+ * The journal itself is local and unconditional. Sending is neither: it happens
6
+ * only when a creator types this flag, and only after they have been shown the
7
+ * exact payload. That ordering is the whole consent model — a report is an act,
8
+ * not a background transmission — so nothing in this file may be called from a
9
+ * path the creator did not ask for.
10
+ *
11
+ * What goes: the SHAPE of the friction (skeleton, counts, timing, error class
12
+ * names) plus the CLI version and platform. What never goes: the raw journal,
13
+ * flag values, paths, error messages, or anything about which project this was.
14
+ * The server re-validates all of it — a client that leaked a value cannot
15
+ * persist it by asking — but the boundary is drawn here first, because a payload
16
+ * that needs server-side scrubbing was built wrong.
17
+ */
18
+ import type { Cluster } from "./clusters.js";
19
+ /** A finding as it goes on the wire. Mirrors the API's dxFindingRequest. */
20
+ export interface WireFinding {
21
+ kind: "error" | "repetition";
22
+ skeleton: string;
23
+ calls: number;
24
+ errors: number;
25
+ spanMs: number;
26
+ errorClasses?: string[];
27
+ note?: string;
28
+ }
29
+ export interface WireReport {
30
+ cliVersion: string;
31
+ platform: string;
32
+ findings: WireFinding[];
33
+ }
34
+ /**
35
+ * The server bounds a report at 50 findings and refuses the whole thing past
36
+ * that, so the worst clusters are sent rather than the request rejected. They
37
+ * arrive ranked, so this is a head, not a sample.
38
+ */
39
+ export declare const MAX_SENT_FINDINGS = 50;
40
+ export declare function platformTag(): string;
41
+ /**
42
+ * Build the wire payload from ranked clusters.
43
+ *
44
+ * Every field here is derived from the journal's own skeletonized entries — this
45
+ * function reads counts and timings, never values. If a future journal field
46
+ * carries content, it must not be added to this mapping without the same
47
+ * scrutiny the skeleton got.
48
+ */
49
+ export declare function buildReport(clusters: Cluster[], cliVersion: string, note?: string): WireReport;
50
+ /**
51
+ * Render the payload for the creator to read BEFORE it is sent.
52
+ *
53
+ * This is not a summary of the payload — it is the payload, formatted. A preview
54
+ * that paraphrased what would be sent would be exactly the wrong thing to show
55
+ * someone deciding whether to send it.
56
+ */
57
+ export declare function renderPayload(report: WireReport): string[];
58
+ export interface SendResult {
59
+ ref: string;
60
+ findings: number;
61
+ expiresAt: string;
62
+ }
63
+ export declare class DXSendError extends Error {
64
+ readonly status?: number | undefined;
65
+ constructor(message: string, status?: number | undefined);
66
+ }
67
+ /**
68
+ * POST the report. The caller has already shown the payload and had it accepted.
69
+ */
70
+ export declare function postReport(apiUrl: string, pat: string, report: WireReport, fetchImpl?: typeof fetch): Promise<SendResult>;
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The server bounds a report at 50 findings and refuses the whole thing past
3
+ * that, so the worst clusters are sent rather than the request rejected. They
4
+ * arrive ranked, so this is a head, not a sample.
5
+ */
6
+ export const MAX_SENT_FINDINGS = 50;
7
+ /** Error classes per finding, matching the server's bound. */
8
+ const MAX_ERROR_CLASSES = 10;
9
+ export function platformTag() {
10
+ return `${process.platform}-${process.arch}`;
11
+ }
12
+ /**
13
+ * Build the wire payload from ranked clusters.
14
+ *
15
+ * Every field here is derived from the journal's own skeletonized entries — this
16
+ * function reads counts and timings, never values. If a future journal field
17
+ * carries content, it must not be added to this mapping without the same
18
+ * scrutiny the skeleton got.
19
+ */
20
+ export function buildReport(clusters, cliVersion, note) {
21
+ const findings = clusters.slice(0, MAX_SENT_FINDINGS).map((c) => {
22
+ const classes = [
23
+ ...new Set(c.entries.map((e) => e.err).filter((e) => e !== null)),
24
+ ].slice(0, MAX_ERROR_CLASSES);
25
+ const f = {
26
+ kind: c.kind,
27
+ skeleton: c.skeleton,
28
+ calls: c.entries.length,
29
+ errors: c.errors,
30
+ spanMs: Math.max(0, Math.round(c.spanMs)),
31
+ };
32
+ if (classes.length > 0)
33
+ f.errorClasses = classes;
34
+ return f;
35
+ });
36
+ // The note rides on the first finding: it is the creator's own words about the
37
+ // friction they are reporting, and the report is about one sitting.
38
+ if (note !== undefined && note.trim() !== "" && findings.length > 0) {
39
+ findings[0].note = note.trim();
40
+ }
41
+ return { cliVersion, platform: platformTag(), findings };
42
+ }
43
+ /**
44
+ * Render the payload for the creator to read BEFORE it is sent.
45
+ *
46
+ * This is not a summary of the payload — it is the payload, formatted. A preview
47
+ * that paraphrased what would be sent would be exactly the wrong thing to show
48
+ * someone deciding whether to send it.
49
+ */
50
+ export function renderPayload(report) {
51
+ const hasNote = report.findings.some((f) => f.note !== undefined && f.note !== "");
52
+ const lines = [
53
+ "This is what would be sent:",
54
+ "",
55
+ ...JSON.stringify(report, null, 2)
56
+ .split("\n")
57
+ .map((l) => ` ${l}`),
58
+ "",
59
+ ];
60
+ // The generated fields carry shape only. The NOTE does not, and saying
61
+ // otherwise above a note that may hold a path or a token would be asking the
62
+ // creator to consent on a false claim — the one thing this screen exists to
63
+ // prevent. So the sentence changes when a note is present.
64
+ if (hasNote) {
65
+ lines.push(" The generated fields carry no paths, flag values, or error messages.", " YOUR NOTE IS SENT EXACTLY AS WRITTEN — check it for paths, URLs,", " tokens, or customer names before sending.");
66
+ }
67
+ else {
68
+ lines.push(" No paths, no flag values, no error messages, and nothing about which", " project this was.");
69
+ }
70
+ lines.push(" Your journal stays on this machine.");
71
+ return lines;
72
+ }
73
+ export class DXSendError extends Error {
74
+ status;
75
+ constructor(message, status) {
76
+ super(message);
77
+ this.status = status;
78
+ this.name = "DXSendError";
79
+ }
80
+ }
81
+ /**
82
+ * POST the report. The caller has already shown the payload and had it accepted.
83
+ */
84
+ export async function postReport(apiUrl, pat, report, fetchImpl = fetch) {
85
+ const res = await fetchImpl(`${apiUrl}/dx-reports`, {
86
+ method: "POST",
87
+ headers: { "content-type": "application/json", authorization: `Bearer ${pat}` },
88
+ body: JSON.stringify(report),
89
+ });
90
+ if (!res.ok) {
91
+ let detail = "";
92
+ try {
93
+ const body = (await res.json());
94
+ detail = body.message ?? "";
95
+ }
96
+ catch {
97
+ // A non-JSON body is not more informative than the status.
98
+ }
99
+ if (res.status === 401) {
100
+ throw new DXSendError("Your access token is no longer valid — run `vincentt login`.", 401);
101
+ }
102
+ // A 400 here means the payload this CLI built failed the server's own
103
+ // grammar. Surfaced verbatim rather than softened: it is a bug in this CLI,
104
+ // and the creator is the one who can report it.
105
+ throw new DXSendError(detail !== "" ? detail : `The report was not accepted (${res.status}).`, res.status);
106
+ }
107
+ return (await res.json());
108
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Normalize an argv into `verb --flag=<v> <v>` form — the key a retry cluster
3
+ * groups on. Two calls that differ only in their values share a skeleton, which
4
+ * is what makes "the caller ran the same thing four times" detectable.
5
+ */
6
+ export declare function skeleton(args: string[]): string;
7
+ /**
8
+ * The dispatched verb — the first bare token, skipping flags AND the values they
9
+ * consume. `vincentt --dir /tmp publish` runs publish, not `/tmp`; recording the
10
+ * path as the verb would both mis-bucket the run and leak the directory.
11
+ */
12
+ export declare function verbOf(args: string[]): string;
@@ -0,0 +1,138 @@
1
+ /**
2
+ * The journal's privacy boundary lives here: a skeleton keeps flag NAMES (which
3
+ * carry the DX signal — a caller guessing `--name` when the flag is `--slug` is
4
+ * exactly the friction worth finding) and discards every VALUE (which carries
5
+ * the risk — slugs, paths, ids, anything typed on a command line).
6
+ *
7
+ * Value elision is not cosmetic. It is what lets `vincentt dx --send` ship a
8
+ * finding without a scrubbing pass, and what keeps the on-disk journal a
9
+ * shell-history-class artifact rather than a record of the creator's work.
10
+ */
11
+ import { BOOLEAN_FLAGS } from "../cli/args.js";
12
+ const ELIDED = "<v>";
13
+ /**
14
+ * The verbs this CLI dispatches. A first bare token that is not one of these is
15
+ * not a verb — it is whatever the caller typed, and it must not be kept.
16
+ *
17
+ * Keeping it verbatim was a leak: an agent that mistypes
18
+ * `vincentt publish-acme-demo-to-prod`, fails, and retries builds a top-ranked
19
+ * friction cluster, so a customer's name ended up at the head of both the
20
+ * on-disk journal and any report built from it. Lowercase kebab-case is exactly
21
+ * what slugs and project names are made of, so no character-class check catches
22
+ * this — only a list of the verbs that actually exist.
23
+ */
24
+ const KNOWN_VERBS = new Set([
25
+ "create",
26
+ "dx",
27
+ "feedback",
28
+ "init",
29
+ "link",
30
+ "login",
31
+ "logout",
32
+ "logs",
33
+ "network",
34
+ "preview",
35
+ "publish",
36
+ "relay",
37
+ "trace",
38
+ "unlink",
39
+ "version",
40
+ "whoami",
41
+ "help",
42
+ ]);
43
+ /** Flags the CLI defines. An unknown `--flag` name is caller text, not signal. */
44
+ const KNOWN_FLAGS = new Set([
45
+ "--allow-custom-api",
46
+ "--api",
47
+ "--console",
48
+ "--days",
49
+ "--dir",
50
+ "--dist",
51
+ "--editor",
52
+ "--errors",
53
+ "--force",
54
+ "--help",
55
+ "--json",
56
+ "--limit",
57
+ "--name",
58
+ "--no-install",
59
+ "--no-qr",
60
+ "--note",
61
+ "--org",
62
+ "--port",
63
+ "--preview",
64
+ "--restart",
65
+ "--send",
66
+ "--session",
67
+ "--since",
68
+ "--slug",
69
+ "--stop",
70
+ "--template",
71
+ "--tester",
72
+ "--timeout",
73
+ "--verbose",
74
+ "--version",
75
+ "--viewer",
76
+ "--wait",
77
+ "--watch",
78
+ "--yes",
79
+ ]);
80
+ /**
81
+ * Placeholders for tokens the CLI does not recognize. Angle brackets cannot
82
+ * appear in a token the caller typed, so a caller cannot forge one.
83
+ *
84
+ * A mistyped verb still produces a finding — it clusters under `<verb>`, which
85
+ * is itself worth seeing (repeated unknown verbs mean an agent is guessing at a
86
+ * command that should exist).
87
+ */
88
+ const UNKNOWN_VERB = "<verb>";
89
+ const UNKNOWN_FLAG = "--<flag>";
90
+ /**
91
+ * Normalize an argv into `verb --flag=<v> <v>` form — the key a retry cluster
92
+ * groups on. Two calls that differ only in their values share a skeleton, which
93
+ * is what makes "the caller ran the same thing four times" detectable.
94
+ */
95
+ export function skeleton(args) {
96
+ const out = [];
97
+ for (let i = 0; i < args.length; i++) {
98
+ const a = args[i];
99
+ if (!a.startsWith("-")) {
100
+ // The first bare token is the verb — kept only when it is one we actually
101
+ // dispatch. Every later positional is a value (a slug, an id, a path).
102
+ if (out.length === 0) {
103
+ out.push(KNOWN_VERBS.has(a) ? a : UNKNOWN_VERB);
104
+ }
105
+ else {
106
+ out.push(ELIDED);
107
+ }
108
+ continue;
109
+ }
110
+ const rawName = a.split("=")[0];
111
+ const name = KNOWN_FLAGS.has(rawName) ? rawName : UNKNOWN_FLAG;
112
+ if (BOOLEAN_FLAGS.has(a)) {
113
+ out.push(name);
114
+ continue;
115
+ }
116
+ // `--flag=value` carries its value inline; `--flag value` consumes the next
117
+ // argv entry. Both collapse to the same skeleton so they cluster together.
118
+ out.push(`${name}=${ELIDED}`);
119
+ if (!a.includes("="))
120
+ i++;
121
+ }
122
+ return out.join(" ");
123
+ }
124
+ /**
125
+ * The dispatched verb — the first bare token, skipping flags AND the values they
126
+ * consume. `vincentt --dir /tmp publish` runs publish, not `/tmp`; recording the
127
+ * path as the verb would both mis-bucket the run and leak the directory.
128
+ */
129
+ export function verbOf(args) {
130
+ for (let i = 0; i < args.length; i++) {
131
+ const a = args[i];
132
+ if (!a.startsWith("-"))
133
+ return KNOWN_VERBS.has(a) ? a : UNKNOWN_VERB;
134
+ if (!BOOLEAN_FLAGS.has(a) && !a.includes("="))
135
+ i++;
136
+ }
137
+ return "";
138
+ }
@@ -5,15 +5,24 @@
5
5
  // before that re-delivers on the next call.
6
6
  import { promises as fs } from "node:fs";
7
7
  import path from "node:path";
8
- import { readRelayInfo } from "./relayInfo.js";
8
+ import { readRelayInfo, relayInfoDir } from "./relayInfo.js";
9
9
  const CURSOR_FILE = ".vincentt/annotations/offset";
10
10
  export function cursorPath(cwd) {
11
11
  return path.join(cwd, CURSOR_FILE);
12
12
  }
13
+ /**
14
+ * The cursor belongs to the tree, beside the annotations the relay writes. A
15
+ * cursor anchored on the raw cwd would restart at 0 when `vincentt feedback` is
16
+ * run from a different folder of the same project, re-delivering every
17
+ * annotation already handed off.
18
+ */
19
+ async function treeCursorPath(cwd) {
20
+ return cursorPath(await relayInfoDir(cwd));
21
+ }
13
22
  /** Last-consumed annotation seq for this project (0 if none consumed yet). */
14
23
  export async function readCursor(cwd) {
15
24
  try {
16
- const raw = await fs.readFile(cursorPath(cwd), "utf8");
25
+ const raw = await fs.readFile(await treeCursorPath(cwd), "utf8");
17
26
  return Number(raw.trim()) || 0;
18
27
  }
19
28
  catch (err) {
@@ -24,7 +33,7 @@ export async function readCursor(cwd) {
24
33
  }
25
34
  /** Advance the cursor. Called ONLY after the annotation was handed off. */
26
35
  export async function writeCursor(cwd, seq) {
27
- const p = cursorPath(cwd);
36
+ const p = await treeCursorPath(cwd);
28
37
  await fs.mkdir(path.dirname(p), { recursive: true });
29
38
  await fs.writeFile(p, `${seq}\n`, "utf8");
30
39
  }
@@ -129,19 +129,24 @@ export async function previewStart(projectCwd, onLog, extra = {}) {
129
129
  const preview = await startPreview({
130
130
  ...extra,
131
131
  projectCwd,
132
+ projectRoot: located.dir,
132
133
  config,
133
134
  projectId: located.binding.projectId,
134
135
  onLog,
135
136
  });
136
137
  activePreview = preview;
137
- activeProjectCwd = projectCwd;
138
+ // The marker is the tree's, not the folder's: `readLivePreview` resolves the
139
+ // binding the same way, so a preview started in a subdirectory is still found
140
+ // from the repo root instead of minting a second live session for one tree.
141
+ activeProjectCwd = located.dir;
138
142
  // Record the OS-assigned relay port so a separate `vincentt logs`/`feedback`
139
143
  // invocation can find this preview's relay.
140
- await writeRelayInfo(projectCwd, {
144
+ await writeRelayInfo(located.dir, {
141
145
  relayPort: preview.relayPort,
142
146
  url: preview.url,
143
147
  pid: process.pid,
144
148
  harnessPath: "/__harness",
149
+ expiresAt: preview.expiresAt,
145
150
  });
146
151
  return { preview, started: true };
147
152
  }
@@ -7,9 +7,32 @@ export interface RelayInfo {
7
7
  pid: number;
8
8
  /** WS/HTTP path prefix routed to the relay (default /__harness). */
9
9
  harnessPath: string;
10
+ /**
11
+ * When the session's hard maximum lifetime expires, ISO8601.
12
+ *
13
+ * A live marker proves the LOCAL relay is up; it does not prove the tunnel
14
+ * URL still resolves — a session can be superseded from another machine or
15
+ * hit its hard max while this process sits idle. Carrying the expiry lets a
16
+ * re-run say "this link is still good until X" instead of printing a URL it
17
+ * only assumes is live. Optional: markers written by an older CLI lack it.
18
+ */
19
+ expiresAt?: string;
10
20
  }
11
21
  export declare function relayInfoPath(cwd: string): string;
22
+ /**
23
+ * The directory a marker for this tree lives in: the binding's directory when
24
+ * there is one, else the cwd unchanged.
25
+ *
26
+ * A preview is one per WORKING TREE, and a binding is found by walking up from
27
+ * the cwd — so `apps/web/` and the repo root are the same tree and must find the
28
+ * same marker. Anchoring on the raw cwd instead made them two, and a second
29
+ * `vincentt preview` from the repo root minted a second live session for a
30
+ * project that already had one (the server could not supersede it either: its
31
+ * tree key is derived from the same path).
32
+ */
33
+ export declare function relayInfoDir(cwd: string): Promise<string>;
12
34
  export declare function writeRelayInfo(cwd: string, info: RelayInfo): Promise<string>;
35
+ /** Reads the marker for the TREE containing cwd, not only the folder itself. */
13
36
  export declare function readRelayInfo(cwd: string): Promise<RelayInfo | undefined>;
14
37
  export declare function clearRelayInfo(cwd: string): Promise<void>;
15
38
  /**
@@ -4,20 +4,38 @@
4
4
  // the gitignored .vincentt/ dir, so it never rides a commit.
5
5
  import { promises as fs } from "node:fs";
6
6
  import path from "node:path";
7
+ import { locateProjectBinding } from "../shared/config.js";
7
8
  const RELAY_INFO_FILE = "relay.json";
8
9
  const VINCENTT_DIR = ".vincentt";
9
10
  export function relayInfoPath(cwd) {
10
11
  return path.join(cwd, VINCENTT_DIR, RELAY_INFO_FILE);
11
12
  }
13
+ /**
14
+ * The directory a marker for this tree lives in: the binding's directory when
15
+ * there is one, else the cwd unchanged.
16
+ *
17
+ * A preview is one per WORKING TREE, and a binding is found by walking up from
18
+ * the cwd — so `apps/web/` and the repo root are the same tree and must find the
19
+ * same marker. Anchoring on the raw cwd instead made them two, and a second
20
+ * `vincentt preview` from the repo root minted a second live session for a
21
+ * project that already had one (the server could not supersede it either: its
22
+ * tree key is derived from the same path).
23
+ */
24
+ export async function relayInfoDir(cwd) {
25
+ const located = await locateProjectBinding(cwd);
26
+ return located?.dir ?? cwd;
27
+ }
12
28
  export async function writeRelayInfo(cwd, info) {
13
29
  const p = relayInfoPath(cwd);
14
30
  await fs.mkdir(path.dirname(p), { recursive: true });
15
31
  await fs.writeFile(p, JSON.stringify(info, null, 2) + "\n", "utf8");
16
32
  return p;
17
33
  }
34
+ /** Reads the marker for the TREE containing cwd, not only the folder itself. */
18
35
  export async function readRelayInfo(cwd) {
19
36
  try {
20
- return JSON.parse(await fs.readFile(relayInfoPath(cwd), "utf8"));
37
+ const dir = await relayInfoDir(cwd);
38
+ return JSON.parse(await fs.readFile(relayInfoPath(dir), "utf8"));
21
39
  }
22
40
  catch (err) {
23
41
  if (err.code === "ENOENT")
@@ -27,7 +45,7 @@ export async function readRelayInfo(cwd) {
27
45
  }
28
46
  export async function clearRelayInfo(cwd) {
29
47
  try {
30
- await fs.unlink(relayInfoPath(cwd));
48
+ await fs.unlink(relayInfoPath(await relayInfoDir(cwd)));
31
49
  }
32
50
  catch {
33
51
  // Already gone — clearing a non-existent info file is a no-op.