@vincentt-xr/cli 0.17.0 → 0.18.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 (95) hide show
  1. package/MIGRATION.md +24 -0
  2. package/README.md +94 -1
  3. package/dist/cli/index.js +170 -84
  4. package/dist/cli/usage-text.js +32 -10
  5. package/dist/domain/api.d.ts +10 -8
  6. package/dist/domain/api.js +14 -9
  7. package/dist/domain/command.js +25 -16
  8. package/dist/domain/copy.d.ts +6 -3
  9. package/dist/domain/copy.js +10 -6
  10. package/dist/dx/command.d.ts +24 -4
  11. package/dist/dx/command.js +80 -25
  12. package/dist/dx/consent.js +6 -5
  13. package/dist/dx/journal.d.ts +17 -2
  14. package/dist/dx/journal.js +19 -3
  15. package/dist/dx/report.js +4 -3
  16. package/dist/dx/reportIo.js +6 -5
  17. package/dist/dx/send.js +6 -5
  18. package/dist/lib/backend.d.ts +0 -16
  19. package/dist/lib/backend.js +2 -26
  20. package/dist/lib/detect.d.ts +87 -0
  21. package/dist/lib/detect.js +867 -0
  22. package/dist/lib/diagnostics.js +2 -2
  23. package/dist/lib/feedback.js +1 -1
  24. package/dist/lib/feedbackCopy.js +1 -1
  25. package/dist/lib/initFrom.js +4 -4
  26. package/dist/lib/lifecycle.d.ts +2 -0
  27. package/dist/lib/lifecycle.js +9 -3
  28. package/dist/lib/listProjects.js +2 -1
  29. package/dist/lib/projects.d.ts +37 -2
  30. package/dist/lib/projects.js +86 -21
  31. package/dist/lib/settings.d.ts +113 -0
  32. package/dist/lib/settings.js +284 -0
  33. package/dist/lib/settingsCopy.d.ts +6 -0
  34. package/dist/lib/settingsCopy.js +99 -0
  35. package/dist/lib/settle.d.ts +61 -0
  36. package/dist/lib/settle.js +338 -0
  37. package/dist/lib/template.d.ts +19 -0
  38. package/dist/lib/template.js +73 -0
  39. package/dist/lib/terminalText.d.ts +3 -0
  40. package/dist/lib/terminalText.js +10 -0
  41. package/dist/lib/version.js +5 -11
  42. package/dist/lib/whoami.js +10 -3
  43. package/dist/library/assetsCommand.js +1 -1
  44. package/dist/library/client.js +8 -3
  45. package/dist/library/render.js +8 -6
  46. package/dist/login/authBackend.js +3 -2
  47. package/dist/login/ensure.d.ts +8 -2
  48. package/dist/login/ensure.js +21 -10
  49. package/dist/login/guard.js +5 -4
  50. package/dist/login/loginCommand.d.ts +2 -2
  51. package/dist/login/loginCommand.js +29 -5
  52. package/dist/login/pkce.js +1 -1
  53. package/dist/login/pkceLogin.js +1 -1
  54. package/dist/platform/outdated.js +2 -2
  55. package/dist/platform/upgradeReport.js +16 -12
  56. package/dist/preview/api.d.ts +5 -11
  57. package/dist/preview/api.js +13 -15
  58. package/dist/preview/command.d.ts +29 -0
  59. package/dist/preview/command.js +61 -1
  60. package/dist/preview/copy.d.ts +35 -3
  61. package/dist/preview/copy.js +102 -10
  62. package/dist/preview/proxy.d.ts +38 -1
  63. package/dist/preview/proxy.js +151 -2
  64. package/dist/preview/qrPage.js +1 -1
  65. package/dist/preview/runner.d.ts +25 -13
  66. package/dist/preview/runner.js +127 -26
  67. package/dist/preview/viewers.js +1 -1
  68. package/dist/publish/api.d.ts +22 -5
  69. package/dist/publish/api.js +80 -9
  70. package/dist/publish/command.d.ts +25 -0
  71. package/dist/publish/command.js +211 -35
  72. package/dist/publish/copy.d.ts +78 -5
  73. package/dist/publish/copy.js +271 -37
  74. package/dist/publish/fit.d.ts +15 -0
  75. package/dist/publish/fit.js +103 -0
  76. package/dist/publish/manifest.d.ts +44 -3
  77. package/dist/publish/manifest.js +172 -3
  78. package/dist/publish/prompt.d.ts +4 -0
  79. package/dist/publish/prompt.js +15 -1
  80. package/dist/publish/upload.d.ts +6 -0
  81. package/dist/publish/upload.js +11 -1
  82. package/dist/publish/visibility.d.ts +21 -0
  83. package/dist/publish/visibility.js +68 -0
  84. package/dist/relay/annotations.js +9 -4
  85. package/dist/relay/server.d.ts +21 -0
  86. package/dist/relay/server.js +35 -1
  87. package/dist/shared/authError.d.ts +27 -0
  88. package/dist/shared/authError.js +48 -4
  89. package/dist/shared/config.d.ts +21 -1
  90. package/dist/shared/config.js +39 -8
  91. package/dist/shared/countLimit.d.ts +7 -2
  92. package/dist/shared/countLimit.js +12 -5
  93. package/dist/shared/http.d.ts +23 -0
  94. package/dist/shared/http.js +58 -0
  95. package/package.json +7 -3
