@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,167 @@
|
|
|
1
|
+
// The f5 asset-library wire shapes, and the ONE parser every renderer reads through.
|
|
2
|
+
//
|
|
3
|
+
// The envelope is camelCase (resultCount, gaps, resolved, unknown) while the asset
|
|
4
|
+
// RECORD is snake_case (has_alpha, usage_hint, text_in_image, occupies_canvas).
|
|
5
|
+
// That mix is deliberate and it is the API's only mixed-convention response
|
|
6
|
+
// (implementation 2.12): the record's vocabulary sits beside English prose in an
|
|
7
|
+
// agent's context, so it reads as prose-adjacent rather than as code identifiers.
|
|
8
|
+
// NEITHER SIDE IS NORMALIZED HERE. Rewriting the record to camelCase would silently
|
|
9
|
+
// break every agent prompt in the wild that reads `usage_hint`.
|
|
10
|
+
//
|
|
11
|
+
// EVERY field is read defensively, for the reason lib/diagnostics.ts records: the
|
|
12
|
+
// CLI and the API are versioned separately on a creator's machine — `npx vincentt`
|
|
13
|
+
// fetches whatever the registry has and points it at whatever API the config
|
|
14
|
+
// resolves to. A field a renderer reads unguarded is a field whose removal from the
|
|
15
|
+
// API crashes the command, which is exactly what removing `sessions` did once.
|
|
16
|
+
// A missing optional field must degrade to a missing LINE, never to an exception.
|
|
17
|
+
/**
|
|
18
|
+
* The asset id shape. Base32-lower, matching the server's `^lib_[a-z2-7]{16}$`
|
|
19
|
+
* exactly.
|
|
20
|
+
*
|
|
21
|
+
* The alphabet is the house convention across this API (f3 sessions {32}, f4
|
|
22
|
+
* uploads {26}, preview host labels, publish keys) and it excludes 0/1/8/9 so an
|
|
23
|
+
* id read off a terminal carries no visually ambiguous characters. 16 chars is
|
|
24
|
+
* 80 bits, which is what makes the key pattern unguessable, one of the four
|
|
25
|
+
* counters to an agent inventing a URL.
|
|
26
|
+
*
|
|
27
|
+
* The CLI briefly accepted hex as well, because every EXAMPLE id written into
|
|
28
|
+
* the design record was hex and contradicted the record's own regex. User ruling
|
|
29
|
+
* 2026-08-23: the regex is authoritative and the examples were wrong. Matching
|
|
30
|
+
* the server exactly is what keeps a client-side reject and a server-side
|
|
31
|
+
* `unknown[]` from ever disagreeing.
|
|
32
|
+
*/
|
|
33
|
+
export const LIB_ID_RE = /^lib_[a-z2-7]{16}$/;
|
|
34
|
+
export function isLibraryId(s) {
|
|
35
|
+
return LIB_ID_RE.test(s);
|
|
36
|
+
}
|
|
37
|
+
function obj(v) {
|
|
38
|
+
return typeof v === "object" && v !== null ? v : {};
|
|
39
|
+
}
|
|
40
|
+
function str(o, k) {
|
|
41
|
+
return typeof o[k] === "string" ? o[k] : undefined;
|
|
42
|
+
}
|
|
43
|
+
function num(o, k) {
|
|
44
|
+
return typeof o[k] === "number" && Number.isFinite(o[k]) ? o[k] : undefined;
|
|
45
|
+
}
|
|
46
|
+
function bool(o, k) {
|
|
47
|
+
return typeof o[k] === "boolean" ? o[k] : undefined;
|
|
48
|
+
}
|
|
49
|
+
function strArray(o, k) {
|
|
50
|
+
const v = o[k];
|
|
51
|
+
if (!Array.isArray(v))
|
|
52
|
+
return undefined;
|
|
53
|
+
return v.filter((x) => typeof x === "string");
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Parse one asset record.
|
|
57
|
+
*
|
|
58
|
+
* `kind` is admitted ONLY as "prop" or "frame". Anything else — including "" and
|
|
59
|
+
* "unknown" — parses to ABSENT rather than being passed through, because a record
|
|
60
|
+
* whose kind is the literal string "unknown" would render as a real classification
|
|
61
|
+
* on the line an agent uses to decide whether the asset is usable at all.
|
|
62
|
+
*
|
|
63
|
+
* Returns undefined when `id`/`url` are missing: a record with no URL is not a
|
|
64
|
+
* usable result, and dropping it is better than rendering an entry whose payload
|
|
65
|
+
* line is blank.
|
|
66
|
+
*/
|
|
67
|
+
export function parseAssetRecord(raw) {
|
|
68
|
+
const o = obj(raw);
|
|
69
|
+
const id = str(o, "id");
|
|
70
|
+
const url = str(o, "url");
|
|
71
|
+
if (id === undefined || url === undefined)
|
|
72
|
+
return undefined;
|
|
73
|
+
// The wire is an input too. A record whose url is not https is dropped rather
|
|
74
|
+
// than rendered, so a compromised or misconfigured API cannot point --download
|
|
75
|
+
// at file:// or a link-local address.
|
|
76
|
+
if (!url.startsWith("https://"))
|
|
77
|
+
return undefined;
|
|
78
|
+
const kindRaw = str(o, "kind");
|
|
79
|
+
const kind = kindRaw === "prop" || kindRaw === "frame" ? kindRaw : undefined;
|
|
80
|
+
// An empty string is the "not confidently read" case the API omits; treat a
|
|
81
|
+
// stray "" the same way rather than rendering an empty labelled line.
|
|
82
|
+
const textInImage = str(o, "text_in_image");
|
|
83
|
+
return {
|
|
84
|
+
id,
|
|
85
|
+
url,
|
|
86
|
+
...(kind !== undefined ? { kind } : {}),
|
|
87
|
+
...(num(o, "width") !== undefined ? { width: num(o, "width") } : {}),
|
|
88
|
+
...(num(o, "height") !== undefined ? { height: num(o, "height") } : {}),
|
|
89
|
+
...(str(o, "aspect") !== undefined ? { aspect: str(o, "aspect") } : {}),
|
|
90
|
+
...(bool(o, "has_alpha") !== undefined ? { has_alpha: bool(o, "has_alpha") } : {}),
|
|
91
|
+
...(num(o, "bytes") !== undefined ? { bytes: num(o, "bytes") } : {}),
|
|
92
|
+
...(str(o, "format") !== undefined ? { format: str(o, "format") } : {}),
|
|
93
|
+
...(str(o, "subject") !== undefined ? { subject: str(o, "subject") } : {}),
|
|
94
|
+
...(strArray(o, "style") !== undefined ? { style: strArray(o, "style") } : {}),
|
|
95
|
+
...(strArray(o, "palette") !== undefined ? { palette: strArray(o, "palette") } : {}),
|
|
96
|
+
...(str(o, "render") !== undefined ? { render: str(o, "render") } : {}),
|
|
97
|
+
...(str(o, "view") !== undefined ? { view: str(o, "view") } : {}),
|
|
98
|
+
...(str(o, "description") !== undefined
|
|
99
|
+
? { description: str(o, "description") }
|
|
100
|
+
: {}),
|
|
101
|
+
...(str(o, "usage_hint") !== undefined ? { usage_hint: str(o, "usage_hint") } : {}),
|
|
102
|
+
...(textInImage ? { text_in_image: textInImage } : {}),
|
|
103
|
+
...(num(o, "occupies_canvas") !== undefined
|
|
104
|
+
? { occupies_canvas: num(o, "occupies_canvas") }
|
|
105
|
+
: {}),
|
|
106
|
+
...(bool(o, "retired") !== undefined ? { retired: bool(o, "retired") } : {}),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
function parseGaps(raw) {
|
|
110
|
+
if (!Array.isArray(raw))
|
|
111
|
+
return [];
|
|
112
|
+
const out = [];
|
|
113
|
+
for (const g of raw) {
|
|
114
|
+
const o = obj(g);
|
|
115
|
+
const tag = str(o, "tag");
|
|
116
|
+
const message = str(o, "message");
|
|
117
|
+
// Both fields are load-bearing and carry different jobs: the agent BRANCHES on
|
|
118
|
+
// `tag` and QUOTES `message`. A half-formed gap is dropped rather than rendered
|
|
119
|
+
// with an invented half.
|
|
120
|
+
if (tag !== undefined && message !== undefined)
|
|
121
|
+
out.push({ tag, message });
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Parse the search envelope.
|
|
127
|
+
*
|
|
128
|
+
* `resultCount` is taken from the wire when present and otherwise derived from the
|
|
129
|
+
* array, so a truncated envelope still renders a truthful count rather than 0 —
|
|
130
|
+
* and 0 is the one value that must never be invented, because zero results is a
|
|
131
|
+
* meaningful product answer (the honest-empty path) rather than an absence.
|
|
132
|
+
*/
|
|
133
|
+
export function parseSearchResponse(raw) {
|
|
134
|
+
const o = obj(raw);
|
|
135
|
+
const results = Array.isArray(o.results)
|
|
136
|
+
? o.results.map(parseAssetRecord).filter((r) => r !== undefined)
|
|
137
|
+
: [];
|
|
138
|
+
return {
|
|
139
|
+
...(str(o, "query") !== undefined ? { query: str(o, "query") } : {}),
|
|
140
|
+
...(str(o, "revision") !== undefined ? { revision: str(o, "revision") } : {}),
|
|
141
|
+
resultCount: num(o, "resultCount") ?? results.length,
|
|
142
|
+
results,
|
|
143
|
+
gaps: parseGaps(o.gaps),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Parse the batch envelope. `unknown[]` entries are echoed VERBATIM by the server
|
|
148
|
+
* and are kept as raw strings — they are the caller's own input coming back, which
|
|
149
|
+
* is what lets an agent diff against what it sent without parsing our prose.
|
|
150
|
+
*/
|
|
151
|
+
export function parseBatchResponse(raw) {
|
|
152
|
+
const o = obj(raw);
|
|
153
|
+
const resolved = Array.isArray(o.resolved)
|
|
154
|
+
? o.resolved.map(parseAssetRecord).filter((r) => r !== undefined)
|
|
155
|
+
: [];
|
|
156
|
+
const unknown = Array.isArray(o.unknown)
|
|
157
|
+
? o.unknown.filter((x) => typeof x === "string")
|
|
158
|
+
: [];
|
|
159
|
+
return { resolved, unknown };
|
|
160
|
+
}
|
|
161
|
+
/** A single record from `GET /library/assets/{id}` — the same record shape, unwrapped. */
|
|
162
|
+
export function parseFetchResponse(raw) {
|
|
163
|
+
const o = obj(raw);
|
|
164
|
+
// Tolerate both a bare record and an { asset: … } envelope: /me already taught
|
|
165
|
+
// this codebase that an envelope can appear on a route that once returned bare.
|
|
166
|
+
return parseAssetRecord(o.asset !== undefined ? o.asset : o);
|
|
167
|
+
}
|
package/dist/publish/prompt.js
CHANGED
|
@@ -7,10 +7,25 @@ import readline from "node:readline";
|
|
|
7
7
|
/** Reads one line from stdin. Anything that is not an explicit answer takes the default. */
|
|
8
8
|
export function stdinConfirm(input = process.stdin, output = process.stdout) {
|
|
9
9
|
return async (prompt, defaultYes) => {
|
|
10
|
+
// A pipe cannot consent. Readline answers a piped line as readily as a typed
|
|
11
|
+
// one, so `echo y | vincentt publish` and `echo y | vincentt dx --send` both
|
|
12
|
+
// read as a person saying yes. A confirmation exists to put a human in the
|
|
13
|
+
// loop; when there is no terminal there is no human to put there, and the
|
|
14
|
+
// safe answer is no — never `defaultYes`, which on a [Y/n] prompt would turn
|
|
15
|
+
// an absent human into an approving one.
|
|
16
|
+
if (!input.isTTY)
|
|
17
|
+
return false;
|
|
10
18
|
const rl = readline.createInterface({ input, output });
|
|
11
19
|
try {
|
|
12
|
-
|
|
13
|
-
|
|
20
|
+
// `close` without an answer must resolve, not hang: on EOF readline never
|
|
21
|
+
// invokes the question callback, so awaiting it alone abandons the promise
|
|
22
|
+
// and every statement after the await — including the caller's "not sent"
|
|
23
|
+
// path — silently never runs.
|
|
24
|
+
const answer = await new Promise((resolve) => {
|
|
25
|
+
rl.question(prompt, resolve);
|
|
26
|
+
rl.once("close", () => resolve(undefined));
|
|
27
|
+
});
|
|
28
|
+
return answer === undefined ? false : interpretAnswer(answer, defaultYes);
|
|
14
29
|
}
|
|
15
30
|
finally {
|
|
16
31
|
rl.close();
|
package/dist/shared/config.d.ts
CHANGED
|
@@ -7,6 +7,16 @@ export interface MachineConfig {
|
|
|
7
7
|
pat: string;
|
|
8
8
|
/** Org slug commands act in when no --org flag or VINCENTT_ORG env is given. */
|
|
9
9
|
activeOrg: string;
|
|
10
|
+
/**
|
|
11
|
+
* Durable authorization for unattended `dx --send`. Written only by
|
|
12
|
+
* `vincentt dx enable`, which requires a TTY. Cleared by `disable` and by `logout`.
|
|
13
|
+
*/
|
|
14
|
+
dxSendEnabled?: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* ISO8601 UTC, when enablement was granted. Its AGE is the mitigation for having
|
|
17
|
+
* no expiry, so an unparseable value fails closed rather than printing.
|
|
18
|
+
*/
|
|
19
|
+
dxSendEnabledAt?: string;
|
|
10
20
|
}
|
|
11
21
|
export interface ProjectBinding {
|
|
12
22
|
/** The backend Project id this working tree publishes to. */
|
|
@@ -26,9 +36,26 @@ export declare function loadMachineConfig(): Promise<Partial<MachineConfig>>;
|
|
|
26
36
|
* every write, even when the file pre-existed with looser perms).
|
|
27
37
|
*/
|
|
28
38
|
export declare function writeMachineConfig(patch: Partial<MachineConfig>): Promise<string>;
|
|
39
|
+
/**
|
|
40
|
+
* Set or clear unattended-send enablement (`vincentt dx enable` / `disable`).
|
|
41
|
+
*
|
|
42
|
+
* A dedicated function rather than a `writeMachineConfig` patch because clearing
|
|
43
|
+
* has to DELETE the keys. A patch of `{dxSendEnabled: undefined}` happens to work
|
|
44
|
+
* — JSON.stringify drops undefined values — but only by a coincidence of the
|
|
45
|
+
* serializer, and the read of a key that survived would be the difference between
|
|
46
|
+
* off and on.
|
|
47
|
+
*/
|
|
48
|
+
export declare function writeDxEnablement(next: {
|
|
49
|
+
at: string;
|
|
50
|
+
} | undefined): Promise<string>;
|
|
29
51
|
/**
|
|
30
52
|
* Remove the stored PAT from ~/.vincentt/config.json (used by `vincentt logout`),
|
|
31
53
|
* preserving apiUrl and any other fields. Returns whether a token was present.
|
|
54
|
+
*
|
|
55
|
+
* Unattended-send enablement goes with the PAT. This function preserves every
|
|
56
|
+
* other field by design, which would leave a disconnected machine still armed to
|
|
57
|
+
* transmit for whoever signs in next — a creator who disconnects a machine has
|
|
58
|
+
* disconnected it.
|
|
32
59
|
*/
|
|
33
60
|
export declare function clearMachineToken(): Promise<{
|
|
34
61
|
path: string;
|
package/dist/shared/config.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// Env vars (VINCENTT_API_URL / VINCENTT_PAT) override the file for local/dogfood
|
|
9
9
|
// runs so a creator can point at a dev backend without editing the machine config.
|
|
10
10
|
// The binding's optional apiUrl does NOT — see decideApiUrl.
|
|
11
|
+
import { randomBytes } from "node:crypto";
|
|
11
12
|
import { promises as fs } from "node:fs";
|
|
12
13
|
import os from "node:os";
|
|
13
14
|
import path from "node:path";
|
|
@@ -31,18 +32,44 @@ export async function loadMachineConfig() {
|
|
|
31
32
|
return (await readJsonIfExists(MACHINE_CONFIG_PATH)) ?? {};
|
|
32
33
|
}
|
|
33
34
|
/**
|
|
34
|
-
* Persist the machine config with owner-only perms
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
35
|
+
* Persist the machine config with owner-only perms, ATOMICALLY: write a sibling
|
|
36
|
+
* temp file at 0600, then rename it over the target. Rename is atomic on the same
|
|
37
|
+
* filesystem, so there is no window in which config.json is truncated.
|
|
38
|
+
*
|
|
39
|
+
* Atomicity is not incidental cleanup. `dx enable` is a third writer to this file
|
|
40
|
+
* and, unlike `login`/`logout`, one an AGENT can trigger — in a session where
|
|
41
|
+
* `login` may also be running, against the file holding the bearer credential. A
|
|
42
|
+
* `writeFile` interrupted mid-write leaves a truncated config with the PAT gone,
|
|
43
|
+
* and it presents as an unexplained logout or as every later command throwing a
|
|
44
|
+
* SyntaxError, neither of which points at the write that caused it.
|
|
45
|
+
*
|
|
46
|
+
* `writeFile`'s `mode` only applies when it CREATES the file, so the temp file is
|
|
47
|
+
* chmod'ed explicitly: a lingering 0644 temp file would be a second, world-readable
|
|
48
|
+
* copy of the PAT. The temp file is removed if the rename fails, so a failed write
|
|
49
|
+
* leaves neither a truncated config nor a stray credential copy.
|
|
50
|
+
*
|
|
51
|
+
* The temp name is unique per call. A fixed `config.json.tmp` is safe for one
|
|
52
|
+
* writer and destructive for two: concurrent `login` and `dx enable` would write
|
|
53
|
+
* the same path, so whichever renamed second would fail ENOENT with its write
|
|
54
|
+
* silently lost — and a failing writer's cleanup would delete the other's
|
|
55
|
+
* in-flight file. Uniqueness also makes the `catch` safe by construction, since
|
|
56
|
+
* a writer can then only ever remove its own temp file.
|
|
38
57
|
*/
|
|
39
58
|
async function persistMachineConfig(next) {
|
|
40
59
|
await fs.mkdir(path.dirname(MACHINE_CONFIG_PATH), { recursive: true, mode: 0o700 });
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
60
|
+
const tmp = `${MACHINE_CONFIG_PATH}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
|
61
|
+
try {
|
|
62
|
+
await fs.writeFile(tmp, JSON.stringify(next, null, 2) + "\n", {
|
|
63
|
+
encoding: "utf8",
|
|
64
|
+
mode: 0o600,
|
|
65
|
+
});
|
|
66
|
+
await fs.chmod(tmp, 0o600);
|
|
67
|
+
await fs.rename(tmp, MACHINE_CONFIG_PATH);
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
await fs.rm(tmp, { force: true }).catch(() => { });
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
46
73
|
return MACHINE_CONFIG_PATH;
|
|
47
74
|
}
|
|
48
75
|
/**
|
|
@@ -55,14 +82,42 @@ export async function writeMachineConfig(patch) {
|
|
|
55
82
|
const next = { ...(await loadMachineConfig()), ...patch };
|
|
56
83
|
return persistMachineConfig(next);
|
|
57
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Set or clear unattended-send enablement (`vincentt dx enable` / `disable`).
|
|
87
|
+
*
|
|
88
|
+
* A dedicated function rather than a `writeMachineConfig` patch because clearing
|
|
89
|
+
* has to DELETE the keys. A patch of `{dxSendEnabled: undefined}` happens to work
|
|
90
|
+
* — JSON.stringify drops undefined values — but only by a coincidence of the
|
|
91
|
+
* serializer, and the read of a key that survived would be the difference between
|
|
92
|
+
* off and on.
|
|
93
|
+
*/
|
|
94
|
+
export async function writeDxEnablement(next) {
|
|
95
|
+
const cfg = await loadMachineConfig();
|
|
96
|
+
if (next === undefined) {
|
|
97
|
+
delete cfg.dxSendEnabled;
|
|
98
|
+
delete cfg.dxSendEnabledAt;
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
cfg.dxSendEnabled = true;
|
|
102
|
+
cfg.dxSendEnabledAt = next.at;
|
|
103
|
+
}
|
|
104
|
+
return persistMachineConfig(cfg);
|
|
105
|
+
}
|
|
58
106
|
/**
|
|
59
107
|
* Remove the stored PAT from ~/.vincentt/config.json (used by `vincentt logout`),
|
|
60
108
|
* preserving apiUrl and any other fields. Returns whether a token was present.
|
|
109
|
+
*
|
|
110
|
+
* Unattended-send enablement goes with the PAT. This function preserves every
|
|
111
|
+
* other field by design, which would leave a disconnected machine still armed to
|
|
112
|
+
* transmit for whoever signs in next — a creator who disconnects a machine has
|
|
113
|
+
* disconnected it.
|
|
61
114
|
*/
|
|
62
115
|
export async function clearMachineToken() {
|
|
63
116
|
const current = await loadMachineConfig();
|
|
64
117
|
const hadToken = Boolean(current.pat);
|
|
65
118
|
delete current.pat;
|
|
119
|
+
delete current.dxSendEnabled;
|
|
120
|
+
delete current.dxSendEnabledAt;
|
|
66
121
|
await persistMachineConfig(current);
|
|
67
122
|
return { path: MACHINE_CONFIG_PATH, hadToken };
|
|
68
123
|
}
|
package/package.json
CHANGED