@paleo/worktree-env 0.6.2 → 0.7.0

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
@@ -58,6 +58,11 @@ await runSetupWorktree({
58
58
  }),
59
59
  },
60
60
  ],
61
+ preSetup: ({ currentWorktree, isMainWorktree, log }) => {
62
+ // Idempotent. Bootstrap source files the kernel expects to find (e.g. seed `.env` from
63
+ // `.env.example` on the main worktree). MUST NOT mutate the main worktree from a linked
64
+ // worktree setup — bootstrap the main first via `setup-worktree --here`.
65
+ },
61
66
  finalizeWorktree: async ({ currentWorktree }) => {
62
67
  // MUST be idempotent. Install deps, start containers, seed a database, etc.
63
68
  },
@@ -68,6 +73,8 @@ await runSetupWorktree({
68
73
 
69
74
  Setup runs in two phases: a fast foreground Part 1 creates the worktree and config, then a detached Part 2 runs `finalizeWorktree` and writes progress to `<runtimeDir>/wt-setup.log`. If Part 2 fails, `cd` into the worktree and run `setup-worktree --here` — it is idempotent and retries the finalize step. To block until Part 2 finishes (CI, agent orchestration), run `setup-worktree --wait` from inside the worktree (or `setup-worktree --wait --slot 8110` from anywhere) — exits 0 on `READY`, 1 on `FAILED`.
70
75
 
76
+ **Bootstrap the main worktree first.** Linked-worktree setup copies config sources from the main worktree, so the main must already have those files. Run `setup-worktree --here` once on the main checkout. Use `preSetup` (with `isMainWorktree === true`) to seed sources from examples or templates. `configFiles` entries are required by default; mark `optional: true` for sources that may legitimately be missing.
77
+
71
78
  `--evict` is best-effort: the cap check and the subsequent register are not atomic, so two concurrent `dev:up --evict` from different worktrees can both pass the check and end up at `devLimit + 1` live servers. The window is narrow; if it matters, `dev:list` + `dev:down` deterministically.
72
79
 
73
80
  ```ts
@@ -5,6 +5,7 @@ import { dirname, join } from "node:path";
5
5
  import { parseDevServerArgs, printDevServerHelp, validateDevServerFlags, } from "./cli.js";
6
6
  import { evictOldest, findOwnEntry, listDevServers, printActiveServers, pruneAndPersist, registerDevServer, removeDevServerEntryByWorktree, stopAllRegistered, unregisterDevServer, } from "./dev-servers-registry.js";
7
7
  import { ConfigError, StartupError } from "./errors.js";
8
+ import { detectCommonJsError } from "./helpers.js";
8
9
  import { awaitAllReady, handleStartupFailure } from "./log-polling.js";
9
10
  import { isProcessAlive, stopProcessGroup } from "./process-control.js";
10
11
  import { resolveCurrentSlot } from "./slots.js";
@@ -80,7 +81,7 @@ async function start(config, mainWorktree, { evict }) {
80
81
  name: s.name,
81
82
  logFile: logFileFor(config.runtimeDir, s.name),
82
83
  detectSuccess: s.detectSuccess,
83
- detectError: s.detectError,
84
+ detectError: s.detectError ?? detectCommonJsError,
84
85
  }));
85
86
  const pollPids = spawnEntries.map((s) => spawnPids[s.name]);
86
87
  await awaitAllReady(pollables, pollPids);
package/dist/helpers.d.ts CHANGED
@@ -15,4 +15,11 @@ export interface CopyAndPatchCtx {
15
15
  mainWorktree: string;
16
16
  log: (msg: string) => void;
17
17
  }
18
- export declare function copyAndPatchFile(ctx: CopyAndPatchCtx, relPath: string, patchFn: (content: string) => string, label: string, force: boolean, required?: boolean): void;
18
+ export declare function copyAndPatchFile(ctx: CopyAndPatchCtx, relPath: string, patchFn: (content: string) => string, label: string, force: boolean, optional?: boolean): void;
19
+ /**
20
+ * Detects common fatal JS startup failures in a log buffer. Returns a short marker string
21
+ * naming the matched pattern, or `false` when none match. Used as the default `detectError`
22
+ * for spawn servers that don't supply one. A custom `detectError` can compose with this:
23
+ * `detectError: (log) => myDetector(log) || helpers.detectCommonJsError(log)`.
24
+ */
25
+ export declare function detectCommonJsError(log: string): string | false;
package/dist/helpers.js CHANGED
@@ -59,7 +59,7 @@ export function readPortFromJsonFile(file, jsonPath) {
59
59
  }
60
60
  return toPort(String(cur), file);
61
61
  }
62
- export function copyAndPatchFile(ctx, relPath, patchFn, label, force, required = false) {
62
+ export function copyAndPatchFile(ctx, relPath, patchFn, label, force, optional = false) {
63
63
  const targetPath = join(ctx.currentWorktree, relPath);
64
64
  const sourcePath = join(ctx.mainWorktree, relPath);
65
65
  const alreadyExists = existsSync(targetPath);
@@ -68,11 +68,12 @@ export function copyAndPatchFile(ctx, relPath, patchFn, label, force, required =
68
68
  return;
69
69
  }
70
70
  if (!existsSync(sourcePath)) {
71
- if (required) {
72
- console.error(`Error: ${relPath} not found in main worktree (required).`);
71
+ if (!optional) {
72
+ console.error(`Error: ${relPath} not found in main worktree. Bootstrap the main worktree first ` +
73
+ "(setup-worktree --here), or mark the entry as optional.");
73
74
  process.exit(1);
74
75
  }
75
- ctx.log(`Warning: ${relPath} not found in main worktree, skipping.`);
76
+ ctx.log(`Warning: ${relPath} not found in main worktree, skipping (optional).`);
76
77
  return;
77
78
  }
78
79
  const content = readFileSync(sourcePath, "utf-8");
@@ -81,6 +82,25 @@ export function copyAndPatchFile(ctx, relPath, patchFn, label, force, required =
81
82
  writeFileSync(targetPath, patched);
82
83
  ctx.log(`${alreadyExists ? "Overwritten" : "Created"} ${label}.`);
83
84
  }
85
+ /**
86
+ * Detects common fatal JS startup failures in a log buffer. Returns a short marker string
87
+ * naming the matched pattern, or `false` when none match. Used as the default `detectError`
88
+ * for spawn servers that don't supply one. A custom `detectError` can compose with this:
89
+ * `detectError: (log) => myDetector(log) || helpers.detectCommonJsError(log)`.
90
+ */
91
+ export function detectCommonJsError(log) {
92
+ if (log.includes("[nodemon] app crashed"))
93
+ return "[nodemon] app crashed";
94
+ if (/^Node\.js v/m.test(log))
95
+ return "Node.js v";
96
+ if (log.includes("Error: Cannot find module "))
97
+ return "Error: Cannot find module";
98
+ if (/^SyntaxError: /m.test(log))
99
+ return "SyntaxError";
100
+ if (log.includes("UnhandledPromiseRejection"))
101
+ return "UnhandledPromiseRejection";
102
+ return false;
103
+ }
84
104
  function toPort(raw, file) {
85
105
  const port = Number(raw);
86
106
  if (!Number.isFinite(port)) {
@@ -22,7 +22,11 @@ export interface SpawnServer {
22
22
  port: number;
23
23
  /** Returns `true` once the log content indicates the server is ready. */
24
24
  detectSuccess: (logContent: string) => boolean;
25
- /** Returns a non-empty marker string when the log content indicates a fatal error, or `false`. */
25
+ /**
26
+ * Returns a non-empty marker string when the log content indicates a fatal error, or `false`.
27
+ * When omitted, `helpers.detectCommonJsError` is used as a default. To disable detection,
28
+ * pass `() => false`.
29
+ */
26
30
  detectError?: (logContent: string) => string | false;
27
31
  }
28
32
  /**
@@ -39,6 +39,13 @@ export interface SetupWorktreeConfig {
39
39
  registryDir: string;
40
40
  /** Config files copied from the main worktree and patched per slot. */
41
41
  configFiles: ConfigFileEntry[];
42
+ /**
43
+ * Runs before `configFiles` are copied. Use this to bootstrap source files the kernel expects
44
+ * to find (e.g. seed `config.json` from `config.example.json` on the main worktree, decrypt
45
+ * an env file). MUST be idempotent. On a linked-worktree setup, MUST NOT mutate the main
46
+ * worktree — bootstrap the main worktree first via `setup-worktree --here`.
47
+ */
48
+ preSetup?: (ctx: PreSetupContext) => Promise<void> | void;
42
49
  /**
43
50
  * MUST be idempotent. After a failure, the user re-runs `setup-worktree --here` from inside
44
51
  * the worktree — this callback will be invoked again with the same context. Re-runs must not
@@ -61,6 +68,17 @@ export interface SetupWorktreeConfig {
61
68
  */
62
69
  worktreeDirName?: WorktreeDirNameFn;
63
70
  }
71
+ /** Context passed to {@link SetupWorktreeConfig.preSetup}. */
72
+ export interface PreSetupContext {
73
+ currentWorktree: string;
74
+ mainWorktree: string;
75
+ /** `true` when running on the main worktree (i.e. `--here` from the main checkout). */
76
+ isMainWorktree: boolean;
77
+ /** Mirrors `--force`. Hooks may use it to overwrite previously bootstrapped files. */
78
+ force: boolean;
79
+ /** Writes to stdout and the setup log. */
80
+ log: (msg: string) => void;
81
+ }
64
82
  /** Context passed to {@link SetupWorktreeConfig.finalizeWorktree}. */
65
83
  export interface SetupContext {
66
84
  currentWorktree: string;
@@ -97,8 +115,12 @@ export interface ConfigFileEntry {
97
115
  path: string;
98
116
  /** Returns the patched content given the source content and the slot's ports. */
99
117
  patch: (content: string, ctx: PatchContext) => string;
100
- /** When `true`, abort if the source file is missing in the main worktree. Defaults to `false`. */
101
- required?: boolean;
118
+ /**
119
+ * When `true`, a missing source on the main worktree logs a warning and skips the entry.
120
+ * Default: required (missing source aborts setup). Bootstrap the main worktree first via
121
+ * `setup-worktree --here`, or seed sources in `preSetup`.
122
+ */
123
+ optional?: boolean;
102
124
  }
103
125
  /** Context passed to {@link ConfigFileEntry.patch}. */
104
126
  export interface PatchContext {
@@ -111,6 +111,15 @@ async function runSetup(args, ctx, run, config) {
111
111
  verboseLog(`Using slot ${slot} (${Object.entries(ports)
112
112
  .map(([k, v]) => `${k}: ${v}`)
113
113
  .join(", ")})`);
114
+ if (config.preSetup) {
115
+ await config.preSetup({
116
+ currentWorktree: setupCtx.currentWorktree,
117
+ mainWorktree: setupCtx.mainWorktree,
118
+ isMainWorktree: setupCtx.isMainWorktree,
119
+ force: args.force ?? false,
120
+ log: teeLog,
121
+ });
122
+ }
114
123
  linkSharedDirectories(setupCtx, config.sharedDirs, verboseLog);
115
124
  generateConfigFiles(setupCtx, config.configFiles, slot, ports, args.force ?? false, verboseLog);
116
125
  teeLog(config.printSummary({
@@ -377,7 +386,7 @@ function generateConfigFiles(ctx, entries, slot, ports, force, log) {
377
386
  ports,
378
387
  mainWorktree: ctx.mainWorktree,
379
388
  currentWorktree: ctx.currentWorktree,
380
- }), entry.path, force, entry.required ?? false);
389
+ }), entry.path, force, entry.optional ?? false);
381
390
  }
382
391
  }
383
392
  function resolveRemoveTarget(args, ctx, registry, removeHere) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paleo/worktree-env",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "description": "Worktree-based concurrent local environment kernel.",
5
5
  "keywords": [
6
6
  "worktree",