@bastani/atomic 0.6.6-0 → 0.6.6-1
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/.opencode/opencode.json +4 -2
- package/README.md +39 -38
- package/dist/lib/atomic-temp.d.ts +8 -0
- package/dist/lib/atomic-temp.d.ts.map +1 -0
- package/dist/lib/terminal-env.d.ts +9 -0
- package/dist/lib/terminal-env.d.ts.map +1 -0
- package/dist/sdk/providers/claude.d.ts.map +1 -1
- package/dist/sdk/providers/copilot.d.ts +24 -14
- package/dist/sdk/providers/copilot.d.ts.map +1 -1
- package/dist/sdk/runtime/executor.d.ts.map +1 -1
- package/dist/sdk/runtime/tmux.d.ts.map +1 -1
- package/package.json +10 -10
- package/src/commands/cli/chat/index.test.ts +194 -2
- package/src/commands/cli/chat/index.ts +83 -28
- package/src/lib/atomic-temp.test.ts +86 -0
- package/src/lib/atomic-temp.ts +62 -0
- package/src/lib/terminal-env.test.ts +343 -0
- package/src/lib/terminal-env.ts +100 -0
- package/src/scripts/clean-dist.test.ts +53 -0
- package/src/scripts/clean-dist.ts +37 -0
- package/src/sdk/providers/claude.ts +42 -20
- package/src/sdk/providers/copilot.test.ts +365 -0
- package/src/sdk/providers/copilot.ts +117 -15
- package/src/sdk/runtime/cc-debounce.ts +2 -2
- package/src/sdk/runtime/executor.test.ts +68 -0
- package/src/sdk/runtime/executor.ts +26 -9
- package/src/sdk/runtime/tmux.ts +6 -2
- package/src/services/system/auth.test.ts +53 -0
- package/src/services/system/auth.ts +31 -28
- package/src/services/system/detect.ts +1 -1
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
TERMINAL_ENV_KEYS,
|
|
4
|
+
buildLauncherEnv,
|
|
5
|
+
buildSpawnEnv,
|
|
6
|
+
buildTmuxEnv,
|
|
7
|
+
mergeTerminalEnv,
|
|
8
|
+
normalizedTerminalEnv,
|
|
9
|
+
pickTerminalEnv,
|
|
10
|
+
} from "./terminal-env.ts";
|
|
11
|
+
|
|
12
|
+
describe("normalizedTerminalEnv", () => {
|
|
13
|
+
test("missing locale defaults to en_US.UTF-8", () => {
|
|
14
|
+
const env = normalizedTerminalEnv({});
|
|
15
|
+
expect(env["LANG"]).toBe("en_US.UTF-8");
|
|
16
|
+
expect(env["LC_ALL"]).toBe("en_US.UTF-8");
|
|
17
|
+
expect(env["LC_CTYPE"]).toBe("en_US.UTF-8");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("existing UTF-8 locale is preserved", () => {
|
|
21
|
+
const base = { LANG: "en_GB.UTF-8", LC_ALL: "fr_FR.utf8", LC_CTYPE: "C.UTF-8" };
|
|
22
|
+
const env = normalizedTerminalEnv(base);
|
|
23
|
+
expect(env["LANG"]).toBe("en_GB.UTF-8");
|
|
24
|
+
expect(env["LC_ALL"]).toBe("fr_FR.utf8");
|
|
25
|
+
expect(env["LC_CTYPE"]).toBe("C.UTF-8");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("non-UTF-8 locale is replaced with en_US.UTF-8", () => {
|
|
29
|
+
const base = { LANG: "en_US.ISO-8859-1", LC_ALL: "C", LC_CTYPE: "POSIX" };
|
|
30
|
+
const env = normalizedTerminalEnv(base);
|
|
31
|
+
expect(env["LANG"]).toBe("en_US.UTF-8");
|
|
32
|
+
expect(env["LC_ALL"]).toBe("en_US.UTF-8");
|
|
33
|
+
expect(env["LC_CTYPE"]).toBe("en_US.UTF-8");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("TERM=dumb becomes xterm-256color", () => {
|
|
37
|
+
const env = normalizedTerminalEnv({ TERM: "dumb" });
|
|
38
|
+
expect(env["TERM"]).toBe("xterm-256color");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("missing TERM defaults to xterm-256color", () => {
|
|
42
|
+
const env = normalizedTerminalEnv({});
|
|
43
|
+
expect(env["TERM"]).toBe("xterm-256color");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("explicit COLORTERM is preserved", () => {
|
|
47
|
+
const env = normalizedTerminalEnv({ COLORTERM: "24bit" });
|
|
48
|
+
expect(env["COLORTERM"]).toBe("24bit");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("missing COLORTERM defaults to truecolor", () => {
|
|
52
|
+
const env = normalizedTerminalEnv({});
|
|
53
|
+
expect(env["COLORTERM"]).toBe("truecolor");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("other env vars carried through unchanged", () => {
|
|
57
|
+
const env = normalizedTerminalEnv({ HOME: "/root", PATH: "/usr/bin" });
|
|
58
|
+
expect(env["HOME"]).toBe("/root");
|
|
59
|
+
expect(env["PATH"]).toBe("/usr/bin");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("undefined values dropped", () => {
|
|
63
|
+
const base: NodeJS.ProcessEnv = { SOME_VAR: undefined };
|
|
64
|
+
const env = normalizedTerminalEnv(base);
|
|
65
|
+
expect("SOME_VAR" in env).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("mergeTerminalEnv", () => {
|
|
70
|
+
test("explicit env vars win over defaults", () => {
|
|
71
|
+
const env = mergeTerminalEnv(
|
|
72
|
+
{ LANG: "ja_JP.UTF-8", TERM: "screen", COLORTERM: "256" },
|
|
73
|
+
{},
|
|
74
|
+
);
|
|
75
|
+
expect(env["LANG"]).toBe("ja_JP.UTF-8");
|
|
76
|
+
expect(env["TERM"]).toBe("screen");
|
|
77
|
+
expect(env["COLORTERM"]).toBe("256");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("missing keys still get sane defaults", () => {
|
|
81
|
+
const env = mergeTerminalEnv({}, {});
|
|
82
|
+
expect(env["LANG"]).toBe("en_US.UTF-8");
|
|
83
|
+
expect(env["TERM"]).toBe("xterm-256color");
|
|
84
|
+
expect(env["COLORTERM"]).toBe("truecolor");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("explicit vars merged on top of baseEnv normalization", () => {
|
|
88
|
+
const base = { LANG: "C", TERM: "dumb" };
|
|
89
|
+
const env = mergeTerminalEnv({ LANG: "de_DE.UTF-8" }, base);
|
|
90
|
+
// explicit wins
|
|
91
|
+
expect(env["LANG"]).toBe("de_DE.UTF-8");
|
|
92
|
+
// TERM=dumb normalized then no override → xterm-256color
|
|
93
|
+
expect(env["TERM"]).toBe("xterm-256color");
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("TERMINAL_ENV_KEYS", () => {
|
|
98
|
+
test("contains exactly the five expected keys", () => {
|
|
99
|
+
expect(TERMINAL_ENV_KEYS).toEqual(["LANG", "LC_ALL", "LC_CTYPE", "TERM", "COLORTERM"]);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("pickTerminalEnv", () => {
|
|
104
|
+
test("picks only TERMINAL_ENV_KEYS", () => {
|
|
105
|
+
const env = { LANG: "en_US.UTF-8", TERM: "xterm", HOME: "/home/user", PATH: "/usr/bin" };
|
|
106
|
+
const picked = pickTerminalEnv(env);
|
|
107
|
+
expect(Object.keys(picked)).toEqual(expect.arrayContaining(["LANG", "TERM"]));
|
|
108
|
+
expect("HOME" in picked).toBe(false);
|
|
109
|
+
expect("PATH" in picked).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("omits absent keys rather than setting undefined", () => {
|
|
113
|
+
const env = { LANG: "en_US.UTF-8" };
|
|
114
|
+
const picked = pickTerminalEnv(env);
|
|
115
|
+
expect("LC_ALL" in picked).toBe(false);
|
|
116
|
+
expect("TERM" in picked).toBe(false);
|
|
117
|
+
expect("COLORTERM" in picked).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("returns all five when all present", () => {
|
|
121
|
+
const env = { LANG: "a", LC_ALL: "b", LC_CTYPE: "c", TERM: "d", COLORTERM: "e" };
|
|
122
|
+
const picked = pickTerminalEnv(env);
|
|
123
|
+
expect(Object.keys(picked).sort()).toEqual(["COLORTERM", "LANG", "LC_ALL", "LC_CTYPE", "TERM"]);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("buildSpawnEnv", () => {
|
|
128
|
+
test("includes full normalized baseEnv", () => {
|
|
129
|
+
const base = { HOME: "/root", PATH: "/usr/bin", TERM: "xterm-256color" };
|
|
130
|
+
const env = buildSpawnEnv({ MY_VAR: "1" }, base);
|
|
131
|
+
expect(env["HOME"]).toBe("/root");
|
|
132
|
+
expect(env["PATH"]).toBe("/usr/bin");
|
|
133
|
+
expect(env["MY_VAR"]).toBe("1");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("explicit env wins over baseEnv", () => {
|
|
137
|
+
const base = { LANG: "C", TERM: "dumb" };
|
|
138
|
+
const env = buildSpawnEnv({ LANG: "ja_JP.UTF-8" }, base);
|
|
139
|
+
expect(env["LANG"]).toBe("ja_JP.UTF-8");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("applies sane terminal defaults from baseEnv", () => {
|
|
143
|
+
const env = buildSpawnEnv({}, {});
|
|
144
|
+
expect(env["TERM"]).toBe("xterm-256color");
|
|
145
|
+
expect(env["COLORTERM"]).toBe("truecolor");
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("buildLauncherEnv", () => {
|
|
150
|
+
test("does NOT include non-terminal keys from baseEnv", () => {
|
|
151
|
+
const base = { HOME: "/root", PATH: "/usr/bin", LANG: "en_US.UTF-8", TERM: "xterm-256color", COLORTERM: "truecolor" };
|
|
152
|
+
const env = buildLauncherEnv({}, base);
|
|
153
|
+
expect("HOME" in env).toBe(false);
|
|
154
|
+
expect("PATH" in env).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("includes TERMINAL_ENV_KEYS from normalized baseEnv", () => {
|
|
158
|
+
const base = { LANG: "C", TERM: "dumb" };
|
|
159
|
+
const env = buildLauncherEnv({}, base);
|
|
160
|
+
// normalization applies: C → en_US.UTF-8, dumb → xterm-256color
|
|
161
|
+
expect(env["LANG"]).toBe("en_US.UTF-8");
|
|
162
|
+
expect(env["TERM"]).toBe("xterm-256color");
|
|
163
|
+
expect(env["COLORTERM"]).toBe("truecolor");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("explicit env wins and is included", () => {
|
|
167
|
+
const base = { LANG: "en_US.UTF-8", TERM: "xterm-256color" };
|
|
168
|
+
const env = buildLauncherEnv({ LANG: "ja_JP.UTF-8", MY_VAR: "hello" }, base);
|
|
169
|
+
expect(env["LANG"]).toBe("ja_JP.UTF-8");
|
|
170
|
+
expect(env["MY_VAR"]).toBe("hello");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("no process.env leakage with empty baseEnv", () => {
|
|
174
|
+
const env = buildLauncherEnv({}, {});
|
|
175
|
+
// Only terminal keys + sane defaults, no PATH/HOME from process.env
|
|
176
|
+
const envKeys = Object.keys(env);
|
|
177
|
+
const nonTerminalKeys = envKeys.filter(
|
|
178
|
+
(k) => !(TERMINAL_ENV_KEYS as readonly string[]).includes(k),
|
|
179
|
+
);
|
|
180
|
+
expect(nonTerminalKeys).toEqual([]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("excludes secret env vars: GH_TOKEN, COPILOT_GITHUB_TOKEN, ANTHROPIC_API_KEY", () => {
|
|
184
|
+
const base = {
|
|
185
|
+
LANG: "en_US.UTF-8",
|
|
186
|
+
TERM: "xterm-256color",
|
|
187
|
+
COLORTERM: "truecolor",
|
|
188
|
+
GH_TOKEN: "ghp_secret",
|
|
189
|
+
COPILOT_GITHUB_TOKEN: "ghu_secret",
|
|
190
|
+
ANTHROPIC_API_KEY: "sk-ant-secret",
|
|
191
|
+
HOME: "/home/user",
|
|
192
|
+
PATH: "/usr/bin:/bin",
|
|
193
|
+
};
|
|
194
|
+
const env = buildLauncherEnv({}, base);
|
|
195
|
+
expect("GH_TOKEN" in env).toBe(false);
|
|
196
|
+
expect("COPILOT_GITHUB_TOKEN" in env).toBe(false);
|
|
197
|
+
expect("ANTHROPIC_API_KEY" in env).toBe(false);
|
|
198
|
+
expect("HOME" in env).toBe(false);
|
|
199
|
+
expect("PATH" in env).toBe(false);
|
|
200
|
+
// terminal keys still present
|
|
201
|
+
expect(env["LANG"]).toBe("en_US.UTF-8");
|
|
202
|
+
expect(env["TERM"]).toBe("xterm-256color");
|
|
203
|
+
expect(env["COLORTERM"]).toBe("truecolor");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("buildSpawnEnv preserves full inherited env including non-terminal keys", () => {
|
|
207
|
+
const base = {
|
|
208
|
+
HOME: "/home/user",
|
|
209
|
+
PATH: "/usr/bin:/bin",
|
|
210
|
+
GH_TOKEN: "ghp_secret",
|
|
211
|
+
LANG: "C",
|
|
212
|
+
TERM: "dumb",
|
|
213
|
+
};
|
|
214
|
+
const env = buildSpawnEnv({ MY_EXPLICIT: "yes" }, base);
|
|
215
|
+
// full env inherited
|
|
216
|
+
expect(env["HOME"]).toBe("/home/user");
|
|
217
|
+
expect(env["PATH"]).toBe("/usr/bin:/bin");
|
|
218
|
+
expect(env["GH_TOKEN"]).toBe("ghp_secret");
|
|
219
|
+
// normalization applied
|
|
220
|
+
expect(env["LANG"]).toBe("en_US.UTF-8");
|
|
221
|
+
expect(env["TERM"]).toBe("xterm-256color");
|
|
222
|
+
// explicit override present
|
|
223
|
+
expect(env["MY_EXPLICIT"]).toBe("yes");
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe("buildTmuxEnv", () => {
|
|
228
|
+
const SENSITIVE_BASE = {
|
|
229
|
+
LANG: "en_US.UTF-8",
|
|
230
|
+
LC_ALL: "en_US.UTF-8",
|
|
231
|
+
LC_CTYPE: "en_US.UTF-8",
|
|
232
|
+
TERM: "xterm-256color",
|
|
233
|
+
COLORTERM: "truecolor",
|
|
234
|
+
GH_TOKEN: "ghp_secret",
|
|
235
|
+
COPILOT_GITHUB_TOKEN: "ghu_secret",
|
|
236
|
+
ANTHROPIC_API_KEY: "sk-ant-secret",
|
|
237
|
+
OPENAI_API_KEY: "sk-openai-secret",
|
|
238
|
+
HOME: "/home/user",
|
|
239
|
+
PATH: "/usr/bin:/bin",
|
|
240
|
+
ARBITRARY_VAR: "should-not-appear",
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
test("excludes GH_TOKEN", () => {
|
|
244
|
+
const env = buildTmuxEnv({}, SENSITIVE_BASE);
|
|
245
|
+
expect("GH_TOKEN" in env).toBe(false);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("excludes COPILOT_GITHUB_TOKEN", () => {
|
|
249
|
+
const env = buildTmuxEnv({}, SENSITIVE_BASE);
|
|
250
|
+
expect("COPILOT_GITHUB_TOKEN" in env).toBe(false);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("excludes ANTHROPIC_API_KEY", () => {
|
|
254
|
+
const env = buildTmuxEnv({}, SENSITIVE_BASE);
|
|
255
|
+
expect("ANTHROPIC_API_KEY" in env).toBe(false);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("excludes OPENAI_API_KEY", () => {
|
|
259
|
+
const env = buildTmuxEnv({}, SENSITIVE_BASE);
|
|
260
|
+
expect("OPENAI_API_KEY" in env).toBe(false);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("excludes HOME", () => {
|
|
264
|
+
const env = buildTmuxEnv({}, SENSITIVE_BASE);
|
|
265
|
+
expect("HOME" in env).toBe(false);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("excludes PATH", () => {
|
|
269
|
+
const env = buildTmuxEnv({}, SENSITIVE_BASE);
|
|
270
|
+
expect("PATH" in env).toBe(false);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("excludes arbitrary inherited env vars", () => {
|
|
274
|
+
const env = buildTmuxEnv({}, SENSITIVE_BASE);
|
|
275
|
+
expect("ARBITRARY_VAR" in env).toBe(false);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("includes normalized LANG", () => {
|
|
279
|
+
const env = buildTmuxEnv({}, { LANG: "C" });
|
|
280
|
+
expect(env["LANG"]).toBe("en_US.UTF-8");
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("includes normalized LC_ALL", () => {
|
|
284
|
+
const env = buildTmuxEnv({}, { LC_ALL: "POSIX" });
|
|
285
|
+
expect(env["LC_ALL"]).toBe("en_US.UTF-8");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("includes normalized LC_CTYPE", () => {
|
|
289
|
+
const env = buildTmuxEnv({}, { LC_CTYPE: "en_US.ISO-8859-1" });
|
|
290
|
+
expect(env["LC_CTYPE"]).toBe("en_US.UTF-8");
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("includes normalized TERM (dumb → xterm-256color)", () => {
|
|
294
|
+
const env = buildTmuxEnv({}, { TERM: "dumb" });
|
|
295
|
+
expect(env["TERM"]).toBe("xterm-256color");
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("includes normalized COLORTERM default", () => {
|
|
299
|
+
const env = buildTmuxEnv({}, {});
|
|
300
|
+
expect(env["COLORTERM"]).toBe("truecolor");
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("preserves explicit UTF-8 LANG", () => {
|
|
304
|
+
const env = buildTmuxEnv({}, { LANG: "ja_JP.UTF-8" });
|
|
305
|
+
expect(env["LANG"]).toBe("ja_JP.UTF-8");
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("preserves explicit TERM when not dumb", () => {
|
|
309
|
+
const env = buildTmuxEnv({}, { TERM: "screen-256color" });
|
|
310
|
+
expect(env["TERM"]).toBe("screen-256color");
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("includes explicit ATOMIC_AGENT", () => {
|
|
314
|
+
const env = buildTmuxEnv({ ATOMIC_AGENT: "copilot" }, {});
|
|
315
|
+
expect(env["ATOMIC_AGENT"]).toBe("copilot");
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("includes explicit COPILOT_CUSTOM_INSTRUCTIONS_DIRS", () => {
|
|
319
|
+
const env = buildTmuxEnv({ COPILOT_CUSTOM_INSTRUCTIONS_DIRS: "/a:/b" }, {});
|
|
320
|
+
expect(env["COPILOT_CUSTOM_INSTRUCTIONS_DIRS"]).toBe("/a:/b");
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("explicit env wins over baseEnv for terminal keys", () => {
|
|
324
|
+
const env = buildTmuxEnv({ LANG: "de_DE.UTF-8", TERM: "screen" }, { LANG: "C", TERM: "dumb" });
|
|
325
|
+
expect(env["LANG"]).toBe("de_DE.UTF-8");
|
|
326
|
+
expect(env["TERM"]).toBe("screen");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("only TERMINAL_ENV_KEYS + explicit vars — no leakage", () => {
|
|
330
|
+
const env = buildTmuxEnv({ ATOMIC_AGENT: "claude" }, SENSITIVE_BASE);
|
|
331
|
+
const envKeys = Object.keys(env);
|
|
332
|
+
const allowedKeys = new Set([...(TERMINAL_ENV_KEYS as readonly string[]), "ATOMIC_AGENT"]);
|
|
333
|
+
const leaked = envKeys.filter((k) => !allowedKeys.has(k));
|
|
334
|
+
expect(leaked).toEqual([]);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("all TERMINAL_ENV_KEYS present with empty baseEnv", () => {
|
|
338
|
+
const env = buildTmuxEnv({}, {});
|
|
339
|
+
for (const key of TERMINAL_ENV_KEYS) {
|
|
340
|
+
expect(key in env).toBe(true);
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const UTF8_RE = /utf-?8/i;
|
|
2
|
+
|
|
3
|
+
function isUtf8(value: string): boolean {
|
|
4
|
+
return UTF8_RE.test(value);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const DEFAULT_LOCALE = "en_US.UTF-8";
|
|
8
|
+
const DEFAULT_TERM = "xterm-256color";
|
|
9
|
+
const DEFAULT_COLORTERM = "truecolor";
|
|
10
|
+
|
|
11
|
+
const LOCALE_KEYS = ["LANG", "LC_ALL", "LC_CTYPE"] as const;
|
|
12
|
+
|
|
13
|
+
export const TERMINAL_ENV_KEYS = [
|
|
14
|
+
"LANG",
|
|
15
|
+
"LC_ALL",
|
|
16
|
+
"LC_CTYPE",
|
|
17
|
+
"TERM",
|
|
18
|
+
"COLORTERM",
|
|
19
|
+
] as const;
|
|
20
|
+
|
|
21
|
+
export type TerminalEnvKey = (typeof TERMINAL_ENV_KEYS)[number];
|
|
22
|
+
|
|
23
|
+
export function normalizedTerminalEnv(
|
|
24
|
+
baseEnv: NodeJS.ProcessEnv = process.env,
|
|
25
|
+
): Record<string, string> {
|
|
26
|
+
const result: Record<string, string> = {};
|
|
27
|
+
|
|
28
|
+
for (const [key, value] of Object.entries(baseEnv)) {
|
|
29
|
+
if (typeof value === "string") {
|
|
30
|
+
result[key] = value;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
for (const key of LOCALE_KEYS) {
|
|
35
|
+
const existing = result[key];
|
|
36
|
+
if (!existing || !isUtf8(existing)) {
|
|
37
|
+
result[key] = DEFAULT_LOCALE;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const term = result["TERM"];
|
|
42
|
+
if (!term || term === "dumb") {
|
|
43
|
+
result["TERM"] = DEFAULT_TERM;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!result["COLORTERM"]) {
|
|
47
|
+
result["COLORTERM"] = DEFAULT_COLORTERM;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function mergeTerminalEnv(
|
|
54
|
+
envVars: Record<string, string> = {},
|
|
55
|
+
baseEnv: NodeJS.ProcessEnv = process.env,
|
|
56
|
+
): Record<string, string> {
|
|
57
|
+
const defaults = normalizedTerminalEnv(baseEnv);
|
|
58
|
+
return { ...defaults, ...envVars };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function pickTerminalEnv(
|
|
62
|
+
env: Record<string, string>,
|
|
63
|
+
): Partial<Record<TerminalEnvKey, string>> {
|
|
64
|
+
const result: Partial<Record<TerminalEnvKey, string>> = {};
|
|
65
|
+
for (const key of TERMINAL_ENV_KEYS) {
|
|
66
|
+
if (key in env) {
|
|
67
|
+
result[key] = env[key];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function buildSpawnEnv(
|
|
74
|
+
explicitEnv: Record<string, string>,
|
|
75
|
+
baseEnv: NodeJS.ProcessEnv = process.env,
|
|
76
|
+
): Record<string, string> {
|
|
77
|
+
return { ...normalizedTerminalEnv(baseEnv), ...explicitEnv };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function buildMinimalEnv(
|
|
81
|
+
explicitEnv: Record<string, string>,
|
|
82
|
+
baseEnv: NodeJS.ProcessEnv = process.env,
|
|
83
|
+
): Record<string, string> {
|
|
84
|
+
const terminalEnv = pickTerminalEnv(normalizedTerminalEnv(baseEnv));
|
|
85
|
+
return { ...terminalEnv, ...explicitEnv };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function buildLauncherEnv(
|
|
89
|
+
explicitEnv: Record<string, string>,
|
|
90
|
+
baseEnv: NodeJS.ProcessEnv = process.env,
|
|
91
|
+
): Record<string, string> {
|
|
92
|
+
return buildMinimalEnv(explicitEnv, baseEnv);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function buildTmuxEnv(
|
|
96
|
+
explicitEnv: Record<string, string>,
|
|
97
|
+
baseEnv: NodeJS.ProcessEnv = process.env,
|
|
98
|
+
): Record<string, string> {
|
|
99
|
+
return buildLauncherEnv(explicitEnv, baseEnv);
|
|
100
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdir, writeFile, access } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { cleanDist } from "./clean-dist.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Verify cleanDist removes files using a temp directory stand-in.
|
|
9
|
+
*
|
|
10
|
+
* cleanDist hard-codes DIST = resolve(ROOT, "dist"), so we test the
|
|
11
|
+
* exported helper by temporarily monkey-patching `process.env` is NOT
|
|
12
|
+
* needed — instead we test the real cleanDist against the real dist
|
|
13
|
+
* path when dist does not exist (no-op / force), and separately verify
|
|
14
|
+
* the file-removal behavior via a parallel helper that mirrors the
|
|
15
|
+
* implementation with a temp path.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
async function pathExists(p: string): Promise<boolean> {
|
|
19
|
+
return access(p).then(
|
|
20
|
+
() => true,
|
|
21
|
+
() => false,
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("cleanDist", () => {
|
|
26
|
+
test("removes nested files and directories", async () => {
|
|
27
|
+
// Build a temp tree that mirrors what a dist/ dir might look like.
|
|
28
|
+
const tmp = join(tmpdir(), `atomic-clean-dist-test-${crypto.randomUUID()}`);
|
|
29
|
+
const nested = join(tmp, "subdir", "deeply", "nested");
|
|
30
|
+
await mkdir(nested, { recursive: true });
|
|
31
|
+
await writeFile(join(tmp, "index.js"), "// bundle");
|
|
32
|
+
await writeFile(join(nested, "chunk.js"), "// chunk");
|
|
33
|
+
|
|
34
|
+
// Confirm setup
|
|
35
|
+
expect(await pathExists(tmp)).toBe(true);
|
|
36
|
+
expect(await pathExists(join(nested, "chunk.js"))).toBe(true);
|
|
37
|
+
|
|
38
|
+
// Use node:fs/promises rm directly (same API as cleanDist) to verify
|
|
39
|
+
// the underlying approach works for arbitrary paths — mirrors the
|
|
40
|
+
// cleanDist implementation without coupling to its hard-coded DIST path.
|
|
41
|
+
const { rm } = await import("node:fs/promises");
|
|
42
|
+
await rm(tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
43
|
+
|
|
44
|
+
expect(await pathExists(tmp)).toBe(false);
|
|
45
|
+
expect(await pathExists(join(nested, "chunk.js"))).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("cleanDist is idempotent when dist does not exist (force=true, no throw)", async () => {
|
|
49
|
+
// cleanDist uses force:true so calling it when dist is absent should not throw.
|
|
50
|
+
// This also exercises the real export without side effects in CI (dist may or may not exist).
|
|
51
|
+
await expect(cleanDist()).resolves.toBeUndefined();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Removes the dist directory relative to the repo root.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* bun run src/scripts/clean-dist.ts
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { rm, access } from "node:fs/promises";
|
|
10
|
+
import { resolve } from "node:path";
|
|
11
|
+
|
|
12
|
+
const ROOT = resolve(import.meta.dir, "../..");
|
|
13
|
+
const DIST = resolve(ROOT, "dist");
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Removes the repo-local dist directory and verifies it no longer exists.
|
|
17
|
+
*
|
|
18
|
+
* @throws {Error} with path-specific message if dist still exists after removal.
|
|
19
|
+
*/
|
|
20
|
+
export async function cleanDist(): Promise<void> {
|
|
21
|
+
await rm(DIST, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
22
|
+
|
|
23
|
+
const stillExists = await access(DIST).then(
|
|
24
|
+
() => true,
|
|
25
|
+
() => false,
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
if (stillExists) {
|
|
29
|
+
throw new Error(`Cleanup failed: "${DIST}" still exists after removal`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Run when executed directly (not imported).
|
|
34
|
+
if (import.meta.main) {
|
|
35
|
+
await cleanDist();
|
|
36
|
+
console.log(`Removed: ${DIST}`);
|
|
37
|
+
}
|
|
@@ -30,13 +30,17 @@ import { watch, unlink, mkdir, writeFile } from "node:fs/promises";
|
|
|
30
30
|
import { existsSync, writeFileSync } from "node:fs";
|
|
31
31
|
import { join } from "node:path";
|
|
32
32
|
import { randomUUID } from "node:crypto";
|
|
33
|
-
import os from "node:os";
|
|
34
33
|
import { claudeHookDirs } from "../../commands/cli/claude-stop-hook.ts";
|
|
35
34
|
import {
|
|
36
35
|
clearInflightTracking,
|
|
37
36
|
waitForInflightDrained,
|
|
38
37
|
} from "../../commands/cli/claude-inflight-hook.ts";
|
|
39
38
|
import { resolveAdditionalInstructionsContent } from "../../services/config/additional-instructions.ts";
|
|
39
|
+
import {
|
|
40
|
+
atomicContentTempPath,
|
|
41
|
+
atomicTempPath,
|
|
42
|
+
withAtomicTempEnv,
|
|
43
|
+
} from "../../lib/atomic-temp.ts";
|
|
40
44
|
|
|
41
45
|
// ---------------------------------------------------------------------------
|
|
42
46
|
// Session tracking — ensures createClaudeSession is called before claudeQuery
|
|
@@ -423,18 +427,17 @@ async function spawnClaudeWithPrompt(
|
|
|
423
427
|
chatFlags: string[],
|
|
424
428
|
sessionId: string,
|
|
425
429
|
): Promise<void> {
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
// POSIX and PowerShell alike.
|
|
429
|
-
const argvPrompt = `'${readPromptInstruction(promptFile)}'`;
|
|
430
|
+
const settingsPath = workflowHookSettingsPath();
|
|
431
|
+
const argvPrompt = `"${escBash(readPromptInstruction(promptFile))}"`;
|
|
430
432
|
const cmd = [
|
|
431
433
|
"claude",
|
|
432
434
|
...chatFlags,
|
|
433
|
-
// Workflow-owned
|
|
434
|
-
//
|
|
435
|
-
// non-overridable by `.atomic/settings.json` chatFlags overrides.
|
|
435
|
+
// Workflow-owned hooks. Placed AFTER chatFlags so commander's last-wins
|
|
436
|
+
// semantics shadow any user-provided --settings, making this
|
|
437
|
+
// non-overridable by `.atomic/settings.json` chatFlags overrides. Passing
|
|
438
|
+
// a path avoids Claude Code's content-hashed /tmp/claude-settings*.json.
|
|
436
439
|
"--settings",
|
|
437
|
-
`
|
|
440
|
+
`"${escBash(settingsPath)}"`,
|
|
438
441
|
"--session-id",
|
|
439
442
|
sessionId,
|
|
440
443
|
argvPrompt,
|
|
@@ -454,6 +457,19 @@ async function spawnClaudeWithPrompt(
|
|
|
454
457
|
await waitForReadyMarker(sessionId);
|
|
455
458
|
}
|
|
456
459
|
|
|
460
|
+
function workflowHookSettingsPath(): string {
|
|
461
|
+
const path = atomicContentTempPath(
|
|
462
|
+
"claude-settings-atomic",
|
|
463
|
+
".json",
|
|
464
|
+
WORKFLOW_HOOK_SETTINGS,
|
|
465
|
+
);
|
|
466
|
+
writeFileSync(path, WORKFLOW_HOOK_SETTINGS, {
|
|
467
|
+
encoding: "utf-8",
|
|
468
|
+
mode: 0o600,
|
|
469
|
+
});
|
|
470
|
+
return path;
|
|
471
|
+
}
|
|
472
|
+
|
|
457
473
|
/**
|
|
458
474
|
* Wait for the SessionStart hook's ready marker at
|
|
459
475
|
* `~/.atomic/claude-ready/<session_id>`.
|
|
@@ -1055,11 +1071,15 @@ export async function claudeQuery(options: ClaudeQueryOptions): Promise<SessionM
|
|
|
1055
1071
|
// Read-tool indirection. The tmp file only has to live long enough
|
|
1056
1072
|
// for Claude's first Read tool call, so we delete it once waitForIdle
|
|
1057
1073
|
// returns (the turn is complete by then).
|
|
1058
|
-
spawnPromptFile =
|
|
1059
|
-
|
|
1060
|
-
|
|
1074
|
+
spawnPromptFile = atomicTempPath(
|
|
1075
|
+
"atomic-claude-prompt",
|
|
1076
|
+
".txt",
|
|
1077
|
+
`${claudeSessionId}-${randomUUID()}`,
|
|
1061
1078
|
);
|
|
1062
|
-
writeFileSync(spawnPromptFile, prompt,
|
|
1079
|
+
writeFileSync(spawnPromptFile, prompt, {
|
|
1080
|
+
encoding: "utf-8",
|
|
1081
|
+
mode: 0o600,
|
|
1082
|
+
});
|
|
1063
1083
|
|
|
1064
1084
|
await spawnClaudeWithPrompt(
|
|
1065
1085
|
paneId,
|
|
@@ -1369,15 +1389,17 @@ export class HeadlessClaudeSessionWrapper {
|
|
|
1369
1389
|
let sdkSessionId = "";
|
|
1370
1390
|
let structuredOutput: unknown = undefined;
|
|
1371
1391
|
try {
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1392
|
+
await withAtomicTempEnv(async () => {
|
|
1393
|
+
for await (const msg of sdkQuery({ prompt, options: headlessSdkOpts })) {
|
|
1394
|
+
if (msg.type === "result") {
|
|
1395
|
+
const record = msg as Record<string, unknown>;
|
|
1396
|
+
sdkSessionId = String(record.session_id ?? "");
|
|
1397
|
+
if (record.subtype === "success" && "structured_output" in record) {
|
|
1398
|
+
structuredOutput = record.structured_output;
|
|
1399
|
+
}
|
|
1378
1400
|
}
|
|
1379
1401
|
}
|
|
1380
|
-
}
|
|
1402
|
+
});
|
|
1381
1403
|
} catch (err) {
|
|
1382
1404
|
const detail = err instanceof Error ? err.message : String(err);
|
|
1383
1405
|
throw new Error(`Claude SDK query failed: ${detail}`);
|