@paleo/workspace 0.19.0 → 0.20.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 +7 -3
- package/dist/cli.d.ts +4 -0
- package/dist/cli.js +19 -0
- package/dist/guide.d.ts +19 -0
- package/dist/guide.js +163 -0
- package/dist/orphans.d.ts +7 -0
- package/dist/orphans.js +11 -0
- package/dist/workspace.js +104 -2
- package/package.json +3 -2
- package/templates/guide.md +91 -0
package/README.md
CHANGED
|
@@ -9,16 +9,16 @@ Each project writes two custom scripts on top, using these entry points:
|
|
|
9
9
|
|
|
10
10
|
## Setup
|
|
11
11
|
|
|
12
|
-
The `
|
|
12
|
+
The `alignfirst-setup-guide` skill is a setup-time companion. Install the skill (globally or locally):
|
|
13
13
|
|
|
14
14
|
```bash
|
|
15
|
-
npx skills add https://github.com/paleo/alignfirst --skill
|
|
15
|
+
npx skills add https://github.com/paleo/alignfirst --skill alignfirst-setup-guide
|
|
16
16
|
```
|
|
17
17
|
|
|
18
18
|
Then, in your project, ask your agent:
|
|
19
19
|
|
|
20
20
|
```text
|
|
21
|
-
Use your
|
|
21
|
+
Use your alignfirst-setup-guide skill. Set up worktree-based local environments in this project.
|
|
22
22
|
```
|
|
23
23
|
|
|
24
24
|
The agent reads the skill, adapts the reference scripts to your stack, installs `@paleo/workspace` as a dev dependency, and wires the npm scripts. After that, you can uninstall the skill, it won't be used by your project anymore.
|
|
@@ -36,8 +36,12 @@ npm run dev -- status # report whether this worktree's dev-ser
|
|
|
36
36
|
npm run dev -- list # active dev-servers across all worktrees
|
|
37
37
|
npm run dev -- down # stop dev server (infrastructure stays up)
|
|
38
38
|
npm run workspace -- remove feat/42 # full teardown
|
|
39
|
+
npm run workspace -- prune # heal workspaces whose worktree was deleted out-of-band
|
|
40
|
+
npm run workspace -- --guide # full operating guide (workspace + dev-server)
|
|
39
41
|
```
|
|
40
42
|
|
|
43
|
+
`--guide` prints the complete workspace + dev-server procedures in your package-manager's syntax. Point agents at it instead of maintaining a separate doc.
|
|
44
|
+
|
|
41
45
|
## API
|
|
42
46
|
|
|
43
47
|
```ts
|
package/dist/cli.d.ts
CHANGED
|
@@ -13,6 +13,8 @@ export type WorkspaceCommand = {
|
|
|
13
13
|
force: boolean;
|
|
14
14
|
} | {
|
|
15
15
|
kind: "list";
|
|
16
|
+
} | {
|
|
17
|
+
kind: "prune";
|
|
16
18
|
} | {
|
|
17
19
|
kind: "status";
|
|
18
20
|
slot?: string;
|
|
@@ -29,6 +31,8 @@ export type WorkspaceCommand = {
|
|
|
29
31
|
} | {
|
|
30
32
|
kind: "migrate";
|
|
31
33
|
oldRegistryDir: string;
|
|
34
|
+
} | {
|
|
35
|
+
kind: "guide";
|
|
32
36
|
} | {
|
|
33
37
|
kind: "help";
|
|
34
38
|
};
|
package/dist/cli.js
CHANGED
|
@@ -5,6 +5,9 @@ export function parseWorkspaceArgs(argv = process.argv.slice(2)) {
|
|
|
5
5
|
if (subcommand === "--help" || subcommand === "-h") {
|
|
6
6
|
return { command: { kind: "help" }, verbose: false };
|
|
7
7
|
}
|
|
8
|
+
if (subcommand === "--guide") {
|
|
9
|
+
return { command: { kind: "guide" }, verbose: false };
|
|
10
|
+
}
|
|
8
11
|
if (subcommand === undefined)
|
|
9
12
|
throw new ConfigError("No command given.");
|
|
10
13
|
try {
|
|
@@ -24,6 +27,8 @@ function parseSubcommand(subcommand, tokens) {
|
|
|
24
27
|
return parseRemove(tokens);
|
|
25
28
|
case "list":
|
|
26
29
|
return parseList(tokens);
|
|
30
|
+
case "prune":
|
|
31
|
+
return parsePrune(tokens);
|
|
27
32
|
case "status":
|
|
28
33
|
return parseStatus(tokens);
|
|
29
34
|
case "wait":
|
|
@@ -101,6 +106,16 @@ function parseList(tokens) {
|
|
|
101
106
|
rejectPositionals(positionals, "list");
|
|
102
107
|
return { command: { kind: "list" }, verbose: values.verbose ?? false };
|
|
103
108
|
}
|
|
109
|
+
function parsePrune(tokens) {
|
|
110
|
+
const { values, positionals } = parseArgs({
|
|
111
|
+
args: tokens,
|
|
112
|
+
options: { verbose: { type: "boolean", short: "v" } },
|
|
113
|
+
allowPositionals: true,
|
|
114
|
+
strict: true,
|
|
115
|
+
});
|
|
116
|
+
rejectPositionals(positionals, "prune");
|
|
117
|
+
return { command: { kind: "prune" }, verbose: values.verbose ?? false };
|
|
118
|
+
}
|
|
104
119
|
function parseStatus(tokens) {
|
|
105
120
|
const { values, positionals } = parseArgs({
|
|
106
121
|
args: tokens,
|
|
@@ -192,6 +207,9 @@ export function printWorkspaceHelp() {
|
|
|
192
207
|
" Refuses on uncommitted changes unless --force.",
|
|
193
208
|
" list",
|
|
194
209
|
" List all registered workspaces (slot, status, branch, path, owner, created).",
|
|
210
|
+
" prune",
|
|
211
|
+
" Heal orphaned workspaces (worktree deleted out-of-band): stop their dev-servers",
|
|
212
|
+
" and drop their registry entries, then run `git worktree prune`.",
|
|
195
213
|
" status [-s|--slot <port>]",
|
|
196
214
|
" Print a workspace summary (ports, branch, readiness, dev-server).",
|
|
197
215
|
" wait [-s|--slot <port>]",
|
|
@@ -201,6 +219,7 @@ export function printWorkspaceHelp() {
|
|
|
201
219
|
"",
|
|
202
220
|
"Global options:",
|
|
203
221
|
" -v, --verbose Show intermediate output.",
|
|
222
|
+
" --guide Print the full workspace + dev-server operating guide.",
|
|
204
223
|
" -h, --help Show this help message.",
|
|
205
224
|
].join("\n"));
|
|
206
225
|
}
|
package/dist/guide.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
interface ScriptInvocation {
|
|
2
|
+
/** Run with no forwarded args, e.g. `npm run dev`. */
|
|
3
|
+
base: string;
|
|
4
|
+
/** Prefix before forwarded args, e.g. `npm run dev --`. */
|
|
5
|
+
withArgs: string;
|
|
6
|
+
}
|
|
7
|
+
export interface PackageManagerCommands {
|
|
8
|
+
workspace: ScriptInvocation;
|
|
9
|
+
dev: ScriptInvocation;
|
|
10
|
+
}
|
|
11
|
+
export interface GuideLayout {
|
|
12
|
+
/** The per-worktree runtime dir, relative to the worktree root (config `runtimeDir`, e.g. `.local-wt`). */
|
|
13
|
+
runtimeDir: string;
|
|
14
|
+
/** The shared (symlinked-from-main) dir names (config `sharedDirs`). */
|
|
15
|
+
sharedDirs: string[];
|
|
16
|
+
}
|
|
17
|
+
export declare function renderGuide(pm: PackageManagerCommands, layout: GuideLayout): string;
|
|
18
|
+
export declare function printGuide(layout: GuideLayout, cwd?: string): void;
|
|
19
|
+
export {};
|
package/dist/guide.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { REGISTRY_SUBDIR } from "./slots.js";
|
|
4
|
+
// Pad every command to the longest one so the `#` comments line up, whatever the
|
|
5
|
+
// package-manager prefix length (npm's `run … --` vs a bare `pnpm dev`).
|
|
6
|
+
function renderRows(rows) {
|
|
7
|
+
const width = Math.max(...rows.map((row) => row.command.length));
|
|
8
|
+
return rows.map((row) => `${row.command.padEnd(width)} # ${row.comment}`).join("\n");
|
|
9
|
+
}
|
|
10
|
+
// Like renderRows, but tolerates bare comment-only lines (rendered verbatim, never padded)
|
|
11
|
+
// interleaved between commands.
|
|
12
|
+
function renderSnippet(lines) {
|
|
13
|
+
const commands = lines.filter((line) => typeof line !== "string");
|
|
14
|
+
const width = Math.max(...commands.map((row) => row.command.length));
|
|
15
|
+
return lines
|
|
16
|
+
.map((line) => typeof line === "string" ? line : `${line.command.padEnd(width)} # ${line.comment}`)
|
|
17
|
+
.join("\n");
|
|
18
|
+
}
|
|
19
|
+
function commandBlocks(pm) {
|
|
20
|
+
const ws = pm.workspace.withArgs;
|
|
21
|
+
const dev = pm.dev.withArgs;
|
|
22
|
+
return {
|
|
23
|
+
setup: [
|
|
24
|
+
{
|
|
25
|
+
command: `${ws} setup my-branch -c`,
|
|
26
|
+
comment: "new branch + worktree (dedup: appends -2, -3…)",
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
command: `${ws} setup my-branch -c --from origin/main`,
|
|
30
|
+
comment: "new branch based on another ref",
|
|
31
|
+
},
|
|
32
|
+
{ command: `${ws} setup my-branch`, comment: "new worktree on an existing branch" },
|
|
33
|
+
{
|
|
34
|
+
command: `${ws} setup`,
|
|
35
|
+
comment: "set up the current worktree (idempotent; bootstrap + retry path)",
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
command: `${ws} wait --slot 8110`,
|
|
39
|
+
comment: "block until ready (exit 0) or failed (exit 1)",
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
recovery: [
|
|
43
|
+
{
|
|
44
|
+
command: `${ws} setup --wait`,
|
|
45
|
+
comment: "retry the finalize step, block until READY/FAILED",
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
inspect: [
|
|
49
|
+
{
|
|
50
|
+
command: `${ws} list`,
|
|
51
|
+
comment: "all registered workspaces (slot, status, branch, path, owner, created)",
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
command: `${ws} status`,
|
|
55
|
+
comment: "current worktree summary (ports, branch, readiness, dev-server)",
|
|
56
|
+
},
|
|
57
|
+
{ command: `${ws} status --slot 8110`, comment: "same, for another worktree" },
|
|
58
|
+
],
|
|
59
|
+
owner: [
|
|
60
|
+
{ command: `${ws} setup my-branch -c --owner alice`, comment: "set on creation" },
|
|
61
|
+
{ command: `${ws} set-owner bob`, comment: "update later, no rebuild" },
|
|
62
|
+
],
|
|
63
|
+
remove: [
|
|
64
|
+
{ command: `${ws} remove my-branch`, comment: "remove by branch name" },
|
|
65
|
+
{ command: `${ws} remove`, comment: "remove the current worktree (run from inside it)" },
|
|
66
|
+
],
|
|
67
|
+
prune: [
|
|
68
|
+
{
|
|
69
|
+
command: `${ws} prune`,
|
|
70
|
+
comment: "stop orphans' dev-servers, drop registry entries, run `git worktree prune`",
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
dev: [
|
|
74
|
+
{
|
|
75
|
+
command: pm.dev.base,
|
|
76
|
+
comment: "foreground; streams logs; CTRL+C stops (attaches if already running here)",
|
|
77
|
+
},
|
|
78
|
+
{ command: `${dev} up`, comment: "start in the background (this worktree)" },
|
|
79
|
+
{
|
|
80
|
+
command: `${dev} restart`,
|
|
81
|
+
comment: "stop this worktree's dev-server if running, then start in background",
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
command: `${dev} status`,
|
|
85
|
+
comment: "report whether this worktree's dev-server is UP or DOWN",
|
|
86
|
+
},
|
|
87
|
+
{ command: `${dev} down`, comment: "stop the dev-server (this worktree only)" },
|
|
88
|
+
{ command: `${dev} list`, comment: "list active dev-servers across all worktrees" },
|
|
89
|
+
{ command: `${dev} down --all`, comment: "stop every active dev-server" },
|
|
90
|
+
{
|
|
91
|
+
command: `${dev} up --evict`,
|
|
92
|
+
comment: "if the cap is full, evict the oldest dev-server and start",
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function driveDevSnippet(pm) {
|
|
98
|
+
const dev = pm.dev.withArgs;
|
|
99
|
+
return renderSnippet([
|
|
100
|
+
{ command: "git worktree list", comment: "1. find the worktree directory" },
|
|
101
|
+
{ command: `cd <worktree-dir> && ${dev} up`, comment: "2. start in the background" },
|
|
102
|
+
"# 3. read the log file (path printed on start) to confirm startup and find URLs",
|
|
103
|
+
{ command: `${dev} down`, comment: "4. stop when done (same directory)" },
|
|
104
|
+
]);
|
|
105
|
+
}
|
|
106
|
+
function renderSharedLayout(sharedDirs) {
|
|
107
|
+
if (sharedDirs.length === 0) {
|
|
108
|
+
return "- No dirs are shared across worktrees.";
|
|
109
|
+
}
|
|
110
|
+
const names = sharedDirs.map((dir) => `\`${dir}/\``).join(", ");
|
|
111
|
+
return `- Shared across worktrees, symlinked from the main worktree: ${names}.`;
|
|
112
|
+
}
|
|
113
|
+
export function renderGuide(pm, layout) {
|
|
114
|
+
const template = readFileSync(new URL("../templates/guide.md", import.meta.url), "utf-8");
|
|
115
|
+
let out = template;
|
|
116
|
+
for (const [name, rows] of Object.entries(commandBlocks(pm))) {
|
|
117
|
+
out = out.replaceAll(`{{COMMANDS:${name}}}`, renderRows(rows));
|
|
118
|
+
}
|
|
119
|
+
return out
|
|
120
|
+
.replaceAll("{{SNIPPET:drive-dev}}", driveDevSnippet(pm))
|
|
121
|
+
.replaceAll("{{DEV_BASE}}", pm.dev.base)
|
|
122
|
+
.replaceAll("{{WS}}", pm.workspace.withArgs)
|
|
123
|
+
.replaceAll("{{DEV}}", pm.dev.withArgs)
|
|
124
|
+
.replaceAll("{{RUNTIME_DIR}}", layout.runtimeDir)
|
|
125
|
+
.replaceAll("{{REGISTRY_SUBDIR}}", REGISTRY_SUBDIR)
|
|
126
|
+
.replaceAll("{{LAYOUT:shared}}", renderSharedLayout(layout.sharedDirs))
|
|
127
|
+
.trimEnd();
|
|
128
|
+
}
|
|
129
|
+
export function printGuide(layout, cwd = process.cwd()) {
|
|
130
|
+
console.log(renderGuide(detectPackageManager(cwd), layout));
|
|
131
|
+
}
|
|
132
|
+
// Mirror docmap's lockfile walk. These scripts are always wired as project scripts (the package
|
|
133
|
+
// is a dev dependency), so there is no global-install fallback — default to npm when unsure.
|
|
134
|
+
function detectPackageManager(cwd) {
|
|
135
|
+
let dir = cwd;
|
|
136
|
+
while (true) {
|
|
137
|
+
if (existsSync(join(dir, "pnpm-lock.yaml")))
|
|
138
|
+
return same("pnpm");
|
|
139
|
+
if (existsSync(join(dir, "yarn.lock")))
|
|
140
|
+
return same("yarn run");
|
|
141
|
+
if (existsSync(join(dir, "bun.lockb")) || existsSync(join(dir, "bun.lock")))
|
|
142
|
+
return same("bun run");
|
|
143
|
+
if (existsSync(join(dir, "package-lock.json")))
|
|
144
|
+
return npm();
|
|
145
|
+
const parent = dirname(dir);
|
|
146
|
+
if (parent === dir)
|
|
147
|
+
return npm();
|
|
148
|
+
dir = parent;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// Only npm needs a `--` separator before forwarded args; every other manager passes them verbatim.
|
|
152
|
+
function npm() {
|
|
153
|
+
return {
|
|
154
|
+
workspace: { base: "npm run workspace", withArgs: "npm run workspace --" },
|
|
155
|
+
dev: { base: "npm run dev", withArgs: "npm run dev --" },
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
function same(prefix) {
|
|
159
|
+
return {
|
|
160
|
+
workspace: { base: `${prefix} workspace`, withArgs: `${prefix} workspace` },
|
|
161
|
+
dev: { base: `${prefix} dev`, withArgs: `${prefix} dev` },
|
|
162
|
+
};
|
|
163
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { SlotsRegistry } from "./slots.js";
|
|
2
|
+
/**
|
|
3
|
+
* Returns the slot ports whose worktree directory no longer exists on disk — workspaces deleted
|
|
4
|
+
* out-of-band (a manual `rm -rf`, a bare `git worktree remove`). The main worktree is never
|
|
5
|
+
* reported: if its directory is gone the whole git context is, and we must not touch it.
|
|
6
|
+
*/
|
|
7
|
+
export declare function findOrphanPorts(registry: SlotsRegistry, exists?: (path: string) => boolean): string[];
|
package/dist/orphans.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
/**
|
|
3
|
+
* Returns the slot ports whose worktree directory no longer exists on disk — workspaces deleted
|
|
4
|
+
* out-of-band (a manual `rm -rf`, a bare `git worktree remove`). The main worktree is never
|
|
5
|
+
* reported: if its directory is gone the whole git context is, and we must not touch it.
|
|
6
|
+
*/
|
|
7
|
+
export function findOrphanPorts(registry, exists = existsSync) {
|
|
8
|
+
return Object.entries(registry.slots)
|
|
9
|
+
.filter(([, entry]) => !entry.main && !exists(entry.worktree))
|
|
10
|
+
.map(([port]) => port);
|
|
11
|
+
}
|
package/dist/workspace.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
import { spawn, spawnSync } from "node:child_process";
|
|
1
|
+
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
|
2
2
|
import { appendFileSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, symlinkSync, writeFileSync, } from "node:fs";
|
|
3
3
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
4
|
import { parseWorkspaceArgs, printWorkspaceHelp } from "./cli.js";
|
|
5
5
|
import { findOwnEntry, liveWorktrees, mergeDevServers, readDevServers, removeDevServerEntryByWorktree, writeDevServers, } from "./dev-servers-registry.js";
|
|
6
6
|
import { ConfigError } from "./errors.js";
|
|
7
|
+
import { printGuide } from "./guide.js";
|
|
7
8
|
import { copyAndPatchFile, formatDuration, setupLogPath, } from "./helpers.js";
|
|
8
|
-
import {
|
|
9
|
+
import { findOrphanPorts } from "./orphans.js";
|
|
9
10
|
import { defaultComputePorts, isValidPort, resolvePortScheme } from "./ports.js";
|
|
11
|
+
import { isProcessAlive, stopProcessGroup } from "./process-control.js";
|
|
10
12
|
import { handleSetOwner, markSlotFailed, markSlotReady, mergeSlots, readSlots, REGISTRY_SUBDIR, registryDirFor, resolveAndRegisterSlot, resolveCurrentSlot, validateSlotAvailability, warnLegacyRegistryDir, writeSlots, } from "./slots.js";
|
|
11
13
|
import { createBranch, detectWorktree, getWorktreeBranch, isWorktreeDirty, removeWorktree, useExistingBranch, } from "./worktree.js";
|
|
12
14
|
export async function runWorkspace(config) {
|
|
@@ -27,6 +29,10 @@ export async function runWorkspace(config) {
|
|
|
27
29
|
printWorkspaceHelp();
|
|
28
30
|
return;
|
|
29
31
|
}
|
|
32
|
+
if (command.kind === "guide") {
|
|
33
|
+
printGuide({ runtimeDir: config.runtimeDir, sharedDirs: config.sharedDirs });
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
30
36
|
warnLegacyRegistryDir(config);
|
|
31
37
|
const registryDir = registryDirFor(config.runtimeDir);
|
|
32
38
|
if (command.kind === "migrate") {
|
|
@@ -51,6 +57,9 @@ export async function runWorkspace(config) {
|
|
|
51
57
|
case "list":
|
|
52
58
|
runList(registryDir);
|
|
53
59
|
return;
|
|
60
|
+
case "prune":
|
|
61
|
+
await runPrune(registryDir);
|
|
62
|
+
return;
|
|
54
63
|
}
|
|
55
64
|
const ctx = detectWorktree();
|
|
56
65
|
const run = { verbose };
|
|
@@ -297,9 +306,11 @@ function runStatus(command, config, registryDir) {
|
|
|
297
306
|
}
|
|
298
307
|
function runList(registryDir) {
|
|
299
308
|
const ctx = detectWorktree();
|
|
309
|
+
const liveOrphans = autoPruneSafeOrphans(ctx.mainWorktree, registryDir);
|
|
300
310
|
const entries = Object.entries(readSlots(ctx.mainWorktree, registryDir).slots).sort(([a], [b]) => Number(a) - Number(b));
|
|
301
311
|
if (entries.length === 0) {
|
|
302
312
|
console.log("No workspaces registered.");
|
|
313
|
+
hintLiveOrphans(liveOrphans);
|
|
303
314
|
return;
|
|
304
315
|
}
|
|
305
316
|
const liveSet = liveWorktrees(readDevServers(ctx.mainWorktree, registryDir));
|
|
@@ -336,6 +347,95 @@ function runList(registryDir) {
|
|
|
336
347
|
console.log(fmt(headers));
|
|
337
348
|
for (const r of rows)
|
|
338
349
|
console.log(fmt(r));
|
|
350
|
+
hintLiveOrphans(liveOrphans);
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Silently drop registry entries for worktrees deleted out-of-band that have no live dev-server —
|
|
354
|
+
* harmless bookkeeping, consistent with the existing dead-PID pruning. Orphans whose dev-server is
|
|
355
|
+
* still running are left untouched (stopping a live process is the explicit `workspace prune`'s job)
|
|
356
|
+
* and returned so the caller can hint about them.
|
|
357
|
+
*/
|
|
358
|
+
function autoPruneSafeOrphans(mainWorktree, registryDir) {
|
|
359
|
+
const registry = readSlots(mainWorktree, registryDir);
|
|
360
|
+
const orphanPorts = findOrphanPorts(registry);
|
|
361
|
+
if (orphanPorts.length === 0)
|
|
362
|
+
return [];
|
|
363
|
+
const live = liveWorktrees(readDevServers(mainWorktree, registryDir));
|
|
364
|
+
const liveOrphans = [];
|
|
365
|
+
let changed = false;
|
|
366
|
+
for (const port of orphanPorts) {
|
|
367
|
+
const entry = registry.slots[port];
|
|
368
|
+
if (live.has(resolve(entry.worktree))) {
|
|
369
|
+
liveOrphans.push(port);
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
delete registry.slots[port];
|
|
373
|
+
removeDevServerEntryByWorktree(mainWorktree, registryDir, entry.worktree);
|
|
374
|
+
changed = true;
|
|
375
|
+
}
|
|
376
|
+
if (changed)
|
|
377
|
+
writeSlots(mainWorktree, registryDir, registry);
|
|
378
|
+
return liveOrphans;
|
|
379
|
+
}
|
|
380
|
+
function hintLiveOrphans(liveOrphans) {
|
|
381
|
+
if (liveOrphans.length === 0)
|
|
382
|
+
return;
|
|
383
|
+
console.log(`\nNote: ${liveOrphans.length} workspace(s) have a deleted worktree but a still-running ` +
|
|
384
|
+
"dev-server. Run `workspace prune` to stop them and clean up.");
|
|
385
|
+
}
|
|
386
|
+
async function runPrune(registryDir) {
|
|
387
|
+
const ctx = detectWorktree();
|
|
388
|
+
const registry = readSlots(ctx.mainWorktree, registryDir);
|
|
389
|
+
const orphanPorts = findOrphanPorts(registry);
|
|
390
|
+
let stoppedProcesses = 0;
|
|
391
|
+
for (const port of orphanPorts) {
|
|
392
|
+
const entry = registry.slots[port];
|
|
393
|
+
stoppedProcesses += await stopOrphanedDevServer(ctx.mainWorktree, registryDir, entry.worktree);
|
|
394
|
+
delete registry.slots[port];
|
|
395
|
+
const ownerSuffix = entry.owner ? `, owner ${entry.owner}` : "";
|
|
396
|
+
console.log(`Pruned slot ${port} (${entry.worktree}${ownerSuffix}).`);
|
|
397
|
+
}
|
|
398
|
+
if (orphanPorts.length > 0)
|
|
399
|
+
writeSlots(ctx.mainWorktree, registryDir, registry);
|
|
400
|
+
pruneGitWorktrees(ctx.mainWorktree);
|
|
401
|
+
if (orphanPorts.length === 0) {
|
|
402
|
+
console.log("No orphaned workspaces to prune.");
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
console.log(`Pruned ${orphanPorts.length} orphaned workspace(s).`);
|
|
406
|
+
if (stoppedProcesses > 0) {
|
|
407
|
+
console.log(`Stopped ${stoppedProcesses} orphaned process(es). Note: infrastructure managed by callback ` +
|
|
408
|
+
"servers (e.g. `docker compose`) is not torn down automatically — check for leftover containers.");
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Stop a gone worktree's dev-server the only way left: its dir (and `dev-server.mjs`) is deleted, so
|
|
413
|
+
* we can't shell out to `dev down` to run callback stop() — we kill the recorded spawn PIDs directly
|
|
414
|
+
* and drop the dev-server entry. Returns the count of live PIDs stopped. Callback-managed infra is
|
|
415
|
+
* not torn down (see `runPrune`'s caveat).
|
|
416
|
+
*/
|
|
417
|
+
async function stopOrphanedDevServer(mainWorktree, registryDir, worktree) {
|
|
418
|
+
const devEntry = findOwnEntry(mainWorktree, registryDir, worktree);
|
|
419
|
+
if (!devEntry)
|
|
420
|
+
return 0;
|
|
421
|
+
let stopped = 0;
|
|
422
|
+
for (const pid of Object.values(devEntry.pids)) {
|
|
423
|
+
if (isProcessAlive(pid)) {
|
|
424
|
+
await stopProcessGroup(pid);
|
|
425
|
+
++stopped;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
removeDevServerEntryByWorktree(mainWorktree, registryDir, worktree);
|
|
429
|
+
return stopped;
|
|
430
|
+
}
|
|
431
|
+
/** Best-effort: clear git's stale `.git/worktrees/<name>` admin files for deleted worktrees. */
|
|
432
|
+
function pruneGitWorktrees(mainWorktree) {
|
|
433
|
+
try {
|
|
434
|
+
execFileSync("git", ["worktree", "prune"], { cwd: mainWorktree, stdio: "ignore" });
|
|
435
|
+
}
|
|
436
|
+
catch {
|
|
437
|
+
// Best-effort; nothing actionable if git is unavailable.
|
|
438
|
+
}
|
|
339
439
|
}
|
|
340
440
|
async function runWait(command, config, registryDir) {
|
|
341
441
|
// standalone `workspace wait` (no prior setup in this invocation) → print the full summary on success.
|
|
@@ -392,8 +492,10 @@ async function handleRemove(command, ctx, run, config, registryDir) {
|
|
|
392
492
|
const ownerSuffix = target.owner ? `, owner ${target.owner}` : "";
|
|
393
493
|
if (!existsSync(target.worktreePath)) {
|
|
394
494
|
console.warn(`Warning: Worktree directory ${target.worktreePath} not found. Cleaning up registry only.`);
|
|
495
|
+
await stopOrphanedDevServer(ctx.mainWorktree, registryDir, target.worktreePath);
|
|
395
496
|
delete registry.slots[target.slotPort];
|
|
396
497
|
writeSlots(ctx.mainWorktree, registryDir, registry);
|
|
498
|
+
pruneGitWorktrees(ctx.mainWorktree);
|
|
397
499
|
console.log(`Removed registry entry for branch "${target.branch}" (slot ${target.slotPort}${ownerSuffix}).`);
|
|
398
500
|
return;
|
|
399
501
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@paleo/workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "Run multiple git-worktree dev environments side by side.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"workspace",
|
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
}
|
|
31
31
|
},
|
|
32
32
|
"files": [
|
|
33
|
-
"dist"
|
|
33
|
+
"dist",
|
|
34
|
+
"templates"
|
|
34
35
|
],
|
|
35
36
|
"publishConfig": {
|
|
36
37
|
"access": "public"
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Workspace & Dev Server — Guide
|
|
2
|
+
|
|
3
|
+
A **workspace** is a git worktree (with its branch) plus its own dev setup: dedicated ports, config files, a database, and a dev server you can bring up or down. Workspaces are isolated, so you can run several branches in parallel.
|
|
4
|
+
|
|
5
|
+
## Setting up a workspace
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
{{COMMANDS:setup}}
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
With `-c`, the new branch starts at the current worktree's HEAD (like `git switch -c`); `--from <ref>` accepts any commit-ish as the base.
|
|
12
|
+
|
|
13
|
+
The foreground command creates the worktree, assigns a port slot, sets up symlinks, and generates config files. The remaining steps (dependency install, build, database provisioning) run **detached in the background** and stream to the setup log, ending with a `READY:` or `FAILED:` banner. Add `--wait` to block until that banner.
|
|
14
|
+
|
|
15
|
+
**Main worktree:** from a fresh clone, run `setup` once on the main worktree before creating linked worktrees.
|
|
16
|
+
|
|
17
|
+
### Recovery from a failed setup
|
|
18
|
+
|
|
19
|
+
If the background finalize fails (check the setup log), do **not** delete the worktree. From inside it, `setup` is idempotent — repeat until the log ends with `READY:`:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
{{COMMANDS:recovery}}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
**Edge case** — if setup errors with `ERR_MODULE_NOT_FOUND: Cannot find package '@paleo/workspace'`, the worktree never got `node_modules/` (finalize failed before the install). Fall back to the main worktree's wrapper directly:
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
cd <failed-worktree>
|
|
29
|
+
node <main-worktree>/<path-to>/workspace.mjs setup
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Inspecting workspaces
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
{{COMMANDS:inspect}}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Slot owner
|
|
39
|
+
|
|
40
|
+
Each slot records an optional owner (free-form label). An AI bot passes its username; on a personal laptop, omit it.
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
{{COMMANDS:owner}}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Removing a workspace
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
{{COMMANDS:remove}}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Stops the dev server (if running), tears down infrastructure, frees the slot, and removes the worktree. The local branch is always kept. Removal refuses on uncommitted changes — pass `--force` to discard them. When removing the current worktree, the script prints the main worktree path; `cd` there afterward.
|
|
53
|
+
|
|
54
|
+
**NEVER** delete a branch unless the user explicitly requests it.
|
|
55
|
+
|
|
56
|
+
### Healing orphaned workspaces
|
|
57
|
+
|
|
58
|
+
A workspace is **orphaned** when its worktree dir was deleted out-of-band (manual `rm -rf`, bare `git worktree remove`). `list` auto-drops the safe ones (no live dev-server). For the rest:
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
{{COMMANDS:prune}}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### A worktree without setup
|
|
65
|
+
|
|
66
|
+
When you only want a worktree (no ports, no build, no config), use the `git worktree` CLI directly.
|
|
67
|
+
|
|
68
|
+
## Dev server
|
|
69
|
+
|
|
70
|
+
`{{DEV_BASE}}` starts the dev server in the **foreground**: it holds the terminal, tails logs, and stops cleanly on CTRL+C. For agents, `up` starts it in the **background** and returns once ready.
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
{{COMMANDS:dev}}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**Concurrent cap.** `dev` / `dev up` cap simultaneously running dev-servers. At the cap, the start errors with a table of active servers and exits non-zero. Free a slot via `down` in another worktree, `down --all`, or `up --evict` (stops the oldest live one and starts).
|
|
77
|
+
|
|
78
|
+
**Two-tier shutdown.** `down` (and a foreground CTRL+C) only kill dev-server processes — they leave infrastructure (Docker containers, databases) running so restarts stay fast. Full infrastructure cleanup happens via `workspace remove` when tearing the worktree down entirely.
|
|
79
|
+
|
|
80
|
+
### Driving the dev server in another worktree
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
{{SNIPPET:drive-dev}}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Directory layout
|
|
87
|
+
|
|
88
|
+
- `{{RUNTIME_DIR}}/` — per-worktree runtime data (not shared). Names below are fixed by the package:
|
|
89
|
+
- `logs/` — dev-server + setup logs.
|
|
90
|
+
- `{{REGISTRY_SUBDIR}}/` — the registry. Symlinked to the main worktree in linked worktrees.
|
|
91
|
+
{{LAYOUT:shared}}
|