@vincentt-xr/cli 0.1.5 → 0.3.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 (92) hide show
  1. package/README.md +94 -0
  2. package/dist/cli/args.d.ts +14 -0
  3. package/dist/cli/args.js +51 -0
  4. package/dist/cli/index.js +416 -100
  5. package/dist/lib/backend.d.ts +44 -20
  6. package/dist/lib/backend.js +98 -51
  7. package/dist/lib/diagnostics.d.ts +27 -2
  8. package/dist/lib/diagnostics.js +73 -8
  9. package/dist/lib/initCommand.d.ts +14 -0
  10. package/dist/lib/initCommand.js +75 -0
  11. package/dist/lib/lifecycle.d.ts +85 -19
  12. package/dist/lib/lifecycle.js +133 -130
  13. package/dist/lib/org.d.ts +29 -0
  14. package/dist/lib/org.js +45 -0
  15. package/dist/lib/projects.d.ts +82 -0
  16. package/dist/lib/projects.js +217 -0
  17. package/dist/lib/relayInfo.d.ts +41 -3
  18. package/dist/lib/relayInfo.js +94 -0
  19. package/dist/lib/whoami.d.ts +11 -0
  20. package/dist/lib/whoami.js +38 -0
  21. package/dist/login/authBackend.d.ts +30 -0
  22. package/dist/login/authBackend.js +80 -0
  23. package/dist/login/guard.d.ts +13 -0
  24. package/dist/login/guard.js +17 -0
  25. package/dist/login/loginCommand.d.ts +39 -0
  26. package/dist/login/loginCommand.js +72 -0
  27. package/dist/login/pkce.d.ts +47 -0
  28. package/dist/login/pkce.js +106 -0
  29. package/dist/login/pkceLogin.d.ts +53 -0
  30. package/dist/login/pkceLogin.js +152 -0
  31. package/dist/mcp/server.js +52 -25
  32. package/dist/preview/api.d.ts +84 -0
  33. package/dist/preview/api.js +174 -0
  34. package/dist/preview/command.d.ts +96 -0
  35. package/dist/preview/command.js +276 -0
  36. package/dist/preview/copy.d.ts +209 -0
  37. package/dist/preview/copy.js +444 -0
  38. package/dist/preview/index.d.ts +2 -3
  39. package/dist/preview/index.js +5 -6
  40. package/dist/preview/proxy.d.ts +12 -1
  41. package/dist/preview/proxy.js +17 -7
  42. package/dist/preview/qr.d.ts +30 -0
  43. package/dist/preview/qr.js +89 -0
  44. package/dist/preview/runner.d.ts +155 -30
  45. package/dist/preview/runner.js +219 -68
  46. package/dist/preview/stream.d.ts +52 -0
  47. package/dist/preview/stream.js +171 -0
  48. package/dist/preview/viewers.d.ts +113 -0
  49. package/dist/preview/viewers.js +242 -0
  50. package/dist/publish/api.d.ts +67 -0
  51. package/dist/publish/api.js +96 -0
  52. package/dist/publish/bounds.d.ts +25 -0
  53. package/dist/publish/bounds.js +53 -0
  54. package/dist/publish/classify.d.ts +51 -0
  55. package/dist/publish/classify.js +138 -0
  56. package/dist/publish/command.d.ts +27 -0
  57. package/dist/publish/command.js +328 -0
  58. package/dist/publish/consoleVerbs.d.ts +17 -0
  59. package/dist/publish/consoleVerbs.js +26 -0
  60. package/dist/publish/copy.d.ts +180 -0
  61. package/dist/publish/copy.js +606 -0
  62. package/dist/publish/defaults.d.ts +16 -0
  63. package/dist/publish/defaults.js +52 -0
  64. package/dist/publish/manifest.d.ts +27 -0
  65. package/dist/publish/manifest.js +81 -0
  66. package/dist/publish/paths.d.ts +25 -0
  67. package/dist/publish/paths.js +78 -0
  68. package/dist/publish/progress.d.ts +17 -0
  69. package/dist/publish/progress.js +45 -0
  70. package/dist/publish/prompt.d.ts +4 -0
  71. package/dist/publish/prompt.js +31 -0
  72. package/dist/publish/qa.f4.fixtures.d.ts +25 -0
  73. package/dist/publish/qa.f4.fixtures.js +140 -0
  74. package/dist/publish/upload.d.ts +30 -0
  75. package/dist/publish/upload.js +93 -0
  76. package/dist/relay/server.d.ts +32 -0
  77. package/dist/relay/server.js +80 -12
  78. package/dist/relay/store.d.ts +32 -3
  79. package/dist/relay/store.js +76 -22
  80. package/dist/relay/testers.d.ts +90 -0
  81. package/dist/relay/testers.js +144 -0
  82. package/dist/shared/authError.d.ts +1 -0
  83. package/dist/shared/authError.js +11 -0
  84. package/dist/shared/config.d.ts +73 -5
  85. package/dist/shared/config.js +133 -20
  86. package/package.json +2 -3
  87. package/dist/login/login.d.ts +0 -34
  88. package/dist/login/login.js +0 -148
  89. package/dist/preview/cloudflared.d.ts +0 -13
  90. package/dist/preview/cloudflared.js +0 -46
  91. package/dist/preview/tunnel.d.ts +0 -14
  92. package/dist/preview/tunnel.js +0 -28
