@vincentt-xr/cli 0.1.0 → 0.1.2

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/dist/cli/index.js CHANGED
@@ -12,7 +12,7 @@
12
12
  import os from "node:os";
13
13
  import path from "node:path";
14
14
  import { runLogin } from "../login/login.js";
15
- import { clearMachineToken, loadMachineConfig, } from "../shared/config.js";
15
+ import { clearMachineToken, clearProjectBinding, loadMachineConfig, } from "../shared/config.js";
16
16
  import { getMe } from "../lib/backend.js";
17
17
  import { diagQuery, diagRead, previewStart, previewStop, projectCreate, projectPublish, } from "../lib/lifecycle.js";
18
18
  import { fetchNext, readCursor, writeCursor } from "../lib/feedback.js";
@@ -82,10 +82,23 @@ async function create() {
82
82
  name: flag("--name"),
83
83
  slug: flag("--slug"),
84
84
  scaffold: has("--no-scaffold") ? false : undefined,
85
+ force: has("--force"),
86
+ onProgress: (m) => console.error(` ${m}`),
85
87
  });
86
88
  console.log(result.message);
87
- if (result.alreadyBound)
89
+ if (result.alreadyBound) {
88
90
  process.exitCode = 1;
91
+ return;
92
+ }
93
+ // Chain straight into the dev loop when asked.
94
+ if (has("--preview")) {
95
+ console.log("");
96
+ await preview();
97
+ }
98
+ }
99
+ async function unbind() {
100
+ const { path: p, hadBinding } = await clearProjectBinding(projectDir());
101
+ console.log(hadBinding ? `✓ Unbound. Removed ${p}` : "This directory has no project binding.");
89
102
  }
