@paleo/worktree-env 0.1.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 +93 -0
- package/dist/cli.d.ts +29 -0
- package/dist/cli.js +129 -0
- package/dist/dev-limit.d.ts +6 -0
- package/dist/dev-limit.js +13 -0
- package/dist/dev-server.d.ts +35 -0
- package/dist/dev-server.js +234 -0
- package/dist/dev-servers-registry.d.ts +28 -0
- package/dist/dev-servers-registry.js +104 -0
- package/dist/errors.d.ts +11 -0
- package/dist/errors.js +22 -0
- package/dist/helpers.d.ts +8 -0
- package/dist/helpers.js +42 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +5 -0
- package/dist/log-polling.d.ts +18 -0
- package/dist/log-polling.js +41 -0
- package/dist/ports.d.ts +16 -0
- package/dist/ports.js +37 -0
- package/dist/process-control.d.ts +7 -0
- package/dist/process-control.js +67 -0
- package/dist/setup-worktree.d.ts +52 -0
- package/dist/setup-worktree.js +272 -0
- package/dist/slots.d.ts +56 -0
- package/dist/slots.js +132 -0
- package/dist/worktree.d.ts +21 -0
- package/dist/worktree.js +92 -0
- package/package.json +45 -0
package/dist/errors.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export class StartupError extends Error {
|
|
2
|
+
label;
|
|
3
|
+
reason;
|
|
4
|
+
logFile;
|
|
5
|
+
constructor(label, reason, logFile) {
|
|
6
|
+
super(`${label}: ${reason}`);
|
|
7
|
+
this.label = label;
|
|
8
|
+
this.reason = reason;
|
|
9
|
+
this.logFile = logFile;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export class ConfigError extends Error {
|
|
13
|
+
exitCode;
|
|
14
|
+
constructor(message, exitCode = 1) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.exitCode = exitCode;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function exitWith(code, message) {
|
|
20
|
+
console.error(message);
|
|
21
|
+
process.exit(code);
|
|
22
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare function patchEnvFile(content: string, patches: Record<string, string>): string;
|
|
2
|
+
export declare function extractHost(content: string, key: string, fallback?: string): string;
|
|
3
|
+
export interface CopyAndPatchCtx {
|
|
4
|
+
currentWorktree: string;
|
|
5
|
+
mainWorktree: string;
|
|
6
|
+
log: (msg: string) => void;
|
|
7
|
+
}
|
|
8
|
+
export declare function copyAndPatchFile(ctx: CopyAndPatchCtx, relPath: string, patchFn: (content: string) => string, label: string, force: boolean, required?: boolean): void;
|
package/dist/helpers.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
export function patchEnvFile(content, patches) {
|
|
4
|
+
const lines = content.trimEnd().split("\n");
|
|
5
|
+
for (const [key, value] of Object.entries(patches)) {
|
|
6
|
+
const idx = lines.findIndex((l) => l.startsWith(`${key}=`));
|
|
7
|
+
if (idx !== -1) {
|
|
8
|
+
lines[idx] = `${key}=${value}`;
|
|
9
|
+
}
|
|
10
|
+
else {
|
|
11
|
+
lines.push(`${key}=${value}`);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return `${lines.join("\n")}\n`;
|
|
15
|
+
}
|
|
16
|
+
export function extractHost(content, key, fallback = "localhost") {
|
|
17
|
+
const re = new RegExp(`^${key}=(?:https?://)?([^:\\s]+)`, "m");
|
|
18
|
+
const m = content.match(re);
|
|
19
|
+
return m ? m[1] : fallback;
|
|
20
|
+
}
|
|
21
|
+
export function copyAndPatchFile(ctx, relPath, patchFn, label, force, required = false) {
|
|
22
|
+
const targetPath = join(ctx.currentWorktree, relPath);
|
|
23
|
+
const sourcePath = join(ctx.mainWorktree, relPath);
|
|
24
|
+
const alreadyExists = existsSync(targetPath);
|
|
25
|
+
if (alreadyExists && !force) {
|
|
26
|
+
ctx.log(`Skipped ${label} (already exists; use --force to overwrite).`);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (!existsSync(sourcePath)) {
|
|
30
|
+
if (required) {
|
|
31
|
+
console.error(`Error: ${relPath} not found in main worktree (required).`);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
ctx.log(`Warning: ${relPath} not found in main worktree, skipping.`);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const content = readFileSync(sourcePath, "utf-8");
|
|
38
|
+
const patched = patchFn(content);
|
|
39
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
40
|
+
writeFileSync(targetPath, patched);
|
|
41
|
+
ctx.log(`${alreadyExists ? "Overwritten" : "Created"} ${label}.`);
|
|
42
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { runSetupWorktree } from "./setup-worktree.js";
|
|
2
|
+
export type { SetupWorktreeConfig, SetupContext, SummaryContext, PatchContext, ConfigFileEntry, TeardownContext, } from "./setup-worktree.js";
|
|
3
|
+
export { runDevServer } from "./dev-server.js";
|
|
4
|
+
export type { DevServerConfig, DevServerSummaryContext, ServerDescriptor, PortConfig, } from "./dev-server.js";
|
|
5
|
+
export type { ResolvedSlot } from "./slots.js";
|
|
6
|
+
import * as helpers from "./helpers.js";
|
|
7
|
+
export { helpers };
|
|
8
|
+
export { StartupError, ConfigError } from "./errors.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { StartupError } from "./errors.js";
|
|
2
|
+
export declare const LOG_TAIL_LINES = 30;
|
|
3
|
+
export declare const POLL_INTERVAL_MS = 500;
|
|
4
|
+
export declare const TIMEOUT_MS = 120000;
|
|
5
|
+
export interface PollableServer {
|
|
6
|
+
name: string;
|
|
7
|
+
logFile: string;
|
|
8
|
+
detectSuccess: (logContent: string) => boolean;
|
|
9
|
+
detectError?: (logContent: string) => string | false;
|
|
10
|
+
}
|
|
11
|
+
export interface AwaitOptions {
|
|
12
|
+
timeoutMs?: number;
|
|
13
|
+
pollIntervalMs?: number;
|
|
14
|
+
isAlive?: (pid: number) => boolean;
|
|
15
|
+
}
|
|
16
|
+
export declare function waitForReady(server: PollableServer, pid: number, options?: AwaitOptions): Promise<void>;
|
|
17
|
+
export declare function awaitAllReady(servers: PollableServer[], pids: number[], options?: AwaitOptions): Promise<void>;
|
|
18
|
+
export declare function handleStartupFailure(err: StartupError): void;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { StartupError } from "./errors.js";
|
|
4
|
+
import { isProcessAlive as defaultIsAlive } from "./process-control.js";
|
|
5
|
+
export const LOG_TAIL_LINES = 30;
|
|
6
|
+
export const POLL_INTERVAL_MS = 500;
|
|
7
|
+
export const TIMEOUT_MS = 120_000;
|
|
8
|
+
export async function waitForReady(server, pid, options = {}) {
|
|
9
|
+
const timeoutMs = options.timeoutMs ?? TIMEOUT_MS;
|
|
10
|
+
const pollIntervalMs = options.pollIntervalMs ?? POLL_INTERVAL_MS;
|
|
11
|
+
const isAlive = options.isAlive ?? defaultIsAlive;
|
|
12
|
+
const deadline = Date.now() + timeoutMs;
|
|
13
|
+
while (Date.now() < deadline) {
|
|
14
|
+
if (!isAlive(pid)) {
|
|
15
|
+
throw new StartupError(server.name, "process exited unexpectedly", server.logFile);
|
|
16
|
+
}
|
|
17
|
+
if (existsSync(server.logFile)) {
|
|
18
|
+
const logContent = readFileSync(server.logFile, "utf-8");
|
|
19
|
+
if (server.detectSuccess(logContent))
|
|
20
|
+
return;
|
|
21
|
+
const matched = server.detectError?.(logContent);
|
|
22
|
+
if (matched) {
|
|
23
|
+
throw new StartupError(server.name, `error detected (${matched})`, server.logFile);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
|
27
|
+
}
|
|
28
|
+
throw new StartupError(server.name, `did not become ready within ${timeoutMs / 1000}s`, server.logFile);
|
|
29
|
+
}
|
|
30
|
+
export async function awaitAllReady(servers, pids, options) {
|
|
31
|
+
await Promise.all(servers.map((server, i) => waitForReady(server, pids[i], options)));
|
|
32
|
+
}
|
|
33
|
+
export function handleStartupFailure(err) {
|
|
34
|
+
console.error(`\nError: ${err.label} ${err.reason}.`);
|
|
35
|
+
if (err.logFile && existsSync(err.logFile)) {
|
|
36
|
+
const lines = readFileSync(err.logFile, "utf-8").split("\n").slice(-LOG_TAIL_LINES);
|
|
37
|
+
console.error(`\n--- ${err.label} log tail (last ${LOG_TAIL_LINES} lines) ---`);
|
|
38
|
+
console.error(lines.join("\n"));
|
|
39
|
+
console.error(`--- end ---\nFull log: ${join(process.cwd(), err.logFile)}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
package/dist/ports.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface PortSchemeOptions {
|
|
2
|
+
basePort: number;
|
|
3
|
+
portStep?: number;
|
|
4
|
+
maxSlotCount?: number;
|
|
5
|
+
}
|
|
6
|
+
export interface PortScheme {
|
|
7
|
+
basePort: number;
|
|
8
|
+
portStep: number;
|
|
9
|
+
maxSlotCount: number;
|
|
10
|
+
minPort: number;
|
|
11
|
+
maxPort: number;
|
|
12
|
+
}
|
|
13
|
+
export declare function resolvePortScheme(opts: PortSchemeOptions): PortScheme;
|
|
14
|
+
export declare function isValidPort(port: number, scheme: PortScheme): boolean;
|
|
15
|
+
export declare function allPorts(scheme: PortScheme): number[];
|
|
16
|
+
export declare function defaultComputePorts(portNames: string[]): (slot: number) => Record<string, number>;
|
package/dist/ports.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export function resolvePortScheme(opts) {
|
|
2
|
+
const portStep = opts.portStep ?? 10;
|
|
3
|
+
const maxSlotCount = opts.maxSlotCount ?? 19;
|
|
4
|
+
const basePort = opts.basePort;
|
|
5
|
+
return {
|
|
6
|
+
basePort,
|
|
7
|
+
portStep,
|
|
8
|
+
maxSlotCount,
|
|
9
|
+
minPort: basePort + portStep,
|
|
10
|
+
maxPort: basePort + maxSlotCount * portStep,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
export function isValidPort(port, scheme) {
|
|
14
|
+
return (Number.isInteger(port) &&
|
|
15
|
+
port >= scheme.minPort &&
|
|
16
|
+
port <= scheme.maxPort &&
|
|
17
|
+
(port - scheme.basePort) % scheme.portStep === 0);
|
|
18
|
+
}
|
|
19
|
+
export function allPorts(scheme) {
|
|
20
|
+
const ports = [];
|
|
21
|
+
for (let p = scheme.minPort; p <= scheme.maxPort; p += scheme.portStep) {
|
|
22
|
+
ports.push(p);
|
|
23
|
+
}
|
|
24
|
+
return ports;
|
|
25
|
+
}
|
|
26
|
+
export function defaultComputePorts(portNames) {
|
|
27
|
+
if (portNames.length === 0) {
|
|
28
|
+
throw new Error("portNames must not be empty");
|
|
29
|
+
}
|
|
30
|
+
return (slot) => {
|
|
31
|
+
const out = {};
|
|
32
|
+
portNames.forEach((name, i) => {
|
|
33
|
+
out[name] = slot + i;
|
|
34
|
+
});
|
|
35
|
+
return out;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare function readPid(pidFile: string): number | undefined;
|
|
2
|
+
export declare function isProcessAlive(pid: number): boolean;
|
|
3
|
+
export declare function isProcessGroupAlive(pid: number): boolean;
|
|
4
|
+
export declare function killProcessGroup(pid: number, signal: NodeJS.Signals): void;
|
|
5
|
+
export declare function cleanupPidFile(pidFile: string): void;
|
|
6
|
+
export declare function stopProcessGroup(pid: number, timeoutMs?: number): Promise<void>;
|
|
7
|
+
export declare function stopByPidFile(pidFile: string, label: string, log?: (msg: string) => void): Promise<void>;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { existsSync, readFileSync, unlinkSync } from "node:fs";
|
|
2
|
+
export function readPid(pidFile) {
|
|
3
|
+
if (!existsSync(pidFile))
|
|
4
|
+
return undefined;
|
|
5
|
+
const raw = readFileSync(pidFile, "utf-8").trim();
|
|
6
|
+
const pid = Number(raw);
|
|
7
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
8
|
+
return undefined;
|
|
9
|
+
return pid;
|
|
10
|
+
}
|
|
11
|
+
export function isProcessAlive(pid) {
|
|
12
|
+
try {
|
|
13
|
+
process.kill(pid, 0);
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function isProcessGroupAlive(pid) {
|
|
21
|
+
try {
|
|
22
|
+
process.kill(-pid, 0);
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function killProcessGroup(pid, signal) {
|
|
30
|
+
try {
|
|
31
|
+
process.kill(-pid, signal);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
try {
|
|
35
|
+
process.kill(pid, signal);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// already dead
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export function cleanupPidFile(pidFile) {
|
|
43
|
+
if (existsSync(pidFile))
|
|
44
|
+
unlinkSync(pidFile);
|
|
45
|
+
}
|
|
46
|
+
export async function stopProcessGroup(pid, timeoutMs = 10_000) {
|
|
47
|
+
killProcessGroup(pid, "SIGTERM");
|
|
48
|
+
const deadline = Date.now() + timeoutMs;
|
|
49
|
+
while (Date.now() < deadline) {
|
|
50
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
51
|
+
if (!isProcessGroupAlive(pid))
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
killProcessGroup(pid, "SIGKILL");
|
|
55
|
+
}
|
|
56
|
+
export async function stopByPidFile(pidFile, label, log = () => { }) {
|
|
57
|
+
const pid = readPid(pidFile);
|
|
58
|
+
if (pid === undefined || !isProcessAlive(pid)) {
|
|
59
|
+
cleanupPidFile(pidFile);
|
|
60
|
+
log(`No ${label} process is running.`);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
log(`Stopping ${label} (PID ${pid})...`);
|
|
64
|
+
await stopProcessGroup(pid);
|
|
65
|
+
cleanupPidFile(pidFile);
|
|
66
|
+
log(`${label} stopped.`);
|
|
67
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export interface SetupContext {
|
|
2
|
+
currentWorktree: string;
|
|
3
|
+
mainWorktree: string;
|
|
4
|
+
slot: number;
|
|
5
|
+
branch: string;
|
|
6
|
+
owner: string;
|
|
7
|
+
ports: Record<string, number>;
|
|
8
|
+
force: boolean;
|
|
9
|
+
verbose: boolean;
|
|
10
|
+
}
|
|
11
|
+
export interface PatchContext {
|
|
12
|
+
slot: number;
|
|
13
|
+
ports: Record<string, number>;
|
|
14
|
+
mainWorktree: string;
|
|
15
|
+
currentWorktree: string;
|
|
16
|
+
}
|
|
17
|
+
export interface ConfigFileEntry {
|
|
18
|
+
path: string;
|
|
19
|
+
patch: (content: string, ctx: PatchContext) => string;
|
|
20
|
+
required?: boolean;
|
|
21
|
+
}
|
|
22
|
+
export interface SummaryContext {
|
|
23
|
+
slot: number;
|
|
24
|
+
branch: string;
|
|
25
|
+
owner: string;
|
|
26
|
+
ports: Record<string, number>;
|
|
27
|
+
currentWorktree: string;
|
|
28
|
+
mainWorktree: string;
|
|
29
|
+
}
|
|
30
|
+
export interface TeardownContext {
|
|
31
|
+
worktree: string;
|
|
32
|
+
mainWorktree: string;
|
|
33
|
+
verbose: boolean;
|
|
34
|
+
}
|
|
35
|
+
export interface SetupWorktreeConfig {
|
|
36
|
+
basePort: number;
|
|
37
|
+
portStep?: number;
|
|
38
|
+
maxSlotCount?: number;
|
|
39
|
+
ports?: (slot: number) => Record<string, number>;
|
|
40
|
+
portNames?: string[];
|
|
41
|
+
perWorktreeDirs?: string[];
|
|
42
|
+
sharedDirs?: string[];
|
|
43
|
+
devServerPidFiles: string[];
|
|
44
|
+
devLimitEnvVar?: string;
|
|
45
|
+
configFiles: ConfigFileEntry[];
|
|
46
|
+
provisionDatabase: (ctx: SetupContext) => Promise<void> | void;
|
|
47
|
+
teardownInfrastructure?: (ctx: TeardownContext) => Promise<void> | void;
|
|
48
|
+
installAndBuild: (ctx: SetupContext) => Promise<void> | void;
|
|
49
|
+
afterDatabase?: (ctx: SetupContext) => Promise<void> | void;
|
|
50
|
+
printSummary: (ctx: SummaryContext) => string;
|
|
51
|
+
}
|
|
52
|
+
export declare function runSetupWorktree(config: SetupWorktreeConfig): Promise<void>;
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
3
|
+
import { isRemoveMode, isSetOwnerMode, isSetupMode, parseSetupArgs, printSetupHelp, validateSetupFlags, } from "./cli.js";
|
|
4
|
+
import { removeDevServerEntryByWorktree } from "./dev-servers-registry.js";
|
|
5
|
+
import { ConfigError } from "./errors.js";
|
|
6
|
+
import { copyAndPatchFile } from "./helpers.js";
|
|
7
|
+
import { defaultComputePorts, resolvePortScheme } from "./ports.js";
|
|
8
|
+
import { handleSetOwner, readSlots, resolveAndRegisterSlot, validateSlotAvailability, writeSlots, } from "./slots.js";
|
|
9
|
+
import { createBranch, detectWorktree, enforceWorktreeMode, getCurrentBranch, removeWorktree, useExistingBranch, verifyBranchAbsentFromRemote, } from "./worktree.js";
|
|
10
|
+
import { cleanupPidFile, isProcessAlive, isProcessGroupAlive, killProcessGroup, readPid, } from "./process-control.js";
|
|
11
|
+
function makeLog(verbose) {
|
|
12
|
+
return (msg) => {
|
|
13
|
+
if (verbose)
|
|
14
|
+
console.log(msg);
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function resolvePortsFn(config) {
|
|
18
|
+
if (config.ports)
|
|
19
|
+
return config.ports;
|
|
20
|
+
if (config.portNames && config.portNames.length > 0) {
|
|
21
|
+
return defaultComputePorts(config.portNames);
|
|
22
|
+
}
|
|
23
|
+
throw new ConfigError("Config error: provide either `ports` (function) or `portNames` (array).");
|
|
24
|
+
}
|
|
25
|
+
export async function runSetupWorktree(config) {
|
|
26
|
+
let args;
|
|
27
|
+
try {
|
|
28
|
+
args = parseSetupArgs();
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
console.error(err.message);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
const verbose = args.verbose ?? false;
|
|
35
|
+
if (args.help) {
|
|
36
|
+
printSetupHelp();
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
validateSetupFlags(args);
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
if (err instanceof ConfigError) {
|
|
44
|
+
console.error(err.message);
|
|
45
|
+
process.exit(err.exitCode);
|
|
46
|
+
}
|
|
47
|
+
throw err;
|
|
48
|
+
}
|
|
49
|
+
if (!isSetupMode(args) && !isRemoveMode(args) && !isSetOwnerMode(args)) {
|
|
50
|
+
printSetupHelp();
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const ctx = detectWorktree();
|
|
54
|
+
enforceWorktreeMode(args, ctx);
|
|
55
|
+
const run = { verbose };
|
|
56
|
+
if (isRemoveMode(args)) {
|
|
57
|
+
await handleRemove(args, ctx, run, config);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (isSetOwnerMode(args)) {
|
|
61
|
+
handleSetOwnerMode(args, ctx);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
await runSetup(args, ctx, run, config);
|
|
65
|
+
}
|
|
66
|
+
async function runSetup(args, ctx, run, config) {
|
|
67
|
+
const log = makeLog(run.verbose);
|
|
68
|
+
const scheme = resolvePortScheme(config);
|
|
69
|
+
const portsFn = resolvePortsFn(config);
|
|
70
|
+
validateSlotAvailability(args.slot, {
|
|
71
|
+
currentWorktree: ctx.currentWorktree,
|
|
72
|
+
mainWorktree: ctx.mainWorktree,
|
|
73
|
+
scheme,
|
|
74
|
+
});
|
|
75
|
+
const setupCtx = ensureWorktree(args, ctx, run);
|
|
76
|
+
const branch = getCurrentBranch(setupCtx.currentWorktree);
|
|
77
|
+
const { port: slot, owner } = resolveAndRegisterSlot({
|
|
78
|
+
slot: args.slot,
|
|
79
|
+
currentWorktree: setupCtx.currentWorktree,
|
|
80
|
+
mainWorktree: setupCtx.mainWorktree,
|
|
81
|
+
scheme,
|
|
82
|
+
branch,
|
|
83
|
+
requestedOwner: args.owner,
|
|
84
|
+
});
|
|
85
|
+
const ports = portsFn(slot);
|
|
86
|
+
log(`Using slot ${slot} (${Object.entries(ports)
|
|
87
|
+
.map(([k, v]) => `${k}: ${v}`)
|
|
88
|
+
.join(", ")})`);
|
|
89
|
+
const sharedDirs = config.sharedDirs ?? [".local", ".plans"];
|
|
90
|
+
const perWorktreeDirs = config.perWorktreeDirs ?? [".local-data"];
|
|
91
|
+
setupLocalDirectories(setupCtx.currentWorktree, perWorktreeDirs);
|
|
92
|
+
linkSharedDirectories(setupCtx, sharedDirs, log);
|
|
93
|
+
generateConfigFiles(setupCtx, config.configFiles, slot, ports, args.force ?? false, log);
|
|
94
|
+
const force = args.force ?? false;
|
|
95
|
+
const setupContext = {
|
|
96
|
+
currentWorktree: setupCtx.currentWorktree,
|
|
97
|
+
mainWorktree: setupCtx.mainWorktree,
|
|
98
|
+
slot,
|
|
99
|
+
branch,
|
|
100
|
+
owner,
|
|
101
|
+
ports,
|
|
102
|
+
force,
|
|
103
|
+
verbose: run.verbose,
|
|
104
|
+
};
|
|
105
|
+
await config.provisionDatabase(setupContext);
|
|
106
|
+
await config.installAndBuild(setupContext);
|
|
107
|
+
if (config.afterDatabase)
|
|
108
|
+
await config.afterDatabase(setupContext);
|
|
109
|
+
console.log(config.printSummary({
|
|
110
|
+
slot,
|
|
111
|
+
branch,
|
|
112
|
+
owner,
|
|
113
|
+
ports,
|
|
114
|
+
currentWorktree: setupCtx.currentWorktree,
|
|
115
|
+
mainWorktree: setupCtx.mainWorktree,
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
function ensureWorktree(args, ctx, run) {
|
|
119
|
+
if (args.use)
|
|
120
|
+
return useExistingBranch(args.use, ctx, run);
|
|
121
|
+
if (args.create)
|
|
122
|
+
return createBranch(args.create, ctx, run);
|
|
123
|
+
return ctx;
|
|
124
|
+
}
|
|
125
|
+
function setupLocalDirectories(worktreePath, dirs) {
|
|
126
|
+
for (const dir of dirs) {
|
|
127
|
+
mkdirSync(join(worktreePath, dir), { recursive: true });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function linkSharedDirectories(ctx, dirs, log) {
|
|
131
|
+
for (const dirName of dirs) {
|
|
132
|
+
const link = join(ctx.currentWorktree, dirName);
|
|
133
|
+
const mainDir = join(ctx.mainWorktree, dirName);
|
|
134
|
+
if (!existsSync(mainDir)) {
|
|
135
|
+
log(`Skipped ${dirName} symlink (not present in main worktree).`);
|
|
136
|
+
}
|
|
137
|
+
else if (existsSync(link)) {
|
|
138
|
+
log(`Skipped ${dirName} symlink (already exists).`);
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
const relTarget = relative(ctx.currentWorktree, mainDir);
|
|
142
|
+
symlinkSync(relTarget, link);
|
|
143
|
+
log(`Created ${dirName} symlink → main worktree.`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function generateConfigFiles(ctx, entries, slot, ports, force, log) {
|
|
148
|
+
for (const entry of entries) {
|
|
149
|
+
copyAndPatchFile({ currentWorktree: ctx.currentWorktree, mainWorktree: ctx.mainWorktree, log }, entry.path, (content) => entry.patch(content, {
|
|
150
|
+
slot,
|
|
151
|
+
ports,
|
|
152
|
+
mainWorktree: ctx.mainWorktree,
|
|
153
|
+
currentWorktree: ctx.currentWorktree,
|
|
154
|
+
}), entry.path, force, entry.required ?? false);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function resolveRemoveTarget(args, ctx, registry, removeSelf) {
|
|
158
|
+
if (removeSelf) {
|
|
159
|
+
if (ctx.isMainWorktree) {
|
|
160
|
+
console.error("Error: Cannot remove the main worktree.");
|
|
161
|
+
process.exit(1);
|
|
162
|
+
}
|
|
163
|
+
const resolvedCurrent = resolve(ctx.currentWorktree);
|
|
164
|
+
const entry = Object.entries(registry.slots).find(([, v]) => resolve(v.worktree) === resolvedCurrent);
|
|
165
|
+
if (!entry) {
|
|
166
|
+
console.error("Error: No slot found for this worktree in the registry.");
|
|
167
|
+
process.exit(1);
|
|
168
|
+
}
|
|
169
|
+
return { slotPort: entry[0], branch: entry[1].branch, worktreePath: ctx.currentWorktree };
|
|
170
|
+
}
|
|
171
|
+
const branch = args.remove ?? "";
|
|
172
|
+
const entry = Object.entries(registry.slots).find(([, v]) => v.branch === branch);
|
|
173
|
+
if (!entry) {
|
|
174
|
+
console.error(`Error: No worktree found for branch "${branch}" in the slot registry.`);
|
|
175
|
+
process.exit(1);
|
|
176
|
+
}
|
|
177
|
+
const worktreePath = entry[1].worktree;
|
|
178
|
+
if (resolve(ctx.currentWorktree) === resolve(worktreePath)) {
|
|
179
|
+
console.error("Error: You are currently in this worktree. Use --remove-self instead.");
|
|
180
|
+
process.exit(1);
|
|
181
|
+
}
|
|
182
|
+
return { slotPort: entry[0], branch, worktreePath };
|
|
183
|
+
}
|
|
184
|
+
async function stopDevServerByPidFiles(worktreePath, pidFiles, log) {
|
|
185
|
+
for (const pidFileRel of pidFiles) {
|
|
186
|
+
const pidFile = join(worktreePath, pidFileRel);
|
|
187
|
+
const pid = readPid(pidFile);
|
|
188
|
+
if (pid === undefined)
|
|
189
|
+
continue;
|
|
190
|
+
if (!isProcessAlive(pid)) {
|
|
191
|
+
cleanupPidFile(pidFile);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
log(`Stopping dev server (PID ${pid})...`);
|
|
195
|
+
killProcessGroup(pid, "SIGTERM");
|
|
196
|
+
const deadline = Date.now() + 5_000;
|
|
197
|
+
let stillAlive = true;
|
|
198
|
+
while (Date.now() < deadline) {
|
|
199
|
+
if (!isProcessGroupAlive(pid)) {
|
|
200
|
+
stillAlive = false;
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
204
|
+
}
|
|
205
|
+
if (stillAlive) {
|
|
206
|
+
killProcessGroup(pid, "SIGKILL");
|
|
207
|
+
}
|
|
208
|
+
cleanupPidFile(pidFile);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
async function handleRemove(args, ctx, run, config) {
|
|
212
|
+
const log = makeLog(run.verbose);
|
|
213
|
+
const removeSelf = Boolean(args["remove-self"]);
|
|
214
|
+
const registry = readSlots(ctx.mainWorktree);
|
|
215
|
+
const target = resolveRemoveTarget(args, ctx, registry, removeSelf);
|
|
216
|
+
if (!args["no-remote-check"]) {
|
|
217
|
+
verifyBranchAbsentFromRemote(target.branch, run);
|
|
218
|
+
}
|
|
219
|
+
if (!existsSync(target.worktreePath)) {
|
|
220
|
+
console.warn(`Warning: Worktree directory ${target.worktreePath} not found. Cleaning up registry only.`);
|
|
221
|
+
delete registry.slots[target.slotPort];
|
|
222
|
+
writeSlots(ctx.mainWorktree, registry);
|
|
223
|
+
console.log(`Removed registry entry for branch "${target.branch}" (slot ${target.slotPort}).`);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
await stopDevServerByPidFiles(target.worktreePath, config.devServerPidFiles, log);
|
|
227
|
+
if (config.teardownInfrastructure) {
|
|
228
|
+
await config.teardownInfrastructure({
|
|
229
|
+
worktree: target.worktreePath,
|
|
230
|
+
mainWorktree: ctx.mainWorktree,
|
|
231
|
+
verbose: run.verbose,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
delete registry.slots[target.slotPort];
|
|
235
|
+
writeSlots(ctx.mainWorktree, registry);
|
|
236
|
+
removeDevServerEntryByWorktree(ctx.mainWorktree, target.worktreePath);
|
|
237
|
+
if (removeSelf) {
|
|
238
|
+
process.chdir(ctx.mainWorktree);
|
|
239
|
+
}
|
|
240
|
+
removeWorktree(target.worktreePath, run);
|
|
241
|
+
console.log(`Removed worktree for branch "${target.branch}" (slot ${target.slotPort}).`);
|
|
242
|
+
if (removeSelf) {
|
|
243
|
+
console.log(`Now run: cd ${ctx.mainWorktree}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
function handleSetOwnerMode(args, ctx) {
|
|
247
|
+
const newOwner = args["set-owner"] ?? "default";
|
|
248
|
+
const { slotPort } = handleSetOwner({
|
|
249
|
+
newOwner,
|
|
250
|
+
currentWorktree: ctx.currentWorktree,
|
|
251
|
+
mainWorktree: ctx.mainWorktree,
|
|
252
|
+
isMainWorktree: ctx.isMainWorktree,
|
|
253
|
+
});
|
|
254
|
+
// Propagate to dev-servers.json entries for this worktree.
|
|
255
|
+
const devServersPath = join(ctx.mainWorktree, ".local/worktrees/dev-servers.json");
|
|
256
|
+
if (existsSync(devServersPath)) {
|
|
257
|
+
const data = JSON.parse(readFileSync(devServersPath, "utf-8"));
|
|
258
|
+
let changed = false;
|
|
259
|
+
const resolvedCurrent = resolve(ctx.currentWorktree);
|
|
260
|
+
for (const server of data.servers) {
|
|
261
|
+
if (resolve(server.worktree) === resolvedCurrent) {
|
|
262
|
+
server.owner = newOwner;
|
|
263
|
+
changed = true;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (changed) {
|
|
267
|
+
mkdirSync(dirname(devServersPath), { recursive: true });
|
|
268
|
+
writeFileSync(devServersPath, `${JSON.stringify(data, undefined, 2)}\n`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
console.log(`Owner for slot ${slotPort}: ${newOwner}`);
|
|
272
|
+
}
|
package/dist/slots.d.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { type PortScheme } from "./ports.js";
|
|
2
|
+
export declare const WORKTREES_DIR = ".local/worktrees";
|
|
3
|
+
export declare const SLOTS_FILE = ".local/worktrees/slots.json";
|
|
4
|
+
export interface SlotEntry {
|
|
5
|
+
worktree: string;
|
|
6
|
+
branch: string;
|
|
7
|
+
owner: string;
|
|
8
|
+
}
|
|
9
|
+
export interface SlotsRegistry {
|
|
10
|
+
slots: Record<string, SlotEntry>;
|
|
11
|
+
}
|
|
12
|
+
export interface ResolvedSlot {
|
|
13
|
+
slot: number;
|
|
14
|
+
worktree: string;
|
|
15
|
+
branch: string;
|
|
16
|
+
owner: string;
|
|
17
|
+
}
|
|
18
|
+
export declare function readSlots(mainWorktree: string): SlotsRegistry;
|
|
19
|
+
export declare function writeSlots(mainWorktree: string, registry: SlotsRegistry): void;
|
|
20
|
+
export interface PickSlotArgs {
|
|
21
|
+
slot?: string;
|
|
22
|
+
currentWorktree: string;
|
|
23
|
+
mainWorktree: string;
|
|
24
|
+
scheme: PortScheme;
|
|
25
|
+
}
|
|
26
|
+
export declare function pickSlotPort(args: PickSlotArgs, registry: SlotsRegistry): number;
|
|
27
|
+
export interface RegisterSlotInput {
|
|
28
|
+
slot?: string;
|
|
29
|
+
currentWorktree: string;
|
|
30
|
+
mainWorktree: string;
|
|
31
|
+
scheme: PortScheme;
|
|
32
|
+
branch: string;
|
|
33
|
+
requestedOwner?: string;
|
|
34
|
+
}
|
|
35
|
+
export declare function resolveAndRegisterSlot(input: RegisterSlotInput): {
|
|
36
|
+
port: number;
|
|
37
|
+
owner: string;
|
|
38
|
+
};
|
|
39
|
+
export declare function validateSlotAvailability(slotArg: string | undefined, ctx: {
|
|
40
|
+
currentWorktree: string;
|
|
41
|
+
mainWorktree: string;
|
|
42
|
+
scheme: PortScheme;
|
|
43
|
+
}): void;
|
|
44
|
+
export declare function lookupSlotForCwd(): ResolvedSlot | undefined;
|
|
45
|
+
export declare function synthesizeMainSlot(basePort: number): ResolvedSlot | undefined;
|
|
46
|
+
export declare function resolveCurrentSlot(basePort: number): ResolvedSlot;
|
|
47
|
+
export interface SetOwnerInput {
|
|
48
|
+
newOwner: string;
|
|
49
|
+
currentWorktree: string;
|
|
50
|
+
mainWorktree: string;
|
|
51
|
+
isMainWorktree: boolean;
|
|
52
|
+
}
|
|
53
|
+
export declare function handleSetOwner(input: SetOwnerInput): {
|
|
54
|
+
slotPort: string;
|
|
55
|
+
owner: string;
|
|
56
|
+
};
|