@@ -1,26 +1,50 @@
1
1
  import type { ResolvedConfig } from "../shared/config.js";
2
+ /**
3
+ * A project as the Go API returns it. `liveUrl` is SERVER-COMPUTED — the apex
4
+ * differs per environment, so a client that builds `<slug>.<apex>` itself would
5
+ * print the wrong address on staging and dev.
6
+ */
7
+ export interface ProjectView {
8
+ id: string;
9
+ slug: string;
10
+ name: string;
11
+ orgId: string;
12
+ liveUrl: string;
13
+ publishState: string;
14
+ }
2
15
  export interface CreatedProject {
3
16
  projectId: string;
4
17
  slug: string;
5
18
  name: string;
6
- }
7
- export interface PublishResult {
8
- version: number;
9
- /** Immutable versioned URL (<slug>.<apex>/v<n>/). */
10
- url: string;
11
- /** Stable live alias (<slug>.<apex>/). */
19
+ orgId: string;
12
20
  liveUrl: string;
13
21
  }
14
22
  export declare class BackendError extends Error {
15
23
  readonly status?: number | undefined;
16
24
  constructor(message: string, status?: number | undefined);
17
25
  }
26
+ /** The address a caller asked for is not available. Carries the server's suggestions. */
27
+ export declare class SlugTakenError extends BackendError {
28
+ readonly suggestions: string[];
29
+ constructor(suggestions: string[]);
30
+ }
31
+ /**
32
+ * The uniform 404 for every unreachable-project cause. The message is the
33
+ * server's, verbatim: the console shows the same six words, so a creator moving
34
+ * between surfaces sees one product and support can match a screenshot to a log
35
+ * line without a translation table.
36
+ */
37
+ export declare class ProjectUnreachableError extends BackendError {
38
+ constructor(message: string);
39
+ }
18
40
  /**
19
- * Create a backend project (reuses POST /projects). A `slug` pins the permanent
20
- * <slug>.vincentt.app host; omit it and the platform assigns a catchy one. A
21
- * taken slug comes back 409, surfaced so the caller can re-ask.
41
+ * Register a project in an org. `slug` is optional and the server NEVER assigns
42
+ * one on a collision — it returns 409 with suggestions and the caller re-asks, so
43
+ * a creator's address is always a name they chose.
22
44
  */
23
- export declare function createProject(cfg: ResolvedConfig, name: string, slug?: string): Promise<CreatedProject>;
45
+ export declare function createProject(cfg: ResolvedConfig, orgSlug: string, name: string, slug?: string): Promise<CreatedProject>;
46
+ /** Read a project by id. Backs `link`; the org is derived server-side from the row. */
47
+ export declare function getProject(cfg: ResolvedConfig, projectId: string): Promise<ProjectView>;
24
48
  export interface MintedTunnel {
25
49
  tunnelId: string;
26
50
  /** The public host the phone opens: <slug>-<token>.<previewApex>. */
@@ -39,20 +63,20 @@ export declare function reapTunnel(cfg: ResolvedConfig, projectId: string, ref:
39
63
  }): Promise<void>;
40
64
  /** Recursively list files under `dir`, returning dir-relative POSIX paths. */
41
65
  export declare function walkDir(dir: string, base?: string): Promise<string[]>;
42
- /**
43
- * Upload a locally-built dist and make it live. Each file rides as a multipart
44
- * part whose filename is its dist-relative path (the backend reconstructs the
45
- * tree). Requires an index.html at the dist root.
46
- */
47
- export declare function publishUpload(cfg: ResolvedConfig, projectId: string, distDir: string, opts?: {
48
- note?: string;
49
- commitSha?: string;
50
- }): Promise<PublishResult>;
66
+ export interface MeMembership {
67
+ orgId: string;
68
+ orgSlug: string;
69
+ orgName: string;
70
+ orgKind: "personal" | "team";
71
+ role: string;
72
+ status: string;
73
+ }
51
74
  export interface Me {
52
75
  id?: string;
53
76
  email?: string;
54
77
  name?: string;
55
- networkId?: string;
78
+ personalOrgId?: string;
79
+ memberships?: MeMembership[];
56
80
  }
57
81
  /** Resolve the signed-in identity for `vincentt whoami`. Throws on any non-2xx so
58
82
  * the caller can degrade to "token present, unverified". */
