@vincentt-xr/cli 0.7.0 → 0.8.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 +8 -1
- package/dist/cli/args.js +9 -1
- package/dist/cli/index.js +138 -7
- 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/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/prompt.js +17 -2
- package/dist/shared/config.d.ts +27 -0
- package/dist/shared/config.js +64 -9
- package/package.json +1 -1
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable authorization for unattended `vincentt dx --send`.
|
|
3
|
+
*
|
|
4
|
+
* `--send` transmits only when the session is interactive OR this machine has
|
|
5
|
+
* been explicitly enabled by a person, at a terminal, via `vincentt dx enable`.
|
|
6
|
+
* The state is two fields in ~/.vincentt/config.json, set by `enable`, cleared by
|
|
7
|
+
* `disable` and by `logout`, and it has NO expiry — it stays on until the creator
|
|
8
|
+
* turns it off.
|
|
9
|
+
*
|
|
10
|
+
* Because there is no expiry, VISIBILITY is the entire mitigation, and the AGE is
|
|
11
|
+
* the load-bearing half of it: `ENABLED` alone reads as configuration, while
|
|
12
|
+
* `(145 days)` reads as drift and is the number that makes someone ask whether
|
|
13
|
+
* they still want it. That is why an unparseable timestamp fails CLOSED rather
|
|
14
|
+
* than printing `since Invalid Date (NaN days)` — a degraded mitigation is worse
|
|
15
|
+
* than a visible absence.
|
|
16
|
+
*/
|
|
17
|
+
import { loadMachineConfig } from "../shared/config.js";
|
|
18
|
+
/**
|
|
19
|
+
* The product-lifetime bound on an explicitly internal-beta capability. Past this
|
|
20
|
+
* date `enable` refuses AND an existing flag stops being honored, so FORGETTING
|
|
21
|
+
* THIS TURNS THE FEATURE OFF — the safe direction.
|
|
22
|
+
*
|
|
23
|
+
* This is deliberately a constant in shipped code rather than a CI test with a
|
|
24
|
+
* hard-coded date: such a test fires in an uninvolved engineer's lap at a moment
|
|
25
|
+
* with no context, and the cheapest green is bumping the constant. Reaching this
|
|
26
|
+
* date is a decision to make (extend, or remove the feature), not a number to
|
|
27
|
+
* raise.
|
|
28
|
+
*/
|
|
29
|
+
export const DX_UNATTENDED_SUNSET = Date.parse("2027-02-24T00:00:00.000Z");
|
|
30
|
+
const DISABLED = { enabled: false };
|
|
31
|
+
/**
|
|
32
|
+
* Decide enablement from an already-parsed config object. Pure, and STRICT:
|
|
33
|
+
* identity against `true`, never truthiness.
|
|
34
|
+
*
|
|
35
|
+
* Truthiness fails OPEN on `"false"`, `"0"`, `{}`, `[]`, `1`, and `"garbage"` —
|
|
36
|
+
* every one of those is truthy in JavaScript. The worst is `"false"`, which is
|
|
37
|
+
* what a person hand-editing this file writes when they are trying to turn the
|
|
38
|
+
* feature OFF; `{}` is what a refactor grouping the two fields produces. A gate
|
|
39
|
+
* that reads a creator's "off" as "on" is inverted, not merely loose.
|
|
40
|
+
*/
|
|
41
|
+
export function readEnablement(cfg, now) {
|
|
42
|
+
if (typeof cfg !== "object" || cfg === null)
|
|
43
|
+
return DISABLED;
|
|
44
|
+
const { dxSendEnabled, dxSendEnabledAt } = cfg;
|
|
45
|
+
if (dxSendEnabled !== true)
|
|
46
|
+
return DISABLED;
|
|
47
|
+
if (typeof dxSendEnabledAt !== "string")
|
|
48
|
+
return DISABLED;
|
|
49
|
+
if (!Number.isFinite(Date.parse(dxSendEnabledAt)))
|
|
50
|
+
return DISABLED;
|
|
51
|
+
if (now >= DX_UNATTENDED_SUNSET)
|
|
52
|
+
return DISABLED;
|
|
53
|
+
return { enabled: true, at: dxSendEnabledAt };
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Read enablement from the real machine config.
|
|
57
|
+
*
|
|
58
|
+
* Wrapped in its OWN try/catch, independent of the auth read, because
|
|
59
|
+
* `readJsonIfExists` rethrows everything that is not ENOENT: a truncated
|
|
60
|
+
* config.json must DISABLE the relaxation, not throw through it and take an
|
|
61
|
+
* unrelated verb down with it.
|
|
62
|
+
*/
|
|
63
|
+
export async function loadEnablement(now) {
|
|
64
|
+
try {
|
|
65
|
+
return readEnablement(await loadMachineConfig(), now);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return DISABLED;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
72
|
+
/** Whole days elapsed, floored, never negative — a clock skew must not print `(-2 days)`. */
|
|
73
|
+
export function ageInDays(at, now) {
|
|
74
|
+
const t = Date.parse(at);
|
|
75
|
+
if (!Number.isFinite(t))
|
|
76
|
+
return 0;
|
|
77
|
+
return Math.max(0, Math.floor((now - t) / DAY_MS));
|
|
78
|
+
}
|
|
79
|
+
/** The calendar day of the grant, in UTC. The time of day is noise at this age scale. */
|
|
80
|
+
export function grantedOn(at) {
|
|
81
|
+
return new Date(Date.parse(at)).toISOString().slice(0, 10);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The one visibility line, in the single pinned shape every human surface uses:
|
|
85
|
+
*
|
|
86
|
+
* Unattended send: ENABLED since 2026-04-01 (145 days)
|
|
87
|
+
*
|
|
88
|
+
* Returns undefined when not enabled. Nothing is printed in that case, on
|
|
89
|
+
* purpose: a line that prints unconditionally is a line readers learn to skip,
|
|
90
|
+
* which would cost exactly the attention the enabled line needs.
|
|
91
|
+
*/
|
|
92
|
+
export function enablementLine(e, now) {
|
|
93
|
+
if (!e.enabled || e.at === undefined)
|
|
94
|
+
return undefined;
|
|
95
|
+
const days = ageInDays(e.at, now);
|
|
96
|
+
return `Unattended send: ENABLED since ${grantedOn(e.at)} (${days} ${days === 1 ? "day" : "days"})`;
|
|
97
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QA fixtures for the dx-agent-consent delta (f7).
|
|
3
|
+
*
|
|
4
|
+
* Declared in the change's qa.md `## Fixtures` section. All three are
|
|
5
|
+
* machine-config state, which f7 has never had a fixture for — its existing
|
|
6
|
+
* fixtures (F7_JOURNAL_FRICTION, F7_REPORT_EXPIRED, …) are journal-side or
|
|
7
|
+
* server-side.
|
|
8
|
+
*
|
|
9
|
+
* F7_CONFIG_SIGNED_IN is written by driving the REAL writeMachineConfig(), never
|
|
10
|
+
* by hand-writing JSON. A hand-written config asserts against a shape the CLI
|
|
11
|
+
* never produces — the f1 orgs/memberships failure at file scope, where a mock
|
|
12
|
+
* of state the API could actually produce drifted from the API and stayed green.
|
|
13
|
+
*
|
|
14
|
+
* These are QA-owned (qa.* prefix) and deliberately separate from dev's test
|
|
15
|
+
* files in the same package.
|
|
16
|
+
*/
|
|
17
|
+
import type { JournalEntry } from "./journal.js";
|
|
18
|
+
/** The honest baseline config, as the CLI itself writes it. */
|
|
19
|
+
export declare const F7_CONFIG_SIGNED_IN: {
|
|
20
|
+
readonly apiUrl: "https://api.test";
|
|
21
|
+
readonly consoleUrl: "https://console.test";
|
|
22
|
+
readonly pat: "vct_alice";
|
|
23
|
+
readonly activeOrg: "alice";
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* The instant every fixture is dated against. 145 days after the grant in
|
|
27
|
+
* F7_CONFIG_ENABLED, and before DX_UNATTENDED_SUNSET.
|
|
28
|
+
*/
|
|
29
|
+
export declare const F7_NOW: number;
|
|
30
|
+
/** 145 days before F7_NOW. The AGE is the point — see qa.md on F7_CONFIG_ENABLED. */
|
|
31
|
+
export declare const F7_ENABLED_AT = "2026-04-01T00:00:00.000Z";
|
|
32
|
+
/**
|
|
33
|
+
* The single pinned human-facing shape. Every human surface must render this
|
|
34
|
+
* byte-for-byte; the age is the load-bearing half, so it is pinned as a literal
|
|
35
|
+
* rather than computed (a computed expectation would follow a broken
|
|
36
|
+
* implementation wherever it went).
|
|
37
|
+
*/
|
|
38
|
+
export declare const F7_PINNED_ENABLEMENT_LINE = "Unattended send: ENABLED since 2026-04-01 (145 days)";
|
|
39
|
+
/** F7_CONFIG_SIGNED_IN + durable enablement, aged. */
|
|
40
|
+
export declare const F7_CONFIG_ENABLED: {
|
|
41
|
+
readonly dxSendEnabled: true;
|
|
42
|
+
readonly dxSendEnabledAt: "2026-04-01T00:00:00.000Z";
|
|
43
|
+
readonly apiUrl: "https://api.test";
|
|
44
|
+
readonly consoleUrl: "https://console.test";
|
|
45
|
+
readonly pat: "vct_alice";
|
|
46
|
+
readonly activeOrg: "alice";
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Every shape the enablement read must treat as DISABLED.
|
|
50
|
+
*
|
|
51
|
+
* Rows B, C, E, F, G are TRUTHY IN JAVASCRIPT — `if (cfg.dxSendEnabled)` passes
|
|
52
|
+
* every one of them. That is the entire reason this fixture exists, and why the
|
|
53
|
+
* read is specified as `=== true`. The worst is B ("false"): what a person
|
|
54
|
+
* hand-editing this file writes when trying to turn the feature OFF.
|
|
55
|
+
*
|
|
56
|
+
* `raw` rows (K, L) are written as bytes rather than as an object, because the
|
|
57
|
+
* property under test is that an UNPARSEABLE config fails closed instead of
|
|
58
|
+
* throwing through into an unrelated verb.
|
|
59
|
+
*/
|
|
60
|
+
export interface MalformedRow {
|
|
61
|
+
id: string;
|
|
62
|
+
why: string;
|
|
63
|
+
/** A config object to serialize, or undefined when `raw` carries the bytes. */
|
|
64
|
+
patch?: Record<string, unknown>;
|
|
65
|
+
/** Literal file bytes, for the rows that are not valid JSON at all. */
|
|
66
|
+
raw?: string;
|
|
67
|
+
}
|
|
68
|
+
export declare const F7_CONFIG_MALFORMED_ENABLEMENT: MalformedRow[];
|
|
69
|
+
/**
|
|
70
|
+
* F7_JOURNAL_FRICTION, as the record declares it: three runs of the same verb in
|
|
71
|
+
* a few minutes, which is a repetition cluster.
|
|
72
|
+
*/
|
|
73
|
+
export declare function f7JournalFriction(now?: number): JournalEntry[];
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** The honest baseline config, as the CLI itself writes it. */
|
|
2
|
+
export const F7_CONFIG_SIGNED_IN = {
|
|
3
|
+
apiUrl: "https://api.test",
|
|
4
|
+
consoleUrl: "https://console.test",
|
|
5
|
+
pat: "vct_alice",
|
|
6
|
+
activeOrg: "alice",
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* The instant every fixture is dated against. 145 days after the grant in
|
|
10
|
+
* F7_CONFIG_ENABLED, and before DX_UNATTENDED_SUNSET.
|
|
11
|
+
*/
|
|
12
|
+
export const F7_NOW = Date.parse("2026-08-24T12:00:00.000Z");
|
|
13
|
+
/** 145 days before F7_NOW. The AGE is the point — see qa.md on F7_CONFIG_ENABLED. */
|
|
14
|
+
export const F7_ENABLED_AT = "2026-04-01T00:00:00.000Z";
|
|
15
|
+
/**
|
|
16
|
+
* The single pinned human-facing shape. Every human surface must render this
|
|
17
|
+
* byte-for-byte; the age is the load-bearing half, so it is pinned as a literal
|
|
18
|
+
* rather than computed (a computed expectation would follow a broken
|
|
19
|
+
* implementation wherever it went).
|
|
20
|
+
*/
|
|
21
|
+
export const F7_PINNED_ENABLEMENT_LINE = "Unattended send: ENABLED since 2026-04-01 (145 days)";
|
|
22
|
+
/** F7_CONFIG_SIGNED_IN + durable enablement, aged. */
|
|
23
|
+
export const F7_CONFIG_ENABLED = {
|
|
24
|
+
...F7_CONFIG_SIGNED_IN,
|
|
25
|
+
dxSendEnabled: true,
|
|
26
|
+
dxSendEnabledAt: F7_ENABLED_AT,
|
|
27
|
+
};
|
|
28
|
+
export const F7_CONFIG_MALFORMED_ENABLEMENT = [
|
|
29
|
+
{ id: "A", why: 'truthy STRING "true"', patch: { dxSendEnabled: "true", dxSendEnabledAt: F7_ENABLED_AT } },
|
|
30
|
+
{ id: "B", why: 'truthy in JS, means false to a human', patch: { dxSendEnabled: "false", dxSendEnabledAt: F7_ENABLED_AT } },
|
|
31
|
+
{ id: "C", why: 'truthy in JS ("0")', patch: { dxSendEnabled: "0", dxSendEnabledAt: F7_ENABLED_AT } },
|
|
32
|
+
{ id: "D", why: "truthy, not the boolean (1)", patch: { dxSendEnabled: 1, dxSendEnabledAt: F7_ENABLED_AT } },
|
|
33
|
+
{ id: "E", why: "truthy ({} — what a refactor grouping the fields produces)", patch: { dxSendEnabled: {}, dxSendEnabledAt: F7_ENABLED_AT } },
|
|
34
|
+
{ id: "F", why: "truthy ([])", patch: { dxSendEnabled: [], dxSendEnabledAt: F7_ENABLED_AT } },
|
|
35
|
+
{ id: "G", why: 'truthy ("garbage")', patch: { dxSendEnabled: "garbage", dxSendEnabledAt: F7_ENABLED_AT } },
|
|
36
|
+
{ id: "H", why: "key absent entirely", patch: {} },
|
|
37
|
+
{ id: "I", why: "enabled true, dxSendEnabledAt absent", patch: { dxSendEnabled: true } },
|
|
38
|
+
{ id: "J", why: 'enabled true, dxSendEnabledAt "not-a-date"', patch: { dxSendEnabled: true, dxSendEnabledAt: "not-a-date" } },
|
|
39
|
+
{ id: "K", why: "file truncated mid-JSON", raw: '{"pat":"vct_alice","dxSendEn' },
|
|
40
|
+
{ id: "L", why: "file empty (0 bytes)", raw: "" },
|
|
41
|
+
];
|
|
42
|
+
/**
|
|
43
|
+
* F7_JOURNAL_FRICTION, as the record declares it: three runs of the same verb in
|
|
44
|
+
* a few minutes, which is a repetition cluster.
|
|
45
|
+
*/
|
|
46
|
+
export function f7JournalFriction(now = F7_NOW) {
|
|
47
|
+
const entry = (minutesAgo) => ({
|
|
48
|
+
ts: new Date(now - minutesAgo * 60_000).toISOString(),
|
|
49
|
+
v: "0.7.0",
|
|
50
|
+
verb: "preview",
|
|
51
|
+
skel: "preview",
|
|
52
|
+
exit: 0,
|
|
53
|
+
ms: 800,
|
|
54
|
+
err: null,
|
|
55
|
+
wt: "abc123",
|
|
56
|
+
});
|
|
57
|
+
return [entry(3), entry(2), entry(1)];
|
|
58
|
+
}
|
package/dist/lib/version.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Enablement } from "../dx/enablement.js";
|
|
1
2
|
export interface VersionReport {
|
|
2
3
|
cliVersion: string;
|
|
3
4
|
nodeVersion: string;
|
|
@@ -8,6 +9,12 @@ export interface VersionReport {
|
|
|
8
9
|
ignoredBindingApiUrl?: string;
|
|
9
10
|
/** True when the resolved host is the built-in fallback (nothing configured). */
|
|
10
11
|
isDefault: boolean;
|
|
12
|
+
/** True when this machine may send DX reports unattended. */
|
|
13
|
+
dxSendEnabled: boolean;
|
|
14
|
+
/** ISO8601 UTC of the grant. Present only when enabled. */
|
|
15
|
+
dxSendEnabledAt?: string;
|
|
16
|
+
/** The instant the age is computed against, so renderVersion stays pure. */
|
|
17
|
+
dxAgeAsOf: number;
|
|
11
18
|
}
|
|
12
19
|
export interface VersionEnv {
|
|
13
20
|
envApiUrl?: string;
|
|
@@ -16,6 +23,10 @@ export interface VersionEnv {
|
|
|
16
23
|
allowCustomApi?: boolean;
|
|
17
24
|
nodeVersion: string;
|
|
18
25
|
cliVersion: string;
|
|
26
|
+
/** Durable unattended-send state, already read. Absent reads as not enabled. */
|
|
27
|
+
enablement?: Enablement;
|
|
28
|
+
/** Injected so the age is testable without waiting a day. */
|
|
29
|
+
now?: number;
|
|
19
30
|
}
|
|
20
31
|
/**
|
|
21
32
|
* Decide what to report. Pure, so the transcript is testable without a home
|
|
@@ -27,7 +38,15 @@ export declare function buildVersionReport(env: VersionEnv): VersionReport;
|
|
|
27
38
|
* fact, not two.
|
|
28
39
|
*/
|
|
29
40
|
export declare function renderVersion(r: VersionReport): string[];
|
|
30
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* Read the machine's real state and produce the report.
|
|
43
|
+
*
|
|
44
|
+
* Every read here is wrapped. This is the command people run WHEN THINGS ARE
|
|
45
|
+
* ALREADY BROKEN, and a truncated config.json — the state that most needs
|
|
46
|
+
* diagnosing — makes `loadMachineConfig` throw, since readJsonIfExists rethrows
|
|
47
|
+
* everything that is not ENOENT. A diagnostic that dies on the fault it exists to
|
|
48
|
+
* report is worse than one that reports less.
|
|
49
|
+
*/
|
|
31
50
|
export declare function versionReport(cwd: string, opts: {
|
|
32
51
|
cliVersion: string;
|
|
33
52
|
allowCustomApi?: boolean;
|
package/dist/lib/version.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// reads like a server outage rather than a misconfiguration. So this reports the
|
|
8
8
|
// resolved host AND its provenance, using the same decideApiUrl the real verbs
|
|
9
9
|
// use — a version command that re-derived the URL could disagree with them.
|
|
10
|
+
import { enablementLine, loadEnablement } from "../dx/enablement.js";
|
|
10
11
|
import { decideApiUrl, loadMachineConfig, locateProjectBinding, } from "../shared/config.js";
|
|
11
12
|
/**
|
|
12
13
|
* Decide what to report. Pure, so the transcript is testable without a home
|
|
@@ -34,6 +35,7 @@ export function buildVersionReport(env) {
|
|
|
34
35
|
apiSource = "built-in default";
|
|
35
36
|
isDefault = true;
|
|
36
37
|
}
|
|
38
|
+
const enablement = env.enablement ?? { enabled: false };
|
|
37
39
|
return {
|
|
38
40
|
cliVersion: env.cliVersion,
|
|
39
41
|
nodeVersion: env.nodeVersion,
|
|
@@ -41,6 +43,9 @@ export function buildVersionReport(env) {
|
|
|
41
43
|
apiSource,
|
|
42
44
|
ignoredBindingApiUrl: decided.ignoredBindingApiUrl,
|
|
43
45
|
isDefault,
|
|
46
|
+
dxSendEnabled: enablement.enabled,
|
|
47
|
+
dxSendEnabledAt: enablement.enabled ? enablement.at : undefined,
|
|
48
|
+
dxAgeAsOf: env.now ?? Date.now(),
|
|
44
49
|
};
|
|
45
50
|
}
|
|
46
51
|
/**
|
|
@@ -64,19 +69,50 @@ export function renderVersion(r) {
|
|
|
64
69
|
if (r.ignoredBindingApiUrl) {
|
|
65
70
|
out.push("", ` ! This folder's binding names a different API (${r.ignoredBindingApiUrl})`, " and was ignored. Re-run with --allow-custom-api to use it.");
|
|
66
71
|
}
|
|
67
|
-
out.push("", ` node ${r.nodeVersion}
|
|
72
|
+
out.push("", ` node ${r.nodeVersion}`);
|
|
73
|
+
// `version` is what people run when something is ALREADY broken, which is
|
|
74
|
+
// exactly when a forgotten enablement is most likely to be discovered. Printed
|
|
75
|
+
// only when enabled — see the note in dx/enablement.ts on why there is no `off`
|
|
76
|
+
// counterpart.
|
|
77
|
+
const dx = enablementLine({ enabled: r.dxSendEnabled, at: r.dxSendEnabledAt }, r.dxAgeAsOf);
|
|
78
|
+
if (dx !== undefined)
|
|
79
|
+
out.push("", ` ${dx}`);
|
|
80
|
+
out.push("");
|
|
68
81
|
return out;
|
|
69
82
|
}
|
|
70
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* Read the machine's real state and produce the report.
|
|
85
|
+
*
|
|
86
|
+
* Every read here is wrapped. This is the command people run WHEN THINGS ARE
|
|
87
|
+
* ALREADY BROKEN, and a truncated config.json — the state that most needs
|
|
88
|
+
* diagnosing — makes `loadMachineConfig` throw, since readJsonIfExists rethrows
|
|
89
|
+
* everything that is not ENOENT. A diagnostic that dies on the fault it exists to
|
|
90
|
+
* report is worse than one that reports less.
|
|
91
|
+
*/
|
|
71
92
|
export async function versionReport(cwd, opts) {
|
|
72
|
-
const
|
|
73
|
-
|
|
93
|
+
const now = Date.now();
|
|
94
|
+
let machineApiUrl;
|
|
95
|
+
try {
|
|
96
|
+
machineApiUrl = (await loadMachineConfig()).apiUrl;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
machineApiUrl = undefined;
|
|
100
|
+
}
|
|
101
|
+
let located;
|
|
102
|
+
try {
|
|
103
|
+
located = await locateProjectBinding(cwd);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
located = undefined;
|
|
107
|
+
}
|
|
74
108
|
return buildVersionReport({
|
|
75
109
|
envApiUrl: process.env.VINCENTT_API_URL,
|
|
76
|
-
machineApiUrl
|
|
110
|
+
machineApiUrl,
|
|
77
111
|
bindingApiUrl: located?.binding.apiUrl,
|
|
78
112
|
allowCustomApi: opts.allowCustomApi,
|
|
79
113
|
nodeVersion: process.version,
|
|
80
114
|
cliVersion: opts.cliVersion,
|
|
115
|
+
enablement: await loadEnablement(now),
|
|
116
|
+
now,
|
|
81
117
|
});
|
|
82
118
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type LibraryClient } from "./client.js";
|
|
2
|
+
export interface AssetsDeps {
|
|
3
|
+
/** stdout — the JSON document under --json, the human screen otherwise. */
|
|
4
|
+
out: (line: string) => void;
|
|
5
|
+
/** stderr — EVERY human line when --json is on. */
|
|
6
|
+
err: (line: string) => void;
|
|
7
|
+
client: LibraryClient;
|
|
8
|
+
/** The project root `--download` writes under. */
|
|
9
|
+
cwd: string;
|
|
10
|
+
}
|
|
11
|
+
export interface SearchOptions {
|
|
12
|
+
query: string;
|
|
13
|
+
kind?: string;
|
|
14
|
+
render?: string;
|
|
15
|
+
palette?: string;
|
|
16
|
+
limit?: string;
|
|
17
|
+
json: boolean;
|
|
18
|
+
}
|
|
19
|
+
export declare function assetsSearch(deps: AssetsDeps, opts: SearchOptions): Promise<number>;
|
|
20
|
+
export interface ShowOptions {
|
|
21
|
+
ids: string[];
|
|
22
|
+
json: boolean;
|
|
23
|
+
}
|
|
24
|
+
export declare function assetsShow(deps: AssetsDeps, opts: ShowOptions): Promise<number>;
|
|
25
|
+
export interface AddOptions {
|
|
26
|
+
ids: string[];
|
|
27
|
+
json: boolean;
|
|
28
|
+
download: boolean;
|
|
29
|
+
force: boolean;
|
|
30
|
+
dir?: string;
|
|
31
|
+
}
|
|
32
|
+
export declare function assetsAdd(deps: AssetsDeps, opts: AddOptions): Promise<number>;
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// `vincentt assets search | show | add` — the three creator-facing f5 verbs.
|
|
2
|
+
//
|
|
3
|
+
// EXIT CODES ARE THE AGENT'S WHOLE CONTROL FLOW and the three are never collapsed:
|
|
4
|
+
// 0 we answered — INCLUDING a zero-result answer, which is a correct answer to a
|
|
5
|
+
// well-formed question. A non-zero exit here teaches an agent that an empty
|
|
6
|
+
// library is a TOOL failure, and an agent's move on a tool failure is to retry
|
|
7
|
+
// or route around — where "route around" means inventing a URL.
|
|
8
|
+
// 1 we answered and something you asked for is missing (partial resolution).
|
|
9
|
+
// 4 we did not answer (unreachable / not signed in / no corpus loaded).
|
|
10
|
+
// Exit 1 vs 4 matters because the correct recovery differs: re-search vs retry later.
|
|
11
|
+
//
|
|
12
|
+
// STREAM DISCIPLINE: under `--json`, stdout carries ONLY the JSON document. Every
|
|
13
|
+
// human line — progress, warnings, errors — goes to stderr, always, so an agent
|
|
14
|
+
// piping stdout into a parser never gets a mixed stream.
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { LibraryError, LibraryUnreachableError, } from "./client.js";
|
|
17
|
+
import { DOWNLOAD_DIR, downloadPath, exists, overwriteRefusalLines, writeAsset, } from "./download.js";
|
|
18
|
+
import { dedupeForDisplay, parseIdArgs, parseSearchFlags } from "./parse.js";
|
|
19
|
+
import { addLines, humanBytes, searchLines, showLines, unknownBlockLines, unreachableLines, } from "./render.js";
|
|
20
|
+
import { isLibraryId } from "./wire.js";
|
|
21
|
+
/**
|
|
22
|
+
* Map a thrown client error onto the exit-4 screen it deserves.
|
|
23
|
+
*
|
|
24
|
+
* A 401/403 is "not signed in"; a 503 library_unavailable is "we could not look" —
|
|
25
|
+
* and it is NEVER rendered as a zero-result answer with gaps. Collapsing those two
|
|
26
|
+
* would have the platform emit an inventory claim it cannot support: telling every
|
|
27
|
+
* agent "the library has no wearable assets" about a ~500-record library because a
|
|
28
|
+
* corpus failed to load.
|
|
29
|
+
*/
|
|
30
|
+
function failureScreen(err, retry) {
|
|
31
|
+
if (err instanceof LibraryUnreachableError) {
|
|
32
|
+
return { lines: unreachableLines("transport", retry), code: 4 };
|
|
33
|
+
}
|
|
34
|
+
if (err instanceof LibraryError) {
|
|
35
|
+
if (err.status === 401 || err.status === 403) {
|
|
36
|
+
return { lines: unreachableLines("auth", retry), code: 4 };
|
|
37
|
+
}
|
|
38
|
+
if (err.status === 429) {
|
|
39
|
+
return {
|
|
40
|
+
lines: [
|
|
41
|
+
"",
|
|
42
|
+
"✗ Too many library requests. Wait a moment and try again.",
|
|
43
|
+
"",
|
|
44
|
+
" Nothing is wrong with your project.",
|
|
45
|
+
"",
|
|
46
|
+
],
|
|
47
|
+
code: 4,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return { lines: unreachableLines("unavailable", retry), code: 4 };
|
|
51
|
+
}
|
|
52
|
+
return { lines: unreachableLines("transport", retry), code: 4 };
|
|
53
|
+
}
|
|
54
|
+
export async function assetsSearch(deps, opts) {
|
|
55
|
+
const flags = parseSearchFlags({
|
|
56
|
+
kind: opts.kind,
|
|
57
|
+
render: opts.render,
|
|
58
|
+
palette: opts.palette,
|
|
59
|
+
limit: opts.limit,
|
|
60
|
+
});
|
|
61
|
+
if (!flags.ok) {
|
|
62
|
+
for (const l of flags.lines)
|
|
63
|
+
deps.err(l);
|
|
64
|
+
return 1;
|
|
65
|
+
}
|
|
66
|
+
const retry = `vincentt assets search "${opts.query}"`;
|
|
67
|
+
let res;
|
|
68
|
+
try {
|
|
69
|
+
res = await deps.client.search({ q: opts.query, ...flags.flags });
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
const screen = failureScreen(err, retry);
|
|
73
|
+
for (const l of screen.lines)
|
|
74
|
+
deps.err(l);
|
|
75
|
+
return screen.code;
|
|
76
|
+
}
|
|
77
|
+
if (opts.json) {
|
|
78
|
+
// The agent's shape, and it is STABLE. `gaps` and `results` are always present
|
|
79
|
+
// even when empty: a key that appears only on failure is a key nobody handles.
|
|
80
|
+
deps.out(JSON.stringify({
|
|
81
|
+
query: res.query ?? opts.query,
|
|
82
|
+
...(res.revision !== undefined ? { revision: res.revision } : {}),
|
|
83
|
+
resultCount: res.resultCount,
|
|
84
|
+
results: res.results,
|
|
85
|
+
gaps: res.gaps,
|
|
86
|
+
}, null, 2));
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
for (const l of searchLines(res, opts.query))
|
|
90
|
+
deps.err(l);
|
|
91
|
+
// Zero results is exit 0. This is the single most important exit code in f5.
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Resolve ids through the ONE call shape the design mandates: a single id uses the
|
|
96
|
+
* fetch route, 2+ use ONE batch call — never N sequential fetches
|
|
97
|
+
* (D-multi-asset-is-mandatory). The ids go out AS GIVEN, duplicates intact.
|
|
98
|
+
*/
|
|
99
|
+
async function resolveIds(client, ids) {
|
|
100
|
+
if (ids.length === 1) {
|
|
101
|
+
const one = await client.fetchOne(ids[0]);
|
|
102
|
+
return one === undefined
|
|
103
|
+
? { resolved: [], unknown: [ids[0]] }
|
|
104
|
+
: { resolved: [one], unknown: [] };
|
|
105
|
+
}
|
|
106
|
+
const res = await client.batch(ids);
|
|
107
|
+
return { resolved: res.resolved, unknown: res.unknown };
|
|
108
|
+
}
|
|
109
|
+
export async function assetsShow(deps, opts) {
|
|
110
|
+
const parsed = parseIdArgs(opts.ids);
|
|
111
|
+
if (!parsed.ok) {
|
|
112
|
+
for (const l of parsed.lines)
|
|
113
|
+
deps.err(l);
|
|
114
|
+
return 1;
|
|
115
|
+
}
|
|
116
|
+
let result;
|
|
117
|
+
try {
|
|
118
|
+
result = await resolveIds(deps.client, parsed.ids);
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
const screen = failureScreen(err, `vincentt assets show ${parsed.ids.join(" ")}`);
|
|
122
|
+
for (const l of screen.lines)
|
|
123
|
+
deps.err(l);
|
|
124
|
+
return screen.code;
|
|
125
|
+
}
|
|
126
|
+
const shown = dedupeForDisplay(result.resolved, (a) => a.id);
|
|
127
|
+
const unknown = [...new Set(result.unknown)];
|
|
128
|
+
if (opts.json) {
|
|
129
|
+
deps.out(JSON.stringify({ resolved: result.resolved, unknown: result.unknown }, null, 2));
|
|
130
|
+
return unknown.length > 0 ? 1 : 0;
|
|
131
|
+
}
|
|
132
|
+
for (const a of shown)
|
|
133
|
+
for (const l of showLines(a))
|
|
134
|
+
deps.err(l);
|
|
135
|
+
if (unknown.length > 0) {
|
|
136
|
+
const malformed = unknown.some((id) => !isLibraryId(id));
|
|
137
|
+
for (const l of unknownBlockLines(unknown, shown.length, malformed))
|
|
138
|
+
deps.err(l);
|
|
139
|
+
return 1;
|
|
140
|
+
}
|
|
141
|
+
return 0;
|
|
142
|
+
}
|
|
143
|
+
export async function assetsAdd(deps, opts) {
|
|
144
|
+
const parsed = parseIdArgs(opts.ids);
|
|
145
|
+
if (!parsed.ok) {
|
|
146
|
+
for (const l of parsed.lines)
|
|
147
|
+
deps.err(l);
|
|
148
|
+
return 1;
|
|
149
|
+
}
|
|
150
|
+
let result;
|
|
151
|
+
try {
|
|
152
|
+
result = await resolveIds(deps.client, parsed.ids);
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
const screen = failureScreen(err, `vincentt assets add ${parsed.ids.join(" ")}`);
|
|
156
|
+
for (const l of screen.lines)
|
|
157
|
+
deps.err(l);
|
|
158
|
+
return screen.code;
|
|
159
|
+
}
|
|
160
|
+
const shown = dedupeForDisplay(result.resolved, (a) => a.id);
|
|
161
|
+
const unknown = [...new Set(result.unknown)];
|
|
162
|
+
// The arity in the header counts what the CALLER asked for, de-duplicated for
|
|
163
|
+
// display — "4 of 5" must mean four of the five distinct things they named.
|
|
164
|
+
const requested = new Set(parsed.ids).size;
|
|
165
|
+
if (opts.json) {
|
|
166
|
+
deps.out(JSON.stringify({ resolved: result.resolved, unknown: result.unknown }, null, 2));
|
|
167
|
+
if (opts.download) {
|
|
168
|
+
// --json + --download still writes, and still announces every path — but on
|
|
169
|
+
// stderr, so stdout stays a single parseable document.
|
|
170
|
+
await runDownload(deps, shown, opts);
|
|
171
|
+
}
|
|
172
|
+
return unknown.length > 0 ? 1 : 0;
|
|
173
|
+
}
|
|
174
|
+
for (const l of addLines(shown, unknown.length, requested))
|
|
175
|
+
deps.err(l);
|
|
176
|
+
let downloadCode = 0;
|
|
177
|
+
if (opts.download) {
|
|
178
|
+
downloadCode = await runDownload(deps, shown, opts);
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
// `add` is a verb that sounds like it writes files. This one sentence, on every
|
|
182
|
+
// successful add, stops a creator searching their repo for what it added — and
|
|
183
|
+
// it is the honest statement of "the platform informs, never performs".
|
|
184
|
+
deps.err(" Nothing was written. These are URLs to reference from your source.");
|
|
185
|
+
deps.err("");
|
|
186
|
+
}
|
|
187
|
+
if (unknown.length > 0) {
|
|
188
|
+
const malformed = unknown.some((id) => !isLibraryId(id));
|
|
189
|
+
for (const l of unknownBlockLines(unknown, shown.length, malformed))
|
|
190
|
+
deps.err(l);
|
|
191
|
+
if (!opts.download) {
|
|
192
|
+
deps.err(" Nothing was written.");
|
|
193
|
+
deps.err("");
|
|
194
|
+
}
|
|
195
|
+
return 1;
|
|
196
|
+
}
|
|
197
|
+
return downloadCode;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* The `--download` side effect. Every written path is announced, per the rule that
|
|
201
|
+
* a state-changing command names its side effects on the creator's disk.
|
|
202
|
+
*/
|
|
203
|
+
async function runDownload(deps, records, opts) {
|
|
204
|
+
const baseDir = opts.dir !== undefined ? path.resolve(opts.dir) : path.join(deps.cwd, DOWNLOAD_DIR);
|
|
205
|
+
// The overwrite gate runs over ALL records BEFORE any byte is written, so a
|
|
206
|
+
// refusal never leaves a half-written set on disk.
|
|
207
|
+
if (!opts.force) {
|
|
208
|
+
for (const a of records) {
|
|
209
|
+
const target = downloadPath(baseDir, a);
|
|
210
|
+
if (await exists(target)) {
|
|
211
|
+
for (const l of overwriteRefusalLines(path.relative(deps.cwd, target)))
|
|
212
|
+
deps.err(l);
|
|
213
|
+
return 1;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
deps.err(`→ Downloading ${records.length} ${records.length === 1 ? "file" : "files"}…`);
|
|
218
|
+
const written = [];
|
|
219
|
+
for (const a of records) {
|
|
220
|
+
const target = downloadPath(baseDir, a);
|
|
221
|
+
let bytes;
|
|
222
|
+
try {
|
|
223
|
+
bytes = await deps.client.download(a.url);
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
// A CDN failure is NOT a resolution failure: the ids resolved and the URLs
|
|
227
|
+
// above are correct and usable. Saying so is the difference between a
|
|
228
|
+
// creator retrying the download and a creator concluding the asset is gone.
|
|
229
|
+
deps.err("");
|
|
230
|
+
deps.err(`✗ Couldn't download ${a.id}.`);
|
|
231
|
+
deps.err("");
|
|
232
|
+
deps.err(" The URLs above are correct and still work — reference them directly");
|
|
233
|
+
deps.err(" instead, or try --download again in a moment.");
|
|
234
|
+
deps.err("");
|
|
235
|
+
if (written.length > 0) {
|
|
236
|
+
deps.err(` ${written.length} file${written.length === 1 ? "" : "s"} already written:`);
|
|
237
|
+
for (const w of written)
|
|
238
|
+
deps.err(` ${w}`);
|
|
239
|
+
deps.err("");
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
deps.err(" Nothing was written.");
|
|
243
|
+
deps.err("");
|
|
244
|
+
}
|
|
245
|
+
void err;
|
|
246
|
+
return 1;
|
|
247
|
+
}
|
|
248
|
+
const res = await writeAsset(target, bytes);
|
|
249
|
+
const rel = path.relative(deps.cwd, res.path);
|
|
250
|
+
written.push(rel);
|
|
251
|
+
const size = humanBytes(res.bytes);
|
|
252
|
+
deps.err(`✓ Wrote ${rel}${size ? ` ${size}` : ""}`);
|
|
253
|
+
}
|
|
254
|
+
deps.err("");
|
|
255
|
+
// The reference line CHANGES after a download: the correct reference is now the
|
|
256
|
+
// local path, and printing the CDN URL here would be actively wrong — the creator
|
|
257
|
+
// would ship both a copy and a remote load.
|
|
258
|
+
deps.err(" Reference them as /assets/library/<filename>, not as the library URL.");
|
|
259
|
+
deps.err("");
|
|
260
|
+
deps.err(" These are now your files, in your repo. Nothing links them back to the");
|
|
261
|
+
deps.err(" library and nothing updates them.");
|
|
262
|
+
deps.err("");
|
|
263
|
+
return 0;
|
|
264
|
+
}
|