@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.
@@ -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
+ }
@@ -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
- /** Read the machine's real state and produce the report. */
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;
@@ -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
- /** Read the machine's real state and produce the report. */
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 machine = await loadMachineConfig();
73
- const located = await locateProjectBinding(cwd);
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: machine.apiUrl,
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
+ }