@vincentt-xr/cli 0.7.0 → 0.9.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 (49) hide show
  1. package/dist/cli/args.d.ts +18 -1
  2. package/dist/cli/args.js +40 -1
  3. package/dist/cli/index.js +208 -10
  4. package/dist/dx/command.d.ts +59 -2
  5. package/dist/dx/command.js +150 -11
  6. package/dist/dx/enablement.d.ts +51 -0
  7. package/dist/dx/enablement.js +97 -0
  8. package/dist/dx/qa.f7.fixtures.d.ts +73 -0
  9. package/dist/dx/qa.f7.fixtures.js +58 -0
  10. package/dist/dx/skeleton.js +1 -0
  11. package/dist/lib/initCommand.d.ts +28 -1
  12. package/dist/lib/initCommand.js +91 -16
  13. package/dist/lib/initFrom.d.ts +48 -0
  14. package/dist/lib/initFrom.js +198 -0
  15. package/dist/lib/sourceRef.d.ts +46 -0
  16. package/dist/lib/sourceRef.js +107 -0
  17. package/dist/lib/sourceRefCopy.d.ts +11 -0
  18. package/dist/lib/sourceRefCopy.js +80 -0
  19. package/dist/lib/version.d.ts +20 -1
  20. package/dist/lib/version.js +41 -5
  21. package/dist/library/assetsCommand.d.ts +32 -0
  22. package/dist/library/assetsCommand.js +264 -0
  23. package/dist/library/client.d.ts +42 -0
  24. package/dist/library/client.js +137 -0
  25. package/dist/library/download.d.ts +46 -0
  26. package/dist/library/download.js +98 -0
  27. package/dist/library/parse.d.ts +43 -0
  28. package/dist/library/parse.js +109 -0
  29. package/dist/library/qa.f5-b32.spec.d.ts +1 -0
  30. package/dist/library/qa.f5-b32.spec.js +202 -0
  31. package/dist/library/qa.f5-cli.spec.d.ts +1 -0
  32. package/dist/library/qa.f5-cli.spec.js +433 -0
  33. package/dist/library/qa.f5-contract.spec.d.ts +1 -0
  34. package/dist/library/qa.f5-contract.spec.js +206 -0
  35. package/dist/library/render.d.ts +39 -0
  36. package/dist/library/render.js +305 -0
  37. package/dist/library/wire.d.ts +96 -0
  38. package/dist/library/wire.js +167 -0
  39. package/dist/publish/api.d.ts +20 -0
  40. package/dist/publish/command.d.ts +3 -0
  41. package/dist/publish/command.js +39 -4
  42. package/dist/publish/copy.d.ts +29 -0
  43. package/dist/publish/copy.js +86 -0
  44. package/dist/publish/prompt.js +17 -2
  45. package/dist/scaffold/index.d.ts +9 -0
  46. package/dist/scaffold/index.js +13 -1
  47. package/dist/shared/config.d.ts +27 -0
  48. package/dist/shared/config.js +64 -9
  49. package/package.json +1 -1
@@ -148,6 +148,11 @@ export async function publishCommand(deps, opts = {}) {
148
148
  address,
149
149
  liveVersion: project.liveVersion ?? undefined,
150
150
  fetchImpl,
151
+ org: {
152
+ name: orgName || undefined,
153
+ slug: (await deps.orgSlug?.()) || undefined,
154
+ consoleUrl: deps.consoleUrl,
155
+ },
151
156
  });
152
157
  }
