@vincentt-xr/cli 0.1.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.
Files changed (47) hide show
  1. package/dist/cli/deprecated/harness-mcp.d.ts +2 -0
  2. package/dist/cli/deprecated/harness-mcp.js +7 -0
  3. package/dist/cli/deprecated/harness-relay.d.ts +2 -0
  4. package/dist/cli/deprecated/harness-relay.js +7 -0
  5. package/dist/cli/deprecated/harness.d.ts +2 -0
  6. package/dist/cli/deprecated/harness.js +7 -0
  7. package/dist/cli/index.d.ts +2 -0
  8. package/dist/cli/index.js +243 -0
  9. package/dist/lib/backend.d.ts +61 -0
  10. package/dist/lib/backend.js +155 -0
  11. package/dist/lib/diagnostics.d.ts +13 -0
  12. package/dist/lib/diagnostics.js +61 -0
  13. package/dist/lib/feedback.d.ts +15 -0
  14. package/dist/lib/feedback.js +58 -0
  15. package/dist/lib/lifecycle.d.ts +45 -0
  16. package/dist/lib/lifecycle.js +140 -0
  17. package/dist/lib/relayInfo.d.ts +14 -0
  18. package/dist/lib/relayInfo.js +35 -0
  19. package/dist/login/login.d.ts +34 -0
  20. package/dist/login/login.js +148 -0
  21. package/dist/mcp/server.d.ts +16 -0
  22. package/dist/mcp/server.js +188 -0
  23. package/dist/preview/cloudflared.d.ts +13 -0
  24. package/dist/preview/cloudflared.js +46 -0
  25. package/dist/preview/index.d.ts +3 -0
  26. package/dist/preview/index.js +6 -0
  27. package/dist/preview/net.d.ts +6 -0
  28. package/dist/preview/net.js +56 -0
  29. package/dist/preview/proxy.d.ts +4 -0
  30. package/dist/preview/proxy.js +49 -0
  31. package/dist/preview/runner.d.ts +45 -0
  32. package/dist/preview/runner.js +111 -0
  33. package/dist/preview/tunnel.d.ts +14 -0
  34. package/dist/preview/tunnel.js +28 -0
  35. package/dist/relay/annotations.d.ts +30 -0
  36. package/dist/relay/annotations.js +126 -0
  37. package/dist/relay/injectors.d.ts +29 -0
  38. package/dist/relay/injectors.js +72 -0
  39. package/dist/relay/server.d.ts +24 -0
  40. package/dist/relay/server.js +186 -0
  41. package/dist/relay/store.d.ts +13 -0
  42. package/dist/relay/store.js +68 -0
  43. package/dist/scaffold/index.d.ts +26 -0
  44. package/dist/scaffold/index.js +85 -0
  45. package/dist/shared/config.d.ts +47 -0
  46. package/dist/shared/config.js +105 -0
  47. package/package.json +41 -0
