@bastani/atomic 0.6.6-0 → 0.6.6

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.
@@ -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
- // sessionDir is the workflow's `${name}-${sessionId}` directory under
427
- // ~/.atomic/sessions slug-based, so single-quoting is sufficient on
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 Stop hook. Placed AFTER chatFlags so commander's
434
- // last-wins semantics shadow any user-provided --settings, making this
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
- `'${WORKFLOW_HOOK_SETTINGS}'`,
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 = join(
1059
- os.tmpdir(),
1060
- `atomic-claude-prompt-${claudeSessionId}-${randomUUID()}.txt`,
1074
+ spawnPromptFile = atomicTempPath(
1075
+ "atomic-claude-prompt",
1076
+ ".txt",
1077
+ `${claudeSessionId}-${randomUUID()}`,
1061
1078
  );
1062
- writeFileSync(spawnPromptFile, prompt, "utf-8");
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
- for await (const msg of sdkQuery({ prompt, options: headlessSdkOpts })) {
1373
- if (msg.type === "result") {
1374
- const record = msg as Record<string, unknown>;
1375
- sdkSessionId = String(record.session_id ?? "");
1376
- if (record.subtype === "success" && "structured_output" in record) {
1377
- structuredOutput = record.structured_output;
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}`);