@dbx-tools/projen 0.9.152 → 0.9.153

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
@@ -24,10 +24,13 @@ packages. It can also generate Zod schemas from declarations and coordinate
24
24
  generated-file cleanup and watch tasks.
25
25
 
26
26
  Every `src`-bearing folder under the configured roots becomes a
27
- `DBXToolsTypeScriptProject`. Folder path drives package name and runtime tags.
27
+ `DBXToolsTypeScriptProject`. `packages/js` is selected automatically when it
28
+ exists; otherwise discovery uses `packages`. Folder path drives package name and
29
+ runtime tags.
28
30
  Use `packageDefaults` for native options shared by every discovered package,
29
31
  such as `{ publishable: false }`; reserve `resolvePackageOptions` for genuine
30
- per-package differences.
32
+ per-package differences. Use `rootTsconfig` only for repository-wide compiler
33
+ differences; root tests, Bun/Node types, and React JSX are already included.
31
34
 
32
35
  Use `DBXToolsTypeScriptProject` itself as the root when one package needs the
33
36
  same Bun workspace, repository, task, and release defaults without a separate
@@ -107,7 +110,11 @@ new projectPy.DBXToolsPythonWorkspace(root, {
107
110
  ```
108
111
 
109
112
  Distribution names and modules come from the parent scope plus each package
110
- directory. The repository comes from the parent project metadata or Git remote.
113
+ directory. Direct children of the Python root that contain `src/` are discovered
114
+ automatically; `packages` only needs entries whose metadata differs from the
115
+ generated defaults. Set `discoverPackages: false` for a fully explicit list.
116
+ The repository comes from the parent project metadata, existing generated root
117
+ manifest, or Git remote.
111
118
  `internalDependencies` renders standalone Git `#subdirectory=` requirements
112
119
  without repeating repository coordinates. Pass `repository`, `name`, or
113
120
  `module` only to override those conventions. `root.vscode` is Projen's existing
@@ -315,13 +322,18 @@ parameter to `Project`). Drop to `mixin.create(predicate, fn)` +
315
322
  Built-in tag mixins set runtime defaults for `shared`, `node`, `cli`, `server`,
316
323
  `ui`, and `app`. Repo-specific mixins layer package-specific dependencies,
317
324
  scripts, and generated files on top. `ui` is for reusable React component
318
- libraries. `app` owns a Bun-built browser application.
325
+ libraries. `app` owns a Bun-built browser application. Terminal
326
+ `packages/**/app` and `packages/**/server` directories select those specific
327
+ roles instead of inheriting a generic `ui` or `node` ancestor.
319
328
 
320
329
  AppKit mode defaults to `auto`: when the workspace has both `app` and `server`
321
330
  packages, the engine adds the AppKit, Vite, React plugin, and Tailwind
322
331
  dependencies to the package that uses each one. It also disables AppKit's
323
332
  package migration, includes the Vite and generated-type inputs in the browser
324
- tsconfig, adds `appkit:sync`, and ignores local Databricks state. Set
333
+ tsconfig, generates a read-only monorepo-safe `vite.config.ts`, adds
334
+ `appkit:sync`, and ignores local Databricks state. Put repository-specific Vite
335
+ settings in the optional unmanaged `vite.config.override.ts`; the generated
336
+ config merges it over the engine defaults. Set
325
337
  `appkit: false` to disable this ownership. Set `appkit: true` to require AppKit
326
338
  and fail synthesis when either package role or its required entry files are
327
339
  missing. Keep application-specific UI and server dependencies in the consuming
@@ -338,8 +350,12 @@ with concrete versions and keeps every manifest stable through upload. The
338
350
  generated working tree is never modified. The task passes an explicit profile,
339
351
  using the supplied flag or resolving one through `@dbx-tools/auth`, and fails
340
352
  before staging when neither profile nor complete environment credentials are
341
- available. A repository-owned deployment flow can run its own Bun checks and
342
- spawn `appkit:deploy` for the upload step instead of maintaining staging or
353
+ available. It validates the bundle strictly, removes only stale direct-engine
354
+ state from the temporary snapshot, and defaults to active-run protection while
355
+ skipping duplicate npm validation and tests. If deployment or app startup
356
+ fails, the task prints the current Databricks App logs before returning the
357
+ original failure. A repository-owned deployment flow can run its own Bun checks
358
+ and spawn `appkit:deploy` for the upload step instead of maintaining staging or
343
359
  materialization code.
344
360
 
345
361
  Every workspace root exposes a generic development watcher:
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.152";
6
+ export const PACKAGE_VERSION = "0.9.153";
7
7
  export * as appkitWorkspace from "./src/appkit-workspace.ts";
8
8
  export * as barrels from "./src/barrels.ts";
9
9
  export * as bunApp from "./src/bun-app.ts";
@@ -77,6 +77,7 @@ export type { SharedCoreUsageKind, SharedCoreExportUsage } from "./src/shared-co
77
77
  export { AGNOSTIC_COMPILER_OPTIONS, PACKAGE_TAG_MIXINS } from "./src/tags.ts";
78
78
  export type { PackageTag } from "./src/tags.ts";
79
79
  export { DBXToolsRootTsconfig } from "./src/tsconfig.ts";
80
+ export type { DBXToolsRootTsconfigOptions } from "./src/tsconfig.ts";
80
81
  export type { IgnoreGroupOptions, WatchLoopOptions } from "./src/watch.ts";
81
82
  export type { WorkspaceMutationLockOptions } from "./src/workspace-lock.ts";
82
83
  export { VERSION_FILE, DEFAULT_VERSION } from "./src/workspace-version.ts";
package/package.json CHANGED
@@ -30,11 +30,11 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@clack/prompts": "^1.7.0",
33
- "@dbx-tools/auth": "^0.9.152",
34
- "@dbx-tools/cli-args": "^0.9.152",
35
- "@dbx-tools/core": "^0.9.152",
36
- "@dbx-tools/path": "^0.9.152",
37
- "@dbx-tools/shared-core": "^0.9.152",
33
+ "@dbx-tools/auth": "^0.9.153",
34
+ "@dbx-tools/cli-args": "^0.9.153",
35
+ "@dbx-tools/core": "^0.9.153",
36
+ "@dbx-tools/path": "^0.9.153",
37
+ "@dbx-tools/shared-core": "^0.9.153",
38
38
  "commander": "^15.0.0",
39
39
  "concurrently": "^10.0.3",
40
40
  "constructs": "^10.6.0",
@@ -56,11 +56,12 @@
56
56
  },