90
103
  async function publish() {
91
104
  const result = await projectPublish(projectDir(), {
@@ -182,9 +195,11 @@ function usage() {
182
195
  `Auth\n` +
183
196
  ` login Sign in; save an access token to ~/.vincentt/config.json\n` +
184
197
  ` logout Remove the saved access token\n` +
185
- ` whoami Show the signed-in account (via GET /me)\n\n` +
198
+ ` whoami Show the signed-in account (via GET /network/me)\n\n` +
186
199
  `Dev loop\n` +
187
- ` create Create/bind a project (scaffolds the starter if empty)\n` +
200
+ ` create Scaffold + bind a project (empty dir → full starter)\n` +
201
+ ` [--name <s>] [--slug <s>] [--preview] [--force]\n` +
202
+ ` unbind Detach this directory from its project\n` +
188
203
  ` publish Upload the built dist; go live at <slug>.vincentt.app\n` +
189
204
  ` preview Serve + tunnel the app for on-device preview\n\n` +
190
205
  `Diagnostics (need a running preview)\n` +
@@ -210,6 +225,8 @@ async function main() {
210
225
  return whoami();
211
226
  case "create":
212
227
  return create();
228
+ case "unbind":
229
+ return unbind();
213
230
  case "publish":
214
231
  return publish();
215
232
  case "preview":
@@ -137,9 +137,9 @@ export async function publishUpload(cfg, projectId, distDir, opts = {}) {
137
137
  /** Resolve the signed-in identity for `vincentt whoami`. Throws on any non-2xx so
138
138
  * the caller can degrade to "token present, unverified". */
139
139
  export async function getMe(cfg) {
140
- const res = await authFetch(cfg, "/me");
140
+ const res = await authFetch(cfg, "/network/me");
141
141
  if (!res.ok) {
142
- throw new BackendError(`/me failed (${res.status}): ${await safeText(res)}`, res.status);
142
+ throw new BackendError(`/network/me failed (${res.status}): ${await safeText(res)}`, res.status);
143
143
  }
144
144
  return (await res.json());
145
145
  }
@@ -5,6 +5,10 @@ export interface CreateOptions {
5
5
  name?: string;
6
6
  slug?: string;
7
7
  scaffold?: boolean;
8
+ /** Rebind a directory that already has a binding (drops the old one). */
9
+ force?: boolean;
10
+ /** Step-level progress for slow phases (scaffold, install). CLI prints; MCP omits. */
11
+ onProgress?: (message: string) => void;
8
12
  }
9
13
  export interface CreateResult {
10
14
  message: string;
@@ -4,56 +4,96 @@
4
4
  // MCP SDK concerns leak in here. Both surfaces render the returned data their
5
5
  // own way.
6
6
  import path from "node:path";
7
- import { createProject, gitHeadSha, publishUpload } from "./backend.js";
7
+ import { BackendError, createProject, gitHeadSha, publishUpload } from "./backend.js";
8
+ import { slugify } from "./slug.js";
8
9
  import { filterErrors, httpRelayClient, renderResult, } from "./diagnostics.js";
9
10
  import { clearRelayInfo, readRelayInfo, writeRelayInfo } from "./relayInfo.js";
10
- import { loadProjectBinding, resolveConfig, writeProjectBinding, } from "../shared/config.js";
11
- import { installDependencies, needsScaffold, scaffoldFromTemplate, } from "../scaffold/index.js";
11
+ import { clearProjectBinding, loadProjectBinding, resolveConfig, writeProjectBinding, } from "../shared/config.js";
12
+ import { detectPackageManager, installDependencies, needsScaffold, scaffoldFromTemplate, } from "../scaffold/index.js";
12
13
  import { startPreview } from "../preview/index.js";
13
14
  export async function projectCreate(projectCwd, opts = {}) {
15
+ const progress = opts.onProgress ?? (() => { });
14
16
  const existing = await loadProjectBinding(projectCwd);
15
- if (existing) {
17
+ if (existing && !opts.force) {
16
18
  return {
17
- message: `This directory is already bound to project ${existing.projectId} (slug ${existing.slug}). Delete .vincentt/project.json to rebind.`,
19
+ message: `This directory is already bound to project ${existing.projectId} (slug ${existing.slug}).\n` +
20
+ `Run \`vincentt create --force\` to rebind, or \`vincentt unbind\` to detach.`,
18
21
  alreadyBound: true,
19
22
  slug: existing.slug,
20
23
  projectId: existing.projectId,
21
24
  };
22
25
  }
26
+ if (existing)
27
+ await clearProjectBinding(projectCwd);
23
28
  // Resolve config first so a missing PAT fails before any clone/side effect.
24
29
  const cfg = await resolveConfig(projectCwd);
25
30
  let scaffolded = false;
26
31
  if (opts.scaffold !== false && (await needsScaffold(projectCwd))) {
32
+ progress("Scaffolding the v2-template starter…");
27
33
  await scaffoldFromTemplate(projectCwd);
28
34
  scaffolded = true;
29
35
  }
30
- const created = await createProject(cfg, opts.name ?? path.basename(projectCwd), opts.slug);
36
+ const name = opts.name ?? path.basename(projectCwd);
37
+ progress("Registering the project…");
38
+ const created = await createProjectPreferringSlug(cfg, name, opts.slug);
31
39
  const bindingPath = await writeProjectBinding(projectCwd, {
32
40
  projectId: created.projectId,
33
41
  slug: created.slug,
34
42
  });
35
- let scaffoldNote = "";
43
+ const steps = [];
36
44
  if (scaffolded) {
37
- scaffoldNote = "Scaffolded the v2-template starter into this directory.\n";
45
+ steps.push("Scaffolded v2-template into this directory.");
46
+ const pm = await detectPackageManager(projectCwd);
47
+ progress(`Installing dependencies (${pm} install — this can take a minute)…`);
38
48
  try {
39
49
  await installDependencies(projectCwd);
40
- scaffoldNote += "Installed dependencies (npm install).\n";
50
+ steps.push(`Installed dependencies (${pm}).`);
41
51
  }
42
52
  catch (err) {
43
53
  const msg = err instanceof Error ? err.message : String(err);
44
- scaffoldNote += `Note: \`npm install\` failed (${msg}) — run it manually before preview.\n`;
54
+ steps.push(`Dependencies NOT installed (${msg}) — run \`${pm} install\` before preview.`);
45
55
  }
46
56
  }
57
+ const preamble = steps.length ? steps.map((s) => `✓ ${s}`).join("\n") + "\n" : "";
47
58
  return {
48
- message: scaffoldNote +
49
- `Created "${created.name}" → slug ${created.slug} (id ${created.projectId}).\n` +
50
- `Binding written to ${bindingPath} (gitignored).\n` +
51
- `Build locally, then publish to go live at ${created.slug}.vincentt.app.`,
59
+ message: preamble +
60
+ `✓ Created "${created.name}" — slug: ${created.slug} (id ${created.projectId})\n` +
61
+ ` Live URL after publish: https://${created.slug}.vincentt.app\n` +
62
+ ` Binding: ${bindingPath} (gitignored)\n\n` +
63
+ `Next:\n` +
64
+ ` vincentt preview open a live URL on your phone\n` +
65
+ ` vincentt publish go live at ${created.slug}.vincentt.app`,
52
66
  alreadyBound: false,
53
67
  slug: created.slug,
54
68
  projectId: created.projectId,
55
69
  };
56
70
  }
71
+ /**
72
+ * Create the backend project, preferring a slug derived from the name so the live
73
+ * URL reads like the app (`my-app.vincentt.app`) instead of a random one. An
74
+ * explicit `slug` is authoritative — a collision surfaces so the caller re-asks.
75
+ * A derived slug is best-effort: on any 4xx we fall back to a platform-assigned
76
+ * slug rather than failing create over a name we chose.
77
+ */
78
+ async function createProjectPreferringSlug(cfg, name, explicitSlug) {
79
+ if (explicitSlug)
80
+ return createProject(cfg, name, explicitSlug);
81
+ const candidate = slugify(name);
82
+ if (!candidate)
83
+ return createProject(cfg, name);
84
+ try {
85
+ return await createProject(cfg, name, candidate);
86
+ }
87
+ catch (err) {
88
+ if (err instanceof BackendError &&
89
+ err.status !== undefined &&
90
+ err.status >= 400 &&
91
+ err.status < 500) {
92
+ return createProject(cfg, name); // taken/invalid derived slug → let the platform pick
93
+ }
94
+ throw err;
95
+ }
96
+ }
57
97
  export async function projectPublish(projectCwd, opts = {}) {
58
98
  const binding = await loadProjectBinding(projectCwd);
59
99
  if (!binding) {
@@ -0,0 +1 @@
1
+ export declare function slugify(input: string): string;
@@ -0,0 +1,14 @@
1
+ // Derive a DNS-safe slug candidate from a human name. The slug becomes the
2
+ // permanent <slug>.vincentt.app host, so it must be lowercase (DNS lowercases,
3
+ // S3 does not — the platform enforces lowercase end-to-end) and a single DNS
4
+ // label (<= 63 chars). Returns "" when the name has no usable characters, in
5
+ // which case the caller lets the backend auto-assign.
6
+ export function slugify(input) {
7
+ return input
8
+ .toLowerCase()
9
+ .trim()
10
+ .replace(/[^a-z0-9]+/g, "-")
11
+ .replace(/^-+|-+$/g, "")
12
+ .slice(0, 63)
13
+ .replace(/-+$/g, "");
14
+ }
@@ -16,7 +16,9 @@ export interface StartPreviewOptions {
16
16
  /** stdio for the app dev serve. "ignore" (default) keeps an MCP server's stdout
17
17
  * clean; a CLI can pass "inherit" to surface build output. */
18
18
  appStdio?: "ignore" | "inherit";
19
- /** How long to wait for cloudflared to register before giving up (default 45s). */
19
+ /** How long to wait for cloudflared to register before giving up (default 90s).
20
+ * Generous because on a network that blocks the QUIC edge, cloudflared spends
21
+ * ~20-30s failing QUIC before it falls back to the http2 edge, which does work. */
20
22
  registerTimeoutMs?: number;
21
23
  /** How long to wait for the app to serve before returning anyway (default 8s).
22
24
  * Kept short so an MCP tool call stays within strict clients' ~30s timeout;
@@ -40,6 +42,9 @@ export interface RunningPreview {
40
42
  export declare function startPreview(opts: StartPreviewOptions): Promise<RunningPreview>;
41
43
  /**
42
44
  * Resolve once cloudflared reports a live edge connection, reject if it exits
43
- * first or never registers. Exported for unit testing the log-scan/timeout logic.
45
+ * first or never registers. On failure the rejection carries cloudflared's own
46
+ * recent output — otherwise "did not register" hides the real cause (an invalid
47
+ * run token, or a network that blocks the QUIC edge, the two common culprits).
48
+ * `onLine` streams each log line out live. Exported for unit testing.
44
49
  */
45
- export declare function waitForRegister(tunnel: ChildProcess, timeoutMs: number): Promise<void>;
50
+ export declare function waitForRegister(tunnel: ChildProcess, timeoutMs: number, onLine?: (line: string) => void): Promise<void>;
@@ -16,7 +16,7 @@ import { startSessionTunnel } from "./tunnel.js";
16
16
  import { proxyWeb, proxyWs } from "./proxy.js";
17
17
  import { getFreePort, isPortListening, waitForApp } from "./net.js";
18
18
  export async function startPreview(opts) {
19
- const { projectCwd, harnessPath = "/__harness", devCommand = ["npm", "run", "dev"], appStdio = "ignore", registerTimeoutMs = 45_000, appReadyTimeoutMs = 8_000, onLog = () => undefined, } = opts;
19
+ const { projectCwd, harnessPath = "/__harness", devCommand = ["npm", "run", "dev"], appStdio = "ignore", registerTimeoutMs = 90_000, appReadyTimeoutMs = 8_000, onLog = () => undefined, } = opts;
20
20
  // Resolve ports. The app port defaults to 5173; the relay + front ports are
21
21
  // harness-internal, so auto-pick free ones (the relay port is returned for the
22
22
  // MCP diag_* tools to point at) instead of colliding on fixed defaults.
@@ -70,7 +70,7 @@ export async function startPreview(opts) {
70
70
  tunnel = spawn(cloudflaredBin, ["tunnel", "run", "--token", session.runToken], {
71
71
  stdio: ["ignore", "pipe", "pipe"],
72
72
  });
73
- await waitForRegister(tunnel, registerTimeoutMs);
73
+ await waitForRegister(tunnel, registerTimeoutMs, (line) => onLog(`[cloudflared] ${line}`));
74
74
  // Don't hand back a live URL that serves a blank page: wait for the app to
75
75
  // actually respond. Non-fatal — a slow build still returns, flagged not-ready.
76
76
  const appReady = reuseApp ? true : await waitForApp(appPort, appReadyTimeoutMs);
@@ -86,11 +86,15 @@ export async function startPreview(opts) {
86
86
  }
87
87
  /**
88
88
  * Resolve once cloudflared reports a live edge connection, reject if it exits
89
- * first or never registers. Exported for unit testing the log-scan/timeout logic.
89
+ * first or never registers. On failure the rejection carries cloudflared's own
90
+ * recent output — otherwise "did not register" hides the real cause (an invalid
91
+ * run token, or a network that blocks the QUIC edge, the two common culprits).
92
+ * `onLine` streams each log line out live. Exported for unit testing.
90
93
  */
91
- export function waitForRegister(tunnel, timeoutMs) {
94
+ export function waitForRegister(tunnel, timeoutMs, onLine) {
92
95
  return new Promise((resolve, reject) => {
93
96
  let settled = false;
97
+ const recent = [];
94
98
  const finish = (fn) => {
95
99
  if (settled)
96
100
  return;
@@ -98,14 +102,25 @@ export function waitForRegister(tunnel, timeoutMs) {
98
102
  clearTimeout(timer);
99
103
  fn();
100
104
  };
105
+ const tail = () => {
106
+ const lines = recent.slice(-15).join("\n").trim();
107
+ return lines ? `\ncloudflared output:\n${lines}` : "";
108
+ };
101
109
  const scan = (buf) => {
102
- if (/Registered tunnel connection|Connection [^ ]+ registered/.test(String(buf))) {
110
+ const text = String(buf);
111
+ for (const line of text.split(/\r?\n/)) {
112
+ if (!line.trim())
113
+ continue;
114
+ recent.push(line);
115
+ onLine?.(line);
116
+ }
117
+ if (/Registered tunnel connection|Connection [^ ]+ registered/.test(text)) {
103
118
  finish(resolve);
104
119
  }
105
120
  };
106
121
  tunnel.stdout?.on("data", scan);
107
122
  tunnel.stderr?.on("data", scan);
108
- tunnel.on("exit", (code) => finish(() => reject(new Error(`cloudflared exited before registering (code ${code}).`))));
109
- const timer = setTimeout(() => finish(() => reject(new Error(`cloudflared did not register within ${timeoutMs}ms.`))), timeoutMs);
123
+ tunnel.on("exit", (code) => finish(() => reject(new Error(`cloudflared exited before registering (code ${code}).${tail()}`))));
124
+ const timer = setTimeout(() => finish(() => reject(new Error(`cloudflared did not register within ${timeoutMs}ms.${tail()}`))), timeoutMs);
110
125
  });
111
126
  }
@@ -17,10 +17,19 @@ export interface ScaffoldOptions {
17
17
  * fresh repo. Throws a clear error if the clone fails (e.g. no git access).
18
18
  */
19
19
  export declare function scaffoldFromTemplate(targetDir: string, opts?: ScaffoldOptions): Promise<void>;
20
+ export type PackageManager = "pnpm" | "yarn" | "npm";
20
21
  /**
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.
22
+ * Pick the package manager the scaffolded template is set up for, by its lockfile.
23
+ * The template ships a pnpm-lock.yaml + `node-linker=hoisted` (the hoisted layout
24
+ * the esbuild singleton aliasing needs), so installing with the wrong manager
25
+ * leaves a second lockfile and an unproven node_modules layout. Defaults to npm.
25
26
  */
26
- export declare function installDependencies(dir: string): Promise<void>;
27
+ export declare function detectPackageManager(dir: string): Promise<PackageManager>;
28
+ /**
29
+ * Install a freshly scaffolded project's dependencies (with the template's own
30
+ * package manager) so the first preview/build doesn't fail on a missing
31
+ * node_modules. Throws on failure (no network, manager not installed); the caller
32
+ * surfaces it as a "run <pm> install yourself" hint rather than aborting create.
33
+ * Returns the manager used so the caller can name it in messages.
34
+ */
35
+ export declare function installDependencies(dir: string): Promise<PackageManager>;
@@ -71,15 +71,40 @@ async function isGitRepo(dir) {
71
71
  }
72
72
  }
73
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.
74
+ * Pick the package manager the scaffolded template is set up for, by its lockfile.
75
+ * The template ships a pnpm-lock.yaml + `node-linker=hoisted` (the hoisted layout
76
+ * the esbuild singleton aliasing needs), so installing with the wrong manager
77
+ * leaves a second lockfile and an unproven node_modules layout. Defaults to npm.
78
+ */
79
+ export async function detectPackageManager(dir) {
80
+ const has = async (f) => {
81
+ try {
82
+ await fs.access(path.join(dir, f));
83
+ return true;
84
+ }
85
+ catch {
86
+ return false;
87
+ }
88
+ };
89
+ if (await has("pnpm-lock.yaml"))
90
+ return "pnpm";
91
+ if (await has("yarn.lock"))
92
+ return "yarn";
93
+ return "npm";
94
+ }
95
+ /**
96
+ * Install a freshly scaffolded project's dependencies (with the template's own
97
+ * package manager) so the first preview/build doesn't fail on a missing
98
+ * node_modules. Throws on failure (no network, manager not installed); the caller
99
+ * surfaces it as a "run <pm> install yourself" hint rather than aborting create.
100
+ * Returns the manager used so the caller can name it in messages.
78
101
  */
79
102
  export async function installDependencies(dir) {
80
- // Windows: npm is npm.cmd — execFile needs a shell to resolve it.
81
- await execFileAsync("npm", ["install"], {
103
+ const pm = await detectPackageManager(dir);
104
+ // Windows: the managers are .cmd shims — execFile needs a shell to resolve them.
105
+ await execFileAsync(pm, ["install"], {
82
106
  cwd: dir,
83
107
  shell: process.platform === "win32",
84
108
  });
109
+ return pm;
85
110
  }
@@ -35,6 +35,11 @@ export declare function loadProjectBinding(cwd: string): Promise<ProjectBinding
35
35
  * secret-free-but-tenant-scoped binding never rides a commit or `git archive`).
36
36
  */
37
37
  export declare function writeProjectBinding(cwd: string, binding: ProjectBinding): Promise<string>;
38
+ /** Remove the per-tree binding so the directory can be re-created/rebound. */
39
+ export declare function clearProjectBinding(cwd: string): Promise<{
40
+ path: string;
41
+ hadBinding: boolean;
42
+ }>;
38
43
  export interface ResolvedConfig {
39
44
  apiUrl: string;
40
45
  pat: string;
@@ -72,6 +72,13 @@ export async function writeProjectBinding(cwd, binding) {
72
72
  await ensureGitignored(cwd, `${BINDING_DIR}/`);
73
73
  return p;
74
74
  }
75
+ /** Remove the per-tree binding so the directory can be re-created/rebound. */
76
+ export async function clearProjectBinding(cwd) {
77
+ const p = projectBindingPath(cwd);
78
+ const hadBinding = (await readJsonIfExists(p)) !== undefined;
79
+ await fs.rm(p, { force: true });
80
+ return { path: p, hadBinding };
81
+ }
75
82
  /** Append a pattern to the repo's .gitignore if not already present (best-effort). */
76
83
  async function ensureGitignored(cwd, pattern) {
77
84
  const gitignore = path.join(cwd, ".gitignore");
@@ -80,7 +87,9 @@ async function ensureGitignored(cwd, pattern) {
80
87
  const has = current.split(/\r?\n/).some((l) => l.trim() === pattern.trim());
81
88
  if (has)
82
89
  return;
83
- const next = current && !current.endsWith("\n") ? `${current}\n${pattern}\n` : `${current}${pattern}\n`;
90
+ const next = current && !current.endsWith("\n")
91
+ ? `${current}\n${pattern}\n`
92
+ : `${current}${pattern}\n`;
84
93
  await fs.writeFile(gitignore, next, "utf8");
85
94
  }
86
95
  catch {
@@ -95,7 +104,10 @@ async function ensureGitignored(cwd, pattern) {
95
104
  export async function resolveConfig(cwd) {
96
105
  const machine = await loadMachineConfig();
97
106
  const binding = await loadProjectBinding(cwd);
98
- const apiUrl = binding?.apiUrl ?? process.env.VINCENTT_API_URL ?? machine.apiUrl ?? "http://localhost:5051";
107
+ const apiUrl = binding?.apiUrl ??
108
+ process.env.VINCENTT_API_URL ??
109
+ machine.apiUrl ??
110
+ "http://localhost:5051";
99
111
  const pat = process.env.VINCENTT_PAT ?? machine.pat;
100
112
  if (!pat) {
101
113
  throw new Error(`No Vincentt access token found. Add {"apiUrl":"…","pat":"…"} to ${MACHINE_CONFIG_PATH}, ` +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincentt-xr/cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Vincentt CLI — the vincentt command-line dev loop (login, create, publish, preview, diagnostics, feedback, relay, mcp)",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",