@@ -73,9 +73,9 @@ export function renderResult(result, kind, now) {
73
73
  .filter(Boolean)
74
74
  .join("\n");
75
75
  const prefix = header ? `${header}\n` : "";
76
- const cursor = `\n\n(latestSeq ${latestSeq} — pass since=${latestSeq} to get only newer ${kind} events)`;
76
+ const cursor = `\n\n(latestSeq ${latestSeq}; pass since=${latestSeq} to get only newer ${kind} events)`;
77
77
  if (lines.length === 0) {
78
- return `${prefix}No ${kind} events${latestSeq >= 0 ? " matched" : " yet — is the preview app open on the device?"}.${latestSeq >= 0 ? cursor : ""}`;
78
+ return `${prefix}No ${kind} events${latestSeq >= 0 ? " matched" : " yet (is the preview app open on the device?)"}.${latestSeq >= 0 ? cursor : ""}`;
79
79
  }
80
80
  return `${prefix}${lines.join("\n")}${cursor}`;
81
81
  }
@@ -89,7 +89,7 @@ export async function fetchNext(cwd, opts) {
89
89
  const info = await readRelayInfo(cwd);
90
90
  if (!info)
91
91
  return { kind: "closed", reason: "unreachable" };
92
- const base = `http://localhost:${info.relayPort}`;
92
+ const base = `http://127.0.0.1:${info.relayPort}`;
93
93
  const u = new URL(`${info.harnessPath}/feedback/next`, base);
94
94
  u.searchParams.set("project", cwd);
95
95
  u.searchParams.set("after", String(opts.after));
@@ -86,7 +86,7 @@ export function sessionEnded(handed) {
86
86
  "",
87
87
  "✗ The preview ended. Nothing more can arrive on it.",
88
88
  "",
89
- ` Nothing was missed — the ${handed} already handed over stay handed over.`,
89
+ ` Nothing was missed: the ${handed} already handed over stay handed over.`,
90
90
  "",
91
91
  ];
92
92
  }
@@ -22,7 +22,7 @@ const ORG_SOURCE_LABEL = {
22
22
  };
23
23
  /** The one sentence every registration-half failure opens with. Byte-identical across arms. */
24
24
  function reassure(print, dirLabel) {
25
- print(` Your source is fine — ${dirLabel} is on disk and it's yours.`);
25
+ print(` Your source is fine: ${dirLabel} is on disk and it's yours.`);
26
26
  }
27
27
  /** The resume block: `cd <dir>` (when there is one) then the exact create command. */
