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.
Files changed (150) hide show
  1. package/README.md +22 -0
  2. package/bin/claudeup.js +6 -1
  3. package/package.json +6 -6
  4. package/src/__tests__/alias-adopt.test.ts +331 -329
  5. package/src/__tests__/alias-parser.test.ts +384 -376
  6. package/src/__tests__/alias-shell-writer.test.ts +638 -625
  7. package/src/__tests__/alias-store.test.ts +143 -143
  8. package/src/__tests__/catalog-cache-store.test.ts +265 -265
  9. package/src/__tests__/catalog-notice.test.ts +123 -123
  10. package/src/__tests__/cli-apply-seams.test.ts +12 -8
  11. package/src/__tests__/cli-router.test.ts +132 -3
  12. package/src/__tests__/cli-update-view.test.ts +27 -8
  13. package/src/__tests__/community-staleness.test.ts +0 -2
  14. package/src/__tests__/component-badge.test.ts +4 -4
  15. package/src/__tests__/content-drift.test.ts +5 -1
  16. package/src/__tests__/conventions-integration.test.ts +774 -689
  17. package/src/__tests__/conventions-manager.test.ts +1216 -1152
  18. package/src/__tests__/doctor-bins.test.ts +14 -6
  19. package/src/__tests__/doctor.test.ts +438 -425
  20. package/src/__tests__/dual-write-prevention.test.ts +277 -282
  21. package/src/__tests__/enabled-not-installed.test.ts +71 -63
  22. package/src/__tests__/file-locking.test.ts +196 -196
  23. package/src/__tests__/gap-fill-versions.test.ts +318 -311
  24. package/src/__tests__/github-budget.test.ts +170 -164
  25. package/src/__tests__/gitignore-detector.test.ts +160 -152
  26. package/src/__tests__/gitignore-fixer.test.ts +285 -247
  27. package/src/__tests__/gitignore-prerun.test.ts +57 -57
  28. package/src/__tests__/gitignore-resolver.test.ts +173 -146
  29. package/src/__tests__/gitignore-service.test.ts +121 -119
  30. package/src/__tests__/install-command.test.ts +3 -3
  31. package/src/__tests__/install-plan.test.ts +13 -7
  32. package/src/__tests__/manifest.test.ts +16 -5
  33. package/src/__tests__/markdown-renderer.test.ts +0 -1
  34. package/src/__tests__/marketplace-badge.test.ts +111 -90
  35. package/src/__tests__/marketplace-version.test.ts +64 -61
  36. package/src/__tests__/plugin-manager-fallback.test.ts +249 -239
  37. package/src/__tests__/plugin-requires.test.ts +18 -4
  38. package/src/__tests__/plugin-setup.test.ts +12 -12
  39. package/src/__tests__/profile-command.test.ts +1 -7
  40. package/src/__tests__/profile-materializer.test.ts +31 -8
  41. package/src/__tests__/profile-sync.test.ts +23 -5
  42. package/src/__tests__/registry-version-resolution.test.ts +61 -47
  43. package/src/__tests__/resolve-executable.test.ts +1 -1
  44. package/src/__tests__/resolver.test.ts +40 -15
  45. package/src/__tests__/scope-action.test.ts +52 -50
  46. package/src/__tests__/scope-squares.test.tsx +4 -5
  47. package/src/__tests__/skill-install-files.test.ts +111 -103
  48. package/src/__tests__/symlink-manager.test.ts +27 -10
  49. package/src/__tests__/theme-env.test.ts +281 -0
  50. package/src/__tests__/theme-resolve.test.ts +380 -0
  51. package/src/__tests__/update-apply.test.ts +1 -3
  52. package/src/__tests__/useGitignoreModal.test.ts +69 -61
  53. package/src/__tests__/version-snapshot.test.ts +181 -165
  54. package/src/cli/ansi.ts +23 -15
  55. package/src/cli/doctor.ts +10 -9
  56. package/src/cli/install.ts +3 -3
  57. package/src/cli/profile.ts +5 -4
  58. package/src/cli/prompt.ts +0 -1
  59. package/src/cli/router.ts +69 -2
  60. package/src/cli/update-view.ts +11 -6
  61. package/src/cli/update.ts +9 -4
  62. package/src/cli/upgrade.ts +6 -4
  63. package/src/data/alias-flags.ts +260 -260
  64. package/src/data/gitignore-defaults.ts +20 -20
  65. package/src/data/gitignore-reasons.ts +86 -92
  66. package/src/data/gitignore-templates.ts +14 -14
  67. package/src/data/predefined-profiles.ts +8 -9
  68. package/src/data/skill-repos.ts +43 -35
  69. package/src/main.tsx +29 -6
  70. package/src/opentui.d.ts +1 -3
  71. package/src/prerunner/index.ts +7 -8
  72. package/src/services/alias-settings.ts +20 -23
  73. package/src/services/alias-shell-writer.ts +800 -803
  74. package/src/services/alias-store.ts +77 -77
  75. package/src/services/claude-settings.ts +27 -18
  76. package/src/services/content-drift.ts +13 -3
  77. package/src/services/conventions-manager.ts +692 -617
  78. package/src/services/doctor.ts +464 -403
  79. package/src/services/gitignore-detector.ts +113 -103
  80. package/src/services/gitignore-fixer.ts +204 -189
  81. package/src/services/gitignore-prerun.ts +26 -26
  82. package/src/services/gitignore-resolver.ts +162 -144
  83. package/src/services/gitignore-service.ts +120 -117
  84. package/src/services/install-plan.ts +4 -4
  85. package/src/services/manifest.ts +1 -1
  86. package/src/services/marketplace-catalog-git.ts +5 -2
  87. package/src/services/marketplace-fetcher.ts +241 -235
  88. package/src/services/plugin-manager.ts +17 -20
  89. package/src/services/plugin-mcp-config.ts +2 -2
  90. package/src/services/plugin-requires.ts +9 -6
  91. package/src/services/plugin-setup.ts +7 -9
  92. package/src/services/plugin-version-check.ts +4 -4
  93. package/src/services/profile-materializer.ts +9 -5
  94. package/src/services/profile-sync.ts +12 -4
  95. package/src/services/profiles.ts +3 -3
  96. package/src/services/resolver.ts +10 -6
  97. package/src/services/settings-manager.ts +82 -46
  98. package/src/services/skills-manager.ts +11 -9
  99. package/src/services/symlink-manager.ts +2 -5
  100. package/src/services/toolchain.ts +3 -3
  101. package/src/services/update-cache.ts +1 -1
  102. package/src/services/update-engine.ts +4 -4
  103. package/src/services/update-plan.ts +9 -11
  104. package/src/services/version-snapshot.ts +88 -88
  105. package/src/types/gitignore.ts +28 -28
  106. package/src/ui/App.tsx +36 -36
  107. package/src/ui/adapters/settingsAdapter.ts +2 -2
  108. package/src/ui/components/CategoryHeader.tsx +1 -1
  109. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  110. package/src/ui/components/ScopeIndicator.tsx +3 -1
  111. package/src/ui/components/ScrollableList.tsx +2 -1
  112. package/src/ui/components/StyledText.tsx +1 -1
  113. package/src/ui/components/layout/FooterHints.tsx +5 -3
  114. package/src/ui/components/layout/Panel.tsx +2 -2
  115. package/src/ui/components/layout/ScreenLayout.tsx +5 -4
  116. package/src/ui/components/modals/InputModal.tsx +7 -1
  117. package/src/ui/components/modals/ModalContainer.tsx +3 -3
  118. package/src/ui/components/modals/SelectModal.tsx +1 -1
  119. package/src/ui/components/primitives/DetailSection.tsx +1 -1
  120. package/src/ui/components/primitives/KeyValueLine.tsx +1 -1
  121. package/src/ui/components/primitives/ScopeSquares.tsx +2 -2
  122. package/src/ui/hooks/index.ts +0 -1
  123. package/src/ui/hooks/useGitignoreModal.ts +1 -5
  124. package/src/ui/hooks/useMismatchModal.ts +4 -10
  125. package/src/ui/renderers/cliToolRenderers.tsx +2 -2
  126. package/src/ui/renderers/gitignoreRenderers.tsx +2 -2
  127. package/src/ui/renderers/mcpRenderers.tsx +1 -1
  128. package/src/ui/renderers/pluginRenderers.tsx +3 -9
  129. package/src/ui/renderers/profileRenderers.tsx +9 -8
  130. package/src/ui/renderers/settingsRenderers.tsx +4 -4
  131. package/src/ui/renderers/skillRenderers.tsx +14 -14
  132. package/src/ui/screens/AliasScreen.tsx +31 -34
  133. package/src/ui/screens/CliToolsScreen.tsx +14 -13
  134. package/src/ui/screens/EnvVarsScreen.tsx +8 -8
  135. package/src/ui/screens/GitignoreScreen.tsx +15 -14
  136. package/src/ui/screens/McpRegistryScreen.tsx +10 -7
  137. package/src/ui/screens/McpScreen.tsx +10 -10
  138. package/src/ui/screens/PluginsScreen.tsx +17 -21
  139. package/src/ui/screens/ProfilesScreen.tsx +25 -24
  140. package/src/ui/screens/SkillsScreen.tsx +16 -14
  141. package/src/ui/state/AppContext.tsx +4 -8
  142. package/src/ui/state/DimensionsContext.tsx +3 -2
  143. package/src/ui/theme-mode.ts +20 -38
  144. package/src/ui/theme-resolve.ts +148 -0
  145. package/src/utils/clipboard.ts +5 -5
  146. package/src/utils/command-utils.ts +2 -2
  147. package/src/utils/config-dir.ts +1 -3
  148. package/src/utils/file-locking.ts +88 -86
  149. package/src/ui/hooks/useAsyncData.ts +0 -127
  150. 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
