@dbx-tools/projen 0.1.1 → 0.3.43

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/src/watch.ts ADDED
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Generic file-watch utility shared by the `sync --watch` task watchers.
3
+ *
4
+ * `watchLoop` wraps `@dbx-tools/path`'s chokidar watcher with the
5
+ * behavior every dbx-tools watcher wants: it debounces bursts, serializes runs (a
6
+ * change mid-run re-runs once afterwards), drops generated paths (barrels/manifests/
7
+ * decls - reacting to our own output would loop), and shuts down on SIGINT. Callers
8
+ * pass the paths to watch and an `onBatch` handler; the concern-specific glue - which
9
+ * barrels to rebuild, when to regenerate openapi, when to re-synth - lives in the task
10
+ * that owns it (`tasks/barrels.ts`, `tasks/openapi.ts`, `tasks/projenrc.ts`), each
11
+ * forwarding here rather than duplicating the watch machinery.
12
+ *
13
+ * `watchRoots()` is the one shared input - the package roots where every
14
+ * watchable source file lives - so the barrels and openapi watchers don't each
15
+ * recompute it. {@link watchFiles} owns the chokidar wiring; this is thin glue.
16
+ */
17
+ import { isAbsolute, resolve } from "node:path";
18
+ import { watch as fileScan } from "@dbx-tools/path";
19
+ import { log } from "@dbx-tools/shared-core";
20
+ import { isGeneratedFile, recordedRoots, repoRoot } from "./packages";
21
+
22
+ const logger = log.logger("projen:watch");
23
+ const DEBOUNCE_MS = 250;
24
+
25
+ /** node-path's built-in ignore-group toggles (`{ dot, temp, test, lock, defaults }`). */
26
+ export type IgnoreGroupOptions = NonNullable<
27
+ Parameters<typeof fileScan.watchFiles>[1]
28
+ >["ignoreOptions"];
29
+
30
+ /** The package roots (absolute), where every watchable source file lives. */
31
+ export function watchRoots(): string[] {
32
+ return recordedRoots().map((r) => resolve(repoRoot, r));
33
+ }
34
+
35
+ /**
36
+ * Generated paths (barrels, manifests, tsconfigs, decls) must never drive a watch -
37
+ * they change *because* we generate, so reacting would loop.
38
+ */
39
+ function ignoredPath(path: string): boolean {
40
+ const abs = isAbsolute(path) ? path : resolve(repoRoot, path);
41
+ return isGeneratedFile(abs);
42
+ }
43
+
44
+ /**
45
+ * Shared debounce/flush machinery backed by `watchFiles`. Watches `paths` and, on
46
+ * each debounced batch of non-generated changes, calls `onBatch` with the absolute
47
+ * changed paths. Runs are serialized (a change during a run re-runs once afterwards);
48
+ * watches until SIGINT.
49
+ *
50
+ * `ignoreOptions` toggles node-path's built-in ignore groups for this watcher only
51
+ * (e.g. the projenrc watcher passes `{ dot: false }` so its lone dotfile target,
52
+ * `.projenrc.ts`, isn't pruned by the default dotfile group and left with nothing to
53
+ * watch - which would let the process exit immediately).
54
+ */
55
+ export function watchLoop(
56
+ tag: string,
57
+ paths: string[],
58
+ onBatch: (changed: string[]) => void | Promise<void>,
59
+ ignoreOptions?: IgnoreGroupOptions,
60
+ ): void {
61
+ const pending = new Set<string>();
62
+ let timer: ReturnType<typeof setTimeout> | undefined;
63
+ let running = false;
64
+ let rerun = false;
65
+
66
+ async function flush(): Promise<void> {
67
+ if (running) {
68
+ rerun = true;
69
+ return;
70
+ }
71
+ running = true;
72
+ const relevant = [...pending]
73
+ .map((p) => (isAbsolute(p) ? p : resolve(repoRoot, p)))
74
+ .filter((p) => !ignoredPath(p));
75
+ pending.clear();
76
+ try {
77
+ if (relevant.length) await onBatch(relevant);
78
+ } catch (err) {
79
+ logger.error(`${tag} cycle failed:`, err instanceof Error ? err.message : err);
80
+ } finally {
81
+ running = false;
82
+ if (rerun) {
83
+ rerun = false;
84
+ setTimeout(() => void flush(), 0);
85
+ }
86
+ }
87
+ }
88
+
89
+ const watcher = fileScan.watchFiles(paths, {
90
+ cwd: repoRoot,
91
+ ignoreInitial: true,
92
+ ignore: (path) => ignoredPath(path),
93
+ ignoreOptions,
94
+ });
95
+ watcher.on("all", (_event, path) => {
96
+ pending.add(path);
97
+ clearTimeout(timer);
98
+ timer = setTimeout(() => void flush(), DEBOUNCE_MS);
99
+ });
100
+ watcher.on("error", (err) => logger.error(`${tag} watcher error:`, err));
101
+ watcher.on("ready", () => logger.info(`${tag}: watching for changes … (Ctrl-C to stop)`));
102
+
103
+ process.on("SIGINT", () => {
104
+ void watcher.close();
105
+ process.exit(0);
106
+ });
107
+ }
package/tasks/barrels.ts CHANGED
@@ -3,7 +3,7 @@ import { sep } from "node:path";
3
3
  import { generateBarrels } from "../src/barrels";
