@dbx-tools/projen 0.9.119 → 0.9.120

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/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.119";
6
+ export const PACKAGE_VERSION = "0.9.120";
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";
@@ -41,7 +41,7 @@ export { BUN_DEV_OVERRIDE, BUN_BUILD_OVERRIDE, BUN_APP_OVERRIDES, RootBunfigFile
41
41
  export { BUN_VERSION } from "./src/bun-workflow.ts";
42
42
  export { DBXToolsConfigDataSchema, DBXToolsConfig } from "./src/dbx-tools-config.ts";
43
43
  export type { DBXToolsConfigOptions, DBXToolsConfigData } from "./src/dbx-tools-config.ts";
44
- export { DEV_WATCH_TASK, DEV_WATCH_SCRIPT, DEV_RESTART_DEBOUNCE_MS, DEV_RESTART_KEY, SERVER_WATCH_DISABLED_ENV } from "./src/dev-watch.ts";
44
+ export { DEV_WATCH_TASK, DEV_WATCH_SCRIPT, DEV_RESTART_DEBOUNCE_MS, DEV_RESTART_KEY } from "./src/dev-watch.ts";
45
45
  export { resolvePkgRoot } from "./src/engine-root.ts";
46
46
  export { PYTHON_GENERATED_PACKAGE, PYTHON_SYNC_PACKAGE } from "./src/generated.ts";
47
47
  export type { HeaderOpts } from "./src/generated.ts";
package/package.json CHANGED
@@ -30,10 +30,10 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@clack/prompts": "^1.7.0",
33
- "@dbx-tools/cli-args": "^0.9.119",
34
- "@dbx-tools/core": "^0.9.119",
35
- "@dbx-tools/path": "^0.9.119",
36
- "@dbx-tools/shared-core": "^0.9.119",
33
+ "@dbx-tools/cli-args": "^0.9.120",
34
+ "@dbx-tools/core": "^0.9.120",
35
+ "@dbx-tools/path": "^0.9.120",
36
+ "@dbx-tools/shared-core": "^0.9.120",
37
37
  "commander": "^15.0.0",
38
38
  "concurrently": "^10.0.3",
39
39
  "constructs": "^10.6.0",
@@ -55,7 +55,7 @@
55
55
  },
56
56
  "main": "index.ts",
57
57
  "license": "Apache-2.0",
58
- "version": "0.9.119",
58
+ "version": "0.9.120",
59
59
  "types": "index.ts",
60
60
  "type": "module",