153
158
  async function runUpload(deps, cfg, i) {
@@ -156,7 +161,7 @@ async function runUpload(deps, cfg, i) {
156
161
  opened = await openUpload(cfg, i.projectId, i.manifest.files, i.note, i.fetchImpl);
157
162
  }
158
163
  catch (err) {
159
- for (const l of openErrorLines(err, i.address, i.liveVersion))
164
+ for (const l of openErrorLines(err, i.address, i.liveVersion, i.org))
160
165
  deps.print(l);
161
166
  return 1;
162
167
  }
@@ -221,6 +226,9 @@ async function commitWithHealing(deps, cfg, i, uploadId) {
221
226
  catch (err) {
222
227
  if (!(err instanceof PublishApiError))
223
228
  throw err;
229
+ // The plan limit is a 409 and falls past this branch deliberately. It is
230
+ // the interlock refusing, not the flip failing, and retrying it three
231
+ // times would be the retry loop the refusal exists to stop.
224
232
  if (err.code === "publish_flip_failed" || err.status === 502) {
225
233
  if (attempt === COMMIT_RETRIES) {
226
234
  for (const l of copy.flipFailed(i.address, i.liveVersion))
@@ -236,15 +244,39 @@ async function commitWithHealing(deps, cfg, i, uploadId) {
236
244
  await deps.sleep(COMMIT_RETRY_DELAY_MS);
237
245
  continue;
238
246
  }
239
- for (const l of commitErrorLines(err, i.address, i.liveVersion))
247
+ for (const l of commitErrorLines(err, i.address, i.liveVersion, i.org))
240
248
  deps.print(l);
241
249
  return 1;
242
250
  }
243
251
  }
244
252
  return 1;
245
253
  }
246
- function commitErrorLines(err, address, liveVersion) {
254
+ /**
255
+ * The plan-limit refusal, from the detail the API sends. It is reachable from
256
+ * open AND from commit, because the interlock lives at the port and every flip
257
+ * path folds through the same 409.
258
+ *
259
+ * The figures come off the wire and are never computed here: a CLI that derived
260
+ * an allowance would be guessing at another service's state.
261
+ */
262
+ function planLimitLines(err, org) {
263
+ return copy.planLimitRefused({
264
+ // THE WIRE WINS over the machine's configured active org. The API names the
265
+ // org that owns this project; the local config names whichever org the CLI
266
+ // was last pointed at, and they are not always the same one.
267
+ orgName: err.detail.orgName ?? org?.name,
268
+ orgSlug: err.detail.orgSlug ?? org?.slug,
269
+ planName: err.detail.planName,
270
+ figureBytes: err.detail.figureBytes ?? 0,
271
+ allowanceBytes: err.detail.allowanceBytes ?? 0,
272
+ cutOffAt: err.detail.cutOffAt,
273
+ consoleUrl: org?.consoleUrl,
274
+ });
275
+ }
276
+ function commitErrorLines(err, address, liveVersion, org) {
247
277
  switch (err.code) {
278
+ case "unavailable_plan_limit":
279
+ return planLimitLines(err, org);
248
280
  case "publish_expired":
249
281
  return copy.publishExpired(address, liveVersion);
250
282
  case "publish_contended":
@@ -267,7 +299,7 @@ function commitErrorLines(err, address, liveVersion) {
267
299
  ];
268
300
  }
269
301
  }
270
- function openErrorLines(err, address, liveVersion) {
302
+ function openErrorLines(err, address, liveVersion, org) {
271
303
  if (!(err instanceof PublishApiError)) {
272
304
  return [
273
305
  "",
@@ -277,6 +309,9 @@ function openErrorLines(err, address, liveVersion) {
277
309
  "",
278
310
  ];
279
311
  }
312
+ if (err.code === "unavailable_plan_limit") {
313
+ return planLimitLines(err, org);
314
+ }
280
315
  if (err.code === "publish_too_large") {
281
316
  return copy.tooLargeFromServer(err.detail.observed ?? 0, err.detail.limit ?? 0);
282
317
  }
@@ -132,6 +132,35 @@ export declare function finishing(): string;
132
132
  export declare function liveAt(liveUrl: string, version: number): string[];
133
133
  /** Prints only if a retry is needed. It says nothing about a failure, because nothing has failed yet. */
134
134
  export declare function takingLonger(): string;
135
+ export interface PlanLimitInput {
136
+ /** The org's display name, and its slug when the CLI holds one. */
137
+ orgName?: string;
138
+ orgSlug?: string;
139
+ /** The plan in force. Absent when the API did not name one — the line is then dropped. */
140
+ planName?: string;
141
+ figureBytes: number;
142
+ allowanceBytes: number;
143
+ /** RFC3339, from the cutoff marker. */
144
+ cutOffAt?: string;
145
+ consoleUrl?: string;
146
+ }
147
+ /**
148
+ * Flow L32 — the org is cut off and open-publish refused before a byte moved.
149
+ *
150
+ * This is NOT flipFailed and must never be folded into it. The API gives the two
151
+ * distinct wire codes because one means the system working correctly and the
152
+ * other means the ledger is broken; a creator told "the address didn't switch
153
+ * over" when they hit a plan limit has been told the platform is malfunctioning.
154
+ *
155
+ * The three facts are a STACKED ALIGNED BLOCK because they are a comparison —
156
+ * the figure against the allowance. Inline they are a sentence to parse; stacked
157
+ * an agent and a human read them the same way.
158
+ *
159
+ * "cannot be done from the CLI" is load-bearing: an agent's default response to a
160
+ * failure is to retry, and naming the remedy as a human action outside the tool
161
+ * is the only thing that stops the loop.
162
+ */
163
+ export declare function planLimitRefused(i: PlanLimitInput): string[];
135
164
  /**
136
165
  * The cause is NOT named. Every non-healing arm of the commit state machine
137
166
  * returns this identically by design, and the CLI must not invent a distinction
@@ -440,6 +440,92 @@ export function liveAt(liveUrl, version) {
440
440
  export function takingLonger() {
441
441
  return "! Taking a moment longer than usual…";
442
442
  }
443
+ const SUPPORT_EMAIL = "hello@vincentt.studio";
444
+ /**
445
+ * Flow L32 — the org is cut off and open-publish refused before a byte moved.
446
+ *
447
+ * This is NOT flipFailed and must never be folded into it. The API gives the two
448
+ * distinct wire codes because one means the system working correctly and the
449
+ * other means the ledger is broken; a creator told "the address didn't switch
450
+ * over" when they hit a plan limit has been told the platform is malfunctioning.
451
+ *
452
+ * The three facts are a STACKED ALIGNED BLOCK because they are a comparison —
453
+ * the figure against the allowance. Inline they are a sentence to parse; stacked
454
+ * an agent and a human read them the same way.
455
+ *
456
+ * "cannot be done from the CLI" is load-bearing: an agent's default response to a
457
+ * failure is to retry, and naming the remedy as a human action outside the tool
458
+ * is the only thing that stops the loop.
459
+ */
460
+ export function planLimitRefused(i) {
461
+ const rows = [];
462
+ if (i.orgName || i.orgSlug)
463
+ rows.push(["Organization", orgLabel(i.orgName, i.orgSlug)]);
464
+ if (i.planName) {
465
+ rows.push(["Plan", `${i.planName} — ${formatBytes(i.allowanceBytes)} traffic per month`]);
466
+ }
467
+ else {
468
+ rows.push(["Allowance", `${formatBytes(i.allowanceBytes)} traffic per month`]);
469
+ }
470
+ rows.push(["Served", `${formatBytes(i.figureBytes)} this month`]);
471
+ const when = i.cutOffAt ? shortDate(i.cutOffAt) : "";
472
+ const takenOn = when ? ` on ${when}` : "";
473
+ return [
474
+ "",
475
+ "✗ Vincentt has taken this organization's published addresses offline.",
476
+ "",
477
+ ...alignedRows(rows),
478
+ "",
479
+ ` Published addresses were taken offline${takenOn} because the organization`,
480
+ " went over its monthly traffic allowance. Restoring this is a manual action",
481
+ " by a Vincentt operator and cannot be done from the CLI.",
482
+ "",
483
+ " Every version is still stored and the address is still yours.",
484
+ "",
485
+ ...remedyLines(i.orgSlug, i.consoleUrl),
486
+ " Nothing was uploaded.",
487
+ "",
488
+ ];
489
+ }
490
+ function orgLabel(name, slug) {
491
+ if (name && slug && name !== slug)
492
+ return `${name} (${slug})`;
493
+ return name || slug || "";
494
+ }
495
+ /**
496
+ * Left-aligned label column. Distinct from alignedPair, which right-aligns two
497
+ * values so magnitudes compare — here the values are prose of different kinds.
498
+ */
499
+ function alignedRows(rows) {
500
+ const width = Math.max(...rows.map((r) => r[0].length));
501
+ return rows.map(([label, value]) => ` ${label.padEnd(width)} ${value}`);
502
+ }
503
+ /**
504
+ * f4's rule: where a verb does not exist because the act belongs to another
505
+ * surface, say where it lives and link to it — and if the link cannot be built,
506
+ * drop the link and keep the explanation.
507
+ */
508
+ function remedyLines(orgSlug, consoleUrl) {
509
+ if (!orgSlug)
510
+ return [` Email ${SUPPORT_EMAIL}.`, ""];
511
+ const base = (consoleUrl ?? "https://console.vincentt.studio").replace(/\/$/, "");
512
+ return [
513
+ ` See ${base}/orgs/${orgSlug}/usage, or email`,
514
+ ` ${SUPPORT_EMAIL}.`,
515
+ "",
516
+ ];
517
+ }
518
+ const MONTHS = [
519
+ "Jan", "Feb", "Mar", "Apr", "May", "Jun",
520
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
521
+ ];
522
+ /** `29 Aug` — the date the creator reads, in UTC, matching the marker's own day. */
523
+ function shortDate(iso) {
524
+ const d = new Date(iso);
525
+ if (Number.isNaN(d.getTime()))
526
+ return "";
527
+ return `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]}`;
528
+ }
443
529
  // ---- Flow L29, the server-side failures ---------------------------------------
444
530
  /**
445
531
  * The cause is NOT named. Every non-healing arm of the commit state machine
@@ -7,10 +7,25 @@ import readline from "node:readline";
7
7
  /** Reads one line from stdin. Anything that is not an explicit answer takes the default. */
8
8
  export function stdinConfirm(input = process.stdin, output = process.stdout) {
9
9
  return async (prompt, defaultYes) => {
10
+ // A pipe cannot consent. Readline answers a piped line as readily as a typed
11
+ // one, so `echo y | vincentt publish` and `echo y | vincentt dx --send` both
12
+ // read as a person saying yes. A confirmation exists to put a human in the
13
+ // loop; when there is no terminal there is no human to put there, and the
14
+ // safe answer is no — never `defaultYes`, which on a [Y/n] prompt would turn
15
+ // an absent human into an approving one.
16
+ if (!input.isTTY)
17
+ return false;
10
18
  const rl = readline.createInterface({ input, output });
11
19
  try {
12
- const answer = await new Promise((resolve) => rl.question(prompt, resolve));
13
- return interpretAnswer(answer, defaultYes);
20
+ // `close` without an answer must resolve, not hang: on EOF readline never
21
+ // invokes the question callback, so awaiting it alone abandons the promise
22
+ // and every statement after the await — including the caller's "not sent"
23
+ // path — silently never runs.
24
+ const answer = await new Promise((resolve) => {
25
+ rl.question(prompt, resolve);
26
+ rl.once("close", () => resolve(undefined));
27
+ });
28
+ return answer === undefined ? false : interpretAnswer(answer, defaultYes);
14
29
  }
15
30
  finally {
16
31
  rl.close();
@@ -30,6 +30,15 @@ export declare function needsScaffold(dir: string): Promise<boolean>;
30
30
  * Dotfiles count. A folder holding only `.git` is a repo someone cares about.
31
31
  */
32
32
  export declare function isNonEmptyDir(dir: string): Promise<boolean>;
33
+ /**
34
+ * The clone failed because the REF does not exist, as opposed to the repo being
35
+ * unreachable. The two remedies are opposite, and a caller cloning a gallery
36
+ * source words both differently — so the split is carried by the error's type
37
+ * rather than by re-matching git's text downstream.
38
+ */
39
+ export declare class ScaffoldRefError extends Error {
40
+ constructor(message: string);
41
+ }
33
42
  export interface ScaffoldOptions {
34
43
  repo?: string;
35
44
  /** Template ref (tag) to scaffold from. Defaults to `DEFAULT_TEMPLATE_REF`. */
@@ -59,6 +59,18 @@ export async function isNonEmptyDir(dir) {
59
59
  return false;
60
60
  }
61
61
  }
62
+ /**
63
+ * The clone failed because the REF does not exist, as opposed to the repo being
64
+ * unreachable. The two remedies are opposite, and a caller cloning a gallery
65
+ * source words both differently — so the split is carried by the error's type
66
+ * rather than by re-matching git's text downstream.
67
+ */
68
+ export class ScaffoldRefError extends Error {
69
+ constructor(message) {
70
+ super(message);
71
+ this.name = "ScaffoldRefError";
72
+ }
73
+ }
62
74
  /**
63
75
  * Scaffold the template into `targetDir`. Clones to a temp dir, strips `.git`,
64
76
  * copies the files in (never overwriting anything already there), then inits a
@@ -80,7 +92,7 @@ export async function scaffoldFromTemplate(targetDir, opts = {}) {
80
92
  // real cause is a typo'd version). git names the ref in this specific case,
81
93
  // so split on that rather than reporting one cause for both.
82
94
  if (/(remote branch|Remote branch).*not found|couldn't find remote ref/i.test(msg)) {
83
- throw new Error(`The template has no version "${ref}". ` +
95
+ throw new ScaffoldRefError(`The template has no version "${ref}". ` +
84
96
  `Check the tag exists, or omit --template to use the latest release.`);
85
97
  }
86
98
  throw new Error(`Could not clone the ${repo} template (check your git access): ${msg}`);
@@ -7,6 +7,16 @@ export interface MachineConfig {
7
7
  pat: string;
8
8
  /** Org slug commands act in when no --org flag or VINCENTT_ORG env is given. */
9
9
  activeOrg: string;
10
+ /**
11
+ * Durable authorization for unattended `dx --send`. Written only by
12
+ * `vincentt dx enable`, which requires a TTY. Cleared by `disable` and by `logout`.
13
+ */
14
+ dxSendEnabled?: boolean;
15
+ /**
16
+ * ISO8601 UTC, when enablement was granted. Its AGE is the mitigation for having
17
+ * no expiry, so an unparseable value fails closed rather than printing.
18
+ */
19
+ dxSendEnabledAt?: string;
10
20
  }
11
21
  export interface ProjectBinding {
12
22
  /** The backend Project id this working tree publishes to. */
@@ -26,9 +36,26 @@ export declare function loadMachineConfig(): Promise<Partial<MachineConfig>>;
26
36
  * every write, even when the file pre-existed with looser perms).
27
37
  */
28
38
  export declare function writeMachineConfig(patch: Partial<MachineConfig>): Promise<string>;
39
+ /**
40
+ * Set or clear unattended-send enablement (`vincentt dx enable` / `disable`).
41
+ *
42
+ * A dedicated function rather than a `writeMachineConfig` patch because clearing
43
+ * has to DELETE the keys. A patch of `{dxSendEnabled: undefined}` happens to work
44
+ * — JSON.stringify drops undefined values — but only by a coincidence of the
45
+ * serializer, and the read of a key that survived would be the difference between
46
+ * off and on.
47
+ */
48
+ export declare function writeDxEnablement(next: {
49
+ at: string;
50
+ } | undefined): Promise<string>;
29
51
  /**
30
52
  * Remove the stored PAT from ~/.vincentt/config.json (used by `vincentt logout`),
31
53
  * preserving apiUrl and any other fields. Returns whether a token was present.
54
+ *
55
+ * Unattended-send enablement goes with the PAT. This function preserves every
56
+ * other field by design, which would leave a disconnected machine still armed to
57
+ * transmit for whoever signs in next — a creator who disconnects a machine has
58
+ * disconnected it.
32
59
  */
33
60
  export declare function clearMachineToken(): Promise<{
34
61
  path: string;
@@ -8,6 +8,7 @@
8
8
  // Env vars (VINCENTT_API_URL / VINCENTT_PAT) override the file for local/dogfood
9
9
  // runs so a creator can point at a dev backend without editing the machine config.
10
10
  // The binding's optional apiUrl does NOT — see decideApiUrl.
11
+ import { randomBytes } from "node:crypto";
11
12
  import { promises as fs } from "node:fs";
12
13
  import os from "node:os";
13
14
  import path from "node:path";
@@ -31,18 +32,44 @@ export async function loadMachineConfig() {
31
32
  return (await readJsonIfExists(MACHINE_CONFIG_PATH)) ?? {};
32
33
  }
33
34
  /**
34
- * Persist the machine config with owner-only perms. `writeFile`'s `mode` only applies when
35
- * it CREATES the file, so a pre-existing config.json with looser perms (e.g. 0644) would keep
36
- * them and leave a bearer credential world-readable. An explicit chmod after the write tightens
37
- * it to 0600 every time, whether the file was just created or already existed.
35
+ * Persist the machine config with owner-only perms, ATOMICALLY: write a sibling
36
+ * temp file at 0600, then rename it over the target. Rename is atomic on the same
37
+ * filesystem, so there is no window in which config.json is truncated.
38
+ *
39
+ * Atomicity is not incidental cleanup. `dx enable` is a third writer to this file
40
+ * and, unlike `login`/`logout`, one an AGENT can trigger — in a session where
41
+ * `login` may also be running, against the file holding the bearer credential. A
42
+ * `writeFile` interrupted mid-write leaves a truncated config with the PAT gone,
43
+ * and it presents as an unexplained logout or as every later command throwing a
44
+ * SyntaxError, neither of which points at the write that caused it.
45
+ *
46
+ * `writeFile`'s `mode` only applies when it CREATES the file, so the temp file is
47
+ * chmod'ed explicitly: a lingering 0644 temp file would be a second, world-readable
48
+ * copy of the PAT. The temp file is removed if the rename fails, so a failed write
49
+ * leaves neither a truncated config nor a stray credential copy.
50
+ *
51
+ * The temp name is unique per call. A fixed `config.json.tmp` is safe for one
52
+ * writer and destructive for two: concurrent `login` and `dx enable` would write
53
+ * the same path, so whichever renamed second would fail ENOENT with its write
54
+ * silently lost — and a failing writer's cleanup would delete the other's
55
+ * in-flight file. Uniqueness also makes the `catch` safe by construction, since
56
+ * a writer can then only ever remove its own temp file.
38
57
  */
39
58
  async function persistMachineConfig(next) {
40
59
  await fs.mkdir(path.dirname(MACHINE_CONFIG_PATH), { recursive: true, mode: 0o700 });
41
- await fs.writeFile(MACHINE_CONFIG_PATH, JSON.stringify(next, null, 2) + "\n", {
42
- encoding: "utf8",
43
- mode: 0o600,
44
- });
45
- await fs.chmod(MACHINE_CONFIG_PATH, 0o600);
60
+ const tmp = `${MACHINE_CONFIG_PATH}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
61
+ try {
62
+ await fs.writeFile(tmp, JSON.stringify(next, null, 2) + "\n", {
63
+ encoding: "utf8",
64
+ mode: 0o600,
65
+ });
66
+ await fs.chmod(tmp, 0o600);
67
+ await fs.rename(tmp, MACHINE_CONFIG_PATH);
68
+ }
69
+ catch (err) {
70
+ await fs.rm(tmp, { force: true }).catch(() => { });
71
+ throw err;
72
+ }
46
73
  return MACHINE_CONFIG_PATH;
47
74
  }
48
75
  /**
@@ -55,14 +82,42 @@ export async function writeMachineConfig(patch) {
55
82
  const next = { ...(await loadMachineConfig()), ...patch };
56
83
  return persistMachineConfig(next);
57
84
  }
85
+ /**
86
+ * Set or clear unattended-send enablement (`vincentt dx enable` / `disable`).
87
+ *
88
+ * A dedicated function rather than a `writeMachineConfig` patch because clearing
89
+ * has to DELETE the keys. A patch of `{dxSendEnabled: undefined}` happens to work
90
+ * — JSON.stringify drops undefined values — but only by a coincidence of the
91
+ * serializer, and the read of a key that survived would be the difference between
92
+ * off and on.
93
+ */
94
+ export async function writeDxEnablement(next) {
95
+ const cfg = await loadMachineConfig();
96
+ if (next === undefined) {
97
+ delete cfg.dxSendEnabled;
98
+ delete cfg.dxSendEnabledAt;
99
+ }
100
+ else {
101
+ cfg.dxSendEnabled = true;
102
+ cfg.dxSendEnabledAt = next.at;
103
+ }
104
+ return persistMachineConfig(cfg);
105
+ }
58
106
  /**
59
107
  * Remove the stored PAT from ~/.vincentt/config.json (used by `vincentt logout`),
60
108
  * preserving apiUrl and any other fields. Returns whether a token was present.
109
+ *
110
+ * Unattended-send enablement goes with the PAT. This function preserves every
111
+ * other field by design, which would leave a disconnected machine still armed to
112
+ * transmit for whoever signs in next — a creator who disconnects a machine has
113
+ * disconnected it.
61
114
  */
62
115
  export async function clearMachineToken() {
63
116
  const current = await loadMachineConfig();
64
117
  const hadToken = Boolean(current.pat);
65
118
  delete current.pat;
119
+ delete current.dxSendEnabled;
120
+ delete current.dxSendEnabledAt;
66
121
  await persistMachineConfig(current);
67
122
  return { path: MACHINE_CONFIG_PATH, hadToken };
68
123
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincentt-xr/cli",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Vincentt CLI — the vincentt command-line dev loop (login, create, publish, preview, diagnostics, feedback, relay)",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",