@@ -6,6 +6,7 @@ import { promises as fs } from "node:fs";
6
6
  import path from "node:path";
7
7
  import { execFile } from "node:child_process";
8
8
  import { promisify } from "node:util";
9
+ import { authFailureMessage } from "../shared/authError.js";
9
10
  const execFileAsync = promisify(execFile);
10
11
  export class BackendError extends Error {
11
12
  status;
@@ -29,29 +30,106 @@ async function safeText(res) {
29
30
  return "";
30
31
  }
31
32
  }
33
+ /** The address a caller asked for is not available. Carries the server's suggestions. */
34
+ export class SlugTakenError extends BackendError {
35
+ suggestions;
36
+ constructor(suggestions) {
37
+ super("That address is taken.", 409);
38
+ this.suggestions = suggestions;
39
+ this.name = "SlugTakenError";
40
+ }
41
+ }
42
+ /**
43
+ * The uniform 404 for every unreachable-project cause. The message is the
44
+ * server's, verbatim: the console shows the same six words, so a creator moving
45
+ * between surfaces sees one product and support can match a screenshot to a log
46
+ * line without a translation table.
47
+ */
48
+ export class ProjectUnreachableError extends BackendError {
49
+ constructor(message) {
50
+ super(message, 404);
51
+ this.name = "ProjectUnreachableError";
52
+ }
53
+ }
54
+ async function errorBody(res) {
55
+ try {
56
+ const parsed = await res.clone().json();
57
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
58
+ }
59
+ catch {
60
+ return {};
61
+ }
62
+ }
32
63
  /**
33
- * Create a backend project (reuses POST /projects). A `slug` pins the permanent
34
- * <slug>.vincentt.app host; omit it and the platform assigns a catchy one. A
35
- * taken slug comes back 409, surfaced so the caller can re-ask.
64
+ * Register a project in an org. `slug` is optional and the server NEVER assigns
65
+ * one on a collision — it returns 409 with suggestions and the caller re-asks, so
66
+ * a creator's address is always a name they chose.
36
67
  */
