@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.
- package/dist/cli/args.d.ts +18 -1
- package/dist/cli/args.js +40 -1
- package/dist/cli/index.js +208 -10
- package/dist/dx/command.d.ts +59 -2
- package/dist/dx/command.js +150 -11
- package/dist/dx/enablement.d.ts +51 -0
- package/dist/dx/enablement.js +97 -0
- package/dist/dx/qa.f7.fixtures.d.ts +73 -0
- package/dist/dx/qa.f7.fixtures.js +58 -0
- package/dist/dx/skeleton.js +1 -0
- package/dist/lib/initCommand.d.ts +28 -1
- package/dist/lib/initCommand.js +91 -16
- package/dist/lib/initFrom.d.ts +48 -0
- package/dist/lib/initFrom.js +198 -0
- package/dist/lib/sourceRef.d.ts +46 -0
- package/dist/lib/sourceRef.js +107 -0
- package/dist/lib/sourceRefCopy.d.ts +11 -0
- package/dist/lib/sourceRefCopy.js +80 -0
- package/dist/lib/version.d.ts +20 -1
- package/dist/lib/version.js +41 -5
- package/dist/library/assetsCommand.d.ts +32 -0
- package/dist/library/assetsCommand.js +264 -0
- package/dist/library/client.d.ts +42 -0
- package/dist/library/client.js +137 -0
- package/dist/library/download.d.ts +46 -0
- package/dist/library/download.js +98 -0
- package/dist/library/parse.d.ts +43 -0
- package/dist/library/parse.js +109 -0
- package/dist/library/qa.f5-b32.spec.d.ts +1 -0
- package/dist/library/qa.f5-b32.spec.js +202 -0
- package/dist/library/qa.f5-cli.spec.d.ts +1 -0
- package/dist/library/qa.f5-cli.spec.js +433 -0
- package/dist/library/qa.f5-contract.spec.d.ts +1 -0
- package/dist/library/qa.f5-contract.spec.js +206 -0
- package/dist/library/render.d.ts +39 -0
- package/dist/library/render.js +305 -0
- package/dist/library/wire.d.ts +96 -0
- package/dist/library/wire.js +167 -0
- package/dist/publish/api.d.ts +20 -0
- package/dist/publish/command.d.ts +3 -0
- package/dist/publish/command.js +39 -4
- package/dist/publish/copy.d.ts +29 -0
- package/dist/publish/copy.js +86 -0
- package/dist/publish/prompt.js +17 -2
- package/dist/scaffold/index.d.ts +9 -0
- package/dist/scaffold/index.js +13 -1
- package/dist/shared/config.d.ts +27 -0
- package/dist/shared/config.js +64 -9
- package/package.json +1 -1
package/dist/publish/command.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/publish/copy.d.ts
CHANGED
|
@@ -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
|
package/dist/publish/copy.js
CHANGED
|
@@ -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
|
package/dist/publish/prompt.js
CHANGED
|
@@ -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
|
-
|
|
13
|
-
|
|
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();
|
package/dist/scaffold/index.d.ts
CHANGED
|
@@ -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`. */
|
package/dist/scaffold/index.js
CHANGED
|
@@ -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
|
|
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}`);
|
package/dist/shared/config.d.ts
CHANGED
|
@@ -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;
|
package/dist/shared/config.js
CHANGED
|
@@ -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
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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