@zocomputer/system-api 0.0.0-stage → 0.1.1
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/README.md +51 -2
- package/dist/catalog/index.d.ts +106 -0
- package/dist/catalog/index.js +237 -0
- package/dist/core/catalog.d.ts +155 -0
- package/dist/core/catalog.js +24 -0
- package/dist/core/client.d.ts +156 -0
- package/dist/core/client.js +87 -0
- package/dist/core/database.d.ts +124 -0
- package/dist/core/database.js +314 -0
- package/dist/core/envelope.d.ts +45 -0
- package/dist/core/envelope.js +112 -0
- package/dist/core/errors.d.ts +52 -0
- package/dist/core/errors.js +33 -0
- package/dist/core/index.d.ts +14 -0
- package/dist/core/index.js +13 -0
- package/dist/core/json-call.d.ts +4 -0
- package/dist/core/json-call.js +109 -0
- package/dist/core/json.d.ts +17 -0
- package/dist/core/json.js +4 -0
- package/dist/core/transport.d.ts +47 -0
- package/dist/core/transport.js +367 -0
- package/dist/core/validator.d.ts +52 -0
- package/dist/core/validator.js +1 -0
- package/dist/generate/cli.d.ts +44 -0
- package/dist/generate/cli.js +358 -0
- package/dist/generate/generate.d.ts +46 -0
- package/dist/generate/generate.js +215 -0
- package/dist/generate/identifiers.d.ts +27 -0
- package/dist/generate/identifiers.js +67 -0
- package/dist/generate/index.d.ts +15 -0
- package/dist/generate/index.js +15 -0
- package/dist/generate/manifest.d.ts +1 -0
- package/dist/generate/manifest.js +1 -0
- package/dist/generate/types.d.ts +57 -0
- package/dist/generate/types.js +357 -0
- package/dist/generated/catalog.d.ts +2 -0
- package/dist/generated/catalog.js +854 -0
- package/dist/generated/index.d.ts +2 -0
- package/dist/generated/index.js +4 -0
- package/dist/generated/manifest.json +799 -0
- package/dist/generated/operations.d.ts +8450 -0
- package/dist/generated/operations.js +3 -0
- package/dist/generated/schema.json +39451 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +11 -0
- package/dist/json/index.d.ts +81 -0
- package/dist/json/index.js +404 -0
- package/dist/validation/index.d.ts +19 -0
- package/dist/validation/index.js +149 -0
- package/dist/validation/json.d.ts +1 -0
- package/dist/validation/json.js +1 -0
- package/dist/validation/pattern.d.ts +56 -0
- package/dist/validation/pattern.js +382 -0
- package/dist/validation/schema.d.ts +103 -0
- package/dist/validation/schema.js +1300 -0
- package/openapi.json +40754 -0
- package/package.json +93 -4
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `zo-system-api` — the offline typed-client generator CLI.
|
|
4
|
+
*
|
|
5
|
+
* zo-system-api generate --out <dir> --base-url <url>
|
|
6
|
+
* [--workspace <id>] [--catalog <mode>] [--from <schema.json>] [--check]
|
|
7
|
+
*
|
|
8
|
+
* Fetches the authorized catalog export from `<base-url>/catalog/export` (or
|
|
9
|
+
* reads it from `--from` for offline regeneration), renders the five-file typed
|
|
10
|
+
* client bundle, and writes it atomically into `--out`. `--check` writes nothing
|
|
11
|
+
* and exits non-zero if the on-disk bundle differs from what would be generated.
|
|
12
|
+
*
|
|
13
|
+
* The access token is read from `ZO_SYSTEM_API_ACCESS_TOKEN`, or from stdin when
|
|
14
|
+
* that variable is unset. The token, the base URL, and any fetched identifiers
|
|
15
|
+
* are never written to stdout: on success the CLI prints only a file count and
|
|
16
|
+
* the output directory.
|
|
17
|
+
*/
|
|
18
|
+
import { createHash } from "node:crypto";
|
|
19
|
+
import { realpathSync } from "node:fs";
|
|
20
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
21
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
22
|
+
import { dirname, join, resolve } from "node:path";
|
|
23
|
+
import { argv } from "node:process";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
25
|
+
import { BUNDLE_FILE_NAMES, generateBundle } from "./index.js";
|
|
26
|
+
/** A CLI usage or runtime failure whose message is safe to print. */
|
|
27
|
+
class CliError extends Error {
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Client ceiling on the catalog export response, mirroring the service's export
|
|
31
|
+
* byte bound. A stalled or hostile endpoint cannot make the CLI buffer without
|
|
32
|
+
* limit: the response body is counted while streaming and aborted past this.
|
|
33
|
+
*/
|
|
34
|
+
const MAX_EXPORT_BYTES = 4 * 1024 * 1024;
|
|
35
|
+
/**
|
|
36
|
+
* Client deadline for the export request. The service bounds its own work at 90s
|
|
37
|
+
* (MAX_REQUEST_SECONDS); this leaves margin for the round trip, but a truly
|
|
38
|
+
* stalled endpoint can never hang the CLI forever.
|
|
39
|
+
*/
|
|
40
|
+
const EXPORT_TIMEOUT_MS = 120_000;
|
|
41
|
+
function parseArgs(argv) {
|
|
42
|
+
const flags = new Map();
|
|
43
|
+
let check = false;
|
|
44
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
45
|
+
const arg = argv[i];
|
|
46
|
+
if (arg === "--check") {
|
|
47
|
+
check = true;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (!arg.startsWith("--"))
|
|
51
|
+
throw new CliError(`unexpected argument: ${arg}`);
|
|
52
|
+
const key = arg.slice(2);
|
|
53
|
+
const value = argv[i + 1];
|
|
54
|
+
if (value === undefined || value.startsWith("--"))
|
|
55
|
+
throw new CliError(`flag --${key} requires a value`);
|
|
56
|
+
flags.set(key, value);
|
|
57
|
+
i += 1;
|
|
58
|
+
}
|
|
59
|
+
const known = new Set(["out", "base-url", "workspace", "catalog", "from"]);
|
|
60
|
+
for (const key of flags.keys())
|
|
61
|
+
if (!known.has(key))
|
|
62
|
+
throw new CliError(`unknown flag --${key}`);
|
|
63
|
+
const out = flags.get("out");
|
|
64
|
+
if (out === undefined)
|
|
65
|
+
throw new CliError("--out <dir> is required");
|
|
66
|
+
const from = flags.get("from");
|
|
67
|
+
const baseUrl = flags.get("base-url");
|
|
68
|
+
if (from === undefined && baseUrl === undefined)
|
|
69
|
+
throw new CliError("one of --base-url <url> or --from <file> is required");
|
|
70
|
+
// The two sources are mutually exclusive: accepting both and silently
|
|
71
|
+
// preferring one could make a release command appear to fetch the current
|
|
72
|
+
// authorized contract while actually regenerating from a stale local file.
|
|
73
|
+
if (from !== undefined && baseUrl !== undefined)
|
|
74
|
+
throw new CliError("--from and --base-url are mutually exclusive: pass exactly one source");
|
|
75
|
+
return {
|
|
76
|
+
out,
|
|
77
|
+
baseUrl,
|
|
78
|
+
workspace: flags.get("workspace"),
|
|
79
|
+
catalog: flags.get("catalog"),
|
|
80
|
+
from,
|
|
81
|
+
check,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Validate the base URL as a credential-bearing endpoint: HTTPS, or explicit
|
|
86
|
+
* loopback HTTP for local/integration use. Reject any other scheme, non-loopback
|
|
87
|
+
* HTTP, and any userinfo, query, or fragment — a bearer token must never be able
|
|
88
|
+
* to ride along in the URL, and the endpoint must not carry hidden state.
|
|
89
|
+
*/
|
|
90
|
+
function validateBaseUrl(raw) {
|
|
91
|
+
let url;
|
|
92
|
+
try {
|
|
93
|
+
url = new URL(raw);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
throw new CliError("--base-url is not a valid URL");
|
|
97
|
+
}
|
|
98
|
+
if (url.username !== "" || url.password !== "")
|
|
99
|
+
throw new CliError("--base-url must not contain a username or password");
|
|
100
|
+
if (url.search !== "")
|
|
101
|
+
throw new CliError("--base-url must not contain a query string");
|
|
102
|
+
if (url.hash !== "")
|
|
103
|
+
throw new CliError("--base-url must not contain a fragment");
|
|
104
|
+
const loopbackHosts = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
|
|
105
|
+
const isLoopback = loopbackHosts.has(url.hostname);
|
|
106
|
+
if (url.protocol === "https:")
|
|
107
|
+
return url;
|
|
108
|
+
if (url.protocol === "http:" && isLoopback)
|
|
109
|
+
return url;
|
|
110
|
+
if (url.protocol === "http:")
|
|
111
|
+
throw new CliError("--base-url must use https except for loopback (localhost/127.0.0.1/[::1])");
|
|
112
|
+
throw new CliError(`--base-url must use https, not ${url.protocol}`);
|
|
113
|
+
}
|
|
114
|
+
/** Read the access token from the environment, falling back to stdin. */
|
|
115
|
+
async function readToken() {
|
|
116
|
+
const fromEnv = process.env["ZO_SYSTEM_API_ACCESS_TOKEN"];
|
|
117
|
+
if (fromEnv !== undefined && fromEnv.trim() !== "")
|
|
118
|
+
return fromEnv.trim();
|
|
119
|
+
const chunks = [];
|
|
120
|
+
for await (const chunk of process.stdin)
|
|
121
|
+
chunks.push(chunk);
|
|
122
|
+
const token = Buffer.concat(chunks).toString("utf8").trim();
|
|
123
|
+
if (token === "")
|
|
124
|
+
throw new CliError("no access token: set ZO_SYSTEM_API_ACCESS_TOKEN or pipe the token on stdin");
|
|
125
|
+
return token;
|
|
126
|
+
}
|
|
127
|
+
async function fetchExport(baseUrl, workspace, catalog, token, options = {}) {
|
|
128
|
+
const timeoutMs = options.timeoutMs ?? EXPORT_TIMEOUT_MS;
|
|
129
|
+
const maxBytes = options.maxBytes ?? MAX_EXPORT_BYTES;
|
|
130
|
+
const endpoint = new URL("catalog/export", ensureTrailingSlash(baseUrl));
|
|
131
|
+
endpoint.searchParams.set("workspace_id", workspace);
|
|
132
|
+
if (catalog !== undefined)
|
|
133
|
+
endpoint.searchParams.set("catalog", catalog);
|
|
134
|
+
let response;
|
|
135
|
+
try {
|
|
136
|
+
response = await fetch(endpoint, {
|
|
137
|
+
headers: { authorization: `Bearer ${token}`, accept: "application/json" },
|
|
138
|
+
redirect: "manual",
|
|
139
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
if (error.name === "TimeoutError")
|
|
144
|
+
throw new CliError(`catalog export request timed out after ${timeoutMs / 1000}s`);
|
|
145
|
+
throw new CliError(`could not reach the catalog export endpoint: ${error.message}`);
|
|
146
|
+
}
|
|
147
|
+
// With redirect:"manual", undici surfaces a 3xx as an opaque redirect: refuse
|
|
148
|
+
// it rather than let fetch resend the Authorization header to a new origin.
|
|
149
|
+
if (response.type === "opaqueredirect" ||
|
|
150
|
+
(response.status >= 300 && response.status < 400))
|
|
151
|
+
throw new CliError("catalog export endpoint returned a redirect; refusing to follow it");
|
|
152
|
+
if (!response.ok)
|
|
153
|
+
throw new CliError(`catalog export request failed with HTTP ${response.status}`);
|
|
154
|
+
return readBounded(response, maxBytes);
|
|
155
|
+
}
|
|
156
|
+
/** Read a response body into memory, aborting if it exceeds `max` bytes. */
|
|
157
|
+
async function readBounded(response, max) {
|
|
158
|
+
const body = response.body;
|
|
159
|
+
if (body === null) {
|
|
160
|
+
const buffered = new Uint8Array(await response.arrayBuffer());
|
|
161
|
+
if (buffered.byteLength > max)
|
|
162
|
+
throw new CliError(`catalog export exceeded the ${max}-byte limit`);
|
|
163
|
+
return buffered;
|
|
164
|
+
}
|
|
165
|
+
const reader = body.getReader();
|
|
166
|
+
const chunks = [];
|
|
167
|
+
let total = 0;
|
|
168
|
+
for (;;) {
|
|
169
|
+
const { done, value } = await reader.read();
|
|
170
|
+
if (done)
|
|
171
|
+
break;
|
|
172
|
+
total += value.byteLength;
|
|
173
|
+
if (total > max) {
|
|
174
|
+
await reader.cancel();
|
|
175
|
+
throw new CliError(`catalog export exceeded the ${max}-byte limit`);
|
|
176
|
+
}
|
|
177
|
+
chunks.push(value);
|
|
178
|
+
}
|
|
179
|
+
const out = new Uint8Array(total);
|
|
180
|
+
let offset = 0;
|
|
181
|
+
for (const chunk of chunks) {
|
|
182
|
+
out.set(chunk, offset);
|
|
183
|
+
offset += chunk.byteLength;
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
function ensureTrailingSlash(url) {
|
|
188
|
+
if (url.pathname.endsWith("/"))
|
|
189
|
+
return url;
|
|
190
|
+
const copy = new URL(url.href);
|
|
191
|
+
copy.pathname = `${url.pathname}/`;
|
|
192
|
+
return copy;
|
|
193
|
+
}
|
|
194
|
+
function toBytes(content) {
|
|
195
|
+
return typeof content === "string"
|
|
196
|
+
? new TextEncoder().encode(content)
|
|
197
|
+
: content;
|
|
198
|
+
}
|
|
199
|
+
function digest(bytes) {
|
|
200
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
201
|
+
}
|
|
202
|
+
/** Compare the generated bundle to the on-disk directory; return changed files. */
|
|
203
|
+
async function diffBundle(outDir, files) {
|
|
204
|
+
const changed = [];
|
|
205
|
+
for (const name of BUNDLE_FILE_NAMES) {
|
|
206
|
+
const target = join(outDir, name);
|
|
207
|
+
const next = toBytes(files[name]);
|
|
208
|
+
if (!existsSync(target)) {
|
|
209
|
+
changed.push(name);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
const current = new Uint8Array(await readFile(target));
|
|
213
|
+
if (digest(current) !== digest(next))
|
|
214
|
+
changed.push(name);
|
|
215
|
+
}
|
|
216
|
+
// A stray extra file in the bundle directory is also drift.
|
|
217
|
+
if (existsSync(outDir))
|
|
218
|
+
for (const entry of readdirSync(outDir))
|
|
219
|
+
if (!BUNDLE_FILE_NAMES.includes(entry))
|
|
220
|
+
changed.push(entry);
|
|
221
|
+
return changed;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Write the bundle into `outDir`, replacing it atomically at the directory
|
|
225
|
+
* boundary: stage every file in a sibling temp directory, then swap it into
|
|
226
|
+
* place, so a reader never sees a half-written mix of old and new files.
|
|
227
|
+
*/
|
|
228
|
+
async function writeBundleAtomically(outDir, files, deps = { rename }) {
|
|
229
|
+
// Restore uses the real rename by default so a test that injects a failing
|
|
230
|
+
// `rename` to simulate an install failure does not also sabotage the rollback;
|
|
231
|
+
// it is separately injectable to exercise the both-fail data-loss guard.
|
|
232
|
+
const restore = deps.restore ?? rename;
|
|
233
|
+
const parent = dirname(outDir);
|
|
234
|
+
await mkdir(parent, { recursive: true });
|
|
235
|
+
const suffix = `${process.pid}-${Date.now().toString(36)}`;
|
|
236
|
+
const stage = join(parent, `.zo-system-api-${suffix}`);
|
|
237
|
+
await mkdir(stage, { recursive: true });
|
|
238
|
+
const old = existsSync(outDir)
|
|
239
|
+
? join(parent, `.zo-system-api-old-${suffix}`)
|
|
240
|
+
: undefined;
|
|
241
|
+
// True only while the moved-aside old bundle is the sole surviving copy of the
|
|
242
|
+
// output — install failed AND restore failed. While it holds, the backup must
|
|
243
|
+
// never be deleted: that would turn a failed regeneration into data loss. It is
|
|
244
|
+
// cleared the moment the new bundle installs or the old one is restored.
|
|
245
|
+
let oldIsOnlyCopy = false;
|
|
246
|
+
try {
|
|
247
|
+
for (const name of BUNDLE_FILE_NAMES)
|
|
248
|
+
await writeFile(join(stage, name), toBytes(files[name]));
|
|
249
|
+
if (old) {
|
|
250
|
+
await deps.rename(outDir, old);
|
|
251
|
+
oldIsOnlyCopy = true;
|
|
252
|
+
}
|
|
253
|
+
try {
|
|
254
|
+
await deps.rename(stage, outDir);
|
|
255
|
+
oldIsOnlyCopy = false;
|
|
256
|
+
}
|
|
257
|
+
catch (installError) {
|
|
258
|
+
// Installing the new bundle failed after moving the old one aside; restore
|
|
259
|
+
// it so the caller is never left with a missing output and a stray backup.
|
|
260
|
+
// If the restore itself fails, the moved-aside bundle is the only copy left
|
|
261
|
+
// — keep it (oldIsOnlyCopy stays true) so the cleanup below never drops it.
|
|
262
|
+
if (old && !existsSync(outDir)) {
|
|
263
|
+
try {
|
|
264
|
+
await restore(old, outDir);
|
|
265
|
+
oldIsOnlyCopy = false;
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
// Restore failed; the backup is the only surviving copy. Leave it.
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
throw installError;
|
|
272
|
+
}
|
|
273
|
+
if (old)
|
|
274
|
+
await rm(old, { recursive: true, force: true });
|
|
275
|
+
}
|
|
276
|
+
finally {
|
|
277
|
+
await rm(stage, { recursive: true, force: true });
|
|
278
|
+
if (old && !oldIsOnlyCopy)
|
|
279
|
+
await rm(old, { recursive: true, force: true });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
async function runGenerate(args) {
|
|
283
|
+
let schemaBytes;
|
|
284
|
+
if (args.from !== undefined) {
|
|
285
|
+
schemaBytes = new Uint8Array(readFileSync(resolve(args.from)));
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
const baseUrl = validateBaseUrl(args.baseUrl);
|
|
289
|
+
if (args.workspace === undefined || args.workspace === "")
|
|
290
|
+
throw new CliError("--workspace <id> is required when fetching from --base-url");
|
|
291
|
+
schemaBytes = await fetchExport(baseUrl, args.workspace, args.catalog, await readToken());
|
|
292
|
+
}
|
|
293
|
+
// generateBundle snapshots schemaBytes and derives the model internally, so the
|
|
294
|
+
// emitted types and persisted schema.json always come from one immutable source.
|
|
295
|
+
// The requested selection is verified against the export's own echo inside
|
|
296
|
+
// generateBundle; it never fabricates provenance. Empty fields are not checked.
|
|
297
|
+
const { files } = await generateBundle({
|
|
298
|
+
schemaBytes,
|
|
299
|
+
selection: {
|
|
300
|
+
workspace: args.workspace ?? "",
|
|
301
|
+
catalog: args.catalog ?? "",
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
const outDir = resolve(args.out);
|
|
305
|
+
if (args.check) {
|
|
306
|
+
const changed = await diffBundle(outDir, files);
|
|
307
|
+
if (changed.length === 0) {
|
|
308
|
+
process.stdout.write("Bundle is up to date.\n");
|
|
309
|
+
return 0;
|
|
310
|
+
}
|
|
311
|
+
process.stdout.write(`Bundle is out of date; ${changed.length} file(s) differ: ${changed
|
|
312
|
+
.sort()
|
|
313
|
+
.join(", ")}\n`);
|
|
314
|
+
return 1;
|
|
315
|
+
}
|
|
316
|
+
await writeBundleAtomically(outDir, files);
|
|
317
|
+
process.stdout.write(`Wrote ${BUNDLE_FILE_NAMES.length} files to ${args.out}\n`);
|
|
318
|
+
return 0;
|
|
319
|
+
}
|
|
320
|
+
async function main(argv) {
|
|
321
|
+
const [command, ...rest] = argv;
|
|
322
|
+
if (command === undefined || command === "--help" || command === "-h") {
|
|
323
|
+
process.stdout.write("Usage: zo-system-api generate --out <dir> --base-url <url> " +
|
|
324
|
+
"[--workspace <id>] [--catalog <mode>] [--from <file>] [--check]\n");
|
|
325
|
+
return command === undefined ? 1 : 0;
|
|
326
|
+
}
|
|
327
|
+
if (command !== "generate")
|
|
328
|
+
throw new CliError(`unknown command: ${command}`);
|
|
329
|
+
return runGenerate(parseArgs(rest));
|
|
330
|
+
}
|
|
331
|
+
/** True when this module is the process entry point (run as the `zo-system-api` bin). */
|
|
332
|
+
function isMainEntry() {
|
|
333
|
+
const entry = argv[1];
|
|
334
|
+
if (entry === undefined)
|
|
335
|
+
return false;
|
|
336
|
+
try {
|
|
337
|
+
return realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url));
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (isMainEntry())
|
|
344
|
+
main(argv.slice(2))
|
|
345
|
+
.then((code) => {
|
|
346
|
+
process.exitCode = code;
|
|
347
|
+
})
|
|
348
|
+
.catch((error) => {
|
|
349
|
+
const message = error instanceof CliError
|
|
350
|
+
? error.message
|
|
351
|
+
: error instanceof Error
|
|
352
|
+
? error.message
|
|
353
|
+
: String(error);
|
|
354
|
+
process.stderr.write(`zo-system-api: ${message}\n`);
|
|
355
|
+
process.exitCode = 1;
|
|
356
|
+
});
|
|
357
|
+
// Exported for tests and the programmatic path; `main` runs only as the bin entry.
|
|
358
|
+
export { fetchExport, main, parseArgs, readToken, validateBaseUrl, writeBundleAtomically, };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** The generator version stamped into every emitted catalog and manifest. */
|
|
2
|
+
export declare const GENERATOR_VERSION = "0.1.0";
|
|
3
|
+
/** The file names in the generated bundle, in a stable order. */
|
|
4
|
+
export declare const BUNDLE_FILE_NAMES: readonly ["index.ts", "operations.ts", "catalog.ts", "schema.json", "manifest.json"];
|
|
5
|
+
/**
|
|
6
|
+
* The selection the caller asked for, used only to *verify* against the service's
|
|
7
|
+
* own echo — never to fabricate provenance. Fields left empty are not checked.
|
|
8
|
+
* Deliberately excludes the service URL: the base URL carries the bearer token
|
|
9
|
+
* and may be an internal host, and persisting it would make two identical
|
|
10
|
+
* authorized exports differ by endpoint.
|
|
11
|
+
*/
|
|
12
|
+
export interface CatalogSelection {
|
|
13
|
+
readonly workspace: string;
|
|
14
|
+
readonly catalog: string;
|
|
15
|
+
}
|
|
16
|
+
/** Everything the orchestrator needs to emit a bundle. */
|
|
17
|
+
export interface GenerateInput {
|
|
18
|
+
/**
|
|
19
|
+
* The exact catalog export bytes: the single source of truth. The generator
|
|
20
|
+
* snapshots these before any async work and derives the {@link NormalizedCatalog}
|
|
21
|
+
* from the same snapshot it persists as `schema.json` and digests, so the
|
|
22
|
+
* emitted types can never describe a different export than the one recorded.
|
|
23
|
+
*/
|
|
24
|
+
readonly schemaBytes: Uint8Array;
|
|
25
|
+
/**
|
|
26
|
+
* The selection the caller requested, verified against the export's own echo.
|
|
27
|
+
* Optional: the builtin export carries no selection, so there is nothing to
|
|
28
|
+
* record or verify. Recorded provenance always comes from the export itself.
|
|
29
|
+
*/
|
|
30
|
+
readonly selection?: CatalogSelection;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The emitted bundle: `schema.json` is exact bytes, every other file is text.
|
|
34
|
+
* Keys are exactly {@link BUNDLE_FILE_NAMES}.
|
|
35
|
+
*/
|
|
36
|
+
export interface GeneratedBundle {
|
|
37
|
+
readonly files: {
|
|
38
|
+
readonly "index.ts": string;
|
|
39
|
+
readonly "operations.ts": string;
|
|
40
|
+
readonly "catalog.ts": string;
|
|
41
|
+
readonly "schema.json": Uint8Array;
|
|
42
|
+
readonly "manifest.json": string;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** Emit the full five-file bundle for one catalog. */
|
|
46
|
+
export declare function generateBundle(input: GenerateInput): Promise<GeneratedBundle>;
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The generation orchestrator: turn one normalized catalog export into the
|
|
3
|
+
* five-file typed-client bundle a consumer commits and imports.
|
|
4
|
+
*
|
|
5
|
+
* The bundle is deterministic and self-consistent:
|
|
6
|
+
* - `operations.ts` — every operation Input/Output model and every resource
|
|
7
|
+
* type, plus the `Operations` and `DefaultVersions` type maps.
|
|
8
|
+
* - `catalog.ts` — the runtime `catalog` value (default-version pins and
|
|
9
|
+
* per-version digests) built with `defineCatalog`, tied to the type maps.
|
|
10
|
+
* - `index.ts` — the consumer entry that re-exports both.
|
|
11
|
+
* - `schema.json` — the exact original export bytes (lossless; drives the
|
|
12
|
+
* schema digest and offline regeneration).
|
|
13
|
+
* - `manifest.json` — selection metadata, the generator version, and the
|
|
14
|
+
* schema/bundle digests that identify this bundle.
|
|
15
|
+
*
|
|
16
|
+
* Every emitted type name is minted through one shared {@link NameRegistry}, so
|
|
17
|
+
* the independently compiled schemas never collide when concatenated.
|
|
18
|
+
*/
|
|
19
|
+
import { createHash } from "node:crypto";
|
|
20
|
+
import { loadNormalizedCatalog } from "./manifest.js";
|
|
21
|
+
import { operationTypeName, resourceTypeName } from "./identifiers.js";
|
|
22
|
+
import { GeneratorError, NameRegistry, renderSchemaType } from "./types.js";
|
|
23
|
+
/** The generator version stamped into every emitted catalog and manifest. */
|
|
24
|
+
export const GENERATOR_VERSION = "0.1.0";
|
|
25
|
+
/** Reserved top-level names the generator never assigns to a schema type. */
|
|
26
|
+
const RESERVED_NAMES = ["Operations", "DefaultVersions"];
|
|
27
|
+
/** The file names in the generated bundle, in a stable order. */
|
|
28
|
+
export const BUNDLE_FILE_NAMES = [
|
|
29
|
+
"index.ts",
|
|
30
|
+
"operations.ts",
|
|
31
|
+
"catalog.ts",
|
|
32
|
+
"schema.json",
|
|
33
|
+
"manifest.json",
|
|
34
|
+
];
|
|
35
|
+
const BANNER = "// Generated by @zocomputer/system-api. Do not edit by hand.\n" +
|
|
36
|
+
"// Regenerate with the `zo-system-api generate` CLI.\n";
|
|
37
|
+
function sha256Hex(...parts) {
|
|
38
|
+
const hash = createHash("sha256");
|
|
39
|
+
for (const part of parts)
|
|
40
|
+
hash.update(part);
|
|
41
|
+
return `sha256:${hash.digest("hex")}`;
|
|
42
|
+
}
|
|
43
|
+
/** Emit the full five-file bundle for one catalog. */
|
|
44
|
+
export async function generateBundle(input) {
|
|
45
|
+
// One immutable source of truth: copy the export bytes before any async work,
|
|
46
|
+
// then derive the model from the same snapshot we persist and digest. The copy
|
|
47
|
+
// means a caller mutating its array mid-generation or after return cannot make
|
|
48
|
+
// the emitted types disagree with schema.json or the recorded digests.
|
|
49
|
+
const schemaBytes = input.schemaBytes.slice();
|
|
50
|
+
const { catalog } = loadNormalizedCatalog(schemaBytes);
|
|
51
|
+
// Provenance is the service's own selection echo, never fabricated from the
|
|
52
|
+
// caller's request. A requested selection, when given, only verifies the echo;
|
|
53
|
+
// a requested workspace/catalog with no echo to verify against is a mismatch,
|
|
54
|
+
// so a scoped request can never silently accept an unscoped/echoless export.
|
|
55
|
+
const echoed = catalog.selection;
|
|
56
|
+
const requested = input.selection;
|
|
57
|
+
if (requested !== undefined) {
|
|
58
|
+
const wantsWorkspace = requested.workspace !== "";
|
|
59
|
+
const wantsCatalog = requested.catalog !== "";
|
|
60
|
+
if ((wantsWorkspace || wantsCatalog) && echoed === undefined)
|
|
61
|
+
throw new GeneratorError(`a selection was requested (workspace '${requested.workspace}', ` +
|
|
62
|
+
`catalog '${requested.catalog}'), but the export carries no selection ` +
|
|
63
|
+
`echo to verify it against`);
|
|
64
|
+
if (echoed !== undefined) {
|
|
65
|
+
if (wantsWorkspace && requested.workspace !== echoed.workspace)
|
|
66
|
+
throw new GeneratorError(`requested workspace '${requested.workspace}' does not match the export's ` +
|
|
67
|
+
`selection workspace '${echoed.workspace}'`);
|
|
68
|
+
if (wantsCatalog && requested.catalog !== echoed.catalog)
|
|
69
|
+
throw new GeneratorError(`requested catalog '${requested.catalog}' does not match the export's ` +
|
|
70
|
+
`selection catalog '${echoed.catalog}'`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const registry = new NameRegistry(RESERVED_NAMES);
|
|
74
|
+
const modelTexts = [];
|
|
75
|
+
let usesJsonValue = false;
|
|
76
|
+
// Assign a name to every operation model, in deterministic order.
|
|
77
|
+
const opNames = new Map();
|
|
78
|
+
for (const op of catalog.operations) {
|
|
79
|
+
for (const version of op.versions) {
|
|
80
|
+
const inKey = `op:${op.action}:${version.version}:Input`;
|
|
81
|
+
const outKey = `op:${op.action}:${version.version}:Output`;
|
|
82
|
+
const inName = registry.claim(inKey, operationTypeName(op.action, version.version, "Input"));
|
|
83
|
+
const outName = registry.claim(outKey, operationTypeName(op.action, version.version, "Output"));
|
|
84
|
+
const inRendered = await renderSchemaType(version.input, inName, inKey, registry, `${op.action} v${version.version} input`);
|
|
85
|
+
const outRendered = await renderSchemaType(version.output, outName, outKey, registry, `${op.action} v${version.version} output`);
|
|
86
|
+
usesJsonValue ||= inRendered.usesJsonValue || outRendered.usesJsonValue;
|
|
87
|
+
modelTexts.push(inRendered.text, outRendered.text);
|
|
88
|
+
let queryName;
|
|
89
|
+
if (version.query !== undefined) {
|
|
90
|
+
const queryKey = `op:${op.action}:${version.version}:Query`;
|
|
91
|
+
queryName = registry.claim(queryKey, operationTypeName(op.action, version.version, "Query"));
|
|
92
|
+
const queryRendered = await renderSchemaType(version.query, queryName, queryKey, registry, `${op.action} v${version.version} query`);
|
|
93
|
+
usesJsonValue ||= queryRendered.usesJsonValue;
|
|
94
|
+
modelTexts.push(queryRendered.text);
|
|
95
|
+
}
|
|
96
|
+
opNames.set(`${op.action}:${version.version}`, {
|
|
97
|
+
input: inName,
|
|
98
|
+
output: outName,
|
|
99
|
+
...(queryName !== undefined ? { query: queryName } : {}),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// Assign a name to every resource type.
|
|
104
|
+
for (const resource of catalog.resourceTypes) {
|
|
105
|
+
const key = `res:${resource.type}:${resource.version}`;
|
|
106
|
+
const name = registry.claim(key, resourceTypeName(resource.type, resource.version));
|
|
107
|
+
const rendered = await renderSchemaType(resource.propertiesSchema, name, key, registry, `resource ${resource.type} v${resource.version}`);
|
|
108
|
+
usesJsonValue ||= rendered.usesJsonValue;
|
|
109
|
+
modelTexts.push(rendered.text);
|
|
110
|
+
}
|
|
111
|
+
const operationsFile = renderOperationsFile(catalog, opNames, modelTexts, usesJsonValue);
|
|
112
|
+
const catalogFile = renderCatalogFile(catalog);
|
|
113
|
+
const indexFile = renderIndexFile();
|
|
114
|
+
const schemaDigest = sha256Hex(schemaBytes);
|
|
115
|
+
const bundleDigest = sha256Hex(indexFile, "\0", operationsFile, "\0", catalogFile, "\0", schemaBytes);
|
|
116
|
+
const manifestFile = renderManifestFile(catalog, echoed, schemaDigest, bundleDigest);
|
|
117
|
+
return {
|
|
118
|
+
files: {
|
|
119
|
+
"index.ts": indexFile,
|
|
120
|
+
"operations.ts": operationsFile,
|
|
121
|
+
"catalog.ts": catalogFile,
|
|
122
|
+
"schema.json": schemaBytes,
|
|
123
|
+
"manifest.json": manifestFile,
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
function renderOperationsFile(catalog, opNames, modelTexts, usesJsonValue) {
|
|
128
|
+
const parts = [BANNER];
|
|
129
|
+
if (usesJsonValue)
|
|
130
|
+
parts.push('import type { JsonValue } from "@zocomputer/system-api/core";\n');
|
|
131
|
+
parts.push(modelTexts.join("\n\n"));
|
|
132
|
+
// The `Operations` type map: action → version → { input; output; query? },
|
|
133
|
+
// with `query` only for a version that declares list query options.
|
|
134
|
+
const opLines = ["export type Operations = {"];
|
|
135
|
+
for (const op of catalog.operations) {
|
|
136
|
+
opLines.push(` ${JSON.stringify(op.action)}: {`);
|
|
137
|
+
for (const version of op.versions) {
|
|
138
|
+
const names = opNames.get(`${op.action}:${version.version}`);
|
|
139
|
+
const query = names.query !== undefined ? `; query: ${names.query}` : "";
|
|
140
|
+
opLines.push(` ${version.version}: { input: ${names.input}; output: ${names.output}${query} };`);
|
|
141
|
+
}
|
|
142
|
+
opLines.push(" };");
|
|
143
|
+
}
|
|
144
|
+
opLines.push("};");
|
|
145
|
+
parts.push(opLines.join("\n"));
|
|
146
|
+
// The `DefaultVersions` type map: action → its default version. Only actions
|
|
147
|
+
// with an eligible pin appear; a visible but unpinned action is omitted, so a
|
|
148
|
+
// typed call against it requires an explicit version.
|
|
149
|
+
const defLines = ["export type DefaultVersions = {"];
|
|
150
|
+
for (const op of catalog.operations)
|
|
151
|
+
if (op.defaultVersion !== undefined)
|
|
152
|
+
defLines.push(` ${JSON.stringify(op.action)}: ${op.defaultVersion};`);
|
|
153
|
+
defLines.push("};");
|
|
154
|
+
parts.push(defLines.join("\n"));
|
|
155
|
+
return `${parts.join("\n\n")}\n`;
|
|
156
|
+
}
|
|
157
|
+
function renderCatalogFile(catalog) {
|
|
158
|
+
const lines = [
|
|
159
|
+
BANNER,
|
|
160
|
+
// Generated for a NodeNext consumer without allowImportingTsExtensions:
|
|
161
|
+
// emit `.js` specifiers, which resolve to the sibling `.ts` at compile time.
|
|
162
|
+
'import { defineCatalog } from "@zocomputer/system-api/core";\n' +
|
|
163
|
+
'import type { DefaultVersions, Operations } from "./operations.js";',
|
|
164
|
+
"",
|
|
165
|
+
"export const catalog = defineCatalog<Operations, DefaultVersions>({",
|
|
166
|
+
` generatorVersion: ${JSON.stringify(GENERATOR_VERSION)},`,
|
|
167
|
+
" actions: {",
|
|
168
|
+
];
|
|
169
|
+
for (const op of catalog.operations) {
|
|
170
|
+
lines.push(` ${JSON.stringify(op.action)}: {`);
|
|
171
|
+
// Omit the field for an unpinned action: TypedActions forbids a numeric
|
|
172
|
+
// default there, and the runtime rejects an omitted version for it.
|
|
173
|
+
if (op.defaultVersion !== undefined)
|
|
174
|
+
lines.push(` defaultVersion: ${op.defaultVersion},`);
|
|
175
|
+
lines.push(" versions: {");
|
|
176
|
+
for (const version of op.versions)
|
|
177
|
+
lines.push(` ${version.version}: { digest: ${JSON.stringify(version.digest)} },`);
|
|
178
|
+
lines.push(" },");
|
|
179
|
+
lines.push(" },");
|
|
180
|
+
}
|
|
181
|
+
lines.push(" },");
|
|
182
|
+
lines.push("});");
|
|
183
|
+
return `${lines.join("\n")}\n`;
|
|
184
|
+
}
|
|
185
|
+
function renderIndexFile() {
|
|
186
|
+
return (`${BANNER}\n` +
|
|
187
|
+
'export * from "./operations.js";\n' +
|
|
188
|
+
'export { catalog } from "./catalog.js";\n');
|
|
189
|
+
}
|
|
190
|
+
function renderManifestFile(catalog, echoed, schemaDigest, bundleDigest) {
|
|
191
|
+
const manifest = {
|
|
192
|
+
generatorVersion: GENERATOR_VERSION,
|
|
193
|
+
// Provenance from the export itself only: the selection the service echoed
|
|
194
|
+
// (absent for the builtin export) and its canonical digest, never the
|
|
195
|
+
// caller's request or the service URL.
|
|
196
|
+
...(echoed !== undefined
|
|
197
|
+
? { selection: { workspace: echoed.workspace, catalog: echoed.catalog } }
|
|
198
|
+
: {}),
|
|
199
|
+
...(catalog.catalogDigest !== undefined
|
|
200
|
+
? { catalogDigest: catalog.catalogDigest }
|
|
201
|
+
: {}),
|
|
202
|
+
schemaDigest,
|
|
203
|
+
bundleDigest,
|
|
204
|
+
actions: catalog.operations.flatMap((op) => op.versions.map((v) => ({
|
|
205
|
+
action: op.action,
|
|
206
|
+
version: v.version,
|
|
207
|
+
digest: v.digest,
|
|
208
|
+
}))),
|
|
209
|
+
resourceTypes: catalog.resourceTypes.map((r) => ({
|
|
210
|
+
type: r.type,
|
|
211
|
+
version: r.version,
|
|
212
|
+
})),
|
|
213
|
+
};
|
|
214
|
+
return `${JSON.stringify(manifest, null, 2)}\n`;
|
|
215
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic, collision-safe TypeScript identifiers and sanitized doc text
|
|
3
|
+
* for generated output. Schema data is never code: a generated type name is
|
|
4
|
+
* derived only from a safe character class, and documentation text is
|
|
5
|
+
* neutralized so it can never terminate a comment or inject TypeScript.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* PascalCase a raw token from its alphanumeric runs. Non-alphanumeric
|
|
9
|
+
* characters (dots, dashes, underscores, anything else) are treated as segment
|
|
10
|
+
* separators, so `zo.machine.read_file` becomes `ZoMachineReadFile`. A leading
|
|
11
|
+
* digit run is prefixed with `_` so the result is a valid identifier head.
|
|
12
|
+
*/
|
|
13
|
+
export declare function pascalCase(raw: string): string;
|
|
14
|
+
/**
|
|
15
|
+
* Type name for an action's input or output at a version:
|
|
16
|
+
* `("zo.machine.read_file", 1, "Input")` → `ZoMachineReadFileInputV1`.
|
|
17
|
+
*/
|
|
18
|
+
export declare function operationTypeName(action: string, version: number, kind: "Input" | "Output" | "Query"): string;
|
|
19
|
+
/** Type name for a resource type: `("machine", 1)` → `MachineV1`. */
|
|
20
|
+
export declare function resourceTypeName(type: string, version: number): string;
|
|
21
|
+
/**
|
|
22
|
+
* Neutralize free-form documentation text so it is safe inside a `/** … *\/`
|
|
23
|
+
* block: drop control characters (except newline/tab), collapse the comment
|
|
24
|
+
* terminator `*\/`, and bound the length. The result contains no sequence that
|
|
25
|
+
* can close the comment or start a new syntactic construct.
|
|
26
|
+
*/
|
|
27
|
+
export declare function sanitizeDoc(text: string): string;
|