- resolveSkillFilePath,
5
- selectSkillFiles,
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
- 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(["SKILL.md", "references/OUTPUT-FORMAT.md", "assets/icon-small.svg"]);
35
- });
36
-
37
- test("REGRESSION: a sibling with a shared name prefix is excluded", () => {
38
- // Without the trailing slash on the prefix, "skills/audit" also matches
39
- // "skills/audit-website/..." and dumps a second skill's files into the
40
- // first one's directory.
41
- expect(
42
- selectSkillFiles(
43
- [
44
- blob("skills/audit/SKILL.md"),
45
- blob("skills/audit-website/SKILL.md"),
46
- blob("skills/audit-website/references/OUTPUT-FORMAT.md"),
47
- ],
48
- "skills/audit",
49
- ),
50
- ).toEqual(["SKILL.md"]);
51
- });
52
-
53
- test("tree entries are skipped — only blobs are downloadable", () => {
54
- expect(
55
- selectSkillFiles(
56
- [
57
- tree("skills/x/references"),
58
- blob("skills/x/SKILL.md"),
59
- blob("skills/x/references/a.md"),
60
- ],
61
- "skills/x",
62
- ),
63
- ).toEqual(["SKILL.md", "references/a.md"]);
64
- });
65
-
66
- test("files outside the skill directory are excluded", () => {
67
- expect(
68
- selectSkillFiles(
69
- [blob("README.md"), blob("other/SKILL.md"), blob("skills/x/SKILL.md")],
70
- "skills/x",
71
- ),
72
- ).toEqual(["SKILL.md"]);
73
- });
74
-
75
- test("a trailing slash on the skill path does not break matching", () => {
76
- expect(
77
- selectSkillFiles([blob("skills/x/SKILL.md")], "skills/x/"),
78
- ).toEqual(["SKILL.md"]);
79
- });
80
-
81
- test("a skill directory with no files yields nothing", () => {
82
- expect(selectSkillFiles([blob("skills/other/SKILL.md")], "skills/x")).toEqual([]);
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
- const dir = path.join(path.sep, "tmp", "skills", "demo");
88
-
89
- test("resolves a plain relative path inside the install directory", () => {
90
- expect(resolveSkillFilePath(dir, "SKILL.md")).toBe(
91
- path.join(dir, "SKILL.md"),
92
- );
93
- });
94
-
95
- test("resolves a nested path", () => {
96
- expect(resolveSkillFilePath(dir, "references/OUTPUT-FORMAT.md")).toBe(
97
- path.join(dir, "references", "OUTPUT-FORMAT.md"),
98
- );
99
- });
100
-
101
- test("SECURITY: rejects paths escaping via ..", () => {
102
- // These paths come from a remote repository and are untrusted input to a
103
- // filesystem write.
104
- expect(resolveSkillFilePath(dir, "../evil.md")).toBeNull();
105
- expect(resolveSkillFilePath(dir, "../../.bashrc")).toBeNull();
106
- expect(resolveSkillFilePath(dir, "refs/../../../evil.md")).toBeNull();
107
- });
108
-
109
- test("SECURITY: rejects absolute paths", () => {
110
- expect(resolveSkillFilePath(dir, path.join(path.sep, "etc", "passwd"))).toBeNull();
111
- });
112
-
113
- test("rejects an empty path", () => {
114
- expect(resolveSkillFilePath(dir, "")).toBeNull();
115
- });
116
-
117
- test("a .. that stays inside the directory is allowed", () => {
118
- expect(resolveSkillFilePath(dir, "refs/../SKILL.md")).toBe(
119
- path.join(dir, "SKILL.md"),
120
- );
121
- });
122
-
123
- test("REGRESSION: a sibling directory sharing the name prefix is rejected", () => {
124
- // path.startsWith without a separator would accept /tmp/skills/demo-evil
125
- // as being inside /tmp/skills/demo.
126
- expect(resolveSkillFilePath(dir, "../demo-evil/x.md")).toBeNull();
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 { mkdtemp, rm, lstat, readlink } from "node:fs/promises";
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) await fs.writeJson(join(dir, "settings.json"), 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((await lstat(join(project, ".claude", "settings.json"))).isSymbolicLink()).toBe(true);
44
- expect((await lstat(join(project, ".mcp.json"))).isSymbolicLink()).toBe(true);
45
- expect((await lstat(join(project, ".claude", "skills"))).isSymbolicLink()).toBe(true);
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(join(project, ".claude", "settings.json"));
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((await lstat(join(project, ".claude", "settings.json"))).isSymbolicLink()).toBe(true);
58
- expect((await fs.readJson(profileDir("frontend", project) + "/settings.json")).a).toBe(2);
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((await fs.readJson(join(project, ".claude", "settings.json"))).which).toBe("frontend");
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((await fs.readJson(join(project, ".claude", "settings.json"))).which).toBe("backend");
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
+ });