@openpkg-ts/cli 0.11.3 → 0.12.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.
Files changed (3) hide show
  1. package/README.md +26 -5
  2. package/dist/index.js +280 -69
  3. package/package.json +3 -3
package/README.md CHANGED
@@ -5,9 +5,15 @@ Extract [OpenPkg](https://openpkg.dev) documents and generate docs from the comm
5
5
  ## Usage
6
6
 
7
7
  ```bash
8
- # Extract an OpenPkg spec
8
+ # Extract from an entry file
9
9
  bunx @openpkg-ts/cli spec src/index.ts -o openpkg.json
10
10
 
11
+ # Or resolve the package/entry from a dir, cwd, intent, or git URL
12
+ bunx @openpkg-ts/cli spec
13
+ bunx @openpkg-ts/cli spec .
14
+ bunx @openpkg-ts/cli spec . sdk
15
+ bunx @openpkg-ts/cli spec https://github.com/org/repo
16
+
11
17
  # Generate markdown docs (from source or an existing spec)
12
18
  bunx @openpkg-ts/cli docs src/index.ts -o docs/api.md
13
19
  bunx @openpkg-ts/cli docs openpkg.json -f html -o docs/api.html
@@ -19,16 +25,31 @@ bunx @openpkg-ts/cli list src/index.ts
19
25
  bunx @openpkg-ts/cli diff old.json new.json
20
26
  ```
21
27
 
28
+ Prefers TypeScript source (`src/index.ts`) over `dist/*.d.ts`. Several packages and no intent → prompt (or a list if not a TTY).
29
+
30
+ Opt-in Jev routing (needs `AI_GATEWAY_API_KEY` and the `ai` package). Sends package.json + file heads to Vercel AI Gateway with zero data retention:
31
+
32
+ ```bash
33
+ bunx @openpkg-ts/cli spec . --jev
34
+ bunx @openpkg-ts/cli spec . --jev --follow-external auto
35
+ ```
36
+
37
+ `followExternal: "auto"` (config or flag) requires `--jev`. Config: `openpkg.config.json` or `package.json#openpkg`.
38
+
39
+ ```json
40
+ { "followExternal": "auto", "decisions": "jev" }
41
+ ```
42
+
22
43
  ## Commands
23
44
 
24
45
  | Command | Description |
25
46
  |---------|-------------|
26
- | `spec <entry.ts>` | Extract an OpenPkg spec from a TypeScript entry point |
27
- | `docs <entry.ts \| spec.json>` | Generate docs (`-f md\|html\|json`) |
28
- | `list <entry.ts>` | List exports with kind and location (`--json`) |
47
+ | `spec [path \| entry.ts] [intent...]` | Extract a spec from a file, package dir, cwd, or git URL |
48
+ | `docs [path \| entry.ts \| spec.json] [intent...]` | Generate docs (`-f md\|html\|json`) |
49
+ | `list [path \| entry.ts] [intent...]` | List exports with kind and location (`--json`) |
29
50
  | `diff <old.json> <new.json>` | Compare specs; exits 2 if breaking changes |
30
51
 
31
- `-o, --output` writes to a file instead of stdout.
52
+ `-o, --output` writes to a file instead of stdout. `--jev` routes package/entry with Jev. `--follow-external auto` expands load-bearing externals (requires `--jev`).
32
53
 
33
54
  For programmatic use, richer options, and framework integrations (search indexes, nav trees), use `@openpkg-ts/sdk` directly.
34
55
 
package/dist/index.js CHANGED
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import fs from "node:fs";
5
- import path from "node:path";
4
+ import fs2 from "node:fs";
5
+ import path2 from "node:path";
6
+ import readline from "node:readline/promises";
6
7
  import { parseArgs } from "node:util";
7
8
  import {
8
9
  calculateNextVersion,
@@ -12,23 +13,99 @@ import {
12
13
  extractSpec,
13
14
  getAvailableVersions,
14
15
  getValidationErrors,
16
+ isPathLikeInput,
17
+ isRemoteInput,
15
18
  listExports,
16
19
  loadConfig,
17
20
  mergeConfig,
18
- recommendSemverBump
21
+ pickEntry,
22
+ recommendSemverBump,
23
+ resolveTarget
19
24
  } from "@openpkg-ts/sdk";
25
+
26
+ // src/env.ts
27
+ import fs from "node:fs";
28
+ import path from "node:path";
29
+ function parseEnvText(content) {
30
+ const out = {};
31
+ for (const rawLine of content.split(/\r?\n/)) {
32
+ let line = rawLine.trim();
33
+ if (!line || line.startsWith("#"))
34
+ continue;
35
+ if (line.startsWith("export "))
36
+ line = line.slice(7).trimStart();
37
+ const eq = line.indexOf("=");
38
+ if (eq <= 0)
39
+ continue;
40
+ const key = line.slice(0, eq).trim();
41
+ if (!key || /\s/.test(key))
42
+ continue;
43
+ let value = line.slice(eq + 1);
44
+ if (value.startsWith(" ") || value.startsWith("\t"))
45
+ value = value.trimStart();
46
+ if (value.startsWith('"')) {
47
+ let i = 1;
48
+ let parsed = "";
49
+ while (i < value.length) {
50
+ const ch = value[i];
51
+ if (ch === "\\" && i + 1 < value.length) {
52
+ const next = value[i + 1];
53
+ parsed += next === "n" ? `
54
+ ` : next === "r" ? "\r" : next === "t" ? "\t" : next;
55
+ i += 2;
56
+ continue;
57
+ }
58
+ if (ch === '"')
59
+ break;
60
+ parsed += ch;
61
+ i++;
62
+ }
63
+ out[key] = parsed;
64
+ continue;
65
+ }
66
+ if (value.startsWith("'")) {
67
+ const end = value.indexOf("'", 1);
68
+ out[key] = end === -1 ? value.slice(1) : value.slice(1, end);
69
+ continue;
70
+ }
71
+ const hash = value.search(/\s+#/);
72
+ out[key] = (hash === -1 ? value : value.slice(0, hash)).trim();
73
+ }
74
+ return out;
75
+ }
76
+ function parseIfExists(file) {
77
+ try {
78
+ if (!fs.existsSync(file))
79
+ return {};
80
+ return parseEnvText(fs.readFileSync(file, "utf8"));
81
+ } catch {
82
+ return {};
83
+ }
84
+ }
85
+ function loadCwdEnv(cwd = process.cwd()) {
86
+ const parsed = {
87
+ ...parseIfExists(path.join(cwd, ".env")),
88
+ ...parseIfExists(path.join(cwd, ".env.local"))
89
+ };
90
+ for (const [k, v] of Object.entries(parsed)) {
91
+ if (process.env[k] === undefined)
92
+ process.env[k] = v;
93
+ }
94
+ }
95
+
96
+ // src/index.ts
20
97
  var HELP = `openpkg - extract TypeScript API specs and generate docs
21
98
 
22
99
  Usage:
23
- openpkg spec <entry.ts> [-o spec.json] [--follow-external <pkg,...>]
24
- openpkg docs <entry.ts | spec.json> [-f md|html|json] [-o out]
25
- openpkg list <entry.ts> [--json]
100
+ openpkg spec [path | entry.ts] [intent...] [-o spec.json] [--follow-external <pkg,...>]
101
+ openpkg docs [path | entry.ts | spec.json] [intent...] [-f md|html|json] [-o out]
102
+ openpkg list [path | entry.ts] [intent...] [--json]
26
103
  openpkg validate <spec.json>
27
104
  openpkg diff <old.json> <new.json> [--json]
28
105
 
29
106
  Commands:
30
- spec Extract an OpenPkg spec from a TypeScript entry point
31
- docs Generate docs from an entry point or an existing spec file
107
+ spec Extract an OpenPkg spec (dir, cwd, or entry file)
108
+ docs Generate docs from a package, entry point, or spec file
32
109
  list List exports (name, kind, location)
33
110
  validate Validate a spec file against the OpenPkg meta-schema
34
111
  diff Compare two spec files and recommend a semver bump
@@ -38,32 +115,45 @@ Options:
38
115
  -f, --format docs output format: md (default), html, json
39
116
  --json list/diff output as JSON
40
117
  --follow-external Expand types from these packages (comma-separated,
41
- globs ok: "@ai-sdk/*"). Default: stub externals.
118
+ globs ok: "@ai-sdk/*", or "auto" with --jev).
119
+ Default: stub externals.
42
120
  --follow-external-all Expand every external package (use with care)
43
121
  --only Only extract these exports (comma-separated, * ok)
44
122
  --ignore Ignore these exports (comma-separated, * ok)
123
+ --jev Route package/entry with Jev (needs AI_GATEWAY_API_KEY)
45
124
  -h, --help Show this help
46
125
  -v, --version Show version
47
126
 
48
127
  Config: reads openpkg.config.json (or package.json "openpkg" field) from the
49
- cwd. Flags override the file. Example openpkg.config.json:
50
- { "followExternal": ["@acme/payment-kit", "@ai-sdk/*"] }
128
+ cwd. Flags override the file. Example:
129
+ { "followExternal": ["@ai-sdk/*"] }
130
+ { "followExternal": "auto", "decisions": "jev" }
51
131
  `;
52
- function fail(message) {
53
- console.error(`error: ${message}`);
54
- process.exit(1);
132
+
133
+ class CliError extends Error {
134
+ exitCode;
135
+ printed;
136
+ constructor(message, exitCode = 1, printed = false) {
137
+ super(message);
138
+ this.exitCode = exitCode;
139
+ this.printed = printed;
140
+ this.name = "CliError";
141
+ }
142
+ }
143
+ function fail(message, exitCode = 1) {
144
+ throw new CliError(message, exitCode);
55
145
  }
56
146
  function write(content, output) {
57
147
  if (output) {
58
- fs.mkdirSync(path.dirname(path.resolve(output)), { recursive: true });
59
- fs.writeFileSync(output, content);
148
+ fs2.mkdirSync(path2.dirname(path2.resolve(output)), { recursive: true });
149
+ fs2.writeFileSync(output, content);
60
150
  console.error(`wrote ${output}`);
61
151
  } else {
62
152
  console.log(content);
63
153
  }
64
154
  }
65
155
  function version() {
66
- const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
156
+ const pkg = JSON.parse(fs2.readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
67
157
  return pkg.version;
68
158
  }
69
159
  function reportDiagnostics(diagnostics) {
@@ -73,7 +163,87 @@ function reportDiagnostics(diagnostics) {
73
163
  }
74
164
  }
75
165
  if (diagnostics.some((d) => d.severity === "error")) {
76
- process.exit(1);
166
+ throw new CliError("", 1, true);
167
+ }
168
+ }
169
+ function parseTargetArgs(positionals, cwd) {
170
+ if (!positionals.length)
171
+ return { input: cwd };
172
+ const first = positionals[0];
173
+ const rest = positionals.slice(1).join(" ").trim();
174
+ if (isRemoteInput(first)) {
175
+ return { input: first, ...rest ? { intent: rest } : {} };
176
+ }
177
+ const abs = path2.resolve(cwd, first);
178
+ if (fs2.existsSync(abs) || isPathLikeInput(first)) {
179
+ return { input: abs, ...rest ? { intent: rest } : {} };
180
+ }
181
+ return { input: cwd, intent: positionals.join(" ") };
182
+ }
183
+ function formatPackages(candidates, cwd) {
184
+ return candidates.map((c, i) => {
185
+ const rel = path2.relative(cwd, c.dir) || ".";
186
+ return ` ${i + 1}. ${c.name} ${rel}`;
187
+ }).join(`
188
+ `);
189
+ }
190
+ async function choosePackage(candidates, cwd) {
191
+ const body = `multiple packages — pick one:
192
+ ${formatPackages(candidates, cwd)}`;
193
+ if (!process.stdin.isTTY || !process.stderr.isTTY) {
194
+ const hint = candidates[0]?.name.split("/").pop() ?? "sdk";
195
+ fail(`${body}
196
+ re-run with a path or intent, e.g. openpkg spec . ${hint}`);
197
+ }
198
+ console.error(body);
199
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
200
+ try {
201
+ const answer = await rl.question(`Package [1-${candidates.length}]: `);
202
+ const i = Number(answer.trim());
203
+ if (!Number.isInteger(i) || i < 1 || i > candidates.length)
204
+ fail("invalid selection");
205
+ return candidates[i - 1];
206
+ } finally {
207
+ rl.close();
208
+ }
209
+ }
210
+ async function resolveCliTarget(positionals, decisions) {
211
+ loadCwdEnv();
212
+ const cwd = process.cwd();
213
+ const { input, intent } = parseTargetArgs(positionals, cwd);
214
+ const resolved = await resolveTarget({ input, intent, cwd, decisions });
215
+ const cleanup = resolved.cleanup;
216
+ try {
217
+ if (resolved.kind === "unavailable")
218
+ fail(resolved.reason);
219
+ if (resolved.kind === "empty")
220
+ fail(resolved.reason);
221
+ if (resolved.kind === "needs-build") {
222
+ fail(resolved.command ? `${resolved.reason}
223
+ → ${resolved.command}` : resolved.reason, 2);
224
+ }
225
+ if (resolved.kind === "explicit") {
226
+ return {
227
+ entryFile: resolved.entryFile,
228
+ entryPointSource: resolved.entryPointSource,
229
+ cleanup
230
+ };
231
+ }
232
+ if (resolved.kind === "ok") {
233
+ return {
234
+ entryFile: resolved.entryFile,
235
+ entryPointSource: resolved.entryPointSource,
236
+ cleanup
237
+ };
238
+ }
239
+ const chosen = await choosePackage(resolved.candidates, cwd);
240
+ const picked = pickEntry(chosen.dir);
241
+ if (!picked)
242
+ fail(`no TypeScript entry found in ${chosen.name}`);
243
+ return { ...picked, cleanup };
244
+ } catch (err) {
245
+ cleanup?.();
246
+ throw err;
77
247
  }
78
248
  }
79
249
  function toList(value) {
@@ -82,6 +252,15 @@ function toList(value) {
82
252
  const items = value.split(",").map((s) => s.trim()).filter(Boolean);
83
253
  return items.length > 0 ? items : undefined;
84
254
  }
255
+ function parseFollowExternal(value, all) {
256
+ if (all)
257
+ return true;
258
+ if (!value)
259
+ return;
260
+ if (value.trim() === "auto")
261
+ return "auto";
262
+ return toList(value);
263
+ }
85
264
  function reportStubbedExternals(spec) {
86
265
  const counts = new Map;
87
266
  for (const t of spec.types ?? []) {
@@ -105,86 +284,112 @@ async function specCommand(args) {
105
284
  "follow-external": { type: "string" },
106
285
  "follow-external-all": { type: "boolean" },
107
286
  only: { type: "string" },
108
- ignore: { type: "string" }
287
+ ignore: { type: "string" },
288
+ jev: { type: "boolean" }
109
289
  },
110
290
  allowPositionals: true
111
291
  });
112
- const entryFile = positionals[0];
113
- if (!entryFile)
114
- fail("spec requires an entry file (openpkg spec src/index.ts)");
115
292
  const fileConfig = loadConfig(process.cwd());
116
293
  const cliConfig = {
117
- followExternal: values["follow-external-all"] ? true : toList(values["follow-external"]),
294
+ followExternal: parseFollowExternal(values["follow-external"], values["follow-external-all"]),
118
295
  only: toList(values.only),
119
- ignore: toList(values.ignore)
296
+ ignore: toList(values.ignore),
297
+ ...values.jev ? { decisions: "jev" } : {}
120
298
  };
121
- const config = mergeConfig(fileConfig, cliConfig);
122
- const { spec, diagnostics } = await extractSpec({
123
- entryFile,
124
- followExternal: config.followExternal,
125
- only: config.only,
126
- ignore: config.ignore,
127
- externals: config.externals
128
- });
129
- reportDiagnostics(diagnostics);
130
- if (!config.followExternal)
131
- reportStubbedExternals(spec);
132
- write(JSON.stringify(spec, null, 2), values.output);
299
+ let cleanup;
300
+ try {
301
+ const resolved = await resolveCliTarget(positionals, cliConfig.decisions ?? fileConfig?.decisions);
302
+ cleanup = resolved.cleanup;
303
+ const { entryFile, entryPointSource } = resolved;
304
+ const config = mergeConfig(fileConfig, cliConfig);
305
+ if (config.followExternal === "auto" && config.decisions !== "jev") {
306
+ fail("followExternal auto requires --jev");
307
+ }
308
+ const { spec, diagnostics } = await extractSpec({
309
+ entryFile,
310
+ entryPointSource,
311
+ followExternal: config.followExternal,
312
+ only: config.only,
313
+ ignore: config.ignore,
314
+ externals: config.externals,
315
+ decisions: config.decisions
316
+ });
317
+ reportDiagnostics(diagnostics);
318
+ if (!config.followExternal)
319
+ reportStubbedExternals(spec);
320
+ write(JSON.stringify(spec, null, 2), values.output);
321
+ } finally {
322
+ cleanup?.();
323
+ }
133
324
  }
134
325
  async function docsCommand(args) {
135
326
  const { values, positionals } = parseArgs({
136
327
  args,
137
328
  options: {
138
329
  output: { type: "string", short: "o" },
139
- format: { type: "string", short: "f" }
330
+ format: { type: "string", short: "f" },
331
+ jev: { type: "boolean" }
140
332
  },
141
333
  allowPositionals: true
142
334
  });
143
- const input = positionals[0];
144
- if (!input)
145
- fail("docs requires an entry file or spec file (openpkg docs src/index.ts)");
146
335
  const format = values.format ?? "md";
147
336
  if (!["md", "html", "json"].includes(format))
148
337
  fail(`unknown format "${format}" (md|html|json)`);
149
338
  let docs;
150
- if (input.endsWith(".json")) {
151
- docs = createDocs(input);
152
- } else {
153
- const { spec, diagnostics } = await extractSpec({ entryFile: input });
154
- reportDiagnostics(diagnostics);
155
- docs = createDocs(spec);
339
+ let cleanup;
340
+ try {
341
+ if (positionals[0]?.endsWith(".json")) {
342
+ docs = createDocs(positionals[0]);
343
+ } else {
344
+ const decisions = values.jev ? "jev" : loadConfig(process.cwd())?.decisions;
345
+ const resolved = await resolveCliTarget(positionals, decisions);
346
+ cleanup = resolved.cleanup;
347
+ const { spec, diagnostics } = await extractSpec({
348
+ entryFile: resolved.entryFile,
349
+ entryPointSource: resolved.entryPointSource
350
+ });
351
+ reportDiagnostics(diagnostics);
352
+ docs = createDocs(spec);
353
+ }
354
+ const content = format === "md" ? docs.toMarkdown() : format === "html" ? docs.toHTML() : JSON.stringify(docs.toJSON(), null, 2);
355
+ write(content, values.output);
356
+ } finally {
357
+ cleanup?.();
156
358
  }
157
- const content = format === "md" ? docs.toMarkdown() : format === "html" ? docs.toHTML() : JSON.stringify(docs.toJSON(), null, 2);
158
- write(content, values.output);
159
359
  }
160
360
  async function listCommand(args) {
161
361
  const { values, positionals } = parseArgs({
162
362
  args,
163
- options: { json: { type: "boolean" } },
363
+ options: { json: { type: "boolean" }, jev: { type: "boolean" } },
164
364
  allowPositionals: true
165
365
  });
166
- const entryFile = positionals[0];
167
- if (!entryFile)
168
- fail("list requires an entry file (openpkg list src/index.ts)");
169
- const { exports, errors } = await listExports({ entryFile });
170
- for (const err of errors) {
171
- console.error(`error: ${err}`);
172
- }
173
- if (errors.length > 0 && exports.length === 0) {
174
- process.exit(1);
175
- }
176
- if (values.json) {
177
- console.log(JSON.stringify(exports, null, 2));
178
- return;
179
- }
180
- for (const exp of exports) {
181
- const location = exp.file ? ` (${exp.file}:${exp.line})` : "";
182
- console.log(`${exp.kind.padEnd(10)}${exp.name}${location}`);
366
+ const decisions = values.jev ? "jev" : loadConfig(process.cwd())?.decisions;
367
+ let cleanup;
368
+ try {
369
+ const resolved = await resolveCliTarget(positionals, decisions);
370
+ cleanup = resolved.cleanup;
371
+ const { exports, errors } = await listExports({ entryFile: resolved.entryFile });
372
+ for (const err of errors) {
373
+ console.error(`error: ${err}`);
374
+ }
375
+ if (errors.length > 0 && exports.length === 0) {
376
+ throw new CliError("", 1, true);
377
+ }
378
+ if (values.json) {
379
+ console.log(JSON.stringify(exports, null, 2));
380
+ return;
381
+ }
382
+ for (const exp of exports) {
383
+ const location = exp.file ? ` (${exp.file}:${exp.line})` : "";
384
+ console.log(`${exp.kind.padEnd(10)}${exp.name}${location}`);
385
+ }
386
+ } finally {
387
+ cleanup?.();
183
388
  }
184
389
  }
185
390
  function readSpecFile(file) {
186
391
  try {
187
- return JSON.parse(fs.readFileSync(file, "utf-8"));
392
+ return JSON.parse(fs2.readFileSync(file, "utf-8"));
188
393
  } catch (err) {
189
394
  fail(`failed to read spec file ${file}: ${err instanceof Error ? err.message : String(err)}`);
190
395
  }
@@ -220,7 +425,7 @@ function validateCommand(args) {
220
425
  for (const e of errors) {
221
426
  console.error(`${e.instancePath || "/"} ${e.message}`);
222
427
  }
223
- process.exit(1);
428
+ throw new CliError("", 1, true);
224
429
  }
225
430
  function diffCommand(args) {
226
431
  const { values, positionals } = parseArgs({
@@ -302,5 +507,11 @@ async function main() {
302
507
  }
303
508
  }
304
509
  main().catch((err) => {
305
- fail(err instanceof Error ? err.message : String(err));
510
+ if (err instanceof CliError) {
511
+ if (!err.printed && err.message)
512
+ console.error(`error: ${err.message}`);
513
+ process.exit(err.exitCode);
514
+ }
515
+ console.error(`error: ${err instanceof Error ? err.message : String(err)}`);
516
+ process.exit(1);
306
517
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpkg-ts/cli",
3
- "version": "0.11.3",
3
+ "version": "0.12.1",
4
4
  "description": "CLI for OpenPkg - extract TypeScript API specs and generate docs",
5
5
  "keywords": [
6
6
  "openpkg",
@@ -31,11 +31,11 @@
31
31
  "lint": "biome check src/",
32
32
  "lint:fix": "biome check --write src/",
33
33
  "format": "biome format --write src/",
34
- "typecheck": "tsc --noEmit -p .",
34
+ "typecheck": "tsc --noEmit --types bun -p .",
35
35
  "test": "bun test"
36
36
  },
37
37
  "dependencies": {
38
- "@openpkg-ts/sdk": "^0.51.0"
38
+ "@openpkg-ts/sdk": "^0.52.2"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@types/bun": "latest",