@vincentt-xr/cli 0.2.0 → 0.4.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 (43) hide show
  1. package/README.md +3 -1
  2. package/dist/cli/args.d.ts +9 -0
  3. package/dist/cli/args.js +20 -0
  4. package/dist/cli/index.js +114 -25
  5. package/dist/lib/backend.d.ts +0 -16
  6. package/dist/lib/backend.js +0 -34
  7. package/dist/lib/initCommand.d.ts +18 -1
  8. package/dist/lib/initCommand.js +70 -7
  9. package/dist/lib/lifecycle.d.ts +12 -4
  10. package/dist/lib/lifecycle.js +36 -30
  11. package/dist/lib/version.d.ts +34 -0
  12. package/dist/lib/version.js +82 -0
  13. package/dist/publish/api.d.ts +67 -0
  14. package/dist/publish/api.js +96 -0
  15. package/dist/publish/bounds.d.ts +25 -0
  16. package/dist/publish/bounds.js +53 -0
  17. package/dist/publish/classify.d.ts +51 -0
  18. package/dist/publish/classify.js +138 -0
  19. package/dist/publish/command.d.ts +27 -0
  20. package/dist/publish/command.js +328 -0
  21. package/dist/publish/consoleVerbs.d.ts +17 -0
  22. package/dist/publish/consoleVerbs.js +26 -0
  23. package/dist/publish/copy.d.ts +180 -0
  24. package/dist/publish/copy.js +606 -0
  25. package/dist/publish/defaults.d.ts +16 -0
  26. package/dist/publish/defaults.js +52 -0
  27. package/dist/publish/manifest.d.ts +27 -0
  28. package/dist/publish/manifest.js +81 -0
  29. package/dist/publish/paths.d.ts +25 -0
  30. package/dist/publish/paths.js +78 -0
  31. package/dist/publish/progress.d.ts +17 -0
  32. package/dist/publish/progress.js +45 -0
  33. package/dist/publish/prompt.d.ts +7 -0
  34. package/dist/publish/prompt.js +43 -0
  35. package/dist/publish/qa.f4.fixtures.d.ts +25 -0
  36. package/dist/publish/qa.f4.fixtures.js +140 -0
  37. package/dist/publish/upload.d.ts +30 -0
  38. package/dist/publish/upload.js +93 -0
  39. package/dist/scaffold/index.d.ts +26 -0
  40. package/dist/scaffold/index.js +43 -1
  41. package/package.json +2 -3
  42. package/dist/mcp/server.d.ts +0 -16
  43. package/dist/mcp/server.js +0 -215
@@ -10,6 +10,18 @@ import path from "node:path";
10
10
  import { promisify } from "node:util";
11
11
  const execFileAsync = promisify(execFile);
12
12
  export const TEMPLATE_REPO = "https://github.com/vincentt-xr/v2-template.git";
13
+ /**
14
+ * The template ref a scaffold resolves by default.
15
+ *
16
+ * `latest` is a MOVING tag on v2-template, force-updated by its Release workflow
17
+ * only after the tagged tree passes a frozen install + build + typecheck. Cloning
18
+ * it rather than the default branch is what stops an unreleased commit on the
19
+ * template's `main` from reaching every new creator the moment it merges.
20
+ *
21
+ * A creator pins a known version with `--template 1.0.0`; version tags are
22
+ * immutable, which is what makes that an escape hatch rather than a coin flip.
23
+ */
24
+ export const DEFAULT_TEMPLATE_REF = "latest";
13
25
  const realGit = async (args) => {
14
26
  await execFileAsync("git", args);
15
27
  };
@@ -26,6 +38,27 @@ export async function needsScaffold(dir) {
26
38
  return true;
27
39
  }
28
40
  }
