@dbx-tools/projen 0.9.58 → 0.9.61

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 CHANGED
@@ -481,8 +481,22 @@ bun run release --publish pypi --no-docs
481
481
  bun run release --no-npm --no-local-publish
482
482
  bun run release --publish local --install never
483
483
  bun run release --local-registry false --local-pypi auto
484
+ bun run release --demo-deploy
485
+ bun run release --no-release-notes
484
486
  ```
485
487
 
488
+ `--demo-deploy` is off by default. After tagging and any local registry
489
+ publication it runs `bun run demo:deploy`, which stages the AppKit demo
490
+ and deploys it to the FEVM workspace. That step is local only and is not
491
+ recorded in the annotated tag.
492
+
493
+ After bump, release writes `docs/releases/vX.Y.Z.md` with `dbx genie exec -C
494
+ "$PWD" --sandbox read-only --ephemeral -o`. If Genie fails or writes an empty
495
+ file, the step keeps going with a short git-log summary. `--no-release-notes`
496
+ skips the file. Notes are committed with the version bump and are not recorded
497
+ in the annotated tag. They are skipped on `--no-bump` because that path
498
+ requires an already committed tree.
499
+
486
500
  `--install auto` keeps Projen's normal local dependency-install behavior;
487
501
  `always` performs a manifest-resolved install first, and `never` uses dependencies already
488
502
  installed while still regenerating versioned sources. `--no-validation` skips
@@ -553,6 +567,8 @@ release [options]
553
567
  | `--docs` | build and deploy docs for a selected scope |
554
568
  | `--no-docs` | skip documentation build and deployment |
555
569
  | `--no-validation` | skip optional release validation tasks; version/source checks remain mandatory |
570
+ | `--no-release-notes` | skip writing docs/releases notes (Genie and git-log fallback) |
571
+ | `--demo-deploy` | after tagging, stage and deploy the AppKit demo app (off by default) |
556
572
  | `--no-local-publish` | skip publishing to configured local registries |
557
573
  | `--local-registry <auto\|false\|url>` | local npm registry selection (default: "auto") |
558
574
  | `--local-pypi <auto\|false\|url>` | local devpi registry selection (default: "auto") |
package/index.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // Hand edits are overwritten on the next watch; this file is read-only.
4
4
 
5
5
  export const PACKAGE_IDENTIFIER = "@dbx-tools/projen";
6
- export const PACKAGE_VERSION = "0.9.58";
6
+ export const PACKAGE_VERSION = "0.9.61";
7
7
  export * as barrels from "./src/barrels.ts";
8
8
  export * as bunApp from "./src/bun-app.ts";
9
9
  export * as bunWorkflow from "./src/bun-workflow.ts";
@@ -29,6 +29,7 @@ export * as release from "./src/release.ts";
29
29
  export * as releaseContext from "./src/release-context.ts";
30
30
  export * as releaseOptions from "./src/release-options.ts";
31
31
  export * as scaffold from "./src/scaffold.ts";
32
+ export * as sharedCoreUsage from "./src/shared-core-usage.ts";
32
33
  export * as tags from "./src/tags.ts";
33
34
  export * as tsconfig from "./src/tsconfig.ts";
34
35
  export * as vscode from "./src/vscode.ts";
@@ -65,6 +66,8 @@ export type { ReleaseDocsOptions, DBXToolsReleaseOptions } from "./src/release.t
65
66
  export { RELEASE_VERSION } from "./src/release-context.ts";
66
67
  export { RELEASE_PUBLISH_TARGETS, RELEASE_INSTALL_MODES, ReleaseStepSelectionSchema } from "./src/release-options.ts";
67
68
  export type { ReleasePublishTarget, ReleaseInstallMode, ReleaseStepSelection, ReleaseSelectionOptions } from "./src/release-options.ts";
69
+ export { SHARED_CORE_BINDING_EXPORTS } from "./src/shared-core-usage.ts";
70
+ export type { SharedCoreUsageKind, SharedCoreExportUsage } from "./src/shared-core-usage.ts";
68
71
  export { AGNOSTIC_COMPILER_OPTIONS, PACKAGE_TAG_MIXINS } from "./src/tags.ts";
69
72
  export type { PackageTag } from "./src/tags.ts";
70
73
  export { DBXToolsRootTsconfig } from "./src/tsconfig.ts";
package/package.json CHANGED
@@ -30,9 +30,9 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@clack/prompts": "^1.7.0",
33
- "@dbx-tools/core": "^0.9.58",
34
- "@dbx-tools/path": "^0.9.58",
35
- "@dbx-tools/shared-core": "^0.9.58",
33
+ "@dbx-tools/core": "^0.9.61",
34
+ "@dbx-tools/path": "^0.9.61",
35
+ "@dbx-tools/shared-core": "^0.9.61",
36
36
  "commander": "^15.0.0",
37
37
  "concurrently": "^10.0.3",
38
38
  "constructs": "^10.6.0",
@@ -54,7 +54,7 @@
54
54
  },
55
55
  "main": "index.ts",
56
56
  "license": "Apache-2.0",
57
- "version": "0.9.58",
57
+ "version": "0.9.61",
58
58
  "types": "index.ts",
59
59
  "type": "module",
60
60
  "exports": {
@@ -69,6 +69,25 @@ export function captureTaskCommand(
69
69
  return result.exitCode === 0 ? (result.stdout?.trim() ?? "") : "";
70
70
  }
71
71
 
72
+ /** Run a command with inherited output and return whether it exited successfully. */
73
+ export function tryTaskCommand(
74
+ cwd: string,
75
+ command: string,
76
+ args: readonly string[],
77
+ options: TaskCommandOptions = {},
78
+ ): boolean {
79
+ return (
80
+ exec.spawnSync(executable(command), [...args], {
81
+ cwd,
82
+ env: options.env,
83
+ stdout: "inherit",
84
+ stderr: options.stderr ?? "inherit",
85
+ stdin: "ignore",
86
+ check: false,
87
+ }).exitCode === 0
88
+ );
89
+ }
90
+
72
91
  /** Whether a quiet, non-interactive command exits successfully. */
73
92
  export function taskCommandSucceeds(
74
93
  cwd: string,
package/src/project-js.ts CHANGED
@@ -275,7 +275,7 @@ export function projectReleaseBranch(project: DBXToolsJavaScriptProject): string
275
275
  */
276
276
  export function addPackageFiles(pkg: javascript.NodeProject, ...entries: string[]): void {
277
277
  const current = (pkg.package.manifest.files ?? []) as string[];
278
- pkg.package.addField("files", [...new Set([...current, ...entries])]);
278
+ pkg.package.addField("files", [...object.sequence(current, entries).distinct()]);
279
279
  }
280
280
 
281
281
  /**
@@ -859,16 +859,45 @@ class GeneratedSource extends Component {
859
859
  * are the child's business - so `bun run build` at the root type-checked nothing
860
860
  * and ran no package tests. Compilation groups ordinary package tsconfigs into
861
861
  * a few TypeScript processes and runs custom package lifecycles alongside them.
862
- * Tests still use Bun's filtered workspace fan-out. Both read the current
863
- * workspace list from `package.json`, so a newly added member needs no re-synth.
864
- *
865
- * `*` matches every workspace MEMBER and never the root itself, so the test
866
- * task delegating to it cannot recurse. Members declared outside the scanned
867
- * package roots (`extraWorkspaceMembers`) are covered by both tasks.
862
+ * Tests use a small number of root runner groups, skip packages without tests,
863
+ * and expose focused, changed, unit, integration, and full tiers. The test task
864
+ * reads the current workspace list from `package.json`, so a newly added member
865
+ * needs no re-synth.
868
866
  */
869
867
  class WorkspaceValidationTasks extends Component {
870
868
  private configured = false;
871
869
 
870
+ public constructor(project: DBXToolsNodeProject | DBXToolsTypeScriptProject) {
871
+ super(project);
872
+ if (project.parent) return;
873
+ project.addTask("workspace:graph", {
874
+ description: "Write the workspace dependency and reverse-dependency graph",
875
+ execArgs: taskCommand("test-workspace.ts", "graph"),
876
+ });
877
+ project.addTask("test:focused", {
878
+ description: "Run tests for explicit workspace packages or paths",
879
+ execArgs: taskCommand("test-workspace.ts", "focused"),
880
+ receiveArgs: true,
881
+ });
882
+ project.addTask("test:changed", {
883
+ description: "Run tests for changed packages and their dependents",
884
+ execArgs: taskCommand("test-workspace.ts", "changed"),
885
+ receiveArgs: true,
886
+ });
887
+ project.addTask("test:unit", {
888
+ description: "Run unit tests in grouped root runners",
889
+ execArgs: taskCommand("test-workspace.ts", "unit"),
890
+ });
891
+ project.addTask("test:integration", {
892
+ description: "Run integration and packed-consumer tests",
893
+ execArgs: taskCommand("test-workspace.ts", "integration"),
894
+ });
895
+ project.addTask("test:all", {
896
+ description: "Run all JavaScript, TypeScript, and Python tests",
897
+ execArgs: taskCommand("test-workspace.ts", "all"),
898
+ });
899
+ }
900
+
872
901
  public override preSynthesize(): void {
873
902
  if (this.configured) return;
874
903
  this.configured = true;
@@ -877,7 +906,7 @@ class WorkspaceValidationTasks extends Component {
877
906
  return;
878
907
  }
879
908
  project.compileTask.execArgs(taskCommand("compile-workspace.ts"));
880
- project.testTask.exec("bun run --filter '*' test");
909
+ project.testTask.spawn(project.tasks.tryFind("test:all")!);
881
910
  }
882
911
  }
883
912
 
@@ -1253,7 +1282,7 @@ function initProject(
1253
1282
  // agnostic floor is set in the child's constructor; per-tag deps/tsconfig come from
1254
1283
  // the PACKAGE_TAG_MIXINS applied across the subtree below.
1255
1284
  for (const p of scanPackages(rootAbs, roots)) {
1256
- const tags = [...new Set([...p.tagCandidates, ...resolveTags(p, tagPaths)])];
1285
+ const tags = [...object.sequence(p.tagCandidates, resolveTags(p, tagPaths)).distinct()];
1257
1286
  const found = existing.get(p.memberPath);
1258
1287
  if (found) {
1259
1288
  found.dbxToolsConfig.tags.push(...tags);
@@ -1272,7 +1301,7 @@ function initProject(
1272
1301
  }
1273
1302
 
1274
1303
  // The root project may itself carry tags (via a `""`/`"."` tag-path key).
1275
- const rootTags = [...new Set([...(tagPaths[""] ?? []), ...(tagPaths["."] ?? [])])];
1304
+ const rootTags = [...object.sequence(tagPaths[""], tagPaths["."]).distinct()];
1276
1305
  if (rootTags.length) project.dbxToolsConfig.tags.push(...rootTags);
1277
1306
 
1278
1307
  // Apply per-tag mixins across the whole subtree now that every child exists
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Runtime-export usage for `@dbx-tools/shared-core`.
3
+ *
4
+ * Classifies each live export as production, test, package-internal, generated
5
+ * binding, or unused. The report is informational; architecture tests fail only
6
+ * unused exports and duplicated owned helpers.
7
+ */
8
+ import { readFileSync } from "node:fs";
9
+ import { join, relative } from "node:path";
10
+ import { find } from "@dbx-tools/path";
11
+ import * as core from "@dbx-tools/shared-core";
12
+ import { PACKAGE_IDENTIFIER, object } from "@dbx-tools/shared-core";
13
+ import { PYTHON_GENERATED_PACKAGE } from "./generated.ts";
14
+ import { readPackageManifest, recordedPackages, repoRoot } from "./packages.ts";
15
+
16
+ /** How one runtime export is referenced in this repository. */
17
+ export type SharedCoreUsageKind = "production" | "test" | "internal" | "binding" | "unused";
18
+
19
+ /** One classified shared-core runtime export. */
20
+ export interface SharedCoreExportUsage {
21
+ readonly id: string;
22
+ readonly namespace: string;
23
+ readonly name: string;
24
+ readonly kind: SharedCoreUsageKind;
25
+ readonly files: readonly string[];
26
+ }
27
+
28
+ /** Binding-safe wrappers called from generated Python even without TS imports. */
29
+ export const SHARED_CORE_BINDING_EXPORTS = [
30
+ "bindings.logActiveLevel",
31
+ "bindings.logLevelEnabled",
32
+ ] as const;
33
+
34
+ const SOURCE_PATTERN = "**/*.{ts,tsx,js,mjs}";
35
+ const NAMESPACE_EXPORT = /^export \* as (\w+) from "\.\/src\/([^"]+)\.ts";$/gm;
36
+ const LOCAL_OWNED_HELPER =
37
+ /(?:export\s+)?(?:async\s+)?function(?:\s*\*)?\s+(isRecord|sleep|escapeRegExp|trimToNull|trimToUndefined|trimToEmpty)\s*\(|(?:export\s+)?const\s+(isRecord|sleep|escapeRegExp|trimToNull|trimToUndefined|trimToEmpty)\s*=/g;
38
+
39
+ /** Owners of the shared helpers; other files must import them. */
40
+ const OWNED_HELPER_FILES = new Set([
41
+ "packages/js/shared/core/src/object.ts",
42
+ "packages/js/shared/core/src/async-utils.ts",
43
+ "packages/js/shared/core/src/pattern.ts",
44
+ "packages/js/shared/core/src/string-utils.ts",
45
+ "docs/scripts/repository-docs.mjs",
46
+ ]);
47
+
48
+ export function sharedCorePackageDir(root: string = repoRoot): string {
49
+ for (const pkg of recordedPackages(root)) {
50
+ if (readPackageManifest(pkg.dir)?.name === PACKAGE_IDENTIFIER) return pkg.dir;
51
+ }
52
+ throw new Error(`workspace package ${PACKAGE_IDENTIFIER} is missing`);
53
+ }
54
+
55
+ function namespaceMap(coreDir: string): Readonly<Record<string, string>> {
56
+ const barrel = readFileSync(join(coreDir, "index.ts"), "utf8");
57
+ return Object.fromEntries(
58
+ [...barrel.matchAll(NAMESPACE_EXPORT)].map((match) => [match[1]!, match[2]!]),
59
+ );
60
+ }
61
+
62
+ function sourceFiles(root: string, includeGenerated: boolean): string[] {
63
+ const ignore = [
64
+ "**/node_modules/**",
65
+ "**/lib/**",
66
+ "**/.docs-build/**",
67
+ // Bundled PythonMonkey runtimes inline shared-core; they are not local copies.
68
+ "**/node_runtime/runtime.js",
69
+ "**/_runtime.js",
70
+ ];
71
+ if (!includeGenerated) ignore.push(`**/${PYTHON_GENERATED_PACKAGE}/**`);
72
+ return [...find.findFiles(SOURCE_PATTERN, { cwd: root, ignore })].sort();
73
+ }
74
+
75
+ function classifyFile(
76
+ relativePath: string,
77
+ coreDirRel: string,
78
+ ): Exclude<SharedCoreUsageKind, "unused"> {
79
+ if (relativePath.includes(`/${PYTHON_GENERATED_PACKAGE}/`)) return "binding";
80
+ if (relativePath.startsWith(`${coreDirRel}/src/`)) return "internal";
81
+ if (/(?:^|\/)(?:test|tests)\//.test(relativePath) || relativePath.includes(".test.")) {
82
+ return "test";
83
+ }
84
+ return "production";
85
+ }
86
+
87
+ function preferKind(
88
+ current: SharedCoreUsageKind,
89
+ next: Exclude<SharedCoreUsageKind, "unused">,
90
+ ): SharedCoreUsageKind {
91
+ const rank: Record<SharedCoreUsageKind, number> = {
92
+ production: 0,
93
+ binding: 1,
94
+ internal: 2,
95
+ test: 3,
96
+ unused: 4,
97
+ };
98
+ return rank[next] < rank[current] ? next : current;
99
+ }
100
+
101
+ function mentionsExport(
102
+ source: string,
103
+ namespace: string,
104
+ name: string,
105
+ moduleFile: string,
106
+ ): boolean {
107
+ if (source.includes(`${namespace}.${name}`)) return true;
108
+ const imported =
109
+ source.includes(`from "@dbx-tools/shared-core/${moduleFile}"`) ||
110
+ source.includes(`from "./${moduleFile}.ts"`) ||
111
+ source.includes(`from "../src/${moduleFile}.ts"`);
112
+ if (!imported) return false;
113
+ return new RegExp(`\\b${name}\\b`).test(source);
114
+ }
115
+
116
+ /** Classify every namespace runtime export against repository callers. */
117
+ export function sharedCoreExportUsage(root: string = repoRoot): SharedCoreExportUsage[] {
118
+ const coreDir = sharedCorePackageDir(root);
119
+ const coreDirRel = relative(root, coreDir);
120
+ const namespaces = namespaceMap(coreDir);
121
+ const files = sourceFiles(root, true).map((file) => ({
122
+ path: relative(root, file),
123
+ text: readFileSync(file, "utf8"),
124
+ }));
125
+ const usage: SharedCoreExportUsage[] = [];
126
+ for (const [namespace, moduleFile] of Object.entries(namespaces)) {
127
+ const exported = core[namespace as keyof typeof core];
128
+ if (!exported || typeof exported !== "object") continue;
129
+ for (const name of Object.keys(exported).sort()) {
130
+ if (name === "__esModule" || name === "default") continue;
131
+ const id = `${namespace}.${name}`;
132
+ const defining = `${coreDirRel}/src/${moduleFile}.ts`;
133
+ const matches = files
134
+ .filter(({ path, text }) => {
135
+ if (path === defining) {
136
+ return (text.match(new RegExp(`\\b${name}\\b`, "g"))?.length ?? 0) > 1;
137
+ }
138
+ return mentionsExport(text, namespace, name, moduleFile);
139
+ })
140
+ .map(({ path }) => path);
141
+ let kind: SharedCoreUsageKind = "unused";
142
+ if (
143
+ SHARED_CORE_BINDING_EXPORTS.includes(id as (typeof SHARED_CORE_BINDING_EXPORTS)[number])
144
+ ) {
145
+ kind = "binding";
146
+ }
147
+ for (const file of matches) kind = preferKind(kind, classifyFile(file, coreDirRel));
148
+ usage.push({ id, namespace, name, kind, files: matches });
149
+ }
150
+ }
151
+ return usage.sort((left, right) => left.id.localeCompare(right.id));
152
+ }
153
+
154
+ /** Local copies of helpers owned by shared-core. */
155
+ export function duplicatedOwnedHelpers(root: string = repoRoot): string[] {
156
+ const duplicates: string[] = [];
157
+ for (const file of sourceFiles(root, false)) {
158
+ const relativePath = relative(root, file);
159
+ if (OWNED_HELPER_FILES.has(relativePath)) continue;
160
+ if (relativePath.startsWith("packages/js/shared/core/test/")) continue;
161
+ const text = readFileSync(file, "utf8");
162
+ LOCAL_OWNED_HELPER.lastIndex = 0;
163
+ if (LOCAL_OWNED_HELPER.test(text)) duplicates.push(relativePath);
164
+ }
165
+ return duplicates.sort();
166
+ }
167
+
168
+ /** Render a compact usage table for `shared-core:usage`. */
169
+ export function formatSharedCoreUsage(usage: readonly SharedCoreExportUsage[]): string {
170
+ const counts = object.sequence(usage).group({
171
+ production: (row) => row.kind === "production",
172
+ binding: (row) => row.kind === "binding",
173
+ internal: (row) => row.kind === "internal",
174
+ test: (row) => row.kind === "test",
175
+ unused: (row) => row.kind === "unused",
176
+ });
177
+ const lines = [
178
+ `shared-core runtime exports: ${usage.length}`,
179
+ ` production ${counts.production.length}`,
180
+ ` binding ${counts.binding.length}`,
181
+ ` internal ${counts.internal.length}`,
182
+ ` test ${counts.test.length}`,
183
+ ` unused ${counts.unused.length}`,
184
+ ];
185
+ if (counts.unused.length > 0) {
186
+ lines.push("unused:");
187
+ for (const row of counts.unused) lines.push(` ${row.id}`);
188
+ }
189
+ return `${lines.join("\n")}\n`;
190
+ }
package/tasks/publish.ts CHANGED
@@ -46,7 +46,7 @@
46
46
  import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
