@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
@@ -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
+ }
@@ -0,0 +1,42 @@
1
+ import type { ResolvedConfig } from "../shared/config.js";
2
+ import { type AssetRecord, type BatchResponse, type SearchResponse } from "./wire.js";
3
+ /** The API said no, with a code we can act on. */
4
+ export declare class LibraryError extends Error {
5
+ readonly status: number;
6
+ readonly code?: string | undefined;
7
+ constructor(message: string, status: number, code?: string | undefined);
8
+ }
9
+ /** We could not reach the API at all — a transport failure, not an answer. */
10
+ export declare class LibraryUnreachableError extends Error {
11
+ readonly cause?: unknown | undefined;
12
+ constructor(cause?: unknown | undefined);
13
+ }
14
+ export interface SearchParams {
15
+ q: string;
16
+ kind?: string;
17
+ render?: string;
18
+ palette?: string;
19
+ limit?: number;
20
+ }
21
+ /**
22
+ * The transport seam. Every f5 command takes one of these, so the whole surface is
23
+ * testable against a recording stub with no socket — and so the deny fixture in
24
+ * QA-F5-C3 has exactly one place to observe.
25
+ */
26
+ export interface LibraryClient {
27
+ search(params: SearchParams): Promise<SearchResponse>;
28
+ fetchOne(id: string): Promise<AssetRecord | undefined>;
29
+ batch(ids: string[]): Promise<BatchResponse>;
30
+ /** Download asset BYTES from a URL the SERVER returned. Only `--download` calls this. */
31
+ download(url: string): Promise<Uint8Array>;
32
+ }
33
+ /**
34
+ * The real HTTP client, PAT-authed like every other machine call.
35
+ *
36
+ * A thrown TypeError from `fetch` is a transport failure (DNS, refused connection,
37
+ * a deny fixture) and becomes LibraryUnreachableError → exit 4. An HTTP status is
38
+ * an ANSWER and becomes LibraryError, which the commands map by code. Collapsing
39
+ * the two would merge "we did not answer" into "we answered no", and the agent's
40
+ * correct recovery differs between them (retry later vs re-search).
41
+ */
42
+ export declare function httpLibraryClient(cfg: ResolvedConfig): LibraryClient;
@@ -0,0 +1,137 @@
1
+ // The f5 library API client — THE module B36 layer 1 pins.
2
+ //
3
+ // B36 asserts that `create` / `build` / `preview` / `publish` make ZERO requests to
4
+ // any f5 route (implementation 2.7). The mechanical form of that assertion is an
5
+ // import check: this module must be imported by exactly the assets/library command
6
+ // modules and their tests. A hit in any other command module fails the check, which
7
+ // is why every f5 network call is funnelled through here rather than spread across
8
+ // the verbs.
9
+ //
10
+ // It builds request PATHS from ids and filters. It never accepts a URL from a
11
+ // caller: there is no `--url` flag anywhere in the CLI and no code path where a
12
+ // CLI-supplied URL reaches a fetch. That is a security control, not a style choice
13
+ // (D-add-prints-urls degraded the no-invented-URLs rule from prevention to
14
+ // detection, and this is the one arm that stayed prevention).
15
+ import { parseBatchResponse, parseFetchResponse, parseSearchResponse, } from "./wire.js";
16
+ /** The API said no, with a code we can act on. */
17
+ export class LibraryError extends Error {
18
+ status;
19
+ code;
20
+ constructor(message, status, code) {
21
+ super(message);
22
+ this.status = status;
23
+ this.code = code;
24
+ this.name = "LibraryError";
25
+ }
26
+ }
27
+ /** We could not reach the API at all — a transport failure, not an answer. */
28
+ export class LibraryUnreachableError extends Error {
29
+ cause;
30
+ constructor(cause) {
31
+ super("Can't reach the asset library.");
32
+ this.cause = cause;
33
+ this.name = "LibraryUnreachableError";
34
+ }
35
+ }
36
+ async function errorCode(res) {
37
+ try {
38
+ const body = (await res.clone().json());
39
+ return typeof body?.code === "string" ? body.code : undefined;
40
+ }
41
+ catch {
42
+ return undefined;
43
+ }
44
+ }
45
+ async function readJson(res) {
46
+ try {
47
+ return await res.json();
48
+ }
49
+ catch {
50
+ return {};
51
+ }
52
+ }
53
+ /**
54
+ * The real HTTP client, PAT-authed like every other machine call.
55
+ *
56
+ * A thrown TypeError from `fetch` is a transport failure (DNS, refused connection,
57
+ * a deny fixture) and becomes LibraryUnreachableError → exit 4. An HTTP status is
58
+ * an ANSWER and becomes LibraryError, which the commands map by code. Collapsing
59
+ * the two would merge "we did not answer" into "we answered no", and the agent's
60
+ * correct recovery differs between them (retry later vs re-search).
61
+ */
62
+ export function httpLibraryClient(cfg) {
63
+ const base = cfg.apiUrl.replace(/\/$/, "");
64
+ const auth = { authorization: `Bearer ${cfg.pat}` };
65
+ const call = async (pathname, init) => {
66
+ try {
67
+ return await fetch(`${base}${pathname}`, {
68
+ ...init,
69
+ headers: { ...(init?.headers ?? {}), ...auth },
70
+ });
71
+ }
72
+ catch (err) {
73
+ throw new LibraryUnreachableError(err);
74
+ }
75
+ };
76
+ const fail = async (res, what) => {
77
+ throw new LibraryError(`${what} failed (${res.status})`, res.status, await errorCode(res));
78
+ };
79
+ return {
80
+ async search(params) {
81
+ const qs = new URLSearchParams({ q: params.q });
82
+ if (params.kind !== undefined)
83
+ qs.set("kind", params.kind);
84
+ if (params.render !== undefined)
85
+ qs.set("render", params.render);
86
+ if (params.palette !== undefined)
87
+ qs.set("palette", params.palette);
88
+ if (params.limit !== undefined)
89
+ qs.set("limit", String(params.limit));
90
+ // No `cursor`, no `page`. Cut from v1 (D-nextCursor-is-cut-from-v1): a
91
+ // revision-scoped cursor is only valid on the box and the moment that issued
92
+ // it. A caller wanting more re-queries with a larger --limit.
93
+ const res = await call(`/library/assets?${qs.toString()}`);
94
+ if (!res.ok)
95
+ await fail(res, "library search");
96
+ return parseSearchResponse(await readJson(res));
97
+ },
98
+ async fetchOne(id) {
99
+ const res = await call(`/library/assets/${encodeURIComponent(id)}`);
100
+ if (res.status === 404)
101
+ return undefined;
102
+ if (!res.ok)
103
+ await fail(res, "library fetch");
104
+ return parseFetchResponse(await readJson(res));
105
+ },
106
+ async batch(ids) {
107
+ const res = await call(`/library/assets:batch`, {
108
+ method: "POST",
109
+ headers: { "content-type": "application/json" },
110
+ body: JSON.stringify({ ids }),
111
+ });
112
+ if (!res.ok)
113
+ await fail(res, "library batch");
114
+ return parseBatchResponse(await readJson(res));
115
+ },
116
+ async download(url) {
117
+ // The url argument is ALWAYS a server-returned record.url, never anything a
118
+ // caller typed: `add` resolves ids first and downloads only what came back.
119
+ // parseAssetRecord already drops any record whose url is not https, so this
120
+ // is the second assertion of the same invariant, at the point of the fetch.
121
+ if (!url.startsWith("https://")) {
122
+ throw new LibraryError(`refusing to download a non-https url`, 0);
123
+ }
124
+ let res;
125
+ try {
126
+ res = await fetch(url);
127
+ }
128
+ catch (err) {
129
+ throw new LibraryUnreachableError(err);
130
+ }
131
+ if (!res.ok) {
132
+ throw new LibraryError(`download failed (${res.status})`, res.status);
133
+ }
134
+ return new Uint8Array(await res.arrayBuffer());
135
+ },
136
+ };
137
+ }
@@ -0,0 +1,46 @@
1
+ import type { AssetRecord } from "./wire.js";
2
+ /** Where downloads land, relative to the project root. */
3
+ export declare const DOWNLOAD_DIR: string;
4
+ /**
5
+ * Slugify a subject for use in a filename.
6
+ *
7
+ * Everything outside [a-z0-9-] is dropped rather than escaped, which removes `/`,
8
+ * `\`, `.` and `%` as a class instead of enumerating them. A subject that reduces
9
+ * to nothing yields "asset", so the filename never starts with the separator or
10
+ * collapses into the extension.
11
+ */
12
+ export declare function safeSubject(subject: string | undefined): string;
13
+ /** The extension, from `format` — admitted only as png/webp, else png. */
14
+ export declare function safeExt(format: string | undefined): string;
15
+ /**
16
+ * `<subject>-<id>.<ext>`, e.g. `pumpkin-lib_7f3a2c67d4e5b6a7.png`.
17
+ *
18
+ * Throws when `id` is not id-shaped. The id is the one component that could carry
19
+ * a separator if a compromised or drifted API returned something unexpected, and a
20
+ * throw here is strictly better than composing a path from it: the id has ALREADY
21
+ * been validated against the same regex on the way in, so a failure means the wire
22
+ * disagrees with the contract and writing anything at that point is guesswork.
23
+ */
24
+ export declare function downloadFilename(a: AssetRecord): string;
25
+ /**
26
+ * The absolute path a record downloads to, under `dir`.
27
+ *
28
+ * The join is re-checked against the resolved base: even with a validated filename,
29
+ * asserting containment is one line and it is the assertion that stays true if
30
+ * someone later relaxes downloadFilename.
31
+ */
32
+ export declare function downloadPath(baseDir: string, a: AssetRecord): string;
33
+ export interface WriteResult {
34
+ path: string;
35
+ bytes: number;
36
+ }
37
+ /** Does the file already exist? (Used for the refuse-to-overwrite gate.) */
38
+ export declare function exists(p: string): Promise<boolean>;
39
+ export declare function writeAsset(p: string, bytes: Uint8Array): Promise<WriteResult>;
40
+ /**
41
+ * The overwrite refusal names the INTERESTING reason, not "file exists": B37 means
42
+ * the bytes at a library URL never change, so the copy on disk is already identical
43
+ * and re-downloading achieves nothing. That teaches immutability in passing and is
44
+ * more useful than a bare flag suggestion.
45
+ */
46
+ export declare function overwriteRefusalLines(relPath: string): string[];