41
+ /**
42
+ * True when `dir` exists and holds anything at all.
43
+ *
44
+ * `needsScaffold` only answers "is there an app here", which waves through a home
45
+ * directory, a notes folder, or any repo that is not a Node project — and the
46
+ * scaffold then scatters ~30 files into it. Nothing is overwritten (the copy is
47
+ * `force: false`), but the files are unlisted and separating them again is manual,
48
+ * so the creator is asked first.
49
+ *
50
+ * Dotfiles count. A folder holding only `.git` is a repo someone cares about.
51
+ */
52
+ export async function isNonEmptyDir(dir) {
53
+ try {
54
+ const entries = await fs.readdir(dir);
55
+ return entries.length > 0;
56
+ }
57
+ catch {
58
+ // Missing (the common case for `init my-app`) is not non-empty.
59
+ return false;
60
+ }
61
+ }
29
62
  /**
30
63
  * Scaffold the template into `targetDir`. Clones to a temp dir, strips `.git`,
31
64
  * copies the files in (never overwriting anything already there), then inits a
@@ -33,14 +66,23 @@ export async function needsScaffold(dir) {
33
66
  */
34
67
  export async function scaffoldFromTemplate(targetDir, opts = {}) {
35
68
  const repo = opts.repo ?? TEMPLATE_REPO;
69
+ const ref = opts.ref ?? DEFAULT_TEMPLATE_REF;
36
70
  const runGit = opts.runGit ?? realGit;
37
71
  const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "vincentt-tmpl-"));
