@paleo/workspace 0.19.1 → 0.21.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 +6 -1
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +28 -1
- 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 +122 -4
- package/package.json +3 -2
- package/templates/guide.md +91 -0
package/README.md
CHANGED
|
@@ -26,7 +26,8 @@ The agent reads the skill, adapts the reference scripts to your stack, installs
|
|
|
26
26
|
## Workflow
|
|
27
27
|
|
|
28
28
|
```sh
|
|
29
|
-
npm run workspace -- setup feat/42 -c
|
|
29
|
+
npm run workspace -- setup feat/42 -c # new branch + worktree + isolated env
|
|
30
|
+
npm run workspace -- setup feat/42 -c --go # …then drop into a shell there (exit to return)
|
|
30
31
|
npm run dev # foreground: stream logs, CTRL+C stops; attaches if already running
|
|
31
32
|
npm run dev -- up # start in the background (no-op if already running here)
|
|
32
33
|
npm run dev -- up --restart # stop the dev-server in this worktree if running, then start fresh
|
|
@@ -36,8 +37,12 @@ npm run dev -- status # report whether this worktree's dev-ser
|
|
|
36
37
|
npm run dev -- list # active dev-servers across all worktrees
|
|
37
38
|
npm run dev -- down # stop dev server (infrastructure stays up)
|
|
38
39
|
npm run workspace -- remove feat/42 # full teardown
|
|
40
|
+
npm run workspace -- prune # heal workspaces whose worktree was deleted out-of-band
|
|
41
|
+
npm run workspace -- --guide # full operating guide (workspace + dev-server)
|
|
39
42
|
```
|
|
40
43
|
|
|
44
|
+
`--guide` prints the complete workspace + dev-server procedures in your package-manager's syntax. Point agents at it instead of maintaining a separate doc.
|
|
45
|
+
|
|
41
46
|
## API
|
|
42
47
|
|
|
43
48
|
```ts
|
package/dist/cli.d.ts
CHANGED
|
@@ -7,12 +7,15 @@ export type WorkspaceCommand = {
|
|
|
7
7
|
slot?: string;
|
|
8
8
|
force: boolean;
|
|
9
9
|
wait: boolean;
|
|
10
|
+
go: boolean;
|
|
10
11
|
} | {
|
|
11
12
|
kind: "remove";
|
|
12
13
|
branch?: string;
|
|
13
14
|
force: boolean;
|
|
14
15
|
} | {
|
|
15
16
|
kind: "list";
|
|
17
|
+
} | {
|
|
18
|
+
kind: "prune";
|
|
16
19
|
} | {
|
|
17
20
|
kind: "status";
|
|
18
21
|
slot?: string;
|
|
@@ -29,6 +32,8 @@ export type WorkspaceCommand = {
|
|
|
29
32
|
} | {
|
|
30
33
|
kind: "migrate";
|
|
31
34
|
oldRegistryDir: string;
|
|
35
|
+
} | {
|
|
36
|
+
kind: "guide";
|
|
32
37
|
} | {
|
|
33
38
|
kind: "help";
|
|
34
39
|
};
|
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":
|
|
@@ -48,6 +53,7 @@ function parseSetup(tokens) {
|
|
|
48
53
|
slot: { type: "string", short: "s" },
|
|
49
54
|
force: { type: "boolean" },
|
|
50
55
|
wait: { type: "boolean" },
|
|
56
|
+
go: { type: "boolean" },
|
|
51
57
|
verbose: { type: "boolean", short: "v" },
|
|
52
58
|
},
|
|
53
59
|
allowPositionals: true,
|
|
@@ -55,12 +61,16 @@ function parseSetup(tokens) {
|
|
|
55
61
|
});
|
|
56
62
|
const branch = takeOptionalPositional(positionals, "setup");
|
|
57
63
|
const newBranch = values["new-branch"] ?? false;
|
|
64
|
+
const go = values.go ?? false;
|
|
58
65
|
if (newBranch && branch === undefined) {
|
|
59
66
|
throw new ConfigError("`workspace setup <branch> -c` requires a branch name.");
|
|
60
67
|
}
|
|
61
68
|
if (values.from !== undefined && !newBranch) {
|
|
62
69
|
throw new ConfigError("`--from` requires `-c`/`--new-branch`.");
|
|
63
70
|
}
|
|
71
|
+
if (go && branch === undefined) {
|
|
72
|
+
throw new ConfigError("`--go` requires a branch (the worktree to enter).");
|
|
73
|
+
}
|
|
64
74
|
return {
|
|
65
75
|
command: {
|
|
66
76
|
kind: "setup",
|
|
@@ -71,6 +81,7 @@ function parseSetup(tokens) {
|
|
|
71
81
|
slot: values.slot,
|
|
72
82
|
force: values.force ?? false,
|
|
73
83
|
wait: values.wait ?? false,
|
|
84
|
+
go,
|
|
74
85
|
},
|
|
75
86
|
verbose: values.verbose ?? false,
|
|
76
87
|
};
|
|
@@ -101,6 +112,16 @@ function parseList(tokens) {
|
|
|
101
112
|
rejectPositionals(positionals, "list");
|
|
102
113
|
return { command: { kind: "list" }, verbose: values.verbose ?? false };
|
|
103
114
|
}
|
|
115
|
+
function parsePrune(tokens) {
|
|
116
|
+
const { values, positionals } = parseArgs({
|
|
117
|
+
args: tokens,
|
|
118
|
+
options: { verbose: { type: "boolean", short: "v" } },
|
|
119
|
+
allowPositionals: true,
|
|
120
|
+
strict: true,
|
|
121
|
+
});
|
|
122
|
+
rejectPositionals(positionals, "prune");
|
|
123
|
+
return { command: { kind: "prune" }, verbose: values.verbose ?? false };
|
|
124
|
+
}
|
|
104
125
|
function parseStatus(tokens) {
|
|
105
126
|
const { values, positionals } = parseArgs({
|
|
106
127
|
args: tokens,
|
|
@@ -181,17 +202,22 @@ export function printWorkspaceHelp() {
|
|
|
181
202
|
"Manage workspaces: a git worktree plus its own dev setup (ports, config, database, dev server).",
|
|
182
203
|
"",
|
|
183
204
|
"Commands:",
|
|
184
|
-
" setup [<branch>] [-c|--new-branch] [--from <ref>] [--owner <name>] [-s|--slot <port>] [--force] [--wait]",
|
|
205
|
+
" setup [<branch>] [-c|--new-branch] [--from <ref>] [--owner <name>] [-s|--slot <port>] [--force] [--wait] [--go]",
|
|
185
206
|
" Set up the workspace. With <branch>, create a sibling worktree for it",
|
|
186
207
|
" (add -c to create the branch first). Without, set up the current worktree",
|
|
187
208
|
" (idempotent; bootstrap and retry path).",
|
|
188
209
|
" With -c, the new branch starts at the current worktree's HEAD, or at <ref> with --from.",
|
|
189
210
|
" Finalize runs in the background; add --wait to block until it reaches READY.",
|
|
211
|
+
" With --go, drop into an interactive shell in the new worktree (exit to return);",
|
|
212
|
+
" combine with --wait to enter only once it is READY. Requires a branch and $SHELL.",
|
|
190
213
|
" remove [<branch>] [--force]",
|
|
191
214
|
" Remove a workspace by branch, or the current one when omitted.",
|
|
192
215
|
" Refuses on uncommitted changes unless --force.",
|
|
193
216
|
" list",
|
|
194
217
|
" List all registered workspaces (slot, status, branch, path, owner, created).",
|
|
218
|
+
" prune",
|
|
219
|
+
" Heal orphaned workspaces (worktree deleted out-of-band): stop their dev-servers",
|
|
220
|
+
" and drop their registry entries, then run `git worktree prune`.",
|
|
195
221
|
" status [-s|--slot <port>]",
|
|
196
222
|
" Print a workspace summary (ports, branch, readiness, dev-server).",
|
|
197
223
|
" wait [-s|--slot <port>]",
|
|
@@ -201,6 +227,7 @@ export function printWorkspaceHelp() {
|
|
|
201
227
|
"",
|
|
202
228
|
"Global options:",
|
|
203
229
|
" -v, --verbose Show intermediate output.",
|
|
230
|
+
" --guide Print the full workspace + dev-server operating guide.",
|
|
204
231
|
" -h, --help Show this help message.",
|
|
205
232
|
].join("\n"));
|
|
206
233
|
}
|
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 };
|
|
@@ -62,13 +71,29 @@ export async function runWorkspace(config) {
|
|
|
62
71
|
handleSetOwnerMode(command, ctx, registryDir);
|
|
63
72
|
return;
|
|
64
73
|
case "setup": {
|
|
65
|
-
const { slot } = await runSetup(command, ctx, run, config, registryDir);
|
|
74
|
+
const { slot, worktree } = await runSetup(command, ctx, run, config, registryDir);
|
|
66
75
|
if (command.wait)
|
|
67
76
|
await waitForSlot(slot, config, registryDir, { printSummary: false });
|
|
77
|
+
if (command.go)
|
|
78
|
+
enterWorktree(worktree);
|
|
68
79
|
return;
|
|
69
80
|
}
|
|
70
81
|
}
|
|
71
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* `--go`: open an interactive shell in the freshly set-up worktree (exit to return). Falls back to
|
|
85
|
+
* printing a `cd` hint when there is no `$SHELL` or stdin is not a tty (scripts, pipes) — dropping
|
|
86
|
+
* into an interactive shell there would hang.
|
|
87
|
+
*/
|
|
88
|
+
function enterWorktree(worktree) {
|
|
89
|
+
const shell = process.env.SHELL;
|
|
90
|
+
if (shell === undefined || !process.stdin.isTTY) {
|
|
91
|
+
console.log(`Now run: cd ${worktree}`);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
console.error(`Entering ${worktree} (exit to return).`);
|
|
95
|
+
spawnSync(shell, [], { cwd: worktree, stdio: "inherit" });
|
|
96
|
+
}
|
|
72
97
|
async function runSetup(command, ctx, run, config, registryDir) {
|
|
73
98
|
const scheme = resolvePortScheme(config);
|
|
74
99
|
const portsFn = resolvePortsFn(config);
|
|
@@ -149,7 +174,7 @@ async function runSetup(command, ctx, run, config, registryDir) {
|
|
|
149
174
|
});
|
|
150
175
|
child.unref();
|
|
151
176
|
closeSync(logFd);
|
|
152
|
-
return { slot };
|
|
177
|
+
return { slot, worktree: setupCtx.currentWorktree };
|
|
153
178
|
}
|
|
154
179
|
function refuseIfFinalizePending(ctx, registryDir, force) {
|
|
155
180
|
if (force)
|
|
@@ -297,9 +322,11 @@ function runStatus(command, config, registryDir) {
|
|
|
297
322
|
}
|
|
298
323
|
function runList(registryDir) {
|
|
299
324
|
const ctx = detectWorktree();
|
|
325
|
+
const liveOrphans = autoPruneSafeOrphans(ctx.mainWorktree, registryDir);
|
|
300
326
|
const entries = Object.entries(readSlots(ctx.mainWorktree, registryDir).slots).sort(([a], [b]) => Number(a) - Number(b));
|
|
301
327
|
if (entries.length === 0) {
|
|
302
328
|
console.log("No workspaces registered.");
|
|
329
|
+
hintLiveOrphans(liveOrphans);
|
|
303
330
|
return;
|
|
304
331
|
}
|
|
305
332
|
const liveSet = liveWorktrees(readDevServers(ctx.mainWorktree, registryDir));
|
|
@@ -336,6 +363,95 @@ function runList(registryDir) {
|
|
|
336
363
|
console.log(fmt(headers));
|
|
337
364
|
for (const r of rows)
|
|
338
365
|
console.log(fmt(r));
|
|
366
|
+
hintLiveOrphans(liveOrphans);
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Silently drop registry entries for worktrees deleted out-of-band that have no live dev-server —
|
|
370
|
+
* harmless bookkeeping, consistent with the existing dead-PID pruning. Orphans whose dev-server is
|
|
371
|
+
* still running are left untouched (stopping a live process is the explicit `workspace prune`'s job)
|
|
372
|
+
* and returned so the caller can hint about them.
|
|
373
|
+
*/
|
|
374
|
+
function autoPruneSafeOrphans(mainWorktree, registryDir) {
|
|
375
|
+
const registry = readSlots(mainWorktree, registryDir);
|
|
376
|
+
const orphanPorts = findOrphanPorts(registry);
|
|
377
|
+
if (orphanPorts.length === 0)
|
|
378
|
+
return [];
|
|
379
|
+
const live = liveWorktrees(readDevServers(mainWorktree, registryDir));
|
|
380
|
+
const liveOrphans = [];
|
|
381
|
+
let changed = false;
|
|
382
|
+
for (const port of orphanPorts) {
|
|
383
|
+
const entry = registry.slots[port];
|
|
384
|
+
if (live.has(resolve(entry.worktree))) {
|
|
385
|
+
liveOrphans.push(port);
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
delete registry.slots[port];
|
|
389
|
+
removeDevServerEntryByWorktree(mainWorktree, registryDir, entry.worktree);
|
|
390
|
+
changed = true;
|
|
391
|
+
}
|
|
392
|
+
if (changed)
|
|
393
|
+
writeSlots(mainWorktree, registryDir, registry);
|
|
394
|
+
return liveOrphans;
|
|
395
|
+
}
|
|
396
|
+
function hintLiveOrphans(liveOrphans) {
|
|
397
|
+
if (liveOrphans.length === 0)
|
|
398
|
+
return;
|
|
399
|
+
console.log(`\nNote: ${liveOrphans.length} workspace(s) have a deleted worktree but a still-running ` +
|
|
400
|
+
"dev-server. Run `workspace prune` to stop them and clean up.");
|
|
401
|
+
}
|
|
402
|
+
async function runPrune(registryDir) {
|
|
403
|
+
const ctx = detectWorktree();
|
|
404
|
+
const registry = readSlots(ctx.mainWorktree, registryDir);
|
|
405
|
+
const orphanPorts = findOrphanPorts(registry);
|
|
406
|
+
let stoppedProcesses = 0;
|
|
407
|
+
for (const port of orphanPorts) {
|
|
408
|
+
const entry = registry.slots[port];
|
|
409
|
+
stoppedProcesses += await stopOrphanedDevServer(ctx.mainWorktree, registryDir, entry.worktree);
|
|
410
|
+
delete registry.slots[port];
|
|
411
|
+
const ownerSuffix = entry.owner ? `, owner ${entry.owner}` : "";
|
|
412
|
+
console.log(`Pruned slot ${port} (${entry.worktree}${ownerSuffix}).`);
|
|
413
|
+
}
|
|
414
|
+
if (orphanPorts.length > 0)
|
|
415
|
+
writeSlots(ctx.mainWorktree, registryDir, registry);
|
|
416
|
+
pruneGitWorktrees(ctx.mainWorktree);
|
|
417
|
+
if (orphanPorts.length === 0) {
|
|
418
|
+
console.log("No orphaned workspaces to prune.");
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
console.log(`Pruned ${orphanPorts.length} orphaned workspace(s).`);
|
|
422
|
+
if (stoppedProcesses > 0) {
|
|
423
|
+
console.log(`Stopped ${stoppedProcesses} orphaned process(es). Note: infrastructure managed by callback ` +
|
|
424
|
+
"servers (e.g. `docker compose`) is not torn down automatically — check for leftover containers.");
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Stop a gone worktree's dev-server the only way left: its dir (and `dev-server.mjs`) is deleted, so
|
|
429
|
+
* we can't shell out to `dev down` to run callback stop() — we kill the recorded spawn PIDs directly
|
|
430
|
+
* and drop the dev-server entry. Returns the count of live PIDs stopped. Callback-managed infra is
|
|
431
|
+
* not torn down (see `runPrune`'s caveat).
|
|
432
|
+
*/
|
|
433
|
+
async function stopOrphanedDevServer(mainWorktree, registryDir, worktree) {
|
|
434
|
+
const devEntry = findOwnEntry(mainWorktree, registryDir, worktree);
|
|
435
|
+
if (!devEntry)
|
|
436
|
+
return 0;
|
|
437
|
+
let stopped = 0;
|
|
438
|
+
for (const pid of Object.values(devEntry.pids)) {
|
|
439
|
+
if (isProcessAlive(pid)) {
|
|
440
|
+
await stopProcessGroup(pid);
|
|
441
|
+
++stopped;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
removeDevServerEntryByWorktree(mainWorktree, registryDir, worktree);
|
|
445
|
+
return stopped;
|
|
446
|
+
}
|
|
447
|
+
/** Best-effort: clear git's stale `.git/worktrees/<name>` admin files for deleted worktrees. */
|
|
448
|
+
function pruneGitWorktrees(mainWorktree) {
|
|
449
|
+
try {
|
|
450
|
+
execFileSync("git", ["worktree", "prune"], { cwd: mainWorktree, stdio: "ignore" });
|
|
451
|
+
}
|
|
452
|
+
catch {
|
|
453
|
+
// Best-effort; nothing actionable if git is unavailable.
|
|
454
|
+
}
|
|
339
455
|
}
|
|
340
456
|
async function runWait(command, config, registryDir) {
|
|
341
457
|
// standalone `workspace wait` (no prior setup in this invocation) → print the full summary on success.
|
|
@@ -392,8 +508,10 @@ async function handleRemove(command, ctx, run, config, registryDir) {
|
|
|
392
508
|
const ownerSuffix = target.owner ? `, owner ${target.owner}` : "";
|
|
393
509
|
if (!existsSync(target.worktreePath)) {
|
|
394
510
|
console.warn(`Warning: Worktree directory ${target.worktreePath} not found. Cleaning up registry only.`);
|
|
511
|
+
await stopOrphanedDevServer(ctx.mainWorktree, registryDir, target.worktreePath);
|
|
395
512
|
delete registry.slots[target.slotPort];
|
|
396
513
|
writeSlots(ctx.mainWorktree, registryDir, registry);
|
|
514
|
+
pruneGitWorktrees(ctx.mainWorktree);
|
|
397
515
|
console.log(`Removed registry entry for branch "${target.branch}" (slot ${target.slotPort}${ownerSuffix}).`);
|
|
398
516
|
return;
|
|
399
517
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@paleo/workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.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}}
|