@vincentt-xr/cli 0.16.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 +196 -87
  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 +24 -11
  57. package/dist/preview/api.js +42 -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 +39 -6
  69. package/dist/publish/api.js +80 -9
  70. package/dist/publish/command.d.ts +25 -0
  71. package/dist/publish/command.js +230 -32
  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 +114 -0
  92. package/dist/shared/countLimit.js +176 -0
  93. package/dist/shared/http.d.ts +23 -0
  94. package/dist/shared/http.js +58 -0
  95. package/package.json +7 -3
@@ -0,0 +1,284 @@
1
+ // `vincentt.json`: the committed file that tells the CLI how an app runs.
2
+ //
3
+ // Precedence is flag > file > template default, per field. The file's PRESENCE
4
+ // is the switch: absent, every value is today's template default; present, it is
5
+ // the whole truth and a missing field is refused by name. That refusal is what
6
+ // stops an outside app whose detection failed from silently running `npm run
7
+ // dev` on 5173.
8
+ //
9
+ // Pure: the file read is injected, nothing here touches the network or spawns.
10
+ import { constants as fsConstants, promises as fs } from "node:fs";
11
+ import path from "node:path";
12
+ import { CONTROL_CHARS, escapeControl } from "./terminalText.js";
13
+ export const SETTINGS_FILE = "vincentt.json";
14
+ export const SETTINGS_SCHEMA_URL = "https://vincentt.studio/schema/vincentt.json";
15
+ export const SETTINGS_MAX_BYTES = 4096;
16
+ export const SETTINGS_FIELDS = [
17
+ "dev.command",
18
+ "dev.port",
19
+ "build.command",
20
+ "build.output",
21
+ ];
22
+ export const TEMPLATE_DEFAULTS = {
23
+ devCommand: "npm run dev",
24
+ devPort: 5173,
25
+ buildOutput: "dist",
26
+ };
27
+ export class SettingsError extends Error {
28
+ problem;
29
+ constructor(problem) {
30
+ super(describeProblem(problem));
31
+ this.problem = problem;
32
+ this.name = "SettingsError";
33
+ }
34
+ }
35
+ /** One sentence naming what is wrong, for advisories that are not the full refusal. */
36
+ export function describeProblem(p) {
37
+ switch (p.kind) {
38
+ case "missing":
39
+ return `vincentt.json has no ${p.field}`;
40
+ case "unknown_key":
41
+ return `unknown setting "${escapeControl(p.key)}"`;
42
+ case "invalid_json":
43
+ return p.line !== undefined
44
+ ? `not valid JSON (line ${p.line}, column ${p.column})`
45
+ : "not valid JSON";
46
+ case "bad_value":
47
+ return `${p.field} ${p.message}`;
48
+ case "not_object":
49
+ return "not a JSON object";
50
+ case "too_large":
51
+ return `larger than ${SETTINGS_MAX_BYTES / 1024} KB`;
52
+ case "not_file":
53
+ return "not a regular file";
54
+ }
55
+ }
56
+ const ALLOWED = {
57
+ dev: ["command", "port"],
58
+ build: ["command", "output"],
59
+ };
60
+ const COMMAND_MAX = 512;
61
+ /**
62
+ * The schema rule for one field's value, as the message a prompt or a refusal
63
+ * prints. `undefined` means the value is acceptable. Shared by the file parser,
64
+ * the joining prompts and the flag values, so there is one rule.
65
+ */
66
+ export function validateField(field, value) {
67
+ switch (field) {
68
+ case "dev.command":
69
+ case "build.command":
70
+ if (typeof value !== "string" || value.trim() === "")
71
+ return "must be a command";
72
+ if (value.length > COMMAND_MAX)
73
+ return `must be at most ${COMMAND_MAX} characters`;
74
+ // A carriage return or an escape sequence could redraw the disclosure
75
+ // line while the shell runs the real string.
76
+ if (CONTROL_CHARS.test(value))
77
+ return "must not contain control characters";
78
+ return undefined;
79
+ case "dev.port":
80
+ if (typeof value !== "number" ||
81
+ !Number.isInteger(value) ||
82
+ value < 1 ||
83
+ value > 65535) {
84
+ return "must be a whole number from 1 to 65535";
85
+ }
86
+ return undefined;
87
+ case "build.output":
88
+ return validateOutput(value);
89
+ }
90
+ }
91
+ function validateOutput(value) {
92
+ const inside = "must be a folder inside this project, written as a relative path";
93
+ if (typeof value !== "string" || value === "")
94
+ return inside;
95
+ if (value.startsWith("/") || value.includes("\\") || /^[A-Za-z]:/.test(value))
96
+ return inside;
97
+ if (value.split("/").some((seg) => seg === ".."))
98
+ return inside;
99
+ if (CONTROL_CHARS.test(value))
100
+ return "must not contain control characters";
101
+ return undefined;
102
+ }
103
+ /** Parse a typed or flagged value into the field's type, then validate it. */
104
+ export function coerceField(field, raw) {
105
+ const value = field === "dev.port" && /^\d+$/.test(raw.trim()) ? Number(raw.trim()) : raw.trim();
106
+ const message = validateField(field, value);
107
+ return message === undefined ? { ok: true, value } : { ok: false, message };
108
+ }
109
+ /** Parse the file's text. Throws SettingsError; never returns a partial object. */
110
+ export function parseSettings(text) {
111
+ if (Buffer.byteLength(text, "utf8") > SETTINGS_MAX_BYTES) {
112
+ throw new SettingsError({ kind: "too_large" });
113
+ }
114
+ let raw;
115
+ try {
116
+ raw = JSON.parse(text);
117
+ }
118
+ catch (err) {
119
+ throw new SettingsError({ kind: "invalid_json", ...jsonErrorPosition(err, text) });
120
+ }
121
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
122
+ throw new SettingsError({ kind: "not_object" });
123
+ }
124
+ const obj = raw;
125
+ for (const key of Object.keys(obj)) {
126
+ if (key === "$schema")
127
+ continue;
128
+ // Own keys only: `constructor` or `__proto__` must never read through the prototype.
129
+ if (!Object.prototype.hasOwnProperty.call(ALLOWED, key)) {
130
+ throw new SettingsError({ kind: "unknown_key", key });
131
+ }
132
+ const allowed = ALLOWED[key];
133
+ const section = obj[key];
134
+ if (typeof section !== "object" || section === null || Array.isArray(section)) {
135
+ throw new SettingsError({
136
+ kind: "bad_value",
137
+ field: `${key}.${allowed[0]}`,
138
+ value: section,
139
+ message: `must sit inside a "${key}" object`,
140
+ });
141
+ }
142
+ for (const sub of Object.keys(section)) {
143
+ if (!allowed.includes(sub))
144
+ throw new SettingsError({ kind: "unknown_key", key: `${key}.${sub}` });
145
+ }
146
+ }
147
+ if (obj.$schema !== undefined && typeof obj.$schema !== "string") {
148
+ throw new SettingsError({
149
+ kind: "bad_value",
150
+ field: "$schema",
151
+ value: obj.$schema,
152
+ message: "must be a URL string",
153
+ });
154
+ }
155
+ const out = obj;
156
+ for (const field of SETTINGS_FIELDS) {
157
+ const value = fieldOf(out, field);
158
+ if (value === undefined)
159
+ continue;
160
+ const message = validateField(field, value);
161
+ if (message !== undefined) {
162
+ throw new SettingsError({ kind: "bad_value", field, value, message });
163
+ }
164
+ }
165
+ return out;
166
+ }
167
+ function jsonErrorPosition(err, text) {
168
+ const message = err instanceof Error ? err.message : "";
169
+ const lc = /line (\d+) column (\d+)/.exec(message);
170
+ if (lc)
171
+ return { line: Number(lc[1]), column: Number(lc[2]) };
172
+ const pos = /position (\d+)/.exec(message);
173
+ if (!pos)
174
+ return {};
175
+ const offset = Number(pos[1]);
176
+ const before = text.slice(0, offset);
177
+ const line = before.split("\n").length;
178
+ const column = offset - before.lastIndexOf("\n");
179
+ return { line, column };
180
+ }
181
+ export function fieldOf(file, field) {
182
+ switch (field) {
183
+ case "dev.command":
184
+ return file.dev?.command;
185
+ case "dev.port":
186
+ return file.dev?.port;
187
+ case "build.command":
188
+ return file.build?.command;
189
+ case "build.output":
190
+ return file.build?.output;
191
+ }
192
+ }
193
+ /**
194
+ * Read `vincentt.json` without trusting what it is. Opened non-blocking so a
195
+ * FIFO cannot park the open, then checked on the OPEN descriptor to be a
196
+ * regular file of at most 4 KB, and read no further than one byte past the cap.
197
+ * A symlink to /dev/zero is a character device and is refused before any read.
198
+ * Throws ENOENT (absent) or SettingsError (present but unusable).
199
+ */
200
+ export async function readSettingsText(abs) {
201
+ const handle = await fs.open(abs, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK);
202
+ try {
203
+ const stat = await handle.stat();
204
+ if (!stat.isFile())
205
+ throw new SettingsError({ kind: "not_file" });
206
+ if (stat.size > SETTINGS_MAX_BYTES)
207
+ throw new SettingsError({ kind: "too_large" });
208
+ const buf = Buffer.alloc(SETTINGS_MAX_BYTES + 1);
209
+ const { bytesRead } = await handle.read(buf, 0, buf.length, 0);
210
+ if (bytesRead > SETTINGS_MAX_BYTES)
211
+ throw new SettingsError({ kind: "too_large" });
212
+ return buf.subarray(0, bytesRead).toString("utf8");
213
+ }
214
+ finally {
215
+ await handle.close();
216
+ }
217
+ }
218
+ const defaultIo = { readFile: readSettingsText };
219
+ /**
220
+ * The file at `root`, or undefined when there is none. A present file that
221
+ * cannot be used throws: an unreadable file is never treated as absent, or a
222
+ * typo would silently restore the template defaults.
223
+ */
224
+ export async function readSettings(root, io = defaultIo) {
225
+ let text;
226
+ try {
227
+ text = await io.readFile(path.join(root, SETTINGS_FILE));
228
+ }
229
+ catch (err) {
230
+ if (err.code === "ENOENT")
231
+ return undefined;
232
+ throw err;
233
+ }
234
+ return parseSettings(text);
235
+ }
236
+ /**
237
+ * Resolve the fields a verb needs. `need` scopes the refusal: preview never
238
+ * refuses over a missing build.output, and publish never over a missing dev
239
+ * port. build.command is never refused anywhere, because nothing runs it.
240
+ */
241
+ export function resolveSettings(flags, file, defaults = TEMPLATE_DEFAULTS, need = ["dev", "build"]) {
242
+ const pick = (field, flagName, flagValue, fallback, required) => {
243
+ if (flagValue !== undefined)
244
+ return { value: flagValue, source: "flag", flag: flagName };
245
+ if (!file)
246
+ return { value: fallback, source: "default" };
247
+ const fromFile = fieldOf(file, field);
248
+ if (fromFile !== undefined)
249
+ return { value: fromFile, source: "vincentt.json" };
250
+ if (required)
251
+ throw new SettingsError({ kind: "missing", field, flag: flagName });
252
+ return { value: fallback, source: "default" };
253
+ };
254
+ const dev = need.includes("dev");
255
+ const build = need.includes("build");
256
+ const buildCommand = file?.build?.command;
257
+ return {
258
+ devCommand: pick("dev.command", "--dev-command", flags.devCommand, defaults.devCommand, dev),
259
+ devPort: pick("dev.port", "--port", flags.devPort, defaults.devPort, dev),
260
+ buildOutput: pick("build.output", "--dist", flags.buildOutput, defaults.buildOutput, build),
261
+ ...(buildCommand !== undefined
262
+ ? { buildCommand: { value: buildCommand, source: "vincentt.json" } }
263
+ : {}),
264
+ };
265
+ }
266
+ /** The file as joining writes it: 2-space indentation and a trailing newline. */
267
+ export function serializeSettings(values) {
268
+ const out = { $schema: SETTINGS_SCHEMA_URL };
269
+ if (values["dev.command"] !== undefined || values["dev.port"] !== undefined) {
270
+ out.dev = {};
271
+ if (values["dev.command"] !== undefined)
272
+ out.dev.command = String(values["dev.command"]);
273
+ if (values["dev.port"] !== undefined)
274
+ out.dev.port = Number(values["dev.port"]);
275
+ }
276
+ if (values["build.command"] !== undefined || values["build.output"] !== undefined) {
277
+ out.build = {};
278
+ if (values["build.command"] !== undefined)
279
+ out.build.command = String(values["build.command"]);
280
+ if (values["build.output"] !== undefined)
281
+ out.build.output = String(values["build.output"]);
282
+ }
283
+ return JSON.stringify(out, null, 2) + "\n";
284
+ }
@@ -0,0 +1,6 @@
1
+ import type { Resolved, SettingsError } from "./settings.js";
2
+ export type SettingsVerb = "preview" | "publish";
3
+ /** The three source words, and only three. */
4
+ export declare function sourceWords(r: Resolved<unknown>): string;
5
+ /** Flow P2 and its publish twin. Every variant ends with what did not happen. */
6
+ export declare function settingsRefusal(err: SettingsError, verb: SettingsVerb): string[];
@@ -0,0 +1,99 @@
1
+ // The settings lines shared by `preview` and `publish`: the refusal (2B.9) and
2
+ // the per-value source words (2B.0 rule 1). Pure string functions.
3
+ import { escapeControl } from "./terminalText.js";
4
+ /** The three source words, and only three. */
5
+ export function sourceWords(r) {
6
+ if (r.source === "flag")
7
+ return `from ${r.flag ?? "a flag"}`;
8
+ if (r.source === "vincentt.json")
9
+ return "from vincentt.json";
10
+ return "default (no vincentt.json)";
11
+ }
12
+ const REMEDY = {
13
+ "dev.command": {
14
+ edit: `"dev": { "command": "<command>" }`,
15
+ run: `vincentt preview --dev-command "<command>"`,
16
+ },
17
+ "dev.port": {
18
+ edit: `"dev": { "port": <port> }`,
19
+ run: "vincentt preview --port <port>",
20
+ },
21
+ "build.output": {
22
+ edit: `"build": { "output": "<folder>" }`,
23
+ run: "vincentt publish --dist <folder>",
24
+ },
25
+ "build.command": { edit: `"build": { "command": "<command>" }`, run: "" },
26
+ };
27
+ /** Flow P2 and its publish twin. Every variant ends with what did not happen. */
28
+ export function settingsRefusal(err, verb) {
29
+ const last = verb === "preview" ? " Nothing was started." : " Nothing was uploaded.";
30
+ const p = err.problem;
31
+ switch (p.kind) {
32
+ case "missing": {
33
+ const remedy = REMEDY[p.field];
34
+ return [
35
+ "",
36
+ `✗ vincentt.json has no ${p.field}.`,
37
+ "",
38
+ " This folder has a vincentt.json, so Vincentt uses only what it says. It does not",
39
+ " fall back to the template's defaults.",
40
+ "",
41
+ ` Add it to vincentt.json: ${remedy.edit}`,
42
+ ` Or pass it for one run: ${remedy.run}`,
43
+ "",
44
+ last,
45
+ "",
46
+ ];
47
+ }
48
+ case "unknown_key":
49
+ return [
50
+ "",
51
+ `✗ vincentt.json has a setting Vincentt does not know: "${escapeControl(p.key)}".`,
52
+ "",
53
+ " It accepts dev.command, dev.port, build.command and build.output.",
54
+ "",
55
+ last,
56
+ "",
57
+ ];
58
+ case "invalid_json":
59
+ return [
60
+ "",
61
+ p.line !== undefined
62
+ ? `✗ vincentt.json is not valid JSON (line ${p.line}, column ${p.column}).`
63
+ : "✗ vincentt.json is not valid JSON.",
64
+ "",
65
+ " Fix the file so Vincentt can read it.",
66
+ "",
67
+ last,
68
+ "",
69
+ ];
70
+ case "bad_value":
71
+ return [
72
+ "",
73
+ `✗ vincentt.json has a ${p.field} Vincentt can't use: ${escapeControl(String(JSON.stringify(p.value)))}.`,
74
+ "",
75
+ ` It ${p.message}.`,
76
+ "",
77
+ last,
78
+ "",
79
+ ];
80
+ case "not_object":
81
+ return ["", "✗ vincentt.json is not a JSON object.", "", last, ""];
82
+ case "too_large":
83
+ return [
84
+ "",
85
+ "✗ vincentt.json is larger than 4 KB, more than a settings file holds.",
86
+ "",
87
+ last,
88
+ "",
89
+ ];
90
+ case "not_file":
91
+ return [
92
+ "",
93
+ "✗ vincentt.json is not a regular file, so Vincentt does not read it.",
94
+ "",
95
+ last,
96
+ "",
97
+ ];
98
+ }
99
+ }
@@ -0,0 +1,61 @@
1
+ import { type DetectProbes } from "./detect.js";
2
+ import { type SettingsField } from "./settings.js";
3
+ import type { Ask } from "../publish/prompt.js";
4
+ import type { ProjectBinding } from "../shared/config.js";
5
+ export interface JoinFlags {
6
+ devCommand?: string;
7
+ devPort?: string;
8
+ buildCommand?: string;
9
+ buildOutput?: string;
10
+ }
11
+ export interface SettleIo {
12
+ print: (line: string) => void;
13
+ /** Present only on a TTY. `--json` passes none. */
14
+ ask?: Ask;
15
+ /** Machine mode: print nothing, prompt nothing. */
16
+ json?: boolean;
17
+ /** Injected for tests; defaults to the real, capped, read-only file probes. */
18
+ probes?: DetectProbes;
19
+ /** Injected for tests; defaults to an exclusive create, so a race never overwrites. */
20
+ writeFile?: (abs: string, text: string) => Promise<void>;
21
+ }
22
+ export type JsonSource = "detected" | "flag";
23
+ export interface JoinJson {
24
+ settings: {
25
+ template: boolean;
26
+ written: boolean;
27
+ file: "vincentt.json";
28
+ fields: Partial<Record<SettingsField, {
29
+ value: string | number;
30
+ source: JsonSource;
31
+ evidence: string;
32
+ }>>;
33
+ missing: {
34
+ field: SettingsField;
35
+ reason: string;
36
+ remedy?: {
37
+ edit: string;
38
+ flag: string;
39
+ };
40
+ }[];
41
+ differs: {
42
+ field: SettingsField;
43
+ file: string | number;
44
+ folder: string | number;
45
+ evidence: string;
46
+ }[];
47
+ };
48
+ advisories: "git_lfs"[];
49
+ }
50
+ /** The real probes: read-only, capped, and confined to the project's own tree. */
51
+ export declare function fsProbes(root: string): DetectProbes;
52
+ /**
53
+ * Settle the settings file for a folder whose binding was just written.
54
+ * Returns the `--json` shape; in text mode it has already printed the report.
55
+ */
56
+ export interface SettleResult {
57
+ json: JoinJson;
58
+ /** A join report was printed, so the caller closes with its next-step line. */
59
+ reported: boolean;
60
+ }
61
+ export declare function settleSettings(root: string, binding: Pick<ProjectBinding, "template"> | undefined, flags: JoinFlags, io: SettleIo): Promise<SettleResult>;