@dbx-tools/projen 0.9.12 → 0.9.18

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.
@@ -47,13 +47,15 @@ export interface PlannedRustTarget {
47
47
  readonly runner: string;
48
48
  readonly python: string;
49
49
  readonly libc: string;
50
- readonly cargoExcludes: string;
50
+ readonly glibcVersion: string;
51
51
  readonly packages: readonly string[];
52
+ readonly binaries: readonly string[];
53
+ readonly features: readonly string[];
52
54
  }
53
55
 
54
56
  /** Complete plan persisted as a workflow artifact and used for recovery. */
55
57
  export interface ReleasePlan {
56
- readonly schemaVersion: 1;
58
+ readonly schemaVersion: 2;
57
59
  readonly units: readonly PlannedReleaseUnit[];
58
60
  readonly publishBatches: readonly (readonly string[])[];
59
61
  readonly nodePackages: readonly PlannedPackage[];
@@ -130,18 +132,52 @@ export function buildReleasePlan(
130
132
  ]);
131
133
  }
132
134
  }
133
- const rustTargetsByKey = new Map<string, PlannedRustTarget>();
135
+ const rustTargets = new Map<string, PlannedRustTarget>();
134
136
  for (const artifact of artifacts) {
135
137
  const targets = artifact.data?.targets;
136
138
  if (!Array.isArray(targets)) continue;
139
+ const binary = artifact.kind === "github-binary";
137
140
  for (const value of targets) {
138
141
  if (!value || typeof value !== "object" || Array.isArray(value)) continue;
139
142
  const target = value as Record<string, unknown>;
140
143
  const required = ["os", "cpu", "node", "cargo", "runner", "python"] as const;
141
144
  if (!required.every((field) => typeof target[field] === "string")) continue;
142
145
  const key = String(target.node);
143
- const currentTarget = rustTargetsByKey.get(key);
144
- rustTargetsByKey.set(key, {
146
+ const currentTarget = rustTargets.get(key);
147
+ const packages = [
148
+ ...new Set([
149
+ ...(currentTarget?.packages ?? []),
150
+ ...(rustPackagesByUnit.get(artifact.unit) ?? []),
151
+ ]),
152
+ ].sort();
153
+ const binaries = binary
154
+ ? [
155
+ ...new Set([
156
+ ...(currentTarget?.binaries ?? []),
157
+ ...(typeof artifact.data?.binary === "string" ? [artifact.data.binary] : []),
158
+ ]),
159
+ ].sort()
160
+ : (currentTarget?.binaries ?? []);
161
+ const configuredFeatures = Array.isArray(artifact.data?.features)
162
+ ? artifact.data.features.filter((feature): feature is string => typeof feature === "string")
163
+ : [];
164
+ const crate = typeof artifact.data?.crate === "string" ? artifact.data.crate : undefined;
165
+ const features = binary
166
+ ? [
167
+ ...new Set([
168
+ ...(currentTarget?.features ?? []),
169
+ ...(crate
170
+ ? configuredFeatures.map((feature) => `${crate}/${feature}`)
171
+ : configuredFeatures),
172
+ ]),
173
+ ].sort()
174
+ : [
175
+ ...new Set([
176
+ ...(currentTarget?.features ?? []),
177
+ ...packages.map((pkg) => `${pkg}/uniffi-bindgen`),
178
+ ]),
179
+ ].sort();
180
+ rustTargets.set(key, {
145
181
  os: String(target.os),
146
182
  cpu: String(target.cpu),
147
183
  node: String(target.node),
@@ -149,17 +185,14 @@ export function buildReleasePlan(
149
185
  runner: String(target.runner),
150
186
  python: String(target.python),
151
187
  libc: typeof target.libc === "string" ? target.libc : "",
152
- cargoExcludes: typeof target.cargoExcludes === "string" ? target.cargoExcludes : "",
153
- packages: [
154
- ...new Set([
155
- ...(currentTarget?.packages ?? []),
156
- ...(rustPackagesByUnit.get(artifact.unit) ?? []),
157
- ]),
158
- ].sort(),
188
+ glibcVersion: typeof target.glibcVersion === "string" ? target.glibcVersion : "",
189
+ packages,
190
+ binaries,
191
+ features,
159
192
  });
160
193
  }
161
194
  }
162
- const rustTargets = [...rustTargetsByKey.values()].sort((a, b) => a.node.localeCompare(b.node));
195
+ const plannedRustTargets = [...rustTargets.values()].sort((a, b) => a.node.localeCompare(b.node));
163
196
  const publishBatches = current.publishBatches
164
197
  .map((batch) => batch.filter((unit) => selected.has(unit)))
165
198
  .filter((batch) => batch.length > 0);
@@ -179,14 +212,14 @@ export function buildReleasePlan(
179
212
  .filter(([, enabled]) => !enabled)
180
213
  .map(([name]) => name);
181
214
  return {
182
- schemaVersion: 1,
215
+ schemaVersion: 2,
183
216
  units,
184
217
  publishBatches,
185
218
  nodePackages,
186
219
  pythonPackages,
187
220
  rustPackages,
188
221
  artifacts,
189
- rustTargets,
222
+ rustTargets: plannedRustTargets,
190
223
  stages,
191
224
  omittedStages,
192
225
  };
package/src/release.ts CHANGED
@@ -117,6 +117,7 @@ export function releaseArtifactSteps(options: {
117
117
  readonly recoveredName: string;
118
118
  readonly pattern: string;
119
119
  readonly path: string;
120
+ readonly condition?: string;
120
121
  }): readonly JobStep[] {
121
122
  const shared = {
122
123
  pattern: options.pattern,
@@ -126,13 +127,13 @@ export function releaseArtifactSteps(options: {
126
127
  return [
127
128
  {
128
129
  name: options.currentName,
129
- if: "${{ inputs.source_run_id == '' }}",
130
+ if: `\${{ ${options.condition ? `${options.condition} && ` : ""}inputs.source_run_id == '' }}`,
130
131
  uses: "actions/download-artifact@v8",
131
132
  with: shared,
132
133
  },
133
134
  {
134
135
  name: options.recoveredName,
135
- if: "${{ inputs.source_run_id != '' }}",
136
+ if: `\${{ ${options.condition ? `${options.condition} && ` : ""}inputs.source_run_id != '' }}`,
136
137
  uses: "actions/download-artifact@v8",
137
138
  with: {
138
139
  ...shared,
@@ -179,6 +180,7 @@ function verifyContextJob(tagPrefix: string, releaseBranch: string): Job {
179
180
  release_tag: { stepId: "release", outputName: "release_tag" },
180
181
  expected_sha: { stepId: "release", outputName: "expected_sha" },
181
182
  release_version: { stepId: "release", outputName: "release_version" },
183
+ build_mode: { stepId: "release", outputName: "build_mode" },
182
184
  },
183
185
  steps: [
184
186
  {
@@ -186,7 +188,7 @@ function verifyContextJob(tagPrefix: string, releaseBranch: string): Job {
186
188
  uses: "actions/checkout@v6",
187
189
  with: {
188
190
  ref: "${{ github.event_name == 'push' && github.sha || inputs.expected_sha }}",
189
- "fetch-depth": 2,
191
+ "fetch-depth": 0,
190
192
  },
191
193
  },
192
194
  {
@@ -205,6 +207,7 @@ function verifyContextJob(tagPrefix: string, releaseBranch: string): Job {
205
207
  EXPECTED_SHA:
206
208
  "${{ github.event_name == 'workflow_dispatch' && inputs.expected_sha || '' }}",
207
209
  DRY_RUN: "${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false }}",
210
+ GH_TOKEN: "${{ github.token }}",
208
211
  },
209
212
  // prettier-ignore
210
213
  run: stringUtils.dedent(
@@ -246,9 +249,15 @@ function verifyContextJob(tagPrefix: string, releaseBranch: string): Job {
246
249
  case "\${{ inputs.source_run_id }}" in *[!0-9]*|"") exit 1 ;; esac
247
250
  fi
248
251
  fi
252
+ BUILD_MODE="$(git log -1 --format=%B -- VERSION | sed -n 's/^Release-Build: //p' | tail -1)"
253
+ case "$BUILD_MODE" in local|remote) ;; *) BUILD_MODE="remote" ;; esac
254
+ if [ "$BUILD_MODE" = "local" ] && ! gh release view "$RELEASE_TAG" >/dev/null 2>&1; then
255
+ gh release create "$RELEASE_TAG" --draft --title "$RELEASE_TAG" --target "$RELEASE_SHA"
256
+ fi
249
257
  echo "release_tag=$RELEASE_TAG" >> "$GITHUB_OUTPUT"
250
258
  echo "expected_sha=$RELEASE_SHA" >> "$GITHUB_OUTPUT"
251
259
  echo "release_version=$RELEASE_VERSION" >> "$GITHUB_OUTPUT"
260
+ echo "build_mode=$BUILD_MODE" >> "$GITHUB_OUTPUT"
252
261
  `
253
262
  // ============================================================================
254
263
  ),
@@ -1021,6 +1030,11 @@ export class DBXToolsRelease extends Component {
1021
1030
  description:
1022
1031
  "Prepare, validate, locally publish, and automatically merge a release PR",
1023
1032
  },
1033
+ "release:assets": {
1034
+ exec: taskScript(project, "release-assets.ts"),
1035
+ receiveArgs: true,
1036
+ description: "Build and upload native assets for an existing GitHub Release",
1037
+ },
1024
1038
  }
1025
1039
  : {}),
1026
1040
  });
package/src/watch.ts CHANGED
@@ -81,13 +81,28 @@ function ignoredPath(path: string): boolean {
81
81
  * (e.g. the projenrc watcher passes `{ dot: false }` so its lone dotfile target,
82
82
  * `.projenrc.ts`, isn't pruned by the default dotfile group and left with nothing to
83
83
  * watch - which would let the process exit immediately).
84
+ *
85
+ * `check` is forwarded to the workspace mutation lock so a watcher that can tell
86
+ * the batch is a no-op never waits on an unrelated holder.
84
87
  */
88
+ export interface WatchLoopOptions {
89
+ ignoreOptions?: IgnoreGroupOptions;
90
+ check?: (changed: string[]) => boolean | Promise<boolean>;
91
+ }
92
+
93
+ function resolveWatchLoopOptions(config?: IgnoreGroupOptions | WatchLoopOptions): WatchLoopOptions {
94
+ if (!config) return {};
95
+ if ("check" in config || "ignoreOptions" in config) return config as WatchLoopOptions;
96
+ return { ignoreOptions: config as IgnoreGroupOptions };
97
+ }
98
+
85
99
  export function watchLoop(
86
100
  tag: string,
87
101
  paths: string[],
88
102
  onBatch: (changed: string[]) => void | Promise<void>,
89
- ignoreOptions?: IgnoreGroupOptions,
103
+ config?: IgnoreGroupOptions | WatchLoopOptions,
90
104
  ): void {
105
+ const { ignoreOptions, check } = resolveWatchLoopOptions(config);
91
106
  const pending = new Set<string>();
92
107
  let timer: ReturnType<typeof setTimeout> | undefined;
93
108
  let running = false;
@@ -105,7 +120,9 @@ export function watchLoop(
105
120
  pending.clear();
106
121
  try {
107
122
  if (relevant.length) {
108
- await withWorkspaceMutationLock(repoRoot, () => onBatch(relevant));
123
+ await withWorkspaceMutationLock(repoRoot, () => onBatch(relevant), {
124
+ check: check ? () => check(relevant) : undefined,
125
+ });
109
126
  }
110
127
  } catch (err) {
111
128
  logger.error(`${tag} cycle failed:`, err instanceof Error ? err.message : err);
@@ -6,17 +6,43 @@ import { log } from "@dbx-tools/shared-core";
6
6
  const MUTATION_LOCK_SCOPE = "dbx-tools-workspace-mutation";
7
7
  const logger = log.logger("projen:workspace-lock");
8
8
 
9
+ export interface WorkspaceMutationLockOptions {
10
+ /**
11
+ * When this returns false, skip both the lock and the callback. After the
12
+ * lock is held it runs again so a completed holder can make the work a no-op
13
+ * without taking the critical section only to return.
14
+ */
15
+ check?: () => boolean | Promise<boolean>;
16
+ }
17
+
9
18
  /**
10
19
  * Serialize synthesis, generated-file watchers, and release preparation for one
11
20
  * repository without coordinating unrelated worktrees or repositories.
21
+ *
22
+ * `check` is check-lock-check: a false result before acquire never waits, and a
23
+ * false result after acquire releases immediately without running `callback`.
12
24
  */
13
- export function withWorkspaceMutationLock<T>(
25
+ export async function withWorkspaceMutationLock<T>(
14
26
  root: string,
15
27
  callback: () => T | Promise<T>,
16
- ): Promise<T> {
28
+ options?: WorkspaceMutationLockOptions,
29
+ ): Promise<T | undefined> {
17
30
  const repository = resolve(root);
18
- return fileLock.withFileLock([MUTATION_LOCK_SCOPE, repository], callback, {
19
- backends: process.platform === "win32" ? ["file"] : ["flock", "file"],
20
- onWait: () => logger.info("waiting for workspace mutation lock", { repository }),
21
- });
31
+ const check = options?.check;
32
+ if (check && !(await check())) {
33
+ return undefined;
34
+ }
35
+ return fileLock.withFileLock(
36
+ [MUTATION_LOCK_SCOPE, repository],
37
+ async () => {
38
+ if (check && !(await check())) {
39
+ return undefined;
40
+ }
41
+ return callback();
42
+ },
43
+ {
44
+ backends: process.platform === "win32" ? ["file"] : ["flock", "file"],
45
+ onWait: () => logger.info("waiting for workspace mutation lock", { repository }),
46
+ },
47
+ );
22
48
  }
package/tasks/barrels.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env -S bun
2
2
  import { sep } from "node:path";
3
3
  import { parseArgs } from "node:util";
4
- import { log, stringUtils } from "@dbx-tools/shared-core";
4
+ import { log, object, stringUtils } from "@dbx-tools/shared-core";
5
5
  import { generateBarrels } from "../src/barrels.ts";
6
6
  import { recordedPackages } from "../src/packages.ts";
7
7
  import { watchLoop, watchRoots } from "../src/watch.ts";
@@ -17,41 +17,58 @@ const { values } = parseArgs({
17
17
  });
18
18
 
19
19
  /** The recorded package dir that owns `abs`, if any (for a targeted barrel rebuild). */
20
- function ownerPackageDir(abs: string, pkgDirs: string[]): string | undefined {
21
- return pkgDirs.find((dir) => abs === dir || abs.startsWith(dir + sep));
20
+ function ownerPackageDir(
21
+ abs: string,
22
+ packages: readonly { readonly dir: string; readonly tags: readonly string[] }[],
23
+ ): string | undefined {
24
+ // The OpenAPI task rebuilds generated-client barrels after writing its files.
25
+ return packages.find(
26
+ ({ dir, tags }) => !tags.includes("openapi") && (abs === dir || abs.startsWith(dir + sep)),
27
+ )?.dir;
28
+ }
29
+
30
+ function changedBarrelTargets(changed: readonly string[]): object.Sequence<string> {
31
+ const packages = recordedPackages();
32
+ return object
33
+ .sequence(changed)
34
+ .map((path) => ownerPackageDir(path, packages))
35
+ .nonNull()
36
+ .distinct();
37
+ }
38
+
39
+ function warnUnownedChanges(changed: readonly string[]): void {
40
+ if (changed.length) {
41
+ logger.warn(
42
+ `no recorded package owns ${stringUtils.pluralize(changed.length, "change")}; ` +
43
+ "run `bun run default` (or touch .projenrc.ts) to pick up a new package folder",
44
+ );
45
+ }
22
46
  }
23
47
 
24
48
  if (values.watch) {
25
49
  // Watch the package roots; a source edit inside a package rebuilds just that
26
50
  // package's `index.ts` barrel (no re-synth - the projenrc watcher owns that).
27
51
  // watchLoop already drops generated paths, so a barrel write never re-triggers us.
28
- watchLoop("barrels", watchRoots(), (changed) => {
29
- const pkgDirs = recordedPackages().map((p) => p.dir);
30
- const dirs = new Set<string>();
31
- const unowned: string[] = [];
32
- for (const p of changed) {
33
- const owner = ownerPackageDir(p, pkgDirs);
34
- if (owner) dirs.add(owner);
35
- else unowned.push(p);
36
- }
37
- // A change under a package root that no RECORDED package owns is almost always a
38
- // NEW package folder: it has no `pnpm-workspace.yaml` member yet, so there is no
39
- // barrel for this watcher to target and only a re-synth can create one. Say so,
40
- // rather than rebuilding every barrel in the repo - the previous fallback, which
41
- // did a full-repo sweep on behalf of a file it could not barrel anyway, and in
42
- // doing so raced the projenrc watcher's own post-synth sweep.
43
- if (dirs.size === 0) {
44
- if (unowned.length) {
45
- logger.warn(
46
- `no recorded package owns ${stringUtils.pluralize(unowned.length, "change")}; ` +
47
- "run `bun run default` (or touch .projenrc.ts) to pick up a new package folder",
48
- );
49
- }
50
- return;
51
- }
52
- const n = generateBarrels({ dirs: [...dirs] });
53
- if (n) logger.success(`rebuilt ${stringUtils.pluralize(n, "barrel")}`);
54
- });
52
+ watchLoop(
53
+ "barrels",
54
+ watchRoots(),
55
+ (changed) => {
56
+ const n = generateBarrels({ dirs: [...changedBarrelTargets(changed)] });
57
+ if (n) logger.success(`rebuilt ${stringUtils.pluralize(n, "barrel")}`);
58
+ },
59
+ {
60
+ check: (changed) => {
61
+ const targets = changedBarrelTargets(changed);
62
+ // A change under a package root that no recorded package owns is a new
63
+ // folder: only a re-synth can create its barrel, so this watcher skips.
64
+ if (!targets.some(() => true)) {
65
+ warnUnownedChanges(changed);
66
+ return false;
67
+ }
68
+ return true;
69
+ },
70
+ },
71
+ );
55
72
  } else {
56
73
  const dirs = Array.isArray(values.dir)
57
74
  ? values.dir.filter((value): value is string => typeof value === "string")
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env -S bun
2
+ /** Compile source-first workspace packages in a few TypeScript processes. */
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { availableParallelism } from "node:os";
5
+ import { join, resolve } from "node:path";
6
+ import * as exec from "@dbx-tools/core/exec";
7
+
8
+ interface PackageManifest {
9
+ readonly name?: string;
10
+ readonly scripts?: Record<string, string>;
11
+ readonly workspaces?: string[];
12
+ }
13
+
14
+ interface TaskManifest {
15
+ readonly tasks?: {
16
+ readonly compile?: {
17
+ readonly steps?: Array<{ readonly execArgs?: string[] }>;
18
+ };
19
+ };
20
+ }
21
+
22
+ export interface CompilePlan {
23
+ readonly typescriptConfigs: string[];
24
+ readonly customPackages: Array<{ readonly name: string; readonly directory: string }>;
25
+ }
26
+
27
+ function readJson<T>(path: string): T {
28
+ return JSON.parse(readFileSync(path, "utf8")) as T;
29
+ }
30
+
31
+ function plainTypeScriptBuild(steps: unknown): boolean {
32
+ if (!Array.isArray(steps) || steps.length !== 1) return false;
33
+ const step = steps[0] as { execArgs?: string[] };
34
+ if (!step || typeof step !== "object") return false;
35
+ return (
36
+ Object.keys(step).length === 1 &&
37
+ step.execArgs?.length === 2 &&
38
+ step.execArgs[0] === "tsc" &&
39
+ step.execArgs[1] === "--build"
40
+ );
41
+ }
42
+
43
+ /** Read the root's concrete workspace paths on every run, including newly added members. */
44
+ export function compilePlan(root: string): CompilePlan {
45
+ const workspace = readJson<PackageManifest>(join(root, "package.json"));
46
+ const typescriptConfigs: string[] = [];
47
+ const customPackages: CompilePlan["customPackages"] = [];
48
+ for (const member of workspace.workspaces ?? []) {
49
+ const directory = resolve(root, member);
50
+ const manifest = readJson<PackageManifest>(join(directory, "package.json"));
51
+ const script = manifest.scripts?.compile;
52
+ if (!script) continue;
53
+ const taskPath = join(directory, ".projen", "tasks.json");
54
+ const steps = existsSync(taskPath)
55
+ ? readJson<TaskManifest>(taskPath).tasks?.compile?.steps
56
+ : undefined;
57
+ if (
58
+ script === "projen compile" &&
59
+ !manifest.scripts?.precompile &&
60
+ !manifest.scripts?.postcompile &&
61
+ plainTypeScriptBuild(steps)
62
+ ) {
63
+ typescriptConfigs.push(join(directory, "tsconfig.json"));
64
+ } else {
65
+ customPackages.push({ name: manifest.name ?? member, directory });
66
+ }
67
+ }
68
+ return { typescriptConfigs, customPackages };
69
+ }
70
+
71
+ const emoji = /[\p{Extended_Pictographic}\uFE0F\u200D]/gu;
72
+
73
+ async function main(): Promise<void> {
74
+ const root = process.cwd();
75
+ const plan = compilePlan(root);
76
+ const jobs: Array<{ name: string; result: Promise<exec.ExecResult> }> = [];
77
+ if (plan.typescriptConfigs.length > 0) {
78
+ const batchCount = Math.min(4, availableParallelism(), plan.typescriptConfigs.length);
79
+ const batches = Array.from({ length: batchCount }, () => [] as string[]);
80
+ plan.typescriptConfigs.forEach((config, index) => batches[index % batchCount]!.push(config));
81
+ console.log(
82
+ `Type-checking ${plan.typescriptConfigs.length} workspaces in ${batchCount} batches`,
83
+ );
84
+ for (const [index, batch] of batches.entries()) {
85
+ jobs.push({
86
+ name: `TypeScript batch ${index + 1}`,
87
+ result: exec.spawn("bunx", ["tsc", "--build", ...batch], {
88
+ cwd: root,
89
+ stdin: "ignore",
90
+ stdout: "inherit",
91
+ stderr: "inherit",
92
+ }),
93
+ });
94
+ }
95
+ }
96
+ for (const pkg of plan.customPackages) {
97
+ jobs.push({
98
+ name: pkg.name,
99
+ result: exec.spawn("bun", ["run", "compile"], {
100
+ cwd: pkg.directory,
101
+ stdin: "ignore",
102
+ stdout: (line) => console.log(`${pkg.name}: ${line.replace(emoji, "")}`),
103
+ stderr: (line) => console.error(`${pkg.name}: ${line.replace(emoji, "")}`),
104
+ }),
105
+ });
106
+ }
107
+ const results = await Promise.allSettled(jobs.map((job) => job.result));
108
+ let failed = false;
109
+ for (const [index, result] of results.entries()) {
110
+ if (result.status === "rejected") {
111
+ console.error(`${jobs[index]!.name} compile failed:`, result.reason);
112
+ failed = true;
113
+ } else if (result.value.exitCode !== 0) {
114
+ console.error(`${jobs[index]!.name} compile exited with ${result.value.exitCode}`);
115
+ failed = true;
116
+ }
117
+ }
118
+ if (failed) process.exitCode = 1;
119
+ }
120
+
121
+ if (import.meta.main) await main();
package/tasks/openapi.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env -S bun
2
- import { log, stringUtils } from "@dbx-tools/shared-core";
2
+ import { log, object, stringUtils } from "@dbx-tools/shared-core";
3
3
  import { generateBarrels } from "../src/barrels.ts";
4
- import { generateOpenapi, isTsoaController } from "../src/openapi.ts";
4
+ import { generateOpenapi, isOpenapiSource, openapiWatchRoots } from "../src/openapi.ts";
5
5
  import { runSynth } from "../src/scaffold.ts";
6
- import { watchLoop, watchRoots } from "../src/watch.ts";
6
+ import { watchLoop } from "../src/watch.ts";
7
7
 
8
8
  const logger = log.logger("projen:openapi");
9
9
 
@@ -11,14 +11,18 @@ if (process.argv.includes("--watch")) {
11
11
  // Watch the package roots; a changed tsoa controller regenerates the openapi
12
12
  // packages (spec + client) and rebuilds just their barrels. The projenrc watcher
13
13
  // (alongside under `concurrently`) owns full re-synth, so no re-synth here.
14
- watchLoop("openapi", watchRoots(), async (changed) => {
15
- if (!changed.some(isTsoaController)) return;
16
- const dirs = await generateOpenapi();
17
- if (dirs.length) {
18
- generateBarrels({ dirs });
19
- logger.success(`regenerated openapi (${stringUtils.pluralize(dirs.length, "package")})`);
20
- }
21
- });
14
+ watchLoop(
15
+ "openapi",
16
+ openapiWatchRoots(),
17
+ async () => {
18
+ const dirs = await generateOpenapi();
19
+ if (dirs.length) {
20
+ generateBarrels({ dirs });
21
+ logger.success(`regenerated openapi (${stringUtils.pluralize(dirs.length, "package")})`);
22
+ }
23
+ },
24
+ { check: (changed) => object.sequence(changed).some(isOpenapiSource) },
25
+ );
22
26
  } else {
23
27
  // One-shot: regenerate, then re-synth so any newly created openapi folder becomes a
24
28
  // workspace member (a new package needs install + linking, which the watch path skips).