@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,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[];
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// `--download` — the NON-DEFAULT opt-in. `add` prints URLs and writes nothing by
|
|
2
|
+
// default (D-add-prints-urls); this path exists for offline demos and for a creator
|
|
3
|
+
// who wants the byte in their repo to edit.
|
|
4
|
+
//
|
|
5
|
+
// The filename is composed from `subject` + `id`, both of which the corpus schema
|
|
6
|
+
// validated server-side, and NEVER from a wire string like `url` or `key`. That is
|
|
7
|
+
// what makes path traversal on the derived filename impossible rather than merely
|
|
8
|
+
// unlikely — but the composition is defended here too, because "the server
|
|
9
|
+
// validated it" is a property of a system the CLI is versioned separately from.
|
|
10
|
+
import { promises as fs } from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { isLibraryId } from "./wire.js";
|
|
13
|
+
/** Where downloads land, relative to the project root. */
|
|
14
|
+
export const DOWNLOAD_DIR = path.join("public", "assets", "library");
|
|
15
|
+
/**
|
|
16
|
+
* Slugify a subject for use in a filename.
|
|
17
|
+
*
|
|
18
|
+
* Everything outside [a-z0-9-] is dropped rather than escaped, which removes `/`,
|
|
19
|
+
* `\`, `.` and `%` as a class instead of enumerating them. A subject that reduces
|
|
20
|
+
* to nothing yields "asset", so the filename never starts with the separator or
|
|
21
|
+
* collapses into the extension.
|
|
22
|
+
*/
|
|
23
|
+
export function safeSubject(subject) {
|
|
24
|
+
const s = (subject ?? "")
|
|
25
|
+
.toLowerCase()
|
|
26
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
27
|
+
.replace(/^-+|-+$/g, "")
|
|
28
|
+
.slice(0, 40)
|
|
29
|
+
.replace(/-+$/g, "");
|
|
30
|
+
return s.length > 0 ? s : "asset";
|
|
31
|
+
}
|
|
32
|
+
/** The extension, from `format` — admitted only as png/webp, else png. */
|
|
33
|
+
export function safeExt(format) {
|
|
34
|
+
return format === "webp" ? "webp" : "png";
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* `<subject>-<id>.<ext>`, e.g. `pumpkin-lib_7f3a2c67d4e5b6a7.png`.
|
|
38
|
+
*
|
|
39
|
+
* Throws when `id` is not id-shaped. The id is the one component that could carry
|
|
40
|
+
* a separator if a compromised or drifted API returned something unexpected, and a
|
|
41
|
+
* throw here is strictly better than composing a path from it: the id has ALREADY
|
|
42
|
+
* been validated against the same regex on the way in, so a failure means the wire
|
|
43
|
+
* disagrees with the contract and writing anything at that point is guesswork.
|
|
44
|
+
*/
|
|
45
|
+
export function downloadFilename(a) {
|
|
46
|
+
if (!isLibraryId(a.id)) {
|
|
47
|
+
throw new Error(`refusing to derive a filename from a non-id: ${a.id}`);
|
|
48
|
+
}
|
|
49
|
+
return `${safeSubject(a.subject)}-${a.id}.${safeExt(a.format)}`;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The absolute path a record downloads to, under `dir`.
|
|
53
|
+
*
|
|
54
|
+
* The join is re-checked against the resolved base: even with a validated filename,
|
|
55
|
+
* asserting containment is one line and it is the assertion that stays true if
|
|
56
|
+
* someone later relaxes downloadFilename.
|
|
57
|
+
*/
|
|
58
|
+
export function downloadPath(baseDir, a) {
|
|
59
|
+
const base = path.resolve(baseDir);
|
|
60
|
+
const full = path.resolve(base, downloadFilename(a));
|
|
61
|
+
if (full !== path.join(base, path.basename(full))) {
|
|
62
|
+
throw new Error(`refusing to write outside ${base}`);
|
|
63
|
+
}
|
|
64
|
+
return full;
|
|
65
|
+
}
|
|
66
|
+
/** Does the file already exist? (Used for the refuse-to-overwrite gate.) */
|
|
67
|
+
export async function exists(p) {
|
|
68
|
+
try {
|
|
69
|
+
await fs.stat(p);
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
export async function writeAsset(p, bytes) {
|
|
77
|
+
await fs.mkdir(path.dirname(p), { recursive: true });
|
|
78
|
+
await fs.writeFile(p, bytes);
|
|
79
|
+
return { path: p, bytes: bytes.byteLength };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The overwrite refusal names the INTERESTING reason, not "file exists": B37 means
|
|
83
|
+
* the bytes at a library URL never change, so the copy on disk is already identical
|
|
84
|
+
* and re-downloading achieves nothing. That teaches immutability in passing and is
|
|
85
|
+
* more useful than a bare flag suggestion.
|
|
86
|
+
*/
|
|
87
|
+
export function overwriteRefusalLines(relPath) {
|
|
88
|
+
return [
|
|
89
|
+
"",
|
|
90
|
+
`✗ ${relPath} already exists.`,
|
|
91
|
+
"",
|
|
92
|
+
" The library never changes a file at a URL, so the copy you have is the",
|
|
93
|
+
" same bytes. Pass --force to overwrite it anyway.",
|
|
94
|
+
"",
|
|
95
|
+
" Nothing was written.",
|
|
96
|
+
"",
|
|
97
|
+
];
|
|
98
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** An argument that is unmistakably a URL rather than an id. */
|
|
2
|
+
export declare function looksLikeUrl(arg: string): boolean;
|
|
3
|
+
export interface IdArgsOk {
|
|
4
|
+
ok: true;
|
|
5
|
+
/** The ids AS GIVEN, duplicates intact — the server charges pre-dedup. */
|
|
6
|
+
ids: string[];
|
|
7
|
+
}
|
|
8
|
+
export interface IdArgsError {
|
|
9
|
+
ok: false;
|
|
10
|
+
lines: string[];
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Validate the positional ids for `show` / `add`.
|
|
14
|
+
*
|
|
15
|
+
* A URL is a USAGE error (exit 1, zero network calls). A merely malformed id
|
|
16
|
+
* (`pastel-pumpkin.png`) is NOT: it goes to the server and comes back in
|
|
17
|
+
* `unknown[]`, so the other ids in the same call still resolve. Those two cases
|
|
18
|
+
* look similar and are deliberately different — one is a shape the CLI must never
|
|
19
|
+
* transmit, the other is a lookup that must not destroy its siblings.
|
|
20
|
+
*/
|
|
21
|
+
export declare function parseIdArgs(args: string[]): IdArgsOk | IdArgsError;
|
|
22
|
+
/** De-duplicate for DISPLAY, preserving first-occurrence order. */
|
|
23
|
+
export declare function dedupeForDisplay<T>(items: T[], key: (t: T) => string): T[];
|
|
24
|
+
export interface SearchFlags {
|
|
25
|
+
kind?: string;
|
|
26
|
+
render?: string;
|
|
27
|
+
palette?: string;
|
|
28
|
+
limit?: number;
|
|
29
|
+
}
|
|
30
|
+
export type SearchFlagResult = {
|
|
31
|
+
ok: true;
|
|
32
|
+
flags: SearchFlags;
|
|
33
|
+
} | {
|
|
34
|
+
ok: false;
|
|
35
|
+
lines: string[];
|
|
36
|
+
};
|
|
37
|
+
/** `--limit` is clamped by the SERVER at 50; the CLI only rejects nonsense. */
|
|
38
|
+
export declare function parseSearchFlags(raw: {
|
|
39
|
+
kind?: string;
|
|
40
|
+
render?: string;
|
|
41
|
+
palette?: string;
|
|
42
|
+
limit?: string;
|
|
43
|
+
}): SearchFlagResult;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Argument parsing for the `assets` verb group.
|
|
2
|
+
//
|
|
3
|
+
// THE CONTROL LIVES HERE. Every f5 verb accepts ids and only ids: there is no
|
|
4
|
+
// `--url` flag anywhere in the CLI, and an argument that looks like a URL is
|
|
5
|
+
// refused by the PARSER, before any network call is made. Under
|
|
6
|
+
// D-add-prints-urls the agent handles URLs as strings by design, so a parser that
|
|
7
|
+
// refuses one is the only thing standing between a hallucinated URL and a fetch.
|
|
8
|
+
// The AGENTS.md prose is advice; this function is the control.
|
|
9
|
+
/** An argument that is unmistakably a URL rather than an id. */
|
|
10
|
+
export function looksLikeUrl(arg) {
|
|
11
|
+
// Scheme-ful and scheme-relative forms both count. This is deliberately broad:
|
|
12
|
+
// a false positive costs one clear error message, while a false negative puts a
|
|
13
|
+
// caller-supplied string on the path to a fetch.
|
|
14
|
+
return /^[a-z][a-z0-9+.-]*:\/\//i.test(arg) || arg.startsWith("//");
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Validate the positional ids for `show` / `add`.
|
|
18
|
+
*
|
|
19
|
+
* A URL is a USAGE error (exit 1, zero network calls). A merely malformed id
|
|
20
|
+
* (`pastel-pumpkin.png`) is NOT: it goes to the server and comes back in
|
|
21
|
+
* `unknown[]`, so the other ids in the same call still resolve. Those two cases
|
|
22
|
+
* look similar and are deliberately different — one is a shape the CLI must never
|
|
23
|
+
* transmit, the other is a lookup that must not destroy its siblings.
|
|
24
|
+
*/
|
|
25
|
+
export function parseIdArgs(args) {
|
|
26
|
+
if (args.length === 0) {
|
|
27
|
+
return {
|
|
28
|
+
ok: false,
|
|
29
|
+
lines: [
|
|
30
|
+
"",
|
|
31
|
+
"✗ No asset ids given.",
|
|
32
|
+
"",
|
|
33
|
+
" Ids look like lib_7f3a2c67d4e5b6a7. Search returns them; nothing else does.",
|
|
34
|
+
"",
|
|
35
|
+
' Find one: vincentt assets search "<what you want>"',
|
|
36
|
+
"",
|
|
37
|
+
],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
const urls = args.filter(looksLikeUrl);
|
|
41
|
+
if (urls.length > 0) {
|
|
42
|
+
return {
|
|
43
|
+
ok: false,
|
|
44
|
+
lines: [
|
|
45
|
+
"",
|
|
46
|
+
"✗ This command takes asset ids, not URLs.",
|
|
47
|
+
"",
|
|
48
|
+
` ${urls[0]}`,
|
|
49
|
+
"",
|
|
50
|
+
" Ids look like lib_7f3a2c67d4e5b6a7. Search returns them; nothing else does.",
|
|
51
|
+
"",
|
|
52
|
+
' Find one: vincentt assets search "<what you want>"',
|
|
53
|
+
"",
|
|
54
|
+
],
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
// Duplicates are NOT trimmed here. The CLI de-duplicates for DISPLAY only,
|
|
58
|
+
// because the server charges pre-dedup and pre-trimming would hide that cost
|
|
59
|
+
// from the operator.
|
|
60
|
+
return { ok: true, ids: args };
|
|
61
|
+
}
|
|
62
|
+
/** De-duplicate for DISPLAY, preserving first-occurrence order. */
|
|
63
|
+
export function dedupeForDisplay(items, key) {
|
|
64
|
+
const seen = new Set();
|
|
65
|
+
const out = [];
|
|
66
|
+
for (const item of items) {
|
|
67
|
+
const k = key(item);
|
|
68
|
+
if (seen.has(k))
|
|
69
|
+
continue;
|
|
70
|
+
seen.add(k);
|
|
71
|
+
out.push(item);
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
/** `--limit` is clamped by the SERVER at 50; the CLI only rejects nonsense. */
|
|
76
|
+
export function parseSearchFlags(raw) {
|
|
77
|
+
const flags = {};
|
|
78
|
+
if (raw.kind !== undefined) {
|
|
79
|
+
if (raw.kind !== "prop" && raw.kind !== "frame") {
|
|
80
|
+
return {
|
|
81
|
+
ok: false,
|
|
82
|
+
lines: [
|
|
83
|
+
"",
|
|
84
|
+
`✗ --kind takes prop or frame, not "${raw.kind}".`,
|
|
85
|
+
"",
|
|
86
|
+
" --kind prop objects to place in a scene",
|
|
87
|
+
" --kind frame full-screen camera overlays",
|
|
88
|
+
"",
|
|
89
|
+
],
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
flags.kind = raw.kind;
|
|
93
|
+
}
|
|
94
|
+
if (raw.render !== undefined)
|
|
95
|
+
flags.render = raw.render;
|
|
96
|
+
if (raw.palette !== undefined)
|
|
97
|
+
flags.palette = raw.palette;
|
|
98
|
+
if (raw.limit !== undefined) {
|
|
99
|
+
const n = Number(raw.limit);
|
|
100
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
101
|
+
return {
|
|
102
|
+
ok: false,
|
|
103
|
+
lines: ["", `✗ --limit takes a whole number, not "${raw.limit}".`, ""],
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
flags.limit = n;
|
|
107
|
+
}
|
|
108
|
+
return { ok: true, flags };
|
|
109
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|