@@ -0,0 +1,186 @@
1
+ // The relay: a WebSocket + HTTP server that sits beside Vite. The phone's
2
+ // harness client pushes diag event batches over WS and POSTs annotations over the
3
+ // tunnel; the MCP server / CLI pull diagnostics via a localhost HTTP endpoint and
4
+ // long-poll for annotations. Deliberately tiny — buffering/query logic is in
5
+ // store.ts, the reverse-channel store in annotations.ts. One relay per preview run.
6
+ import { createServer } from "node:http";
7
+ import { randomBytes } from "node:crypto";
8
+ import path from "node:path";
9
+ import { WebSocketServer } from "ws";
10
+ import { EventStore } from "./store.js";
11
+ import { AnnotationStore, annotationsDir } from "./annotations.js";
12
+ import { claudeCodeInjector, createDispatcher, pasteInjector, } from "./injectors.js";
13
+ /** Cap an annotation POST body so a bad/huge upload can't exhaust memory. */
14
+ const MAX_ANNOTATION_BYTES = 25 * 1024 * 1024;
15
+ /** Clamp the long-poll hold so a request can't park forever. */
16
+ const MAX_FEEDBACK_TIMEOUT_MS = 600_000;
17
+ export function startRelay(opts = {}) {
18
+ const port = opts.port ?? 7331;
19
+ const wsPath = opts.path ?? "/__harness";
20
+ const store = new EventStore(opts.capacity);
21
+ const log = opts.onLog ?? (() => undefined);
22
+ const makeId = opts.makeId ?? (() => randomBytes(3).toString("hex"));
23
+ // Reverse channel is enabled when we know which project to bind annotations to.
24
+ let annotations;
25
+ let dispatcher;
26
+ if (opts.projectDir || opts.annotationStore) {
27
+ annotations =
28
+ opts.annotationStore ?? new AnnotationStore(opts.projectDir);
29
+ const queuePath = path.join(opts.projectDir ? annotationsDir(opts.projectDir) : ".", "queue.ndjson");
30
+ dispatcher =
31
+ opts.dispatcher ??
32
+ createDispatcher([
33
+ claudeCodeInjector(queuePath),
34
+ pasteInjector((p) => log(`annotation ready to paste:\n${p}`)),
35
+ ], log);
36
+ }
37
+ const http = createServer((req, res) => handleHttp(req, res, { store, annotations, dispatcher, wsPath, makeId, log }));
38
+ const wss = new WebSocketServer({ server: http, path: wsPath });
39
+ wss.on("connection", (ws) => {
40
+ log("client connected");
41
+ ws.on("message", (data) => {
42
+ try {
43
+ const msg = JSON.parse(String(data));
44
+ if (msg.type === "events" && Array.isArray(msg.events)) {
45
+ store.ingest(msg.sessionId, msg.events);
46
+ }
47
+ }
48
+ catch {
49
+ // ignore malformed frames — a bad client must not crash the relay
50
+ }
51
+ });
52
+ ws.on("close", () => log("client disconnected"));
53
+ });
54
+ http.listen(port, () => log(`relay listening on :${port} (ws ${wsPath}, http /query)`));
55
+ return {
56
+ store,
57
+ annotations,
58
+ close: () => new Promise((resolve) => {
59
+ annotations?.close();
60
+ wss.close();
61
+ http.close(() => resolve());
62
+ }),
63
+ };
64
+ }
65
+ function handleHttp(req, res, ctx) {
66
+ const url = new URL(req.url ?? "/", "http://localhost");
67
+ const p = url.pathname;
68
+ if (p === "/query") {
69
+ handleQuery(req, res, url, ctx);
70
+ return;
71
+ }
72
+ if (p === `${ctx.wsPath}/annotation`) {
73
+ handleAnnotationPost(req, res, ctx);
74
+ return;
75
+ }
76
+ if (p === `${ctx.wsPath}/feedback/next`) {
77
+ handleFeedbackNext(res, url, ctx);
78
+ return;
79
+ }
80
+ res.writeHead(404).end();
81
+ }
82
+ // The MCP server / CLI (same laptop) GETs/POSTs /query with a RelayQuery. Localhost
83
+ // only; no auth in the local dev loop (the platform phase adds it).
84
+ function handleQuery(req, res, url, ctx) {
85
+ readQuery(req, url)
86
+ .then((q) => {
87
+ const result = ctx.store.query(q);
88
+ res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(result));
89
+ })
90
+ .catch((err) => {
91
+ ctx.log(`query error: ${err}`);
92
+ res.writeHead(400).end();
93
+ });
94
+ }
95
+ // The device POSTs an annotation over the tunnel (front proxy routes wsPath/* here).
96
+ function handleAnnotationPost(req, res, ctx) {
97
+ if (!ctx.annotations) {
98
+ res.writeHead(503).end("reverse channel not enabled");
99
+ return;
100
+ }
101
+ readBody(req, MAX_ANNOTATION_BYTES)
102
+ .then(async (body) => {
103
+ const input = JSON.parse(body);
104
+ if (!input || typeof input.message !== "string" || typeof input.screenshot !== "string") {
105
+ res.writeHead(400).end("annotation requires message + screenshot");
106
+ return;
107
+ }
108
+ const annotation = await ctx.annotations.add(input, {
109
+ now: Date.now(),
110
+ id: ctx.makeId(),
111
+ });
112
+ // Fire the injector chain best-effort — a failed dispatch must not fail the
113
+ // POST; the long-poll consumer still sees the persisted annotation.
114
+ if (ctx.dispatcher) {
115
+ ctx.dispatcher.dispatch(annotation).catch((err) => ctx.log(`dispatch: ${err}`));
116
+ }
117
+ res
118
+ .writeHead(200, { "content-type": "application/json" })
119
+ .end(JSON.stringify({ id: annotation.id, seq: annotation.seq }));
120
+ })
121
+ .catch((err) => {
122
+ ctx.log(`annotation error: ${err}`);
123
+ if (!res.headersSent)
124
+ res.writeHead(400).end();
125
+ });
126
+ }
127
+ // The agnostic consumer (`vincentt feedback --wait`) long-polls this: return the
128
+ // next annotation after `after`, or 204 when the hold window elapses.
129
+ function handleFeedbackNext(res, url, ctx) {
130
+ if (!ctx.annotations) {
131
+ res.writeHead(503).end("reverse channel not enabled");
132
+ return;
133
+ }
134
+ const after = Number(url.searchParams.get("after") ?? "0") || 0;
135
+ const timeoutMs = Math.min(Number(url.searchParams.get("timeout") ?? "300000") || 300_000, MAX_FEEDBACK_TIMEOUT_MS);
136
+ ctx.annotations
137
+ .nextAfter(after, timeoutMs)
138
+ .then((annotation) => {
139
+ if (!annotation) {
140
+ res.writeHead(204).end();
141
+ return;
142
+ }
143
+ res
144
+ .writeHead(200, { "content-type": "application/json" })
145
+ .end(JSON.stringify(annotation));
146
+ })
147
+ .catch((err) => {
148
+ ctx.log(`feedback error: ${err}`);
149
+ if (!res.headersSent)
150
+ res.writeHead(400).end();
151
+ });
152
+ }
153
+ async function readQuery(req, url) {
154
+ if (req.method === "POST") {
155
+ const body = await readBody(req);
156
+ return body ? JSON.parse(body) : {};
157
+ }
158
+ const q = {};
159
+ const p = url.searchParams;
160
+ if (p.get("sessionId"))
161
+ q.sessionId = p.get("sessionId");
162
+ if (p.get("kind"))
163
+ q.kind = p.get("kind");
164
+ if (p.get("since"))
165
+ q.since = Number(p.get("since"));
166
+ if (p.get("limit"))
167
+ q.limit = Number(p.get("limit"));
168
+ return q;
169
+ }
170
+ function readBody(req, maxBytes = Infinity) {
171
+ return new Promise((resolve, reject) => {
172
+ let data = "";
173
+ let bytes = 0;
174
+ req.on("data", (c) => {
175
+ bytes += c.length;
176
+ if (bytes > maxBytes) {
177
+ reject(new Error("request body too large"));
178
+ req.destroy();
179
+ return;
180
+ }
181
+ data += c;
182
+ });
183
+ req.on("end", () => resolve(data));
184
+ req.on("error", reject);
185
+ });
186
+ }
@@ -0,0 +1,13 @@
1
+ import type { DiagEvent, RelayQuery, RelayResult } from "@vincentt-xr/harness/events";
2
+ export declare class EventStore {
3
+ private readonly capacity;
4
+ private readonly buf;
5
+ private readonly sessionsSeen;
6
+ private latest;
7
+ constructor(capacity?: number);
8
+ /** Ingest a batch from a client. Tracks the session and advances latestSeq. */
9
+ ingest(sessionId: string, events: DiagEvent[]): void;
10
+ /** Answer an MCP query against the buffer. Newest-biased when limited. */
11
+ query(q?: RelayQuery): RelayResult;
12
+ size(): number;
13
+ }
@@ -0,0 +1,68 @@
1
+ // The relay's memory: a bounded ring buffer of recent diagnostics events,
2
+ // queryable the way the MCP server asks ("network events since seq 40", "last
3
+ // 20 errors"). Pure and dependency-free so it is fully unit-testable; the WS
4
+ // server in server.ts is a thin wrapper that feeds this and answers queries
5
+ // from it.
6
+ export class EventStore {
7
+ capacity;
8
+ buf = [];
9
+ sessionsSeen = new Set();
10
+ latest = -1;
11
+ constructor(capacity = 5000) {
12
+ this.capacity = capacity;
13
+ }
14
+ /** Ingest a batch from a client. Tracks the session and advances latestSeq. */
15
+ ingest(sessionId, events) {
16
+ this.sessionsSeen.add(sessionId);
17
+ for (const e of events) {
18
+ // Tag ownership on the stored copy so cross-session queries can filter.
19
+ this.buf.push(withSession(e, sessionId));
20
+ if (e.seq > this.latest)
21
+ this.latest = e.seq;
22
+ }
23
+ // Ring: drop oldest beyond capacity.
24
+ if (this.buf.length > this.capacity) {
25
+ this.buf.splice(0, this.buf.length - this.capacity);
26
+ }
27
+ }
28
+ /** Answer an MCP query against the buffer. Newest-biased when limited. */
29
+ query(q = {}) {
30
+ let out = this.buf.filter((e) => {
31
+ if (q.sessionId && sessionOf(e) !== q.sessionId)
32
+ return false;
33
+ if (q.kind && e.kind !== q.kind)
34
+ return false;
35
+ if (q.since !== undefined && e.seq <= q.since)
36
+ return false;
37
+ return true;
38
+ });
39
+ if (q.limit !== undefined && out.length > q.limit) {
40
+ out = out.slice(out.length - q.limit); // keep the newest `limit`
41
+ }
42
+ return {
43
+ events: out.map(stripSession),
44
+ latestSeq: this.latest,
45
+ sessions: [...this.sessionsSeen],
46
+ };
47
+ }
48
+ size() {
49
+ return this.buf.length;
50
+ }
51
+ }
52
+ // The store tags each event with its owning session out-of-band (a symbol key)
53
+ // so it survives in the buffer without polluting the wire type. Queries strip
54
+ // it before returning.
55
+ const SESSION = Symbol("session");
56
+ function withSession(e, sessionId) {
57
+ return Object.assign(Object.create(Object.getPrototypeOf(e)), e, {
58
+ [SESSION]: sessionId,
59
+ });
60
+ }
61
+ function sessionOf(e) {
62
+ return e[SESSION];
63
+ }
64
+ function stripSession(e) {
65
+ const copy = { ...e };
66
+ delete copy[SESSION];
67
+ return copy;
68
+ }
@@ -0,0 +1,26 @@
1
+ export declare const TEMPLATE_REPO = "https://github.com/vincentt-xr/v2-template.git";
2
+ /** Injectable git runner (real git by default; a fake in tests avoids the network). */
3
+ export type GitRunner = (args: string[]) => Promise<void>;
4
+ /**
5
+ * True when `dir` has no app yet (no package.json) → safe to scaffold into.
6
+ * A directory that already holds an app is left untouched (project_create just binds).
7
+ */
8
+ export declare function needsScaffold(dir: string): Promise<boolean>;
9
+ export interface ScaffoldOptions {
10
+ repo?: string;
11
+ /** Test seam — defaults to spawning real git. */
12
+ runGit?: GitRunner;
13
+ }
14
+ /**
15
+ * Scaffold the template into `targetDir`. Clones to a temp dir, strips `.git`,
16
+ * copies the files in (never overwriting anything already there), then inits a
17
+ * fresh repo. Throws a clear error if the clone fails (e.g. no git access).
18
+ */
19
+ export declare function scaffoldFromTemplate(targetDir: string, opts?: ScaffoldOptions): Promise<void>;
20
+ /**
21
+ * Install a freshly scaffolded project's dependencies so the first preview/build
22
+ * doesn't fail on a missing node_modules. Throws on failure (no network, no npm);
23
+ * the caller surfaces it as a "run npm install yourself" hint rather than aborting
24
+ * project creation.
25
+ */
26
+ export declare function installDependencies(dir: string): Promise<void>;
@@ -0,0 +1,85 @@
1
+ // Scaffold a new app from the v2-template GitHub template as part of
2
+ // project_create. Shallow-clones the template, drops its git history, copies the
3
+ // files into the target, and inits a fresh repo the creator owns — the local-first
4
+ // equivalent of GitHub's "Use this template". Node-only (spawns git); not imported
5
+ // by browser consumers.
6
+ import { execFile } from "node:child_process";
7
+ import { promises as fs } from "node:fs";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+ import { promisify } from "node:util";
11
+ const execFileAsync = promisify(execFile);
12
+ export const TEMPLATE_REPO = "https://github.com/vincentt-xr/v2-template.git";
13
+ const realGit = async (args) => {
14
+ await execFileAsync("git", args);
15
+ };
16
+ /**
17
+ * True when `dir` has no app yet (no package.json) → safe to scaffold into.
18
+ * A directory that already holds an app is left untouched (project_create just binds).
19
+ */
20
+ export async function needsScaffold(dir) {
21
+ try {
22
+ await fs.access(path.join(dir, "package.json"));
23
+ return false;
24
+ }
25
+ catch {
26
+ return true;
27
+ }
28
+ }
29
+ /**
30
+ * Scaffold the template into `targetDir`. Clones to a temp dir, strips `.git`,
31
+ * copies the files in (never overwriting anything already there), then inits a
32
+ * fresh repo. Throws a clear error if the clone fails (e.g. no git access).
33
+ */
34
+ export async function scaffoldFromTemplate(targetDir, opts = {}) {
35
+ const repo = opts.repo ?? TEMPLATE_REPO;
36
+ const runGit = opts.runGit ?? realGit;
37
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "vincentt-tmpl-"));
38
+ try {
39
+ try {
40
+ await runGit(["clone", "--depth", "1", repo, tmp]);
41
+ }
42
+ catch (err) {
43
+ const msg = err instanceof Error ? err.message : String(err);
44
+ throw new Error(`Could not clone the ${repo} template (check your git access): ${msg}`);
45
+ }
46
+ await fs.rm(path.join(tmp, ".git"), { recursive: true, force: true });
47
+ // Copy template contents in without clobbering anything already in the target.
48
+ for (const entry of await fs.readdir(tmp)) {
49
+ await fs.cp(path.join(tmp, entry), path.join(targetDir, entry), {
50
+ recursive: true,
51
+ force: false,
52
+ errorOnExist: false,
53
+ });
54
+ }
55
+ // Fresh history the creator owns (skip if the target is already a repo).
56
+ if (!(await isGitRepo(targetDir))) {
57
+ await runGit(["-C", targetDir, "init"]);
58
+ }
59
+ }
60
+ finally {
61
+ await fs.rm(tmp, { recursive: true, force: true });
62
+ }
63
+ }
64
+ async function isGitRepo(dir) {
65
+ try {
66
+ await execFileAsync("git", ["-C", dir, "rev-parse", "--git-dir"]);
67
+ return true;
68
+ }
69
+ catch {
70
+ return false;
71
+ }
72
+ }
73
+ /**
74
+ * Install a freshly scaffolded project's dependencies so the first preview/build
75
+ * doesn't fail on a missing node_modules. Throws on failure (no network, no npm);
76
+ * the caller surfaces it as a "run npm install yourself" hint rather than aborting
77
+ * project creation.
78
+ */
79
+ export async function installDependencies(dir) {
80
+ // Windows: npm is npm.cmd — execFile needs a shell to resolve it.
81
+ await execFileAsync("npm", ["install"], {
82
+ cwd: dir,
83
+ shell: process.platform === "win32",
84
+ });
85
+ }
@@ -0,0 +1,47 @@
1
+ export interface MachineConfig {
2
+ /** Base URL of the Vincentt backend API (e.g. https://api.vincentt.studio). */
3
+ apiUrl: string;
4
+ /** Personal access token (bearer) for machine auth. */
5
+ pat: string;
6
+ }
7
+ export interface ProjectBinding {
8
+ /** The backend Project id this working tree publishes to. */
9
+ projectId: string;
10
+ /** The project's slug — its <slug>.<apex> host. Informational (server-authoritative). */
11
+ slug: string;
12
+ /** Optional per-project API override; else the machine config's apiUrl. */
13
+ apiUrl?: string;
14
+ }
15
+ export declare const MACHINE_CONFIG_PATH: string;
16
+ export declare function projectBindingPath(cwd: string): string;
17
+ export declare function loadMachineConfig(): Promise<Partial<MachineConfig>>;
18
+ /**
19
+ * Merge a patch into ~/.vincentt/config.json (used by `harness login` to persist
20
+ * a freshly minted PAT). Preserves any other fields already present. The file
21
+ * holds a bearer credential, so the dir/file get owner-only perms.
22
+ */
23
+ export declare function writeMachineConfig(patch: Partial<MachineConfig>): Promise<string>;
24
+ /**
25
+ * Remove the stored PAT from ~/.vincentt/config.json (used by `vincentt logout`),
26
+ * preserving apiUrl and any other fields. Returns whether a token was present.
27
+ */
28
+ export declare function clearMachineToken(): Promise<{
29
+ path: string;
30
+ hadToken: boolean;
31
+ }>;
32
+ export declare function loadProjectBinding(cwd: string): Promise<ProjectBinding | undefined>;
33
+ /**
34
+ * Write the per-tree binding and make sure `.vincentt/` is gitignored (so the
35
+ * secret-free-but-tenant-scoped binding never rides a commit or `git archive`).
36
+ */
37
+ export declare function writeProjectBinding(cwd: string, binding: ProjectBinding): Promise<string>;
38
+ export interface ResolvedConfig {
39
+ apiUrl: string;
40
+ pat: string;
41
+ }
42
+ /**
43
+ * Resolve the API URL + PAT for a machine call. Precedence: a project binding's
44
+ * apiUrl wins for the URL; env vars override the machine-config file; the file is
45
+ * the base. Throws a clear, actionable error when no PAT is configured.
46
+ */
47
+ export declare function resolveConfig(cwd: string): Promise<ResolvedConfig>;
@@ -0,0 +1,105 @@
1
+ // Config the lifecycle MCP verbs (project_create / project_publish) read to reach
2
+ // a creator's Vincentt backend. Two homes, by sensitivity:
3
+ // • ~/.vincentt/config.json — machine-global { apiUrl, pat }. The PAT is a
4
+ // bearer credential, so it lives OUTSIDE any repo (never committed).
5
+ // • <project>/.vincentt/project.json — the per-tree binding { projectId, slug }.
6
+ // Gitignored: `git archive HEAD` (the remix path) must NOT carry it, or a
7
+ // remixer would inherit — and publish over — the original's project.
8
+ // Env vars (VINCENTT_API_URL / VINCENTT_PAT) override the file for local/dogfood
9
+ // runs so a creator can point at a dev backend without editing the machine config.
10
+ import { promises as fs } from "node:fs";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+ export const MACHINE_CONFIG_PATH = path.join(os.homedir(), ".vincentt", "config.json");
14
+ const BINDING_DIR = ".vincentt";
15
+ const BINDING_FILE = "project.json";
16
+ export function projectBindingPath(cwd) {
17
+ return path.join(cwd, BINDING_DIR, BINDING_FILE);
18
+ }
19
+ async function readJsonIfExists(p) {
20
+ try {
21
+ return JSON.parse(await fs.readFile(p, "utf8"));
22
+ }
23
+ catch (err) {
24
+ if (err.code === "ENOENT")
25
+ return undefined;
26
+ throw err;
27
+ }
28
+ }
29
+ export async function loadMachineConfig() {
30
+ return (await readJsonIfExists(MACHINE_CONFIG_PATH)) ?? {};
31
+ }
32
+ /**
33
+ * Merge a patch into ~/.vincentt/config.json (used by `harness login` to persist
34
+ * a freshly minted PAT). Preserves any other fields already present. The file
35
+ * holds a bearer credential, so the dir/file get owner-only perms.
36
+ */
37
+ export async function writeMachineConfig(patch) {
38
+ const next = { ...(await loadMachineConfig()), ...patch };
39
+ await fs.mkdir(path.dirname(MACHINE_CONFIG_PATH), { recursive: true, mode: 0o700 });
40
+ await fs.writeFile(MACHINE_CONFIG_PATH, JSON.stringify(next, null, 2) + "\n", {
41
+ encoding: "utf8",
42
+ mode: 0o600,
43
+ });
44
+ return MACHINE_CONFIG_PATH;
45
+ }
46
+ /**
47
+ * Remove the stored PAT from ~/.vincentt/config.json (used by `vincentt logout`),
48
+ * preserving apiUrl and any other fields. Returns whether a token was present.
49
+ */
50
+ export async function clearMachineToken() {
51
+ const current = await loadMachineConfig();
52
+ const hadToken = Boolean(current.pat);
53
+ delete current.pat;
54
+ await fs.mkdir(path.dirname(MACHINE_CONFIG_PATH), { recursive: true, mode: 0o700 });
55
+ await fs.writeFile(MACHINE_CONFIG_PATH, JSON.stringify(current, null, 2) + "\n", {
56
+ encoding: "utf8",
57
+ mode: 0o600,
58
+ });
59
+ return { path: MACHINE_CONFIG_PATH, hadToken };
60
+ }
61
+ export async function loadProjectBinding(cwd) {
62
+ return readJsonIfExists(projectBindingPath(cwd));
63
+ }
64
+ /**
65
+ * Write the per-tree binding and make sure `.vincentt/` is gitignored (so the
66
+ * secret-free-but-tenant-scoped binding never rides a commit or `git archive`).
67
+ */
68
+ export async function writeProjectBinding(cwd, binding) {
69
+ await fs.mkdir(path.join(cwd, BINDING_DIR), { recursive: true });
70
+ const p = projectBindingPath(cwd);
71
+ await fs.writeFile(p, JSON.stringify(binding, null, 2) + "\n", "utf8");
72
+ await ensureGitignored(cwd, `${BINDING_DIR}/`);
73
+ return p;
74
+ }
75
+ /** Append a pattern to the repo's .gitignore if not already present (best-effort). */
76
+ async function ensureGitignored(cwd, pattern) {
77
+ const gitignore = path.join(cwd, ".gitignore");
78
+ try {
79
+ const current = await fs.readFile(gitignore, "utf8").catch(() => "");
80
+ const has = current.split(/\r?\n/).some((l) => l.trim() === pattern.trim());
81
+ if (has)
82
+ return;
83
+ const next = current && !current.endsWith("\n") ? `${current}\n${pattern}\n` : `${current}${pattern}\n`;
84
+ await fs.writeFile(gitignore, next, "utf8");
85
+ }
86
+ catch {
87
+ // A missing/unwritable .gitignore must not fail project creation.
88
+ }
89
+ }
90
+ /**
91
+ * Resolve the API URL + PAT for a machine call. Precedence: a project binding's
92
+ * apiUrl wins for the URL; env vars override the machine-config file; the file is
93
+ * the base. Throws a clear, actionable error when no PAT is configured.
94
+ */
95
+ export async function resolveConfig(cwd) {
96
+ const machine = await loadMachineConfig();
97
+ const binding = await loadProjectBinding(cwd);
98
+ const apiUrl = binding?.apiUrl ?? process.env.VINCENTT_API_URL ?? machine.apiUrl ?? "http://localhost:5051";
99
+ const pat = process.env.VINCENTT_PAT ?? machine.pat;
100
+ if (!pat) {
101
+ throw new Error(`No Vincentt access token found. Add {"apiUrl":"…","pat":"…"} to ${MACHINE_CONFIG_PATH}, ` +
102
+ `or set the VINCENTT_PAT env var.`);
103
+ }
104
+ return { apiUrl: apiUrl.replace(/\/$/, ""), pat };
105
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@vincentt-xr/cli",
3
+ "version": "0.1.0",
4
+ "description": "Vincentt CLI — the vincentt command-line dev loop (login, create, publish, preview, diagnostics, feedback, relay, mcp)",
5
+ "license": "UNLICENSED",
6
+ "type": "module",
7
+ "files": [
8
+ "dist"
9
+ ],
10
+ "exports": {
11
+ "./preview": {
12
+ "types": "./dist/preview/index.d.ts",
13
+ "import": "./dist/preview/index.js"
14
+ },
15
+ "./relay": {
16
+ "types": "./dist/relay/server.d.ts",
17
+ "import": "./dist/relay/server.js"
18
+ }
19
+ },
20
+ "bin": {
21
+ "vincentt": "./dist/cli/index.js",
22
+ "harness": "./dist/cli/deprecated/harness.js",
23
+ "harness-mcp": "./dist/cli/deprecated/harness-mcp.js",
24
+ "harness-relay": "./dist/cli/deprecated/harness-relay.js"
25
+ },
26
+ "dependencies": {
27
+ "@modelcontextprotocol/sdk": "^1.29.0",
28
+ "cloudflared": "^0.7.1",
29
+ "ws": "^8.18.0",
30
+ "zod": "^3.23.8",
31
+ "@vincentt-xr/harness": "1.0.0"
32
+ },
33
+ "devDependencies": {
34
+ "@types/ws": "^8.5.13"
35
+ },
36
+ "scripts": {
37
+ "build": "tsc -p tsconfig.build.json",
38
+ "typecheck": "tsc --noEmit",
39
+ "test": "vitest run"
40
+ }
41
+ }