claudeup 6.2.1 → 6.3.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/README.md +22 -0
- package/bin/claudeup.js +6 -1
- package/package.json +6 -6
- package/src/__tests__/alias-adopt.test.ts +331 -329
- package/src/__tests__/alias-parser.test.ts +384 -376
- package/src/__tests__/alias-shell-writer.test.ts +638 -625
- package/src/__tests__/alias-store.test.ts +143 -143
- package/src/__tests__/catalog-cache-store.test.ts +265 -265
- package/src/__tests__/catalog-notice.test.ts +123 -123
- package/src/__tests__/cli-apply-seams.test.ts +12 -8
- package/src/__tests__/cli-router.test.ts +132 -3
- package/src/__tests__/cli-update-view.test.ts +27 -8
- package/src/__tests__/community-staleness.test.ts +0 -2
- package/src/__tests__/component-badge.test.ts +4 -4
- package/src/__tests__/content-drift.test.ts +5 -1
- package/src/__tests__/conventions-integration.test.ts +774 -689
- package/src/__tests__/conventions-manager.test.ts +1216 -1152
- package/src/__tests__/doctor-bins.test.ts +14 -6
- package/src/__tests__/doctor.test.ts +438 -425
- package/src/__tests__/dual-write-prevention.test.ts +277 -282
- package/src/__tests__/enabled-not-installed.test.ts +71 -63
- package/src/__tests__/file-locking.test.ts +196 -196
- package/src/__tests__/gap-fill-versions.test.ts +318 -311
- package/src/__tests__/github-budget.test.ts +170 -164
- package/src/__tests__/gitignore-detector.test.ts +160 -152
- package/src/__tests__/gitignore-fixer.test.ts +285 -247
- package/src/__tests__/gitignore-prerun.test.ts +57 -57
- package/src/__tests__/gitignore-resolver.test.ts +173 -146
- package/src/__tests__/gitignore-service.test.ts +121 -119
- package/src/__tests__/install-command.test.ts +3 -3
- package/src/__tests__/install-plan.test.ts +13 -7
- package/src/__tests__/manifest.test.ts +16 -5
- package/src/__tests__/markdown-renderer.test.ts +0 -1
- package/src/__tests__/marketplace-badge.test.ts +111 -90
- package/src/__tests__/marketplace-version.test.ts +64 -61
- package/src/__tests__/plugin-manager-fallback.test.ts +249 -239
- package/src/__tests__/plugin-requires.test.ts +18 -4
- package/src/__tests__/plugin-setup.test.ts +12 -12
- package/src/__tests__/profile-command.test.ts +1 -7
- package/src/__tests__/profile-materializer.test.ts +31 -8
- package/src/__tests__/profile-sync.test.ts +23 -5
- package/src/__tests__/registry-version-resolution.test.ts +61 -47
- package/src/__tests__/resolve-executable.test.ts +1 -1
- package/src/__tests__/resolver.test.ts +40 -15
- package/src/__tests__/scope-action.test.ts +52 -50
- package/src/__tests__/scope-squares.test.tsx +4 -5
- package/src/__tests__/skill-install-files.test.ts +111 -103
- package/src/__tests__/symlink-manager.test.ts +27 -10
- package/src/__tests__/theme-env.test.ts +281 -0
- package/src/__tests__/theme-resolve.test.ts +380 -0
- package/src/__tests__/update-apply.test.ts +1 -3
- package/src/__tests__/useGitignoreModal.test.ts +69 -61
- package/src/__tests__/version-snapshot.test.ts +181 -165
- package/src/cli/ansi.ts +23 -15
- package/src/cli/doctor.ts +10 -9
- package/src/cli/install.ts +3 -3
- package/src/cli/profile.ts +5 -4
- package/src/cli/prompt.ts +0 -1
- package/src/cli/router.ts +69 -2
- package/src/cli/update-view.ts +11 -6
- package/src/cli/update.ts +9 -4
- package/src/cli/upgrade.ts +6 -4
- package/src/data/alias-flags.ts +260 -260
- package/src/data/gitignore-defaults.ts +20 -20
- package/src/data/gitignore-reasons.ts +86 -92
- package/src/data/gitignore-templates.ts +14 -14
- package/src/data/predefined-profiles.ts +8 -9
- package/src/data/skill-repos.ts +43 -35
- package/src/main.tsx +29 -6
- package/src/opentui.d.ts +1 -3
- package/src/prerunner/index.ts +7 -8
- package/src/services/alias-settings.ts +20 -23
- package/src/services/alias-shell-writer.ts +800 -803
- package/src/services/alias-store.ts +77 -77
- package/src/services/claude-settings.ts +27 -18
- package/src/services/content-drift.ts +13 -3
- package/src/services/conventions-manager.ts +692 -617
- package/src/services/doctor.ts +464 -403
- package/src/services/gitignore-detector.ts +113 -103
- package/src/services/gitignore-fixer.ts +204 -189
- package/src/services/gitignore-prerun.ts +26 -26
- package/src/services/gitignore-resolver.ts +162 -144
- package/src/services/gitignore-service.ts +120 -117
- package/src/services/install-plan.ts +4 -4
- package/src/services/manifest.ts +1 -1
- package/src/services/marketplace-catalog-git.ts +5 -2
- package/src/services/marketplace-fetcher.ts +241 -235
- package/src/services/plugin-manager.ts +17 -20
- package/src/services/plugin-mcp-config.ts +2 -2
- package/src/services/plugin-requires.ts +9 -6
- package/src/services/plugin-setup.ts +7 -9
- package/src/services/plugin-version-check.ts +4 -4
- package/src/services/profile-materializer.ts +9 -5
- package/src/services/profile-sync.ts +12 -4
- package/src/services/profiles.ts +3 -3
- package/src/services/resolver.ts +10 -6
- package/src/services/settings-manager.ts +82 -46
- package/src/services/skills-manager.ts +11 -9
- package/src/services/symlink-manager.ts +2 -5
- package/src/services/toolchain.ts +3 -3
- package/src/services/update-cache.ts +1 -1
- package/src/services/update-engine.ts +4 -4
- package/src/services/update-plan.ts +9 -11
- package/src/services/version-snapshot.ts +88 -88
- package/src/types/gitignore.ts +28 -28
- package/src/ui/App.tsx +36 -36
- package/src/ui/adapters/settingsAdapter.ts +2 -2
- package/src/ui/components/CategoryHeader.tsx +1 -1
- package/src/ui/components/FlagDetailEditor.tsx +0 -0
- package/src/ui/components/ScopeIndicator.tsx +3 -1
- package/src/ui/components/ScrollableList.tsx +2 -1
- package/src/ui/components/StyledText.tsx +1 -1
- package/src/ui/components/layout/FooterHints.tsx +5 -3
- package/src/ui/components/layout/Panel.tsx +2 -2
- package/src/ui/components/layout/ScreenLayout.tsx +5 -4
- package/src/ui/components/modals/InputModal.tsx +7 -1
- package/src/ui/components/modals/ModalContainer.tsx +3 -3
- package/src/ui/components/modals/SelectModal.tsx +1 -1
- package/src/ui/components/primitives/DetailSection.tsx +1 -1
- package/src/ui/components/primitives/KeyValueLine.tsx +1 -1
- package/src/ui/components/primitives/ScopeSquares.tsx +2 -2
- package/src/ui/hooks/index.ts +0 -1
- package/src/ui/hooks/useGitignoreModal.ts +1 -5
- package/src/ui/hooks/useMismatchModal.ts +4 -10
- package/src/ui/renderers/cliToolRenderers.tsx +2 -2
- package/src/ui/renderers/gitignoreRenderers.tsx +2 -2
- package/src/ui/renderers/mcpRenderers.tsx +1 -1
- package/src/ui/renderers/pluginRenderers.tsx +3 -9
- package/src/ui/renderers/profileRenderers.tsx +9 -8
- package/src/ui/renderers/settingsRenderers.tsx +4 -4
- package/src/ui/renderers/skillRenderers.tsx +14 -14
- package/src/ui/screens/AliasScreen.tsx +31 -34
- package/src/ui/screens/CliToolsScreen.tsx +14 -13
- package/src/ui/screens/EnvVarsScreen.tsx +8 -8
- package/src/ui/screens/GitignoreScreen.tsx +15 -14
- package/src/ui/screens/McpRegistryScreen.tsx +10 -7
- package/src/ui/screens/McpScreen.tsx +10 -10
- package/src/ui/screens/PluginsScreen.tsx +17 -21
- package/src/ui/screens/ProfilesScreen.tsx +25 -24
- package/src/ui/screens/SkillsScreen.tsx +16 -14
- package/src/ui/state/AppContext.tsx +4 -8
- package/src/ui/state/DimensionsContext.tsx +3 -2
- package/src/ui/theme-mode.ts +20 -38
- package/src/ui/theme-resolve.ts +148 -0
- package/src/utils/clipboard.ts +5 -5
- package/src/utils/command-utils.ts +2 -2
- package/src/utils/config-dir.ts +1 -3
- package/src/utils/file-locking.ts +88 -86
- package/src/ui/hooks/useAsyncData.ts +0 -127
- package/src/ui/state/AnimationContext.tsx +0 -76
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import {
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
resolveSkillFilePath,
|
|
5
|
+
selectSkillFiles,
|
|
6
6
|
} from "../services/skills-manager.js";
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -21,108 +21,116 @@ const blob = (p: string) => ({ path: p, type: "blob" });
|
|
|
21
21
|
const tree = (p: string) => ({ path: p, type: "tree" });
|
|
22
22
|
|
|
23
23
|
describe("selectSkillFiles", () => {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
24
|
+
test("returns paths relative to the skill directory", () => {
|
|
25
|
+
expect(
|
|
26
|
+
selectSkillFiles(
|
|
27
|
+
[
|
|
28
|
+
blob("skills/audit-website/SKILL.md"),
|
|
29
|
+
blob("skills/audit-website/references/OUTPUT-FORMAT.md"),
|
|
30
|
+
blob("skills/audit-website/assets/icon-small.svg"),
|
|
31
|
+
],
|
|
32
|
+
"skills/audit-website",
|
|
33
|
+
),
|
|
34
|
+
).toEqual([
|
|
35
|
+
"SKILL.md",
|
|
36
|
+
"references/OUTPUT-FORMAT.md",
|
|
37
|
+
"assets/icon-small.svg",
|
|
38
|
+
]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("REGRESSION: a sibling with a shared name prefix is excluded", () => {
|
|
42
|
+
// Without the trailing slash on the prefix, "skills/audit" also matches
|
|
43
|
+
// "skills/audit-website/..." and dumps a second skill's files into the
|
|
44
|
+
// first one's directory.
|
|
45
|
+
expect(
|
|
46
|
+
selectSkillFiles(
|
|
47
|
+
[
|
|
48
|
+
blob("skills/audit/SKILL.md"),
|
|
49
|
+
blob("skills/audit-website/SKILL.md"),
|
|
50
|
+
blob("skills/audit-website/references/OUTPUT-FORMAT.md"),
|
|
51
|
+
],
|
|
52
|
+
"skills/audit",
|
|
53
|
+
),
|
|
54
|
+
).toEqual(["SKILL.md"]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("tree entries are skipped — only blobs are downloadable", () => {
|
|
58
|
+
expect(
|
|
59
|
+
selectSkillFiles(
|
|
60
|
+
[
|
|
61
|
+
tree("skills/x/references"),
|
|
62
|
+
blob("skills/x/SKILL.md"),
|
|
63
|
+
blob("skills/x/references/a.md"),
|
|
64
|
+
],
|
|
65
|
+
"skills/x",
|
|
66
|
+
),
|
|
67
|
+
).toEqual(["SKILL.md", "references/a.md"]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("files outside the skill directory are excluded", () => {
|
|
71
|
+
expect(
|
|
72
|
+
selectSkillFiles(
|
|
73
|
+
[blob("README.md"), blob("other/SKILL.md"), blob("skills/x/SKILL.md")],
|
|
74
|
+
"skills/x",
|
|
75
|
+
),
|
|
76
|
+
).toEqual(["SKILL.md"]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("a trailing slash on the skill path does not break matching", () => {
|
|
80
|
+
expect(selectSkillFiles([blob("skills/x/SKILL.md")], "skills/x/")).toEqual([
|
|
81
|
+
"SKILL.md",
|
|
82
|
+
]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("a skill directory with no files yields nothing", () => {
|
|
86
|
+
expect(
|
|
87
|
+
selectSkillFiles([blob("skills/other/SKILL.md")], "skills/x"),
|
|
88
|
+
).toEqual([]);
|
|
89
|
+
});
|
|
84
90
|
});
|
|
85
91
|
|
|
86
92
|
describe("resolveSkillFilePath", () => {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
93
|
+
const dir = path.join(path.sep, "tmp", "skills", "demo");
|
|
94
|
+
|
|
95
|
+
test("resolves a plain relative path inside the install directory", () => {
|
|
96
|
+
expect(resolveSkillFilePath(dir, "SKILL.md")).toBe(
|
|
97
|
+
path.join(dir, "SKILL.md"),
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("resolves a nested path", () => {
|
|
102
|
+
expect(resolveSkillFilePath(dir, "references/OUTPUT-FORMAT.md")).toBe(
|
|
103
|
+
path.join(dir, "references", "OUTPUT-FORMAT.md"),
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("SECURITY: rejects paths escaping via ..", () => {
|
|
108
|
+
// These paths come from a remote repository and are untrusted input to a
|
|
109
|
+
// filesystem write.
|
|
110
|
+
expect(resolveSkillFilePath(dir, "../evil.md")).toBeNull();
|
|
111
|
+
expect(resolveSkillFilePath(dir, "../../.bashrc")).toBeNull();
|
|
112
|
+
expect(resolveSkillFilePath(dir, "refs/../../../evil.md")).toBeNull();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("SECURITY: rejects absolute paths", () => {
|
|
116
|
+
expect(
|
|
117
|
+
resolveSkillFilePath(dir, path.join(path.sep, "etc", "passwd")),
|
|
118
|
+
).toBeNull();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("rejects an empty path", () => {
|
|
122
|
+
expect(resolveSkillFilePath(dir, "")).toBeNull();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("a .. that stays inside the directory is allowed", () => {
|
|
126
|
+
expect(resolveSkillFilePath(dir, "refs/../SKILL.md")).toBe(
|
|
127
|
+
path.join(dir, "SKILL.md"),
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("REGRESSION: a sibling directory sharing the name prefix is rejected", () => {
|
|
132
|
+
// path.startsWith without a separator would accept /tmp/skills/demo-evil
|
|
133
|
+
// as being inside /tmp/skills/demo.
|
|
134
|
+
expect(resolveSkillFilePath(dir, "../demo-evil/x.md")).toBeNull();
|
|
135
|
+
});
|
|
128
136
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import { lstat, mkdtemp, readlink, rm } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import fs from "fs-extra";
|
|
@@ -17,7 +17,8 @@ async function materialize(
|
|
|
17
17
|
) {
|
|
18
18
|
const dir = profileDir(name, project);
|
|
19
19
|
await fs.ensureDir(dir);
|
|
20
|
-
if (files.settings)
|
|
20
|
+
if (files.settings)
|
|
21
|
+
await fs.writeJson(join(dir, "settings.json"), files.settings);
|
|
21
22
|
if (files.mcp) await fs.writeJson(join(dir, "mcp.json"), files.mcp);
|
|
22
23
|
if (files.skills) await fs.ensureDir(join(dir, "skills"));
|
|
23
24
|
}
|
|
@@ -40,12 +41,20 @@ describe("symlink-manager", () => {
|
|
|
40
41
|
await activateProfile("frontend", project);
|
|
41
42
|
|
|
42
43
|
// settings.json is a symlink into the profile dir
|
|
43
|
-
expect(
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
expect(
|
|
45
|
+
(await lstat(join(project, ".claude", "settings.json"))).isSymbolicLink(),
|
|
46
|
+
).toBe(true);
|
|
47
|
+
expect((await lstat(join(project, ".mcp.json"))).isSymbolicLink()).toBe(
|
|
48
|
+
true,
|
|
49
|
+
);
|
|
50
|
+
expect(
|
|
51
|
+
(await lstat(join(project, ".claude", "skills"))).isSymbolicLink(),
|
|
52
|
+
).toBe(true);
|
|
46
53
|
|
|
47
54
|
// reading through the link returns the profile's content
|
|
48
|
-
const settings = await fs.readJson(
|
|
55
|
+
const settings = await fs.readJson(
|
|
56
|
+
join(project, ".claude", "settings.json"),
|
|
57
|
+
);
|
|
49
58
|
expect(settings.enabledPlugins["dev@magus"]).toBe(true);
|
|
50
59
|
});
|
|
51
60
|
|
|
@@ -54,8 +63,12 @@ describe("symlink-manager", () => {
|
|
|
54
63
|
await activateProfile("frontend", project);
|
|
55
64
|
await fs.writeJson(join(project, ".claude", "settings.json"), { a: 2 });
|
|
56
65
|
// The real file inside the profile dir was updated (symlink preserved).
|
|
57
|
-
expect(
|
|
58
|
-
|
|
66
|
+
expect(
|
|
67
|
+
(await lstat(join(project, ".claude", "settings.json"))).isSymbolicLink(),
|
|
68
|
+
).toBe(true);
|
|
69
|
+
expect(
|
|
70
|
+
(await fs.readJson(`${profileDir("frontend", project)}/settings.json`)).a,
|
|
71
|
+
).toBe(2);
|
|
59
72
|
});
|
|
60
73
|
|
|
61
74
|
test("switching repoints links and activeProfile tracks it", async () => {
|
|
@@ -64,11 +77,15 @@ describe("symlink-manager", () => {
|
|
|
64
77
|
|
|
65
78
|
await activateProfile("frontend", project);
|
|
66
79
|
expect(await activeProfile(project)).toBe("frontend");
|
|
67
|
-
expect(
|
|
80
|
+
expect(
|
|
81
|
+
(await fs.readJson(join(project, ".claude", "settings.json"))).which,
|
|
82
|
+
).toBe("frontend");
|
|
68
83
|
|
|
69
84
|
await activateProfile("backend", project);
|
|
70
85
|
expect(await activeProfile(project)).toBe("backend");
|
|
71
|
-
expect(
|
|
86
|
+
expect(
|
|
87
|
+
(await fs.readJson(join(project, ".claude", "settings.json"))).which,
|
|
88
|
+
).toBe("backend");
|
|
72
89
|
});
|
|
73
90
|
|
|
74
91
|
test("uses relative (not absolute) symlinks", async () => {
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { loadProjectDotenv } from "../services/dotenv.js";
|
|
7
|
+
import { resolveTheme, snapshotThemeEnv } from "../ui/theme-resolve.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `TERM_THEME` is read from the process environment and never from a `.env`.
|
|
11
|
+
*
|
|
12
|
+
* Nothing at runtime enforces that — `loadProjectDotenv` merges into the same
|
|
13
|
+
* `process.env` the resolver would otherwise read — so the isolation is the
|
|
14
|
+
* ORDER of two calls in main.tsx: snapshot first, dotenv second. T-env-1 and
|
|
15
|
+
* T-env-2 prove the mechanism; T-env-3 pins the order at source level, which is
|
|
16
|
+
* the only place it can be pinned without spawning the TUI.
|
|
17
|
+
*
|
|
18
|
+
* Order alone is not enough on the source path: Bun autoloads the cwd's `.env`
|
|
19
|
+
* into `process.env` BEFORE any user code runs, so a snapshot taken as the
|
|
20
|
+
* first statement already holds the file's values. Every source launcher
|
|
21
|
+
* therefore passes `--no-env-file` (the compiled binary is built with
|
|
22
|
+
* `--no-compile-autoload-dotenv`). T-env-4 proves that flag live, with a
|
|
23
|
+
* negative control showing the leak without it.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const dirs: string[] = [];
|
|
27
|
+
|
|
28
|
+
const MAIN_TSX = path.join(import.meta.dir, "..", "main.tsx");
|
|
29
|
+
const THEME_RESOLVE = path.join(
|
|
30
|
+
import.meta.dir,
|
|
31
|
+
"..",
|
|
32
|
+
"ui",
|
|
33
|
+
"theme-resolve.ts",
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Spawn bun from `cwd` with the three theme variables removed from the parent
|
|
38
|
+
* environment, so the only way `TERM_THEME` can reach the child is Bun's own
|
|
39
|
+
* `.env` autoload. Returns stdout/stderr/status.
|
|
40
|
+
*/
|
|
41
|
+
function spawnBun(
|
|
42
|
+
cwd: string,
|
|
43
|
+
args: string[],
|
|
44
|
+
): { status: number | null; stdout: string; stderr: string } {
|
|
45
|
+
const env: Record<string, string | undefined> = { ...process.env };
|
|
46
|
+
env.TERM_THEME = undefined;
|
|
47
|
+
env.CLAUDEUP_THEME = undefined;
|
|
48
|
+
env.COLORFGBG = undefined;
|
|
49
|
+
const r = spawnSync("bun", args, {
|
|
50
|
+
cwd,
|
|
51
|
+
env: env as NodeJS.ProcessEnv,
|
|
52
|
+
encoding: "utf-8",
|
|
53
|
+
input: "",
|
|
54
|
+
});
|
|
55
|
+
return { status: r.status, stdout: r.stdout, stderr: r.stderr };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A one-file harness that prints `snapshotThemeEnv()` of its own process. */
|
|
59
|
+
function writeSnapshotHarness(cwd: string): string {
|
|
60
|
+
const file = path.join(cwd, "print-snapshot.ts");
|
|
61
|
+
writeFileSync(
|
|
62
|
+
file,
|
|
63
|
+
[
|
|
64
|
+
`import { snapshotThemeEnv } from ${JSON.stringify(THEME_RESOLVE)};`,
|
|
65
|
+
"console.log(JSON.stringify(snapshotThemeEnv()));",
|
|
66
|
+
"",
|
|
67
|
+
].join("\n"),
|
|
68
|
+
);
|
|
69
|
+
return file;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
for (const dir of dirs.splice(0))
|
|
74
|
+
rmSync(dir, { recursive: true, force: true });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
function tempDir(): string {
|
|
78
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), "claudeup-theme-env-"));
|
|
79
|
+
dirs.push(dir);
|
|
80
|
+
return dir;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe("snapshotThemeEnv", () => {
|
|
84
|
+
test("T-env-1: the snapshot is a copy — later writes to env do not reach it", () => {
|
|
85
|
+
const env: Record<string, string | undefined> = {
|
|
86
|
+
TERM_THEME: "light",
|
|
87
|
+
CLAUDEUP_THEME: "dark",
|
|
88
|
+
COLORFGBG: "0;15",
|
|
89
|
+
UNRELATED: "x",
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const snapshot = snapshotThemeEnv(env);
|
|
93
|
+
env.TERM_THEME = "dark";
|
|
94
|
+
env.CLAUDEUP_THEME = undefined;
|
|
95
|
+
env.COLORFGBG = "15;0";
|
|
96
|
+
|
|
97
|
+
expect(snapshot).toEqual({
|
|
98
|
+
TERM_THEME: "light",
|
|
99
|
+
CLAUDEUP_THEME: "dark",
|
|
100
|
+
COLORFGBG: "0;15",
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("T-env-1b: an unset variable is absent from the snapshot, not undefined", () => {
|
|
105
|
+
expect(snapshotThemeEnv({})).toEqual({});
|
|
106
|
+
expect(Object.keys(snapshotThemeEnv({ TERM_THEME: "light" }))).toEqual([
|
|
107
|
+
"TERM_THEME",
|
|
108
|
+
]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("T-env-2: a .env TERM_THEME lands in env but never in a snapshot taken first", () => {
|
|
112
|
+
const cwd = tempDir();
|
|
113
|
+
writeFileSync(path.join(cwd, ".env"), "TERM_THEME=light\n");
|
|
114
|
+
const env: Record<string, string | undefined> = {};
|
|
115
|
+
|
|
116
|
+
const snapshot = snapshotThemeEnv(env);
|
|
117
|
+
const outcome = loadProjectDotenv(cwd, env);
|
|
118
|
+
|
|
119
|
+
expect(outcome.warnings).toEqual([]);
|
|
120
|
+
expect(env.TERM_THEME).toBe("light");
|
|
121
|
+
expect(snapshot.TERM_THEME).toBeUndefined();
|
|
122
|
+
expect(snapshot).toEqual({});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("T-env-2b: a .env TERM_THEME=light never reaches the resolver — the probe still runs and wins", async () => {
|
|
126
|
+
// End to end through the resolver: snapshot, then .env, then resolve
|
|
127
|
+
// with the snapshot. Were the .env value reaching the resolver, step 3
|
|
128
|
+
// would answer light and the probe would never be called.
|
|
129
|
+
const cwd = tempDir();
|
|
130
|
+
writeFileSync(
|
|
131
|
+
path.join(cwd, ".env"),
|
|
132
|
+
"TERM_THEME=light\nCLAUDEUP_THEME=light\n",
|
|
133
|
+
);
|
|
134
|
+
const env: Record<string, string | undefined> = {};
|
|
135
|
+
let probeCalls = 0;
|
|
136
|
+
|
|
137
|
+
const snapshot = snapshotThemeEnv(env);
|
|
138
|
+
loadProjectDotenv(cwd, env);
|
|
139
|
+
const interactive = await resolveTheme({
|
|
140
|
+
env: snapshot,
|
|
141
|
+
isInteractive: true,
|
|
142
|
+
probe: async () => {
|
|
143
|
+
probeCalls += 1;
|
|
144
|
+
return "dark";
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
const headless = await resolveTheme({
|
|
148
|
+
env: snapshot,
|
|
149
|
+
isInteractive: false,
|
|
150
|
+
probe: async () => "light",
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
expect(env.TERM_THEME).toBe("light");
|
|
154
|
+
expect(interactive).toEqual({ mode: "dark", source: "osc11" });
|
|
155
|
+
expect(probeCalls).toBe(1);
|
|
156
|
+
expect(headless).toEqual({ mode: "dark", source: "default" });
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("T-env-2c: .env.local is isolated the same way as .env", async () => {
|
|
160
|
+
const cwd = tempDir();
|
|
161
|
+
writeFileSync(path.join(cwd, ".env.local"), "TERM_THEME=light\n");
|
|
162
|
+
const env: Record<string, string | undefined> = { COLORFGBG: "15;0" };
|
|
163
|
+
|
|
164
|
+
const snapshot = snapshotThemeEnv(env);
|
|
165
|
+
loadProjectDotenv(cwd, env);
|
|
166
|
+
const r = await resolveTheme({
|
|
167
|
+
env: snapshot,
|
|
168
|
+
isInteractive: false,
|
|
169
|
+
probe: async () => null,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
expect(env.TERM_THEME).toBe("light");
|
|
173
|
+
expect(r).toEqual({ mode: "dark", source: "COLORFGBG" });
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("Bun .env autoload (live)", () => {
|
|
178
|
+
test("T-env-4: `bun --no-env-file src/main.tsx --theme bogus` from a cwd with .env exits in the router, not from the file", () => {
|
|
179
|
+
// --theme bogus exits 1 before the renderer, so no tty is needed. The
|
|
180
|
+
// point of the run is the exit path: a leaked .env value cannot change
|
|
181
|
+
// it, and the flag must not break the source launch either.
|
|
182
|
+
const cwd = tempDir();
|
|
183
|
+
writeFileSync(
|
|
184
|
+
path.join(cwd, ".env"),
|
|
185
|
+
"TERM_THEME=light\nCLAUDEUP_THEME=light\n",
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
const r = spawnBun(cwd, ["--no-env-file", MAIN_TSX, "--theme", "bogus"]);
|
|
189
|
+
|
|
190
|
+
expect(r.status).toBe(1);
|
|
191
|
+
expect(r.stderr).toContain("bogus");
|
|
192
|
+
expect(r.stderr).toContain("--theme");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("T-env-4b: with --no-env-file the process snapshot holds nothing from the cwd's .env", () => {
|
|
196
|
+
const cwd = tempDir();
|
|
197
|
+
writeFileSync(
|
|
198
|
+
path.join(cwd, ".env"),
|
|
199
|
+
"TERM_THEME=light\nCLAUDEUP_THEME=light\n",
|
|
200
|
+
);
|
|
201
|
+
const harness = writeSnapshotHarness(cwd);
|
|
202
|
+
|
|
203
|
+
const r = spawnBun(cwd, ["--no-env-file", harness]);
|
|
204
|
+
|
|
205
|
+
expect(r.status).toBe(0);
|
|
206
|
+
expect(JSON.parse(r.stdout.trim())).toEqual({});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("T-env-4c (negative control): without --no-env-file the same harness sees TERM_THEME=light — the flag is what isolates", () => {
|
|
210
|
+
const cwd = tempDir();
|
|
211
|
+
writeFileSync(
|
|
212
|
+
path.join(cwd, ".env"),
|
|
213
|
+
"TERM_THEME=light\nCLAUDEUP_THEME=light\n",
|
|
214
|
+
);
|
|
215
|
+
const harness = writeSnapshotHarness(cwd);
|
|
216
|
+
|
|
217
|
+
const r = spawnBun(cwd, [harness]);
|
|
218
|
+
|
|
219
|
+
expect(r.status).toBe(0);
|
|
220
|
+
expect(JSON.parse(r.stdout.trim())).toEqual({
|
|
221
|
+
TERM_THEME: "light",
|
|
222
|
+
CLAUDEUP_THEME: "light",
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("T-env-4d: every source launcher passes --no-env-file", () => {
|
|
227
|
+
// package.json scripts and the bin fallback are the launch surfaces a
|
|
228
|
+
// developer actually uses; a `bun src/main.tsx` without the flag is the
|
|
229
|
+
// bug T-env-4c demonstrates.
|
|
230
|
+
const root = path.join(import.meta.dir, "..", "..");
|
|
231
|
+
const pkg = JSON.parse(
|
|
232
|
+
readFileSync(path.join(root, "package.json"), "utf-8"),
|
|
233
|
+
) as { scripts: Record<string, string> };
|
|
234
|
+
const bin = readFileSync(path.join(root, "bin", "claudeup.js"), "utf-8");
|
|
235
|
+
|
|
236
|
+
for (const name of ["start", "dev"]) {
|
|
237
|
+
expect(pkg.scripts[name]).toContain("--no-env-file");
|
|
238
|
+
expect(pkg.scripts[name]).toContain("src/main.tsx");
|
|
239
|
+
}
|
|
240
|
+
expect(bin).toMatch(/spawnSync\(\s*"bun",\s*\[\s*"--no-env-file"/);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Drop `//` line comments and `/* *\/` blocks so a guard below matches code,
|
|
246
|
+
* never a comment that happens to name the call. Strings in main.tsx contain
|
|
247
|
+
* neither `//` nor `/*`, so the crude regex is exact enough for that one file.
|
|
248
|
+
*/
|
|
249
|
+
function stripComments(source: string): string {
|
|
250
|
+
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, "");
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
describe("main.tsx order", () => {
|
|
254
|
+
const code = stripComments(
|
|
255
|
+
readFileSync(path.join(import.meta.dir, "..", "main.tsx"), "utf-8"),
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
test("T-env-3: the snapshot STATEMENT runs before the loadProjectDotenv() call", () => {
|
|
259
|
+
// Source-level guard. Honest about what it is: nothing at runtime would
|
|
260
|
+
// notice a .env winning, and the TUI cannot be spawned in a test. It
|
|
261
|
+
// anchors on the concrete statement, in comment-stripped source, so the
|
|
262
|
+
// explanatory comment above the call cannot satisfy it (it once did).
|
|
263
|
+
const snapshotAt = code.indexOf("const themeEnv = snapshotThemeEnv()");
|
|
264
|
+
const dotenvAt = code.indexOf("loadProjectDotenv(");
|
|
265
|
+
|
|
266
|
+
expect(snapshotAt).toBeGreaterThan(-1);
|
|
267
|
+
expect(dotenvAt).toBeGreaterThan(-1);
|
|
268
|
+
expect(snapshotAt).toBeLessThan(dotenvAt);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("T-env-3b: main.tsx reads the three theme variables only through the snapshot", () => {
|
|
272
|
+
// A later `process.env.TERM_THEME` would see the merged .env and bypass
|
|
273
|
+
// the snapshot silently; the resolver must be handed `themeEnv` instead.
|
|
274
|
+
for (const name of ["TERM_THEME", "CLAUDEUP_THEME", "COLORFGBG"]) {
|
|
275
|
+
expect(code).not.toMatch(
|
|
276
|
+
new RegExp(`process\\.env(\\.${name}\\b|\\[["'\`]${name}["'\`]\\])`),
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
expect(code).toMatch(/env:\s*themeEnv\b/);
|
|
280
|
+
});
|
|
281
|
+
});
|