@love-moon/conductor-cli 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +129 -0
- package/bin/conductor-config.js +133 -8
- package/bin/conductor-daemon.js +53 -29
- package/bin/conductor-diagnose.js +3 -1
- package/bin/conductor-fire.js +11 -0
- package/bin/conductor-project.js +3 -1
- package/bin/conductor-update.js +13 -0
- package/package.json +5 -5
- package/src/daemon-lock.js +240 -0
- package/src/daemon.js +717 -123
- package/src/guest-daemon.js +268 -0
- package/src/runtime-backends.js +63 -2
- package/src/version-check.js +23 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* RFC 0035 — guest daemon supervision.
|
|
7
|
+
*
|
|
8
|
+
* The owner's daemon keeps one child `conductor daemon` process per accepted
|
|
9
|
+
* share. Each child authenticates as the *grantee*, so the backend sees an
|
|
10
|
+
* ordinary daemon belonging to that user and every existing ownership check
|
|
11
|
+
* keeps working. Nothing in the backend's hot path knows sharing exists.
|
|
12
|
+
*
|
|
13
|
+
* This module is deliberately pure-ish: path/config/env construction and the
|
|
14
|
+
* reconcile decision are exported as plain functions so they can be tested
|
|
15
|
+
* without spawning anything.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** One node process per guest; this bounds the owner's machine, not the product. */
|
|
19
|
+
export const MAX_GUEST_DAEMONS = 3;
|
|
20
|
+
|
|
21
|
+
export const GUEST_RESTART_BASE_MS = 10_000;
|
|
22
|
+
export const GUEST_RESTART_MAX_MS = 5 * 60_000;
|
|
23
|
+
|
|
24
|
+
const expandHome = (value, homeDir) => {
|
|
25
|
+
if (typeof value !== "string" || !value.trim()) return "";
|
|
26
|
+
const trimmed = value.trim();
|
|
27
|
+
if (trimmed === "~") return homeDir;
|
|
28
|
+
if (trimmed.startsWith("~/")) return path.join(homeDir, trimmed.slice(2));
|
|
29
|
+
return path.resolve(trimmed);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Every path a guest instance must NOT share with its host daemon.
|
|
34
|
+
*
|
|
35
|
+
* This is the part that is easy to get wrong and expensive when you do:
|
|
36
|
+
* `--config-file` does *not* derive `CONDUCTOR_HOME` (`conductor-paths.js`
|
|
37
|
+
* resolves it from the environment only), and the daemon PID lock lives under
|
|
38
|
+
* `CONDUCTOR_WS`, not under `CONDUCTOR_HOME`. Two daemons sharing a workspace
|
|
39
|
+
* root contend for one `daemon.pid`, and `--force` on the second one will
|
|
40
|
+
* SIGKILL the first -- across accounts. Verified live before this was written.
|
|
41
|
+
*/
|
|
42
|
+
export function resolveGuestPaths(shareId, workspaceRoot, homeDir = os.homedir()) {
|
|
43
|
+
const home = homeDir || "/tmp";
|
|
44
|
+
const conductorHome = path.join(home, ".conductor-guests", shareId);
|
|
45
|
+
const ws = workspaceRoot
|
|
46
|
+
? path.join(expandHome(workspaceRoot, home), "ws")
|
|
47
|
+
: path.join(home, "conductor-guests", shareId, "ws");
|
|
48
|
+
return {
|
|
49
|
+
conductorHome,
|
|
50
|
+
configDir: path.join(home, ".conductor", "shares", shareId),
|
|
51
|
+
configPath: path.join(home, ".conductor", "shares", shareId, "config.yaml"),
|
|
52
|
+
workspace: ws,
|
|
53
|
+
fireStateDir: path.join(conductorHome, "state"),
|
|
54
|
+
// The root a guest's project paths must stay under. Its parent, not `ws`,
|
|
55
|
+
// so the guest can also see sibling dirs the owner deliberately placed.
|
|
56
|
+
guestRoot: workspaceRoot
|
|
57
|
+
? expandHome(workspaceRoot, home)
|
|
58
|
+
: path.join(home, "conductor-guests", shareId),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const yamlQuote = (value) => `"${String(value).replace(/(["\\])/g, "\\$1")}"`;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The guest's config.yaml.
|
|
66
|
+
*
|
|
67
|
+
* Note what is NOT here: `envs:`. Leaving it out is the whole point of the
|
|
68
|
+
* feature -- the guest inherits the machine's already-logged-in AI CLIs
|
|
69
|
+
* (`~/.claude`, `~/.codex`). Setting per-instance `CODEX_HOME` /
|
|
70
|
+
* `CLAUDE_CONFIG_DIR` here would isolate credentials and defeat the purpose.
|
|
71
|
+
*/
|
|
72
|
+
export function buildGuestConfigYaml({
|
|
73
|
+
agentToken,
|
|
74
|
+
backendUrl,
|
|
75
|
+
guestHost,
|
|
76
|
+
workspace,
|
|
77
|
+
allowCliList,
|
|
78
|
+
}) {
|
|
79
|
+
const lines = [
|
|
80
|
+
"# Generated by conductor daemon (RFC 0035 daemon sharing). Do not edit.",
|
|
81
|
+
"# Regenerated from the backend on every supervisor reconcile.",
|
|
82
|
+
`agent_token: ${yamlQuote(agentToken)}`,
|
|
83
|
+
`backend_url: ${yamlQuote(backendUrl)}`,
|
|
84
|
+
`daemon_name: ${yamlQuote(guestHost)}`,
|
|
85
|
+
`workspace: ${yamlQuote(workspace)}`,
|
|
86
|
+
"conductor_guest: true",
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
// Inherited from the owner, and load-bearing: `conductor-fire` refuses a
|
|
90
|
+
// backend that has no `allow_cli_list` entry unless it is one of the
|
|
91
|
+
// command-optional SDK backends (copilot, dsh). Omitting this leaves a guest
|
|
92
|
+
// able to start a task and then die with
|
|
93
|
+
// `Unsupported backend "claude". Supported backends: copilot, dsh.` --
|
|
94
|
+
// i.e. unable to run the very CLIs the machine was shared for.
|
|
95
|
+
//
|
|
96
|
+
// Copying the owner's list is the right semantics, not a shortcut: the guest
|
|
97
|
+
// runs on the owner's machine against the owner's installed, already
|
|
98
|
+
// logged-in tools, so the command lines that work for the owner are exactly
|
|
99
|
+
// the ones that work here.
|
|
100
|
+
const entries = Object.entries(allowCliList || {}).filter(
|
|
101
|
+
([backend, command]) => backend && typeof command === "string" && command.trim(),
|
|
102
|
+
);
|
|
103
|
+
if (entries.length > 0) {
|
|
104
|
+
lines.push("allow_cli_list:");
|
|
105
|
+
for (const [backend, command] of entries) {
|
|
106
|
+
lines.push(` ${backend}: ${yamlQuote(command.trim())}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
lines.push("");
|
|
111
|
+
return lines.join("\n");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Environment for the guest child.
|
|
116
|
+
*
|
|
117
|
+
* `CONDUCTOR_AGENT_TOKEN` is deliberately deleted rather than overwritten: the
|
|
118
|
+
* child inherits the owner's environment, and the daemon only ignores an
|
|
119
|
+
* inherited token because `--config-file` was passed explicitly
|
|
120
|
+
* (`daemon.js` `allowEnvConfigOverrides`). Removing it means a future change to
|
|
121
|
+
* that precedence cannot silently make the guest run as the owner.
|
|
122
|
+
*/
|
|
123
|
+
export function buildGuestEnv(baseEnv, paths, extra = {}) {
|
|
124
|
+
const env = { ...baseEnv };
|
|
125
|
+
delete env.CONDUCTOR_AGENT_TOKEN;
|
|
126
|
+
delete env.CONDUCTOR_BACKEND_URL;
|
|
127
|
+
delete env.CONDUCTOR_WS_URL;
|
|
128
|
+
delete env.CONDUCTOR_BACKEND_WS_URL;
|
|
129
|
+
delete env.CONDUCTOR_DAEMON_NAME;
|
|
130
|
+
delete env.CONDUCTOR_TASK_ID;
|
|
131
|
+
delete env.CONDUCTOR_PROJECT_ID;
|
|
132
|
+
return {
|
|
133
|
+
...env,
|
|
134
|
+
CONDUCTOR_HOME: paths.conductorHome,
|
|
135
|
+
CONDUCTOR_WS: paths.workspace,
|
|
136
|
+
CONDUCTOR_FIRE_STATE_DIR: paths.fireStateDir,
|
|
137
|
+
CONDUCTOR_GUEST_SHARE_ID: extra.shareId || "",
|
|
138
|
+
// Only propagated when the owner actually picked a workspace root. An
|
|
139
|
+
// auto-generated one must not turn into a confinement the owner never
|
|
140
|
+
// chose -- see the note on GUEST_ROOT in daemon.js.
|
|
141
|
+
...(extra.explicitRoot ? { CONDUCTOR_GUEST_ROOT: paths.guestRoot } : {}),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function writeGuestConfig(paths, contents, deps = {}) {
|
|
146
|
+
const mkdirSync = deps.mkdirSync || fs.mkdirSync;
|
|
147
|
+
const writeFileSync = deps.writeFileSync || fs.writeFileSync;
|
|
148
|
+
mkdirSync(paths.configDir, { recursive: true, mode: 0o700 });
|
|
149
|
+
mkdirSync(paths.conductorHome, { recursive: true, mode: 0o700 });
|
|
150
|
+
mkdirSync(paths.workspace, { recursive: true });
|
|
151
|
+
// 0600: the file holds the grantee's credential in plaintext.
|
|
152
|
+
writeFileSync(paths.configPath, contents, { encoding: "utf8", mode: 0o600 });
|
|
153
|
+
return paths.configPath;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Decide what the supervisor should do, given the desired shares from the
|
|
158
|
+
* backend and what is currently running. Pure, so the interesting cases are
|
|
159
|
+
* testable without processes.
|
|
160
|
+
*/
|
|
161
|
+
export function reconcileGuests(desiredShares, runningIds, max = MAX_GUEST_DAEMONS) {
|
|
162
|
+
const usable = (desiredShares || []).filter(
|
|
163
|
+
(share) => share && share.id && share.guestHost && share.agentToken,
|
|
164
|
+
);
|
|
165
|
+
const start = [];
|
|
166
|
+
const keep = [];
|
|
167
|
+
for (const share of usable) {
|
|
168
|
+
if (runningIds.has(share.id)) keep.push(share.id);
|
|
169
|
+
else if (keep.length + start.length < max) start.push(share);
|
|
170
|
+
}
|
|
171
|
+
const desiredIds = new Set(usable.map((share) => share.id));
|
|
172
|
+
// Anything running that the backend no longer lists as active: revoked,
|
|
173
|
+
// or the share moved to another daemon.
|
|
174
|
+
const stop = [...runningIds].filter((id) => !desiredIds.has(id));
|
|
175
|
+
const skipped = usable.length > max ? usable.length - max : 0;
|
|
176
|
+
return { start, stop, keep, skipped };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function nextRestartDelayMs(failureCount) {
|
|
180
|
+
const exponent = Math.max(0, failureCount - 1);
|
|
181
|
+
return Math.min(GUEST_RESTART_BASE_MS * 2 ** exponent, GUEST_RESTART_MAX_MS);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* A guest daemon offers the SAME capabilities as any other daemon.
|
|
186
|
+
*
|
|
187
|
+
* The dividing line is not "can the grantee execute code here" -- they always
|
|
188
|
+
* can, because an AI task is an arbitrary prompt handed to a CLI with a shell,
|
|
189
|
+
* and `pty_task`'s `custom` entrypoint takes caller-supplied command/args/cwd/env
|
|
190
|
+
* (`daemon.js` `entrypointType === "custom"`). Blocking the scriptable door
|
|
191
|
+
* while leaving the interactive one open stops no attacker and only breaks
|
|
192
|
+
* legitimate use.
|
|
193
|
+
*
|
|
194
|
+
* `remote_exec` was on this list for exactly that bad reason. RFC 0034 had
|
|
195
|
+
* already made the argument: it "adds no reach" beyond `create_pty_task`,
|
|
196
|
+
* except on a host whose node-pty probe failed -- which is not the case a
|
|
197
|
+
* blanket guest rule addresses.
|
|
198
|
+
*
|
|
199
|
+
* What a guest genuinely must not do is mutate state the OWNER depends on.
|
|
200
|
+
* That is a different axis, handled per-action below, not by withholding
|
|
201
|
+
* capabilities.
|
|
202
|
+
*/
|
|
203
|
+
export const GUEST_BLOCKED_CAPABILITIES = new Set();
|
|
204
|
+
|
|
205
|
+
export function filterGuestCapabilities(capabilities) {
|
|
206
|
+
return (capabilities || []).filter((cap) => !GUEST_BLOCKED_CAPABILITIES.has(cap));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The two actions a guest must still refuse. Neither restricts what the grantee
|
|
211
|
+
* can do with their own account -- both would reconfigure the owner's machine:
|
|
212
|
+
* - a versioned restart runs a global `npm install -g`, swapping the CLI
|
|
213
|
+
* binary out from under the owner's own daemon and every one of their fires;
|
|
214
|
+
* - `switch_account` renames over `~/.codex/auth.json`, changing the owner's
|
|
215
|
+
* active Codex identity everywhere on the box.
|
|
216
|
+
* Reads (`status`, `quota`, `list_accounts`) stay allowed: the grantee needs to
|
|
217
|
+
* see how much quota is left before running something heavy.
|
|
218
|
+
*/
|
|
219
|
+
export function isGuestRestartAllowed(payload) {
|
|
220
|
+
const target = payload && typeof payload.target_version === "string"
|
|
221
|
+
? payload.target_version.trim()
|
|
222
|
+
: "";
|
|
223
|
+
return !target;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function isGuestAiManagerActionAllowed(action) {
|
|
227
|
+
return action !== "switch_account";
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Confine a guest's project paths to its root.
|
|
232
|
+
*
|
|
233
|
+
* `validate_project_path` resolves any absolute path the caller sends and will
|
|
234
|
+
* `mkdirSync(recursive)` when asked; `get_project_agents` reads arbitrary
|
|
235
|
+
* paths too. Neither is bounded by the workspace today. This is a
|
|
236
|
+
* misuse-prevention boundary, not a security boundary -- the guest's AI agent
|
|
237
|
+
* runs a shell and can reach anything the OS user can.
|
|
238
|
+
*/
|
|
239
|
+
export function isPathInsideGuestRoot(candidate, guestRoot) {
|
|
240
|
+
if (!guestRoot) return true;
|
|
241
|
+
const resolvedRoot = path.resolve(guestRoot);
|
|
242
|
+
const resolved = path.resolve(candidate || "");
|
|
243
|
+
if (resolved === resolvedRoot) return true;
|
|
244
|
+
return resolved.startsWith(`${resolvedRoot}${path.sep}`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Guests are spawned as ordinary children, which means a `SIGKILL` or a hard
|
|
249
|
+
* crash of the host daemon leaves them running with live credentials, serving
|
|
250
|
+
* a share the owner believes is stopped. `detached: false` does not help --
|
|
251
|
+
* on POSIX it only shares the process group; nothing reaps the child.
|
|
252
|
+
*
|
|
253
|
+
* So the guest watches its own parent instead. `process.ppid` becomes 1 (or
|
|
254
|
+
* the reaper) once the host daemon is gone.
|
|
255
|
+
*/
|
|
256
|
+
export function startOrphanWatchdog(options = {}) {
|
|
257
|
+
const intervalMs = options.intervalMs || 15_000;
|
|
258
|
+
const initialPpid = options.ppid ?? process.ppid;
|
|
259
|
+
const onOrphaned = options.onOrphaned || (() => process.exit(0));
|
|
260
|
+
const readPpid = options.readPpid || (() => process.ppid);
|
|
261
|
+
|
|
262
|
+
const timer = setInterval(() => {
|
|
263
|
+
const current = readPpid();
|
|
264
|
+
if (current !== initialPpid) onOrphaned(current, initialPpid);
|
|
265
|
+
}, intervalMs);
|
|
266
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
267
|
+
return () => clearInterval(timer);
|
|
268
|
+
}
|
package/src/runtime-backends.js
CHANGED
|
@@ -366,6 +366,46 @@ function readConfigEnvValue(configFilePath, key) {
|
|
|
366
366
|
}
|
|
367
367
|
}
|
|
368
368
|
|
|
369
|
+
// Read the top-level `disable_built_in_cli_list` config key: the mirror of
|
|
370
|
+
// `allow_cli_list` for the command-optional built-in backends (copilot, dsh)
|
|
371
|
+
// that are advertised WITHOUT any allow_cli_list entry. Listing a name here
|
|
372
|
+
// suppresses that automatic advertising so an operator can opt out of a
|
|
373
|
+
// built-in they don't want. Scoped to command-optional built-ins on purpose:
|
|
374
|
+
// a stray external or PATH-CLI name (e.g. "codex") is a documented no-op,
|
|
375
|
+
// since those never auto-advertise in the first place.
|
|
376
|
+
function readDisabledBuiltInBackends(configFilePath) {
|
|
377
|
+
const targetPath = resolveConductorConfigPath(configFilePath);
|
|
378
|
+
try {
|
|
379
|
+
if (!targetPath || !fs.existsSync(targetPath)) {
|
|
380
|
+
return new Set();
|
|
381
|
+
}
|
|
382
|
+
const parsed = yaml.load(fs.readFileSync(targetPath, "utf8"));
|
|
383
|
+
const raw = parsed?.disable_built_in_cli_list;
|
|
384
|
+
if (!Array.isArray(raw)) {
|
|
385
|
+
return new Set();
|
|
386
|
+
}
|
|
387
|
+
return new Set(
|
|
388
|
+
raw
|
|
389
|
+
.map((value) => normalizeRuntimeBackendName(value))
|
|
390
|
+
.filter((value) => value && isCommandOptionalBuiltInRuntimeBackend(value)),
|
|
391
|
+
);
|
|
392
|
+
} catch {
|
|
393
|
+
return new Set();
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Reuse a caller-provided disabled set when present (so a hot loop reads the
|
|
398
|
+
// config file once), else read it now. Keeps every call site one line.
|
|
399
|
+
function resolveDisabledBuiltIns(options = {}) {
|
|
400
|
+
return options.disabledBuiltIns instanceof Set
|
|
401
|
+
? options.disabledBuiltIns
|
|
402
|
+
: readDisabledBuiltInBackends(options.configFilePath);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export function isDisabledBuiltInRuntimeBackend(backend, options = {}) {
|
|
406
|
+
return resolveDisabledBuiltIns(options).has(normalizeRuntimeBackendName(backend));
|
|
407
|
+
}
|
|
408
|
+
|
|
369
409
|
function resolveProviderModulePaths(options = {}) {
|
|
370
410
|
return [
|
|
371
411
|
...listProviderModulePaths(process.env.AISDK_PROVIDER_PATH),
|
|
@@ -577,6 +617,21 @@ export async function filterRuntimeSupportedAllowCliList(allowCliList, options =
|
|
|
577
617
|
}
|
|
578
618
|
|
|
579
619
|
export async function resolveConfiguredRuntimeBackend(backend, allowCliList, options = {}) {
|
|
620
|
+
const resolved = await resolveConfiguredRuntimeBackendInner(backend, allowCliList, options);
|
|
621
|
+
if (!resolved?.runtimeBackend) {
|
|
622
|
+
return resolved;
|
|
623
|
+
}
|
|
624
|
+
// A disabled command-optional built-in (copilot/dsh via
|
|
625
|
+
// `disable_built_in_cli_list`) is never resolvable, whether it reached here
|
|
626
|
+
// through an explicit allow_cli_list entry or the command-optional fallback.
|
|
627
|
+
const disabled = resolveDisabledBuiltIns(options);
|
|
628
|
+
if (disabled.has(resolved.runtimeBackend) || disabled.has(resolved.requestedBackend)) {
|
|
629
|
+
return null;
|
|
630
|
+
}
|
|
631
|
+
return resolved;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async function resolveConfiguredRuntimeBackendInner(backend, allowCliList, options = {}) {
|
|
580
635
|
const normalizedBackend = normalizeRuntimeBackendName(backend);
|
|
581
636
|
if (!normalizedBackend || LEGACY_RUNTIME_BACKEND_ALIASES.has(normalizedBackend)) {
|
|
582
637
|
return null;
|
|
@@ -634,6 +689,9 @@ export async function listAdvertisedBackends(allowCliList, options = {}) {
|
|
|
634
689
|
)
|
|
635
690
|
: {};
|
|
636
691
|
const configuredBackends = Object.keys(filteredAllowCliList);
|
|
692
|
+
// Read the disable list once and thread it through every per-backend
|
|
693
|
+
// resolve below, so this whole pass touches the config file a single time.
|
|
694
|
+
const resolveOptions = { ...options, disabledBuiltIns: readDisabledBuiltInBackends(options.configFilePath) };
|
|
637
695
|
let runtimeBackends = [...BUILT_IN_RUNTIME_BACKENDS];
|
|
638
696
|
let discoveryError = null;
|
|
639
697
|
try {
|
|
@@ -647,7 +705,7 @@ export async function listAdvertisedBackends(allowCliList, options = {}) {
|
|
|
647
705
|
|
|
648
706
|
for (const backend of configuredBackends) {
|
|
649
707
|
try {
|
|
650
|
-
const configuredBackend = await resolveConfiguredRuntimeBackend(backend, filteredAllowCliList,
|
|
708
|
+
const configuredBackend = await resolveConfiguredRuntimeBackend(backend, filteredAllowCliList, resolveOptions);
|
|
651
709
|
if (!configuredBackend?.runtimeBackend) {
|
|
652
710
|
continue;
|
|
653
711
|
}
|
|
@@ -683,7 +741,10 @@ export async function listAdvertisedBackends(allowCliList, options = {}) {
|
|
|
683
741
|
}
|
|
684
742
|
|
|
685
743
|
const commandOptionalBuiltIns = BUILT_IN_RUNTIME_BACKENDS.filter(
|
|
686
|
-
(backend) =>
|
|
744
|
+
(backend) =>
|
|
745
|
+
isCommandOptionalBuiltInRuntimeBackend(backend) &&
|
|
746
|
+
!runtimeBackendMap[backend] &&
|
|
747
|
+
!resolveOptions.disabledBuiltIns.has(backend),
|
|
687
748
|
);
|
|
688
749
|
for (const backend of commandOptionalBuiltIns) {
|
|
689
750
|
runtimeBackendMap[backend] = backend;
|
package/src/version-check.js
CHANGED
|
@@ -13,6 +13,7 @@ export const DEFAULT_HOMEBREW_FORMULA = "lovemoon-ai/tap/conductor";
|
|
|
13
13
|
const DEFAULT_UPDATE_WINDOW = { startMinutes: 120, endMinutes: 240 };
|
|
14
14
|
const REQUEST_TIMEOUT_MS = 10_000;
|
|
15
15
|
const INSTALL_METHOD_FILENAME = ".install-method";
|
|
16
|
+
const GLOBAL_PACKAGE_MARKER = `${path.sep}lib${path.sep}node_modules${path.sep}`;
|
|
16
17
|
|
|
17
18
|
function resolveTimeoutMs(value) {
|
|
18
19
|
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
@@ -139,6 +140,28 @@ export function resolveInstallMethod(options = {}) {
|
|
|
139
140
|
}
|
|
140
141
|
}
|
|
141
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Derive the npm global prefix that the currently running package was installed into.
|
|
145
|
+
*
|
|
146
|
+
* A global install always lands at `<prefix>/lib/node_modules/<package>`, so the prefix is
|
|
147
|
+
* whatever precedes that marker. Returns `null` for layouts that are not a global install
|
|
148
|
+
* (git checkout, project-local `node_modules`), where the caller should leave npm's own
|
|
149
|
+
* prefix resolution alone.
|
|
150
|
+
*/
|
|
151
|
+
export function resolveGlobalInstallPrefix(packageRoot) {
|
|
152
|
+
if (typeof packageRoot !== "string" || !packageRoot.trim()) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const normalized = path.resolve(packageRoot);
|
|
157
|
+
const markerIndex = normalized.lastIndexOf(GLOBAL_PACKAGE_MARKER);
|
|
158
|
+
if (markerIndex <= 0) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return normalized.slice(0, markerIndex);
|
|
163
|
+
}
|
|
164
|
+
|
|
142
165
|
export function buildUpgradeCommand(options = {}) {
|
|
143
166
|
const installMethod = resolveInstallMethod(options);
|
|
144
167
|
if (installMethod === "homebrew") {
|