28
28
  function resume(print, dirLabel, command, needsCd = true) {
@@ -89,7 +89,7 @@ export async function registerAfterScaffold(deps, opts = {}) {
89
89
  // B-F2-9(a): the open route must be named in something the creator's
90
90
  // agent reads. On a non-TTY this output IS that artifact — the TTY
91
91
  // branch's login prompt, which carries the same line, is unreachable here.
92
- deps.print(" Or don't — the local dev loop needs no account.");
92
+ deps.print(" Or don't: the local dev loop needs no account.");
93
93
  }
94
94
  deps.print("");
95
95
  deps.print(closing);
@@ -97,7 +97,7 @@ export async function registerAfterScaffold(deps, opts = {}) {
97
97
  }
98
98
  deps.print("");
99
99
  deps.print("→ Now registering the project so it gets an address.");
100
- deps.print(" You're not signed in yet — opening the browser to authorize this device…");
100
+ deps.print(" You're not signed in yet. Opening the browser to authorize this device…");
101
101
  // The open path's load-bearing home, and the only new sentence in this flow.
102
102
  // The reader is a human holding working source who is being asked for an
103
103
  // account thirty seconds after watching an app appear on their disk — the one
@@ -212,7 +212,7 @@ export async function registerAfterScaffold(deps, opts = {}) {
212
212
  // not happening.
213
213
  deps.print("");
214
214
  deps.print(scaffolded
215
- ? "! That address is taken. Your source is fine — pick another."
215
+ ? "! That address is taken. Your source is fine; pick another."
216
216
  : "! That address is taken. Pick another.");
217
217
  if (err.suggestions.length > 0) {
218
218
  deps.print("");
@@ -26,6 +26,8 @@ export interface CreateOptions {
26
26
  runLogin?: () => Promise<void>;
27
27
  /** Where the resolved org came from, so a surprising value is diagnosable. */
28
28
  orgSource?: () => OrgSource;
29
+ /** The configured console base, named by the occupied-folder refusal. */
30
+ consoleUrl?: string;
29
31
  /**
30
32
  * Stream the transcript as it happens. The CLI passes console.log so a slow
31
33
  * create shows progress and the apiUrl advisory lands before the request; MCP
@@ -55,6 +55,7 @@ export async function projectCreate(projectCwd, opts = {}) {
55
55
  currentToken: opts.currentToken,
56
56
  runLogin: opts.runLogin,
57
57
  orgSource: opts.orgSource,
58
+ consoleUrl: opts.consoleUrl,
58
59
  }, {
59
60
  name: opts.name,
60
61
  slug: opts.slug,
@@ -116,7 +117,11 @@ export async function projectPublish(projectCwd, opts = {}) {
116
117
  binding: async () => {
117
118
  const located = await locateProjectBinding(projectCwd);
118
119
  return located
119
- ? { projectId: located.binding.projectId, slug: located.binding.slug }
120
+ ? {
121
+ projectId: located.binding.projectId,
122
+ slug: located.binding.slug,
123
+ dir: located.dir,
124
+ }
120
125
  : undefined;
121
126
  },
122
127
  token: async () => process.env.VINCENTT_PAT ?? (await loadMachineConfig()).pat,
@@ -306,9 +311,10 @@ async function relayForProject(projectCwd, override) {
306
311
  // error instead of the one sentence that tells the creator what to do.
307
312
  const info = await readLivePreview(projectCwd);
308
313
  if (!info) {
309
- throw new Error("No running preview for this project — run `vincentt preview` first.");
314
+ throw new Error("No running preview for this project. Run `vincentt preview` first.");
310
315
  }
311
- return httpRelayClient(`http://localhost:${info.relayPort}`);
316
+ // The relay listens on 127.0.0.1 only; `localhost` may resolve to ::1 first.
317
+ return httpRelayClient(`http://127.0.0.1:${info.relayPort}`);
312
318
  }
313
319
  /** Raw diagnostics query against the running relay (used by CLI --json). */
314
320
  export async function diagQuery(projectCwd, kind, opts = {}, relay) {
@@ -21,6 +21,7 @@
21
21
  // branch is testable with no API, no HOME, and no network.
22
22
  import { compactEntries, fileLocationStore, } from "../shared/locationStore.js";
23
23
  import { joinLocations, verifyLocation } from "./locations.js";
24
+ import { EXIT } from "./exitCodes.js";
24
25
  /** The unknown case, printed rather than omitted. Repeated verbatim in the scaffolded agent contract. */
25
26
  export const NO_LOCATION = "no folder known on this machine";
26
27
  /** The row's own two spaces, plus four. A CONSTANT — never aligned to a column. */
@@ -37,7 +38,7 @@ export async function listProjectsCommand(deps, opts = {}) {
37
38
  const cfg = await deps.config();
38
39
  if (!cfg) {
39
40
  deps.print("Not signed in. Run `vincentt login`.");
40
- return 1;
41
+ return EXIT.NOT_AUTHENTICATED;
41
42
  }
42
43
  const org = await deps.org();
43
44
  if (!org.ok) {
@@ -1,8 +1,9 @@
1
1
  import { type FolderCase } from "./classifyTarget.js";
2
- import { type OrgSource } from "./initFrom.js";
2
+ import { registerAfterScaffold, type OrgSource } from "./initFrom.js";
3
3
  import type { ScaffoldCall } from "./initCommand.js";
4
4
  import { type ScaffoldOutcome } from "../scaffold/index.js";
5
5
  import type { Ask } from "../publish/prompt.js";
6
+ import { settleSettings, type JoinFlags } from "./settle.js";
6
7
  import { type ResolvedConfig } from "../shared/config.js";
7
8
  export interface ProjectCommandDeps {
8
9
  /** Print a transcript line. */
@@ -45,6 +46,10 @@ export interface CreateCommandDeps extends ProjectCommandDeps {
45
46
  runLogin?: () => Promise<void>;
46
47
  /** Where the resolved org came from, so a surprising value is diagnosable (AF-3). */
47
48
  orgSource?: () => OrgSource;
49
+ /** The configured console base, named by the occupied-folder refusal. */
50
+ consoleUrl?: string;
51
+ /** Injected for tests. */
52
+ settle?: typeof settleSettings;
48
53
  }
49
54
  export interface CreateCommandOptions {
50
55
  name?: string;
@@ -67,6 +72,8 @@ export interface CreateCommandOptions {
67
72
  * (registering a folder that already holds an app) or when resolution failed.
68
73
  */
69
74
  templateVersion?: string;
75
+ /** Run detection and settle vincentt.json after the binding write (case 2). */
76
+ settleSettings?: boolean;
70
77
  }
71
78
  /**
72
79
  * `vincentt create` — a TWO-HALF verb with one seam.
@@ -89,6 +96,24 @@ export interface CreateCommandOptions {
89
96
  * neighbour.
90
97
  */
91
98
  export declare function createCommand(deps: CreateCommandDeps, opts?: CreateCommandOptions): Promise<number>;
99
+ export interface ClonedRegistrationDeps {
100
+ /** Resolve the API host + PAT for the cloned directory. */
101
+ config: (dir: string) => Promise<ResolvedConfig>;
102
+ /** The org to register in, for the cloned directory. */
103
+ org: (dir: string) => ReturnType<CreateCommandDeps["org"]>;
104
+ currentToken: () => Promise<string | undefined>;
105
+ runLogin: () => Promise<void>;
106
+ orgSource: () => OrgSource;
107
+ }
108
+ /**
109
+ * The registration half of `init --from`: `registerAfterScaffold` over
110
+ * `registerProject`, never over `createCommand`, which would classify and scaffold
111
+ * a second time inside the half that already cloned.
112
+ */
113
+ export declare function registerClonedProject(deps: ClonedRegistrationDeps, rd: Pick<Parameters<typeof registerAfterScaffold>[0], "print" | "targetDir" | "dirLabel" | "ask">, opts: {
114
+ name?: string;
115
+ slug?: string;
116
+ }): Promise<number>;
92
117
  /**
93
118
  * The registration half — the binding guard's remainder, the slug derivation, the
94
119
  * round trip, and the binding write. Extracted from `createCommand` so
@@ -105,7 +130,17 @@ export declare function registerProject(deps: CreateCommandDeps, opts?: CreateCo
105
130
  * "which folder", so linking is entirely client-side, gated only by the server's
106
131
  * willingness to return the project at all.
107
132
  */
108
- export declare function linkCommand(deps: ProjectCommandDeps, projectId: string | undefined): Promise<number>;
133
+ export interface LinkOptions {
134
+ /** `--dev-command`, `--dev-port`, `--build-command`, `--build-output`. */
135
+ flags?: JoinFlags;
136
+ /** Machine mode: one JSON object on stdout, never a prompt. */
137
+ json?: boolean;
138
+ /** Present only on a TTY. */
139
+ ask?: Ask;
140
+ /** Injected for tests. */
141
+ settle?: typeof settleSettings;
142
+ }
143
+ export declare function linkCommand(deps: ProjectCommandDeps, projectId: string | undefined, opts?: LinkOptions): Promise<number>;
109
144
  /**
110
145
  * The API's error messages are lowercase and unpunctuated by convention
111
146
  * (`that handle is taken`, `this project is not available to you`), while both
@@ -12,7 +12,10 @@ import { EXIT } from "./exitCodes.js";
12
12
  import { registerAfterScaffold } from "./initFrom.js";
13
13
  import { slugify } from "./slug.js";
14
14
  import { detectPackageManager, installDependencies, scaffoldFromTemplate, } from "../scaffold/index.js";
15
+ import { settleSettings } from "./settle.js";
16
+ import { escapeControl } from "./terminalText.js";
15
17
  import { clearProjectBinding, loadProjectBinding, locateProjectBinding, writeProjectBinding, } from "../shared/config.js";
18
+ const DEFAULT_CONSOLE_URL = "https://console.vincentt.studio";
16
19
  /**
17
20
  * `vincentt create` — a TWO-HALF verb with one seam.
18
21
  *
@@ -69,15 +72,30 @@ export async function createCommand(deps, opts = {}) {
69
72
  // unforeseen errno. The CLI could not establish that the target is safe to
70
73
  // write into; that is one fact with one remedy pair. A per-cause message
71
74
  // would advertise which filenames the predicate has opinions about, which is
72
- // the allowlist this design refuses to open.
75
+ // the allowlist this design refuses to open. The one distinction drawn is
76
+ // whether a package.json exists, which the screen must state truthfully.
77
+ const hasPackageJson = await fs
78
+ .lstat(path.join(target, "package.json"))
79
+ .then((s) => s.isFile())
80
+ .catch(() => false);
81
+ const consoleUrl = (deps.consoleUrl ?? DEFAULT_CONSOLE_URL).replace(/\/$/, "");
73
82
  deps.print("✗ This folder isn't a Vincentt app, and it isn't empty.");
74
83
  deps.print("");
75
84
  deps.print(" `vincentt create` scaffolds an empty folder and registers an existing app.");
76
- deps.print(" This folder is neither, and adding the template's files here would mix them");
77
- deps.print(" into what's already present.");
85
+ deps.print(hasPackageJson
86
+ ? " This folder is neither: nothing in its package.json comes from Vincentt."
87
+ : " This folder is neither: it has no package.json.");
78
88
  deps.print("");
79
- deps.print(" vincentt create <name> Scaffold and register in a new folder");
80
- deps.print(" vincentt init . Scaffold into THIS folder anyway (asks first)");
89
+ deps.print(" If this folder holds an app you want on Vincentt:");
90
+ deps.print(` 1. Create the project in the console: ${consoleUrl}`);
91
+ deps.print(" 2. Link this folder to it: vincentt link <project-id>");
92
+ deps.print("");
93
+ deps.print(" Or add the harness, then run `vincentt create` again:");
94
+ deps.print(" npm i @vincentt-xr/harness");
95
+ deps.print("");
96
+ deps.print(" To start from the template instead:");
97
+ deps.print(" vincentt create <name> Scaffold and register in a new folder");
98
+ deps.print(" vincentt init . Scaffold into THIS folder anyway (asks first)");
81
99
  deps.print("");
82
100
  deps.print(" Nothing was written.");
83
101
  return 1;
@@ -164,6 +182,9 @@ export async function createCommand(deps, opts = {}) {
164
182
  slug,
165
183
  templateVersion,
166
184
  rethrowSlugTaken: true,
185
+ // Case 2 registers an app already on disk, so it settles vincentt.json
186
+ // the way `link` does. A fresh scaffold IS the template.
187
+ settleSettings: !scaffolded,
167
188
  }),
168
189
  },
169
190
  // Case 2 keeps the shipped basename default: a creator made that folder and
@@ -316,6 +337,35 @@ async function scaffoldInto(deps, target, dirLabel, opts) {
316
337
  deps.print(`✓ Created ${dirLabel}`);
317
338
  return { ok: true, installed, pm, templateVersion: scaffoldResult?.templateVersion };
318
339
  }
340
+ /**
341
+ * The registration half of `init --from`: `registerAfterScaffold` over
342
+ * `registerProject`, never over `createCommand`, which would classify and scaffold
343
+ * a second time inside the half that already cloned.
344
+ */
345
+ export function registerClonedProject(deps, rd, opts) {
346
+ let cachedOrg;
347
+ const org = async () => (cachedOrg ??= await deps.org(rd.targetDir));
348
+ return registerAfterScaffold({
349
+ ...rd,
350
+ currentToken: deps.currentToken,
351
+ runLogin: deps.runLogin,
352
+ resolveOrg: async () => {
353
+ const resolved = await org();
354
+ if (!resolved.ok)
355
+ return resolved;
356
+ return { ok: true, name: resolved.org.name, source: deps.orgSource() };
357
+ },
358
+ create: async ({ name, slug }) => registerProject({
359
+ print: rd.print,
360
+ cwd: rd.targetDir,
361
+ config: () => deps.config(rd.targetDir),
362
+ org,
363
+ },
364
+ // Without this the taken-address refusal prints here and returns, and
365
+ // the bounded re-ask in registerAfterScaffold never sees the 409.
366
+ { name, slug, rethrowSlugTaken: true }),
367
+ }, opts);
368
+ }
319
369
  /**
320
370
  * The registration half — the binding guard's remainder, the slug derivation, the
321
371
  * round trip, and the binding write. Extracted from `createCommand` so
@@ -369,6 +419,14 @@ export async function registerProject(deps, opts = {}) {
369
419
  deps.print(`✓ Created ${created.name} in ${org.org.name}`);
370
420
  deps.print(` Address reserved: ${created.liveUrl}`);
371
421
  deps.print(` Linked this folder: .vincentt/project.json (added to .gitignore)`);
422
+ if (opts.settleSettings) {
423
+ // Between the binding line and today's closing lines, so the last lines are
424
+ // still the ones a creator acts on. A template prints nothing here.
425
+ await (deps.settle ?? settleSettings)(target, await loadProjectBinding(target), {}, {
426
+ print: deps.print,
427
+ ask: deps.ask,
428
+ });
429
+ }
372
430
  deps.print("");
373
431
  deps.print(" Nothing is served at that address until you publish.");
374
432
  deps.print(" Run `vincentt preview` to try it on your phone.");
@@ -401,15 +459,7 @@ function printSlugTaken(print, suggestions, name) {
401
459
  }
402
460
  print(` Run \`vincentt create --name "${name}" --slug <address>\` with another address.`);
403
461
  }
404
- // ---- link -------------------------------------------------------------------
405
- /**
406
- * `vincentt link <project-id>` — one verb for four situations (console-first
407
- * birth, a fresh clone, a folder that lost its binding, a re-link after
408
- * `login --force`). It is a READ plus a local write: the server has no notion of
409
- * "which folder", so linking is entirely client-side, gated only by the server's
410
- * willingness to return the project at all.
411
- */
412
- export async function linkCommand(deps, projectId) {
462
+ export async function linkCommand(deps, projectId, opts = {}) {
413
463
  if (!projectId) {
414
464
  deps.print("✗ Which project? `vincentt link` needs a project id.");
415
465
  deps.print("");
@@ -429,16 +479,31 @@ export async function linkCommand(deps, projectId) {
429
479
  }
430
480
  throw err;
431
481
  }
482
+ const text = opts.json ? () => undefined : deps.print;
432
483
  if (existing && existing.projectId !== project.id) {
433
- deps.print(`! This folder is already linked to ${existing.slug}.`);
434
- deps.print(` Re-linking it to ${project.name}.`);
435
- deps.print("");
484
+ text(`! This folder is already linked to ${existing.slug}.`);
485
+ text(` Re-linking it to ${project.name}.`);
486
+ text("");
436
487
  }
437
488
  await writeProjectBinding(deps.cwd, { projectId: project.id, slug: project.slug });
438
- deps.print(`✓ Linked this folder to ${project.name}`);
489
+ text(`✓ Linked this folder to ${project.name}`);
439
490
  if (project.liveUrl)
440
- deps.print(` Address: ${project.liveUrl}`);
441
- deps.print(" Wrote .vincentt/project.json (added to .gitignore)");
491
+ text(` Address: ${project.liveUrl}`);
492
+ text(" Wrote .vincentt/project.json (added to .gitignore)");
493
+ // Settled only after a successful binding write: a refused link writes no file.
494
+ const joined = await (opts.settle ?? settleSettings)(deps.cwd, await loadProjectBinding(deps.cwd), opts.flags ?? {}, { print: deps.print, ask: opts.json ? undefined : opts.ask, json: opts.json });
495
+ if (opts.json) {
496
+ // JSON escapes C0 controls itself; the C1 range still needs the same escape.
497
+ deps.print(escapeControl(JSON.stringify({
498
+ project: { id: project.id, slug: project.slug, name: project.name, liveUrl: project.liveUrl },
499
+ ...joined.json,
500
+ })));
501
+ return 0;
502
+ }
503
+ if (joined.reported) {
504
+ deps.print("");
505
+ deps.print(" Run `vincentt preview` to try it on your phone.");
506
+ }
442
507
  return 0;
443
508
  }
444
509
  /**
@@ -487,7 +552,7 @@ export async function unlinkCommand(deps) {
487
552
  }
488
553
  deps.print("✓ Unlinked. Removed .vincentt/project.json");
489
554
  deps.print("");
490
- deps.print(" The project itself is untouched — this only disconnects this folder.");
555
+ deps.print(" The project itself is untouched; this only disconnects this folder.");
491
556
  deps.print(" Run `vincentt link <project-id>` to link it again.");
492
557
  return 0;
493
558
  }
@@ -0,0 +1,113 @@
1
+ export declare const SETTINGS_FILE = "vincentt.json";
2
+ export declare const SETTINGS_SCHEMA_URL = "https://vincentt.studio/schema/vincentt.json";
3
+ export declare const SETTINGS_MAX_BYTES = 4096;
4
+ export type SettingsField = "dev.command" | "dev.port" | "build.command" | "build.output";
5
+ export declare const SETTINGS_FIELDS: readonly SettingsField[];
6
+ export interface VincenttJson {
7
+ $schema?: string;
8
+ dev?: {
9
+ command?: string;
10
+ port?: number;
11
+ };
12
+ build?: {
13
+ command?: string;
14
+ output?: string;
15
+ };
16
+ }
17
+ export type Source = "flag" | "vincentt.json" | "default";
18
+ export interface Resolved<T> {
19
+ value: T;
20
+ source: Source;
21
+ /** The flag that supplied it, when source is "flag". */
22
+ flag?: string;
23
+ }
24
+ export interface ResolvedSettings {
25
+ devCommand: Resolved<string>;
26
+ devPort: Resolved<number>;
27
+ buildCommand?: Resolved<string>;
28
+ buildOutput: Resolved<string>;
29
+ }
30
+ export interface SettingsDefaults {
31
+ devCommand: string;
32
+ devPort: number;
33
+ buildOutput: string;
34
+ }
35
+ export declare const TEMPLATE_DEFAULTS: SettingsDefaults;
36
+ export type SettingsProblem = {
37
+ kind: "missing";
38
+ field: SettingsField;
39
+ flag: string;
40
+ } | {
41
+ kind: "unknown_key";
42
+ key: string;
43
+ } | {
44
+ kind: "invalid_json";
45
+ line?: number;
46
+ column?: number;
47
+ } | {
48
+ kind: "bad_value";
49
+ field: SettingsField | "$schema";
50
+ value: unknown;
51
+ message: string;
52
+ } | {
53
+ kind: "not_object";
54
+ } | {
55
+ kind: "too_large";
56
+ } | {
57
+ kind: "not_file";
58
+ };
59
+ export declare class SettingsError extends Error {
60
+ readonly problem: SettingsProblem;
61
+ constructor(problem: SettingsProblem);
62
+ }
63
+ /** One sentence naming what is wrong, for advisories that are not the full refusal. */
64
+ export declare function describeProblem(p: SettingsProblem): string;
65
+ /**
66
+ * The schema rule for one field's value, as the message a prompt or a refusal
67
+ * prints. `undefined` means the value is acceptable. Shared by the file parser,
68
+ * the joining prompts and the flag values, so there is one rule.
69
+ */
70
+ export declare function validateField(field: SettingsField, value: unknown): string | undefined;
71
+ /** Parse a typed or flagged value into the field's type, then validate it. */
72
+ export declare function coerceField(field: SettingsField, raw: string): {
73
+ ok: true;
74
+ value: string | number;
75
+ } | {
76
+ ok: false;
77
+ message: string;
78
+ };
79
+ /** Parse the file's text. Throws SettingsError; never returns a partial object. */
80
+ export declare function parseSettings(text: string): VincenttJson;
81
+ export declare function fieldOf(file: VincenttJson, field: SettingsField): string | number | undefined;
82
+ export interface SettingsIo {
83
+ readFile: (p: string) => Promise<string>;
84
+ }
85
+ /**
86
+ * Read `vincentt.json` without trusting what it is. Opened non-blocking so a
87
+ * FIFO cannot park the open, then checked on the OPEN descriptor to be a
88
+ * regular file of at most 4 KB, and read no further than one byte past the cap.
89
+ * A symlink to /dev/zero is a character device and is refused before any read.
90
+ * Throws ENOENT (absent) or SettingsError (present but unusable).
91
+ */
92
+ export declare function readSettingsText(abs: string): Promise<string>;
93
+ /**
94
+ * The file at `root`, or undefined when there is none. A present file that
95
+ * cannot be used throws: an unreadable file is never treated as absent, or a
96
+ * typo would silently restore the template defaults.
97
+ */
98
+ export declare function readSettings(root: string, io?: SettingsIo): Promise<VincenttJson | undefined>;
99
+ export interface SettingsFlags {
100
+ devCommand?: string;
101
+ devPort?: number;
102
+ buildOutput?: string;
103
+ }
104
+ type Need = "dev" | "build";
105
+ /**
106
+ * Resolve the fields a verb needs. `need` scopes the refusal: preview never
107
+ * refuses over a missing build.output, and publish never over a missing dev
108
+ * port. build.command is never refused anywhere, because nothing runs it.
109
+ */
110
+ export declare function resolveSettings(flags: SettingsFlags, file: VincenttJson | undefined, defaults?: SettingsDefaults, need?: readonly Need[]): ResolvedSettings;
111
+ /** The file as joining writes it: 2-space indentation and a trailing newline. */
112
+ export declare function serializeSettings(values: Partial<Record<SettingsField, string | number>>): string;
113
+ export {};