61
61
  "exports": {
package/src/barrels.ts CHANGED
@@ -339,14 +339,14 @@ export function isGeneratedBarrel(file: string): boolean {
339
339
 
340
340
  /** Public direct modules and child-directory facades for one generated barrel. */
341
341
  function barrelCandidates(barrelDir: string): string[] {
342
- const files = [...find.findFiles("*", { cwd: barrelDir })]
342
+ const files = [...find.findFilesSync("*", { cwd: barrelDir })]
343
343
  .map(toPosix)
344
344
  .filter((file) => !file.includes("/"))
345
345
  .filter((file) => file !== "index.ts" && file !== CUSTOM_EXPORTS_FILE)
346
346
  .filter((file) => isModuleFile(file))
347
347
  .filter((file) => !isExcluded(file, barrelDir))
348
348
  .filter((file) => hasExport(join(barrelDir, file)));
349
- const childIndexes = [...find.findFiles("*/index.ts", { cwd: barrelDir })]
349
+ const childIndexes = [...find.findFilesSync("*/index.ts", { cwd: barrelDir })]
350
350
  .map(toPosix)
351
351
  .filter((file) => file.split("/").length === 2)
352
352
  .filter((file) => !isExcluded(file, barrelDir))
@@ -424,7 +424,7 @@ function generateForPackage(pkgDir: string): number {
424
424
  const srcDir = join(pkgDir, "src");
425
425
  if (!existsSync(srcDir)) return 0;
426
426
 
427
- const sourceFiles = [...find.findFiles("**/*", { cwd: srcDir })]
427
+ const sourceFiles = [...find.findFilesSync("**/*", { cwd: srcDir })]
428
428
  .map(toPosix)
429
429
  .filter((file) => isModuleFile(file) || /(^|\/)index\.ts$/.test(file));
430
430
  const manifest = json.parseRecord(readFileSync(join(pkgDir, "package.json"), "utf8"));
package/src/clean.ts CHANGED
@@ -37,9 +37,9 @@ const CLEAN_SKIP_FILES: ReadonlySet<string> = new Set([".gitignore"]);
37
37
  * ignores AND every dot-prefixed folder (`.projen`, `.vscode`, `.github`, ...), and
38
38
  * {@link CLEAN_SKIP_FILES} entry (`.gitignore`).
39
39
  */
40
- function nativeGeneratedFiles(root: string): string[] {
40
+ async function nativeGeneratedFiles(root: string): Promise<string[]> {
41
41
  const files = new Set<string>();
42
- for (const manifestPath of find.findFiles("**/.projen/files.json", {
42
+ for await (const manifestPath of find.findFiles("**/.projen/files.json", {
43
43
  cwd: root,
44
44
  ignoreOptions: { dot: false },
45
45
  })) {
@@ -54,12 +54,14 @@ function nativeGeneratedFiles(root: string): string[] {
54
54
  return [...files];
55
55
  }
56
56
 
57
- export function listGeneratedFiles(root: string = resolveRepoRoot()): string[] {
57
+ export async function listGeneratedFiles(root: string = resolveRepoRoot()): Promise<string[]> {
58
58
  const rel = (f: string): string => toPosix(relative(root, f));
59
- const custom = [...find.findFiles("**/*", { cwd: root })]
60
- .map((f) => join(root, f))
61
- .filter(isGenerated);
62
- return [...new Set([...nativeGeneratedFiles(root), ...custom])]
59
+ const custom: string[] = [];
60
+ for await (const file of find.findFiles("**/*", { cwd: root })) {
61
+ const absolute = join(root, file);
62
+ if (isGenerated(absolute)) custom.push(absolute);
63
+ }
64
+ return [...new Set([...(await nativeGeneratedFiles(root)), ...custom])]
63
65
  .filter(existsSync)
64
66
  .filter(
65
67
  (file) =>
@@ -79,12 +81,17 @@ export function listGeneratedFiles(root: string = resolveRepoRoot()): string[] {
79
81
  * own - removing the top-level dir takes it along. Other vendor/build/VCS dirs are
80
82
  * skipped for speed.
81
83
  */
82
- export function listNodeModulesDirs(root: string = resolveRepoRoot()): string[] {
84
+ export async function listNodeModulesDirs(root: string = resolveRepoRoot()): Promise<string[]> {
83
85
  if (!existsSync(root)) return [];
84
86
  const rel = (f: string): string => toPosix(relative(root, f));
85
- return [...find.findFiles("**/node_modules", { cwd: root, ignoreOptions: { dot: false } })]
86
- .map((match) => join(root, match))
87
- .sort((a, b) => rel(a).localeCompare(rel(b)));
87
+ const directories: string[] = [];
88
+ for await (const match of find.findFiles("**/node_modules", {
89
+ cwd: root,
90
+ ignoreOptions: { dot: false },
91
+ })) {
92
+ directories.push(join(root, match));
93
+ }
94
+ return directories.sort((a, b) => rel(a).localeCompare(rel(b)));
88
95
  }
89
96
 
90
97
  /**
package/src/dev-watch.ts CHANGED
@@ -8,8 +8,3 @@ export const DEV_WATCH_SCRIPT = "dev-watch.ts";
8
8
  export const DEV_RESTART_DEBOUNCE_MS = 15_000;
9
9
  /** Interactive key that requests an immediate restart. */
10
10
  export const DEV_RESTART_KEY = "r";
11
- /**
12
- * Process environment that skips wrapping or running the file watcher.
13
- * Parsed with `configUtils.boolean` (`1` / `true` / `yes` disable watch).
14
- */
15
- export const SERVER_WATCH_DISABLED_ENV = "SERVER_WATCH_DISABLED";
package/src/packages.ts CHANGED
@@ -174,7 +174,7 @@ function packageOfMember(projectRoot: string, member: string): DiscoveredPackage
174
174
  }
175
175
 
176
176
  /**
177
- * Package dirs under `rootAbs`, found with a single `find.findFiles` scan from
177
+ * Package dirs under `rootAbs`, found with a single `find.findFilesSync` scan from
178
178
  * `@dbx-tools/path` for module files beneath any `src/`. A package is the
179
179
  * folder that OWNS the `src/` - the segments before the FIRST `src/` - so a package's
180
180
  * own subfolders never become nested packages (outermost wins). Barrels/tests/decls
@@ -184,7 +184,7 @@ function packageOfMember(projectRoot: string, member: string): DiscoveredPackage
184
184
  */
185
185
  function collectPackageDirs(rootAbs: string): string[] {
186
186
  const owners = new Set<string>();
187
- for (const file of find.findFiles(SRC_MODULE_GLOB, { cwd: rootAbs })) {
187
+ for (const file of find.findFilesSync(SRC_MODULE_GLOB, { cwd: rootAbs })) {
188
188
  if (!isModuleFile(file)) continue;
189
189
  const segs = toPosix(file).split("/");
190
190
  const srcIdx = segs.indexOf("src");
@@ -328,7 +328,7 @@ export function resolveAllPythonNodeBindings(
328
328
  export function pythonNodeBindingProjects(projectRoot: string): string[] {
329
329
  return [
330
330
  ...find
331
- .findFiles("**/pyproject.toml", { cwd: projectRoot })
331
+ .findFilesSync("**/pyproject.toml", { cwd: projectRoot })
332
332
  .filter((file) => {
333
333
  const manifest = parse(readFileSync(resolve(projectRoot, file), "utf8")) as {
334
334
  tool?: { dbx_tools?: { node_bindings?: unknown } };
@@ -6,7 +6,7 @@
6
6
  * unused exports and duplicated owned helpers.
7
7
  */
8
8
  import { readFileSync } from "node:fs";
9
- import { join, relative } from "node:path";
9
+ import { join, relative, resolve } from "node:path";
10
10
  import { find } from "@dbx-tools/path";
11
11
  import * as core from "@dbx-tools/shared-core";
12
12
  import { PACKAGE_IDENTIFIER, object } from "@dbx-tools/shared-core";
@@ -59,7 +59,7 @@ function namespaceMap(coreDir: string): Readonly<Record<string, string>> {
59
59
  );
60
60
  }
61
61
 
62
- function sourceFiles(root: string, includeGenerated: boolean): string[] {
62
+ async function sourceFiles(root: string, includeGenerated: boolean): Promise<string[]> {
63
63
  const ignore = [
64
64
  "**/node_modules/**",
65
65
  "**/lib/**",
@@ -69,7 +69,11 @@ function sourceFiles(root: string, includeGenerated: boolean): string[] {
69
69
  "**/_runtime.js",
70
70
  ];
71
71
  if (!includeGenerated) ignore.push(`**/${PYTHON_GENERATED_PACKAGE}/**`);
72
- return [...find.findFiles(SOURCE_PATTERN, { cwd: root, ignore })].sort();
72
+ const files: string[] = [];
73
+ for await (const file of find.findFiles(SOURCE_PATTERN, { cwd: root, ignore })) {
74
+ files.push(resolve(root, file));
75
+ }
76
+ return files.sort();
73
77
  }
74
78
 
75
79
  function classifyFile(
@@ -114,11 +118,13 @@ function mentionsExport(
114
118
  }
115
119
 
116
120
  /** Classify every namespace runtime export against repository callers. */
117
- export function sharedCoreExportUsage(root: string = repoRoot): SharedCoreExportUsage[] {
121
+ export async function sharedCoreExportUsage(
122
+ root: string = repoRoot,
123
+ ): Promise<SharedCoreExportUsage[]> {
118
124
  const coreDir = sharedCorePackageDir(root);
119
125
  const coreDirRel = relative(root, coreDir);
120
126
  const namespaces = namespaceMap(coreDir);
121
- const files = sourceFiles(root, true).map((file) => ({
127
+ const files = (await sourceFiles(root, true)).map((file) => ({
122
128
  path: relative(root, file),
123
129
  text: readFileSync(file, "utf8"),
124
130
  }));
@@ -152,9 +158,9 @@ export function sharedCoreExportUsage(root: string = repoRoot): SharedCoreExport
152
158
  }
153
159
 
154
160
  /** Local copies of helpers owned by shared-core. */
155
- export function duplicatedOwnedHelpers(root: string = repoRoot): string[] {
161
+ export async function duplicatedOwnedHelpers(root: string = repoRoot): Promise<string[]> {
156
162
  const duplicates: string[] = [];
157
- for (const file of sourceFiles(root, false)) {
163
+ for (const file of await sourceFiles(root, false)) {
158
164
  const relativePath = relative(root, file);
159
165
  if (OWNED_HELPER_FILES.has(relativePath)) continue;
160
166
  if (relativePath.startsWith("packages/js/shared/core/test/")) continue;
package/src/watch.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  */
18
18
  import { isAbsolute, resolve } from "node:path";
19
19
  import { watch as pathWatch } from "@dbx-tools/path";
20
- import { asyncUtils, log } from "@dbx-tools/shared-core";
20
+ import { asyncUtils, errorUtils, log } from "@dbx-tools/shared-core";
21
21
  import { isGeneratedFile, recordedRoots, repoRoot } from "./packages.ts";
22
22
  import { withWorkspaceMutationLock } from "./workspace-lock.ts";
23
23
 
@@ -90,12 +90,12 @@ export interface WatchLoopOptions {
90
90
  check?: (changed: string[]) => boolean | Promise<boolean>;
91
91
  }
92
92
 
93
- export function watchLoop(
93
+ export async function watchLoop(
94
94
  tag: string,
95
95
  paths: string[],
96
96
  onBatch: (changed: string[]) => void | Promise<void>,
97
97
  config: WatchLoopOptions = {},
98
- ): void {
98
+ ): Promise<void> {
99
99
  const { ignoreOptions, check } = config;
100
100
  const pending = new Set<string>();
101
101
  let timer: ReturnType<typeof setTimeout> | undefined;
@@ -119,7 +119,7 @@ export function watchLoop(
119
119
  });
120
120
  }
121
121
  } catch (err) {
122
- logger.error(`${tag} cycle failed:`, err instanceof Error ? err.message : err);
122
+ logger.error(`${tag} cycle failed`, { error: errorUtils.toError(err) });
123
123
  } finally {
124
124
  running = false;
125
125
  if (rerun) {
@@ -129,7 +129,7 @@ export function watchLoop(
129
129
  }
130
130
  }
131
131
 
132
- const watcher = pathWatch.watchFiles(paths, {
132
+ const watcher = await pathWatch.watchFiles(paths, {
133
133
  cwd: repoRoot,
134
134
  ignoreInitial: true,
135
135
  ignore: (path) => ignoredPath(path),
@@ -156,7 +156,7 @@ export function watchLoop(
156
156
  timer = setTimeout(() => void flush(), DEBOUNCE_MS);
157
157
  });
158
158
  watcher.on("error", (err) => {
159
- logger.error(`${tag} watcher error:`, err);
159
+ logger.error(`${tag} watcher error`, { error: errorUtils.toError(err) });
160
160
  const code = (err as NodeJS.ErrnoException | undefined)?.code;
161
161
  if (code === undefined || !FATAL_WATCH_ERROR_CODES.has(code)) return;
162
162
  logger.error(`${tag}: ${code} leaves the watcher blind - exiting so it is restarted`);
package/tasks/barrels.ts CHANGED
@@ -57,7 +57,7 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
57
57
  // Watch the package roots; a source edit inside a package rebuilds just that
58
58
  // package's `index.ts` barrel (no re-synth - the projenrc watcher owns that).
59
59
  // watchLoop already drops generated paths, so a barrel write never re-triggers us.
60
- watchLoop(
60
+ await watchLoop(
61
61
  "barrels",
62
62
  watchRoots(),
63
63
  (changed) => {
package/tasks/clean.ts CHANGED
@@ -27,8 +27,7 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
27
27
  args,
28
28
  );
29
29
 
30
- const files = listGeneratedFiles();
31
- const nodeModules = listNodeModulesDirs();
30
+ const [files, nodeModules] = await Promise.all([listGeneratedFiles(), listNodeModulesDirs()]);
32
31
  const targets = [...files, ...nodeModules];
33
32
 
34
33
  if (targets.length === 0) {
@@ -4,7 +4,7 @@ import { existsSync, readFileSync } from "node:fs";
4
4
  import { availableParallelism } from "node:os";
5
5
  import { join, resolve } from "node:path";
6
6
  import * as exec from "@dbx-tools/core/exec";
7
- import { log } from "@dbx-tools/shared-core";
7
+ import { errorUtils, log } from "@dbx-tools/shared-core";
8
8
  import { runTaskMain } from "./cli.ts";
9
9
 
10
10
  const logger = log.logger("projen:compile");
@@ -116,7 +116,9 @@ export async function main(): Promise<void> {
116
116
  let failed = false;
117
117
  for (const [index, result] of results.entries()) {
118
118
  if (result.status === "rejected") {
119
- logger.error(`${jobs[index]!.name} compile failed:`, result.reason);
119
+ logger.error(`${jobs[index]!.name} compile failed`, {
120
+ error: errorUtils.toError(result.reason),
121
+ });
120
122
  failed = true;
121
123
  } else if (result.value.exitCode !== 0) {
122
124
  logger.error(`${jobs[index]!.name} compile exited with ${result.value.exitCode}`);
@@ -1,18 +1,14 @@
1
1
  #!/usr/bin/env -S bun
2
2
  /** Restart a development command after relevant workspace changes settle. */
3
3
  import { isAbsolute, relative, resolve } from "node:path";
4
+ import { serializeArgs } from "@dbx-tools/cli-args";
4
5
  import * as exec from "@dbx-tools/core/exec";
5
6
  import { watch as pathWatch } from "@dbx-tools/path";
6
- import { log } from "@dbx-tools/shared-core";
7
+ import { asyncUtils, errorUtils, log } from "@dbx-tools/shared-core";
7
8
  import { CommanderError } from "commander";
8
9
  import { z } from "zod";
9
10
  import { parsedTaskOptions, runTaskMain, taskCommand, taskPositionals } from "./cli.ts";
10
- import {
11
- DEV_RESTART_DEBOUNCE_MS,
12
- DEV_RESTART_KEY,
13
- DEV_WATCH_TASK,
14
- SERVER_WATCH_DISABLED_ENV,
15
- } from "../src/dev-watch.ts";
11
+ import { DEV_RESTART_DEBOUNCE_MS, DEV_RESTART_KEY, DEV_WATCH_TASK } from "../src/dev-watch.ts";
16
12
  import {
17
13
  readPackageManifest,
18
14
  recordedPackages,
@@ -23,33 +19,41 @@ import {
23
19
  const logger = log.logger("projen:dev-watch");
24
20
  const SHUTDOWN_TIMEOUT_MS = 2_000;
25
21
 
26
- export interface DevWatchOptions {
27
- readonly command: readonly string[];
28
- readonly debounceMs: number;
29
- readonly restartKey: string;
30
- /** When true, run the command once and skip file-change restarts. */
31
- readonly serverWatchDisabled: boolean;
32
- }
33
-
34
22
  export const DevWatchOptionsSchema = z.object({
35
23
  debounceMs: z.coerce
36
24
  .number()
37
25
  .int()
38
26
  .nonnegative("Debounce must be a non-negative number of milliseconds")
39
27
  .default(DEV_RESTART_DEBOUNCE_MS)
40
- .describe("Quiet period before restarting the command"),
28
+ .describe("Quiet period before restarting the command")
29
+ .meta({ short: false }),
41
30
  restartKey: z
42
31
  .string()
43
32
  .refine((value) => Array.from(value).length === 1, "Restart key must be exactly one character")
44
33
  .default(DEV_RESTART_KEY)
45
34
  .describe("Interactive immediate-restart key"),
46
- serverWatchDisabled: z
35
+ watchDisable: z
47
36
  .boolean()
48
37
  .default(false)
49
- .describe("Run the command once without watching")
50
- .meta({ env: SERVER_WATCH_DISABLED_ENV }),
38
+ .describe("Run the command once without watching files or restarting on changes")
39
+ .meta({ env: "DEV_WATCH_DISABLE", short: "d" }),
40
+ watchInclude: z
41
+ .array(z.string())
42
+ .default([])
43
+ .describe("Glob patterns to watch; replaces the default workspace-package directories")
44
+ .meta({ env: "DEV_WATCH_INCLUDE", short: "i" }),
45
+ watchExclude: z
46
+ .array(z.string())
47
+ .default([])
48
+ .describe("Glob patterns to ignore after the include set is chosen")
49
+ .meta({ env: "DEV_WATCH_EXCLUDE", short: "e" }),
51
50
  });
52
51
 
52
+ /** Parsed watcher flags plus the pass-through command argv. */
53
+ export type DevWatchOptions = z.output<typeof DevWatchOptionsSchema> & {
54
+ readonly command: readonly string[];
55
+ };
56
+
53
57
  interface WorkspacePackage {
54
58
  readonly dir: string;
55
59
  readonly name?: string;
@@ -76,15 +80,50 @@ export function parseDevWatchOptions(args: readonly string[]): DevWatchOptions {
76
80
  .parse([...args], { from: "user" });
77
81
  const options = parsedTaskOptions(program, DevWatchOptionsSchema);
78
82
  const [command, commandArgs = []] = taskPositionals(program) as [string, string[]?];
79
- if (options.serverWatchDisabled) process.env[SERVER_WATCH_DISABLED_ENV] = "1";
80
83
  return {
84
+ ...options,
81
85
  command: [command, ...commandArgs],
82
- debounceMs: options.debounceMs,
83
- restartKey: options.restartKey,
84
- serverWatchDisabled: options.serverWatchDisabled,
85
86
  };
86
87
  }
87
88
 
89
+ /** Watcher flags the root demo may forward into a nested `dev:watch`. */
90
+ export type DevWatchForwardOptions = Pick<
91
+ DevWatchOptions,
92
+ "watchDisable" | "watchInclude" | "watchExclude"
93
+ >;
94
+
95
+ /**
96
+ * Parse `--watch-disable` / `--watch-include` / `--watch-exclude` from argv that
97
+ * may contain unrelated tokens (the root demo forwards only these flags).
98
+ */
99
+ export function parseWatchGlobOptions(args: readonly string[]): DevWatchForwardOptions {
100
+ const program = taskCommand(
101
+ import.meta.url,
102
+ "Restart a development command after watched changes settle",
103
+ DevWatchOptionsSchema,
104
+ )
105
+ .name(DEV_WATCH_TASK)
106
+ .exitOverride()
107
+ .allowUnknownOption(true)
108
+ .allowExcessArguments(true)
109
+ .parse([...args], { from: "user" });
110
+ const options = parsedTaskOptions(program, DevWatchOptionsSchema);
111
+ return {
112
+ watchDisable: options.watchDisable,
113
+ watchInclude: options.watchInclude,
114
+ watchExclude: options.watchExclude,
115
+ };
116
+ }
117
+
118
+ /** Serialize forwarded watcher flags for a nested `dev:watch`. */
119
+ export function serializedWatchGlobArgs(options: DevWatchForwardOptions): string[] {
120
+ return serializeArgs({
121
+ ...(options.watchDisable ? { watchDisable: true } : {}),
122
+ watchInclude: options.watchInclude,
123
+ watchExclude: options.watchExclude,
124
+ });
125
+ }
126
+
88
127
  function containsPath(parent: string, candidate: string): boolean {
89
128
  const rel = relative(parent, candidate);
90
129
  return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
@@ -156,6 +195,23 @@ export function devWatchDirectories(
156
195
  return dependencies.length > 0 ? dependencies : [owner.dir];
157
196
  }
158
197
 
198
+ /**
199
+ * Choose watch paths for {@link pathWatch.watchFiles}.
200
+ * Default workspace-package directories apply until `--watch-include` is set;
201
+ * `--watch-exclude` is always passed through as ignore globs.
202
+ */
203
+ export function devWatchTargets(
204
+ command: readonly string[],
205
+ cwd: string,
206
+ include: readonly string[] = [],
207
+ exclude: readonly string[] = [],
208
+ ): { paths: readonly string[]; ignore: readonly string[] } {
209
+ return {
210
+ paths: include.length > 0 ? [...include] : devWatchDirectories(command, cwd),
211
+ ignore: [...exclude],
212
+ };
213
+ }
214
+
159
215
  /** Compare one raw terminal character with the configured restart key. */
160
216
  export function isRestartKey(key: string, restartKey: string): boolean {
161
217
  return key === restartKey || key.toLocaleLowerCase() === restartKey.toLocaleLowerCase();
@@ -172,12 +228,13 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
172
228
  return;
173
229
  }
174
230
  const cwd = process.cwd();
175
- if (options.serverWatchDisabled) {
176
- logger.info(`${SERVER_WATCH_DISABLED_ENV} is set; running without file watching`);
231
+ const commandLabel = options.command.map((part) => JSON.stringify(part)).join(" ");
232
+ if (options.watchDisable) {
233
+ logger.info(`watch disabled; running ${commandLabel}`);
177
234
  const [command, ...commandArgs] = options.command;
178
235
  const { exitCode } = await exec.spawn(command!, commandArgs, {
179
236
  cwd,
180
- stdin: "ignore",
237
+ stdin: "inherit",
181
238
  stdout: "inherit",
182
239
  stderr: "inherit",
183
240
  check: false,
@@ -187,19 +244,37 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
187
244
  }
188
245
 
189
246
  const root = resolveRepoRoot(cwd);
190
- const watchDirectories = devWatchDirectories(options.command, cwd);
191
- const commandLabel = options.command.map((part) => JSON.stringify(part)).join(" ");
247
+ const { paths: watchPaths, ignore: watchIgnore } = devWatchTargets(
248
+ options.command,
249
+ cwd,
250
+ options.watchInclude,
251
+ options.watchExclude,
252
+ );
192
253
  let child: exec.ChildProcessResult | undefined;
193
254
  let timer: ReturnType<typeof setTimeout> | undefined;
194
255
  let restartReason: string | undefined;
195
256
  let restarting = false;
196
257
  let stopping = false;
197
258
 
198
- const watcher = pathWatch.watchFiles(watchDirectories, {
259
+ const watcher = await pathWatch.watchFiles([...watchPaths], {
199
260
  cwd: root,
261
+ ignore: watchIgnore.length > 0 ? [...watchIgnore] : undefined,
200
262
  ignoreInitial: true,
263
+ ignoreOptions: {
264
+ git: true,
265
+ },
201
266
  });
202
267
 
268
+ function interruptChild(): void {
269
+ const current = child;
270
+ if (!current?.pid) return;
271
+ try {
272
+ current.kill("SIGINT");
273
+ } catch {
274
+ // The command already exited.
275
+ }
276
+ }
277
+
203
278
  async function stopChild(): Promise<void> {
204
279
  const current = child;
205
280
  if (!current) return;
@@ -210,7 +285,9 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
210
285
  forceTimeoutMs: SHUTDOWN_TIMEOUT_MS,
211
286
  });
212
287
  } catch (err) {
213
- logger.error("failed to stop command process tree:", err);
288
+ logger.error("failed to stop command process tree", {
289
+ error: errorUtils.toError(err),
290
+ });
214
291
  }
215
292
  }
216
293
 
@@ -236,7 +313,7 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
236
313
  .catch((err) => {
237
314
  if (child !== current || stopping) return;
238
315
  child = undefined;
239
- logger.error("command failed:", err);
316
+ logger.error("command failed", { error: errorUtils.toError(err) });
240
317
  });
241
318
  }
242
319
 
@@ -269,11 +346,12 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
269
346
  }
270
347
 
271
348
  watcher.on("all", scheduleRestart);
272
- watcher.on("error", (err) => logger.error("watcher error:", err));
349
+ watcher.on("error", (err) => logger.error("watcher error", { error: errorUtils.toError(err) }));
273
350
  watcher.on("ready", () => {
274
- const packages = watchDirectories.length;
351
+ const count = watchPaths.length;
352
+ const source = options.watchInclude.length > 0 ? "include glob" : "workspace directory";
275
353
  logger.info(
276
- `watching ${packages} workspace ${packages === 1 ? "directory" : "directories"} with ${options.debounceMs}ms restart debounce`,
354
+ `watching ${count} ${source}${count === 1 ? "" : "s"} with ${options.debounceMs}ms restart debounce`,
277
355
  );
278
356
  logger.info(`press ${JSON.stringify(options.restartKey)} to restart; Ctrl-C to stop`);
279
357
  });
@@ -292,33 +370,37 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
292
370
  }
293
371
  }
294
372
  };
373
+
374
+ function restoreStdin(): void {
375
+ stdin.off("data", onInput);
376
+ if (stdin.isTTY) stdin.setRawMode(wasRaw);
377
+ stdin.pause();
378
+ }
379
+
295
380
  if (stdin.isTTY) stdin.setRawMode(true);
296
381
  stdin.resume();
297
382
  stdin.on("data", onInput);
298
383
 
299
- let finish!: () => void;
300
- const complete = new Promise<void>((resolveComplete) => {
301
- finish = resolveComplete;
302
- });
303
-
304
- async function shutdown(): Promise<void> {
305
- if (stopping) return;
384
+ async function shutdown(exitCode = 0): Promise<void> {
385
+ if (stopping) process.exit(exitCode || 130);
306
386
  stopping = true;
307
387
  clearTimeout(timer);
308
388
  restartReason = undefined;
309
- stdin.off("data", onInput);
310
- if (stdin.isTTY) stdin.setRawMode(wasRaw);
311
- stdin.pause();
312
- await Promise.allSettled([watcher.close(), stopChild()]);
313
- finish();
389
+ restoreStdin();
390
+ interruptChild();
391
+ await Promise.race([
392
+ Promise.allSettled([watcher.close(), stopChild()]),
393
+ asyncUtils.sleep(SHUTDOWN_TIMEOUT_MS),
394
+ ]);
395
+ process.exit(exitCode);
314
396
  }
315
397
 
316
398
  for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
317
- process.once(signal, () => void shutdown());
399
+ process.on(signal, () => void shutdown());
318
400
  }
319
401
 
320
402
  requestRestart("initial start");
321
- await complete;
403
+ await new Promise<void>(() => {});
322
404
  }
323
405
 
324
406
  await runTaskMain(import.meta, main);
package/tasks/projenrc.ts CHANGED
@@ -26,8 +26,8 @@ function resynthWatchPaths(): string[] {
26
26
  // makes the watch intelligent vs stock `projen --watch`, which re-synths on any tree
27
27
  // change. `{ dot: false }` keeps the default dotfile ignore group from pruning dotfile
28
28
  // targets. (A one-shot "projenrc" is just a synth, i.e. `bun run default`.)
29
- export function main(): void {
30
- watchLoop(
29
+ export async function main(): Promise<void> {
30
+ await watchLoop(
31
31
  "projenrc",
32
32
  resynthWatchPaths(),
33
33
  () => {
@@ -63,7 +63,7 @@ export async function main(): Promise<void> {
63
63
  await withWorkspaceMutationLock(repoRoot, () => generatePythonNodeBindings(repoRoot));
64
64
  logger.success("generated all Python Node bindings");
65
65
 
66
- watchLoop(
66
+ await watchLoop(
67
67
  "python-node-bindings",
68
68
  watchPaths(configs),
69
69
  (changed) => {
@@ -690,7 +690,8 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
690
690
  path: string,
691
691
  preferredName?: string,
692
692
  ): PythonRecord | undefined {
693
- if (!(type.flags & ts.TypeFlags.Object)) return undefined;
693
+ // Named intersections (`Omit<...> & { nested?: Named }`) are records.
694
+ if (!(type.flags & ts.TypeFlags.Object) && !type.isIntersection()) return undefined;
694
695
  if (checker.isArrayType(type) || type.getCallSignatures().length > 0) return undefined;
695
696
  if (checker.getIndexTypeOfType(type, ts.IndexKind.String)) return undefined;
696
697
  const symbolName = type.aliasSymbol?.getName() ?? type.getSymbol()?.getName();
@@ -32,7 +32,7 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
32
32
  await withWorkspaceMutationLock(repoRoot, build);
33
33
  logger.success("built shared Python Node runtime");
34
34
 
35
- watchLoop("python-node-runtime", [buildScript, shimDirectory], () => {
35
+ await watchLoop("python-node-runtime", [buildScript, shimDirectory], () => {
36
36
  build();
37
37
  logger.success("rebuilt shared Python Node runtime");
38
38
  });
package/tasks/release.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  import { readFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import * as projectUtils from "@dbx-tools/core/project-utils";
6
- import { log } from "@dbx-tools/shared-core";
6
+ import { errorUtils, log } from "@dbx-tools/shared-core";
7
7
  import type { Command } from "commander";
8
8
  import { z } from "zod";
9
9
  import { parsedTaskOptions, runTaskMain, taskCommand } from "./cli.ts";
@@ -149,7 +149,7 @@ function pushAnnotatedReleaseTag(options: {
149
149
  } catch (error) {
150
150
  if (remoteRefCreated) throw error;
151
151
  logger.warn("GitHub API tag creation failed; using git push", {
152
- error,
152
+ error: errorUtils.toError(error),
153
153
  repository: githubAccount.remote.repository,
154
154
  });
155
155
  runTaskCommand(root, "git", ["push", remote, `refs/tags/${tag}`]);
@@ -3,8 +3,8 @@
3
3
  import { runTaskMain } from "./cli.ts";
4
4
  import { formatSharedCoreUsage, sharedCoreExportUsage } from "../src/shared-core-usage.ts";
5
5
 
6
- export function main(): void {
7
- process.stdout.write(formatSharedCoreUsage(sharedCoreExportUsage()));
6
+ export async function main(): Promise<void> {
7
+ process.stdout.write(formatSharedCoreUsage(await sharedCoreExportUsage()));
8
8
  }
9
9
 
10
10
  await runTaskMain(import.meta, main);
package/tasks/sync.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env -S bun
2
2
  /** Synthesize once or supervise Projen, barrels, and registered focused watchers. */
3
3
  import { fileURLToPath } from "node:url";
4
- import { log } from "@dbx-tools/shared-core";
4
+ import { errorUtils, log } from "@dbx-tools/shared-core";
5
5
  import concurrently from "concurrently";
6
6
  import { z } from "zod";
7
7
  import { runTaskMain, taskCommand, taskOptions } from "./cli.ts";
@@ -84,10 +84,7 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
84
84
  } catch (err) {
85
85
  // A tree that doesn't synth is precisely when the watcher is most useful: the edit
86
86
  // that repairs it is the one the projenrc watcher is sitting there waiting for.
87
- logger.error(
88
- "initial sync failed - watching anyway:",
89
- err instanceof Error ? err.message : err,
90
- );
87
+ logger.error("initial sync failed - watching anyway", { error: errorUtils.toError(err) });
91
88
  }
92
89
 
93
90
  const watchers = [
@@ -24,7 +24,7 @@ function pythonVersion(path: string): string | undefined {
24
24
  return typeof manifest.project?.version === "string" ? manifest.project.version : undefined;
25
25
  }
26
26
 
27
- export function main(): void {
27
+ export async function main(): Promise<void> {
28
28
  const root = projectUtils.root() ?? process.cwd();
29
29
  const version = readWorkspaceVersion(root);
30
30
  const mismatches: string[] = [];
@@ -37,7 +37,7 @@ export function main(): void {
37
37
  check(`${pkg.path}/package.json`, manifestVersion(join(pkg.dir, "package.json")));
38
38
  }
39
39
 
40
- for (const path of find.findFiles("**/pyproject.toml", { cwd: root })) {
40
+ for await (const path of find.findFiles("**/pyproject.toml", { cwd: root })) {
41
41
  check(path, pythonVersion(join(root, path)));
42
42
  }
43
43
  if (mismatches.length > 0) {