38
72
  try {
39
73
  try {
40
- await runGit(["clone", "--depth", "1", repo, tmp]);
74
+ await runGit(["clone", "--depth", "1", "--branch", ref, repo, tmp]);
41
75
  }
42
76
  catch (err) {
43
77
  const msg = err instanceof Error ? err.message : String(err);
78
+ // A missing ref and a missing network both surface as a failed clone, and
79
+ // the remedies are opposite ("check your access" is useless advice when the
80
+ // real cause is a typo'd version). git names the ref in this specific case,
81
+ // so split on that rather than reporting one cause for both.
82
+ if (/(remote branch|Remote branch).*not found|couldn't find remote ref/i.test(msg)) {
83
+ throw new Error(`The template has no version "${ref}". ` +
84
+ `Check the tag exists, or omit --template to use the latest release.`);
85
+ }
44
86
  throw new Error(`Could not clone the ${repo} template (check your git access): ${msg}`);
45
87
  }
46
88
  await fs.rm(path.join(tmp, ".git"), { recursive: true, force: true });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@vincentt-xr/cli",
3
- "version": "0.2.0",
4
- "description": "Vincentt CLI — the vincentt command-line dev loop (login, create, publish, preview, diagnostics, feedback, relay, mcp)",
3
+ "version": "0.4.0",
4
+ "description": "Vincentt CLI — the vincentt command-line dev loop (login, create, publish, preview, diagnostics, feedback, relay)",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
7
7
  "files": [
@@ -21,7 +21,6 @@
21
21
  "vincentt": "./dist/cli/index.js"
22
22
  },
23
23
  "dependencies": {
24
- "@modelcontextprotocol/sdk": "^1.29.0",
25
24
  "qrcode": "^1.5.4",
26
25
  "ws": "^8.18.0",
27
26
  "zod": "^3.23.8",
@@ -1,16 +0,0 @@
1
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
- import { type RelayClient } from "../lib/diagnostics.js";
3
- export interface McpOptions {
4
- /** Base URL of the relay's HTTP endpoint (default matches the relay CLI). */
5
- relayUrl?: string;
6
- /** Injected for tests; defaults to an HTTP client against relayUrl. */
7
- relay?: RelayClient;
8
- /** Injected clock for deterministic relative-time rendering in tests. */
9
- now?: () => number;
10
- /** Project directory for the lifecycle verbs' binding. Defaults to process.cwd(). */
11
- cwd?: string;
12
- }
13
- /** Reap the running preview, if any. Called by preview_stop and on server exit. */
14
- export declare function shutdownActivePreview(): Promise<void>;
15
- export declare function createHarnessMcp(opts?: McpOptions): McpServer;
16
- export declare function runStdio(opts?: McpOptions): Promise<void>;
@@ -1,215 +0,0 @@
1
- // The agent-agnostic MCP server: a THIN adapter over the shared dev-loop lib
2
- // (../lib/lifecycle). Any MCP-capable agent (Claude Code, Codex, Cursor, Cline,
3
- // …) points its MCP config at `vincentt mcp` and gets the same verbs the CLI
4
- // exposes — diagnostics PULL tools plus the create/publish/preview lifecycle.
5
- // This file is only SDK wiring + argument shapes; all behavior lives in the lib,
6
- // so the CLI and MCP surfaces can never diverge.
7
- import path from "node:path";
8
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
9
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
10
- import { z } from "zod";
11
- import { httpRelayClient } from "../lib/diagnostics.js";
12
- import { diagRead, getActivePreview, previewStart, previewStop, projectCreate, projectPublish, } from "../lib/lifecycle.js";
13
- /** Resolve a call's project dir: an explicit `projectDir` arg (absolute, or
14
- * relative to the server base) overrides the base; otherwise the base is used. */
15
- function resolveProjectDir(base, arg) {
16
- return arg ? path.resolve(base, arg) : base;
17
- }
18
- const projectDirInput = {
19
- projectDir: z
20
- .string()
21
- .optional()
22
- .describe("Absolute path (or path relative to the server's working dir) of the project. Defaults to VINCENTT_PROJECT_DIR or the server's cwd. Set this when your agent spawns MCP servers outside the project directory."),
23
- };
24
- /** Wrap a lifecycle handler so a thrown error returns readable MCP error text. */
25
- async function guard(run) {
26
- try {
27
- return { content: [{ type: "text", text: await run() }] };
28
- }
29
- catch (err) {
30
- const msg = err instanceof Error ? err.message : String(err);
31
- return { content: [{ type: "text", text: msg }], isError: true };
32
- }
33
- }
34
- /** Reap the running preview, if any. Called by preview_stop and on server exit. */
35
- export async function shutdownActivePreview() {
36
- await previewStop();
37
- }
38
- // An MCP tool description is NOT documentation — it is the routing logic the
39
- // agent executes. The shipped `viewer` text sent an agent asked about "this
40
- // tester's phone" to the coordinate that churns, and the word doing the damage
41
- // was "device". It stays out of both descriptions below.
42
- //
43
- // There is no `sessionId` parameter. It took the raw client-supplied key, which
44
- // made a free-form string an agent could be talked into echoing the same leak
45
- // from the other direction.
46
- const sharedInput = {
47
- tester: z
48
- .string()
49
- .optional()
50
- .describe("Filter to ONE tester, e.g. `t1`. THIS IS THE FILTER YOU WANT for 'it breaks on their phone but not mine': a tester is one browser tab, recognised across refreshes, so it still resolves after the tester reloads — which they will have done before reporting the bug. Several testers normally share one preview and a merged stream is not debuggable. The ids currently connected are listed at the top of any unfiltered result."),
51
- viewer: z
52
- .string()
53
- .optional()
54
- .describe("Filter to ONE connection, e.g. `viewer-2`. This is the SUB-COORDINATE below `tester` and is rarely what you want: a single tab opens several connections, so this shows a fraction of one tester's traffic. A label identifies a CONNECTION, not a person; one that reconnects gets a new label and the old one is never reused. Use `tester` unless you are debugging a specific connection."),
55
- since: z
56
- .number()
57
- .optional()
58
- .describe("Return only events with seq greater than this. Use the latestSeq from a prior call to poll for new events."),
59
- limit: z
60
- .number()
61
- .optional()
62
- .describe("Cap the number of events returned (newest kept)."),
63
- };
64
- export function createHarnessMcp(opts = {}) {
65
- // Diag tools read through this explicit client so the MCP server stays
66
- // self-contained (works against a standalone relay without a relay.json file).
67
- // preview_start re-points it at the live preview's relay port (OS-assigned).
68
- let relayClient = opts.relay ?? httpRelayClient(opts.relayUrl ?? "http://localhost:7331");
69
- const now = opts.now ?? Date.now;
70
- // The project directory the lifecycle verbs operate on. Do NOT assume the agent
71
- // spawned the server in it — only Claude Code reliably does; Cursor/Copilot/Codex
72
- // spawn MCP servers from the editor root or home. VINCENTT_PROJECT_DIR (settable
73
- // in every host's MCP config `env`) is the portable knob; a per-call `projectDir`
74
- // arg overrides it.
75
- const baseProjectDir = opts.cwd ?? process.env.VINCENTT_PROJECT_DIR ?? process.cwd();
76
- const server = new McpServer({ name: "vincentt-harness", version: "0.1.0" });
77
- server.registerTool("diag_logs", {
78
- description: "Read console logs the preview app produced on the device. Use `errorsOnly` to see just errors, `since` to poll for new logs. Replaces the user pasting console output.",
79
- inputSchema: {
80
- ...sharedInput,
81
- errorsOnly: z.boolean().optional().describe("Return only error-level logs."),
82
- },
83
- }, async ({ tester, viewer, since, limit, errorsOnly }) => ({
84
- content: [
85
- {
86
- type: "text",
87
- text: await diagRead(baseProjectDir, "log", { tester, viewer, since, limit, errorsOnly }, relayClient, now),
88
- },
89
- ],
90
- }));
91
- server.registerTool("diag_network", {
92
- description: "Read fetch/XHR requests the preview app made on the device (method, URL, status, duration). Use to find failed asset loads or slow calls without DevTools.",
93
- inputSchema: sharedInput,
94
- }, async ({ tester, viewer, since, limit }) => ({
95
- content: [
96
- {
97
- type: "text",
98
- text: await diagRead(baseProjectDir, "network", { tester, viewer, since, limit }, relayClient, now),
99
- },
100
- ],
101
- }));
102
- server.registerTool("diag_trace", {
103
- description: "Read performance samples from the device (fps, longest main-thread task, long-task count, phase marks). Replaces hand-exporting a DevTools trace to spot jank.",
104
- inputSchema: sharedInput,
105
- }, async ({ tester, viewer, since, limit }) => ({
106
- content: [
107
- {
108
- type: "text",
109
- text: await diagRead(baseProjectDir, "trace", { tester, viewer, since, limit }, relayClient, now),
110
- },
111
- ],
112
- }));
113
- server.registerTool("project_create", {
114
- description: "Register a Vincentt project for this working directory and reserve its <slug>.vincentt.app address, then write the local .vincentt/project.json binding. This does NOT scaffold an app — run `vincentt init` first for that (it needs no account). Ask the user for a project name AND an address before calling; pass what they give. The address is never auto-assigned: if it is taken, this returns suggestions and creates nothing, so re-ask the user rather than picking one. The address is permanent once published. Run once per new app; publish with project_publish to go live.",
115
- inputSchema: {
116
- name: z
117
- .string()
118
- .optional()
119
- .describe("Display name for the project. Defaults to the working-directory name."),
120
- slug: z
121
- .string()
122
- .optional()
123
- .describe("Desired address (<slug>.vincentt.app), lowercase kebab-case. Defaults to one derived from the name. Never auto-assigned on a collision. Permanent once published."),
124
- org: z
125
- .string()
126
- .optional()
127
- .describe("Slug of the organization to create the project in. Defaults to the user's active org."),
128
- force: z
129
- .boolean()
130
- .optional()
131
- .describe("Replace a binding this folder already has."),
132
- ...projectDirInput,
133
- },
134
- }, async ({ name, slug, org, force, projectDir }) => guard(async () => {
135
- const result = await projectCreate(resolveProjectDir(baseProjectDir, projectDir), {
136
- name,
137
- slug,
138
- org,
139
- force,
140
- });
141
- return result.message;
142
- }));
143
- server.registerTool("project_publish", {
144
- description: "Publish the locally-built dist so this project goes live at <slug>.vincentt.app. Build first (e.g. npm run build), then call this — the dist is uploaded as-is and the server never rebuilds. Requires a prior project_create binding.",
145
- inputSchema: {
146
- distDir: z
147
- .string()
148
- .optional()
149
- .describe("Path to the built dist directory, relative to the project (default: dist)."),
150
- note: z
151
- .string()
152
- .optional()
153
- .describe("Optional release note recorded with the version."),
154
- ...projectDirInput,
155
- },
156
- }, async ({ distDir, note, projectDir }) => guard(async () => {
157
- const result = await projectPublish(resolveProjectDir(baseProjectDir, projectDir), {
158
- distDir,
159
- note,
160
- });
161
- return result.message;
162
- }));
163
- server.registerTool("preview_start", {
164
- description: "Start a live on-device preview: serves the app at an https link a phone can open, and wires the diagnostics relay so diag_logs/diag_network/diag_trace read from the connected devices. Returns the URL to open on a phone — no sign-in is needed on the phone, and several devices can open the same link at once. Requires a project_create binding and a signed-in account. The link is temporary: it stops working when the preview stops, and at a 12-hour maximum. One preview at a time per folder — call preview_stop before starting another. The dev server keeps running until preview_stop, so start it once and iterate.",
165
- inputSchema: { ...projectDirInput },
166
- }, async ({ projectDir }) => guard(async () => {
167
- if (getActivePreview()) {
168
- return `A preview is already running:\n${getActivePreview().url}\nCall preview_stop first to restart.`;
169
- }
170
- // App stdout would corrupt the MCP channel; route harness progress to stderr.
171
- const { preview } = await previewStart(resolveProjectDir(baseProjectDir, projectDir), (m) => console.error(`[preview] ${m}`));
172
- // Point the diag tools at this preview's relay (its port is OS-assigned).
173
- relayClient = httpRelayClient(`http://localhost:${preview.relayPort}`);
174
- const readiness = preview.appReady
175
- ? ""
176
- : `\nNote: the app on :${preview.appPort} isn't responding yet — the URL may be blank until its build finishes. Check diag_logs.`;
177
- return (`Preview is live — open this on a device:\n${preview.url}\n\n` +
178
- `No sign-in is needed on the phone, and several devices can open it at once.\n` +
179
- `Diagnostics are live: diag_logs / diag_network / diag_trace read from the\n` +
180
- `connected devices; pass \`tester\` to filter to one tester's stream.\n` +
181
- // The GRANT, the session id and the edge hostname are absent, and stay
182
- // absent. This text lands verbatim in an agent's transcript, which is
183
- // exactly why f1's "a token is never printed" extends to grants.
184
- `The link is temporary and stops working at ${preview.expiresAt}.\n` +
185
- `Call preview_stop when finished.${readiness}`);
186
- }));
187
- server.registerTool("preview_stop", {
188
- description: "Stop the running preview: ends the session, closes the tunnel, and tears down the app dev server and the diagnostics relay. The link stops working immediately. Safe to call when nothing is running.",
189
- inputSchema: {},
190
- }, async () => guard(async () => {
191
- const stopped = await previewStop();
192
- if (!stopped)
193
- return "No preview is running.";
194
- // The preview's relay is gone; send diag reads back to the default.
195
- relayClient =
196
- opts.relay ?? httpRelayClient(opts.relayUrl ?? "http://localhost:7331");
197
- return "Preview stopped. Tunnel and dev server torn down.";
198
- }));
199
- return server;
200
- }
201
- export async function runStdio(opts = {}) {
202
- const server = createHarnessMcp(opts);
203
- const transport = new StdioServerTransport();
204
- await server.connect(transport);
205
- // A running preview owns a child process + a live backend tunnel; reap both when
206
- // the agent disconnects so we never leak a tunnel/DNS route past the session.
207
- const shutdown = async () => {
208
- await shutdownActivePreview();
209
- process.exit(0);
210
- };
211
- process.on("SIGINT", shutdown);
212
- process.on("SIGTERM", shutdown);
213
- // stdout is the MCP channel — status goes to stderr only.
214
- console.error("[vincentt mcp] connected over stdio");
215
- }