57
57
  "main": "index.ts",
58
58
  "license": "Apache-2.0",
59
- "version": "0.9.152",
59
+ "version": "0.9.153",
60
60
  "types": "index.ts",
61
61
  "type": "module",
62
62
  "exports": {
63
63
  ".": "./index.ts",
64
+ "./pypi-trusted-publisher-instructions": "./tasks/pypi-trusted-publisher-instructions.ts",
64
65
  "./python-release-packaging": "./tasks/publish-python.ts",
65
66
  "./release-packaging": "./tasks/publish-npm.ts",
66
67
  "./package.json": "./package.json"
@@ -4,8 +4,9 @@
4
4
  * @module
5
5
  */
6
6
  import { existsSync } from "node:fs";
7
- import { join, relative, sep } from "node:path";
8
- import { Component, type Task, javascript, typescript } from "projen";
7
+ import { basename, join, relative, sep } from "node:path";
8
+ import { Component, type Task, TextFile, javascript, typescript } from "projen";
9
+ import { headerLines } from "./generated.ts";
9
10
 
10
11
  /** AppKit workspace discovery and validation mode. */
11
12
  export type AppKitWorkspaceMode = "auto" | boolean;
@@ -94,9 +95,7 @@ function validateWorkspace(options: AppKitWorkspaceOptions): void {
94
95
  if (options.apps.length === 0) missing.push("an `app` package");
95
96
  if (options.servers.length === 0) missing.push("a `server` package");
96
97
  for (const app of options.apps) {
97
- for (const file of ["index.html", "vite.config.ts"]) {
98
- if (!existsSync(join(app.outdir, file))) missing.push(`${app.name}/${file}`);
99
- }
98
+ if (!existsSync(join(app.outdir, "index.html"))) missing.push(`${app.name}/index.html`);
100
99
  }
101
100
  for (const server of options.servers) {
102
101
  const entrypoint = serverEntrypoint(server);
@@ -117,11 +116,47 @@ function configureApp(project: javascript.NodeProject): void {
117
116
  "@tailwindcss/vite@catalog:",
118
117
  "tailwindcss@catalog:",
119
118
  );
120
- project.addDevDeps("vite@catalog:");
119
+ project.addDevDeps("@databricks/appkit@catalog:", "vite@catalog:");
120
+ const typesDirectory = `${basename(project.outdir)}/shared/appkit-types`;
121
+ new TextFile(project, "vite.config.ts", {
122
+ marker: true,
123
+ readonly: true,
124
+ lines: [
125
+ ...headerLines({ tool: "projen", source: ".projenrc.ts" }),
126
+ 'import { existsSync } from "node:fs";',
127
+ 'import { appKitServingTypesPlugin, appKitTypesPlugin } from "@databricks/appkit";',
128
+ 'import tailwindcss from "@tailwindcss/vite";',
129
+ 'import { defineConfig, mergeConfig, type UserConfig } from "vite";',
130
+ "",
131
+ `const appKitTypesDirectory = ${JSON.stringify(typesDirectory)};`,
132
+ 'const overrideUrl = new URL("./vite.config.override.ts", import.meta.url);',
133
+ "const override = existsSync(overrideUrl)",
134
+ " ? ((await import(overrideUrl.href)).default as UserConfig)",
135
+ " : {};",
136
+ "",
137
+ "export default defineConfig(",
138
+ " mergeConfig(",
139
+ " {",
140
+ " plugins: [",
141
+ " tailwindcss(),",
142
+ " appKitTypesPlugin({",
143
+ " outFile: `${appKitTypesDirectory}/analytics.d.ts`,",
144
+ " }),",
145
+ " appKitServingTypesPlugin({",
146
+ " outFile: `${appKitTypesDirectory}/serving.d.ts`,",
147
+ " }),",
148
+ " ],",
149
+ " },",
150
+ " override,",
151
+ " ),",
152
+ ");",
153
+ ],
154
+ });
121
155
 
122
156
  if (!(project instanceof typescript.TypeScriptProject)) return;
123
157
  project.tsconfig?.file.addOverride("compilerOptions.rootDir", ".");
124
158
  project.tsconfig?.addInclude("vite.config.ts");
159
+ project.tsconfig?.addInclude("vite.config.override.ts");
125
160
  project.tsconfig?.addInclude("shared/appkit-types");
126
161
  }
127
162
 
package/src/bun-app.ts CHANGED
@@ -24,6 +24,7 @@
24
24
  */
25
25
  import { stringUtils } from "@dbx-tools/shared-core";
26
26
  import { type Project, TextFile } from "projen";
27
+ import { header, headerLines } from "./generated.ts";
27
28
 
28
29
  /** Unmanaged dev-server override module, merged over the generated options if present. */
29
30
  export const BUN_DEV_OVERRIDE = "bun-dev.override.ts";
@@ -31,6 +32,8 @@ export const BUN_DEV_OVERRIDE = "bun-dev.override.ts";
31
32
  export const BUN_BUILD_OVERRIDE = "bun-build.override.ts";
32
33
  /** Unmanaged generated-script overrides excluded from formatting and linting. */
33
34
  export const BUN_APP_OVERRIDES = [BUN_DEV_OVERRIDE, BUN_BUILD_OVERRIDE];
35
+ const PROJEN_HEADER = { tool: "projen", source: ".projenrc.ts" } as const;
36
+ const PROJEN_TS_HEADER = header(PROJEN_HEADER).trimEnd().replaceAll("\n", "\n ");
34
37
 
35
38
  /**
36
39
  * Root `bunfig.toml`: pin bun's HOISTED linker workspace-wide.
@@ -49,7 +52,7 @@ export class RootBunfigFile extends TextFile {
49
52
  marker: false,
50
53
  readonly: true,
51
54
  lines: [
52
- '# ~~ Generated by projen. To modify, edit .projenrc.ts and run "bunx projen".',
55
+ ...headerLines({ ...PROJEN_HEADER, comment: "#" }),
53
56
  "# Hoisted (flat) node_modules so a peer dep like @mastra/core resolves to ONE",
54
57
  "# copy - the isolated default splits it into peer-hash variants and breaks",
55
58
  "# identity-based singletons + cross-package types.",
@@ -76,6 +79,7 @@ export class BunfigFile extends TextFile {
76
79
  marker: true,
77
80
  readonly: true,
78
81
  lines: [
82
+ ...headerLines({ ...PROJEN_HEADER, comment: "#" }),
79
83
  "# Tailwind v4 for the dev server (Bun.serve reads [serve.static] plugins).",
80
84
  "# Production build applies the plugin via Bun.build's API in build.ts - the",
81
85
  "# `bun build` CLI does not load bunfig plugins, so there is no [build] here.",
@@ -94,6 +98,7 @@ export class BunDevServerFile extends TextFile {
94
98
  const source = (
95
99
  // ============================================================================
96
100
  /*ts*/`
101
+ ${PROJEN_TS_HEADER}
97
102
  import { existsSync } from "node:fs";
98
103
  import index from "./index.html";
99
104
 
@@ -135,6 +140,7 @@ export class BunBuildFile extends TextFile {
135
140
  String.raw
136
141
  // ============================================================================
137
142
  /*ts*/`
143
+ ${PROJEN_TS_HEADER}
138
144
  import { existsSync } from "node:fs";
139
145
  import { cp, rm } from "node:fs/promises";
140
146
  import { basename } from "node:path";
package/src/generated.ts CHANGED
@@ -10,8 +10,8 @@
10
10
  */
11
11
  import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
12
12
 
13
- /** First line of every banner {@link header} builds. */
14
- const MARKER = "// GENERATED by";
13
+ /** Tool-owned marker text shared by every supported comment style. */
14
+ const MARKER = "GENERATED by";
15
15
 
16
16
  /** Python subpackage, under a package's module directory, that holds all toolchain output. */
17
17
  export const PYTHON_GENERATED_PACKAGE = "_generated";
@@ -34,6 +34,8 @@ export function makeReadonly(file: string): void {
34
34
 
35
35
  /** Inputs for the durable generated-file banner. */
36
36
  export interface HeaderOpts {
37
+ /** Line-comment prefix. Defaults to `//`. */
38
+ readonly comment?: "//" | "#";
37
39
  /** What produced the file, e.g. `"projen watch"`. */
38
40
  readonly tool: string;
39
41
  /** What the file is derived from, e.g. `"the exporting modules beside it"`. */
@@ -42,12 +44,18 @@ export interface HeaderOpts {
42
44
 
43
45
  /** Build the do-not-edit banner (line comments, for TS/JS). */
44
46
  export function header(opts: HeaderOpts): string {
45
- const lines = [`${MARKER} ${opts.tool} - DO NOT EDIT.`];
46
- if (opts.source) lines.push(`// Regenerated from ${opts.source}.`);
47
- lines.push("// Hand edits are overwritten on the next watch; this file is read-only.");
47
+ const comment = opts.comment ?? "//";
48
+ const lines = [`${comment} ${MARKER} ${opts.tool} - DO NOT EDIT.`];
49
+ if (opts.source) lines.push(`${comment} Regenerated from ${opts.source}.`);
50
+ lines.push(`${comment} Hand edits are overwritten on the next watch; this file is read-only.`);
48
51
  return `${lines.join("\n")}\n`;
49
52
  }
50
53
 
54
+ /** Build a generated banner as lines for Projen file components. */
55
+ export function headerLines(opts: HeaderOpts): string[] {
56
+ return header(opts).trimEnd().split("\n");
57
+ }
58
+
51
59
  /**
52
60
  * True if the file exists and carries the do-not-edit banner {@link header}
53
61
  * writes - i.e. a toolchain wrote it and a hand edit would be overwritten.
@@ -61,7 +69,8 @@ export function isGenerated(file: string): boolean {
61
69
  try {
62
70
  const source = readFileSync(file, "utf8");
63
71
  return (
64
- source.startsWith(MARKER) ||
72
+ source.startsWith(`// ${MARKER}`) ||
73
+ source.startsWith(`# ${MARKER}`) ||
65
74
  source.startsWith("// ~~ Generated by projen") ||
66
75
  source.startsWith("# ~~ Generated by projen") ||
67
76
  /"\/\/"\s*:\s*"~~ Generated by projen/.test(source)
@@ -78,7 +87,7 @@ export function isGenerated(file: string): boolean {
78
87
  export function stampGenerated(file: string, opts: HeaderOpts): void {
79
88
  makeWritable(file);
80
89
  const body = readFileSync(file, "utf8");
81
- const next = body.startsWith(MARKER) ? body : `${header(opts)}\n${body}`;
90
+ const next = isGenerated(file) ? body : `${header(opts)}\n${body}`;
82
91
  writeFileSync(file, next);
83
92
  makeReadonly(file);
84
93
  }
package/src/packages.ts CHANGED
@@ -146,11 +146,16 @@ export class DiscoveredPackage {
146
146
 
147
147
  /**
148
148
  * Tag candidates from nesting under the discovery root: cumulative kebab-case join
149
- * of every ancestor folder, excluding the leaf package folder when depth >= 2
149
+ * of every ancestor folder, excluding the leaf package folder when depth >= 2.
150
+ * Terminal `app` and `server` directories are runtime roles and replace their
151
+ * generic ancestor (`ui/app` -> `app`, `node/server` -> `server`).
152
+ * Other paths keep cumulative tags
150
153
  * (`shared/path/coolDude/another` -> `[shared, shared-path, shared-path-cool-dude]`).
151
154
  * Matched against `packageTagPaths` to resolve applied mixin tags.
152
155
  */
153
156
  get tagCandidates(): string[] {
157
+ const leaf = stringUtils.toSlug(this.name);
158
+ if (leaf === "app" || leaf === "server") return [leaf];
154
159
  return nestingTagsFromSegments(this.relSegments);
155
160
  }
156
161
  }
@@ -64,7 +64,7 @@ const DEFAULT_CATALOG: Catalog = {
64
64
  "@types/react-dom": "^19.2.2",
65
65
  tailwindcss: "^4.3.2",
66
66
  "@tailwindcss/vite": "^4.3.2",
67
- vite: "^7.2.4",
67
+ vite: "npm:rolldown-vite@7.1.14",
68
68
  "@vitejs/plugin-react": "^5.1.1",
69
69
  // Tailwind v4 compiler for bun's dev server + `Bun.build` (the `app` tag).
70
70
  "bun-plugin-tailwind": "^0.1.2",
package/src/project-js.ts CHANGED
@@ -40,7 +40,7 @@ import { applyCompiledPublish } from "./publish.ts";
40
40
  import { generatePythonNodeBindings } from "./python-node-bindings.ts";
41
41
  import { DBXToolsRelease, type ReleaseDocsOptions } from "./release.ts";
42
42
  import { AGNOSTIC_COMPILER_OPTIONS, PACKAGE_TAG_MIXINS, type PackageTag } from "./tags.ts";
43
- import { DBXToolsRootTsconfig } from "./tsconfig.ts";
43
+ import { DBXToolsRootTsconfig, type DBXToolsRootTsconfigOptions } from "./tsconfig.ts";
44
44
  import { configureVsCode } from "./vscode.ts";
45
45
  import { readWorkspaceVersion, syncWorkspaceManifestVersion } from "./workspace-version.ts";
46
46
 
@@ -140,7 +140,15 @@ function repositoryOptions(
140
140
  options: DBXToolsJavaScriptProjectOptions,
141
141
  ): Pick<javascript.NodeProjectOptions, "repository" | "repositoryDirectory"> {
142
142
  const workspaceRoot = resolve(options.parent?.root.outdir ?? options.outdir ?? process.cwd());
143
- const repository = options.repository ?? projectUtils.repositoryUrl(workspaceRoot, "npm");
143
+ const existingRepository = readPackageManifest(workspaceRoot)?.repository;
144
+ const existingRepositoryUrl =
145
+ typeof existingRepository === "string"
146
+ ? existingRepository
147
+ : object.isRecord(existingRepository)
148
+ ? stringUtils.trimToNull(existingRepository.url)
149
+ : undefined;
150
+ const repository =
151
+ options.repository ?? projectUtils.repositoryUrl(workspaceRoot, "npm") ?? existingRepositoryUrl;
144
152
  const projectRoot = options.parent
145
153
  ? resolve(options.parent.outdir, options.outdir ?? ".")
146
154
  : resolve(options.outdir ?? process.cwd());
@@ -623,6 +631,8 @@ export type DBXToolsJavaScriptProjectOptions = CommonProjectOptions &
623
631
  * a list = only those tags.
624
632
  */
625
633
  readonly defaultTagMixins?: false | PackageTag[];
634
+ /** Overrides for the generated repository-root TypeScript program. */
635
+ readonly rootTsconfig?: DBXToolsRootTsconfigOptions;
626
636
  /**
627
637
  * Apply AppKit conventions to `app` and `server` packages. Defaults to
628
638
  * `auto`; `true` also validates the required package roles and entry files.
@@ -1065,6 +1075,7 @@ class PrettierIgnoreGenerated extends Component {
1065
1075
  if (!(sub instanceof DBXToolsTypeScriptProject)) continue;
1066
1076
  const rel = toPosix(relative(rootAbs, sub.outdir));
1067
1077
  prettier.addIgnorePattern(`${rel}/index.ts`);
1078
+ prettier.addIgnorePattern(`${rel}/src/**/index.ts`);
1068
1079
  for (const module of codegenModulePaths(sub.dbxToolsConfig.codegenInputs)) {
1069
1080
  prettier.addIgnorePattern(`${rel}/${module}`);
1070
1081
  }
@@ -1236,7 +1247,7 @@ function initProject(
1236
1247
  }
1237
1248
 
1238
1249
  if (project instanceof DBXToolsNodeProject) {
1239
- project.rootTsconfig = new DBXToolsRootTsconfig(project);
1250
+ project.rootTsconfig = new DBXToolsRootTsconfig(project, options.rootTsconfig);
1240
1251
  }
1241
1252
  const projenrc = new typescript.ProjenrcTs(project, {
1242
1253
  runner: typescript.TypeScriptRunner.nodejs(),
@@ -1264,7 +1275,9 @@ function initProject(
1264
1275
  // lockfiles, so the CI-named install task must resolve from manifests as well.
1265
1276
  project.package.installCiTask.reset("bun install");
1266
1277
  configureRootBunRuntime(project);
1267
- const roots = options.packageRoots ?? DEFAULT_PACKAGE_ROOTS;
1278
+ const roots =
1279
+ options.packageRoots ??
1280
+ (existsSync(resolve(project.outdir, "packages/js")) ? ["packages/js"] : DEFAULT_PACKAGE_ROOTS);
1268
1281
  project.dbxToolsConfig.packageRoots = [...roots];
1269
1282
  if (options.syncResynthPaths?.length) {
1270
1283
  project.dbxToolsConfig.syncResynthPaths = [...options.syncResynthPaths];
@@ -1289,6 +1302,9 @@ function initProject(
1289
1302
  project.gitignore.addPatterns(
1290
1303
  ...[...ignore.ignorePatterns({ test: false, dot: false, lock: true })],
1291
1304
  );
1305
+ // The generic compiled-output pattern `**/lib/**` must not hide authored
1306
+ // modules beneath a package's `src/lib/`.
1307
+ project.gitignore.include("**/src/lib/**");
1292
1308
  // What the dot group was actually earning here, named explicitly: secrets and
1293
1309
  // local editor state. Both ignore CONTENTS (`.idea/*`) rather than the
1294
1310
  // directory, so a later `!` negation can still reach a file inside.
package/src/project-py.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  /** Reusable uv workspace generation for Python packages hosted in a projen tree. */
2
+ import { existsSync, readdirSync } from "node:fs";
3
+ import { resolve } from "node:path";
2
4
  import * as projectUtils from "@dbx-tools/core/project-utils";
3
5
  import { stringUtils, type OneOrMany } from "@dbx-tools/shared-core";
4
6
  import { Component, License, TextFile, type Project, github, javascript, python } from "projen";
5
7
  import { JobPermission, type Job, type JobStep } from "projen/lib/github/workflows-model";
6
- import { PYTHON_GENERATED_PACKAGE } from "./generated.ts";
8
+ import { header, PYTHON_GENERATED_PACKAGE } from "./generated.ts";
7
9
  import { LICENSE, projectRepositoryUrl, taskCommand } from "./project-js.ts";
8
10
  import { isDBXToolsJavaScriptProject } from "./project-predicate.ts";
9
11
  import type { DBXToolsProject, DBXToolsProjectOptions } from "./project.ts";
@@ -107,7 +109,10 @@ interface PythonPublication {
107
109
 
108
110
  /** Options for {@link DBXToolsPythonWorkspace}. */
109
111
  export interface DBXToolsPythonWorkspaceOptions {
110
- readonly packages: readonly PythonPackageOptions[];
112
+ /** Package-specific options merged over packages discovered under the Python root. */
113
+ readonly packages?: readonly PythonPackageOptions[];
114
+ /** Discover direct child directories containing `src/`. Defaults to true. */
115
+ readonly discoverPackages?: boolean;
111
116
  readonly repository?: PythonRepositoryOptions;
112
117
  /** Repository-relative Python package root. Defaults to `packages/py`. */
113
118
  readonly root?: string;
@@ -214,6 +219,15 @@ export function pythonModuleName(scope: string, directory: string): string {
214
219
  return [scope, ...directory.split("/")].map((part) => part.replaceAll("-", "_")).join(".");
215
220
  }
216
221
 
222
+ /** Discover direct Python workspace members by their conventional source directory. */
223
+ function discoverPythonPackages(root: string): string[] {
224
+ if (!existsSync(root)) return [];
225
+ return readdirSync(root, { withFileTypes: true })
226
+ .filter((entry) => entry.isDirectory() && existsSync(resolve(root, entry.name, "src")))
227
+ .map((entry) => entry.name)
228
+ .sort();
229
+ }
230
+
217
231
  /** A Python package implemented with projen's `PythonProject` and uv backend. */
218
232
  export class DBXToolsPythonProject extends python.PythonProject implements DBXToolsProject {
219
233
  readonly language = "python" as const;
@@ -349,11 +363,30 @@ export class DBXToolsPythonWorkspace extends Component {
349
363
  ref: options.repository?.ref ?? "main",
350
364
  root: options.root ?? options.repository?.root ?? "packages/py",
351
365
  };
352
- const packageIdentities: ResolvedPythonPackageOptions[] = options.packages.map((pkg) => ({
353
- ...pkg,
354
- name: pkg.name ?? `${scope}-${stringUtils.toSlug(pkg.directory)}`,
355
- module: pkg.module ?? pythonModuleName(scope, pkg.directory),
356
- }));
366
+ const configuredPackages = new Map((options.packages ?? []).map((pkg) => [pkg.directory, pkg]));
367
+ const discoveredDirectories =
368
+ options.discoverPackages === false
369
+ ? []
370
+ : discoverPythonPackages(resolve(project.outdir, this.repository.root));
371
+ const directories = [
372
+ ...configuredPackages.keys(),
373
+ ...discoveredDirectories.filter((directory) => !configuredPackages.has(directory)),
374
+ ];
375
+ const packageIdentities: ResolvedPythonPackageOptions[] = directories.map((directory) => {
376
+ const pkg = configuredPackages.get(directory);
377
+ return {
378
+ directory,
379
+ description: `${scope} ${directory} Python package.`,
380
+ ...pkg,
381
+ name: pkg?.name ?? `${scope}-${stringUtils.toSlug(directory)}`,
382
+ module: pkg?.module ?? pythonModuleName(scope, directory),
383
+ };
384
+ });
385
+ if (packageIdentities.length === 0) {
386
+ throw new Error(
387
+ `No Python packages were configured or discovered under ${this.repository.root}`,
388
+ );
389
+ }
357
390
  const configuredNodeRuntimes = packageIdentities.filter(({ nodeRuntime }) => nodeRuntime);
358
391
  if (configuredNodeRuntimes.length > 1) {
359
392
  throw new Error("A Python workspace can configure only one shared Node runtime package");
@@ -760,26 +793,24 @@ export class DBXToolsPythonWorkspace extends Component {
760
793
  }),
761
794
  ];
762
795
  const helper = ".projen/pypi-trusted-publisher-instructions.mjs";
796
+ const helperHeader = header({ tool: "projen", source: ".projenrc.ts" })
797
+ .trimEnd()
798
+ .replaceAll("\n", "\n ");
763
799
  new TextFile(project.root, helper, {
800
+ marker: true,
801
+ readonly: true,
764
802
  // prettier-ignore
765
803
  lines: stringUtils.dedent(
766
804
  // ============================================================================
767
805
  /*js*/`
768
806
  #!/usr/bin/env bun
769
- import { parseArgs } from "node:util";
807
+ ${helperHeader}
808
+ import { printPypiTrustedPublisherInstructions } from "@dbx-tools/projen/pypi-trusted-publisher-instructions";
770
809
 
771
- const { values } = parseArgs({
772
- options: { secretFile: { type: "string" } },
810
+ await printPypiTrustedPublisherInstructions({
811
+ beforeAuthentication: ${JSON.stringify(linesBeforeAuthentication)},
812
+ afterAuthentication: ${JSON.stringify(linesAfterAuthentication)},
773
813
  });
774
- const beforeAuthentication = ${JSON.stringify(linesBeforeAuthentication)};
775
- const afterAuthentication = ${JSON.stringify(linesAfterAuthentication)};
776
- const authentication = values.secretFile
777
- ? [
778
- \`- If browser authentication is required, read credentials from \${values.secretFile} directly into the browser without printing, logging, or exposing secret values.\`,
779
- \`- If \${values.secretFile} is absent, invalid, or insufficient for authentication, pause and ask the user to complete authentication.\`,
780
- ]
781
- : ["- If browser authentication is required, pause and ask the user to complete authentication."];
782
- process.stdout.write(\`\${[...beforeAuthentication, ...authentication, ...afterAuthentication].join("\\n").trimEnd()}\\n\`);
783
814
  `
784
815
  // ============================================================================
785
816
  ).split("\n"),
package/src/tsconfig.ts CHANGED
@@ -3,6 +3,12 @@
3
3
  */
4
4
  import { Component, type Project, javascript, typescript } from "projen";
5
5
 
6
+ /** Consumer overrides for the repository-root TypeScript program. */
7
+ export interface DBXToolsRootTsconfigOptions {
8
+ readonly compilerOptions?: javascript.TypeScriptCompilerOptions;
9
+ readonly include?: readonly string[];
10
+ }
11
+
6
12
  /**
7
13
  * Compiler options for the ROOT program only (`.projenrc.ts` + the engine as
8
14
  * seen from the root). Each package owns its own projen-generated
@@ -40,7 +46,7 @@ export class DBXToolsRootTsconfig extends Component {
40
46
  /** Root program config inherited by the generated Projen config. */
41
47
  readonly config?: javascript.TypescriptConfig;
42
48
 
43
- constructor(scope: Project) {
49
+ constructor(scope: Project, options: DBXToolsRootTsconfigOptions = {}) {
44
50
  super(scope);
45
51
 
46
52
  if (scope instanceof typescript.TypeScriptProject) {
@@ -57,8 +63,9 @@ export class DBXToolsRootTsconfig extends Component {
57
63
  lib: ["ESNext"],
58
64
  types: ["node", "bun"],
59
65
  jsx: javascript.TypeScriptJsxMode.REACT_JSX,
66
+ ...options.compilerOptions,
60
67
  },
61
- include: [".projenrc.ts", "test/**/*.ts", "test/**/*.tsx"],
68
+ include: [".projenrc.ts", "test/**/*.ts", "test/**/*.tsx", ...(options.include ?? [])],
62
69
  exclude: ["node_modules", "**/dist", "**/node_modules"],
63
70
  });
64
71
  }
@@ -43,6 +43,10 @@ export function materializeWorkspaceManifest(
43
43
  workspace.catalog && typeof workspace.catalog === "object"
44
44
  ? (workspace.catalog as Readonly<Record<string, unknown>>)
45
45
  : {};
46
+ const overrides =
47
+ workspace.overrides && typeof workspace.overrides === "object"
48
+ ? (workspace.overrides as Readonly<Record<string, unknown>>)
49
+ : {};
46
50
  if (typeof version !== "string" || !version) {
47
51
  throw new Error("Workspace manifest has no version");
48
52
  }
@@ -52,7 +56,7 @@ export function materializeWorkspaceManifest(
52
56
  manifest[field] = Object.fromEntries(
53
57
  Object.entries(dependencies).map(([name, value]) => [
54
58
  name,
55
- materializeDependency(name, value, version, catalog),
59
+ materializeDependency(name, value, version, catalog, overrides),
56
60
  ]),
57
61
  );
58
62
  }
@@ -134,7 +138,12 @@ function materializeDependency(
134
138
  value: unknown,
135
139
  version: string,
136
140
  catalog: Readonly<Record<string, unknown>>,
141
+ overrides: Readonly<Record<string, unknown>>,
137
142
  ): unknown {
143
+ const override = overrides[name];
144
+ if (typeof override === "string" && override && !override.startsWith("$")) {
145
+ return override;
146
+ }
138
147
  if (value === "catalog:") {
139
148
  const resolved = catalog[name];
140
149
  if (typeof resolved !== "string" || !resolved) {
@@ -1,16 +1,77 @@
1
1
  #!/usr/bin/env -S bun
2
2
  /** Deploy an AppKit workspace through npm-compatible temporary manifests. */
3
+ import { existsSync, rmSync } from "node:fs";
4
+ import { join } from "node:path";
3
5
  import { profile } from "@dbx-tools/auth";
6
+ import { serializeArgs } from "@dbx-tools/cli-args";
4
7
  import * as exec from "@dbx-tools/core/exec";
5
- import { runTaskMain } from "./cli.ts";
8
+ import { errorUtils, log } from "@dbx-tools/shared-core";
9
+ import { z } from "zod";
10
+ import { runTaskMain, taskCommand, taskOptions } from "./cli.ts";
6
11
  import { withStagedWorkspaceManifests } from "../src/workspace-manifest.ts";
7
12
 
13
+ const logger = log.logger("projen:appkit-deploy");
14
+
15
+ export const AppKitDeployContextOptionsSchema = z.object({
16
+ target: z
17
+ .string()
18
+ .trim()
19
+ .min(1)
20
+ .optional()
21
+ .describe("Databricks bundle target")
22
+ .meta({ env: [], short: "t" }),
23
+ profile: z.string().trim().min(1).optional().describe("Databricks CLI profile").meta({
24
+ env: [],
25
+ helpDefault: false,
26
+ short: "p",
27
+ }),
28
+ var: z
29
+ .array(z.string().trim().min(1))
30
+ .default([])
31
+ .describe("Databricks bundle variable assignment")
32
+ .meta({ env: [] }),
33
+ });
34
+
35
+ export const AppKitDeployOptionsSchema = AppKitDeployContextOptionsSchema.extend({
36
+ autoApprove: z
37
+ .boolean()
38
+ .default(false)
39
+ .describe("Skip interactive deployment approval")
40
+ .meta({ env: [] }),
41
+ failOnActiveRuns: z
42
+ .boolean()
43
+ .default(true)
44
+ .describe("Fail when bundle jobs or pipelines are active")
45
+ .meta({ env: [] }),
46
+ skipTests: z
47
+ .boolean()
48
+ .default(true)
49
+ .describe("Skip Databricks CLI tests after the Bun validation")
50
+ .meta({ env: [] }),
51
+ skipValidation: z
52
+ .boolean()
53
+ .default(true)
54
+ .describe("Skip Databricks CLI npm validation after the Bun validation")
55
+ .meta({ env: [] }),
56
+ });
57
+
58
+ export type AppKitDeployOptions = z.output<typeof AppKitDeployOptionsSchema>;
59
+
60
+ interface DeployCommandResult {
61
+ readonly exitCode: number;
62
+ readonly stderr?: string;
63
+ readonly stdout?: string;
64
+ }
65
+
8
66
  /** Effects accepted by the AppKit deploy task for focused tests. */
9
67
  export interface AppKitDeployDependencies {
10
68
  readonly environment?: NodeJS.ProcessEnv;
69
+ readonly logs?: (args: readonly string[], cwd: string) => Promise<DeployCommandResult>;
70
+ readonly plan?: (args: readonly string[], cwd: string) => Promise<DeployCommandResult>;
11
71
  readonly root?: string;
12
72
  readonly resolveProfile?: () => string | undefined;
13
- readonly run?: (args: readonly string[], cwd: string) => Promise<{ readonly exitCode: number }>;
73
+ readonly run?: (args: readonly string[], cwd: string) => Promise<DeployCommandResult>;
74
+ readonly validate?: (args: readonly string[], cwd: string) => Promise<DeployCommandResult>;
14
75
  }
15
76
 
16
77
  /** Run Databricks Apps deploy while generated workspace manifests are materialized. */
@@ -19,7 +80,42 @@ export async function deployApp(
19
80
  dependencies: AppKitDeployDependencies = {},
20
81
  ): Promise<void> {
21
82
  const root = dependencies.root ?? process.cwd();
22
- const deployArgs = resolveDeployArgs(args, dependencies);
83
+ const options = await resolveDeployOptions(args, dependencies);
84
+ const context = AppKitDeployContextOptionsSchema.parse({
85
+ target: options.target,
86
+ profile: options.profile,
87
+ var: options.var,
88
+ });
89
+ const contextArgs = serializeArgs(context);
90
+ const deployArgs = serializeArgs({
91
+ ...context,
92
+ ...(options.autoApprove ? { autoApprove: true } : {}),
93
+ ...(options.failOnActiveRuns ? { failOnActiveRuns: true } : {}),
94
+ ...(options.skipTests ? { skipTests: true } : {}),
95
+ ...(options.skipValidation ? { skipValidation: true } : {}),
96
+ });
97
+ const validate =
98
+ dependencies.validate ??
99
+ (dependencies.run
100
+ ? undefined
101
+ : (validateArgs: readonly string[], cwd: string) =>
102
+ exec.spawn(
103
+ "databricks",
104
+ ["bundle", "validate", ...serializeArgs({ strict: true }), ...validateArgs],
105
+ {
106
+ cwd,
107
+ env: dependencies.environment ?? process.env,
108
+ stdin: "ignore",
109
+ stdout: "inherit",
110
+ stderr: "inherit",
111
+ },
112
+ ));
113
+ if (validate) {
114
+ const validation = await validate(contextArgs, root);
115
+ if (validation.exitCode !== 0) {
116
+ throw new Error(`databricks bundle validate exited with code ${validation.exitCode}`);
117
+ }
118
+ }
23
119
  const run =
24
120
  dependencies.run ??
25
121
  ((deployArgs: readonly string[], cwd: string) =>
@@ -31,40 +127,119 @@ export async function deployApp(
31
127
  stderr: "inherit",
32
128
  }));
33
129
  await withStagedWorkspaceManifests(root, async (stage) => {
130
+ await reconcileStagedBundleState(stage, contextArgs, options.target, dependencies);
34
131
  const result = await run(deployArgs, stage);
35
132
  if (result.exitCode !== 0) {
133
+ await printAppLogs(contextArgs, stage, dependencies);
36
134
  throw new Error(`databricks apps deploy exited with code ${result.exitCode}`);
37
135
  }
38
136
  });
39
137
  }
40
138
 
41
- function resolveDeployArgs(
139
+ async function reconcileStagedBundleState(
140
+ stage: string,
141
+ contextArgs: readonly string[],
142
+ target: string | undefined,
143
+ dependencies: AppKitDeployDependencies,
144
+ ): Promise<void> {
145
+ const plan =
146
+ dependencies.plan ??
147
+ (dependencies.run
148
+ ? undefined
149
+ : (args: readonly string[], cwd: string) =>
150
+ Promise.resolve(
151
+ exec.spawnSync(
152
+ "databricks",
153
+ ["bundle", "plan", ...serializeArgs({ quiet: true }), ...args],
154
+ {
155
+ cwd,
156
+ env: dependencies.environment ?? process.env,
157
+ stdin: "ignore",
158
+ stdout: "capture",
159
+ stderr: "capture",
160
+ },
161
+ ),
162
+ ));
163
+ if (!plan) return;
164
+ const result = await plan(contextArgs, stage);
165
+ if (result.exitCode === 0) return;
166
+ const output = `${result.stderr ?? ""}\n${result.stdout ?? ""}`.toLowerCase();
167
+ if (
168
+ !output.includes("lineage mismatch in state files") ||
169
+ !output.includes("remote direct state")
170
+ ) {
171
+ return;
172
+ }
173
+ if (!target) return;
174
+ const state = join(stage, ".databricks", "bundle", target, "resources.json");
175
+ if (!existsSync(state)) return;
176
+ rmSync(state);
177
+ logger.warn("Removed stale staged bundle state", { target });
178
+ }
179
+
180
+ async function printAppLogs(
181
+ contextArgs: readonly string[],
182
+ cwd: string,
183
+ dependencies: AppKitDeployDependencies,
184
+ ): Promise<void> {
185
+ logger.error("Databricks Apps deployment failed; recent app logs follow");
186
+ const showLogs =
187
+ dependencies.logs ??
188
+ ((args: readonly string[], logsCwd: string) =>
189
+ exec.spawn("databricks", ["apps", "logs", ...serializeArgs({ tailLines: 200 }), ...args], {
190
+ cwd: logsCwd,
191
+ env: dependencies.environment ?? process.env,
192
+ stdin: "ignore",
193
+ stdout: "inherit",
194
+ stderr: "inherit",
195
+ }));
196
+ try {
197
+ const result = await showLogs(contextArgs, cwd);
198
+ if (result.exitCode !== 0) {
199
+ logger.warn("Could not read Databricks App logs", { exitCode: result.exitCode });
200
+ }
201
+ } catch (error) {
202
+ logger.warn("Could not read Databricks App logs", {
203
+ message: errorUtils.toError(error).message,
204
+ });
205
+ }
206
+ }
207
+
208
+ async function resolveDeployOptions(
42
209
  args: readonly string[],
43
210
  dependencies: AppKitDeployDependencies,
44
- ): readonly string[] {
45
- const environment = dependencies.environment ?? process.env;
46
- const hasProfile = args.some(
47
- (argument) =>
48
- argument === "--profile" || argument === "-p" || argument.startsWith("--profile="),
211
+ ): Promise<AppKitDeployOptions> {
212
+ const parsed = await taskOptions(
213
+ taskCommand(
214
+ import.meta.url,
215
+ "Validate and deploy an AppKit workspace through Databricks Apps",
216
+ AppKitDeployOptionsSchema,
217
+ ),
218
+ AppKitDeployOptionsSchema,
219
+ args,
49
220
  );
221
+ const environment = dependencies.environment ?? process.env;
50
222
  const hasEnvironmentAuth =
51
223
  Boolean(environment.DATABRICKS_HOST) &&
52
224
  (Boolean(environment.DATABRICKS_TOKEN) ||
53
225
  (Boolean(environment.DATABRICKS_CLIENT_ID) && Boolean(environment.DATABRICKS_CLIENT_SECRET)));
226
+ const configuredProfile = environment.DATABRICKS_CONFIG_PROFILE ?? environment.DATABRICKS_PROFILE;
54
227
  const resolvedProfile =
55
- hasProfile || hasEnvironmentAuth
228
+ parsed.profile ??
229
+ configuredProfile ??
230
+ (hasEnvironmentAuth
56
231
  ? undefined
57
- : (dependencies.resolveProfile ?? (() => profile.resolveProfile()?.name))();
58
- if (!hasProfile && !hasEnvironmentAuth && !resolvedProfile) {
232
+ : (dependencies.resolveProfile ?? (() => profile.resolveProfile()?.name))());
233
+ if (!hasEnvironmentAuth && !resolvedProfile) {
59
234
  throw new Error(
60
235
  "Select a Databricks profile with --profile or configure Databricks environment credentials",
61
236
  );
62
237
  }
63
- return [
64
- ...(args.includes("--skip-validation") ? [] : ["--skip-validation"]),
65
- ...args,
66
- ...(resolvedProfile ? ["--profile", resolvedProfile] : []),
67
- ];
238
+ return AppKitDeployOptionsSchema.parse({
239
+ ...parsed,
240
+ target: parsed.target ?? environment.DATABRICKS_BUNDLE_TARGET,
241
+ profile: resolvedProfile,
242
+ });
68
243
  }
69
244
 
70
245
  await runTaskMain(import.meta, () => deployApp(process.argv.slice(2)));
@@ -0,0 +1,54 @@
1
+ /** Parse and print generated PyPI trusted-publisher setup instructions. */
2
+ import { z } from "zod";
3
+ import { taskCommand, taskOptions } from "./cli.ts";
4
+
5
+ export const PypiTrustedPublisherInstructionsOptionsSchema = z
6
+ .object({
7
+ secretFile: z
8
+ .string()
9
+ .trim()
10
+ .min(1)
11
+ .optional()
12
+ .describe("Credential file used only when browser authentication is required")
13
+ .meta({ env: [], helpDefault: false, short: false }),
14
+ })
15
+ .describe("Options for rendering PyPI trusted-publisher setup instructions");
16
+
17
+ /** Project-specific instruction sections wrapped around browser authentication guidance. */
18
+ export interface PypiTrustedPublisherInstructions {
19
+ readonly afterAuthentication: readonly string[];
20
+ readonly beforeAuthentication: readonly string[];
21
+ }
22
+
23
+ /** Parse task options and write the complete trusted-publisher instructions. */
24
+ export async function printPypiTrustedPublisherInstructions(
25
+ instructions: PypiTrustedPublisherInstructions,
26
+ argv: readonly string[] = process.argv.slice(2),
27
+ ): Promise<void> {
28
+ const { secretFile } = await taskOptions(
29
+ taskCommand(
30
+ import.meta.url,
31
+ "Print system-browser instructions for PyPI trusted publishers",
32
+ PypiTrustedPublisherInstructionsOptionsSchema,
33
+ ),
34
+ PypiTrustedPublisherInstructionsOptionsSchema,
35
+ argv,
36
+ );
37
+ const authentication = secretFile
38
+ ? [
39
+ `- If browser authentication is required, read credentials from ${secretFile} directly into the browser without printing, logging, or exposing secret values.`,
40
+ `- If ${secretFile} is absent, invalid, or insufficient for authentication, pause and ask the user to complete authentication.`,
41
+ ]
42
+ : [
43
+ "- If browser authentication is required, pause and ask the user to complete authentication.",
44
+ ];
45
+ process.stdout.write(
46
+ `${[
47
+ ...instructions.beforeAuthentication,
48
+ ...authentication,
49
+ ...instructions.afterAuthentication,
50
+ ]
51
+ .join("\n")
52
+ .trimEnd()}\n`,
53
+ );
54
+ }
@@ -13,7 +13,7 @@ import ts from "typescript";
13
13
  import { z } from "zod";
14
14
  import { runTaskMain, taskCommand, taskOptions } from "./cli.ts";
15
15
  import { TaskCheckOptionSchema, TaskForceOptionSchema, TaskRootOptionSchema } from "./options.ts";
16
- import { header, makeReadonly, makeWritable } from "../src/generated.ts";
16
+ import { header, headerLines, makeReadonly, makeWritable } from "../src/generated.ts";
17
17
  import { publicFunctionExports } from "../src/module-exports.ts";
18
18
  import { resolveRepoRoot } from "../src/packages.ts";
19
19
  import {
@@ -434,9 +434,11 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
434
434
  section.includes("Callable["),
435
435
  );
436
436
  return [
437
- "# GENERATED by projen/tasks/python-node-bindings.ts - DO NOT EDIT.",
438
- `# Regenerated from ${source}.`,
439
- "# Hand edits are overwritten; this file is read-only.",
437
+ ...headerLines({
438
+ comment: "#",
439
+ tool: "projen/tasks/python-node-bindings.ts",
440
+ source,
441
+ }),
440
442
  "",
441
443
  "from __future__ import annotations",
442
444
  "",
@@ -1162,9 +1164,11 @@ function removeEmptyDirectories(directory: string): void {
1162
1164
 
1163
1165
  function pythonRuntimeLoader(source: string): string {
1164
1166
  return [
1165
- "# GENERATED by projen/tasks/python-node-bindings.ts - DO NOT EDIT.",
1166
- `# Regenerated from ${source}.`,
1167
- "# Hand edits are overwritten; this file is read-only.",
1167
+ ...headerLines({
1168
+ comment: "#",
1169
+ tool: "projen/tasks/python-node-bindings.ts",
1170
+ source,
1171
+ }),
1168
1172
  "",
1169
1173
  "from __future__ import annotations",
1170
1174
  "",