@vincentt-xr/cli 0.3.0 → 0.4.1

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 CHANGED
@@ -68,7 +68,9 @@ Reverse channel
68
68
 
69
69
  Servers
70
70
  relay Run the diagnostics/annotation relay
71
- mcp Run the MCP adapter (for hosts without a shell)
71
+
72
+ Meta
73
+ version CLI version, and the API this machine resolves to
72
74
 
73
75
  Common options: --dir <path> (project dir, default cwd)
74
76
  ```
@@ -5,6 +5,14 @@
5
5
  * usage instead of running the (side-effectful) verb.
6
6
  */
7
7
  export declare function isHelpInvocation(args: string[]): boolean;
8
+ /**
9
+ * True for `vincentt --version` / `-v` — the flag form, as the FIRST argument
10
+ * only. Unlike `--help`, this is deliberately not honored anywhere in the args:
11
+ * `--help` is safe to obey late because it cancels the action, whereas treating
12
+ * a trailing `-v` on `vincentt publish -v` as "print the version" would silently
13
+ * skip a publish the creator asked for. Anywhere else it stays an unknown flag.
14
+ */
15
+ export declare function isVersionInvocation(args: string[]): boolean;
8
16
  export declare function refusedFlag(args: string[]): string | undefined;
9
17
  /**
10
18
  * The bare arguments, with flag VALUES removed. Without this, `vincentt link --dir
package/dist/cli/args.js CHANGED
@@ -7,6 +7,16 @@
7
7
  export function isHelpInvocation(args) {
8
8
  return args[0] === undefined || args.includes("--help") || args.includes("-h");
9
9
  }
10
+ /**
11
+ * True for `vincentt --version` / `-v` — the flag form, as the FIRST argument
12
+ * only. Unlike `--help`, this is deliberately not honored anywhere in the args:
13
+ * `--help` is safe to obey late because it cancels the action, whereas treating
14
+ * a trailing `-v` on `vincentt publish -v` as "print the version" would silently
15
+ * skip a publish the creator asked for. Anywhere else it stays an unknown flag.
16
+ */
17
+ export function isVersionInvocation(args) {
18
+ return args[0] === "--version" || args[0] === "-v";
19
+ }
10
20
  /** Flags that stand alone; every other `--flag` consumes the next argument. */
