@zjaaal/artemis 0.0.0-stage → 0.1.2
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/LICENSE +21 -0
- package/README.md +568 -2
- package/dist/cli/constants.d.ts +30 -0
- package/dist/cli/constants.js +34 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +1 -0
- package/dist/cli/init-command.d.ts +8 -0
- package/dist/cli/init-command.js +42 -0
- package/dist/cli/init.d.ts +23 -0
- package/dist/cli/init.js +153 -0
- package/dist/cli/launch.d.ts +10 -0
- package/dist/cli/launch.js +116 -0
- package/dist/cli/main.d.ts +2 -0
- package/dist/cli/main.js +7 -0
- package/dist/cli/resolve-hook.d.ts +9 -0
- package/dist/cli/resolve-hook.js +21 -0
- package/dist/cli/settings.d.ts +18 -0
- package/dist/cli/settings.js +110 -0
- package/dist/constants.d.ts +94 -0
- package/dist/constants.js +86 -0
- package/dist/context/context.d.ts +6 -0
- package/dist/context/context.js +35 -0
- package/dist/define/define.d.ts +15 -0
- package/dist/define/define.js +99 -0
- package/dist/discover/discover.d.ts +7 -0
- package/dist/discover/discover.js +99 -0
- package/dist/edges/files.d.ts +25 -0
- package/dist/edges/files.js +111 -0
- package/dist/edges/modules.d.ts +2 -0
- package/dist/edges/modules.js +6 -0
- package/dist/edges/process.d.ts +2 -0
- package/dist/edges/process.js +9 -0
- package/dist/edges/prompts.d.ts +2 -0
- package/dist/edges/prompts.js +14 -0
- package/dist/edges/system.d.ts +4 -0
- package/dist/edges/system.js +5 -0
- package/dist/errors/errors.d.ts +25 -0
- package/dist/errors/errors.js +37 -0
- package/dist/help/help.d.ts +15 -0
- package/dist/help/help.js +169 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +12 -0
- package/dist/kits/terminal/constants.d.ts +10 -0
- package/dist/kits/terminal/constants.js +10 -0
- package/dist/kits/terminal/index.d.ts +3 -0
- package/dist/kits/terminal/index.js +2 -0
- package/dist/kits/terminal/models.d.ts +57 -0
- package/dist/kits/terminal/models.js +2 -0
- package/dist/kits/terminal/open-link.d.ts +2 -0
- package/dist/kits/terminal/open-link.js +12 -0
- package/dist/kits/terminal/open-tabs.d.ts +3 -0
- package/dist/kits/terminal/open-tabs.js +5 -0
- package/dist/kits/terminal/toml.d.ts +3 -0
- package/dist/kits/terminal/toml.js +53 -0
- package/dist/kits/terminal/warp.d.ts +6 -0
- package/dist/kits/terminal/warp.js +32 -0
- package/dist/kits/worktrees/constants.d.ts +25 -0
- package/dist/kits/worktrees/constants.js +25 -0
- package/dist/kits/worktrees/git.d.ts +2 -0
- package/dist/kits/worktrees/git.js +7 -0
- package/dist/kits/worktrees/index.d.ts +3 -0
- package/dist/kits/worktrees/index.js +2 -0
- package/dist/kits/worktrees/instances.d.ts +9 -0
- package/dist/kits/worktrees/instances.js +33 -0
- package/dist/kits/worktrees/lock-edges.d.ts +7 -0
- package/dist/kits/worktrees/lock-edges.js +21 -0
- package/dist/kits/worktrees/lock.d.ts +7 -0
- package/dist/kits/worktrees/lock.js +104 -0
- package/dist/kits/worktrees/models.d.ts +123 -0
- package/dist/kits/worktrees/models.js +2 -0
- package/dist/kits/worktrees/names.d.ts +5 -0
- package/dist/kits/worktrees/names.js +9 -0
- package/dist/kits/worktrees/queries.d.ts +20 -0
- package/dist/kits/worktrees/queries.js +61 -0
- package/dist/kits/worktrees/registration.d.ts +15 -0
- package/dist/kits/worktrees/registration.js +107 -0
- package/dist/kits/worktrees/registry-store.d.ts +3 -0
- package/dist/kits/worktrees/registry-store.js +27 -0
- package/dist/kits/worktrees/worktrees.d.ts +10 -0
- package/dist/kits/worktrees/worktrees.js +56 -0
- package/dist/models.d.ts +485 -0
- package/dist/models.js +1 -0
- package/dist/parse/parse.d.ts +8 -0
- package/dist/parse/parse.js +81 -0
- package/dist/resolve/resolve.d.ts +18 -0
- package/dist/resolve/resolve.js +96 -0
- package/dist/run/report.d.ts +6 -0
- package/dist/run/report.js +51 -0
- package/dist/run/run.d.ts +8 -0
- package/dist/run/run.js +163 -0
- package/dist/run/world.d.ts +3 -0
- package/dist/run/world.js +12 -0
- package/dist/utils/error-code.d.ts +3 -0
- package/dist/utils/error-code.js +7 -0
- package/dist/utils/error-message.d.ts +3 -0
- package/dist/utils/error-message.js +2 -0
- package/dist/utils/is-record.d.ts +3 -0
- package/dist/utils/is-record.js +2 -0
- package/dist/utils/tag.d.ts +3 -0
- package/dist/utils/tag.js +8 -0
- package/dist/utils/terminator.d.ts +5 -0
- package/dist/utils/terminator.js +8 -0
- package/package.json +72 -4
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { WithLockFn } from './models.js';
|
|
2
|
+
/**
|
|
3
|
+
* Runs `work` while holding `<stateFile>.lock`, so that two processes registering at once cannot
|
|
4
|
+
* both read the registry, pick the same slot and overwrite each other. Synchronous like the rest
|
|
5
|
+
* of the kit: it retries every few milliseconds, and takes over a lock whose process has died.
|
|
6
|
+
*/
|
|
7
|
+
export declare const withLock: WithLockFn;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { createExclusive, makeDirectory, modifiedAt, readTextIfExists, removeFile } from '../../edges/files.js';
|
|
3
|
+
import { CommandError } from '../../errors/errors.js';
|
|
4
|
+
import { LOCK_GRACE_MS, LOCK_POLL_MS, LOCK_SUFFIX, TAKEOVER_STALE_MS, TAKEOVER_SUFFIX } from './constants.js';
|
|
5
|
+
/** The process id a lock names (`<pid> <token>`), or undefined when it holds none yet. */
|
|
6
|
+
const holderPid = (content) => Number.parseInt(content, 10) || undefined;
|
|
7
|
+
/** How long ago a file was written, by the injected clock; undefined when it is not there. */
|
|
8
|
+
function ageOf(file, edges) {
|
|
9
|
+
const written = modifiedAt(file);
|
|
10
|
+
return written === undefined ? undefined : edges.now() - written;
|
|
11
|
+
}
|
|
12
|
+
/** A lock with no process id in it is probably being written right now: give it a moment. */
|
|
13
|
+
const isBeingWritten = (lock, edges) => (ageOf(lock, edges) ?? LOCK_GRACE_MS) < LOCK_GRACE_MS;
|
|
14
|
+
function isHeld(lock, observed, edges) {
|
|
15
|
+
const pid = holderPid(observed);
|
|
16
|
+
return pid === undefined ? isBeingWritten(lock, edges) : edges.isProcessAlive(pid);
|
|
17
|
+
}
|
|
18
|
+
function lockedMessage(observed) {
|
|
19
|
+
const pid = holderPid(observed);
|
|
20
|
+
return `The instance registry is locked by ${pid === undefined ? 'another process' : `process ${pid}`}.`;
|
|
21
|
+
}
|
|
22
|
+
/** The lock is held by someone alive: wait a little, or give up once the deadline has passed. */
|
|
23
|
+
function waitOrFail({ lock, deadline, edges }, observed) {
|
|
24
|
+
if (edges.now() > deadline) {
|
|
25
|
+
throw new CommandError(lockedMessage(observed), {
|
|
26
|
+
hint: `If no other command is running, delete ${lock}.`
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
edges.pause(LOCK_POLL_MS);
|
|
30
|
+
}
|
|
31
|
+
/** Whoever was taking over died half way: its guard is old enough to clear. */
|
|
32
|
+
function clearAbandonedGuard(guard, edges) {
|
|
33
|
+
if ((ageOf(guard, edges) ?? 0) > TAKEOVER_STALE_MS) {
|
|
34
|
+
removeFile(guard);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Removes a lock whose holder is gone. Only one process at a time may do it (the guard), and it
|
|
39
|
+
* removes the lock only if it still holds exactly what was judged stale: if someone else got there
|
|
40
|
+
* first and made a new lock, that one is left alone. While the stale file exists nobody else can
|
|
41
|
+
* create a lock, so nothing can replace it between that check and the removal.
|
|
42
|
+
*/
|
|
43
|
+
function takeOver({ lock, ownership, edges }, observed) {
|
|
44
|
+
const guard = `${lock}${TAKEOVER_SUFFIX}`;
|
|
45
|
+
if (!createExclusive(guard, ownership)) {
|
|
46
|
+
clearAbandonedGuard(guard, edges);
|
|
47
|
+
edges.pause(LOCK_POLL_MS);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
if (readTextIfExists(lock) === observed) {
|
|
52
|
+
removeFile(lock);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
removeFile(guard);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** The lock exists: wait for its holder, or take it over if the holder is gone. */
|
|
60
|
+
function contend(attempt) {
|
|
61
|
+
const observed = readTextIfExists(attempt.lock);
|
|
62
|
+
// Released while we looked: just try to create it again.
|
|
63
|
+
if (observed === undefined) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (isHeld(attempt.lock, observed, attempt.edges)) {
|
|
67
|
+
waitOrFail(attempt, observed);
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
takeOver(attempt, observed);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function acquire(attempt) {
|
|
74
|
+
makeDirectory(path.dirname(attempt.lock));
|
|
75
|
+
while (!createExclusive(attempt.lock, attempt.ownership)) {
|
|
76
|
+
contend(attempt);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** Gives the lock back, but only if it is still ours: never delete one another process holds. */
|
|
80
|
+
function release({ lock, ownership }) {
|
|
81
|
+
if (readTextIfExists(lock) === ownership) {
|
|
82
|
+
removeFile(lock);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Runs `work` while holding `<stateFile>.lock`, so that two processes registering at once cannot
|
|
87
|
+
* both read the registry, pick the same slot and overwrite each other. Synchronous like the rest
|
|
88
|
+
* of the kit: it retries every few milliseconds, and takes over a lock whose process has died.
|
|
89
|
+
*/
|
|
90
|
+
export const withLock = (stateFile, work, edges) => {
|
|
91
|
+
const attempt = {
|
|
92
|
+
lock: `${stateFile}${LOCK_SUFFIX}`,
|
|
93
|
+
ownership: `${edges.pid} ${edges.token()}`,
|
|
94
|
+
deadline: edges.now() + edges.timeoutMs,
|
|
95
|
+
edges
|
|
96
|
+
};
|
|
97
|
+
acquire(attempt);
|
|
98
|
+
try {
|
|
99
|
+
return work();
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
release(attempt);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
export type WorktreeMeta = Record<string, unknown>;
|
|
2
|
+
/** What the registry file stores for a worktree. `root` is null for a detached instance. */
|
|
3
|
+
export type WorktreeRecord = {
|
|
4
|
+
slot: number;
|
|
5
|
+
root: string | null;
|
|
6
|
+
meta?: WorktreeMeta;
|
|
7
|
+
};
|
|
8
|
+
export type WorktreeRecords = Record<string, WorktreeRecord>;
|
|
9
|
+
/** One instance of the app: a stable slot, its ports and color. */
|
|
10
|
+
export type WorktreeInstance = {
|
|
11
|
+
name: string;
|
|
12
|
+
slot: number;
|
|
13
|
+
root: string | null;
|
|
14
|
+
isMain: boolean;
|
|
15
|
+
meta: WorktreeMeta;
|
|
16
|
+
color: string;
|
|
17
|
+
ports: Record<string, number>;
|
|
18
|
+
};
|
|
19
|
+
export type IsCheckoutFn = (root: string) => boolean;
|
|
20
|
+
export type NowFn = () => number;
|
|
21
|
+
export type IsProcessAliveFn = (pid: number) => boolean;
|
|
22
|
+
export type PauseFn = (ms: number) => void;
|
|
23
|
+
export type TokenFn = () => string;
|
|
24
|
+
/** The clock, process liveness and waiting, injected so lock tests never sleep. */
|
|
25
|
+
export type LockEdges = {
|
|
26
|
+
/** The process id written into the lock file. */
|
|
27
|
+
pid: number;
|
|
28
|
+
/** Tells this holder apart from any other holder with the same pid. */
|
|
29
|
+
token: TokenFn;
|
|
30
|
+
now: NowFn;
|
|
31
|
+
isProcessAlive: IsProcessAliveFn;
|
|
32
|
+
pause: PauseFn;
|
|
33
|
+
timeoutMs: number;
|
|
34
|
+
};
|
|
35
|
+
export type WorktreesOptions = {
|
|
36
|
+
/** JSON file the registry is kept in (the app's data folder). */
|
|
37
|
+
stateFile: string;
|
|
38
|
+
/** Path of the main checkout (slot 0). */
|
|
39
|
+
mainCheckout: string;
|
|
40
|
+
/** Base ports, shifted by `slot * slotStep`. */
|
|
41
|
+
ports: Record<string, number>;
|
|
42
|
+
/** Whether a git root is a checkout of this app; other repos count as outside any checkout. */
|
|
43
|
+
isCheckout?: IsCheckoutFn;
|
|
44
|
+
/** Variable that pins the instance by name, overriding the folder. */
|
|
45
|
+
envVar?: string;
|
|
46
|
+
/** Port shift per slot. Default 100. */
|
|
47
|
+
slotStep?: number;
|
|
48
|
+
/** One per slot, cycling. */
|
|
49
|
+
colors?: readonly string[];
|
|
50
|
+
/** Name of the main checkout's instance. Default "main". */
|
|
51
|
+
mainName?: string;
|
|
52
|
+
/** Environment to read `envVar` from. Default: the process environment. */
|
|
53
|
+
env?: NodeJS.ProcessEnv;
|
|
54
|
+
/** Clock for the registry lock. Default: `Date.now`. */
|
|
55
|
+
now?: NowFn;
|
|
56
|
+
/** Process liveness for the registry lock. Default: `process.kill(pid, 0)`. */
|
|
57
|
+
isProcessAlive?: IsProcessAliveFn;
|
|
58
|
+
/** How the lock waits between retries. Default: a blocking wait. */
|
|
59
|
+
pause?: PauseFn;
|
|
60
|
+
/** How long to wait for the lock before giving up. Default 10 seconds. */
|
|
61
|
+
lockTimeoutMs?: number;
|
|
62
|
+
};
|
|
63
|
+
export type CurrentOptions = {
|
|
64
|
+
cwd?: string;
|
|
65
|
+
};
|
|
66
|
+
export type EnsureOptions = {
|
|
67
|
+
cwd?: string;
|
|
68
|
+
root?: string;
|
|
69
|
+
name?: string;
|
|
70
|
+
};
|
|
71
|
+
export type EnsureDetachedOptions = {
|
|
72
|
+
name: string;
|
|
73
|
+
meta?: WorktreeMeta;
|
|
74
|
+
};
|
|
75
|
+
export type Worktrees = {
|
|
76
|
+
/** Every instance: the main one first, then the registered worktrees by slot. */
|
|
77
|
+
list: () => WorktreeInstance[];
|
|
78
|
+
get: (name: string) => WorktreeInstance;
|
|
79
|
+
current: (options?: CurrentOptions) => WorktreeInstance;
|
|
80
|
+
ensure: (options?: EnsureOptions) => WorktreeInstance;
|
|
81
|
+
ensureDetached: (options: EnsureDetachedOptions) => WorktreeInstance;
|
|
82
|
+
remove: (name: string) => void;
|
|
83
|
+
};
|
|
84
|
+
export type DefineWorktreesFn = (options: WorktreesOptions) => Worktrees;
|
|
85
|
+
export type ToWorktreeNameFn = (folder: string) => string;
|
|
86
|
+
/** One acquisition of the registry lock: where it is, who is asking, and when to give up. */
|
|
87
|
+
export type LockAttempt = {
|
|
88
|
+
lock: string;
|
|
89
|
+
ownership: string;
|
|
90
|
+
deadline: number;
|
|
91
|
+
edges: LockEdges;
|
|
92
|
+
};
|
|
93
|
+
export type WithLockFn = <TResult>(stateFile: string, work: () => TResult, edges: LockEdges) => TResult;
|
|
94
|
+
/** Options with their defaults applied, and the registry store they work on. */
|
|
95
|
+
export type KitConfig = {
|
|
96
|
+
stateFile: string;
|
|
97
|
+
mainRoot: string;
|
|
98
|
+
ports: Record<string, number>;
|
|
99
|
+
isCheckout: IsCheckoutFn;
|
|
100
|
+
envVar: string | undefined;
|
|
101
|
+
slotStep: number;
|
|
102
|
+
colors: readonly string[];
|
|
103
|
+
mainName: string;
|
|
104
|
+
env: NodeJS.ProcessEnv;
|
|
105
|
+
store: RegistryStore;
|
|
106
|
+
lock: LockEdges;
|
|
107
|
+
};
|
|
108
|
+
/** What turns a record into an instance. */
|
|
109
|
+
export type InstanceConfig = Pick<KitConfig, 'ports' | 'slotStep' | 'colors'>;
|
|
110
|
+
export type RegistryStore = {
|
|
111
|
+
read: () => WorktreeRecords;
|
|
112
|
+
write: (records: WorktreeRecords) => void;
|
|
113
|
+
};
|
|
114
|
+
export type CreateRegistryStoreFn = (stateFile: string) => RegistryStore;
|
|
115
|
+
export type DescribeInstanceFn = (config: InstanceConfig, name: string, record: WorktreeRecord) => WorktreeInstance;
|
|
116
|
+
export type NextFreeSlotFn = (records: WorktreeRecords) => number;
|
|
117
|
+
export type RecordOfFn = (records: WorktreeRecords, name: string) => WorktreeRecord | undefined;
|
|
118
|
+
/** A name that is not taken by a registered worktree or by the main checkout. */
|
|
119
|
+
export type UniqueNameFn = (wanted: string, base: string, taken: NameCheck) => string;
|
|
120
|
+
export type NameCheck = (name: string) => boolean;
|
|
121
|
+
export type IsValidNameFn = (name: string) => boolean;
|
|
122
|
+
/** Something that runs with its own arguments, such as a registry operation. */
|
|
123
|
+
export type Operation<TArgs extends unknown[], TResult> = (...args: TArgs) => TResult;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { IsValidNameFn, ToWorktreeNameFn } from './models.js';
|
|
2
|
+
/** "Core_Feat X" -> "core-feat-x": a folder name turned into a valid instance name. */
|
|
3
|
+
export declare const toWorktreeName: ToWorktreeNameFn;
|
|
4
|
+
/** Lowercase letters, digits and dashes, starting with a letter or digit, within the length limit. */
|
|
5
|
+
export declare const isValidName: IsValidNameFn;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { EDGE_DASHES, INVALID_NAME_RUN, MAX_INSTANCE_NAME_LENGTH, VALID_NAME } from './constants.js';
|
|
2
|
+
/** "Core_Feat X" -> "core-feat-x": a folder name turned into a valid instance name. */
|
|
3
|
+
export const toWorktreeName = (folder) => folder
|
|
4
|
+
.toLowerCase()
|
|
5
|
+
.replace(INVALID_NAME_RUN, '-')
|
|
6
|
+
.replace(EDGE_DASHES, '')
|
|
7
|
+
.slice(0, MAX_INSTANCE_NAME_LENGTH);
|
|
8
|
+
/** Lowercase letters, digits and dashes, starting with a letter or digit, within the length limit. */
|
|
9
|
+
export const isValidName = (name) => VALID_NAME.test(name);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { CommandError } from '../../errors/errors.js';
|
|
2
|
+
import type { CurrentOptions, KitConfig, WorktreeInstance } from './models.js';
|
|
3
|
+
/** The instance pinned by the app's env variable, if the app has one and it is set. */
|
|
4
|
+
export declare const pinnedName: ({ envVar, env }: KitConfig) => string | undefined;
|
|
5
|
+
export declare const noInstanceError: (name: string) => CommandError;
|
|
6
|
+
/** The main checkout is slot 0 and needs no registration. */
|
|
7
|
+
export declare const mainInstance: (config: KitConfig) => WorktreeInstance;
|
|
8
|
+
/** Every instance: the main one first, then the registered worktrees by slot. */
|
|
9
|
+
export declare function listInstances(config: KitConfig): WorktreeInstance[];
|
|
10
|
+
export declare function getInstance(config: KitConfig, name: string): WorktreeInstance;
|
|
11
|
+
/** The checkout root containing `cwd`, or null when it is not a checkout of this app. */
|
|
12
|
+
export declare function rootOf(config: KitConfig, cwd: string): string | null;
|
|
13
|
+
/** The instance of a checkout root: the main one, or the registered worktree there. */
|
|
14
|
+
export declare function instanceAt(config: KitConfig, root: string): WorktreeInstance | undefined;
|
|
15
|
+
/**
|
|
16
|
+
* The instance for where the app runs: the env variable's if set; else the checkout containing
|
|
17
|
+
* `cwd` (main -> main, registered -> its instance); outside any checkout, main. A worktree that
|
|
18
|
+
* is not registered throws, so nothing lands on another worktree's instance by mistake.
|
|
19
|
+
*/
|
|
20
|
+
export declare function currentInstance(config: KitConfig, { cwd }?: CurrentOptions): WorktreeInstance;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { gitRoot } from './git.js';
|
|
2
|
+
import { workingDirectory } from '../../edges/system.js';
|
|
3
|
+
import { CommandError } from '../../errors/errors.js';
|
|
4
|
+
import { MAIN_SLOT } from './constants.js';
|
|
5
|
+
import { describeInstance } from './instances.js';
|
|
6
|
+
/** The instance pinned by the app's env variable, if the app has one and it is set. */
|
|
7
|
+
export const pinnedName = ({ envVar, env }) => envVar ? env[envVar] || undefined : undefined;
|
|
8
|
+
export const noInstanceError = (name) => new CommandError(`There is no instance named "${name}".`, {
|
|
9
|
+
hint: 'Registered instances are listed by the app; a worktree registers on its first start.'
|
|
10
|
+
});
|
|
11
|
+
/** The main checkout is slot 0 and needs no registration. */
|
|
12
|
+
export const mainInstance = (config) => describeInstance(config, config.mainName, { slot: MAIN_SLOT, root: config.mainRoot });
|
|
13
|
+
/** Every instance: the main one first, then the registered worktrees by slot. */
|
|
14
|
+
export function listInstances(config) {
|
|
15
|
+
const registered = Object.entries(config.store.read())
|
|
16
|
+
.map(([name, record]) => describeInstance(config, name, record))
|
|
17
|
+
.sort((a, b) => a.slot - b.slot);
|
|
18
|
+
return [mainInstance(config), ...registered];
|
|
19
|
+
}
|
|
20
|
+
export function getInstance(config, name) {
|
|
21
|
+
const found = listInstances(config).find((instance) => instance.name === name);
|
|
22
|
+
if (!found) {
|
|
23
|
+
throw noInstanceError(name);
|
|
24
|
+
}
|
|
25
|
+
return found;
|
|
26
|
+
}
|
|
27
|
+
/** The checkout root containing `cwd`, or null when it is not a checkout of this app. */
|
|
28
|
+
export function rootOf(config, cwd) {
|
|
29
|
+
const root = gitRoot(cwd);
|
|
30
|
+
return root && config.isCheckout(root) ? root : null;
|
|
31
|
+
}
|
|
32
|
+
/** The instance of a checkout root: the main one, or the registered worktree there. */
|
|
33
|
+
export function instanceAt(config, root) {
|
|
34
|
+
if (root === config.mainRoot) {
|
|
35
|
+
return mainInstance(config);
|
|
36
|
+
}
|
|
37
|
+
return listInstances(config).find((instance) => !instance.isMain && instance.root === root);
|
|
38
|
+
}
|
|
39
|
+
const unregisteredHint = (envVar) => `Register it first (the app's start command does that)${envVar ? `, or set ${envVar} to use another instance` : ''}.`;
|
|
40
|
+
/**
|
|
41
|
+
* The instance for where the app runs: the env variable's if set; else the checkout containing
|
|
42
|
+
* `cwd` (main -> main, registered -> its instance); outside any checkout, main. A worktree that
|
|
43
|
+
* is not registered throws, so nothing lands on another worktree's instance by mistake.
|
|
44
|
+
*/
|
|
45
|
+
export function currentInstance(config, { cwd = workingDirectory() } = {}) {
|
|
46
|
+
const pinned = pinnedName(config);
|
|
47
|
+
if (pinned) {
|
|
48
|
+
return getInstance(config, pinned);
|
|
49
|
+
}
|
|
50
|
+
const root = rootOf(config, cwd);
|
|
51
|
+
if (!root) {
|
|
52
|
+
return mainInstance(config);
|
|
53
|
+
}
|
|
54
|
+
const found = instanceAt(config, root);
|
|
55
|
+
if (!found) {
|
|
56
|
+
throw new CommandError(`${root} is a worktree with no instance yet.`, {
|
|
57
|
+
hint: unregisteredHint(config.envVar)
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return found;
|
|
61
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { EnsureDetachedOptions, EnsureOptions, KitConfig, WorktreeInstance } from './models.js';
|
|
2
|
+
/**
|
|
3
|
+
* Returns the instance for a checkout, registering it on first use: `root` if given, else the
|
|
4
|
+
* checkout containing `cwd`. Named after its folder (with a suffix if taken) unless `name` is
|
|
5
|
+
* given. Outside any checkout it is the current instance.
|
|
6
|
+
*/
|
|
7
|
+
export declare function ensureInstance(config: KitConfig, { cwd, root, name }?: EnsureOptions): WorktreeInstance;
|
|
8
|
+
/**
|
|
9
|
+
* Returns a named instance that belongs to no checkout (e.g. one per container image),
|
|
10
|
+
* registering it on first use with the next free slot. The folder never resolves to it; it is
|
|
11
|
+
* reached by name (get, or the env variable). `meta` is stored with it and updated each time.
|
|
12
|
+
*/
|
|
13
|
+
export declare function ensureDetachedInstance(config: KitConfig, { name, meta }: EnsureDetachedOptions): WorktreeInstance;
|
|
14
|
+
/** Forgets a registered worktree, freeing its slot. Never touches the worktree itself. */
|
|
15
|
+
export declare function removeInstance(config: KitConfig, name: string): void;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { pathExists, realPathOrResolve } from '../../edges/files.js';
|
|
3
|
+
import { workingDirectory } from '../../edges/system.js';
|
|
4
|
+
import { CommandError, UsageError } from '../../errors/errors.js';
|
|
5
|
+
import { FALLBACK_WORKTREE_NAME } from './constants.js';
|
|
6
|
+
import { describeInstance, nextFreeSlot, recordOf, uniqueName } from './instances.js';
|
|
7
|
+
import { isValidName, toWorktreeName } from './names.js';
|
|
8
|
+
import { currentInstance, getInstance, instanceAt, noInstanceError, pinnedName, rootOf } from './queries.js';
|
|
9
|
+
const isTakenBy = (config, records) => (name) => Object.hasOwn(records, name) || name === config.mainName;
|
|
10
|
+
const isCheckoutRoot = (config, root) => pathExists(root) && config.isCheckout(root);
|
|
11
|
+
function assertChoosable(config, records, name) {
|
|
12
|
+
if (!isValidName(name)) {
|
|
13
|
+
throw new UsageError(`"${name}" is not a valid instance name.`, {
|
|
14
|
+
hint: 'Use lowercase letters, digits and dashes, e.g. "feat-x".'
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
if (isTakenBy(config, records)(name)) {
|
|
18
|
+
throw new UsageError(`Instance "${name}" already belongs to another worktree.`, {
|
|
19
|
+
hint: 'Pick another name.'
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** Outside any checkout there is nothing to register: a known name, or the current instance. */
|
|
24
|
+
function ensureOutsideCheckout(config, records, cwd, name) {
|
|
25
|
+
const existing = name ? recordOf(records, name) : undefined;
|
|
26
|
+
if (name && existing) {
|
|
27
|
+
return describeInstance(config, name, existing);
|
|
28
|
+
}
|
|
29
|
+
if (name) {
|
|
30
|
+
throw new UsageError(`Instance "${name}" does not exist yet, so it needs a worktree.`, {
|
|
31
|
+
hint: 'Run it inside the worktree, or pass the worktree path.'
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return currentInstance(config, { cwd });
|
|
35
|
+
}
|
|
36
|
+
function registerCheckout(config, records, target, name) {
|
|
37
|
+
const known = instanceAt(config, target);
|
|
38
|
+
if (known) {
|
|
39
|
+
if (name && name !== known.name) {
|
|
40
|
+
throw new UsageError(`${target} is already registered as "${known.name}".`);
|
|
41
|
+
}
|
|
42
|
+
return known;
|
|
43
|
+
}
|
|
44
|
+
if (name) {
|
|
45
|
+
assertChoosable(config, records, name);
|
|
46
|
+
}
|
|
47
|
+
const base = toWorktreeName(path.basename(target)) || FALLBACK_WORKTREE_NAME;
|
|
48
|
+
const finalName = uniqueName(name ?? base, base, isTakenBy(config, records));
|
|
49
|
+
const record = { slot: nextFreeSlot(records), root: target };
|
|
50
|
+
config.store.write({ ...records, [finalName]: record });
|
|
51
|
+
return describeInstance(config, finalName, record);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Returns the instance for a checkout, registering it on first use: `root` if given, else the
|
|
55
|
+
* checkout containing `cwd`. Named after its folder (with a suffix if taken) unless `name` is
|
|
56
|
+
* given. Outside any checkout it is the current instance.
|
|
57
|
+
*/
|
|
58
|
+
export function ensureInstance(config, { cwd = workingDirectory(), root, name } = {}) {
|
|
59
|
+
const pinned = pinnedName(config);
|
|
60
|
+
if (!root && !name && pinned) {
|
|
61
|
+
return getInstance(config, pinned);
|
|
62
|
+
}
|
|
63
|
+
const records = config.store.read();
|
|
64
|
+
const explicitRoot = root ? realPathOrResolve(root) : undefined;
|
|
65
|
+
if (explicitRoot !== undefined && !isCheckoutRoot(config, explicitRoot)) {
|
|
66
|
+
throw new CommandError(`${explicitRoot} is not a checkout of this app.`, {
|
|
67
|
+
hint: 'Point it at the root of a worktree.'
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
const target = explicitRoot ?? rootOf(config, cwd);
|
|
71
|
+
return target
|
|
72
|
+
? registerCheckout(config, records, target, name)
|
|
73
|
+
: ensureOutsideCheckout(config, records, cwd, name);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Returns a named instance that belongs to no checkout (e.g. one per container image),
|
|
77
|
+
* registering it on first use with the next free slot. The folder never resolves to it; it is
|
|
78
|
+
* reached by name (get, or the env variable). `meta` is stored with it and updated each time.
|
|
79
|
+
*/
|
|
80
|
+
export function ensureDetachedInstance(config, { name, meta = {} }) {
|
|
81
|
+
if (!isValidName(name ?? '') || name === config.mainName) {
|
|
82
|
+
throw new UsageError(`"${name}" is not a valid instance name.`, {
|
|
83
|
+
hint: 'Use lowercase letters, digits and dashes, e.g. "trunk-c467312".'
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
const records = config.store.read();
|
|
87
|
+
const existing = recordOf(records, name);
|
|
88
|
+
if (existing?.root) {
|
|
89
|
+
throw new UsageError(`Instance "${name}" already belongs to ${existing.root}.`, {
|
|
90
|
+
hint: 'Pick another name.'
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
const record = { slot: existing?.slot ?? nextFreeSlot(records), root: null, meta };
|
|
94
|
+
config.store.write({ ...records, [name]: record });
|
|
95
|
+
return describeInstance(config, name, record);
|
|
96
|
+
}
|
|
97
|
+
/** Forgets a registered worktree, freeing its slot. Never touches the worktree itself. */
|
|
98
|
+
export function removeInstance(config, name) {
|
|
99
|
+
if (name === config.mainName) {
|
|
100
|
+
throw new CommandError('The main checkout is not registered, so it cannot be removed.');
|
|
101
|
+
}
|
|
102
|
+
const records = config.store.read();
|
|
103
|
+
if (!recordOf(records, name)) {
|
|
104
|
+
throw noInstanceError(name);
|
|
105
|
+
}
|
|
106
|
+
config.store.write(Object.fromEntries(Object.entries(records).filter(([key]) => key !== name)));
|
|
107
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { makeDirectory, readTextIfExists, writeText } from '../../edges/files.js';
|
|
3
|
+
import { CommandError } from '../../errors/errors.js';
|
|
4
|
+
import { messageOf } from '../../utils/error-message.js';
|
|
5
|
+
import { STATE_INDENT_SPACES } from './constants.js';
|
|
6
|
+
/** The registry file: JSON the app keeps wherever it says. The kit stores nothing of its own. */
|
|
7
|
+
export const createRegistryStore = (stateFile) => ({
|
|
8
|
+
read: () => {
|
|
9
|
+
const text = readTextIfExists(stateFile);
|
|
10
|
+
if (text === undefined) {
|
|
11
|
+
return {};
|
|
12
|
+
}
|
|
13
|
+
try {
|
|
14
|
+
const records = JSON.parse(text);
|
|
15
|
+
return records;
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
throw new CommandError(`${stateFile} is not valid JSON: ${messageOf(error)}`, {
|
|
19
|
+
hint: 'Fix the file, or delete it to forget every registered worktree.'
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
write: (records) => {
|
|
24
|
+
makeDirectory(path.dirname(stateFile));
|
|
25
|
+
writeText(stateFile, `${JSON.stringify(records, null, STATE_INDENT_SPACES)}\n`);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { DefineWorktreesFn } from './models.js';
|
|
2
|
+
/**
|
|
3
|
+
* Artemis worktrees kit: give every checkout of a repo its own instance (a stable slot, ports
|
|
4
|
+
* shifted by that slot, a color) so several copies of an app can run side by side.
|
|
5
|
+
*
|
|
6
|
+
* Every checkout is a worktree, the main one included. The main checkout is slot 0 and needs no
|
|
7
|
+
* registration; other worktrees register on first use (ensure) and keep their slot until removed.
|
|
8
|
+
* The kit stores nothing of its own: the app says where the registry lives (stateFile).
|
|
9
|
+
*/
|
|
10
|
+
export declare const defineWorktrees: DefineWorktreesFn;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { currentProcess } from '../../edges/process.js';
|
|
2
|
+
import { processId } from '../../edges/system.js';
|
|
3
|
+
import { isProcessAlive, newToken, now, pause } from './lock-edges.js';
|
|
4
|
+
import { DEFAULT_COLORS, DEFAULT_MAIN_NAME, DEFAULT_SLOT_STEP, LOCK_TIMEOUT_MS } from './constants.js';
|
|
5
|
+
import { realPathOrResolve } from '../../edges/files.js';
|
|
6
|
+
import { createRegistryStore } from './registry-store.js';
|
|
7
|
+
import { withLock } from './lock.js';
|
|
8
|
+
import { ensureDetachedInstance, ensureInstance, removeInstance } from './registration.js';
|
|
9
|
+
import { currentInstance, getInstance, listInstances } from './queries.js';
|
|
10
|
+
const everyFolderIsACheckout = () => true;
|
|
11
|
+
/** The options with their defaults applied, and the registry file they work on. */
|
|
12
|
+
function configure(options) {
|
|
13
|
+
return {
|
|
14
|
+
stateFile: options.stateFile,
|
|
15
|
+
mainRoot: realPathOrResolve(options.mainCheckout),
|
|
16
|
+
ports: options.ports,
|
|
17
|
+
isCheckout: options.isCheckout ?? everyFolderIsACheckout,
|
|
18
|
+
envVar: options.envVar,
|
|
19
|
+
slotStep: options.slotStep ?? DEFAULT_SLOT_STEP,
|
|
20
|
+
colors: options.colors ?? DEFAULT_COLORS,
|
|
21
|
+
mainName: options.mainName ?? DEFAULT_MAIN_NAME,
|
|
22
|
+
env: options.env ?? currentProcess().env,
|
|
23
|
+
store: createRegistryStore(options.stateFile),
|
|
24
|
+
lock: {
|
|
25
|
+
pid: processId(),
|
|
26
|
+
token: newToken,
|
|
27
|
+
now: options.now ?? now,
|
|
28
|
+
isProcessAlive: options.isProcessAlive ?? isProcessAlive,
|
|
29
|
+
pause: options.pause ?? pause,
|
|
30
|
+
timeoutMs: options.lockTimeoutMs ?? LOCK_TIMEOUT_MS
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/** Every write to the registry happens under its lock; reading needs none. */
|
|
35
|
+
function locked(config, operation) {
|
|
36
|
+
return (...args) => withLock(config.stateFile, () => operation(...args), config.lock);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Artemis worktrees kit: give every checkout of a repo its own instance (a stable slot, ports
|
|
40
|
+
* shifted by that slot, a color) so several copies of an app can run side by side.
|
|
41
|
+
*
|
|
42
|
+
* Every checkout is a worktree, the main one included. The main checkout is slot 0 and needs no
|
|
43
|
+
* registration; other worktrees register on first use (ensure) and keep their slot until removed.
|
|
44
|
+
* The kit stores nothing of its own: the app says where the registry lives (stateFile).
|
|
45
|
+
*/
|
|
46
|
+
export const defineWorktrees = (options) => {
|
|
47
|
+
const config = configure(options);
|
|
48
|
+
return {
|
|
49
|
+
list: () => listInstances(config),
|
|
50
|
+
get: (name) => getInstance(config, name),
|
|
51
|
+
current: (currentOptions) => currentInstance(config, currentOptions),
|
|
52
|
+
ensure: locked(config, (ensureOptions) => ensureInstance(config, ensureOptions)),
|
|
53
|
+
ensureDetached: locked(config, (detachedOptions) => ensureDetachedInstance(config, detachedOptions)),
|
|
54
|
+
remove: locked(config, (name) => removeInstance(config, name))
|
|
55
|
+
};
|
|
56
|
+
};
|