4
4
  import { log, string } from "@dbx-tools/shared-core";
5
5
  import { watchLoop, watchRoots } from "../src/watch";
6
- import { workspacePackages } from "../src/workspace";
6
+ import { recordedPackages } from "../src/packages";
7
7
 
8
8
  const logger = log.logger("projen:barrels");
9
9
 
@@ -17,7 +17,7 @@ if (process.argv.includes("--watch")) {
17
17
  // package's `index.ts` barrel (no re-synth - the projenrc watcher owns that).
18
18
  // watchLoop already drops generated paths, so a barrel write never re-triggers us.
19
19
  watchLoop("barrels", watchRoots(), (changed) => {
20
- const pkgDirs = workspacePackages().map((p) => p.dir);
20
+ const pkgDirs = recordedPackages().map((p) => p.dir);
21
21
  const dirs = new Set<string>();
22
22
  for (const p of changed) {
23
23
  const owner = ownerPackageDir(p, pkgDirs);
package/tasks/bump.ts ADDED
@@ -0,0 +1,340 @@
1
+ #!/usr/bin/env -S npx tsx
2
+ /**
3
+ * `projen bump` - synth, compute the next release version, then (by default)
4
+ * commit, tag, and push it. Pushing the tag is what triggers the release
5
+ * workflow.
6
+ *
7
+ * The next version is derived from the HIGHEST of:
8
+ * - the latest published git tag matching `<prefix><semver>` (fetched from
9
+ * the remote so a release made elsewhere is respected),
10
+ * - the same for every `--sibling` prefix, and
11
+ * - the local `package.json` version,
12
+ * then incremented by `--level` (patch | minor | major; default patch).
13
+ *
14
+ * `--sibling <dir>:<tagPrefix>` (repeatable) releases a standalone in-repo
15
+ * project - one that is NOT a pnpm workspace member, so `pnpm -r` cannot see it -
16
+ * at the SAME version as the root, in the same run: its manifest is stamped, its
17
+ * `<tagPrefix><version>` tag is cut and pushed (triggering its own workflow), and
18
+ * it is included in the local-registry publish. Taking the base version from
19
+ * every prefix at once is what keeps the two in lockstep: the engine sat at
20
+ * 0.1.24 while the packages reached 0.3.41 precisely because each namespace only
21
+ * ever looked at its own tags.
22
+ *
23
+ * Flags (all default ON; negate with the `--no-` form, per commander):
24
+ * --synth / --no-synth run `projen` (synth) first so the tree is current
25
+ * --version / --no-version write the bumped version into package.json
26
+ * --commit / --no-commit commit the release (staged with `git add -A`)
27
+ * --tag / --no-tag create the `<prefix><version>` git tag
28
+ * --push / --no-push push the CURRENT branch + tag to origin
29
+ *
30
+ * `--publish` / `--no-publish` is an alias for `--push` (pushing the tag is
31
+ * what publishes). The tag prefix comes from `--prefix` (default `v`).
32
+ *
33
+ * `--local-registry <value>` publishes the just-tagged version to a LOCAL
34
+ * registry (e.g. a verdaccio) right after the git tag is pushed - so a local
35
+ * `pnpm run bump` both fires the GitHub release (public npm) and populates your
36
+ * local registry. Values:
37
+ * - `auto` (default): publish only when `npm config get registry` is a
38
+ * loopback host (`localhost` / `127.0.0.0/8` / `::1`); otherwise skip.
39
+ * - `false`: never publish locally.
40
+ * - a URL: always publish to that registry.
41
+ */
42
+ import { chmodSync, existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
43
+ import { resolve } from "node:path";
44
+ import { Command, Option } from "commander";
45
+ import { exec, project } from "@dbx-tools/core";
46
+ import { log, net } from "@dbx-tools/shared-core";
47
+
48
+ const logger = log.logger("projen:bump");
49
+ const LEVELS = ["patch", "minor", "major"] as const;
50
+ type Level = (typeof LEVELS)[number];
51
+
52
+ /** A standalone in-repo project released alongside the root, on its own tag prefix. */
53
+ interface Sibling {
54
+ /** Repo-relative directory, e.g. `projen`. */
55
+ readonly dir: string;
56
+ /** Git tag prefix, disjoint from the root's, e.g. `projen-v`. */
57
+ readonly prefix: string;
58
+ }
59
+
60
+ /**
61
+ * Commander collector for the repeatable `--sibling <dir>:<tagPrefix>`. Split on
62
+ * the LAST colon so a directory containing one still parses.
63
+ */
64
+ function parseSibling(value: string, previous: Sibling[]): Sibling[] {
65
+ const at = value.lastIndexOf(":");
66
+ if (at <= 0 || at === value.length - 1) {
67
+ throw new Error(`--sibling expects <dir>:<tagPrefix>, got "${value}"`);
68
+ }
69
+ return [...previous, { dir: value.slice(0, at), prefix: value.slice(at + 1) }];
70
+ }
71
+
72
+ /** Parse `x.y.z` (ignoring any leading `v`/prefix), returning a `[maj,min,pat]` tuple. */
73
+ function parseSemver(raw: string): [number, number, number] | undefined {
74
+ const m = /(\d+)\.(\d+)\.(\d+)/.exec(raw.trim());
75
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : undefined;
76
+ }
77
+
78
+ function compareSemver(a: [number, number, number], b: [number, number, number]): number {
79
+ return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
80
+ }
81
+
82
+ function increment(v: [number, number, number], level: Level): [number, number, number] {
83
+ if (level === "major") return [v[0] + 1, 0, 0];
84
+ if (level === "minor") return [v[0], v[1] + 1, 0];
85
+ return [v[0], v[1], v[2] + 1];
86
+ }
87
+
88
+ function git(args: string[], capture = false): string {
89
+ const res = exec.spawnSync("git", args, {
90
+ cwd: process.cwd(),
91
+ stdout: capture ? "capture" : "inherit",
92
+ stderr: capture ? "ignore" : "inherit",
93
+ stdin: "ignore",
94
+ check: !capture,
95
+ });
96
+ return res.stdout?.trim() ?? "";
97
+ }
98
+
99
+ /** Highest tag matching `<prefix><semver>`, or undefined. Call {@link fetchTags} first. */
100
+ function latestTagVersion(prefix: string): [number, number, number] | undefined {
101
+ const out = git(
102
+ ["-c", "versionsort.suffix=-", "tag", "--sort=-version:refname", "--list", `${prefix}*`],
103
+ true,
104
+ );
105
+ for (const tag of out.split("\n")) {
106
+ const v = parseSemver(tag.replace(prefix, ""));
107
+ if (v) return v;
108
+ }
109
+ return undefined;
110
+ }
111
+
112
+ /** Pull remote tags once, so a release made elsewhere is respected. */
113
+ function fetchTags(): void {
114
+ git(["fetch", "--tags", "--quiet"], true);
115
+ }
116
+
117
+ function readPackageVersion(pkgPath: string): [number, number, number] {
118
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { version?: string };
119
+ return parseSemver(pkg.version ?? "") ?? [0, 0, 0];
120
+ }
121
+
122
+ /**
123
+ * Write `version` into a manifest projen owns. Those are emitted read-only, so
124
+ * the write is bracketed by a chmod; the mode is restored afterwards to leave the
125
+ * tree exactly as synth left it.
126
+ */
127
+ function writeManifestVersion(pkgPath: string, version: string): void {
128
+ const { mode } = statSync(pkgPath);
129
+ chmodSync(pkgPath, mode | 0o200);
130
+ try {
131
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as Record<string, unknown>;
132
+ pkg.version = version;
133
+ writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
134
+ } finally {
135
+ chmodSync(pkgPath, mode);
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Resolve the `--local-registry` value to a registry URL to publish to, or
141
+ * `undefined` to skip. `false` skips; a URL is used as-is; `auto` uses the
142
+ * active npm registry only when it is a loopback host (a local verdaccio etc.).
143
+ */
144
+ function resolveLocalRegistry(value: string): string | undefined {
145
+ const trimmed = value.trim();
146
+ if (!trimmed || trimmed.toLowerCase() === "false") return undefined;
147
+ if (trimmed.toLowerCase() === "auto") {
148
+ const registry = project.npmRegistry();
149
+ return registry && net.isLoopbackHost(registry) ? registry.href : undefined;
150
+ }
151
+ return trimmed;
152
+ }
153
+
154
+ const program = new Command();
155
+ program
156
+ .description("Bump the release version, then commit, tag, and push it")
157
+ .addOption(
158
+ new Option("-l, --level <level>", "semver increment").choices([...LEVELS]).default("patch"),
159
+ )
160
+ .option("--prefix <prefix>", "git tag prefix", "v")
161
+ .option(
162
+ "--sibling <dir:prefix>",
163
+ "standalone in-repo project (not a workspace member) to release at the same version, repeatable",
164
+ parseSibling,
165
+ [] as Sibling[],
166
+ )
167
+ // Declared in the `--no-` form so commander creates a boolean that defaults to
168
+ // `true` and is turned off by `--no-synth` / `--no-version` / ... (the
169
+ // positive `--synth` etc. also work and are no-ops on the default).
170
+ .option("--no-synth", "do not run `projen` (synth) before bumping")
171
+ .option("--no-version", "do not write the bumped version into package.json")
172
+ .option("--no-commit", "do not commit the version change")
173
+ .option("--no-tag", "do not create the git tag")
174
+ .option("--no-push", "do not push the branch and tag to origin")
175
+ // `--publish` is a friendlier alias for `--push` (pushing the tag publishes).
176
+ .option("--no-publish", "alias for --no-push")
177
+ .option(
178
+ "--local-registry <value>",
179
+ "publish locally after the tag push: 'auto' (only a loopback npm registry), 'false', or a registry URL",
180
+ "auto",
181
+ )
182
+ .action(
183
+ (opts: {
184
+ level: Level;
185
+ prefix: string;
186
+ sibling: Sibling[];
187
+ synth: boolean;
188
+ version: boolean;
189
+ commit: boolean;
190
+ tag: boolean;
191
+ push: boolean;
192
+ publish: boolean;
193
+ localRegistry: string;
194
+ }) => {
195
+ const pkgPath = resolve(process.cwd(), "package.json");
196
+ if (!existsSync(pkgPath)) throw new Error(`no package.json in ${process.cwd()}`);
197
+
198
+ const siblings = opts.sibling.map((s) => ({ ...s, pkgPath: resolve(s.dir, "package.json") }));
199
+ for (const s of siblings) {
200
+ if (!existsSync(s.pkgPath)) throw new Error(`--sibling ${s.dir}: no package.json there`);
201
+ }
202
+
203
+ // Synth first so the release commit captures an up-to-date tree (generated
204
+ // manifests, workspace file, tasks, ...) rather than a stale one.
205
+ if (opts.synth) {
206
+ logger.info("synthesizing (projen)");
207
+ exec.spawnSync("pnpm", ["exec", "projen"], {
208
+ cwd: process.cwd(),
209
+ stdout: "inherit",
210
+ stderr: "inherit",
211
+ stdin: "ignore",
212
+ check: true,
213
+ });
214
+ }
215
+
216
+ // Base = highest of the local package version and the latest tag in EVERY
217
+ // namespace being released, so one shared version stays ahead of them all.
218
+ fetchTags();
219
+ const prefixes = [opts.prefix, ...siblings.map((s) => s.prefix)];
220
+ const tagged = prefixes
221
+ .map((prefix) => ({ prefix, version: latestTagVersion(prefix) }))
222
+ .filter((t): t is { prefix: string; version: [number, number, number] } => !!t.version);
223
+ const base = tagged.reduce(
224
+ (highest, t) => (compareSemver(t.version, highest) > 0 ? t.version : highest),
225
+ readPackageVersion(pkgPath),
226
+ );
227
+ const next = increment(base, opts.level);
228
+ const version = next.join(".");
229
+ const tags = prefixes.map((prefix) => `${prefix}${version}`);
230
+ logger.info(
231
+ `bump ${base.join(".")} -> ${version} (${opts.level}); tags ${tags.join(", ")}` +
232
+ `${tagged.length ? "" : " [no remote tag]"}`,
233
+ );
234
+ for (const t of tagged) {
235
+ if (compareSemver(t.version, base) < 0) {
236
+ logger.info(`${t.prefix}* was behind at ${t.version.join(".")}, catching it up`);
237
+ }
238
+ }
239
+
240
+ const push = opts.push && opts.publish;
241
+
242
+ if (opts.version) {
243
+ writeManifestVersion(pkgPath, version);
244
+ for (const s of siblings) writeManifestVersion(s.pkgPath, version);
245
+ const also = siblings.length ? ` (and ${siblings.map((s) => s.dir).join(", ")})` : "";
246
+ logger.info(`wrote version ${version} to package.json${also}`);
247
+ }
248
+
249
+ if (opts.commit) {
250
+ // Stage the whole tree so the release commit captures the version bump
251
+ // plus anything synth regenerated. Skip the commit when nothing changed.
252
+ git(["add", "-A"]);
253
+ const staged = git(["diff", "--cached", "--name-only"], true);
254
+ if (staged) git(["commit", "-m", `chore(release): ${version}`]);
255
+ else logger.info("nothing to commit");
256
+ }
257
+
258
+ if (opts.tag) {
259
+ for (const t of tags) git(["tag", "-a", t, "-m", t]);
260
+ logger.info(`tagged ${tags.join(", ")}`);
261
+ }
262
+
263
+ if (push) {
264
+ git(["push", "origin", "HEAD"]);
265
+ // Push every tag in ONE invocation: each push triggers a workflow, and a
266
+ // partial push would release half the set at this version.
267
+ if (opts.tag) git(["push", "origin", ...tags]);
268
+ logger.success(`pushed ${opts.tag ? tags.join(", ") : "HEAD"} to origin`);
269
+ } else {
270
+ logger.info("skipped push (--no-push / --no-publish)");
271
+ }
272
+
273
+ // Local registry (e.g. verdaccio): publish AFTER the tag push so the
274
+ // GitHub release still owns the public registry. Skipped under
275
+ // `--no-version` (nothing bumped to publish).
276
+ const localRegistry = resolveLocalRegistry(opts.localRegistry);
277
+ const publishToLocalRegistry = opts.version && localRegistry;
278
+ if (opts.version === false && localRegistry) {
279
+ logger.info("skipped local publish (--no-version left package.json unbumped)");
280
+ }
281
+ if (publishToLocalRegistry) {
282
+ logger.info(`publishing ${version} to local registry ${localRegistry}`);
283
+ const runIn = (cwd: string, command: string, args: string[]) =>
284
+ exec.spawnSync(command, args, {
285
+ cwd,
286
+ stdout: "inherit",
287
+ stderr: "inherit",
288
+ stdin: "ignore",
289
+ check: true,
290
+ });
291
+ const runInRepo = (command: string, args: string[]) =>
292
+ runIn(process.cwd(), command, args);
293
+ // Each package keeps `version: 0.0.0` on disk (projen owns the
294
+ // manifest); the root bump above only touched the root. Mirror the CI
295
+ // `release` workflow: stamp the release version on EVERY package (they're
296
+ // projen-readonly, so unlock first) so `pnpm -r publish` publishes them
297
+ // as `version` (and rewrites `workspace:*` sibling pins to it) instead of
298
+ // `0.0.0`. No restore needed - the next `projen` synth rewrites these
299
+ // manifests back to `0.0.0`; the release version lives in the git tag.
300
+ runInRepo("chmod", ["-R", "u+w", "."]);
301
+ runInRepo("pnpm", [
302
+ "-r",
303
+ "exec",
304
+ "npm",
305
+ "version",
306
+ version,
307
+ "--no-git-tag-version",
308
+ "--allow-same-version",
309
+ ]);
310
+ // Provenance is opt-in (see `.projenrc.ts`): the generated
311
+ // `publishConfig` omits it, so local (verdaccio) publishes never try to
312
+ // attest. CI turns it on with `npm_config_provenance=true`.
313
+ runInRepo("pnpm", [
314
+ "-r",
315
+ "publish",
316
+ "--registry",
317
+ localRegistry,
318
+ "--no-git-checks",
319
+ "--access",
320
+ "public",
321
+ ]);
322
+ // `pnpm -r` cannot see a sibling (not a workspace member), so publish each
323
+ // one on its own. Skipping this is what left a local registry serving a
324
+ // current CLI against a months-old engine.
325
+ for (const s of siblings) {
326
+ runIn(s.dir, "pnpm", [
327
+ "publish",
328
+ "--registry",
329
+ localRegistry,
330
+ "--no-git-checks",
331
+ "--access",
332
+ "public",
333
+ ]);
334
+ }
335
+ logger.success(`published ${version} to ${localRegistry}`);
336
+ }
337
+ },
338
+ );
339
+
340
+ await program.parseAsync();
package/tasks/clean.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  import { relative } from "node:path";
3
3
  import { listGeneratedFiles, listNodeModulesDirs, removePaths } from "../src/clean";
4
4
  import { log, string } from "@dbx-tools/shared-core";
5
- import { repoRoot, toPosix } from "../src/workspace";
5
+ import { repoRoot, toPosix } from "../src/packages";
6
6
 
7
7
  const logger = log.logger("projen:clean");
8
8
  const yes = process.argv.includes("-y") || process.argv.includes("--yes");
package/tasks/projenrc.ts CHANGED
@@ -3,7 +3,7 @@ import { resolve } from "node:path";
3
3
  import { log } from "@dbx-tools/shared-core";
4
4
  import { runSynth } from "../src/scaffold";
5
5
  import { watchLoop } from "../src/watch";
6
- import { repoRoot, syncResynthPaths } from "../src/workspace";
6
+ import { repoRoot, syncResynthPaths } from "../src/packages";
7
7
 
8
8
  const logger = log.logger("projen:projenrc");
9
9
 
package/tasks/sync.ts CHANGED
@@ -6,13 +6,27 @@ import { runSynth } from "../src/scaffold";
6
6
 
7
7
  const logger = log.logger("projen:sync");
8
8
 
9
+ /**
10
+ * Backoff before a crashed watcher is respawned. A watcher that dies during startup
11
+ * (unparseable config, a bad import) would otherwise hot-loop, so this debounces the
12
+ * retry into something readable while still recovering promptly once the offending
13
+ * file is fixed. Deliberately a flat delay rather than concurrently's `"exponential"`,
14
+ * whose unbounded `2^n` growth would leave a watcher dead for hours after a bad
15
+ * afternoon of crashes.
16
+ */
17
+ const RESTART_DELAY_MS = 5_000;
18
+
19
+ /** How long to let the watcher process trees die on shutdown before exiting regardless. */
20
+ const STOP_GRACE_MS = 2_000;
21
+
9
22
  /** Absolute path to a sibling task script, so `concurrently`'s cwd doesn't matter. */
10
23
  function taskPath(script: string): string {
11
24
  return fileURLToPath(new URL(`./${script}`, import.meta.url));
12
25
  }
13
26
 
14
27
  if (!process.argv.includes("--watch")) {
15
- // One-shot: full synth (+install + barrels via the post-synth component).
28
+ // One-shot: full synth (+install + barrels via the post-synth component). This is
29
+ // the scriptable path, so a failed synth stays a failed exit code.
16
30
  logger.start("synthesizing");
17
31
  runSynth({ post: true });
18
32
  logger.success("synced");
@@ -22,16 +36,66 @@ if (!process.argv.includes("--watch")) {
22
36
  // for stock `projen --watch` - it re-synths (+install) ONLY when `.projenrc.ts` or
23
37
  // a configured `syncResynthPaths` entry changes, while barrels/openapi keep generated
24
38
  // OUTPUT fresh on source edits with no full synth.
39
+ //
40
+ // VS Code auto-runs this on folder open, so from here down nothing is allowed to be
41
+ // fatal: errors are logged and retried, and only a stop signal ends the task.
25
42
  logger.start("initial sync");
26
- runSynth({ post: true });
27
- logger.success("synced - watching (Ctrl-C to stop)");
43
+ try {
44
+ runSynth({ post: true });
45
+ logger.success("synced - watching (Ctrl-C to stop)");
46
+ } catch (err) {
47
+ // A tree that doesn't synth is precisely when the watcher is most useful: the edit
48
+ // that repairs it is the one the projenrc watcher is sitting there waiting for.
49
+ logger.error(
50
+ "initial sync failed - watching anyway:",
51
+ err instanceof Error ? err.message : err,
52
+ );
53
+ }
54
+
28
55
  const { result } = concurrently(
29
56
  [
30
57
  { command: `tsx "${taskPath("projenrc.ts")}"`, name: "projenrc", prefixColor: "magenta" },
31
58
  { command: `tsx "${taskPath("barrels.ts")}" --watch`, name: "barrels", prefixColor: "cyan" },
32
59
  { command: `tsx "${taskPath("openapi.ts")}" --watch`, name: "openapi", prefixColor: "green" },
33
60
  ],
34
- { prefix: "name", killOthersOn: ["failure"] },
61
+ {
62
+ prefix: "name",
63
+ // No `killOthersOn`: one watcher falling over is no reason to tear the other two
64
+ // down. `-1` is concurrently's spelling for "restart forever", so a crashed
65
+ // watcher comes back instead of silently leaving its outputs stale.
66
+ restartTries: -1,
67
+ restartDelay: RESTART_DELAY_MS,
68
+ },
35
69
  );
36
- await result.catch(() => process.exit(1));
70
+
71
+ let stopping = false;
72
+
73
+ /**
74
+ * Wind the watchers down for good.
75
+ *
76
+ * Restarting forever means an ordinary SIGTERM would be answered by respawning every
77
+ * watcher, so the task has to opt out explicitly. SIGINT is the one signal concurrently
78
+ * neutralizes - it rewrites that exit to 0 before the restart controller sees it - so
79
+ * re-emitting it is how we say "stop" in a language the supervisor understands.
80
+ *
81
+ * Killing is asynchronous (concurrently shells out to `ps` to walk each process tree)
82
+ * and can outlive `result`, which is how a plain SIGTERM used to leave orphaned
83
+ * watchers behind. The timer holds the process open until the kills land, and doubles
84
+ * as the backstop that leaves anyway if one of them wedges.
85
+ */
86
+ function stop(): void {
87
+ if (stopping) return;
88
+ stopping = true;
89
+ process.emit("SIGINT", "SIGINT");
90
+ setTimeout(() => process.exit(0), STOP_GRACE_MS);
91
+ }
92
+
93
+ // Registered after `concurrently()` so its own signal handler - the one that actually
94
+ // kills the children - is already in place by the time ours can fire.
95
+ for (const signal of ["SIGTERM", "SIGHUP"] as const) process.on(signal, stop);
96
+
97
+ // With infinite restarts this settles only once a stop signal has wound the watchers
98
+ // down, so there is no failure left to report. When `stop()` is driving, the pending
99
+ // grace timer keeps us alive past this point and owns the exit.
100
+ await result.catch(() => {});
37
101
  }
package/tasks/publish.ts DELETED
@@ -1,30 +0,0 @@
1
- #!/usr/bin/env -S npx tsx
2
- /**
3
- * Projen task entry for release (`publish`, `package`, `release:tag`).
4
- */
5
- import { Command, Option } from "commander";
6
- import { buildFromTag, packForRelease, publish, type BumpLevel } from "../src/publish";
7
-
8
- const INCREMENT_LEVELS = ["patch", "minor", "major"] as const;
9
-
10
- function parseIncrement(value: string): BumpLevel {
11
- if ((INCREMENT_LEVELS as readonly string[]).includes(value)) return value as BumpLevel;
12
- throw new Error(`invalid --increment: ${value} (expected patch, minor, or major)`);
13
- }
14
-
15
- const program = new Command();
16
- program
17
- .option("--pack", "sync version and pnpm pack into dist/js (projen package task)")
18
- .option("--ci", "CI: build and pack from the git tag (projen release:tag task)")
19
- .addOption(
20
- new Option("--increment [level]", "semver bump level")
21
- .choices([...INCREMENT_LEVELS])
22
- .default("patch"),
23
- )
24
- .action((opts: { pack?: boolean; ci?: boolean; increment: string }) => {
25
- if (opts.ci) buildFromTag();
26
- else if (opts.pack) packForRelease();
27
- else publish(undefined, parseIncrement(opts.increment));
28
- });
29
-
30
- await program.parseAsync();