@paleo/worktree-env 0.6.2 → 0.7.1

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,11 +39,21 @@ 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
45
52
  * error on pre-existing state (created directories, started containers, ran migrations,
46
53
  * installed deps, etc.).
54
+ *
55
+ * Runs in a detached child whose stdout/stderr are already redirected to
56
+ * `<runtimeDir>/wt-setup.log`. `console.log` and child-process `stdio: "inherit"` land there.
47
57
  */
48
58
  finalizeWorktree: (ctx: SetupContext) => Promise<void> | void;
49
59
  /**
@@ -61,6 +71,17 @@ export interface SetupWorktreeConfig {
61
71
  */
62
72
  worktreeDirName?: WorktreeDirNameFn;
63
73
  }
74
+ /** Context passed to {@link SetupWorktreeConfig.preSetup}. */
75
+ export interface PreSetupContext {
76
+ currentWorktree: string;
77
+ mainWorktree: string;
78
+ /** `true` when running on the main worktree (i.e. `--here` from the main checkout). */
79
+ isMainWorktree: boolean;
80
+ /** Mirrors `--force`. Hooks may use it to overwrite previously bootstrapped files. */
81
+ force: boolean;
82
+ /** Writes to stdout and the setup log. */
83
+ log: (msg: string) => void;
84
+ }
64
85
  /** Context passed to {@link SetupWorktreeConfig.finalizeWorktree}. */
65
86
  export interface SetupContext {
66
87
  currentWorktree: string;
@@ -97,8 +118,12 @@ export interface ConfigFileEntry {
97
118
  path: string;
98
119
  /** Returns the patched content given the source content and the slot's ports. */
99
120
  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;
121
+ /**
122
+ * When `true`, a missing source on the main worktree logs a warning and skips the entry.
123
+ * Default: required (missing source aborts setup). Bootstrap the main worktree first via
124
+ * `setup-worktree --here`, or seed sources in `preSetup`.
125
+ */
126
+ optional?: boolean;
102
127
  }
103
128
  /** Context passed to {@link ConfigFileEntry.patch}. */
104
129
  export interface PatchContext {
@@ -88,6 +88,7 @@ async function runSetup(args, ctx, run, config) {
88
88
  scheme,
89
89
  branch,
90
90
  requestedOwner: args.owner,
91
+ isMainWorktree: setupCtx.isMainWorktree,
91
92
  });
92
93
  const ports = portsFn(slot);
93
94
  const runtimeDir = join(setupCtx.currentWorktree, config.runtimeDir);
@@ -111,6 +112,15 @@ async function runSetup(args, ctx, run, config) {
111
112
  verboseLog(`Using slot ${slot} (${Object.entries(ports)
112
113
  .map(([k, v]) => `${k}: ${v}`)
113
114
  .join(", ")})`);
115
+ if (config.preSetup) {
116
+ await config.preSetup({
117
+ currentWorktree: setupCtx.currentWorktree,
118
+ mainWorktree: setupCtx.mainWorktree,
119
+ isMainWorktree: setupCtx.isMainWorktree,
120
+ force: args.force ?? false,
121
+ log: teeLog,
122
+ });
123
+ }
114
124
  linkSharedDirectories(setupCtx, config.sharedDirs, verboseLog);
115
125
  generateConfigFiles(setupCtx, config.configFiles, slot, ports, args.force ?? false, verboseLog);
116
126
  teeLog(config.printSummary({
@@ -199,8 +209,7 @@ function printWorktreeInfo(config, slot, worktreeForLog, fallback) {
199
209
  const ports = resolvePortsFn(config)(slot);
200
210
  const branch = entry?.branch ?? fallback.branch;
201
211
  const owner = entry?.owner ?? fallback.owner;
202
- // Main worktree has no slot entry by design — treat it as ready when the registry has no row.
203
- const slotStatus = entry?.status ?? (ctx.isMainWorktree ? "ready" : "pending");
212
+ const slotStatus = entry?.status ?? "pending";
204
213
  const logHint = ` (tail ${join(worktreeForLog, config.runtimeDir, "wt-setup.log")})`;
205
214
  const display = slotStatus === "ready"
206
215
  ? "ready"
@@ -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/dist/slots.d.ts CHANGED
@@ -30,6 +30,8 @@ export interface RegisterSlotInput {
30
30
  scheme: PortScheme;
31
31
  branch: string;
32
32
  requestedOwner?: string;
33
+ /** When `true`, the slot is forced to `scheme.basePort` regardless of `slot` arg. */
34
+ isMainWorktree: boolean;
33
35
  }
34
36
  export declare function resolveAndRegisterSlot(input: RegisterSlotInput): {
35
37
  port: number;
package/dist/slots.js CHANGED
@@ -104,6 +104,8 @@ export function handleSetOwner(input) {
104
104
  }
105
105
  function pickSlotPort(args, registry) {
106
106
  const resolvedCurrent = resolve(args.currentWorktree);
107
+ if (args.isMainWorktree)
108
+ return args.scheme.basePort;
107
109
  if (args.slot !== undefined) {
108
110
  const port = Number(args.slot);
109
111
  if (!isValidPort(port, args.scheme)) {
package/dist/worktree.js CHANGED
@@ -17,12 +17,7 @@ export function enforceWorktreeMode(args, ctx) {
17
17
  process.exit(1);
18
18
  }
19
19
  }
20
- else if (args.here) {
21
- if (ctx.isMainWorktree) {
22
- console.error("Error: --here must be run from a linked worktree, not from the main worktree.");
23
- process.exit(1);
24
- }
25
- }
20
+ // --here runs in any worktree: linked worktree (retry path) or main (initial bootstrap).
26
21
  }
27
22
  export function useExistingBranch(branch, ctx, run, dirNameFn = defaultWorktreeDirName) {
28
23
  if (!branchExists(branch)) {
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.1",
4
4
  "description": "Worktree-based concurrent local environment kernel.",
5
5
  "keywords": [
6
6
  "worktree",