47
47
  import { tmpdir } from "node:os";
48
48
  import { delimiter, dirname, isAbsolute, join, relative, resolve } from "node:path";
49
- import { asyncUtils, log } from "@dbx-tools/shared-core";
49
+ import { asyncUtils, log, object } from "@dbx-tools/shared-core";
50
50
  import {
51
51
  applyPublishConfig,
52
52
  npmReleaseMatches,
@@ -169,7 +169,9 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
169
169
  `${pkg.name ?? dirname(dir)} has invalid publishConfig.access ${String(access)}`,
170
170
  );
171
171
  }
172
- const compiledTargets = [...new Set(compiledPublishTargets(pkg.publishConfig))];
172
+ const compiledTargets = [
173
+ ...object.sequence(compiledPublishTargets(pkg.publishConfig)).distinct(),
174
+ ];
173
175
  publishable.push({
174
176
  dir,
175
177
  name: pkg.name ?? dirname(dir),
@@ -2,7 +2,7 @@
2
2
  /** Keep every configured Python Node bridge current with one repository watcher. */
3
3
  import { dirname, resolve, sep } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { log } from "@dbx-tools/shared-core";
5
+ import { log, object } from "@dbx-tools/shared-core";
6
6
  import { runTaskCommand } from "../src/_task-command.ts";
7
7
  import { repoRoot } from "../src/packages.ts";
8
8
  import {
@@ -41,7 +41,7 @@ function watchPaths(configs: readonly ResolvedPythonNodeBindings[]): string[] {
41
41
  const explicitInputs = configs
42
42
  .flatMap(pythonNodeBindingWatchInputs)
43
43
  .filter((input) => !covered.some((root) => containsPath(root, input)));
44
- return [...new Set([...covered, ...explicitInputs])].sort();
44
+ return [...object.sequence(covered, explicitInputs).distinct()].sort();
45
45
  }
46
46
 
47
47
  function selection(
@@ -2,7 +2,7 @@
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { resolve } from "node:path";
5
- import { net } from "@dbx-tools/shared-core";
5
+ import { net, stringUtils } from "@dbx-tools/shared-core";
6
6
  import { parse } from "smol-toml";
7
7
  import { captureTaskCommand } from "../src/_task-command.ts";
8
8
 
@@ -120,12 +120,6 @@ export function devpiRegistry(index: string): LocalPythonRegistry | undefined {
120
120
  };
121
121
  }
122
122
 
123
- /** Split a whitespace-separated index list (the pip/uv env-var form). */
124
- function splitIndexList(value: string | undefined): string[] {
125
- const trimmed = value?.trim();
126
- return trimmed ? trimmed.split(/\s+/) : [];
127
- }
128
-
129
123
  /** Read a `pip config get <key>`, treating pip's literal "undefined" as unset. */
130
124
  function pipConfig(key: string): string | undefined {
131
125
  const out = captureTaskCommand(process.cwd(), "python", ["-m", "pip", "config", "get", key]);
@@ -153,8 +147,8 @@ export function activePythonIndexes(): string[] {
153
147
  // uv: env vars first (primary, then extras), then uv.toml's index blocks.
154
148
  add(process.env.UV_DEFAULT_INDEX);
155
149
  add(process.env.UV_INDEX_URL);
156
- for (const url of splitIndexList(process.env.UV_INDEX)) add(url);
157
- for (const url of splitIndexList(process.env.UV_EXTRA_INDEX_URL)) add(url);
150
+ for (const url of stringUtils.parseList(process.env.UV_INDEX)) add(url);
151
+ for (const url of stringUtils.parseList(process.env.UV_EXTRA_INDEX_URL)) add(url);
158
152
  const uvConfig = process.env.UV_CONFIG_FILE ?? resolve(homedir(), ".config/uv/uv.toml");
159
153
  if (existsSync(uvConfig)) {
160
154
  for (const url of parseUvIndexes(readFileSync(uvConfig, "utf8"))) add(url);
@@ -162,9 +156,9 @@ export function activePythonIndexes(): string[] {
162
156
 
163
157
  // pip: env vars (primary + extras), then `pip config` (index-url + extra-index-url).
164
158
  add(process.env.PIP_INDEX_URL);
165
- for (const url of splitIndexList(process.env.PIP_EXTRA_INDEX_URL)) add(url);
159
+ for (const url of stringUtils.parseList(process.env.PIP_EXTRA_INDEX_URL)) add(url);
166
160
  add(pipConfig("global.index-url"));
167
- for (const url of splitIndexList(pipConfig("global.extra-index-url"))) add(url);
161
+ for (const url of stringUtils.parseList(pipConfig("global.extra-index-url"))) add(url);
168
162
 
169
163
  return out;
170
164
  }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Write user-facing release notes under `docs/releases/` during `bun run release`.
3
+ *
4
+ * Prefers Genie Code (`dbx genie exec`). When that fails or writes an empty
5
+ * file, falls back to a short git-log summary so the release still commits notes.
6
+ *
7
+ * @module
8
+ */
9
+
10
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
+ import { dirname, join } from "node:path";
12
+ import { log } from "@dbx-tools/shared-core";
13
+
14
+ import { captureTaskCommand, tryTaskCommand } from "../src/_task-command.ts";
15
+
16
+ const logger = log.logger("projen:release-notes");
17
+
18
+ const GENIE_PROMPT =
19
+ "Compare HEAD and the working tree against the latest v* tag. Produce concise user-facing release notes. Ignore generated-file churn and test-only changes. Include breaking changes, fixes, and validation results. Do not modify files.";
20
+
21
+ /** Inputs for one notes write. */
22
+ export interface WriteReleaseNotesOptions {
23
+ readonly prefix: string;
24
+ readonly root: string;
25
+ readonly version: string;
26
+ /** Override Genie invocation; return false to force the git-log fallback. */
27
+ readonly runGenie?: (args: readonly string[]) => boolean;
28
+ }
29
+
30
+ /** Path of the notes file for one workspace version. */
31
+ export function releaseNotesPath(root: string, version: string): string {
32
+ return join(root, "docs", "releases", `v${version}.md`);
33
+ }
34
+
35
+ /** Short markdown notes from commit subjects since the previous matching tag. */
36
+ export function fallbackReleaseNotes(
37
+ version: string,
38
+ commits: readonly string[],
39
+ previousTag?: string,
40
+ ): string {
41
+ const range = previousTag ? `since ${previousTag}` : "in this release";
42
+ const items =
43
+ commits.length > 0
44
+ ? commits.map((subject) => `- ${subject}`).join("\n")
45
+ : "- Version bump and synchronized package metadata.";
46
+ return `# Release ${version}\n\ndbx-tools ${version} ${range}.\n\n${items}\n`;
47
+ }
48
+
49
+ /** Run Genie into `docs/releases/vX.Y.Z.md`, or write {@link fallbackReleaseNotes}. */
50
+ export function writeReleaseNotes(options: WriteReleaseNotesOptions): string {
51
+ const { prefix, root, version } = options;
52
+ const destination = releaseNotesPath(root, version);
53
+ mkdirSync(dirname(destination), { recursive: true });
54
+ const relative = join("docs", "releases", `v${version}.md`);
55
+ const genieArgs = [
56
+ "exec",
57
+ "-C",
58
+ root,
59
+ "--sandbox",
60
+ "read-only",
61
+ "--ephemeral",
62
+ "-o",
63
+ relative,
64
+ GENIE_PROMPT,
65
+ ] as const;
66
+ const ran =
67
+ options.runGenie?.([...genieArgs]) ??
68
+ tryTaskCommand(root, "bun", ["run", "dbx", "genie", ...genieArgs]);
69
+ if (ran && readNotes(destination)) {
70
+ logger.info("wrote Genie release notes", { path: relative });
71
+ return destination;
72
+ }
73
+ const previousTag = captureTaskCommand(root, "git", [
74
+ "describe",
75
+ "--tags",
76
+ "--abbrev=0",
77
+ `--match=${prefix}*`,
78
+ ]);
79
+ const logArgs = previousTag
80
+ ? ["log", "--no-merges", "--pretty=format:%s", `${previousTag}..HEAD`]
81
+ : ["log", "--no-merges", "--pretty=format:%s", "-15"];
82
+ const commits = captureTaskCommand(root, "git", logArgs)
83
+ .split("\n")
84
+ .map((line) => line.trim())
85
+ .filter(Boolean);
86
+ writeFileSync(destination, fallbackReleaseNotes(version, commits, previousTag || undefined));
87
+ logger.warn("Genie release notes unavailable; wrote git-log summary", { path: relative });
88
+ return destination;
89
+ }
90
+
91
+ function readNotes(path: string): string {
92
+ try {
93
+ return readFileSync(path, "utf8").trim();
94
+ } catch {
95
+ return "";
96
+ }
97
+ }
package/tasks/release.ts CHANGED
@@ -4,6 +4,7 @@ import * as projectUtils from "@dbx-tools/core/project-utils";
4
4
  import { log } from "@dbx-tools/shared-core";
5
5
  import { Command, Option } from "commander";
6
6
  import { publishLocalRelease } from "./local-publish.ts";
7
+ import { writeReleaseNotes } from "./release-notes.ts";
7
8
  import { assertReleaseVersion } from "./release-version.ts";
8
9
  import { captureTaskCommand, runTaskCommand, taskCommandSucceeds } from "../src/_task-command.ts";
9
10
  import {
@@ -105,6 +106,11 @@ export async function runRelease(
105
106
  readonly install?: ReleaseInstallMode;
106
107
  readonly pythonRoot?: string;
107
108
  readonly validationTasks?: readonly string[];
109
+ /** After tagging, stage and deploy the AppKit demo app. Off by default. */
110
+ readonly demoDeploy?: boolean;
111
+ /** Write `docs/releases/vX.Y.Z.md` via Genie, with a git-log fallback. On by default. */
112
+ readonly releaseNotes?: boolean;
113
+ readonly writeReleaseNotes?: typeof writeReleaseNotes;
108
114
  },
109
115
  ): Promise<string> {
110
116
  const { branch, prefix, remote, root } = options;
@@ -125,6 +131,9 @@ export async function runRelease(
125
131
  const version = readWorkspaceVersion(root);
126
132
  const tag = `${prefix}${version}`;
127
133
  assertReleaseVersion(version, { root, prefixes: [prefix], assertNext: true });
134
+ if ((options.releaseNotes ?? true) && (options.bump ?? true)) {
135
+ (options.writeReleaseNotes ?? writeReleaseNotes)({ prefix, root, version });
136
+ }
128
137
 
129
138
  if (taskCommandSucceeds(root, "git", ["rev-parse", "--verify", `refs/tags/${tag}`])) {
130
139
  throw new Error(`release tag ${tag} already exists`);
@@ -190,6 +199,10 @@ export async function runRelease(
190
199
  });
191
200
  }
192
201
 
202
+ if (options.demoDeploy) {
203
+ runTaskCommand(root, "bun", ["run", "demo:deploy"]);
204
+ }
205
+
193
206
  return tag;
194
207
  }
195
208
 
@@ -228,6 +241,8 @@ export function createReleaseCommand(): Command {
228
241
  "--no-validation",
229
242
  "skip optional release validation tasks; version/source checks remain mandatory",
230
243
  )
244
+ .option("--no-release-notes", "skip writing docs/releases notes (Genie and git-log fallback)")
245
+ .option("--demo-deploy", "after tagging, stage and deploy the AppKit demo app (off by default)")
231
246
  .option("--no-local-publish", "skip publishing to configured local registries")
232
247
  .option("--local-registry <auto|false|url>", "local npm registry selection", "auto")
233
248
  .option("--local-pypi <auto|false|url>", "local devpi registry selection", "auto")
@@ -245,6 +260,8 @@ export function createReleaseCommand(): Command {
245
260
  localPypi: string;
246
261
  install: ReleaseInstallMode;
247
262
  validate: string[];
263
+ demoDeploy?: boolean;
264
+ releaseNotes?: boolean;
248
265
  },
249
266
  ) => {
250
267
  await runRelease({
@@ -264,6 +281,8 @@ export function createReleaseCommand(): Command {
264
281
  docs: options.docs,
265
282
  validation: options.validation,
266
283
  validationTasks: options.validate,
284
+ demoDeploy: options.demoDeploy,
285
+ releaseNotes: options.releaseNotes,
267
286
  });
268
287
  },
269
288
  );
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env -S bun
2
+ /** Print shared-core runtime-export usage by caller kind. */
3
+ import { formatSharedCoreUsage, sharedCoreExportUsage } from "../src/shared-core-usage.ts";
4
+
5
+ process.stdout.write(formatSharedCoreUsage(sharedCoreExportUsage()));
@@ -0,0 +1,253 @@
1
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { basename, join, relative, resolve, sep } from "node:path";
3
+ import * as exec from "@dbx-tools/core/exec";
4
+ import { log, object } from "@dbx-tools/shared-core";
5
+
6
+ const logger = log.logger("projen:test");
7
+
8
+ interface PackageManifest {
9
+ readonly name?: string;
10
+ readonly workspaces?: readonly string[];
11
+ readonly dependencies?: Readonly<Record<string, string>>;
12
+ readonly optionalDependencies?: Readonly<Record<string, string>>;
13
+ readonly peerDependencies?: Readonly<Record<string, string>>;
14
+ readonly devDependencies?: Readonly<Record<string, string>>;
15
+ }
16
+
17
+ export interface WorkspaceGraphPackage {
18
+ readonly name: string;
19
+ readonly path: string;
20
+ readonly dependencies: readonly string[];
21
+ readonly reverseDependencies: readonly string[];
22
+ }
23
+
24
+ export interface WorkspaceGraph {
25
+ readonly packages: readonly WorkspaceGraphPackage[];
26
+ }
27
+
28
+ type TestMode = "focused" | "changed" | "unit" | "integration" | "all" | "graph";
29
+
30
+ const PROJEN_INTEGRATION_TEST =
31
+ /(?:local-publish|npm-release|packed-consumer|project-py|publish|python-sync|release|root-install|sdk-boundary).*\.test\.ts$/;
32
+
33
+ function readJson<T>(path: string): T {
34
+ return JSON.parse(readFileSync(path, "utf8")) as T;
35
+ }
36
+
37
+ function toPosix(path: string): string {
38
+ return path.split(sep).join("/");
39
+ }
40
+
41
+ export function workspaceGraph(root: string): WorkspaceGraph {
42
+ const workspace = readJson<PackageManifest>(join(root, "package.json"));
43
+ const packages = new Map<string, { path: string; manifest: PackageManifest }>();
44
+ for (const member of workspace.workspaces ?? []) {
45
+ const manifest = readJson<PackageManifest>(join(root, member, "package.json"));
46
+ if (manifest.name) packages.set(manifest.name, { path: toPosix(member), manifest });
47
+ }
48
+ const reverse = new Map<string, Set<string>>();
49
+ const rows = [...packages].map(([name, pkg]) => {
50
+ const dependencies = new Set<string>();
51
+ for (const field of [
52
+ "dependencies",
53
+ "optionalDependencies",
54
+ "peerDependencies",
55
+ "devDependencies",
56
+ ] as const) {
57
+ for (const dependency of Object.keys(pkg.manifest[field] ?? {})) {
58
+ if (!packages.has(dependency)) continue;
59
+ dependencies.add(dependency);
60
+ const dependents = reverse.get(dependency) ?? new Set<string>();
61
+ dependents.add(name);
62
+ reverse.set(dependency, dependents);
63
+ }
64
+ }
65
+ return { name, path: pkg.path, dependencies: [...dependencies].sort() };
66
+ });
67
+ return {
68
+ packages: rows
69
+ .map((pkg) => ({
70
+ ...pkg,
71
+ reverseDependencies: [...(reverse.get(pkg.name) ?? [])].sort(),
72
+ }))
73
+ .sort((left, right) => left.name.localeCompare(right.name)),
74
+ };
75
+ }
76
+
77
+ function writeGraph(root: string, graph: WorkspaceGraph): void {
78
+ const path = join(root, ".projen", "workspace-graph.json");
79
+ writeFileSync(path, `${JSON.stringify(graph, null, 2)}\n`);
80
+ logger.info(`wrote ${relative(root, path)}`);
81
+ }
82
+
83
+ function testFiles(directory: string): string[] {
84
+ if (!existsSync(directory)) return [];
85
+ const files: string[] = [];
86
+ const visit = (current: string): void => {
87
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
88
+ const path = join(current, entry.name);
89
+ if (entry.isDirectory()) visit(path);
90
+ else if (/\.(?:test|spec)\.tsx?$/.test(entry.name)) files.push(path);
91
+ }
92
+ };
93
+ visit(directory);
94
+ return files.sort();
95
+ }
96
+
97
+ function integrationTest(path: string): boolean {
98
+ return (
99
+ path.includes(".integration.test.") ||
100
+ (toPosix(path).includes("/projen/test/") && PROJEN_INTEGRATION_TEST.test(basename(path)))
101
+ );
102
+ }
103
+
104
+ function packageForPath(graph: WorkspaceGraph, path: string): WorkspaceGraphPackage | undefined {
105
+ const normalized = toPosix(path);
106
+ return [...graph.packages]
107
+ .sort((left, right) => right.path.length - left.path.length)
108
+ .find((pkg) => normalized === pkg.path || normalized.startsWith(`${pkg.path}/`));
109
+ }
110
+
111
+ function affectedPackages(graph: WorkspaceGraph, changed: readonly string[]): Set<string> {
112
+ const byName = new Map(graph.packages.map((pkg) => [pkg.name, pkg]));
113
+ const affected = new Set<string>();
114
+ const pending: string[] = [];
115
+ for (const path of changed) {
116
+ const pkg = packageForPath(graph, path);
117
+ if (!pkg) return new Set(graph.packages.map((entry) => entry.name));
118
+ if (!affected.has(pkg.name)) pending.push(pkg.name);
119
+ }
120
+ while (pending.length > 0) {
121
+ const name = pending.pop()!;
122
+ if (affected.has(name)) continue;
123
+ affected.add(name);
124
+ pending.push(...(byName.get(name)?.reverseDependencies ?? []));
125
+ }
126
+ return affected;
127
+ }
128
+
129
+ function changedFiles(root: string, base?: string): string[] {
130
+ const candidates = base ? [base] : ["origin/main", "main", "HEAD~1"];
131
+ let committed: string[] = [];
132
+ for (const candidate of candidates) {
133
+ const result = exec.spawnSync("git", ["diff", "--name-only", `${candidate}...HEAD`], {
134
+ cwd: root,
135
+ stdin: "ignore",
136
+ stdout: "capture",
137
+ stderr: "ignore",
138
+ });
139
+ if (result.exitCode === 0) {
140
+ committed = result.stdoutLines.filter(Boolean);
141
+ break;
142
+ }
143
+ }
144
+ const working = exec.spawnSync("git", ["diff", "--name-only", "HEAD"], {
145
+ cwd: root,
146
+ stdin: "ignore",
147
+ stdout: "capture",
148
+ stderr: "ignore",
149
+ check: true,
150
+ }).stdoutLines;
151
+ return [...object.sequence(committed, working).filter(Boolean).distinct()].sort();
152
+ }
153
+
154
+ function selectedTests(
155
+ root: string,
156
+ graph: WorkspaceGraph,
157
+ mode: TestMode,
158
+ args: readonly string[],
159
+ ): string[] {
160
+ const all = [
161
+ ...graph.packages.flatMap((pkg) => testFiles(join(root, pkg.path, "test"))),
162
+ ...testFiles(join(root, "docs/scripts")),
163
+ ];
164
+ if (mode === "unit") return all.filter((path) => !integrationTest(path));
165
+ if (mode === "integration") return all.filter(integrationTest);
166
+ if (mode === "all") return all;
167
+
168
+ let names: Set<string>;
169
+ if (mode === "changed") {
170
+ const baseIndex = args.indexOf("--base");
171
+ const changed = changedFiles(root, baseIndex >= 0 ? args[baseIndex + 1] : undefined);
172
+ if (changed.length === 0) return [];
173
+ names = affectedPackages(graph, changed);
174
+ } else {
175
+ if (args.length === 0) throw new Error("test:focused requires a package name or path");
176
+ names = new Set<string>();
177
+ for (const value of args) {
178
+ const direct = graph.packages.find((pkg) => pkg.name === value);
179
+ const byPath = direct ?? packageForPath(graph, toPosix(relative(root, resolve(root, value))));
180
+ if (!byPath) throw new Error(`Unknown workspace package or path: ${value}`);
181
+ names.add(byPath.name);
182
+ }
183
+ }
184
+ const paths = new Set(graph.packages.filter((pkg) => names.has(pkg.name)).map((pkg) => pkg.path));
185
+ return all.filter((path) => {
186
+ const relativePath = toPosix(relative(root, path));
187
+ return [...paths].some((pkg) => relativePath.startsWith(`${pkg}/`));
188
+ });
189
+ }
190
+
191
+ function runnerGroup(root: string, path: string): string {
192
+ const relativePath = toPosix(relative(root, path));
193
+ if (relativePath.startsWith("packages/js/shared/")) return "browser-shared";
194
+ if (
195
+ relativePath.startsWith("packages/js/ui/") ||
196
+ relativePath.startsWith("packages/example/app/")
197
+ ) {
198
+ return "react-ui";
199
+ }
200
+ if (relativePath.startsWith("projen/")) return "projen";
201
+ if (relativePath.startsWith("docs/scripts/")) return "docs";
202
+ if (relativePath.startsWith("packages/example/")) return "examples";
203
+ return "node";
204
+ }
205
+
206
+ async function runTests(root: string, files: readonly string[]): Promise<void> {
207
+ if (files.length === 0) {
208
+ logger.info("no tests selected");
209
+ return;
210
+ }
211
+ const groups = new Map<string, string[]>();
212
+ for (const file of files) {
213
+ const name = runnerGroup(root, file);
214
+ const paths = groups.get(name) ?? [];
215
+ paths.push(relative(root, file));
216
+ groups.set(name, paths);
217
+ }
218
+ const started = performance.now();
219
+ const jobs = [...groups].map(async ([name, paths]) => {
220
+ const groupStarted = performance.now();
221
+ const result = await exec.spawn("bun", ["test", ...paths], {
222
+ cwd: root,
223
+ stdin: "ignore",
224
+ stdout: "inherit",
225
+ stderr: "inherit",
226
+ });
227
+ const seconds = ((performance.now() - groupStarted) / 1000).toFixed(2);
228
+ logger.info(`${name}: ${paths.length} files in ${seconds}s`);
229
+ return { name, result };
230
+ });
231
+ const results = await Promise.all(jobs);
232
+ logger.info(
233
+ `selected tests: ${files.length} files in ${((performance.now() - started) / 1000).toFixed(2)}s`,
234
+ );
235
+ const failed = results.filter(({ result }) => result.exitCode !== 0);
236
+ if (failed.length > 0) {
237
+ throw new Error(`Test groups failed: ${failed.map(({ name }) => name).join(", ")}`);
238
+ }
239
+ }
240
+
241
+ export async function main(argv: readonly string[] = process.argv.slice(2)): Promise<void> {
242
+ const root = process.cwd();
243
+ const mode = (argv[0] ?? "all") as TestMode;
244
+ if (!["focused", "changed", "unit", "integration", "all", "graph"].includes(mode)) {
245
+ throw new Error(`Unknown test mode: ${mode}`);
246
+ }
247
+ const graph = workspaceGraph(root);
248
+ writeGraph(root, graph);
249
+ if (mode === "graph") return;
250
+ await runTests(root, selectedTests(root, graph, mode, argv.slice(1)));
251
+ }
252
+
253
+ if (import.meta.main) await main();
@@ -0,0 +1,22 @@
1
+ import { resolve } from "node:path";
2
+
3
+ const entries = {
4
+ auth: "packages/js/ui/appkit/src/auth/react/index.ts",
5
+ email: "packages/js/ui/appkit/src/email/react/index.ts",
6
+ search: "packages/js/ui/appkit/src/search/react/index.ts",
7
+ } as const;
8
+
9
+ for (const [name, entry] of Object.entries(entries)) {
10
+ const result = await Bun.build({
11
+ entrypoints: [resolve(entry)],
12
+ target: "browser",
13
+ minify: true,
14
+ sourcemap: "none",
15
+ external: ["react", "react-dom", "@databricks/*", "@dbx-tools/shared-*", "lucide-react"],
16
+ });
17
+ if (!result.success) {
18
+ throw new AggregateError(result.logs, `Could not build the ${name} UI subpath`);
19
+ }
20
+ const bytes = result.outputs.reduce((total, output) => total + output.size, 0);
21
+ console.log(`${name}: ${bytes} bytes`);
22
+ }