@dbx-tools/cli 0.1.112 → 0.3.2

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.
@@ -1,1510 +0,0 @@
1
- import { createRequire } from "node:module";
2
- import { consola } from "consola";
3
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
- import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
5
- import { generate } from "ts-to-zod";
6
- import ts from "typescript";
7
- import { PacwichError, createFileSystemProject } from "pacwich";
8
- import pMemoize from "p-memoize";
9
- import { getWorkspaceScriptMetadata } from "pacwich/script";
10
- import { $ } from "bun";
11
- import semver from "semver";
12
-
13
- //#region packages/cli/src/project.ts
14
- const getProject = pMemoize(async () => {
15
- const options = { packageManager: process.env.PACWICH_PACKAGE_MANAGER ?? "bun" };
16
- const projectPath = readProjectPath();
17
- if (projectPath) options.rootDirectory = projectPath;
18
- const project = createFileSystemProject(options);
19
- const shell = process.env.PACWICH_SHELL_DEFAULT;
20
- if (shell) project.config.project.defaults.shell = shell;
21
- return project;
22
- });
23
- /**
24
- * Project root pacwich reports when invoked under `pacwich run`.
25
- * Returns undefined when run directly (no workspace-script context), in
26
- * which case pacwich discovers the root from the cwd instead. A
27
- * {@link PacwichError} means "no metadata available" and is swallowed;
28
- * anything else propagates.
29
- */
30
- function readProjectPath() {
31
- try {
32
- return getWorkspaceScriptMetadata("projectPath") || void 0;
33
- } catch (error) {
34
- if (error instanceof PacwichError) return void 0;
35
- throw error;
36
- }
37
- }
38
-
39
- //#endregion
40
- //#region packages/cli/src/script.ts
41
- /** Log `message` and exit non-zero. */
42
- function fail(message) {
43
- consola.error(message);
44
- process.exit(1);
45
- }
46
- /** Narrow an unknown thrown value to its message string. */
47
- function errorMessage(err) {
48
- return err instanceof Error ? err.message : String(err);
49
- }
50
- /** Split text on newlines, trimming each line and dropping blanks. */
51
- function nonEmptyLines(text) {
52
- return text.split("\n").map((line) => line.trim()).filter(Boolean);
53
- }
54
- /**
55
- * Run a script across the workspaces, streaming each output chunk under
56
- * its workspace tag and returning the run summary. A bare identifier in
57
- * `script` (e.g. `build`) is run as that workspace's `package.json`
58
- * script; anything containing whitespace is treated as an inline shell
59
- * command (run through Bun's shell by default).
60
- */
61
- async function runScript(options) {
62
- const project = await getProject();
63
- const inline = !/^[\w.-]+$/.test(options.script.trim()) ? typeof options.inline === "object" ? options.inline : { shell: "bun" } : void 0;
64
- const { output, summary } = project.runScriptAcrossWorkspaces({
65
- ...options,
66
- ...inline ? { inline } : {}
67
- });
68
- for await (const { chunk, metadata } of output.text()) consola.withTag(metadata.workspace.name).log(chunk.trimEnd());
69
- return summary;
70
- }
71
-
72
- //#endregion
73
- //#region packages/cli/src/shell.ts
74
- /**
75
- * Run a command, streaming live unless `quiet`. Returns trimmed captured
76
- * output regardless. Throws on non-zero unless `nothrow`.
77
- */
78
- async function sh(args, opts = {}) {
79
- let cmd = (opts.input !== void 0 ? $`${args} < ${new Response(opts.input)}` : $`${args}`).nothrow();
80
- if (opts.quiet) cmd = cmd.quiet();
81
- if (opts.cwd) cmd = cmd.cwd(opts.cwd);
82
- const res = await cmd;
83
- const stdout = res.stdout.toString().trim();
84
- const stderr = res.stderr.toString().trim();
85
- if (!opts.nothrow && res.exitCode !== 0) {
86
- const detail = stderr || stdout;
87
- throw new Error(`\`${args.join(" ")}\` failed (exit ${res.exitCode})${detail ? `: ${detail}` : ""}`);
88
- }
89
- return {
90
- exitCode: res.exitCode,
91
- stdout,
92
- stderr
93
- };
94
- }
95
- /** `bun x <args>` for one-off CLI tools (knip, syncpack, prettier, ...). */
96
- function bunx(args, opts = {}) {
97
- return sh([
98
- "bun",
99
- "x",
100
- ...args
101
- ], opts);
102
- }
103
-
104
- //#endregion
105
- //#region packages/cli/src/git.ts
106
- /**
107
- * Run `git <args>`, returning trimmed output. Quiet by default since git is
108
- * used mostly for its stdout (rev-parse, log, diff, ...); pass `quiet: false`
109
- * to stream a mutating op live. Throws on non-zero unless `nothrow`.
110
- */
111
- function git(args, opts = {}) {
112
- return sh(["git", ...args], {
113
- quiet: true,
114
- ...opts
115
- });
116
- }
117
- /**
118
- * True when the `git` executable is resolvable on PATH. The PATH lookup
119
- * is stable for the process, so memoize it - callers (`isGitRepo`,
120
- * `requireGitRepo`, codegen's per-file ignore check) hit this repeatedly.
121
- */
122
- let gitAvailable;
123
- function hasGit() {
124
- return gitAvailable ??= Boolean(Bun.which("git"));
125
- }
126
- /**
127
- * True when `cwd` (default: the process cwd) is inside a git work tree.
128
- * Returns false when git isn't installed, so callers can use it as a
129
- * single "can I use git here?" gate.
130
- */
131
- async function isGitRepo(cwd) {
132
- if (!hasGit()) return false;
133
- const { exitCode, stdout } = await git(["rev-parse", "--is-inside-work-tree"], {
134
- nothrow: true,
135
- quiet: true,
136
- cwd
137
- });
138
- return exitCode === 0 && stdout === "true";
139
- }
140
- /**
141
- * Assert git is installed and `cwd` is inside a git repo, aborting with a
142
- * clear message otherwise. For commands that genuinely need version
143
- * control (commit, tag, push); optional callers should use {@link hasGit}
144
- * / {@link isGitRepo} and skip instead of failing.
145
- */
146
- async function requireGitRepo(caller, cwd) {
147
- if (!hasGit()) fail(`${caller} requires git, but no \`git\` executable was found on PATH.`);
148
- if (!await isGitRepo(cwd)) fail(`${caller} must be run inside a git repository.`);
149
- }
150
-
151
- //#endregion
152
- //#region packages/cli/src/package.ts
153
- const root = (await getProject()).rootDirectory;
154
- /** A workspace package: its parsed manifest, location, and dependency edges. */
155
- var WorkspacePackage = class WorkspacePackage {
156
- dir;
157
- slug;
158
- jsonPath;
159
- constructor(meta, dir) {
160
- this.meta = meta;
161
- this.dir = dir;
162
- this.slug = relative(root, dir);
163
- this.jsonPath = join(dir, "package.json");
164
- }
165
- /** Build from a pacwich {@link Workspace}, reading its manifest. */
166
- static async fromWorkspace(ws) {
167
- const dir = resolve(root, ws.path);
168
- return new WorkspacePackage(await Bun.file(join(dir, "package.json")).json(), dir);
169
- }
170
- };
171
- /** Resolve `path` against the repo root. */
172
- function toAbsolute(path) {
173
- return isAbsolute(path) ? path : resolve(root, path);
174
- }
175
- /** Repo-relative form of `path`, or absolute when it sits outside the root. */
176
- function toRelative(path) {
177
- const rel = relative(root, path);
178
- return rel !== "" && !rel.startsWith("..") && !rel.startsWith("/") && !rel.startsWith("\\") ? rel : resolve(path);
179
- }
180
- /** Yield every workspace `package.json` path (`includeRoot` prepends the root manifest). */
181
- async function* discoverPackageJsons(includeRoot = false) {
182
- const project = await getProject();
183
- if (includeRoot) yield resolve(root, project.rootWorkspace.path, "package.json");
184
- for (const ws of project.workspaces) yield resolve(root, ws.path, "package.json");
185
- }
186
- /** Workspace packages passing `filter` (default: non-private), sorted by slug. */
187
- async function discoverPackages(filter = (pkg) => pkg.meta.private !== true) {
188
- const project = await getProject();
189
- return (await Promise.all(project.workspaces.map((ws) => WorkspacePackage.fromWorkspace(ws)))).filter(filter).sort((a, b) => a.slug.localeCompare(b.slug));
190
- }
191
- /** Write `value` as JSON, preserving the file's trailing newline to avoid format churn. */
192
- async function writeJson(path, value) {
193
- const file = Bun.file(path);
194
- const trailingNewline = await file.exists() ? (await file.text()).endsWith("\n") : true;
195
- await Bun.write(path, JSON.stringify(value, null, 2) + (trailingNewline ? "\n" : ""));
196
- }
197
-
198
- //#endregion
199
- //#region packages/cli/src/codegen.ts
200
- const HEADER = [
201
- "// AUTO-GENERATED by `dbxtools codegen` - DO NOT EDIT.",
202
- "// Regenerate via `dbxtools codegen`.",
203
- ""
204
- ].join("\n");
205
- const GENERATED_DIRNAME = "generated";
206
- function deriveName(path) {
207
- const file = basename(path);
208
- if (file === "model.d.ts" || file === "model.ts") return basename(dirname(path));
209
- return file.replace(/\.d\.ts$|\.ts$/, "");
210
- }
211
- function parseInputArg(value) {
212
- const eq = value.indexOf("=");
213
- if (eq === -1) return {
214
- source: value,
215
- name: deriveName(value)
216
- };
217
- return {
218
- source: value.slice(0, eq),
219
- name: value.slice(eq + 1)
220
- };
221
- }
222
- /**
223
- * Resolve a codegen input to an absolute path. A `node_modules/...`
224
- * source is searched for in each `node_modules` from the consuming
225
- * package up to the filesystem root, so it resolves whether the
226
- * dependency is nested under the package or hoisted to the workspace
227
- * root - the package manager's hoisting layout doesn't matter as long
228
- * as the consuming package declares the dependency. Any other source
229
- * (and the not-found fallback) is treated as repo-root-relative.
230
- */
231
- function resolveInputSource(source, fromDir) {
232
- if (source.startsWith("node_modules/")) {
233
- let dir = fromDir;
234
- for (;;) {
235
- const candidate = resolve(dir, source);
236
- if (existsSync(candidate)) return candidate;
237
- const parent = dirname(dir);
238
- if (parent === dir) break;
239
- dir = parent;
240
- }
241
- }
242
- return toAbsolute(source);
243
- }
244
- /**
245
- * Parse `entryPath` as TypeScript, drop every `import`
246
- * declaration, and rewrite any type reference whose root
247
- * identifier was introduced by one of those imports to the
248
- * `unknown` keyword. ts-to-zod sees a self-contained source where
249
- * the dropped peer modules surface as `z.unknown()` schemas.
250
- *
251
- * Two reference shapes get stubbed:
252
- *
253
- * - `ns.X` (a `QualifiedName` in type position, or a
254
- * `PropertyAccessExpression` in value position) where `ns` was
255
- * introduced by `import * as ns from "..."`.
256
- * - bare `X` where `X` was introduced by a default or named
257
- * import (`import X from "..."`,
258
- * `import { X } from "..."`, `import { Y as X } from "..."`).
259
- *
260
- * Comments and the rest of the file flow through unchanged.
261
- */
262
- function stripImports(entryPath) {
263
- const text = readFileSync(entryPath, "utf-8");
264
- const sf = ts.createSourceFile(entryPath, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
265
- const namespaceAliases = /* @__PURE__ */ new Set();
266
- const importedNames = /* @__PURE__ */ new Set();
267
- for (const stmt of sf.statements) {
268
- if (!ts.isImportDeclaration(stmt) || !stmt.importClause) continue;
269
- const c = stmt.importClause;
270
- if (c.name) importedNames.add(c.name.text);
271
- const nb = c.namedBindings;
272
- if (!nb) continue;
273
- if (ts.isNamespaceImport(nb)) namespaceAliases.add(nb.name.text);
274
- else for (const el of nb.elements) importedNames.add(el.name.text);
275
- }
276
- const unknownType = () => ts.factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword);
277
- const transformer = (context) => (root) => {
278
- const visitor = (node) => {
279
- if (ts.isImportDeclaration(node)) return void 0;
280
- if (ts.isTypeReferenceNode(node)) {
281
- const tn = node.typeName;
282
- if (ts.isQualifiedName(tn) && ts.isIdentifier(tn.left) && namespaceAliases.has(tn.left.text)) return unknownType();
283
- if (ts.isIdentifier(tn) && importedNames.has(tn.text)) return unknownType();
284
- }
285
- if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression) && namespaceAliases.has(node.expression.text)) return node.name;
286
- return ts.visitEachChild(node, visitor, context);
287
- };
288
- return ts.visitEachChild(root, visitor, context);
289
- };
290
- const result = ts.transform(sf, [transformer]);
291
- const printer = ts.createPrinter({ removeComments: false });
292
- const transformed = result.transformed[0] ?? sf;
293
- const out = printer.printFile(transformed);
294
- result.dispose();
295
- return out;
296
- }
297
- /**
298
- * Final shaping of the source before ts-to-zod sees it:
299
- *
300
- * 1. Promote every top-level `interface` / `type` to an `export`.
301
- * ts-to-zod only emits schemas for exported declarations and
302
- * bails on exported types that reference non-exported ones,
303
- * so private SDK shells (`AuthorizationDetails`,
304
- * `CronSchedule`, ...) need to flip to exports for the
305
- * generator to see them.
306
- * 2. Rewrite each JSDoc block so its leading prose becomes a
307
- * single `@description` tag. ts-to-zod parses the tag and
308
- * emits a matching `.describe(...)` call on the schema field
309
- * (introspectable via `schema.description`, picked up by
310
- * `z.toJSONSchema`). The original prose is dropped so we
311
- * don't see the same paragraph twice (once as the comment
312
- * body, once as the `@description` tag that
313
- * `keepComments: true` would otherwise carry through). Other
314
- * tags (`@minimum`, `@format`, ...) flow through verbatim.
315
- */
316
- function preprocess(source) {
317
- return source.replace(/^(interface|type)\s/gm, "export $1 ").replace(/\/\*\*([\s\S]*?)\*\//g, (match, body) => {
318
- if (/@description\b/.test(body)) return match;
319
- const lines = body.replace(/^\n/, "").split("\n").map((line) => line.replace(/^\s*\*\s?/, "").replace(/\s+$/, ""));
320
- const firstTagIdx = lines.findIndex((line) => /^@\w+/.test(line));
321
- const descLines = firstTagIdx === -1 ? lines : lines.slice(0, firstTagIdx);
322
- const tagLines = firstTagIdx === -1 ? [] : lines.slice(firstTagIdx);
323
- const description = descLines.join(" ").replace(/\s+/g, " ").trim();
324
- if (!description) return match;
325
- return `/**\n${[`@description ${description}`, ...tagLines].map((line) => ` * ${line}`.trimEnd()).join("\n")}\n */`;
326
- });
327
- }
328
- /**
329
- * Take ts-to-zod's `getInferredTypes(...)` output - shaped for a
330
- * separate file (banner, `import { z } from "zod"`,
331
- * `import * as generated from "<schemas>"`, then
332
- * `export type X = z.infer<typeof generated.xSchema>` lines) - and
333
- * rewrite it for inclusion in the same file as the schemas:
334
- *
335
- * - drop the banner and both imports (`z` is already imported,
336
- * the schemas live right above)
337
- * - drop the `generated.` namespace prefix from every reference
338
- * so the type aliases bind to the colocated schema constants
339
- */
340
- function inlineInferredTypes(inferredFile) {
341
- return inferredFile.replace(/^\/\/ Generated by ts-to-zod\s*\n/, "").replace(/^import \{ z \} from "zod";\s*\n+/m, "").replace(/^import \* as generated from "[^"]*";\s*\n+/m, "").replace(/\bgenerated\.(\w+)/g, "$1").trim();
342
- }
343
- /**
344
- * Whether `git check-ignore` reports `absPath` as covered by an
345
- * ignore rule. One subprocess per call - cheap enough for the
346
- * handful of files codegen touches and avoids parsing `.gitignore`
347
- * patterns ourselves.
348
- *
349
- * Exit codes:
350
- * - `0`: path is ignored.
351
- * - `1`: path is not ignored.
352
- * - anything else (e.g. `128` outside a worktree): treated as a
353
- * hard error - we can't safely decide, so refuse.
354
- */
355
- async function isGitIgnored(absPath) {
356
- if (!hasGit()) fail(`cannot verify ${toRelative(absPath)} is gitignored: no \`git\` executable on PATH. codegen needs git to confirm it only overwrites files under \`generated/\`. Install git, or move the file out of \`generated/\` before regenerating.`);
357
- const { exitCode } = await git([
358
- "check-ignore",
359
- "--quiet",
360
- absPath
361
- ], { nothrow: true });
362
- if (exitCode === 0) return true;
363
- if (exitCode === 1) return false;
364
- fail(`\`git check-ignore\` failed (exit ${exitCode}) for ${toRelative(absPath)}; is the workspace inside a git worktree?`);
365
- }
366
- /**
367
- * Refuse to clobber any file in `generated/` that exists and isn't
368
- * already gitignored. The gitignore is the codegen contract: every
369
- * file under `generated/` is owned by codegen, every file outside
370
- * is owned by the developer. Run before any write or delete inside
371
- * `generated/`.
372
- */
373
- async function assertWritable(absPath) {
374
- if (!existsSync(absPath)) return;
375
- if (await isGitIgnored(absPath)) return;
376
- fail(`refusing to overwrite ${toRelative(absPath)}: file exists and is not gitignored. Move it out of \`generated/\` (codegen output) or update the ignore before regenerating.`);
377
- }
378
- /**
379
- * Regenerate the `generated/` tree for one consumer package. The
380
- * package's `package.json` is read-only - the codegen `inputs`
381
- * list comes out, but nothing flows back. Consumers expose the
382
- * generated tree via a hand-written top-level `index.ts`
383
- * re-export.
384
- */
385
- async function generatePackage(pkg) {
386
- const config = pkg.meta.codegen;
387
- if (!config?.inputs?.length) fail(`${pkg.slug}: \`codegen.inputs\` is missing or empty`);
388
- const inputs = config.inputs.map(parseInputArg);
389
- const generatedDir = resolve(pkg.dir, GENERATED_DIRNAME);
390
- if (existsSync(generatedDir)) {
391
- const glob = new Bun.Glob("**/*");
392
- for await (const f of glob.scan({
393
- cwd: generatedDir,
394
- absolute: true,
395
- onlyFiles: true,
396
- dot: true
397
- })) await assertWritable(f);
398
- }
399
- rmSync(generatedDir, {
400
- recursive: true,
401
- force: true
402
- });
403
- mkdirSync(generatedDir, { recursive: true });
404
- await Bun.write(resolve(generatedDir, ".gitignore"), "*\n");
405
- const indexLines = [];
406
- let warnings = 0;
407
- for (const input of inputs) {
408
- const sourcePath = resolveInputSource(input.source, pkg.dir);
409
- if (!existsSync(sourcePath)) fail(`${pkg.slug}: codegen input not found: ${input.source}`);
410
- consola.log(` ${input.name} <- ${input.source}`);
411
- const { getZodSchemasFile, getInferredTypes, errors } = generate({
412
- sourceText: preprocess(stripImports(sourcePath)),
413
- keepComments: true
414
- });
415
- if (errors.length) {
416
- warnings += errors.length;
417
- for (const err of errors) consola.warn(` ! ${err}`);
418
- }
419
- const importPath = `./${input.name}.zod.js`;
420
- const schemas = getZodSchemasFile(importPath);
421
- const inferred = inlineInferredTypes(getInferredTypes(importPath));
422
- const content = HEADER + schemas.trimEnd() + "\n\n" + inferred + "\n";
423
- await Bun.write(resolve(generatedDir, `${input.name}.zod.ts`), content);
424
- indexLines.push(`export * from "./${input.name}.zod.js";`);
425
- }
426
- await Bun.write(resolve(generatedDir, "index.ts"), HEADER + indexLines.join("\n") + "\n");
427
- consola.log(`${pkg.meta.name ?? pkg.slug}: ${inputs.length} module(s) -> ${toRelative(generatedDir)}/` + (warnings ? ` (${warnings} warning(s))` : ""));
428
- }
429
- /** Regenerate the `generated/` tree for every package declaring a `codegen` field. */
430
- async function codegen() {
431
- const targets = (await discoverPackages(() => true)).filter((pkg) => pkg.meta.codegen);
432
- if (targets.length === 0) {
433
- consola.log("codegen: no workspace packages declare a `codegen` field");
434
- return;
435
- }
436
- for (const pkg of targets) await generatePackage(pkg);
437
- }
438
-
439
- //#endregion
440
- //#region packages/cli/src/build.ts
441
- /** Compile every publishable package with the shared tsdown config. */
442
- async function build() {
443
- await codegen();
444
- const targets = await discoverPackages();
445
- if (targets.length === 0) fail("No publishable packages found under packages/");
446
- const configPath = toAbsolute("tsdown.config.ts");
447
- consola.log(`=== Building ${targets.length} package(s) ===`);
448
- const summary = await runScript({
449
- script: `bun x --bun tsdown --config ${configPath}`,
450
- workspacePatterns: targets.map((pkg) => pkg.meta.name),
451
- dependencyOrder: true
452
- });
453
- if (!summary.allSuccess) fail(`Build failed: ${summary.scriptResults.filter((entry) => !entry.success && !entry.skipped).map((entry) => entry.metadata.workspace.name).join(", ")}`);
454
- consola.log(`Built ${summary.successCount} package(s).`);
455
- }
456
-
457
- //#endregion
458
- //#region packages/cli/src/agent.ts
459
- /**
460
- * Run Codex headlessly via `ucode codex exec` for `dbxtools agent` and
461
- * release tooling that drafts notes programmatically.
462
- */
463
- /** Default wall-clock budget for a Codex invocation. */
464
- const AGENT_DEFAULT_TIMEOUT_MS = 3e5;
465
- /** Whether `ucode codex --version` reports a usable Codex CLI. */
466
- async function agentAvailable() {
467
- if (!Bun.which("ucode")) return false;
468
- const result = await sh([
469
- "ucode",
470
- "codex",
471
- "--version"
472
- ], {
473
- nothrow: true,
474
- quiet: true
475
- });
476
- return result.exitCode === 0 && /codex-cli\s+\S+/.test(result.stdout);
477
- }
478
- /** Whether an exit code looks like the process was killed on a timeout. */
479
- function agentTimedOut(exitCode) {
480
- return exitCode === 143 || exitCode === 137;
481
- }
482
- /** argv for `ucode codex exec <prompt>`. */
483
- function agentExecArgs(prompt) {
484
- return [
485
- "ucode",
486
- "codex",
487
- "exec",
488
- "--yolo",
489
- prompt
490
- ];
491
- }
492
- /** Pull assistant prose out of `ucode codex exec` stdout. */
493
- function parseCodexStdout(stdout) {
494
- const startIdx = stdout.lastIndexOf("✔ Starting Codex\n");
495
- if (startIdx >= 0) return stdout.slice(startIdx + 17).trim();
496
- const codexIdx = stdout.lastIndexOf("\ncodex\n");
497
- if (codexIdx >= 0) {
498
- const after = stdout.slice(codexIdx + 7);
499
- const end = after.search(/\n(?:tokens used\b)/);
500
- return (end < 0 ? after : after.slice(0, end)).trim();
501
- }
502
- return stdout.trim();
503
- }
504
- /**
505
- * Run `ucode codex exec` and return captured output. Throws when Codex
506
- * is absent or the wall-clock budget is exceeded. On a non-zero exit
507
- * the result is still returned so callers can use partial text.
508
- */
509
- async function runAgent(prompt, opts = {}) {
510
- if (!await agentAvailable()) throw new Error("ucode codex: CLI not available (run `ucode codex --version`)");
511
- const timeoutMs = opts.timeoutMs ?? AGENT_DEFAULT_TIMEOUT_MS;
512
- const args = agentExecArgs(prompt);
513
- if (!(opts.capture !== false)) return {
514
- text: "",
515
- exitCode: await Bun.spawn(args, {
516
- cwd: opts.cwd,
517
- stdin: Bun.file("/dev/null"),
518
- stdout: "inherit",
519
- stderr: "inherit",
520
- signal: AbortSignal.timeout(timeoutMs)
521
- }).exited,
522
- stderr: ""
523
- };
524
- const result = await withTimeout(sh(args, {
525
- cwd: opts.cwd,
526
- nothrow: true,
527
- quiet: true,
528
- input: ""
529
- }), timeoutMs);
530
- const text = parseCodexStdout(result.stdout);
531
- if (text && opts.echo !== false) {
532
- process.stdout.write(text);
533
- process.stdout.write("\n");
534
- }
535
- return {
536
- text,
537
- exitCode: result.exitCode,
538
- stderr: result.stderr.trim()
539
- };
540
- }
541
- /**
542
- * `dbxtools agent` entry: run Codex and return a process exit code (0 on
543
- * success, 1 on failure or timeout).
544
- */
545
- async function agent(opts) {
546
- if (!await agentAvailable()) fail("ucode codex not available (install ucode and run `ucode codex --version`)");
547
- const prompt = opts.prompt.trim();
548
- if (!prompt) fail("prompt required (pass as arguments or pipe via stdin)");
549
- const timeoutMs = opts.timeoutMs ?? AGENT_DEFAULT_TIMEOUT_MS;
550
- consola.log(`Running ucode codex exec (timeout ${Math.round(timeoutMs / 1e3)}s)...`);
551
- try {
552
- const { text, exitCode, stderr } = await runAgent(prompt, {
553
- timeoutMs,
554
- capture: false
555
- });
556
- if (exitCode === 0) return 0;
557
- if (agentTimedOut(exitCode)) {
558
- consola.warn(`ucode codex timed out after ${Math.round(timeoutMs / 1e3)}s (exit ${exitCode})`);
559
- return 1;
560
- }
561
- if (stderr) consola.warn(stderr);
562
- consola.warn(`ucode codex exited ${exitCode}`);
563
- return text ? 0 : 1;
564
- } catch (err) {
565
- const message = errorMessage(err);
566
- if (/timed out|timeout|aborted/i.test(message)) {
567
- consola.warn(`ucode codex timed out after ${Math.round(timeoutMs / 1e3)}s: ${message}`);
568
- return 1;
569
- }
570
- fail(`ucode codex failed: ${message}`);
571
- }
572
- }
573
- /** Join argv prompt parts, or read stdin when non-interactive and args are empty. */
574
- async function resolveAgentPrompt(promptParts) {
575
- const fromArgs = promptParts.join(" ").trim();
576
- if (fromArgs) return fromArgs;
577
- if (process.stdin.isTTY) return "";
578
- return (await Bun.stdin.text()).trim();
579
- }
580
- /** Reject when `promise` does not settle within `ms` milliseconds. */
581
- function withTimeout(promise, ms) {
582
- return new Promise((resolve, reject) => {
583
- const timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`ucode codex: timed out after ${ms}ms`)), ms);
584
- promise.then((value) => {
585
- clearTimeout(timer);
586
- resolve(value);
587
- }, (err) => {
588
- clearTimeout(timer);
589
- reject(err);
590
- });
591
- });
592
- }
593
-
594
- //#endregion
595
- //#region packages/cli/src/config.ts
596
- /** Read the optional `dbxtools` block from the root `package.json`. */
597
- async function readOverrides() {
598
- return (await Bun.file(toAbsolute("package.json")).json()).dbxtools ?? {};
599
- }
600
- /**
601
- * Pick the npm scope shared by the publishable packages: the `@scope`
602
- * prefix that appears most often among their names. Returns undefined
603
- * when no scoped package exists.
604
- */
605
- async function deriveScope() {
606
- const counts = /* @__PURE__ */ new Map();
607
- for (const pkg of await discoverPackages()) {
608
- const match = /^(@[^/]+)\//.exec(pkg.meta.name ?? "");
609
- if (!match) continue;
610
- const scope = match[1];
611
- counts.set(scope, (counts.get(scope) ?? 0) + 1);
612
- }
613
- let best;
614
- let bestCount = 0;
615
- for (const [scope, count] of counts) if (count > bestCount) {
616
- best = scope;
617
- bestCount = count;
618
- }
619
- return best;
620
- }
621
- /**
622
- * Parse an `owner/name` slug out of a git remote URL, supporting both
623
- * `git@host:owner/name.git` and `https://host/owner/name(.git)` forms.
624
- */
625
- function parseRepoSlug(remoteUrl) {
626
- const cleaned = remoteUrl.trim().replace(/\.git$/, "");
627
- const match = /[:/]([^/:]+\/[^/]+)$/.exec(cleaned);
628
- return match ? match[1] : null;
629
- }
630
- /** Resolve the `owner/name` slug from the `origin` remote, or null. */
631
- async function deriveRepo() {
632
- if (!hasGit()) return null;
633
- const { exitCode, stdout } = await git([
634
- "remote",
635
- "get-url",
636
- "origin"
637
- ], { nothrow: true });
638
- if (exitCode !== 0 || !stdout) return null;
639
- return parseRepoSlug(stdout);
640
- }
641
- /**
642
- * Resolve toolkit config for the current repo, memoized for the
643
- * process. Overrides under the root `package.json` `dbxtools` key win
644
- * over the auto-derived defaults.
645
- */
646
- const getDbxtoolsConfig = pMemoize(async () => {
647
- const overrides = await readOverrides();
648
- const scope = overrides.scope ?? await deriveScope() ?? "";
649
- return {
650
- scope,
651
- repo: overrides.repo ?? await deriveRepo(),
652
- sharedPackage: overrides.sharedPackage ?? (scope ? `${scope}/shared` : null)
653
- };
654
- });
655
-
656
- //#endregion
657
- //#region packages/cli/src/create.ts
658
- const APPKIT_PEER_RANGE = "catalog:";
659
- /** Create a file (and any missing parent dirs) with the given content. */
660
- function write(path, content) {
661
- mkdirSync(dirname(path), { recursive: true });
662
- writeFileSync(path, content);
663
- }
664
- /** Scaffold a new workspace package under `packages/<slug>/`. */
665
- async function create(options) {
666
- const { slug, plugin, shared } = options;
667
- if (plugin && shared) fail("pass at most one of --plugin or --shared, not both");
668
- if (!/^[a-z][a-z0-9-]*$/.test(slug)) fail(`invalid slug "${slug}" (lowercase kebab-case, must start with a letter)`);
669
- const { scope, sharedPackage } = await getDbxtoolsConfig();
670
- if (!scope) fail("could not determine an npm scope; set `dbxtools.scope` in the root package.json");
671
- const existingPackages = await discoverPackages();
672
- const publishedVersions = existingPackages.map((pkg) => pkg.meta.version).filter((version) => Boolean(version));
673
- const hasSharedPkg = sharedPackage !== null && existingPackages.some((pkg) => pkg.meta.name === sharedPackage);
674
- const initialVersion = publishedVersions.reduce((highest, version) => !highest || semver.gt(version, highest) ? version : highest, void 0);
675
- if (!initialVersion) fail("no publishable packages with a `version` found to derive the initial version from");
676
- const kind = plugin ? "plugin" : shared ? "shared" : "standard";
677
- const bareSlug = kind === "plugin" ? slug.replace(/^appkit-/, "") : slug;
678
- const dirSlug = kind === "plugin" ? `appkit-${bareSlug}` : slug;
679
- const pkgDir = toAbsolute(`packages/${dirSlug}`);
680
- if (existsSync(pkgDir)) fail(`packages/${dirSlug} already exists; aborting.`);
681
- const capitalized = dirSlug.split("-").map((s) => s[0].toUpperCase() + s.slice(1));
682
- const pascal = capitalized.join("");
683
- const camel = pascal[0].toLowerCase() + pascal.slice(1);
684
- const className = `${pascal}Plugin`;
685
- const displayName = capitalized.join(" ");
686
- const pkgName = `${scope}/${dirSlug}`;
687
- const basePackageJson = {
688
- name: pkgName,
689
- version: initialVersion,
690
- type: "module",
691
- exports: { ".": "./src/index.ts" },
692
- publishConfig: { access: "public" }
693
- };
694
- const sharedDep = hasSharedPkg && sharedPackage ? { dependencies: { [sharedPackage]: "workspace:*" } } : {};
695
- const pluginPackageJson = {
696
- ...basePackageJson,
697
- ...sharedDep,
698
- peerDependencies: { "@databricks/appkit": APPKIT_PEER_RANGE }
699
- };
700
- const sharedPackageJson = { ...basePackageJson };
701
- const standardPackageJson = {
702
- ...basePackageJson,
703
- ...sharedDep
704
- };
705
- const packageJson = kind === "plugin" ? pluginPackageJson : kind === "shared" ? sharedPackageJson : standardPackageJson;
706
- mkdirSync(pkgDir, { recursive: true });
707
- await writeJson(resolve(pkgDir, "package.json"), packageJson);
708
- if (kind === "plugin") {
709
- const indexTs = `export { ${className}, ${camel} } from "./${dirSlug}.js";\n`;
710
- const pluginTs = `import {
711
- Plugin,
712
- toPlugin,
713
- type IAppRouter,
714
- type PluginManifest,
715
- } from "@databricks/appkit";
716
-
717
- const manifest: PluginManifest<"${bareSlug}"> = {
718
- name: "${bareSlug}",
719
- displayName: "${displayName}",
720
- description: "",
721
- stability: "beta",
722
- resources: {
723
- required: [],
724
- optional: [],
725
- },
726
- };
727
-
728
- export class ${className} extends Plugin {
729
- static manifest = manifest;
730
-
731
- injectRoutes(router: IAppRouter): void {
732
- // Add your routes here, e.g.:
733
- // router.get("/", (_req, res) => {
734
- // res.json({ message: "Hello from ${dirSlug}" });
735
- // });
736
- }
737
- }
738
-
739
- export const ${camel} = toPlugin(${className});
740
- `;
741
- write(resolve(pkgDir, "src", "index.ts"), indexTs);
742
- write(resolve(pkgDir, "src", `${dirSlug}.ts`), pluginTs);
743
- consola.log(`Scaffolded packages/${dirSlug}/ (plugin, npm name ${pkgName}, manifest name "${bareSlug}")`);
744
- } else if (kind === "shared") {
745
- const indexTs = `/**
746
- * ${pkgName}: a dependency-free, browser-safe wire-format contract.
747
- * Pure types (and browser-safe runtime, e.g. zod) only - no \`node:*\`
748
- * imports, even transitively - so any runtime can import it.
749
- */
750
- export * from "./protocol.js";
751
- `;
752
- const protocolTs = `// Wire-format types for ${pkgName}. Pure types: no
753
- // Node-only imports, safe for browser bundles.
754
- //
755
- // Add your shared types below and re-export them from \`./index.ts\`.
756
- `;
757
- write(resolve(pkgDir, "src", "index.ts"), indexTs);
758
- write(resolve(pkgDir, "src", "protocol.ts"), protocolTs);
759
- consola.log(`Scaffolded packages/${dirSlug}/ (shared, npm name ${pkgName})`);
760
- } else {
761
- const indexTs = `export * from "./${dirSlug}.js";\n`;
762
- const sourceTs = `// Source module for ${pkgName}.
763
- //
764
- // Add your exports below and re-export them from \`./index.ts\`.
765
- export {};
766
- `;
767
- write(resolve(pkgDir, "src", "index.ts"), indexTs);
768
- write(resolve(pkgDir, "src", `${dirSlug}.ts`), sourceTs);
769
- consola.log(`Scaffolded packages/${dirSlug}/ (standard, npm name ${pkgName})`);
770
- }
771
- consola.log(`Run \`bun install\` to link the workspace.`);
772
- }
773
-
774
- //#endregion
775
- //#region packages/cli/src/format.ts
776
- const require = createRequire(import.meta.url);
777
- /** Absolute path to a dependency's binary, read from its own `package.json`. */
778
- function resolveBin(pkg, binName) {
779
- const pkgJsonPath = require.resolve(`${pkg}/package.json`);
780
- const meta = require(`${pkg}/package.json`);
781
- const bin = typeof meta.bin === "string" ? meta.bin : meta.bin?.[binName];
782
- if (!bin) throw new Error(`${pkg} declares no "${binName}" bin`);
783
- return resolve(dirname(pkgJsonPath), bin);
784
- }
785
- /**
786
- * Build the single glob prettier walks - a recursive `.ts`/`.tsx` match
787
- * rooted at each discovered package dir. One combined pattern avoids
788
- * prettier's per-pattern "no matching files" error when an individual
789
- * package happens to ship no `.tsx`. A brace alternation is only used
790
- * for two-plus dirs: a single-element `{dir}` brace with a slash inside
791
- * is left un-expanded by prettier's matcher, so the lone dir is emitted
792
- * bare. Returns `null` when no packages were discovered (so the caller
793
- * can skip prettier entirely). `() => true` keeps private workspaces
794
- * (e.g. a demo) that the default filter drops.
795
- */
796
- async function sourceGlob() {
797
- const dirs = (await discoverPackages(() => true)).map((pkg) => pkg.slug);
798
- if (dirs.length === 0) return null;
799
- return `${dirs.length === 1 ? dirs[0] : `{${dirs.join(",")}}`}/**/*.{ts,tsx}`;
800
- }
801
- /**
802
- * Reorder a `scripts` map so npm's lifecycle hooks sit next to their
803
- * base: `pre<x>` immediately before `<x>` and `post<x>` immediately
804
- * after. Groups are ordered by base name; the base script need not
805
- * exist (a lone `prebuild`/`postbuild` still sorts into the `build`
806
- * slot). The `pre`/`post` prefix is stripped purely by name - the same
807
- * heuristic npm uses.
808
- */
809
- function reorderLifecycleScripts(scripts) {
810
- const baseOf = (key) => {
811
- const match = /^(?:pre|post)(.+)$/.exec(key);
812
- return match ? match[1] : key;
813
- };
814
- const bases = [...new Set(Object.keys(scripts).map(baseOf))].sort();
815
- const ordered = {};
816
- for (const base of bases) for (const name of [
817
- `pre${base}`,
818
- base,
819
- `post${base}`
820
- ]) if (name in scripts) ordered[name] = scripts[name];
821
- return ordered;
822
- }
823
- /** syncpack + lifecycle-hook regroup + prettier across the workspace. */
824
- async function format() {
825
- await bunx(["syncpack", "format"]);
826
- const regrouped = [];
827
- for await (const jsonPath of discoverPackageJsons(true)) {
828
- const meta = await Bun.file(jsonPath).json();
829
- const scripts = meta.scripts;
830
- if (!scripts || Object.keys(scripts).length === 0) continue;
831
- const ordered = reorderLifecycleScripts(scripts);
832
- if (JSON.stringify(Object.keys(ordered)) === JSON.stringify(Object.keys(scripts))) continue;
833
- meta.scripts = ordered;
834
- await writeJson(jsonPath, meta);
835
- regrouped.push(toRelative(jsonPath));
836
- }
837
- consola.log(regrouped.length > 0 ? `Regrouped lifecycle scripts in:\n${regrouped.join("\n")}` : "No lifecycle scripts to regroup.");
838
- const sources = await sourceGlob();
839
- if (!sources) {
840
- consola.log("No packages to format.");
841
- return;
842
- }
843
- const { stdout } = await sh([
844
- "bun",
845
- resolveBin("prettier", "prettier"),
846
- "--write",
847
- `--plugin=${require.resolve("prettier-plugin-organize-imports")}`,
848
- sources
849
- ], { quiet: true });
850
- const changed = stdout.split("\n").map((line) => line.trim()).filter((line) => line.length > 0 && !line.endsWith("(unchanged)"));
851
- consola.log(changed.length > 0 ? changed.join("\n") : "No files reformatted.");
852
- }
853
-
854
- //#endregion
855
- //#region packages/cli/src/release.ts
856
- /** SPDX license stamped onto published packages that don't set their own. */
857
- const DEFAULT_LICENSE = "Apache-2.0";
858
- /** Dependency fields whose `workspace:` sibling pins get resolved before publish. */
859
- const DEP_FIELDS = [
860
- "dependencies",
861
- "devDependencies",
862
- "peerDependencies",
863
- "optionalDependencies"
864
- ];
865
- /** Map a `.ts` source path to its emitted `dist` `.js` / `.d.ts` pair. */
866
- function distFromSource(source) {
867
- const stem = source.replace(/^\.\//, "").replace(/^src\//, "").replace(/\.[cm]?tsx?$/, "");
868
- return {
869
- js: `./dist/${stem}.js`,
870
- dts: `./dist/${stem}.d.ts`
871
- };
872
- }
873
- /**
874
- * Resolve a `workspace:` protocol specifier to a concrete range using
875
- * the sibling's `version`. `workspace:*` (and bare `workspace:`) pin the
876
- * exact version, `workspace:^` / `workspace:~` carry the caret / tilde,
877
- * and an explicit `workspace:<range>` keeps its range.
878
- */
879
- function resolveWorkspaceSpec(spec, version) {
880
- const range = spec.slice(10);
881
- if (range === "" || range === "*") return version;
882
- if (range === "^" || range === "~") return `${range}${version}`;
883
- return range;
884
- }
885
- /**
886
- * Return a copy of `deps` with every `workspace:` sibling pin resolved
887
- * to the sibling's just-bumped version from `siblingVersions`.
888
- *
889
- * This is done here, deterministically from the on-disk manifests,
890
- * rather than left to `bun publish` (which resolves `workspace:*` from
891
- * the gitignored `bun.lock`): a stale lockfile would otherwise freeze a
892
- * sibling pin one version behind, so a consumer installs a mismatched
893
- * nested copy of that sibling. A sibling missing from the map (e.g. a
894
- * private, non-published workspace) is left untouched.
895
- */
896
- function resolveWorkspaceDeps(deps, siblingVersions) {
897
- const next = { ...deps };
898
- for (const [name, spec] of Object.entries(next)) {
899
- if (typeof spec !== "string" || !spec.startsWith("workspace:")) continue;
900
- const version = siblingVersions.get(name);
901
- if (version) next[name] = resolveWorkspaceSpec(spec, version);
902
- }
903
- return next;
904
- }
905
- /** Rewrite dev `bin` pointers (`./bin/foo.ts`) to built `dist` targets. */
906
- function stampBin(meta) {
907
- const bin = meta.bin;
908
- if (!bin) return meta;
909
- const stampTarget = (target) => /^\.\/bin\/[^/]+\.[cm]?tsx?$/.test(target) ? target.replace(/^\.\/bin\/(.+)\.[cm]?tsx?$/, "./dist/bin/$1.js") : target;
910
- if (typeof bin === "string") return {
911
- ...meta,
912
- bin: stampTarget(bin)
913
- };
914
- const next = {};
915
- for (const [name, target] of Object.entries(bin)) next[name] = stampTarget(target);
916
- return {
917
- ...meta,
918
- bin: next
919
- };
920
- }
921
- /**
922
- * Return a publishable copy of `meta`: resolve `workspace:` sibling
923
- * pins to concrete versions (via `siblingVersions`), expand any source
924
- * `exports` pointer into a `{ types, default }` pair pointing at the
925
- * built `dist`, and fill in `main`, `types`, `files`, `license`, and
926
- * `type` when absent. Any field the manifest already sets is preserved.
927
- */
928
- function stampManifest(meta, siblingVersions) {
929
- const stamped = { ...meta };
930
- for (const field of DEP_FIELDS) {
931
- const deps = meta[field];
932
- if (deps && typeof deps === "object") stamped[field] = resolveWorkspaceDeps(deps, siblingVersions);
933
- }
934
- const exportsMap = meta.exports;
935
- let rootDist;
936
- let firstDist;
937
- if (exportsMap && typeof exportsMap === "object") {
938
- const nextExports = {};
939
- for (const [subpath, target] of Object.entries(exportsMap)) if (typeof target === "string" && /\.[cm]?tsx?$/.test(target)) {
940
- const dist = distFromSource(target);
941
- nextExports[subpath] = {
942
- types: dist.dts,
943
- default: dist.js
944
- };
945
- if (subpath === ".") rootDist = dist;
946
- firstDist ??= dist;
947
- } else if (typeof target === "string" && target.startsWith("./src/")) nextExports[subpath] = target.replace(/^\.\/src\//, "./dist/");
948
- else nextExports[subpath] = target;
949
- stamped.exports = nextExports;
950
- }
951
- const mainDist = rootDist ?? firstDist;
952
- if (mainDist) {
953
- stamped.main ??= mainDist.js;
954
- stamped.types ??= mainDist.dts;
955
- }
956
- stamped.files ??= ["dist"];
957
- stamped.license ??= DEFAULT_LICENSE;
958
- stamped.type ??= "module";
959
- if (typeof stamped.name === "string" && stamped.name.startsWith("@") && !stamped.publishConfig) stamped.publishConfig = { access: "public" };
960
- return stampBin(stamped);
961
- }
962
- /**
963
- * Build every publishable package, then publish each with a stamped
964
- * (complete) `package.json`, restoring the slim source manifest after
965
- * each publish whether it succeeds or fails.
966
- */
967
- async function release(opts = {}) {
968
- const { dryRun = false } = opts;
969
- await build();
970
- const pkgs = await discoverPackages((pkg) => pkg.meta.private !== true && Boolean(pkg.meta.name));
971
- const siblingVersions = /* @__PURE__ */ new Map();
972
- for (const pkg of pkgs) if (pkg.meta.name && pkg.meta.version) siblingVersions.set(pkg.meta.name, pkg.meta.version);
973
- consola.log(`=== ${dryRun ? "Dry-run publishing" : "Publishing"} ${pkgs.length} package(s) ===`);
974
- const failures = [];
975
- for (const pkg of pkgs) {
976
- const original = await Bun.file(pkg.jsonPath).text();
977
- const stamped = stampManifest(JSON.parse(original), siblingVersions);
978
- await Bun.write(pkg.jsonPath, JSON.stringify(stamped, null, 2) + "\n");
979
- try {
980
- await sh([
981
- "bun",
982
- "publish",
983
- ...dryRun ? ["--dry-run"] : []
984
- ], { cwd: pkg.dir });
985
- consola.success(`${dryRun ? "Dry-run" : "Published"} ${pkg.meta.name}`);
986
- } catch (error) {
987
- consola.error(`Failed to publish ${pkg.meta.name}: ${errorMessage(error)}`);
988
- failures.push(pkg.meta.name);
989
- } finally {
990
- await Bun.write(pkg.jsonPath, original);
991
- }
992
- }
993
- if (failures.length > 0) {
994
- consola.error(`Publish failed for: ${failures.join(", ")}`);
995
- process.exit(1);
996
- }
997
- consola.log(`${dryRun ? "Dry-run complete" : "Published"} ${pkgs.length} package(s).`);
998
- }
999
-
1000
- //#endregion
1001
- //#region packages/cli/src/tag.ts
1002
- /**
1003
- * `git rev-parse <args>`, returning trimmed stdout. Tolerant by
1004
- * design (`nothrow`): rev-parse exits non-zero when a ref is unknown
1005
- * (e.g. an unset `@{u}` upstream or a missing tag), and every caller
1006
- * here treats "unknown" as an empty string rather than an error.
1007
- */
1008
- async function gitRevParse(...args) {
1009
- return (await git(["rev-parse", ...args], { nothrow: true })).stdout;
1010
- }
1011
- /** `git status --porcelain` stdout (one entry per changed path). */
1012
- async function gitStatus() {
1013
- return (await git(["status", "--porcelain"])).stdout;
1014
- }
1015
- /** `git push origin <ref>` (commit branch or tag). */
1016
- async function gitPush(ref) {
1017
- await git([
1018
- "push",
1019
- "origin",
1020
- ref
1021
- ]);
1022
- }
1023
- /** True when the caller passed a non-empty `--notes-since` value. */
1024
- function notesSinceRequested(raw) {
1025
- return typeof raw === "string" && raw.trim().length > 0;
1026
- }
1027
- /**
1028
- * Normalize a `--notes-since` baseline to a tag name. Accepts `v0.1.75`
1029
- * or bare `0.1.75`.
1030
- */
1031
- function normalizeNotesSinceTag(raw) {
1032
- const trimmed = raw.trim();
1033
- if (!trimmed) fail("--notes-since: value must not be empty");
1034
- return trimmed.startsWith("v") ? trimmed : `v${trimmed}`;
1035
- }
1036
- /** Resolve and verify a `--notes-since` tag exists locally. */
1037
- async function resolveNotesSinceTag(raw) {
1038
- const tagName = normalizeNotesSinceTag(raw);
1039
- if (!await gitRevParse("--verify", `refs/tags/${tagName}`)) fail(`--notes-since: tag ${tagName} does not exist locally (fetch tags or pick another baseline)`);
1040
- return tagName;
1041
- }
1042
- /**
1043
- * Find every publishable workspace and assert they all share the same
1044
- * version (the changesets `fixed` policy). Returns the shared version
1045
- * and the package list.
1046
- */
1047
- async function findPublishables() {
1048
- const all = (await discoverPackages()).filter((pkg) => pkg.meta.name && pkg.meta.version);
1049
- if (all.length === 0) fail("No publishable packages found under packages/");
1050
- if (new Set(all.map((p) => p.meta.version)).size > 1) fail(`Publishable packages disagree on version (expected one fixed version):\n` + all.map((p) => ` ${p.meta.name}@${p.meta.version}`).join("\n"));
1051
- return {
1052
- version: all[0].meta.version,
1053
- pkgs: all.map((p) => ({
1054
- name: p.meta.name,
1055
- jsonPath: p.jsonPath
1056
- }))
1057
- };
1058
- }
1059
- /** Mutate just the `version` field of a package.json on disk. */
1060
- async function writeVersion(jsonPath, nextVersion) {
1061
- const meta = await Bun.file(jsonPath).json();
1062
- meta.version = nextVersion;
1063
- await writeJson(jsonPath, meta);
1064
- }
1065
- /**
1066
- * Create (or, on retry, update) the GitHub Release for `tag` with
1067
- * `body` as its markdown description. GitHub renders the Release
1068
- * body as markdown, unlike the bare tag annotation page which is
1069
- * monospace plaintext.
1070
- *
1071
- * Silently no-ops when `gh` is not on `PATH`. Failures are logged
1072
- * but never abort: the tag is already pushed at this point.
1073
- */
1074
- async function publishGithubRelease(tag, body) {
1075
- if (!Bun.which("gh")) {
1076
- consola.log("(skipping GitHub Release: gh CLI not on PATH)");
1077
- return;
1078
- }
1079
- consola.log(`Publishing GitHub Release ${tag}...`);
1080
- const gh = async (args) => {
1081
- const { exitCode } = await sh(["gh", ...args], { nothrow: true });
1082
- return exitCode;
1083
- };
1084
- const createCode = await gh([
1085
- "release",
1086
- "create",
1087
- tag,
1088
- "--title",
1089
- tag,
1090
- "--notes",
1091
- body
1092
- ]);
1093
- if (createCode === 0) return;
1094
- consola.warn(`gh release create exited ${createCode}; trying gh release edit.`);
1095
- const editCode = await gh([
1096
- "release",
1097
- "edit",
1098
- tag,
1099
- "--notes",
1100
- body
1101
- ]);
1102
- if (editCode !== 0) consola.warn(`gh release edit exited ${editCode}; release may need manual creation.`);
1103
- }
1104
- /**
1105
- * Conventional-commit type buckets, in the order they appear in the
1106
- * generated notes. Each subject line is matched against these prefixes
1107
- * (`feat:`, `fix(scope):`, `feat!:`, ...); anything unmatched lands in
1108
- * the trailing "Other" section.
1109
- */
1110
- const NOTE_SECTIONS = [
1111
- ["Features", /^feat(\(.+\))?!?:\s*/i],
1112
- ["Fixes", /^fix(\(.+\))?!?:\s*/i],
1113
- ["Performance", /^perf(\(.+\))?!?:\s*/i],
1114
- ["Refactors", /^refactor(\(.+\))?!?:\s*/i],
1115
- ["Documentation", /^docs(\(.+\))?!?:\s*/i],
1116
- ["Tests", /^test(\(.+\))?!?:\s*/i],
1117
- ["Build & CI", /^(build|ci)(\(.+\))?!?:\s*/i],
1118
- ["Chores", /^chore(\(.+\))?!?:\s*/i]
1119
- ];
1120
- const OTHER_SECTION = "Other";
1121
- /** Cap diff stat bytes fed to Codex so large ranges stay prompt-sized. */
1122
- const CURSOR_PROMPT_STAT_MAX_CHARS = 12e3;
1123
- const RELEASE_COMMIT_RE = /^chore: release v/i;
1124
- /** `git diff --shortstat` for a revision range, trimmed. */
1125
- async function gitRangeShortstat(range) {
1126
- return (await git([
1127
- "diff",
1128
- "--shortstat",
1129
- range
1130
- ], { nothrow: true })).stdout.trim();
1131
- }
1132
- /** Trim long diff output so agent prompts stay bounded. */
1133
- function truncateForPrompt(text, max = CURSOR_PROMPT_STAT_MAX_CHARS) {
1134
- if (text.length <= max) return text;
1135
- return `${text.slice(0, max)}\n\n... (${text.length - max} more characters truncated)`;
1136
- }
1137
- /**
1138
- * Build a markdown release-notes body from the commits in
1139
- * `<prevTag>..HEAD` (or the whole history when there's no previous
1140
- * tag), grouped by conventional-commit type. The `chore: release
1141
- * v<x>` commit this run creates is filtered out, and a compare link is
1142
- * appended when the repo slug and a previous tag are both known.
1143
- *
1144
- * Used as the GitHub Release description (which renders markdown).
1145
- */
1146
- async function releaseNotes(prevTag, tagName, repo, widenBaseline = false) {
1147
- const raw = (await git([
1148
- "log",
1149
- prevTag ? `${prevTag}..HEAD` : "HEAD",
1150
- "--no-merges",
1151
- "--pretty=format:%h %s"
1152
- ], { nothrow: true })).stdout;
1153
- const buckets = /* @__PURE__ */ new Map();
1154
- let includedCount = 0;
1155
- let releaseCommitCount = 0;
1156
- for (const line of nonEmptyLines(raw)) {
1157
- const tab = line.indexOf(" ");
1158
- const hash = tab === -1 ? "" : line.slice(0, tab);
1159
- const subject = tab === -1 ? line : line.slice(tab + 1);
1160
- if (RELEASE_COMMIT_RE.test(subject)) {
1161
- releaseCommitCount++;
1162
- continue;
1163
- }
1164
- includedCount++;
1165
- const section = NOTE_SECTIONS.find(([, re]) => re.test(subject))?.[0] ?? OTHER_SECTION;
1166
- const entry = hash ? `- ${subject} (${hash})` : `- ${subject}`;
1167
- (buckets.get(section) ?? buckets.set(section, []).get(section)).push(entry);
1168
- }
1169
- const parts = [];
1170
- for (const [title] of [...NOTE_SECTIONS, [OTHER_SECTION]]) {
1171
- const lines = buckets.get(title);
1172
- if (lines?.length) parts.push(`### ${title}\n${lines.join("\n")}`);
1173
- }
1174
- let body;
1175
- if (parts.length > 0) body = parts.join("\n\n");
1176
- else if (prevTag) {
1177
- const stat = await gitRangeShortstat(`${prevTag}..HEAD`);
1178
- body = stat ? `No conventional-commit entries since ${prevTag}.\n\nChanges: ${stat}.` : "_No changes since the previous tag._";
1179
- } else body = "_Initial release._";
1180
- if (widenBaseline && prevTag && includedCount > 0 && (includedCount <= 2 || releaseCommitCount >= includedCount)) {
1181
- const stat = await gitRangeShortstat(`${prevTag}..HEAD`);
1182
- if (stat) body += `\n\n**Changes since ${prevTag}**: ${stat}`;
1183
- }
1184
- if (repo && prevTag) body += `\n\n**Full changelog**: https://github.com/${repo}/compare/${prevTag}...${tagName}`;
1185
- return body;
1186
- }
1187
- /**
1188
- * Release-notes wrapper around {@link runAgent}. Returns `null` when
1189
- * Codex is absent, errors, times out, or produces nothing.
1190
- */
1191
- async function agentSummary(prompt, timeoutMs = AGENT_DEFAULT_TIMEOUT_MS) {
1192
- if (!await agentAvailable()) return null;
1193
- consola.log(`Running ucode codex to draft release notes (timeout ${Math.round(timeoutMs / 1e3)}s)...`);
1194
- try {
1195
- const { text, exitCode, stderr } = await runAgent(prompt, {
1196
- timeoutMs,
1197
- echo: false
1198
- });
1199
- if (exitCode === 0 && text) return text;
1200
- if (agentTimedOut(exitCode)) {
1201
- consola.warn(`ucode codex timed out after ${Math.round(timeoutMs / 1e3)}s (exit ${exitCode})${text ? "; partial output shown above" : ""}.`);
1202
- return text || null;
1203
- }
1204
- consola.warn(`ucode codex finished without usable notes (exit ${exitCode}${text ? ", partial output shown above" : ", empty output"}${stderr ? ", see stderr above" : ""}).`);
1205
- return text || null;
1206
- } catch (err) {
1207
- const message = errorMessage(err);
1208
- if (/timed out|timeout|aborted/i.test(message)) {
1209
- consola.warn(`ucode codex timed out after ${Math.round(timeoutMs / 1e3)}s: ${message}`);
1210
- return null;
1211
- }
1212
- consola.warn(`ucode codex failed: ${message}`);
1213
- return null;
1214
- }
1215
- }
1216
- /**
1217
- * Ask Codex to write the release notes for `<prevTag>..HEAD`, feeding
1218
- * it the commit subjects and per-file diff stat as context so it never
1219
- * has to explore the tree. Returns `null` (caller falls back to
1220
- * {@link releaseNotes}) when Codex is unavailable, there's nothing to
1221
- * summarize, or the agent fails.
1222
- */
1223
- async function agentReleaseNotes(prevTag, tagName, repo) {
1224
- if (!await agentAvailable()) return null;
1225
- const range = prevTag ? `${prevTag}..HEAD` : "HEAD";
1226
- const log = (await git([
1227
- "log",
1228
- range,
1229
- "--no-merges",
1230
- "--pretty=format:- %s"
1231
- ], { nothrow: true })).stdout;
1232
- const stat = truncateForPrompt((await git([
1233
- "diff",
1234
- "--stat",
1235
- range
1236
- ], { nothrow: true })).stdout || await gitRangeShortstat(range));
1237
- if (!log && !stat) return null;
1238
- const body = await agentSummary([
1239
- `Write release notes in Markdown for version ${tagName} of this TypeScript monorepo`,
1240
- prevTag ? `, covering the changes since ${prevTag}.` : ".",
1241
- `\n\nRules:`,
1242
- `\n- Output ONLY the release-notes markdown - no preamble, no surrounding code fences, do not modify any files.`,
1243
- `\n- Group changes under short \`###\` headings (e.g. Features, Fixes, Internal).`,
1244
- `\n- One concise bullet per notable change, user-facing and in the present tense.`,
1245
- `\n- Ignore noise: version-bump / "chore: release" commits, lockfile churn, generated output.`,
1246
- `\n\nCommit subjects:\n${log || "(none)"}`,
1247
- `\n\nFile change summary:\n${stat || "(none)"}`
1248
- ].join(""));
1249
- if (!body) return null;
1250
- return repo && prevTag ? `${body}\n\n**Full changelog**: https://github.com/${repo}/compare/${prevTag}...${tagName}` : body;
1251
- }
1252
- /**
1253
- * Build the release-notes body, preferring a Codex-written summary and
1254
- * falling back to the deterministic commit grouping. The returned
1255
- * `source` is just for logging which generator was used.
1256
- */
1257
- async function buildNotes(prevTag, tagName, repo, aiNotes = true, widenBaseline = false) {
1258
- if (aiNotes && await agentAvailable()) consola.log(`Generating release notes for ${tagName} with ucode codex...`);
1259
- else consola.log(`Generating release notes for ${tagName} from commits...`);
1260
- if (aiNotes) {
1261
- const ai = await agentReleaseNotes(prevTag, tagName, repo);
1262
- if (ai) return {
1263
- body: ai,
1264
- source: "codex"
1265
- };
1266
- if (await agentAvailable()) consola.log("Falling back to commit-grouped release notes...");
1267
- }
1268
- return {
1269
- body: await releaseNotes(prevTag, tagName, repo, widenBaseline),
1270
- source: "commits"
1271
- };
1272
- }
1273
- /**
1274
- * Version-bump every publishable workspace, commit, tag, push, create
1275
- * the GitHub Release (with generated notes), and publish to the local
1276
- * registry. See the file header for the full local-state policy.
1277
- */
1278
- async function tag(opts = {}) {
1279
- const { bump = "patch", dryRun = false, publish = true, notesSince, aiNotes = true } = opts;
1280
- await requireGitRepo("dbxtools tag");
1281
- const { repo } = await getDbxtoolsConfig();
1282
- const { version: currentVersion, pkgs } = await findPublishables();
1283
- const nextVersion = semver.inc(currentVersion, bump);
1284
- if (!nextVersion) fail(`Cannot ${bump}-bump version "${currentVersion}" (semver.inc returned null)`);
1285
- const tagName = `v${nextVersion}`;
1286
- const branch = await gitRevParse("--abbrev-ref", "HEAD");
1287
- const dirty = await gitStatus();
1288
- let ahead = "0";
1289
- if (!dryRun) {
1290
- const upstream = await gitRevParse("--abbrev-ref", "--symbolic-full-name", "@{u}");
1291
- if (!upstream) fail(`Branch ${branch} has no upstream. Push the branch first so the release commit lands on a known ref.`);
1292
- ahead = (await git([
1293
- "rev-list",
1294
- "--count",
1295
- `${upstream}..HEAD`
1296
- ])).stdout;
1297
- }
1298
- if (await gitRevParse("--verify", `refs/tags/${tagName}`)) fail(`Tag ${tagName} already exists locally. Pick a different bump.`);
1299
- if ((await git([
1300
- "ls-remote",
1301
- "--tags",
1302
- "origin",
1303
- `refs/tags/${tagName}`
1304
- ])).stdout) fail(`Tag ${tagName} already exists on origin. Pick a different bump.`);
1305
- const latestTag = (await git([
1306
- "describe",
1307
- "--tags",
1308
- "--abbrev=0"
1309
- ], { nothrow: true })).stdout || null;
1310
- const widenNotesBaseline = notesSinceRequested(notesSince);
1311
- const notesBaselineTag = widenNotesBaseline ? await resolveNotesSinceTag(notesSince) : latestTag;
1312
- consola.log(`Bump: ${bump}`);
1313
- consola.log(`Current: ${currentVersion}`);
1314
- consola.log(`Next: ${nextVersion}`);
1315
- consola.log(`Tag: ${tagName}`);
1316
- if (widenNotesBaseline) {
1317
- consola.log(`Latest tag: ${latestTag ?? "(none)"}`);
1318
- consola.log(`Notes since: ${notesBaselineTag} (--notes-since)`);
1319
- } else consola.log(`Previous tag: ${notesBaselineTag ?? "(none)"}`);
1320
- const headSha = await gitRevParse("--short", "HEAD");
1321
- consola.log(`HEAD: ${headSha} (${branch})`);
1322
- consola.log(`Packages:`);
1323
- for (const p of pkgs) consola.log(` ${p.name}`);
1324
- if (dirty) {
1325
- consola.log(`Dirty files (will be folded into the release commit):`);
1326
- for (const line of nonEmptyLines(dirty)) consola.log(` ${line}`);
1327
- }
1328
- if (ahead !== "0") consola.log(`Unpushed commits: ${ahead} (will be pushed with the release commit)`);
1329
- consola.log("");
1330
- const tagMessage = `Release ${tagName}`;
1331
- if (dryRun) {
1332
- consola.log("--dry-run: skipping write, commit, tag, and push.");
1333
- const preview = await buildNotes(notesBaselineTag, tagName, repo, aiNotes, widenNotesBaseline);
1334
- consola.log(`Release notes preview (${preview.source}):`);
1335
- consola.log(preview.body);
1336
- consola.log("");
1337
- if (publish) await sh([
1338
- "bun",
1339
- "run",
1340
- "release",
1341
- "--dry-run"
1342
- ], { nothrow: true });
1343
- return;
1344
- }
1345
- consola.log(`Writing ${nextVersion} to ${pkgs.length} package.json file(s)...`);
1346
- for (const p of pkgs) await writeVersion(p.jsonPath, nextVersion);
1347
- consola.log(`Committing release ${tagName}...`);
1348
- await git(["add", "-A"]);
1349
- await git([
1350
- "commit",
1351
- "-m",
1352
- `chore: release ${tagName}`
1353
- ]);
1354
- consola.log(`Pushing ${branch}...`);
1355
- await gitPush(branch);
1356
- consola.log(`Tagging HEAD as ${tagName}...`);
1357
- await git([
1358
- "tag",
1359
- "-a",
1360
- tagName,
1361
- "-m",
1362
- tagMessage
1363
- ]);
1364
- consola.log(`Pushing ${tagName} to origin...`);
1365
- await gitPush(tagName);
1366
- const notes = await buildNotes(notesBaselineTag, tagName, repo, aiNotes, widenNotesBaseline);
1367
- consola.log(`Release notes ready (${notes.source}):`);
1368
- consola.log(notes.body);
1369
- consola.log("");
1370
- await publishGithubRelease(tagName, notes.body);
1371
- if (publish) {
1372
- consola.log("Refreshing bun.lock to the bumped versions before local publish...");
1373
- if ((await sh(["bun", "install"], { nothrow: true })).exitCode !== 0) consola.warn("bun install failed; skipping local publish so stale sibling pins aren't shipped. CI will publish from the tag.");
1374
- else {
1375
- consola.log("Publishing packages to the local registry (bun dbxtools release)...");
1376
- await sh([
1377
- "bun",
1378
- "run",
1379
- "release"
1380
- ], { nothrow: true });
1381
- }
1382
- }
1383
- consola.log("");
1384
- consola.log(`Released ${tagName}.`);
1385
- if (repo) {
1386
- consola.log(" The Release workflow will fire on the tag push:");
1387
- consola.log(` https://github.com/${repo}/actions/workflows/release.yml`);
1388
- } else consola.log(" The Release workflow will fire on the tag push.");
1389
- }
1390
-
1391
- //#endregion
1392
- //#region packages/cli/src/update.ts
1393
- /** True when `version` is a release with no prerelease segment. */
1394
- function isStableVersion(version) {
1395
- return semver.valid(version) !== null && semver.prerelease(version) === null;
1396
- }
1397
- /** Highest stable version in `versions` that satisfies `range`. */
1398
- function latestStableInRange(versions, range) {
1399
- let best = null;
1400
- for (const version of versions) {
1401
- if (!isStableVersion(version)) continue;
1402
- if (!semver.satisfies(version, range, { includePrerelease: false })) continue;
1403
- if (!best || semver.gt(version, best)) best = version;
1404
- }
1405
- return best;
1406
- }
1407
- /** Rewrite a single range (or `latest`) to a caret pin on the latest stable match. */
1408
- function stableCaretRange(versions, range) {
1409
- const trimmed = range.trim();
1410
- if (trimmed === "latest") return trimmed;
1411
- const alternatives = trimmed.split("||").map((part) => part.trim());
1412
- if (alternatives.length > 1) return alternatives.map((part) => {
1413
- const latest = latestStableInRange(versions, part);
1414
- return latest ? `^${latest}` : part;
1415
- }).join(" || ");
1416
- const latest = latestStableInRange(versions, trimmed);
1417
- return latest ? `^${latest}` : trimmed;
1418
- }
1419
- /** Fetch every published version of `pkg` from the registry. */
1420
- async function npmVersions(pkg, cache) {
1421
- const cached = cache.get(pkg);
1422
- if (cached) return cached;
1423
- const result = await sh([
1424
- "npm",
1425
- "view",
1426
- pkg,
1427
- "versions",
1428
- "--json"
1429
- ], {
1430
- quiet: true,
1431
- nothrow: true
1432
- });
1433
- if (result.exitCode !== 0) return null;
1434
- try {
1435
- const versions = JSON.parse(result.stdout);
1436
- cache.set(pkg, versions);
1437
- return versions;
1438
- } catch {
1439
- return null;
1440
- }
1441
- }
1442
- /** Refresh every root `catalog` entry to the latest stable release in-range. */
1443
- async function updateCatalog() {
1444
- const project = await getProject();
1445
- const rootJsonPath = resolve(project.rootDirectory, project.rootWorkspace.path, "package.json");
1446
- const meta = await Bun.file(rootJsonPath).json();
1447
- const catalog = meta.catalog;
1448
- if (!catalog || Object.keys(catalog).length === 0) return false;
1449
- const versionsCache = /* @__PURE__ */ new Map();
1450
- const changes = [];
1451
- for (const [pkg, range] of Object.entries(catalog)) {
1452
- const versions = await npmVersions(pkg, versionsCache);
1453
- if (!versions) {
1454
- consola.warn(`Skipping catalog entry ${pkg}: could not resolve versions from npm`);
1455
- continue;
1456
- }
1457
- const nextRange = stableCaretRange(versions, range);
1458
- if (nextRange === range) continue;
1459
- catalog[pkg] = nextRange;
1460
- changes.push(`${pkg}: ${range} -> ${nextRange}`);
1461
- }
1462
- if (changes.length === 0) {
1463
- consola.log("Catalog entries already pinned to latest stable versions.");
1464
- return false;
1465
- }
1466
- meta.catalog = catalog;
1467
- await writeJson(rootJsonPath, meta);
1468
- consola.log(`Updated catalog:\n${changes.join("\n")}`);
1469
- return true;
1470
- }
1471
- /** Run `bun update` with `forwardArgs` at the repo root. */
1472
- async function runBunUpdate(forwardArgs) {
1473
- const project = await getProject();
1474
- const rootDir = resolve(project.rootDirectory, project.rootWorkspace.path);
1475
- consola.log(`bun update${forwardArgs.length > 0 ? ` ${forwardArgs.join(" ")}` : ""}`);
1476
- await sh([
1477
- "bun",
1478
- "update",
1479
- ...forwardArgs
1480
- ], { cwd: rootDir });
1481
- }
1482
- /** Refresh catalog pins, then `bun update` at the repo root. */
1483
- async function update(forwardArgs = []) {
1484
- await updateCatalog();
1485
- await runBunUpdate(forwardArgs);
1486
- }
1487
- /** Args after the `update` subcommand in `process.argv`. */
1488
- function forwardedUpdateArgs(argv = process.argv) {
1489
- const start = argv.indexOf("update");
1490
- return start >= 0 ? argv.slice(start + 1) : [];
1491
- }
1492
-
1493
- //#endregion
1494
- //#region packages/cli/src/verify.ts
1495
- /** Workspace verify pass (optional implicit-dependency scan). */
1496
- async function verify(options = {}) {
1497
- const project = await getProject();
1498
- if (!options.workspaceDeps) {
1499
- consola.log(`verify: ${project.workspaces.length} workspace(s) OK (skipped workspace dependency scan; pass --workspace-deps to enable)`);
1500
- return;
1501
- }
1502
- const result = await project.verify({ strict: true });
1503
- for (const issue of [...result.errors, ...result.warnings]) if (issue.level === "error") consola.error(issue.message);
1504
- else consola.warn(issue.message);
1505
- if (!result.ok) fail(`verify found ${result.errors.length} undeclared workspace dependenc${result.errors.length === 1 ? "y" : "ies"}`);
1506
- consola.log(`verify: ${project.workspaces.length} workspace(s) OK`);
1507
- }
1508
-
1509
- //#endregion
1510
- export { git as A, codegen as C, toAbsolute as D, discoverPackages as E, nonEmptyLines as F, getProject as I, sh as M, errorMessage as N, toRelative as O, fail as P, build as S, discoverPackageJsons as T, agentAvailable as _, runBunUpdate as a, resolveAgentPrompt as b, updateCatalog as c, release as d, format as f, agent as g, AGENT_DEFAULT_TIMEOUT_MS as h, latestStableInRange as i, bunx as j, writeJson as k, notesSinceRequested as l, getDbxtoolsConfig as m, forwardedUpdateArgs as n, stableCaretRange as o, create as p, isStableVersion as r, update as s, verify as t, tag as u, agentTimedOut as v, WorkspacePackage as w, runAgent as x, parseCodexStdout as y };