magus-cli 7.9.0 → 7.10.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/package.json +4 -4
- package/src/__tests__/blind/plugin-deps/doctor.test.ts +1 -0
- package/src/__tests__/blind/review-followups/bun-install-path.test.ts +57 -0
- package/src/__tests__/blind/review-followups/d1-empty-lock.test.ts +94 -0
- package/src/__tests__/blind/review-followups/d2-disappearing-lock.test.ts +90 -0
- package/src/__tests__/blind/review-followups/d3-release-temp-cleanup.test.ts +83 -0
- package/src/__tests__/blind/review-followups/d4-tool-stdout.test.ts +152 -0
- package/src/__tests__/blind/review-followups/installer-redirect.test.ts +74 -0
- package/src/__tests__/blind/review-followups/ledger-permissions.test.ts +78 -0
- package/src/__tests__/blind/review-followups/ledger-pruning.test.ts +79 -0
- package/src/__tests__/blind/review-followups/path-advice.test.ts +46 -0
- package/src/__tests__/blind/review-followups/stats-requires.test.ts +16 -0
- package/src/__tests__/deps-plan.test.ts +3 -2
- package/src/__tests__/plugin-mcp-source-shapes.test.ts +250 -0
- package/src/__tests__/worktree-profiles.test.ts +583 -0
- package/src/cli/doctor.ts +24 -2
- package/src/cli/update-view.ts +73 -0
- package/src/cli/update.ts +21 -0
- package/src/cli/worktrees.ts +32 -0
- package/src/services/deps-plan.ts +273 -81
- package/src/services/git-worktree.ts +41 -0
- package/src/services/plugin-mcp-config.ts +3 -1
- package/src/services/worktree-profiles.ts +181 -0
- package/src/shared/plugin/hooks/bun-missing.sh +35 -10
- package/src/shared/plugin/hooks/deps-check.ts +25 -9
- package/src/shared/plugin/hooks/lib/requires-probe.ts +101 -5
- package/src/ui/App.tsx +32 -1
- package/src/ui/adapters/startupReports.ts +22 -2
- package/src/utils/run.ts +21 -1
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The other working trees of this repository that carry a magus manifest but
|
|
3
|
+
* have no profile in effect — and setting them up.
|
|
4
|
+
*
|
|
5
|
+
* A profiled project's generated files are git-ignored, so git never gives
|
|
6
|
+
* them to a new worktree. Claude Code treats the folder it starts in as the
|
|
7
|
+
* project and reads only that folder's settings, so a session started in such
|
|
8
|
+
* a worktree loads only the plugins turned on at USER scope. magus run in that
|
|
9
|
+
* worktree would fix it (`autoActivate`), but nothing tells the user to.
|
|
10
|
+
*
|
|
11
|
+
* So magus, run anywhere in the repository, looks at the other working trees
|
|
12
|
+
* too. Detection and apply are kept apart, and every surface — `magus update`,
|
|
13
|
+
* `magus doctor` and the TUI — uses these two functions; none of them decides
|
|
14
|
+
* anything of its own beyond how to show the result.
|
|
15
|
+
*
|
|
16
|
+
* Detection writes nothing, anywhere: it is `detectProfileStartupNeed` per
|
|
17
|
+
* working tree, which is detection only. Apply is the activation the normal
|
|
18
|
+
* gate runs for a fresh clone, against THAT worktree's own manifest — nothing
|
|
19
|
+
* is ever copied between worktrees.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { realpath, stat } from "node:fs/promises";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import { listWorkingTrees } from "./git-worktree.js";
|
|
25
|
+
import { getManifestPath, readManifest } from "./manifest.js";
|
|
26
|
+
import {
|
|
27
|
+
type ProfileStartupState,
|
|
28
|
+
detectProfileStartupNeed,
|
|
29
|
+
} from "./profile-startup.js";
|
|
30
|
+
import { applyProfileChoice } from "./profile-state.js";
|
|
31
|
+
import { SettingsParseError } from "./settings-file.js";
|
|
32
|
+
|
|
33
|
+
export interface UnsetWorktree {
|
|
34
|
+
/** Absolute path of that working tree. */
|
|
35
|
+
path: string;
|
|
36
|
+
/** Its folder name, how the user knows it. */
|
|
37
|
+
name: string;
|
|
38
|
+
/** From `detectProfileStartupNeed(path)`; never "ok". */
|
|
39
|
+
state: ProfileStartupState;
|
|
40
|
+
declaredDefault: string | null;
|
|
41
|
+
/** The repo declares a profile and nothing would be replaced. */
|
|
42
|
+
autoActivate: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type WorktreeSetupResult =
|
|
46
|
+
| { path: string; name: string; ok: true; profileId: string }
|
|
47
|
+
| { path: string; name: string; ok: false; reason: string };
|
|
48
|
+
|
|
49
|
+
/** The canonical spelling of a path, so `/tmp` and `/private/tmp` compare equal. */
|
|
50
|
+
async function canonical(p: string): Promise<string> {
|
|
51
|
+
try {
|
|
52
|
+
return await realpath(p);
|
|
53
|
+
} catch {
|
|
54
|
+
return path.resolve(p);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function isDirectory(p: string): Promise<boolean> {
|
|
59
|
+
try {
|
|
60
|
+
return (await stat(p)).isDirectory();
|
|
61
|
+
} catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function isFile(p: string): Promise<boolean> {
|
|
67
|
+
try {
|
|
68
|
+
return (await stat(p)).isFile();
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Read-only. The other working trees of `projectPath`'s repository that need
|
|
76
|
+
* setup, sorted by name.
|
|
77
|
+
*
|
|
78
|
+
* Left out: `projectPath`'s own working tree (the normal gate handles it), a
|
|
79
|
+
* working tree whose folder is gone, one with no manifest (not a magus
|
|
80
|
+
* project on that branch), and one already on a profile. Not a repository, or
|
|
81
|
+
* git failing, is `[]`.
|
|
82
|
+
*/
|
|
83
|
+
export async function findUnsetWorktrees(
|
|
84
|
+
projectPath: string,
|
|
85
|
+
): Promise<UnsetWorktree[]> {
|
|
86
|
+
let listing: Awaited<ReturnType<typeof listWorkingTrees>>;
|
|
87
|
+
try {
|
|
88
|
+
listing = await listWorkingTrees(projectPath);
|
|
89
|
+
} catch {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
if (listing === null) return [];
|
|
93
|
+
const self = await canonical(listing.self);
|
|
94
|
+
|
|
95
|
+
const found = await Promise.all(
|
|
96
|
+
listing.trees.map(async (tree): Promise<UnsetWorktree | null> => {
|
|
97
|
+
if (tree.prunable || !(await isDirectory(tree.path))) return null;
|
|
98
|
+
const at = await canonical(tree.path);
|
|
99
|
+
if (at === self) return null;
|
|
100
|
+
if (!(await isFile(getManifestPath(at)))) return null;
|
|
101
|
+
try {
|
|
102
|
+
const need = await detectProfileStartupNeed(at);
|
|
103
|
+
if (need.state === "ok") return null;
|
|
104
|
+
return {
|
|
105
|
+
path: at,
|
|
106
|
+
name: path.basename(at),
|
|
107
|
+
state: need.state,
|
|
108
|
+
declaredDefault: need.declaredDefault,
|
|
109
|
+
autoActivate: need.autoActivate && need.declaredDefault !== null,
|
|
110
|
+
};
|
|
111
|
+
} catch {
|
|
112
|
+
// One working tree magus cannot read says nothing about the others.
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const seen = new Set<string>();
|
|
119
|
+
return found
|
|
120
|
+
.filter((w): w is UnsetWorktree => {
|
|
121
|
+
if (w === null || seen.has(w.path)) return false;
|
|
122
|
+
seen.add(w.path);
|
|
123
|
+
return true;
|
|
124
|
+
})
|
|
125
|
+
.sort((a, b) =>
|
|
126
|
+
a.name === b.name
|
|
127
|
+
? a.path.localeCompare(b.path)
|
|
128
|
+
: a.name.localeCompare(b.name),
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Why an activation failed, in one line that never ends on an absolute path. */
|
|
133
|
+
function failureReason(error: unknown, worktree: string): string {
|
|
134
|
+
if (error instanceof SettingsParseError) {
|
|
135
|
+
return `${path.relative(worktree, error.path)} ${error.problem}`;
|
|
136
|
+
}
|
|
137
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
138
|
+
return message.split("\n")[0] ?? message;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Activate the declared profile in each worktree whose `autoActivate` is true,
|
|
143
|
+
* one after another. Entries that need a choice are skipped and not returned;
|
|
144
|
+
* one failure does not stop the others.
|
|
145
|
+
*/
|
|
146
|
+
export async function setUpWorktrees(
|
|
147
|
+
worktrees: UnsetWorktree[],
|
|
148
|
+
): Promise<WorktreeSetupResult[]> {
|
|
149
|
+
const results: WorktreeSetupResult[] = [];
|
|
150
|
+
for (const wt of worktrees) {
|
|
151
|
+
if (!wt.autoActivate || wt.declaredDefault === null) continue;
|
|
152
|
+
try {
|
|
153
|
+
// That worktree's own manifest, read now: its branch may declare
|
|
154
|
+
// something other than this one's.
|
|
155
|
+
const manifest = await readManifest(wt.path);
|
|
156
|
+
const result = await applyProfileChoice(
|
|
157
|
+
{ kind: "activate", id: wt.declaredDefault },
|
|
158
|
+
manifest,
|
|
159
|
+
wt.path,
|
|
160
|
+
// Nothing to announce: `autoActivate` means nothing of the user's
|
|
161
|
+
// was there to move aside, and printing here would land inside
|
|
162
|
+
// whichever surface called this.
|
|
163
|
+
{ onDisplace: () => {} },
|
|
164
|
+
);
|
|
165
|
+
results.push({
|
|
166
|
+
path: wt.path,
|
|
167
|
+
name: wt.name,
|
|
168
|
+
ok: true,
|
|
169
|
+
profileId: result.profileId,
|
|
170
|
+
});
|
|
171
|
+
} catch (error) {
|
|
172
|
+
results.push({
|
|
173
|
+
path: wt.path,
|
|
174
|
+
name: wt.name,
|
|
175
|
+
ok: false,
|
|
176
|
+
reason: failureReason(error, wt.path),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return results;
|
|
181
|
+
}
|
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
# runs this only when Bun is not on the PATH Claude Code started with, which is
|
|
4
4
|
# the one dependency the TypeScript check cannot report itself. $1 is the label.
|
|
5
5
|
#
|
|
6
|
-
# Writes the same ledger as deps-check.ts: seen/<label> = sh, then claims
|
|
7
|
-
# Bun requirement's dedupe key, so a session prints one Bun line however
|
|
8
|
-
# plugins carry this file.
|
|
9
|
-
#
|
|
10
|
-
#
|
|
6
|
+
# Writes the same ledger as deps-check.ts, 0700: seen/<label> = sh, then claims
|
|
7
|
+
# the Bun requirement's dedupe key, so a session prints one Bun line however
|
|
8
|
+
# many plugins carry this file. A bun in a place an installer or Homebrew puts
|
|
9
|
+
# one is reported as off the PATH, naming that place; only when there is none
|
|
10
|
+
# is Bun called missing. scripts/check-hook-copies.ts recomputes the key, the
|
|
11
|
+
# install line, the Bun prefix and the PATH line formats from
|
|
12
|
+
# lib/requires-probe.ts and fails when this file disagrees. Exit 0 always.
|
|
11
13
|
|
|
12
14
|
label=$1
|
|
13
15
|
case $label in
|
|
@@ -17,6 +19,7 @@ key=bun-3182ab42b0f1
|
|
|
17
19
|
|
|
18
20
|
sid=$(cat 2>/dev/null | tr -d '\n' | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([A-Za-z0-9_-]*\)".*/\1/p')
|
|
19
21
|
if [ -n "$sid" ]; then
|
|
22
|
+
umask 077
|
|
20
23
|
base=${TMPDIR:-/tmp}
|
|
21
24
|
dir=$base/magus-deps-$sid
|
|
22
25
|
if mkdir -p "$dir/seen" "$dir/claimed" 2>/dev/null; then
|
|
@@ -26,13 +29,35 @@ if [ -n "$sid" ]; then
|
|
|
26
29
|
fi
|
|
27
30
|
fi
|
|
28
31
|
|
|
29
|
-
|
|
32
|
+
# The first bun where an installer or Homebrew leaves one. A path that would
|
|
33
|
+
# break the JSON line is skipped.
|
|
34
|
+
found=
|
|
35
|
+
for d in ${BUN_INSTALL:+"$BUN_INSTALL/bin"} "$HOME/.bun/bin" /opt/homebrew/bin /usr/local/bin /home/linuxbrew/.linuxbrew/bin; do
|
|
36
|
+
case $d in
|
|
37
|
+
*\"* | *\\* | *[[:cntrl:]]*) continue ;;
|
|
38
|
+
esac
|
|
39
|
+
if [ -f "$d/bun" ] && [ -x "$d/bun" ]; then
|
|
40
|
+
found=$d
|
|
41
|
+
break
|
|
42
|
+
fi
|
|
43
|
+
done
|
|
44
|
+
|
|
45
|
+
if [ -n "$found" ]; then
|
|
46
|
+
# Under $HOME: shown as ~/…, and $HOME in the PATH line.
|
|
47
|
+
rel=${found#"$HOME"/}
|
|
48
|
+
if [ -n "$HOME" ] && [ "$HOME" != / ] && [ "$rel" != "$found" ]; then
|
|
49
|
+
shown="~/$rel" pathdir="\$HOME/$rel"
|
|
50
|
+
else
|
|
51
|
+
shown=$found pathdir=$found
|
|
52
|
+
fi
|
|
30
53
|
case ${SHELL:-} in
|
|
31
|
-
*zsh) rc='~/.zshrc'
|
|
32
|
-
*fish) rc='~/.config/fish/config.fish'
|
|
33
|
-
*) rc='~/.bashrc'
|
|
54
|
+
*zsh) rc='~/.zshrc' fmt='export PATH="%s:$PATH"' ;;
|
|
55
|
+
*fish) rc='~/.config/fish/config.fish' fmt='fish_add_path %s' ;;
|
|
56
|
+
*) rc='~/.bashrc' fmt='export PATH="%s:$PATH"' ;;
|
|
34
57
|
esac
|
|
35
|
-
|
|
58
|
+
# shellcheck disable=SC2059 # fmt is one of the three fixed formats above
|
|
59
|
+
line=$(printf "$fmt" "$pathdir" | sed 's/["\\]/\\&/g')
|
|
60
|
+
printf '{"systemMessage":"magus-deps: Bun is installed at %s but is not on the PATH Claude Code started with, so magus hooks and MCP servers cannot run (noticed by %s).\\n Add %s to %s, then restart Claude Code from a new terminal."}\n' "$shown" "$label" "$line" "$rc"
|
|
36
61
|
else
|
|
37
62
|
printf '{"systemMessage":"magus-deps: Bun is missing — magus hooks and MCP servers need it (noticed by %s).\\n Install: curl -fsSL https://bun.sh/install | bash, then restart Claude Code from a new terminal."}\n' "$label"
|
|
38
63
|
fi
|
|
@@ -17,7 +17,9 @@
|
|
|
17
17
|
* atomic `mkdir claimed/<dedupeKey>`. A failed mkdir means another plugin's copy
|
|
18
18
|
* reports that dep this session, so one dep is one banner line however many
|
|
19
19
|
* plugins need it. An unreadable session id means no ledger and no claims:
|
|
20
|
-
* more banners, never fewer. Ledgers older than a day are
|
|
20
|
+
* more banners, never fewer. The ledger is 0700. Ledgers older than a day are
|
|
21
|
+
* pruned on write, each on its own, so one this user cannot remove skips only
|
|
22
|
+
* itself.
|
|
21
23
|
*
|
|
22
24
|
* Exit 0 always. A watchdog keeps the hook inside its registration timeout.
|
|
23
25
|
*/
|
|
@@ -94,28 +96,42 @@ async function readHookInput(): Promise<HookInput> {
|
|
|
94
96
|
}
|
|
95
97
|
}
|
|
96
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Remove ledgers older than a day. Each is tried on its own: a shared `/tmp`
|
|
101
|
+
* holds other users' ledgers, which this user cannot remove, and one of those
|
|
102
|
+
* must not stop this user's own from being pruned.
|
|
103
|
+
*/
|
|
97
104
|
function pruneOldLedgers(base: string): void {
|
|
105
|
+
let entries: string[];
|
|
98
106
|
try {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
107
|
+
entries = readdirSync(base);
|
|
108
|
+
} catch {
|
|
109
|
+
return; // Pruning is housekeeping; a failure costs nothing now.
|
|
110
|
+
}
|
|
111
|
+
for (const entry of entries) {
|
|
112
|
+
if (!entry.startsWith("magus-deps-")) continue;
|
|
113
|
+
const dir = join(base, entry);
|
|
114
|
+
try {
|
|
102
115
|
if (Date.now() - statSync(dir).mtimeMs > LEDGER_MAX_AGE_MS) {
|
|
103
116
|
rmSync(dir, { recursive: true, force: true });
|
|
104
117
|
}
|
|
118
|
+
} catch {
|
|
119
|
+
// Another user's, or already gone: the next entry is still pruned.
|
|
105
120
|
}
|
|
106
|
-
} catch {
|
|
107
|
-
// Pruning is housekeeping; a failure costs nothing now.
|
|
108
121
|
}
|
|
109
122
|
}
|
|
110
123
|
|
|
111
|
-
/**
|
|
124
|
+
/**
|
|
125
|
+
* The ledger directory, with `seen/<label>` written, or null without a session.
|
|
126
|
+
* Created 0700: in a shared `/tmp`, no other user reads which plugins ran.
|
|
127
|
+
*/
|
|
112
128
|
function openLedger(hookSession: string | null, label: string): string | null {
|
|
113
129
|
if (!hookSession) return null;
|
|
114
130
|
const base = process.env.TMPDIR || tmpdir();
|
|
115
131
|
const dir = join(base, `magus-deps-${hookSession}`);
|
|
116
132
|
try {
|
|
117
|
-
mkdirSync(join(dir, "seen"), { recursive: true });
|
|
118
|
-
mkdirSync(join(dir, "claimed"), { recursive: true });
|
|
133
|
+
mkdirSync(join(dir, "seen"), { recursive: true, mode: 0o700 });
|
|
134
|
+
mkdirSync(join(dir, "claimed"), { recursive: true, mode: 0o700 });
|
|
119
135
|
writeFileSync(join(dir, "seen", label), "ts");
|
|
120
136
|
} catch {
|
|
121
137
|
return null;
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
|
|
22
22
|
import { spawn } from "node:child_process";
|
|
23
23
|
import { createHash } from "node:crypto";
|
|
24
|
-
import { constants, accessSync, statSync } from "node:fs";
|
|
24
|
+
import { constants, accessSync, readFileSync, statSync } from "node:fs";
|
|
25
25
|
import { delimiter, join } from "node:path";
|
|
26
26
|
|
|
27
27
|
/** The one `requires` shape this reader knows. Any change to it bumps this. */
|
|
@@ -51,6 +51,17 @@ export interface Bootstrap {
|
|
|
51
51
|
prefix: UserPrefix;
|
|
52
52
|
/** Tools the upstream installer script itself runs. */
|
|
53
53
|
needs: readonly string[];
|
|
54
|
+
/**
|
|
55
|
+
* The environment the installer runs with: what keeps it out of the user's
|
|
56
|
+
* rc files, so the PATH line magus prints is the only one.
|
|
57
|
+
*/
|
|
58
|
+
env?: Readonly<Record<string, string>>;
|
|
59
|
+
/**
|
|
60
|
+
* The installer adds `prefix` to the user's rc file itself and has no
|
|
61
|
+
* switch to stop it, so magus prints no PATH line ahead of it; once it has
|
|
62
|
+
* run, the rc file is read to see whether it did.
|
|
63
|
+
*/
|
|
64
|
+
editsRc?: true;
|
|
54
65
|
}
|
|
55
66
|
|
|
56
67
|
/**
|
|
@@ -64,12 +75,14 @@ export const TOOLCHAIN_BOOTSTRAP = {
|
|
|
64
75
|
interpreter: "bash",
|
|
65
76
|
prefix: BUN_PREFIX,
|
|
66
77
|
needs: ["curl", "unzip"],
|
|
78
|
+
editsRc: true,
|
|
67
79
|
},
|
|
68
80
|
uv: {
|
|
69
81
|
url: "https://astral.sh/uv/install.sh",
|
|
70
82
|
interpreter: "sh",
|
|
71
83
|
prefix: LOCAL_PREFIX,
|
|
72
84
|
needs: ["curl"],
|
|
85
|
+
env: { UV_NO_MODIFY_PATH: "1" },
|
|
73
86
|
},
|
|
74
87
|
} as const satisfies Record<string, Bootstrap>;
|
|
75
88
|
|
|
@@ -679,6 +692,79 @@ export function rcFile(shell: string): string {
|
|
|
679
692
|
return "~/.bashrc";
|
|
680
693
|
}
|
|
681
694
|
|
|
695
|
+
/** The files `shell` reads when a terminal starts it, `rcFile(shell)` first. */
|
|
696
|
+
function startupFiles(shell: string): string[] {
|
|
697
|
+
if (shell.endsWith("zsh")) return ["~/.zshrc", "~/.zprofile", "~/.zshenv"];
|
|
698
|
+
if (shell.endsWith("fish")) return ["~/.config/fish/config.fish"];
|
|
699
|
+
return ["~/.bashrc", "~/.bash_profile"];
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const SHELL_VAR_RE = /\$(?:\{([A-Za-z_]\w*)\}|([A-Za-z_]\w*))/g;
|
|
703
|
+
const ASSIGN_RE = /^(?:export\s+)?([A-Za-z_]\w*)=(.*)$/;
|
|
704
|
+
const FISH_SET_RE = /^set\s+(?:-\S+\s+)*([A-Za-z_]\w*)\s+(.*)$/;
|
|
705
|
+
const PATH_EDIT_RE = /\bPATH\b|^fish_add_path\b|^path\+?=/;
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Does `text` (one rc file) put `abs` on PATH? A line that edits PATH counts
|
|
709
|
+
* when, with `~`, `$HOME` and the variables the file assigns earlier
|
|
710
|
+
* substituted, one of its words is `abs`. That reads the line magus prints,
|
|
711
|
+
* a hand-written one, and bun's installer's
|
|
712
|
+
* `export BUN_INSTALL="$HOME/.bun"` + `export PATH="$BUN_INSTALL/bin:$PATH"`.
|
|
713
|
+
*/
|
|
714
|
+
function textAddsToPath(text: string, abs: string, home: string): boolean {
|
|
715
|
+
const vars = new Map([["HOME", home]]);
|
|
716
|
+
const expand = (s: string): string =>
|
|
717
|
+
s
|
|
718
|
+
.replace(
|
|
719
|
+
SHELL_VAR_RE,
|
|
720
|
+
(whole: string, braced?: string, bare?: string) =>
|
|
721
|
+
vars.get(braced ?? bare ?? "") ?? whole,
|
|
722
|
+
)
|
|
723
|
+
.replace(
|
|
724
|
+
/(^|[\s:="'(])~(?=\/|$)/g,
|
|
725
|
+
(_whole: string, lead: string) => `${lead}${home}`,
|
|
726
|
+
);
|
|
727
|
+
for (const raw of text.split("\n")) {
|
|
728
|
+
const line = raw.trim();
|
|
729
|
+
if (line === "" || line.startsWith("#")) continue;
|
|
730
|
+
if (PATH_EDIT_RE.test(line)) {
|
|
731
|
+
const words = expand(line).split(/[\s:"'()=]+/);
|
|
732
|
+
if (words.some((w) => w === abs || w === `${abs}/`)) return true;
|
|
733
|
+
}
|
|
734
|
+
const assign = line.match(ASSIGN_RE) ?? line.match(FISH_SET_RE);
|
|
735
|
+
if (assign?.[1] && assign[2] !== undefined) {
|
|
736
|
+
const value = expand(assign[2]);
|
|
737
|
+
const quoted = /^(["']).*\1$/.test(value);
|
|
738
|
+
vars.set(assign[1], quoted ? value.slice(1, -1) : value);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
return false;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* The startup file of `shell` (as `~/…`) that already puts `dir` on PATH, or
|
|
746
|
+
* null. A PATH line is advice only while no such file exists: when one does,
|
|
747
|
+
* the prefix is off the PATH only because Claude Code started without reading
|
|
748
|
+
* it, and the fix is a restart, not a second line.
|
|
749
|
+
*/
|
|
750
|
+
export function rcFileAdding(
|
|
751
|
+
dir: string,
|
|
752
|
+
shell: string,
|
|
753
|
+
home: string,
|
|
754
|
+
): string | null {
|
|
755
|
+
const abs = expandHome(dir, home).replace(/\/+$/, "");
|
|
756
|
+
for (const file of startupFiles(shell)) {
|
|
757
|
+
let text: string;
|
|
758
|
+
try {
|
|
759
|
+
text = readFileSync(expandHome(file, home), "utf8");
|
|
760
|
+
} catch {
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
if (textAddsToPath(text, abs, home)) return file;
|
|
764
|
+
}
|
|
765
|
+
return null;
|
|
766
|
+
}
|
|
767
|
+
|
|
682
768
|
// ─── presence ─────────────────────────────────────────────────────────────────
|
|
683
769
|
|
|
684
770
|
export type Presence =
|
|
@@ -824,6 +910,8 @@ export type InstallStep =
|
|
|
824
910
|
url: string;
|
|
825
911
|
interpreter: "sh" | "bash";
|
|
826
912
|
prefix: UserPrefix;
|
|
913
|
+
/** The installer's environment, from {@link Bootstrap.env}. */
|
|
914
|
+
env?: Record<string, string>;
|
|
827
915
|
}
|
|
828
916
|
| {
|
|
829
917
|
kind: "release";
|
|
@@ -980,13 +1068,14 @@ export function renderInstall(
|
|
|
980
1068
|
};
|
|
981
1069
|
}
|
|
982
1070
|
case "bootstrap": {
|
|
983
|
-
const b = TOOLCHAIN_BOOTSTRAP[option.tool];
|
|
1071
|
+
const b: Bootstrap = TOOLCHAIN_BOOTSTRAP[option.tool];
|
|
984
1072
|
return {
|
|
985
1073
|
kind: "bootstrap",
|
|
986
1074
|
name,
|
|
987
1075
|
url: b.url,
|
|
988
1076
|
interpreter: b.interpreter,
|
|
989
1077
|
prefix: b.prefix,
|
|
1078
|
+
...(b.env ? { env: { ...b.env } } : {}),
|
|
990
1079
|
};
|
|
991
1080
|
}
|
|
992
1081
|
case "uv-script":
|
|
@@ -1054,8 +1143,12 @@ export function describeStep(step: InstallStep, os: Os): string {
|
|
|
1054
1143
|
const sudo = step.escalation === "sudo" ? "sudo " : "";
|
|
1055
1144
|
return `${sync} && ${sudo}"$(${find})" ${run}`;
|
|
1056
1145
|
}
|
|
1057
|
-
case "bootstrap":
|
|
1058
|
-
|
|
1146
|
+
case "bootstrap": {
|
|
1147
|
+
const env = Object.entries(step.env ?? {}).map(([k, v]) => `${k}=${v}`);
|
|
1148
|
+
const run =
|
|
1149
|
+
env.length > 0 ? ["env", ...env, step.interpreter] : [step.interpreter];
|
|
1150
|
+
return `curl -fsSL ${step.url} | ${shellLine(run)}`;
|
|
1151
|
+
}
|
|
1059
1152
|
case "release": {
|
|
1060
1153
|
const asset = step.url.slice(step.url.lastIndexOf("/") + 1);
|
|
1061
1154
|
const verify = os === "darwin" ? "shasum -a 256 -c" : "sha256sum -c";
|
|
@@ -1174,8 +1267,11 @@ export function renderFix(
|
|
|
1174
1267
|
for (const p of offPath) {
|
|
1175
1268
|
const prefix = USER_PREFIXES.find((u) => u.dir === p.prefix);
|
|
1176
1269
|
const rc = prefix?.rcLine(shell) ?? "";
|
|
1270
|
+
const adding = rcFileAdding(p.prefix, shell, env.home);
|
|
1177
1271
|
lines.push(
|
|
1178
|
-
|
|
1272
|
+
adding
|
|
1273
|
+
? ` ${p.name} is in ${p.prefix}, which is not on the PATH Claude Code started with; ${adding} already puts it on PATH`
|
|
1274
|
+
: ` ${p.name} is in ${p.prefix}, which is not on the PATH Claude Code started with: add ${rc} to ${rcFile(shell)}`,
|
|
1179
1275
|
);
|
|
1180
1276
|
}
|
|
1181
1277
|
lines.push(" Then restart Claude Code from a new terminal.");
|
package/src/ui/App.tsx
CHANGED
|
@@ -21,10 +21,15 @@ import {
|
|
|
21
21
|
checkForUpdates,
|
|
22
22
|
getCurrentVersion,
|
|
23
23
|
} from "../services/version-check.js";
|
|
24
|
+
import {
|
|
25
|
+
findUnsetWorktrees,
|
|
26
|
+
setUpWorktrees,
|
|
27
|
+
} from "../services/worktree-profiles.js";
|
|
24
28
|
import {
|
|
25
29
|
gitignoreStartupReport,
|
|
26
30
|
profileStartupReport,
|
|
27
31
|
staleRepairReport,
|
|
32
|
+
worktreeSetupReport,
|
|
28
33
|
} from "./adapters/startupReports.js";
|
|
29
34
|
import { ScreenErrorBoundary } from "./components/ScreenErrorBoundary.js";
|
|
30
35
|
import { TABS } from "./components/TabBar.js";
|
|
@@ -296,8 +301,15 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
296
301
|
null,
|
|
297
302
|
);
|
|
298
303
|
const staleLoadModal = useStaleLoadModal();
|
|
304
|
+
// This project's own profile question is settled: it needed nothing, or it
|
|
305
|
+
// was answered. Only then are the other worktrees set up — before it, they
|
|
306
|
+
// would be set up from a project that is not set up itself.
|
|
307
|
+
const [profileSettled, setProfileSettled] = useState(false);
|
|
299
308
|
// Stable, so `profileModal.show` is not rebuilt for it on every render.
|
|
300
|
-
const profileAnswered = React.useCallback(() =>
|
|
309
|
+
const profileAnswered = React.useCallback(() => {
|
|
310
|
+
setProfileNeed(null);
|
|
311
|
+
setProfileSettled(true);
|
|
312
|
+
}, []);
|
|
301
313
|
// Activating a profile changes which plugins are on, so the open screen must
|
|
302
314
|
// reload. The result modal that used to follow sent the user to the Profiles
|
|
303
315
|
// tab, which remounted it; without that modal the user stays where they are,
|
|
@@ -550,12 +562,31 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
550
562
|
);
|
|
551
563
|
queueReports(profileStartupReport(problems, activations ?? []));
|
|
552
564
|
if (!needsNothing(need)) setProfileNeed(need);
|
|
565
|
+
else setProfileSettled(true);
|
|
553
566
|
} catch {
|
|
554
567
|
// non-fatal: the Profiles tab still works by hand
|
|
555
568
|
}
|
|
556
569
|
})();
|
|
557
570
|
}, [queueReports]);
|
|
558
571
|
|
|
572
|
+
// The other worktrees of this repository with no profile set up, once this
|
|
573
|
+
// project's own question is settled. Each one whose project already declares
|
|
574
|
+
// a profile, with nothing of the user's to replace, is set up without
|
|
575
|
+
// asking — the same rule this project's own profile follows above. Success
|
|
576
|
+
// shows nothing; failures go to the one failure popup. A worktree that needs
|
|
577
|
+
// a choice is left to `magus update` run in it. Its own effect, for the
|
|
578
|
+
// same reason as the profile check above.
|
|
579
|
+
const worktreesLooked = useRef(false);
|
|
580
|
+
useEffect(() => {
|
|
581
|
+
if (!profileSettled || worktreesLooked.current) return;
|
|
582
|
+
worktreesLooked.current = true;
|
|
583
|
+
findUnsetWorktrees(process.cwd())
|
|
584
|
+
.then((found) => setUpWorktrees(found.filter((w) => w.autoActivate)))
|
|
585
|
+
.then((results) => queueReports(worktreeSetupReport(results)))
|
|
586
|
+
// non-fatal: `magus update` reports the same worktrees
|
|
587
|
+
.catch(() => {});
|
|
588
|
+
}, [profileSettled, queueReports]);
|
|
589
|
+
|
|
559
590
|
// Auto-refresh marketplaces on startup
|
|
560
591
|
useEffect(() => {
|
|
561
592
|
const noRefresh = process.argv.includes("--no-refresh");
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The failure popup's words for everything outside the Plugins screen's apply
|
|
3
3
|
* paths: the profile gate at startup, a profile activation, the startup
|
|
4
|
-
* repairs (stale plugin loads, marketplace refreshes, gitignore fixes)
|
|
5
|
-
* Git State screen. Each is a pure builder over what the service returned, on
|
|
4
|
+
* repairs (stale plugin loads, marketplace refreshes, gitignore fixes), the
|
|
5
|
+
* setup of other worktrees and the Git State screen. Each is a pure builder over what the service returned, on
|
|
6
6
|
* top of {@link failureReport}, so they read like every other failure.
|
|
7
7
|
*
|
|
8
8
|
* Each returns null when nothing failed: success says nothing, because the
|
|
@@ -13,6 +13,7 @@ import type { StaleLoadRepairResult } from "../../services/claude-settings.js";
|
|
|
13
13
|
import type { ActivationResult } from "../../services/profile-activate.js";
|
|
14
14
|
import { SettingsParseError } from "../../services/settings-file.js";
|
|
15
15
|
import { pluginName } from "../../services/update-engine.js";
|
|
16
|
+
import type { WorktreeSetupResult } from "../../services/worktree-profiles.js";
|
|
16
17
|
import { userFacingPath, withUserFacingPath } from "../../utils/user-path.js";
|
|
17
18
|
import type { FailureReport, FailureRow } from "../state/types.js";
|
|
18
19
|
import {
|
|
@@ -212,6 +213,25 @@ export function stepFailureReport(
|
|
|
212
213
|
};
|
|
213
214
|
}
|
|
214
215
|
|
|
216
|
+
/**
|
|
217
|
+
* Worktrees the startup setup could not put on their declared profile, each
|
|
218
|
+
* by folder name with its reason. Null when every one was set up.
|
|
219
|
+
*/
|
|
220
|
+
export function worktreeSetupReport(
|
|
221
|
+
results: readonly WorktreeSetupResult[],
|
|
222
|
+
): FailureReport | null {
|
|
223
|
+
const failed = results.filter(
|
|
224
|
+
(r): r is Extract<WorktreeSetupResult, { ok: false }> => !r.ok,
|
|
225
|
+
);
|
|
226
|
+
return failureReport(
|
|
227
|
+
`${counted(failed.length, "worktree")} ${failed.length === 1 ? "was" : "were"} not set up`,
|
|
228
|
+
failed.map((r) => ({
|
|
229
|
+
subject: r.name,
|
|
230
|
+
reason: exceptionReason(r.reason).reason,
|
|
231
|
+
})),
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
215
235
|
/**
|
|
216
236
|
* M9: generated files the startup gitignore fix could not untrack, each with
|
|
217
237
|
* git's own reason.
|
package/src/utils/run.ts
CHANGED
|
@@ -41,6 +41,11 @@ export interface RunResult {
|
|
|
41
41
|
spawnError?: string;
|
|
42
42
|
/** Combined stdout + stderr, last {@link OUTPUT_TAIL_BYTES}, ANSI stripped. */
|
|
43
43
|
outputTail: string;
|
|
44
|
+
/**
|
|
45
|
+
* stdout alone, last {@link OUTPUT_TAIL_BYTES}, ANSI stripped. Set only
|
|
46
|
+
* when `captureStdout` asked for it.
|
|
47
|
+
*/
|
|
48
|
+
stdout?: string;
|
|
44
49
|
durationMs: number;
|
|
45
50
|
}
|
|
46
51
|
|
|
@@ -53,6 +58,12 @@ export interface RunOptions {
|
|
|
53
58
|
killGraceMs?: number;
|
|
54
59
|
/** Each output chunk as it arrives, ANSI intact. Never called after return. */
|
|
55
60
|
onOutput?: (chunk: string) => void;
|
|
61
|
+
/**
|
|
62
|
+
* Also keep stdout on its own in `RunResult.stdout`: for a query whose
|
|
63
|
+
* answer is what it prints on stdout, so a warning on stderr is never read
|
|
64
|
+
* as the answer.
|
|
65
|
+
*/
|
|
66
|
+
captureStdout?: boolean;
|
|
56
67
|
}
|
|
57
68
|
|
|
58
69
|
/** Keep the last {@link OUTPUT_TAIL_BYTES} of a growing buffer. */
|
|
@@ -67,10 +78,13 @@ export function runArgv(c: Command, opts: RunOptions): Promise<RunResult> {
|
|
|
67
78
|
const started = Date.now();
|
|
68
79
|
return new Promise((resolve) => {
|
|
69
80
|
let output = "";
|
|
81
|
+
let stdout = "";
|
|
70
82
|
let timedOut = false;
|
|
71
83
|
let settled = false;
|
|
72
84
|
let killTimer: ReturnType<typeof setTimeout> | undefined;
|
|
73
|
-
const finish = (
|
|
85
|
+
const finish = (
|
|
86
|
+
result: Omit<RunResult, "outputTail" | "stdout" | "durationMs">,
|
|
87
|
+
) => {
|
|
74
88
|
if (settled) return;
|
|
75
89
|
settled = true;
|
|
76
90
|
clearTimeout(timer);
|
|
@@ -78,6 +92,7 @@ export function runArgv(c: Command, opts: RunOptions): Promise<RunResult> {
|
|
|
78
92
|
resolve({
|
|
79
93
|
...result,
|
|
80
94
|
outputTail: stripAnsi(tail(output)),
|
|
95
|
+
...(opts.captureStdout ? { stdout: stripAnsi(tail(stdout)) } : {}),
|
|
81
96
|
durationMs: Date.now() - started,
|
|
82
97
|
});
|
|
83
98
|
};
|
|
@@ -93,6 +108,11 @@ export function runArgv(c: Command, opts: RunOptions): Promise<RunResult> {
|
|
|
93
108
|
};
|
|
94
109
|
child.stdout?.on("data", onData);
|
|
95
110
|
child.stderr?.on("data", onData);
|
|
111
|
+
if (opts.captureStdout) {
|
|
112
|
+
child.stdout?.on("data", (data: Buffer) => {
|
|
113
|
+
stdout = tail(stdout + data.toString("utf8"));
|
|
114
|
+
});
|
|
115
|
+
}
|
|
96
116
|
const timer = setTimeout(() => {
|
|
97
117
|
timedOut = true;
|
|
98
118
|
child.kill("SIGTERM");
|