11
21
  const BOOLEAN_FLAGS = new Set([
12
22
  "--force",
package/dist/cli/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
- // `vincentt` — the unified CLI for the local-first dev loop. A human and a
3
- // bash-capable coding agent drive the same verbs. No MCP registration is required
4
- // for the default path; `vincentt mcp` exists only for hosts without a shell.
2
+ // `vincentt` — the CLI for the local-first dev loop, and the only surface. A human
3
+ // and a bash-capable coding agent drive the same verbs, so there is nothing to
4
+ // register: an agent that can run a shell command already has the whole product.
5
5
  //
6
6
  // Verb groups:
7
7
  // auth login · logout · whoami
@@ -9,7 +9,9 @@
9
9
  // dev loop publish · preview
10
10
  // diagnostics logs · network · trace
11
11
  // reverse feedback (--wait | --watch)
12
- // servers relay · mcp
12
+ // servers relay
13
+ // meta version
14
+ import { createRequire } from "node:module";
13
15
  import os from "node:os";
14
16
  import path from "node:path";
15
17
  import { runPkceLogin } from "../login/pkceLogin.js";
@@ -21,9 +23,10 @@ import { getMe, ProjectUnreachableError } from "../lib/backend.js";
21
23
  import { initCommand } from "../lib/initCommand.js";
22
24
  import { linkCommand, printUnreachable, unlinkCommand } from "../lib/projects.js";
23
25
  import { whoamiCommand } from "../lib/whoami.js";
26
+ import { renderVersion, versionReport } from "../lib/version.js";
24
27
  import { diagQuery, diagRead, previewStart, previewStop, previewStopSignal, projectCreate, } from "../lib/lifecycle.js";
25
28
  import { publishCommand } from "../publish/command.js";
26
- import { stdinConfirm } from "../publish/prompt.js";
29
+ import { stdinAsk, stdinConfirm } from "../publish/prompt.js";
27
30
  import { consoleVerbCommand, isConsoleOnlyVerb } from "../publish/consoleVerbs.js";
28
31
  import { PreviewUi } from "../preview/command.js";
29
32
  import { httpRelayClient } from "../lib/diagnostics.js";
@@ -32,7 +35,7 @@ import { mintRateLimited, notSignedIn, previewConflict, startBlock, } from "../p
32
35
  import { renderTerminalQr, shouldRenderQr } from "../preview/qr.js";
33
36
  import { PreviewConflictError, PreviewRateLimitedError } from "../preview/api.js";
34
37
  import { fetchNext, readCursor, writeCursor } from "../lib/feedback.js";
35
- import { isHelpInvocation, positionalArgs, refusedFlag } from "./args.js";
38
+ import { isHelpInvocation, isVersionInvocation, positionalArgs, refusedFlag } from "./args.js";
36
39
  // The console origin serves /cli-auth (the browser target for `vincentt login`). VINCENTT_
37
40
  // CONSOLE_URL overrides for dev/dogfood; VINCENTT_EDITOR_URL is honored as a legacy alias.
38
41
  const DEFAULT_CONSOLE_URL = "https://vincentt.app";
@@ -59,18 +62,20 @@ function positional(n) {
59
62
  return positionalArgs(argv)[n];
60
63
  }
61
64
  // ---- auth -------------------------------------------------------------------
62
- function resolveAuthUrls() {
65
+ async function resolveAuthUrls() {
66
+ const machine = await loadMachineConfig();
63
67
  const consoleUrl = flag("--console") ??
64
68
  flag("--editor") ??
65
69
  process.env.VINCENTT_CONSOLE_URL ??
66
70
  process.env.VINCENTT_EDITOR_URL ??
71
+ machine.consoleUrl ??
67
72
  DEFAULT_CONSOLE_URL;
68
- const apiUrl = flag("--api") ?? process.env.VINCENTT_API_URL ?? DEFAULT_API_URL;
73
+ const apiUrl = flag("--api") ?? process.env.VINCENTT_API_URL ?? machine.apiUrl ?? DEFAULT_API_URL;
69
74
  return { consoleUrl, apiUrl };
70
75
  }
71
76
  /** The PKCE loopback + code-exchange login (f1). The raw PAT never rides a redirect query. */
72
77
  async function runLoginFlow() {
73
- const { consoleUrl, apiUrl } = resolveAuthUrls();
78
+ const { consoleUrl, apiUrl } = await resolveAuthUrls();
74
79
  return runPkceLogin({
75
80
  consoleUrl,
76
81
  apiUrl,
@@ -79,7 +84,7 @@ async function runLoginFlow() {
79
84
  }
80
85
  /** `vincentt login` [--force] — the L1/L2/L4 transcripts. */
81
86
  async function login() {
82
- const { apiUrl } = resolveAuthUrls();
87
+ const { apiUrl } = await resolveAuthUrls();
83
88
  const code = await loginCommand({
84
89
  print: (line) => console.log(line),
85
90
  currentToken,
@@ -146,7 +151,20 @@ async function whoami() {
146
151
  }
147
152
  // ---- project ----------------------------------------------------------------
148
153
  async function init() {
149
- process.exitCode = await initCommand({ print: (l) => console.log(l), cwd: process.cwd() }, { dir: positional(1), skipInstall: has("--no-install") });
154
+ // Both prompts are omitted when stdin is not a TTY, and initCommand reads their
155
+ // ABSENCE as "may not ask" — so a piped or CI invocation refuses rather than
156
+ // blocking forever on a question nobody can see.
157
+ const interactive = Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY);
158
+ process.exitCode = await initCommand({
159
+ print: (l) => console.log(l),
160
+ cwd: process.cwd(),
161
+ ask: interactive ? stdinAsk() : undefined,
162
+ confirm: interactive ? stdinConfirm() : undefined,
163
+ }, {
164
+ dir: positional(1),
165
+ skipInstall: has("--no-install"),
166
+ template: flag("--template"),
167
+ });
150
168
  }
151
169
  async function create() {
152
170
  await requireLogin();
@@ -498,11 +516,32 @@ async function feedback() {
498
516
  await writeCursor(cwd, annotation.seq);
499
517
  } while (watch);
500
518
  }
501
- // ---- servers ----------------------------------------------------------------
502
- async function mcp() {
503
- const { runStdio } = await import("../mcp/server.js");
504
- await runStdio({ relayUrl: flag("--relay-url") });
519
+ // ---- meta -------------------------------------------------------------------
520
+ /**
521
+ * The running package's version, read from the manifest rather than baked in by
522
+ * the build — a hardcoded string is one forgotten edit away from a CLI that
523
+ * misreports itself, which is worse than not having the command at all.
524
+ * `src/cli/` and the emitted `dist/cli/` sit at the same depth, so one relative
525
+ * path serves both.
526
+ */
527
+ function cliVersion() {
528
+ try {
529
+ const manifest = createRequire(import.meta.url)("../../package.json");
530
+ return manifest.version ?? "unknown";
531
+ }
532
+ catch {
533
+ return "unknown";
534
+ }
535
+ }
536
+ async function version() {
537
+ const report = await versionReport(projectDir(), {
538
+ cliVersion: cliVersion(),
539
+ allowCustomApi: has("--allow-custom-api"),
540
+ });
541
+ for (const line of renderVersion(report))
542
+ console.log(line);
505
543
  }
544
+ // ---- servers ----------------------------------------------------------------
506
545
  async function relay() {
507
546
  const { startRelay } = await import("../relay/server.js");
508
547
  const port = numFlag("--port") ?? 7331;
@@ -526,11 +565,12 @@ function usage() {
526
565
  ` Full guide: npm view @vincentt-xr/cli readme\n\n` +
527
566
  `Auth\n` +
528
567
  ` login Sign in; save an access token to ~/.vincentt/config.json\n` +
568
+ ` [--api <url>] [--console <url>] to target a non-prod env\n` +
529
569
  ` logout Remove the saved access token\n` +
530
570
  ` whoami Show the signed-in account\n\n` +
531
571
  `Project\n` +
532
572
  ` init [dir] Scaffold a Vincentt XR app — no account needed\n` +
533
- ` [--no-install]\n` +
573
+ ` [--no-install] [--template <version>]\n` +
534
574
  ` create Register a project and reserve its address\n` +
535
575
  ` [--name <s>] [--slug <s>] [--org <s>] [--preview] [--force]\n` +
536
576
  ` link <project-id> Link this folder to an existing project\n` +
@@ -551,13 +591,19 @@ function usage() {
551
591
  ` feedback --wait Block for the next phone annotation, print it, exit\n` +
552
592
  ` feedback --watch Stream annotations as they arrive\n\n` +
553
593
  `Servers\n` +
554
- ` relay Run the diagnostics/annotation relay\n` +
555
- ` mcp Run the MCP adapter (for hosts without a shell)\n\n` +
594
+ ` relay Run the diagnostics/annotation relay\n\n` +
595
+ `Meta\n` +
596
+ ` version CLI version, and the API this machine resolves to\n\n` +
556
597
  `Common options: --dir <path> (project dir, default cwd)\n` +
557
598
  ` --allow-custom-api honor a binding's apiUrl override\n`);
558
599
  }
559
600
  async function main() {
560
601
  const cmd = argv[0];
602
+ // Checked BEFORE the help gate: `--version` is a lone flag with no verb, which
603
+ // the help gate would otherwise answer with usage — the one response that does
604
+ // not contain the version.
605
+ if (isVersionInvocation(argv))
606
+ return version();
561
607
  // `--help`/`-h` anywhere means "explain, don't act" — never let it fall through to
562
608
  // a verb. Otherwise `login --help` runs login, `create --help` scaffolds, etc.
563
609
  if (isHelpInvocation(argv)) {
@@ -565,6 +611,8 @@ async function main() {
565
611
  return;
566
612
  }
567
613
  switch (cmd) {
614
+ case "version":
615
+ return version();
568
616
  case "login":
569
617
  return login();
570
618
  case "logout":
@@ -591,8 +639,6 @@ async function main() {
591
639
  return diag("trace");
592
640
  case "feedback":
593
641
  return feedback();
594
- case "mcp":
595
- return mcp();
596
642
  case "relay":
597
643
  return relay();
598
644
  default:
@@ -1,14 +1,31 @@
1
+ import type { Ask, Confirm } from "../publish/prompt.js";
1
2
  export interface InitDeps {
2
3
  print: (line: string) => void;
3
4
  cwd: string;
4
- scaffold?: (targetDir: string) => Promise<void>;
5
+ scaffold?: (targetDir: string, ref?: string) => Promise<void>;
5
6
  install?: (targetDir: string) => Promise<string>;
6
7
  isEmpty?: (targetDir: string) => Promise<boolean>;
8
+ /** Free-text prompt. Absent → not a TTY, so nothing may be asked. */
9
+ ask?: Ask;
10
+ /** y/N prompt. Absent → not a TTY. */
11
+ confirm?: Confirm;
12
+ /** Non-empty check for the resolved target; injected in tests. */
13
+ isNonEmpty?: (targetDir: string) => Promise<boolean>;
7
14
  }
15
+ /** The folder name offered when `init` is run bare and the creator just hits Enter. */
16
+ export declare const DEFAULT_APP_NAME = "my-ar-app";
17
+ /**
18
+ * A folder name safe to create from a typed answer. Keeps this from becoming a
19
+ * path-traversal seam: the answer names a child of the cwd, never `../` upward and
20
+ * never an absolute path.
21
+ */
22
+ export declare function sanitizeAppName(answer: string): string | null;
8
23
  export interface InitOptions {
9
24
  /** Positional target directory; defaults to the cwd. */
10
25
  dir?: string;
11
26
  /** Skip the dependency install (CI, or a creator who wants to pick a manager). */
12
27
  skipInstall?: boolean;
28
+ /** Template version to scaffold from; defaults to the latest release. */
29
+ template?: string;
13
30
  }
14
31
  export declare function initCommand(deps: InitDeps, opts?: InitOptions): Promise<number>;
@@ -6,10 +6,49 @@
6
6
  // does need an account) is offered at the end as the next thing AVAILABLE, never as
7
7
  // a requirement.
8
8
  import path from "node:path";
9
- import { detectPackageManager, installDependencies, needsScaffold, scaffoldFromTemplate, } from "../scaffold/index.js";
9
+ import { detectPackageManager, installDependencies, isNonEmptyDir, needsScaffold, scaffoldFromTemplate, } from "../scaffold/index.js";
10
+ /** The folder name offered when `init` is run bare and the creator just hits Enter. */
11
+ export const DEFAULT_APP_NAME = "my-ar-app";
12
+ /**
13
+ * A folder name safe to create from a typed answer. Keeps this from becoming a
14
+ * path-traversal seam: the answer names a child of the cwd, never `../` upward and
15
+ * never an absolute path.
16
+ */
17
+ export function sanitizeAppName(answer) {
18
+ const trimmed = answer.trim();
19
+ if (trimmed === "" || trimmed === "." || trimmed === "..")
20
+ return null;
21
+ if (trimmed.includes("/") || trimmed.includes("\\"))
22
+ return null;
23
+ if (trimmed.startsWith("-"))
24
+ return null;
25
+ return trimmed;
26
+ }
10
27
  export async function initCommand(deps, opts = {}) {
11
- const target = path.resolve(deps.cwd, opts.dir ?? ".");
12
- const label = opts.dir ? `./${opts.dir}` : ".";
28
+ // Resolve WHERE before checking what is there. Bare `init` used to mean "here",
29
+ // which is the surprising reading — a creator typing it in their home directory
30
+ // meant "make me an app", not "fill this folder with one".
31
+ let dir = opts.dir;
32
+ if (dir === undefined) {
33
+ if (!deps.ask) {
34
+ deps.print("✗ init needs a target directory when it can't ask.");
35
+ deps.print("");
36
+ deps.print(" vincentt init <dir> Scaffold into a new folder");
37
+ deps.print(" vincentt init . Scaffold into the current folder");
38
+ return 1;
39
+ }
40
+ const answer = await deps.ask(`Project name: (${DEFAULT_APP_NAME}) `, DEFAULT_APP_NAME);
41
+ const name = sanitizeAppName(answer);
42
+ if (name === null) {
43
+ deps.print(`✗ "${answer.trim()}" is not a folder name.`);
44
+ deps.print("");
45
+ deps.print(" Use a plain name (my-ar-app), or `vincentt init .` for this folder.");
46
+ return 1;
47
+ }
48
+ dir = name;
49
+ }
50
+ const target = path.resolve(deps.cwd, dir);
51
+ const label = dir === "." ? "." : `./${dir}`;
13
52
  const isEmpty = deps.isEmpty ?? needsScaffold;
14
53
  if (!(await isEmpty(target))) {
15
54
  deps.print(`✗ ${label} already contains an app (package.json).`);
@@ -18,11 +57,34 @@ export async function initCommand(deps, opts = {}) {
18
57
  deps.print(" vincentt create Register THIS folder as a project");
19
58
  return 1;
20
59
  }
60
+ // No package.json, but not empty either — the case the old guard waved through.
61
+ const isNonEmpty = deps.isNonEmpty ?? isNonEmptyDir;
62
+ if (await isNonEmpty(target)) {
63
+ if (!deps.confirm) {
64
+ deps.print(`✗ ${label} is not empty, and I can't ask whether to add to it.`);
65
+ deps.print("");
66
+ deps.print(" vincentt init <dir> Scaffold into a new folder instead");
67
+ return 1;
68
+ }
69
+ deps.print(`! ${label} is not empty. Scaffolding adds the template's files alongside what's there.`);
70
+ deps.print(" Existing files are never overwritten.");
71
+ if (!(await deps.confirm("? Continue? (y/N) ", false))) {
72
+ deps.print("");
73
+ deps.print(" Nothing was written.");
74
+ return 1;
75
+ }
76
+ }
21
77
  deps.print(`→ Scaffolding a Vincentt XR app in ${label}…`);
22
78
  deps.print(" · React + react-three-fiber + @vincentt-xr/sdk");
23
- const scaffold = deps.scaffold ?? ((dir) => scaffoldFromTemplate(dir));
79
+ // Only announce the version when it was asked for. On the default path the ref
80
+ // is an implementation detail, and naming it would invite creators to pin a
81
+ // version they have no reason to care about.
82
+ if (opts.template)
83
+ deps.print(` · template ${opts.template}`);
84
+ const scaffold = deps.scaffold ??
85
+ ((dir, ref) => scaffoldFromTemplate(dir, { ref }));
24
86
  try {
25
- await scaffold(target);
87
+ await scaffold(target, opts.template);
26
88
  }
27
89
  catch (err) {
28
90
  deps.print("");
@@ -50,8 +112,9 @@ export async function initCommand(deps, opts = {}) {
50
112
  deps.print(`✓ Created ${label}`);
51
113
  deps.print("");
52
114
  const steps = [];
53
- if (opts.dir)
54
- steps.push([`cd ${opts.dir}`, ""]);
115
+ // Keyed off the RESOLVED dir, not the flag — a prompted name needs the cd too.
116
+ if (dir !== ".")
117
+ steps.push([`cd ${dir}`, ""]);
55
118
  if (!installed || opts.skipInstall) {
56
119
  steps.push([`${pm} install`, "Install dependencies"]);
57
120
  }
@@ -0,0 +1,34 @@
1
+ export interface VersionReport {
2
+ cliVersion: string;
3
+ nodeVersion: string;
4
+ apiUrl: string;
5
+ /** Where apiUrl came from, in creator-facing words. */
6
+ apiSource: string;
7
+ /** Set when a binding names a different API that was NOT used. */
8
+ ignoredBindingApiUrl?: string;
9
+ /** True when the resolved host is the built-in fallback (nothing configured). */
10
+ isDefault: boolean;
11
+ }
12
+ export interface VersionEnv {
13
+ envApiUrl?: string;
14
+ machineApiUrl?: string;
15
+ bindingApiUrl?: string;
16
+ allowCustomApi?: boolean;
17
+ nodeVersion: string;
18
+ cliVersion: string;
19
+ }
20
+ /**
21
+ * Decide what to report. Pure, so the transcript is testable without a home
22
+ * directory, an env var, or a built package.
23
+ */
24
+ export declare function buildVersionReport(env: VersionEnv): VersionReport;
25
+ /**
26
+ * The transcript. Two aligned columns so the host and its provenance read as one
27
+ * fact, not two.
28
+ */
29
+ export declare function renderVersion(r: VersionReport): string[];
30
+ /** Read the machine's real state and produce the report. */
31
+ export declare function versionReport(cwd: string, opts: {
32
+ cliVersion: string;
33
+ allowCustomApi?: boolean;
34
+ }): Promise<VersionReport>;
@@ -0,0 +1,82 @@
1
+ // `vincentt version` — which CLI is running, and which backend it will actually
2
+ // talk to.
3
+ //
4
+ // The second half is the point. A creator can have the right CLI pointed at the
5
+ // wrong API (a leftover VINCENTT_API_URL, a machine config written months ago,
6
+ // or the localhost default on a fresh install), and every failure that follows
7
+ // reads like a server outage rather than a misconfiguration. So this reports the
8
+ // resolved host AND its provenance, using the same decideApiUrl the real verbs
9
+ // use — a version command that re-derived the URL could disagree with them.
10
+ import { decideApiUrl, loadMachineConfig, locateProjectBinding, } from "../shared/config.js";
11
+ /**
12
+ * Decide what to report. Pure, so the transcript is testable without a home
13
+ * directory, an env var, or a built package.
14
+ */
15
+ export function buildVersionReport(env) {
16
+ const decided = decideApiUrl({
17
+ bindingApiUrl: env.bindingApiUrl,
18
+ envApiUrl: env.envApiUrl,
19
+ machineApiUrl: env.machineApiUrl,
20
+ allowCustomApi: env.allowCustomApi,
21
+ });
22
+ let apiSource;
23
+ let isDefault = false;
24
+ if (decided.usedBindingApiUrl) {
25
+ apiSource = "this folder's binding (--allow-custom-api)";
26
+ }
27
+ else if (env.envApiUrl) {
28
+ apiSource = "VINCENTT_API_URL";
29
+ }
30
+ else if (env.machineApiUrl) {
31
+ apiSource = "~/.vincentt/config.json";
32
+ }
33
+ else {
34
+ apiSource = "built-in default";
35
+ isDefault = true;
36
+ }
37
+ return {
38
+ cliVersion: env.cliVersion,
39
+ nodeVersion: env.nodeVersion,
40
+ apiUrl: decided.apiUrl,
41
+ apiSource,
42
+ ignoredBindingApiUrl: decided.ignoredBindingApiUrl,
43
+ isDefault,
44
+ };
45
+ }
46
+ /**
47
+ * The transcript. Two aligned columns so the host and its provenance read as one
48
+ * fact, not two.
49
+ */
50
+ export function renderVersion(r) {
51
+ const out = [
52
+ "",
53
+ `vincentt ${r.cliVersion}`,
54
+ "",
55
+ ` api ${r.apiUrl}`,
56
+ ` from ${r.apiSource}`,
57
+ ];
58
+ // A localhost default on a machine that never configured anything is the
59
+ // single most confusing state to be in: every verb fails with a connection
60
+ // error that looks like the platform is down.
61
+ if (r.isDefault && /localhost|127\.0\.0\.1/.test(r.apiUrl)) {
62
+ out.push("", " ! Nothing is configured, so this points at a local backend.", " Set VINCENTT_API_URL, or run `vincentt login` to save one.");
63
+ }
64
+ if (r.ignoredBindingApiUrl) {
65
+ out.push("", ` ! This folder's binding names a different API (${r.ignoredBindingApiUrl})`, " and was ignored. Re-run with --allow-custom-api to use it.");
66
+ }
67
+ out.push("", ` node ${r.nodeVersion}`, "");
68
+ return out;
69
+ }
70
+ /** Read the machine's real state and produce the report. */
71
+ export async function versionReport(cwd, opts) {
72
+ const machine = await loadMachineConfig();
73
+ const located = await locateProjectBinding(cwd);
74
+ return buildVersionReport({
75
+ envApiUrl: process.env.VINCENTT_API_URL,
76
+ machineApiUrl: machine.apiUrl,
77
+ bindingApiUrl: located?.binding.apiUrl,
78
+ allowCustomApi: opts.allowCustomApi,
79
+ nodeVersion: process.version,
80
+ cliVersion: opts.cliVersion,
81
+ });
82
+ }
@@ -1,4 +1,7 @@
1
1
  export type Confirm = (prompt: string, defaultYes: boolean) => Promise<boolean>;
2
2
  /** Reads one line from stdin. Anything that is not an explicit answer takes the default. */
3
3
  export declare function stdinConfirm(input?: NodeJS.ReadableStream, output?: NodeJS.WritableStream): Confirm;
4
+ /** Reads one free-text line. An empty answer takes the offered default. */
5
+ export type Ask = (prompt: string, fallback: string) => Promise<string>;
6
+ export declare function stdinAsk(input?: NodeJS.ReadableStream, output?: NodeJS.WritableStream): Ask;
4
7
  export declare function interpretAnswer(answer: string, defaultYes: boolean): boolean;
@@ -17,6 +17,18 @@ export function stdinConfirm(input = process.stdin, output = process.stdout) {
17
17
  }
18
18
  };
19
19
  }
20
+ export function stdinAsk(input = process.stdin, output = process.stdout) {
21
+ return async (prompt, fallback) => {
22
+ const rl = readline.createInterface({ input, output });
23
+ try {
24
+ const answer = await new Promise((resolve) => rl.question(prompt, resolve));
25
+ return answer.trim() === "" ? fallback : answer.trim();
26
+ }
27
+ finally {
28
+ rl.close();
29
+ }
30
+ };
31
+ }
20
32
  export function interpretAnswer(answer, defaultYes) {
21
33
  const normalized = answer.trim().toLowerCase();
22
34
  if (normalized === "")
@@ -1,4 +1,16 @@
1
1
  export declare const TEMPLATE_REPO = "https://github.com/vincentt-xr/v2-template.git";
2
+ /**
3
+ * The template ref a scaffold resolves by default.
4
+ *
5
+ * `latest` is a MOVING tag on v2-template, force-updated by its Release workflow
6
+ * only after the tagged tree passes a frozen install + build + typecheck. Cloning
7
+ * it rather than the default branch is what stops an unreleased commit on the
8
+ * template's `main` from reaching every new creator the moment it merges.
9
+ *
10
+ * A creator pins a known version with `--template 1.0.0`; version tags are
11
+ * immutable, which is what makes that an escape hatch rather than a coin flip.
12
+ */
13
+ export declare const DEFAULT_TEMPLATE_REF = "latest";
2
14
  /** Injectable git runner (real git by default; a fake in tests avoids the network). */
3
15
  export type GitRunner = (args: string[]) => Promise<void>;
4
16
  /**
@@ -6,8 +18,22 @@ export type GitRunner = (args: string[]) => Promise<void>;
6
18
  * A directory that already holds an app is left untouched (project_create just binds).
7
19
  */
8
20
  export declare function needsScaffold(dir: string): Promise<boolean>;
21
+ /**
22
+ * True when `dir` exists and holds anything at all.
23
+ *
24
+ * `needsScaffold` only answers "is there an app here", which waves through a home
25
+ * directory, a notes folder, or any repo that is not a Node project — and the
26
+ * scaffold then scatters ~30 files into it. Nothing is overwritten (the copy is
27
+ * `force: false`), but the files are unlisted and separating them again is manual,
28
+ * so the creator is asked first.
29
+ *
30
+ * Dotfiles count. A folder holding only `.git` is a repo someone cares about.
31
+ */
32
+ export declare function isNonEmptyDir(dir: string): Promise<boolean>;
9
33
  export interface ScaffoldOptions {
10
34
  repo?: string;
35
+ /** Template ref (tag) to scaffold from. Defaults to `DEFAULT_TEMPLATE_REF`. */
36
+ ref?: string;
11
37
  /** Test seam — defaults to spawning real git. */
12
38
  runGit?: GitRunner;
13
39
  }
@@ -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 });
@@ -1,6 +1,8 @@
1
1
  export interface MachineConfig {
2
2
  /** Base URL of the Vincentt backend API (e.g. https://api.vincentt.studio). */
3
3
  apiUrl: string;
4
+ /** Console origin that serves /cli-auth (e.g. https://vincentt.app); used by `login`. */
5
+ consoleUrl: string;
4
6
  /** Personal access token (bearer) for machine auth. */
5
7
  pat: string;
6
8
  /** Org slug commands act in when no --org flag or VINCENTT_ORG env is given. */
@@ -1,7 +1,7 @@
1
1
  // Config the lifecycle MCP verbs (project_create / project_publish) read to reach
2
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).
3
+ // • ~/.vincentt/config.json — machine-global { apiUrl, consoleUrl, pat }. The
4
+ // PAT is a bearer credential, so it lives OUTSIDE any repo (never committed).
5
5
  // • <project>/.vincentt/project.json — the per-tree binding { projectId, slug }.
6
6
  // Gitignored: `git archive HEAD` (the remix path) must NOT carry it, or a
7
7
  // remixer would inherit — and publish over — the original's project.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@vincentt-xr/cli",
3
- "version": "0.3.0",
4
- "description": "Vincentt CLI — the vincentt command-line dev loop (login, create, publish, preview, diagnostics, feedback, relay, mcp)",
3
+ "version": "0.4.1",
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. A project's FIRST publish cannot be automated: it makes the address permanent, so the user must run `vincentt publish` in their own terminal once. Every publish after that works from here.",
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
- // `yes` is the only value an MCP caller can pass: there is no terminal to
158
- // prompt at. On a FIRST publish the command refuses it outright, which is
159
- // what keeps an irreversible act off an agent's tool surface.
160
- const result = await projectPublish(resolveProjectDir(baseProjectDir, projectDir), { distDir, note, yes: true });
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
- }