@paleo/workspace 0.11.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 +113 -0
- package/dist/cli.d.ts +47 -0
- package/dist/cli.js +235 -0
- package/dist/dev-server.d.ts +52 -0
- package/dist/dev-server.js +349 -0
- package/dist/dev-servers-registry.d.ts +42 -0
- package/dist/dev-servers-registry.js +140 -0
- package/dist/errors.d.ts +10 -0
- package/dist/errors.js +18 -0
- package/dist/helpers.d.ts +31 -0
- package/dist/helpers.js +141 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +6 -0
- package/dist/log-polling.d.ts +17 -0
- package/dist/log-polling.js +41 -0
- package/dist/port-holder.d.ts +25 -0
- package/dist/port-holder.js +147 -0
- package/dist/ports.d.ts +16 -0
- package/dist/ports.js +37 -0
- package/dist/process-control.d.ts +4 -0
- package/dist/process-control.js +41 -0
- package/dist/server-descriptor.d.ts +45 -0
- package/dist/server-descriptor.js +1 -0
- package/dist/slots.d.ts +63 -0
- package/dist/slots.js +164 -0
- package/dist/workspace.d.ts +144 -0
- package/dist/workspace.js +550 -0
- package/dist/worktree.d.ts +28 -0
- package/dist/worktree.js +124 -0
- package/package.json +51 -0
package/dist/slots.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { allPorts, isValidPort } from "./ports.js";
|
|
5
|
+
import { getWorktreeBranch } from "./worktree.js";
|
|
6
|
+
const SLOTS_FILENAME = "slots.json";
|
|
7
|
+
export function readSlots(mainWorktree, registryDir) {
|
|
8
|
+
const filePath = join(mainWorktree, registryDir, SLOTS_FILENAME);
|
|
9
|
+
if (!existsSync(filePath))
|
|
10
|
+
return { slots: {} };
|
|
11
|
+
return JSON.parse(readFileSync(filePath, "utf-8"));
|
|
12
|
+
}
|
|
13
|
+
export function writeSlots(mainWorktree, registryDir, registry) {
|
|
14
|
+
const filePath = join(mainWorktree, registryDir, SLOTS_FILENAME);
|
|
15
|
+
mkdirSync(join(mainWorktree, registryDir), { recursive: true });
|
|
16
|
+
writeFileSync(filePath, `${JSON.stringify(registry, undefined, 2)}\n`);
|
|
17
|
+
}
|
|
18
|
+
export function resolveAndRegisterSlot(input) {
|
|
19
|
+
const registry = readSlots(input.mainWorktree, input.registryDir);
|
|
20
|
+
const port = pickSlotPort(input, registry);
|
|
21
|
+
const existing = registry.slots[String(port)];
|
|
22
|
+
const owner = input.requestedOwner ?? existing?.owner;
|
|
23
|
+
const createdAt = existing?.createdAt ?? new Date().toISOString();
|
|
24
|
+
// Re-runs of `workspace setup` keep a previously finalized slot ready, unless `--force` is set —
|
|
25
|
+
// then we reset to pending so `workspace wait` blocks and `dev:up` refuses during the re-finalize.
|
|
26
|
+
const status = existing?.status === "ready" && !input.force ? "ready" : "pending";
|
|
27
|
+
const entry = {
|
|
28
|
+
worktree: input.currentWorktree,
|
|
29
|
+
createdAt,
|
|
30
|
+
status,
|
|
31
|
+
};
|
|
32
|
+
if (input.isMainWorktree)
|
|
33
|
+
entry.main = true;
|
|
34
|
+
if (owner !== undefined)
|
|
35
|
+
entry.owner = owner;
|
|
36
|
+
registry.slots[String(port)] = entry;
|
|
37
|
+
writeSlots(input.mainWorktree, input.registryDir, registry);
|
|
38
|
+
return { port, owner, status };
|
|
39
|
+
}
|
|
40
|
+
export function markSlotReady(mainWorktree, registryDir, slotPort) {
|
|
41
|
+
const registry = readSlots(mainWorktree, registryDir);
|
|
42
|
+
const entry = registry.slots[String(slotPort)];
|
|
43
|
+
if (!entry)
|
|
44
|
+
return;
|
|
45
|
+
entry.status = "ready";
|
|
46
|
+
delete entry.failure;
|
|
47
|
+
writeSlots(mainWorktree, registryDir, registry);
|
|
48
|
+
}
|
|
49
|
+
export function markSlotFailed(mainWorktree, registryDir, slotPort, message) {
|
|
50
|
+
const registry = readSlots(mainWorktree, registryDir);
|
|
51
|
+
const entry = registry.slots[String(slotPort)];
|
|
52
|
+
if (!entry)
|
|
53
|
+
return;
|
|
54
|
+
entry.status = "failed";
|
|
55
|
+
entry.failure = { at: new Date().toISOString(), message };
|
|
56
|
+
writeSlots(mainWorktree, registryDir, registry);
|
|
57
|
+
}
|
|
58
|
+
export function validateSlotAvailability(slotArg, ctx) {
|
|
59
|
+
if (slotArg === undefined)
|
|
60
|
+
return;
|
|
61
|
+
const port = Number(slotArg);
|
|
62
|
+
if (!isValidPort(port, ctx.scheme)) {
|
|
63
|
+
console.error(`Error: Slot must be a valid port: ${allPorts(ctx.scheme).join(", ")}.`);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
const registry = readSlots(ctx.mainWorktree, ctx.registryDir);
|
|
67
|
+
const existing = registry.slots[String(port)];
|
|
68
|
+
if (existing && resolve(existing.worktree) !== resolve(ctx.currentWorktree)) {
|
|
69
|
+
const existingBranch = getWorktreeBranch(existing.worktree);
|
|
70
|
+
console.error(`Error: Slot ${port} is already taken by ${existing.worktree} (branch: ${existingBranch ?? "(detached)"}).`);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export function resolveCurrentSlot(basePort, registryDir) {
|
|
75
|
+
const slot = lookupSlotForCwd(registryDir) ?? synthesizeMainSlot(basePort);
|
|
76
|
+
if (!slot) {
|
|
77
|
+
console.error("Error: No slot found for this worktree. Run `workspace setup` first.");
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
return slot;
|
|
81
|
+
}
|
|
82
|
+
export function handleSetOwner(input) {
|
|
83
|
+
if (input.isMainWorktree) {
|
|
84
|
+
console.error("Error: `workspace set-owner` must be run from a linked worktree.");
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
const registry = readSlots(input.mainWorktree, input.registryDir);
|
|
88
|
+
const resolvedCurrent = resolve(input.currentWorktree);
|
|
89
|
+
const entry = Object.entries(registry.slots).find(([, v]) => resolve(v.worktree) === resolvedCurrent);
|
|
90
|
+
if (!entry) {
|
|
91
|
+
console.error("Error: No slot found for this worktree in the registry.");
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
const [slotPort, slotData] = entry;
|
|
95
|
+
const updated = {
|
|
96
|
+
worktree: slotData.worktree,
|
|
97
|
+
createdAt: slotData.createdAt,
|
|
98
|
+
status: slotData.status,
|
|
99
|
+
};
|
|
100
|
+
if (slotData.failure)
|
|
101
|
+
updated.failure = slotData.failure;
|
|
102
|
+
if (input.newOwner !== undefined)
|
|
103
|
+
updated.owner = input.newOwner;
|
|
104
|
+
registry.slots[slotPort] = updated;
|
|
105
|
+
writeSlots(input.mainWorktree, input.registryDir, registry);
|
|
106
|
+
return { slotPort, owner: input.newOwner };
|
|
107
|
+
}
|
|
108
|
+
function pickSlotPort(args, registry) {
|
|
109
|
+
const resolvedCurrent = resolve(args.currentWorktree);
|
|
110
|
+
if (args.isMainWorktree)
|
|
111
|
+
return args.scheme.basePort;
|
|
112
|
+
if (args.slot !== undefined) {
|
|
113
|
+
const port = Number(args.slot);
|
|
114
|
+
if (!isValidPort(port, args.scheme)) {
|
|
115
|
+
console.error(`Error: Slot must be a valid port: ${allPorts(args.scheme).join(", ")}.`);
|
|
116
|
+
process.exit(1);
|
|
117
|
+
}
|
|
118
|
+
const existing = registry.slots[String(port)];
|
|
119
|
+
if (existing && resolve(existing.worktree) !== resolvedCurrent) {
|
|
120
|
+
const existingBranch = getWorktreeBranch(existing.worktree);
|
|
121
|
+
console.error(`Error: Slot ${port} is already taken by ${existing.worktree} (branch: ${existingBranch ?? "(detached)"}).`);
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
return port;
|
|
125
|
+
}
|
|
126
|
+
const existingEntry = Object.entries(registry.slots).find(([, v]) => resolve(v.worktree) === resolvedCurrent);
|
|
127
|
+
if (existingEntry)
|
|
128
|
+
return Number(existingEntry[0]);
|
|
129
|
+
for (const port of allPorts(args.scheme)) {
|
|
130
|
+
if (!registry.slots[String(port)])
|
|
131
|
+
return port;
|
|
132
|
+
}
|
|
133
|
+
console.error("Error: All slots are taken. Remove a workspace with `workspace remove` first.");
|
|
134
|
+
process.exit(1);
|
|
135
|
+
}
|
|
136
|
+
function lookupSlotForCwd(registryDir) {
|
|
137
|
+
const cwd = resolve(process.cwd());
|
|
138
|
+
// Reads slots.json relative to cwd's shared-dir symlink (so works in linked worktrees too).
|
|
139
|
+
const filePath = join(registryDir, SLOTS_FILENAME);
|
|
140
|
+
if (!existsSync(filePath))
|
|
141
|
+
return undefined;
|
|
142
|
+
const registry = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
143
|
+
for (const [port, entry] of Object.entries(registry.slots)) {
|
|
144
|
+
if (resolve(entry.worktree) === cwd) {
|
|
145
|
+
const resolved = {
|
|
146
|
+
slot: Number(port),
|
|
147
|
+
worktree: entry.worktree,
|
|
148
|
+
owner: entry.owner,
|
|
149
|
+
};
|
|
150
|
+
if (entry.main)
|
|
151
|
+
resolved.main = true;
|
|
152
|
+
return resolved;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
function synthesizeMainSlot(basePort) {
|
|
158
|
+
const gitCommonDir = execFileSync("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], { encoding: "utf-8" }).trim();
|
|
159
|
+
const mainWorktree = dirname(gitCommonDir);
|
|
160
|
+
const cwd = resolve(process.cwd());
|
|
161
|
+
if (resolve(mainWorktree) !== cwd)
|
|
162
|
+
return undefined;
|
|
163
|
+
return { slot: basePort, worktree: cwd, main: true };
|
|
164
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { type SlotStatus } from "./slots.js";
|
|
2
|
+
import { type WorktreeDirNameFn } from "./worktree.js";
|
|
3
|
+
/** Configuration accepted by {@link runWorkspace}. */
|
|
4
|
+
export interface WorkspaceConfig {
|
|
5
|
+
/**
|
|
6
|
+
* Absolute path to the wrapper script that calls `runWorkspace`. The package re-spawns this
|
|
7
|
+
* file as a detached child for the finalize phase, so it must point at a runnable Node entrypoint
|
|
8
|
+
* — typically `fileURLToPath(import.meta.url)` from your `workspace.mjs`.
|
|
9
|
+
*/
|
|
10
|
+
scriptPath: string;
|
|
11
|
+
/**
|
|
12
|
+
* Absolute path to your dev-server script (the file that calls `runDevServer`). On
|
|
13
|
+
* `workspace remove`, the kernel shells out to `node <devServerScript> --stop` with
|
|
14
|
+
* `cwd: <target worktree>`. Typically
|
|
15
|
+
* `fileURLToPath(new URL('./dev-server.mjs', import.meta.url))` from your `workspace.mjs`.
|
|
16
|
+
*/
|
|
17
|
+
devServerScript: string;
|
|
18
|
+
/** Anchor port for the slot range. Slots are derived from this value. */
|
|
19
|
+
basePort: number;
|
|
20
|
+
/** Distance between consecutive slots. Defaults to `10`. */
|
|
21
|
+
portStep?: number;
|
|
22
|
+
/** Maximum number of slots. Defaults to `19`. */
|
|
23
|
+
maxSlotCount?: number;
|
|
24
|
+
/** Custom port computation; takes precedence over `portNames`. */
|
|
25
|
+
ports?: (slot: number) => Record<string, number>;
|
|
26
|
+
/** Named offsets `[name0, name1, ...]` mapped to `slot+0`, `slot+1`, ... Required if `ports` is omitted. */
|
|
27
|
+
portNames?: string[];
|
|
28
|
+
/** Directories symlinked from the main worktree. */
|
|
29
|
+
sharedDirs: string[];
|
|
30
|
+
/**
|
|
31
|
+
* Per-worktree runtime directory, relative to the worktree root (e.g. `.local-wt`).
|
|
32
|
+
* Holds the setup log and dev-server logs.
|
|
33
|
+
*/
|
|
34
|
+
runtimeDir: string;
|
|
35
|
+
/**
|
|
36
|
+
* Shared registry directory, relative to a worktree root (e.g. `.local/wt-registry`).
|
|
37
|
+
* Holds `slots.json` and `dev-servers.json`. Must resolve to the same physical directory
|
|
38
|
+
* across linked worktrees — typically via a symlink listed in `sharedDirs` (e.g. `.local`).
|
|
39
|
+
*/
|
|
40
|
+
registryDir: string;
|
|
41
|
+
/** Config files copied from the main worktree and patched per slot. */
|
|
42
|
+
configFiles: ConfigFileEntry[];
|
|
43
|
+
/**
|
|
44
|
+
* Runs before `configFiles` are copied. Use this to bootstrap source files the kernel expects
|
|
45
|
+
* to find (e.g. seed `config.json` from `config.example.json` on the main worktree, decrypt
|
|
46
|
+
* an env file). MUST be idempotent. On a linked-worktree setup, MUST NOT mutate the main
|
|
47
|
+
* worktree — bootstrap the main worktree first via `workspace setup`.
|
|
48
|
+
*/
|
|
49
|
+
preSetup?: (ctx: PreSetupContext) => Promise<void> | void;
|
|
50
|
+
/**
|
|
51
|
+
* MUST be idempotent. After a failure, the user re-runs `workspace setup` from inside
|
|
52
|
+
* the worktree — this callback will be invoked again with the same context. Re-runs must not
|
|
53
|
+
* error on pre-existing state (created directories, started containers, ran migrations,
|
|
54
|
+
* installed deps, etc.).
|
|
55
|
+
*
|
|
56
|
+
* Runs in a detached child whose stdout/stderr are already redirected to
|
|
57
|
+
* `<runtimeDir>/wt-setup.log`. `console.log` and child-process `stdio: "inherit"` land there.
|
|
58
|
+
*/
|
|
59
|
+
finalizeWorktree: (ctx: SetupContext) => Promise<void> | void;
|
|
60
|
+
/**
|
|
61
|
+
* Destructive infrastructure teardown on `workspace remove` (e.g. `docker compose down -v` to
|
|
62
|
+
* wipe volumes). Runs after the dev-server stop. Best-effort; errors should be swallowed.
|
|
63
|
+
*/
|
|
64
|
+
purgeInfrastructure?: (ctx: PurgeContext) => Promise<void> | void;
|
|
65
|
+
/** Builds the post-setup summary printed to stdout. */
|
|
66
|
+
printSummary: (ctx: SummaryContext) => string;
|
|
67
|
+
/**
|
|
68
|
+
* Optional override for the worktree directory basename. Receives `{ branch, repoName }` and
|
|
69
|
+
* returns the basename (e.g. `myrepo-feat-ABC-123`). Defaults to {@link defaultWorktreeDirName},
|
|
70
|
+
* which strips a recognizable ticket suffix and caps the slug at 22 chars. The kernel handles
|
|
71
|
+
* deduplication (`-2`, `-3`…) when the resulting directory already exists.
|
|
72
|
+
*/
|
|
73
|
+
worktreeDirName?: WorktreeDirNameFn;
|
|
74
|
+
}
|
|
75
|
+
/** Context passed to {@link WorkspaceConfig.preSetup}. */
|
|
76
|
+
export interface PreSetupContext {
|
|
77
|
+
currentWorktree: string;
|
|
78
|
+
mainWorktree: string;
|
|
79
|
+
/** `true` when running on the main worktree (i.e. `workspace setup` from the main checkout). */
|
|
80
|
+
isMainWorktree: boolean;
|
|
81
|
+
/** Mirrors `--force`. Hooks may use it to overwrite previously bootstrapped files. */
|
|
82
|
+
force: boolean;
|
|
83
|
+
/** Writes to stdout and the setup log. */
|
|
84
|
+
log: (msg: string) => void;
|
|
85
|
+
}
|
|
86
|
+
/** Context passed to {@link WorkspaceConfig.finalizeWorktree}. */
|
|
87
|
+
export interface SetupContext {
|
|
88
|
+
currentWorktree: string;
|
|
89
|
+
mainWorktree: string;
|
|
90
|
+
/** `true` when finalizing the main worktree. Gate "copy from main" steps with `!isMainWorktree`. */
|
|
91
|
+
isMainWorktree: boolean;
|
|
92
|
+
slot: number;
|
|
93
|
+
/** Live-resolved branch of `currentWorktree`. `"(detached)"` for detached HEAD. */
|
|
94
|
+
branch: string;
|
|
95
|
+
owner?: string;
|
|
96
|
+
ports: Record<string, number>;
|
|
97
|
+
force: boolean;
|
|
98
|
+
verbose: boolean;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Context passed to {@link WorkspaceConfig.printSummary}.
|
|
102
|
+
*
|
|
103
|
+
* Called after worktree creation; the dev-server is not running yet.
|
|
104
|
+
*/
|
|
105
|
+
export interface SummaryContext {
|
|
106
|
+
slot: number;
|
|
107
|
+
/** Live-resolved branch of `currentWorktree`. `"(detached)"` for detached HEAD. */
|
|
108
|
+
branch: string;
|
|
109
|
+
owner?: string;
|
|
110
|
+
ports: Record<string, number>;
|
|
111
|
+
currentWorktree: string;
|
|
112
|
+
mainWorktree: string;
|
|
113
|
+
/** `true` when the summary describes the main worktree (slot = `basePort`). */
|
|
114
|
+
isMainWorktree: boolean;
|
|
115
|
+
/** Slot finalize status. `"pending"` until `finalizeWorktree` succeeds, then `"ready"`. */
|
|
116
|
+
status: SlotStatus;
|
|
117
|
+
}
|
|
118
|
+
/** Context passed to {@link WorkspaceConfig.purgeInfrastructure}. */
|
|
119
|
+
export interface PurgeContext {
|
|
120
|
+
worktree: string;
|
|
121
|
+
mainWorktree: string;
|
|
122
|
+
verbose: boolean;
|
|
123
|
+
}
|
|
124
|
+
/** One config file copied from the main worktree and patched per slot. */
|
|
125
|
+
export interface ConfigFileEntry {
|
|
126
|
+
/** Path relative to the worktree root. Same path is read from main and written to current. */
|
|
127
|
+
path: string;
|
|
128
|
+
/** Returns the patched content given the source content and the slot's ports. */
|
|
129
|
+
patch: (content: string, ctx: PatchContext) => string;
|
|
130
|
+
/**
|
|
131
|
+
* When `true`, a missing source on the main worktree logs a warning and skips the entry.
|
|
132
|
+
* Default: required (missing source aborts setup). Bootstrap the main worktree first via
|
|
133
|
+
* `workspace setup`, or seed sources in `preSetup`.
|
|
134
|
+
*/
|
|
135
|
+
optional?: boolean;
|
|
136
|
+
}
|
|
137
|
+
/** Context passed to {@link ConfigFileEntry.patch}. */
|
|
138
|
+
export interface PatchContext {
|
|
139
|
+
slot: number;
|
|
140
|
+
ports: Record<string, number>;
|
|
141
|
+
mainWorktree: string;
|
|
142
|
+
currentWorktree: string;
|
|
143
|
+
}
|
|
144
|
+
export declare function runWorkspace(config: WorkspaceConfig): Promise<void>;
|