37
- export async function createProject(cfg, name, slug) {
38
- const res = await authFetch(cfg, "/projects", {
68
+ export async function createProject(cfg, orgSlug, name, slug) {
69
+ const res = await authFetch(cfg, `/orgs/${encodeURIComponent(orgSlug)}/projects`, {
39
70
  method: "POST",
40
71
  headers: { "content-type": "application/json" },
41
72
  body: JSON.stringify({ name, ...(slug ? { slug } : {}) }),
42
73
  });
43
74
  if (res.status === 409) {
44
- throw new BackendError(`slug "${slug}" is already in use — pick another.`, 409);
75
+ const body = await errorBody(res);
76
+ if (body.code === "slug_taken") {
77
+ throw new SlugTakenError(Array.isArray(body.suggestions) ? body.suggestions : []);
78
+ }
45
79
  }
46
80
  if (!res.ok) {
47
- throw new BackendError(`create project failed (${res.status}): ${await safeText(res)}`, res.status);
48
- }
49
- const p = (await res.json());
50
- const projectId = p._id ?? p.id;
51
- if (!projectId || !p.slug) {
52
- throw new BackendError("create project: response missing id/slug");
81
+ const body = await errorBody(res);
82
+ // A 401 is the auth cutover, not a project error: authFailureMessage owns
83
+ // that wording. Every other status prefers the server's own message.
84
+ throw new BackendError(res.status === 401
85
+ ? authFailureMessage("create project failed", res.status, await safeText(res))
86
+ : (body.message ??
87
+ `create project failed (${res.status}): ${await safeText(res)}`), res.status);
88
+ }
89
+ const p = parseProjectView(await res.json(), "create project");
90
+ return {
91
+ projectId: p.id,
92
+ slug: p.slug,
93
+ name: p.name,
94
+ orgId: p.orgId,
95
+ liveUrl: p.liveUrl,
96
+ };
97
+ }
98
+ /** Read a project by id. Backs `link`; the org is derived server-side from the row. */
99
+ export async function getProject(cfg, projectId) {
100
+ const res = await authFetch(cfg, `/projects/${encodeURIComponent(projectId)}`);
101
+ if (!res.ok) {
102
+ const body = await errorBody(res);
103
+ if (res.status === 404) {
104
+ throw new ProjectUnreachableError(body.message ?? "This project is not available to you.");
105
+ }
106
+ throw new BackendError(res.status === 401
107
+ ? authFailureMessage("get project failed", res.status, await safeText(res))
108
+ : (body.message ?? `get project failed (${res.status}): ${await safeText(res)}`), res.status);
53
109
  }
54
- return { projectId, slug: p.slug, name: p.name ?? name };
110
+ return parseProjectView(await res.json(), "get project");
111
+ }
112
+ /**
113
+ * The wire shape is `id`, not `_id`. The legacy tolerance (`p._id ?? p.id`) is
114
+ * deliberately gone: it would have let an iteration-2-shaped response through
115
+ * silently, which is exactly how a contract break survives two green suites.
116
+ */
117
+ function parseProjectView(raw, what) {
118
+ const p = (raw ?? {});
119
+ const str = (k) => typeof p[k] === "string" ? p[k] : undefined;
120
+ const id = str("id");
121
+ const slug = str("slug");
122
+ if (!id || !slug) {
123
+ throw new BackendError(`${what}: response missing id/slug`);
124
+ }
125
+ return {
126
+ id,
127
+ slug,
128
+ name: str("name") ?? slug,
129
+ orgId: str("orgId") ?? "",
130
+ liveUrl: str("liveUrl") ?? "",
131
+ publishState: str("publishState") ?? "never",
132
+ };
55
133
  }
56
134
  /** Mint a per-session dev tunnel for `localPort` (the backend holds CF creds). */
57
135
  export async function mintTunnel(cfg, projectId, localPort) {
@@ -61,7 +139,7 @@ export async function mintTunnel(cfg, projectId, localPort) {
61
139
  body: JSON.stringify({ port: localPort }),
62
140
  });
63
141
  if (!res.ok) {
64
- throw new BackendError(`tunnel mint failed (${res.status}): ${await safeText(res)}`, res.status);
142
+ throw new BackendError(authFailureMessage("tunnel mint failed", res.status, await safeText(res)), res.status);
65
143
  }
66
144
  return (await res.json());
67
145
  }
@@ -100,48 +178,17 @@ export async function walkDir(dir, base = dir) {
100
178
  }
101
179
  return out;
102
180
  }
103
- /**
104
- * Upload a locally-built dist and make it live. Each file rides as a multipart
105
- * part whose filename is its dist-relative path (the backend reconstructs the
106
- * tree). Requires an index.html at the dist root.
107
- */
108
- export async function publishUpload(cfg, projectId, distDir, opts = {}) {
109
- const files = await walkDir(distDir);
110
- if (!files.includes("index.html")) {
111
- throw new BackendError(`dist at ${distDir} has no index.html at its root — did the build run?`);
112
- }
113
- const form = new FormData();
114
- // The real dist-relative paths ride as an ordered manifest — a multipart filename
115
- // can't carry a directory (FormData strips it to a basename), so the backend maps
116
- // paths[i] → files[i] to rebuild the tree.
117
- form.append("paths", JSON.stringify(files));
118
- for (const rel of files) {
119
- const bytes = await fs.readFile(path.join(distDir, rel));
120
- // Uint8Array (Buffer) is a valid BlobPart; the filename is cosmetic (the backend
121
- // uses the manifest), but pass the basename so server logs read sensibly.
122
- form.append("files", new Blob([bytes]), path.basename(rel));
123
- }
124
- if (opts.note)
125
- form.append("note", opts.note);
126
- if (opts.commitSha)
127
- form.append("commitSha", opts.commitSha);
128
- const res = await authFetch(cfg, `/projects/${projectId}/publish/upload`, {
129
- method: "POST",
130
- body: form,
131
- });
132
- if (!res.ok) {
133
- throw new BackendError(`publish failed (${res.status}): ${await safeText(res)}`, res.status);
134
- }
135
- return (await res.json());
136
- }
137
181
  /** Resolve the signed-in identity for `vincentt whoami`. Throws on any non-2xx so
138
182
  * the caller can degrade to "token present, unverified". */
139
183
  export async function getMe(cfg) {
140
- const res = await authFetch(cfg, "/network/me");
184
+ const res = await authFetch(cfg, "/me");
141
185
  if (!res.ok) {
142
- throw new BackendError(`/network/me failed (${res.status}): ${await safeText(res)}`, res.status);
186
+ throw new BackendError(authFailureMessage("Identity check failed", res.status, await safeText(res)), res.status);
143
187
  }
144
- return (await res.json());
188
+ // GET /me returns { me: {...}, csrfToken }; unwrap it, but tolerate a bare user
189
+ // object so a contract tweak doesn't break whoami.
190
+ const body = (await res.json());
191
+ return (body.me ?? body);
145
192
  }
146
193
  /** The working tree's current commit, for publish provenance. Undefined if not a repo. */
147
194
  export async function gitHeadSha(cwd) {
@@ -7,7 +7,32 @@ export declare function httpRelayClient(baseUrl: string): RelayClient;
7
7
  export declare function formatLog(e: LogEvent, now: number): string;
8
8
  export declare function formatNetwork(e: NetworkEvent, now: number): string;
9
9
  export declare function formatTrace(e: TraceEvent, now: number): string;
10
- /** Render a RelayResult for one kind into the text block a tool returns. */
10
+ /**
11
+ * Render a RelayResult for one kind into the text block a tool returns.
12
+ *
13
+ * EVERY field is read defensively, because the CLI and the relay are versioned
14
+ * SEPARATELY on a user's machine even though they ship from one monorepo:
15
+ * `npx vincentt` fetches whatever the registry has, and points it at whatever
16
+ * relay `vincentt preview` happens to be running. A field this renderer reads
17
+ * unguarded is a field whose removal from the relay CRASHES an older CLI.
18
+ *
19
+ * That is not hypothetical — it is exactly what removing `sessions` did:
20
+ * `result.sessions.length` on a response that no longer carried the field took
21
+ * down the whole command, while `--json` kept working because it never touched
22
+ * it. A missing optional array must degrade to a missing LINE, never to an
23
+ * exception that hides the events the creator asked for.
24
+ */
11
25
  export declare function renderResult(result: RelayResult, kind: DiagEvent["kind"], now: number): string;
12
- /** Only-errors convenience filter applied on top of a log query result. */
26
+ /**
27
+ * Only-errors convenience filter applied on top of a log query result.
28
+ *
29
+ * EVERY index-parallel array is filtered with the SAME predicate rather than
30
+ * passed through: dropping events without dropping their tags would shift each
31
+ * remaining tag by one and attribute every error to the wrong tester — which is
32
+ * worse than no attribution, because it looks right.
33
+ *
34
+ * Adding a parallel array to RelayResult means adding it here too. `eventTesters`
35
+ * arrived with tester attribution and was missed on the first pass, which is
36
+ * exactly the failure this comment already described.
37
+ */
13
38
  export declare function filterErrors(result: RelayResult): RelayResult;
@@ -36,26 +36,91 @@ export function formatTrace(e, now) {
36
36
  return (`${e.fps.toFixed(0)}fps over ${e.windowMs}ms · longest task ${e.longestTaskMs}ms · ` +
37
37
  `${e.longTaskCount} long task(s)${marks} · ${ago(e.t, now)} #${e.seq}`);
38
38
  }
39
- /** Render a RelayResult for one kind into the text block a tool returns. */
39
+ /**
40
+ * Render a RelayResult for one kind into the text block a tool returns.
41
+ *
42
+ * EVERY field is read defensively, because the CLI and the relay are versioned
43
+ * SEPARATELY on a user's machine even though they ship from one monorepo:
44
+ * `npx vincentt` fetches whatever the registry has, and points it at whatever
45
+ * relay `vincentt preview` happens to be running. A field this renderer reads
46
+ * unguarded is a field whose removal from the relay CRASHES an older CLI.
47
+ *
48
+ * That is not hypothetical — it is exactly what removing `sessions` did:
49
+ * `result.sessions.length` on a response that no longer carried the field took
50
+ * down the whole command, while `--json` kept working because it never touched
51
+ * it. A missing optional array must degrade to a missing LINE, never to an
52
+ * exception that hides the events the creator asked for.
53
+ */
40
54
  export function renderResult(result, kind, now) {
41
- const lines = result.events.map((e) => {
55
+ const events = result.events ?? [];
56
+ const latestSeq = typeof result.latestSeq === "number" ? result.latestSeq : -1;
57
+ const lines = events.map((e) => {
42
58
  if (e.kind === "log")
43
59
  return formatLog(e, now);
44
60
  if (e.kind === "network")
45
61
  return formatNetwork(e, now);
46
62
  return formatTrace(e, now);
47
63
  });
48
- const header = result.sessions.length > 1 ? `sessions: ${result.sessions.join(", ")}\n` : "";
49
- const cursor = `\n\n(latestSeq ${result.latestSeq} — pass since=${result.latestSeq} to get only newer ${kind} events)`;
64
+ // Named whenever there is more than one tester, because a merged stream from
65
+ // four phones is not debuggable and the agent has to be told the filter
66
+ // exists.
67
+ //
68
+ // There is NO `sessions:` line. It printed the raw client-supplied session key
69
+ // to the terminal and into MCP tool output — a viewer-controlled string with
70
+ // no validation, on the path the viewer label is carefully guarded on. The
71
+ // tester id replaces it and is minted here, on this machine.
72
+ const header = [testersLine(result.testers), viewersLine(result.viewers)]
73
+ .filter(Boolean)
74
+ .join("\n");
75
+ const prefix = header ? `${header}\n` : "";
76
+ const cursor = `\n\n(latestSeq ${latestSeq} — pass since=${latestSeq} to get only newer ${kind} events)`;
50
77
  if (lines.length === 0) {
51
- return `${header}No ${kind} events${result.latestSeq >= 0 ? " matched" : " yet — is the preview app open on the device?"}.${result.latestSeq >= 0 ? cursor : ""}`;
78
+ return `${prefix}No ${kind} events${latestSeq >= 0 ? " matched" : " yet — is the preview app open on the device?"}.${latestSeq >= 0 ? cursor : ""}`;
52
79
  }
53
- return `${header}${lines.join("\n")}${cursor}`;
80
+ return `${prefix}${lines.join("\n")}${cursor}`;
54
81
  }
55
- /** Only-errors convenience filter applied on top of a log query result. */
82
+ /**
83
+ * The tester line leads, because the tester is what an agent should filter on:
84
+ * it survives the reload a tester does before reporting a bug, so it is the
85
+ * coordinate that still resolves by the time anyone reads this.
86
+ */
87
+ function testersLine(testers) {
88
+ if (!testers || testers.length < 2)
89
+ return "";
90
+ return `testers: ${testers.join(", ")} (pass tester to filter to one)`;
91
+ }
92
+ function viewersLine(viewers) {
93
+ if (!viewers || viewers.length < 2)
94
+ return "";
95
+ // "connection", not "device": one tab opens several of these, so calling a
96
+ // viewer a device is the wrong count in the same words that made "2 viewers"
97
+ // wrong on the roster.
98
+ return `viewers: ${viewers.join(", ")} (pass viewer to filter to one connection)`;
99
+ }
100
+ /**
101
+ * Only-errors convenience filter applied on top of a log query result.
102
+ *
103
+ * EVERY index-parallel array is filtered with the SAME predicate rather than
104
+ * passed through: dropping events without dropping their tags would shift each
105
+ * remaining tag by one and attribute every error to the wrong tester — which is
106
+ * worse than no attribution, because it looks right.
107
+ *
108
+ * Adding a parallel array to RelayResult means adding it here too. `eventTesters`
109
+ * arrived with tester attribution and was missed on the first pass, which is
110
+ * exactly the failure this comment already described.
111
+ */
56
112
  export function filterErrors(result) {
113
+ const events = result.events ?? [];
114
+ const keep = events.map((e) => e.kind === "log" && e.level === "error");
115
+ const parallel = (a) => a === undefined ? undefined : a.filter((_, i) => keep[i]);
57
116
  return {
58
117
  ...result,
59
- events: result.events.filter((e) => e.kind === "log" && e.level === "error"),
118
+ events: events.filter((_, i) => keep[i]),
119
+ ...(result.eventViewers === undefined
120
+ ? {}
121
+ : { eventViewers: parallel(result.eventViewers) }),
122
+ ...(result.eventTesters === undefined
123
+ ? {}
124
+ : { eventTesters: parallel(result.eventTesters) }),
60
125
  };
61
126
  }
@@ -0,0 +1,14 @@
1
+ export interface InitDeps {
2
+ print: (line: string) => void;
3
+ cwd: string;
4
+ scaffold?: (targetDir: string) => Promise<void>;
5
+ install?: (targetDir: string) => Promise<string>;
6
+ isEmpty?: (targetDir: string) => Promise<boolean>;
7
+ }
8
+ export interface InitOptions {
9
+ /** Positional target directory; defaults to the cwd. */
10
+ dir?: string;
11
+ /** Skip the dependency install (CI, or a creator who wants to pick a manager). */
12
+ skipInstall?: boolean;
13
+ }
14
+ export declare function initCommand(deps: InitDeps, opts?: InitOptions): Promise<number>;
@@ -0,0 +1,75 @@
1
+ // `vincentt init [dir]` — scaffold a Vincentt XR app. NO account, NO network call
2
+ // to the API, NO binding written.
3
+ //
4
+ // This is the acquisition wedge: local-first is a smaller room, not a wall, so the
5
+ // template must be reachable without ever touching the auth gate. `create` (which
6
+ // does need an account) is offered at the end as the next thing AVAILABLE, never as
7
+ // a requirement.
8
+ import path from "node:path";
9
+ import { detectPackageManager, installDependencies, needsScaffold, scaffoldFromTemplate, } from "../scaffold/index.js";
10
+ export async function initCommand(deps, opts = {}) {
11
+ const target = path.resolve(deps.cwd, opts.dir ?? ".");
12
+ const label = opts.dir ? `./${opts.dir}` : ".";
13
+ const isEmpty = deps.isEmpty ?? needsScaffold;
14
+ if (!(await isEmpty(target))) {
15
+ deps.print(`✗ ${label} already contains an app (package.json).`);
16
+ deps.print("");
17
+ deps.print(" vincentt init <new-dir> Scaffold somewhere else");
18
+ deps.print(" vincentt create Register THIS folder as a project");
19
+ return 1;
20
+ }
21
+ deps.print(`→ Scaffolding a Vincentt XR app in ${label}…`);
22
+ deps.print(" · React + react-three-fiber + @vincentt-xr/sdk");
23
+ const scaffold = deps.scaffold ?? ((dir) => scaffoldFromTemplate(dir));
24
+ try {
25
+ await scaffold(target);
26
+ }
27
+ catch (err) {
28
+ deps.print("");
29
+ deps.print(`✗ ${err instanceof Error ? err.message : String(err)}`);
30
+ deps.print("");
31
+ deps.print(" Run `vincentt init` again once that is resolved.");
32
+ return 1;
33
+ }
34
+ let installed = true;
35
+ if (!opts.skipInstall) {
36
+ deps.print(" · installing dependencies");
37
+ const install = deps.install ?? ((dir) => installDependencies(dir));
38
+ try {
39
+ await install(target);
40
+ }
41
+ catch {
42
+ installed = false;
43
+ }
44
+ }
45
+ // The template ships a pnpm lockfile and a hoisted node-linker, so telling a
46
+ // creator to `npm run dev` in it produces a second lockfile and an unproven
47
+ // node_modules layout. Name the manager the scaffold is actually set up for.
48
+ const pm = await detectPackageManager(target).catch(() => "npm");
49
+ deps.print("");
50
+ deps.print(`✓ Created ${label}`);
51
+ deps.print("");
52
+ const steps = [];
53
+ if (opts.dir)
54
+ steps.push([`cd ${opts.dir}`, ""]);
55
+ if (!installed || opts.skipInstall) {
56
+ steps.push([`${pm} install`, "Install dependencies"]);
57
+ }
58
+ steps.push([`${pm} run dev`, "Start the local dev server"]);
59
+ for (const line of alignedSteps(steps))
60
+ deps.print(line);
61
+ deps.print("");
62
+ deps.print(" No account needed. When you're ready to put it online:");
63
+ for (const line of alignedSteps([
64
+ ["vincentt login", "Connect this machine to Vincentt"],
65
+ ["vincentt create", "Register the project and reserve its address"],
66
+ ])) {
67
+ deps.print(line);
68
+ }
69
+ return 0;
70
+ }
71
+ /** Two columns, aligned on the widest command, so the block reads as a table. */
72
+ function alignedSteps(steps) {
73
+ const width = Math.max(...steps.map(([cmd]) => cmd.length));
74
+ return steps.map(([cmd, note]) => note ? ` ${cmd.padEnd(width)} ${note}` : ` ${cmd}`);
75
+ }
@@ -1,33 +1,80 @@
1
+ import { type ResolvedOrg } from "./org.js";
1
2
  import { type RelayClient } from "./diagnostics.js";
2
- import { type RunningPreview } from "../preview/index.js";
3
+ import { type ResolvedConfig } from "../shared/config.js";
4
+ import { type RunningPreview, type StartPreviewOptions } from "../preview/index.js";
3
5
  import type { DiagEventKind, RelayResult } from "@vincentt-xr/harness/events";
4
6
  export interface CreateOptions {
5
7
  name?: string;
6
8
  slug?: string;
7
- scaffold?: boolean;
8
- /** Rebind a directory that already has a binding (drops the old one). */
9
+ /** Replace a binding this folder already has. */
9
10
  force?: boolean;
10
- /** Step-level progress for slow phases (scaffold, install). CLI prints; MCP omits. */
11
- onProgress?: (message: string) => void;
11
+ /** --org <slug>; else VINCENTT_ORG; else the machine config's active org. */
12
+ org?: string;
13
+ /** Honor a binding's apiUrl override (--allow-custom-api). */
14
+ allowCustomApi?: boolean;
15
+ /**
16
+ * Stream the transcript as it happens. The CLI passes console.log so a slow
17
+ * create shows progress and the apiUrl advisory lands before the request; MCP
18
+ * omits it and reads the buffered `message`.
19
+ */
20
+ onLine?: (line: string) => void;
12
21
  }
13
22
  export interface CreateResult {
23
+ /** The full transcript, exactly as the CLI prints it. */
14
24
  message: string;
15
- alreadyBound: boolean;
16
- slug?: string;
17
- projectId?: string;
25
+ /** Non-zero when nothing was created (already linked, address taken, no org). */
26
+ exitCode: number;
18
27
  }
28
+ /**
29
+ * The shared `create` orchestration behind both `vincentt create` and the MCP
30
+ * `project_create` tool, so the two surfaces can never diverge in behavior OR in
31
+ * what they tell the caller.
32
+ *
33
+ * It does NOT scaffold — that is `vincentt init`, which needs no account. See
34
+ * `initCommand`.
35
+ */
19
36
  export declare function projectCreate(projectCwd: string, opts?: CreateOptions): Promise<CreateResult>;
37
+ /**
38
+ * Resolve which org a create acts in: the named one if it is an ACTIVE membership,
39
+ * else the personal org when nothing is named. A named org the caller is not an
40
+ * active member of resolves to nothing rather than silently falling back — acting
41
+ * in an org the creator did not ask for is the retargeting the binding rules forbid.
42
+ */
43
+ export declare function resolveOrgForCommand(cfg: ResolvedConfig, input: {
44
+ flag?: string;
45
+ }): Promise<{
46
+ ok: true;
47
+ org: ResolvedOrg;
48
+ } | {
49
+ ok: false;
50
+ message: string;
51
+ }>;
20
52
  export interface PublishOptions {
21
53
  distDir?: string;
22
54
  note?: string;
55
+ allowCustomApi?: boolean;
56
+ /**
57
+ * Where the apiUrl advisory goes. It must reach the creator BEFORE the request,
58
+ * so it cannot ride the returned message — that is only rendered after the call
59
+ * has already happened (or thrown).
60
+ */
61
+ warn?: (line: string) => void;
62
+ /** Skip the confirmation. REFUSED on a first publish; see publishCommand. */
63
+ yes?: boolean;
23
64
  }
24
65
  export interface PublishOutcome {
66
+ /** The full transcript, exactly as the CLI prints it. */
25
67
  message: string;
26
- notBound: boolean;
27
- version?: number;
28
- liveUrl?: string;
29
- url?: string;
68
+ exitCode: number;
30
69
  }
70
+ /**
71
+ * The shared `publish` orchestration behind both `vincentt publish` and the MCP
72
+ * `project_publish` tool, so the two surfaces can never diverge.
73
+ *
74
+ * MCP has no terminal, so it is a non-TTY caller: the command refuses without
75
+ * `--yes` rather than assuming consent, and refuses `--yes` outright on a first
76
+ * publish. An agent cannot make an address permanent.
77
+ */
31
78
  export declare function projectPublish(projectCwd: string, opts?: PublishOptions): Promise<PublishOutcome>;
32
79
  export declare function getActivePreview(): RunningPreview | null;
33
80
  export interface PreviewStartResult {
@@ -35,7 +82,14 @@ export interface PreviewStartResult {
35
82
  /** false when a preview was already running (the existing one is returned). */
36
83
  started: boolean;
37
84
  }
38
- export declare function previewStart(projectCwd: string, onLog: (msg: string) => void): Promise<PreviewStartResult>;
85
+ /** Everything the runner needs that this process does not already know. */
86
+ export type PreviewStartOptions = Omit<StartPreviewOptions, "projectCwd" | "config" | "projectId">;
87
+ /**
88
+ * Resolve the binding and the config, then start. The binding is a HINT and
89
+ * never an authority (B12): the projectId it carries is presented to the API,
90
+ * which resolves the org itself and answers 404 for every unreachable cause.
91
+ */
92
+ export declare function previewStart(projectCwd: string, onLog: (msg: string) => void, extra?: PreviewStartOptions): Promise<PreviewStartResult>;
39
93
  export declare function previewStop(): Promise<boolean>;
40
94
  export interface PreviewStopOutcome {
41
95
  stopped: boolean;
@@ -44,18 +98,30 @@ export interface PreviewStopOutcome {
44
98
  /**
45
99
  * Stop a preview started by a *different* process (the common case: `vincentt
46
100
  * preview` runs in the background and the agent later wants it down). Reads the
47
- * pidfile and signals the recorded pids rather than the in-process `activePreview`.
48
- * SIGTERM to the preview process runs its graceful handler (which reaps the tunnel
49
- * + backend DNS route); if that process is already gone, its cloudflared child may
50
- * be orphaned to init, so kill the recorded tunnel pid directly. Either way, clear
51
- * the (now stale) relay info.
101
+ * pidfile and signals the recorded pid rather than the in-process
102
+ * `activePreview`.
103
+ *
104
+ * There is NO orphaned-tunnel case any more, and that is the point of the edge
105
+ * repoint: the tunnel is a socket this process owns rather than a child process
106
+ * that could be reparented to init. When the preview process dies the socket
107
+ * closes, the edge sees the tunnel go, and the session drains on its own — so
108
+ * SIGTERM plus clearing the stale relay info is the whole of the cleanup.
52
109
  */
53
110
  export declare function previewStopSignal(projectCwd: string): Promise<PreviewStopOutcome>;
54
111
  export interface DiagQueryOptions {
55
- sessionId?: string;
56
112
  since?: number;
57
113
  limit?: number;
58
114
  errorsOnly?: boolean;
115
+ /**
116
+ * One tester — one browser tab, across its refreshes. The filter that answers
117
+ * "it breaks on their phone but not mine". Absent = every tester.
118
+ */
119
+ tester?: string;
120
+ /**
121
+ * One CONNECTION (2.8), the sub-coordinate below the tester. Absent = every
122
+ * viewer, which is the default read.
123
+ */
124
+ viewer?: string;
59
125
  }
60
126
  /** Raw diagnostics query against the running relay (used by CLI --json). */
61
127
  export declare function diagQuery(projectCwd: string, kind: DiagEventKind, opts?: DiagQueryOptions, relay?: RelayClient): Promise<RelayResult>;