setup-agent 0.0.3 → 0.0.4

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/src/cli.ts CHANGED
@@ -1,9 +1,8 @@
1
- /// <reference types="node" />
2
1
  import { connectCheckout } from "./client";
3
2
  import type { CheckoutClient } from "./client";
4
3
  import { INPUT_PRESETS, isPresetId, presetInput } from "./presets";
5
4
  import { spawn } from "node:child_process";
6
- import { readFile, writeFile } from "node:fs/promises";
5
+ import { open, readFile } from "node:fs/promises";
7
6
  import { createInterface } from "node:readline/promises";
8
7
  import { resolve } from "node:path";
9
8
  import {
@@ -14,7 +13,7 @@ import {
14
13
  isAgentPresent,
15
14
  isClosed,
16
15
  isOptionIcon,
17
- parseModelAnswer,
16
+ isValidModelAnswer,
18
17
  parseMultipleAnswer,
19
18
  stepProgress,
20
19
  } from "./protocol";
@@ -30,7 +29,7 @@ Commands (the url always comes first):
30
29
  ask <prompt> [--placeholder <text>] [--optional] [--step <step-id>] [--wait]
31
30
  Ask the user for a line of text.
32
31
  ask [prompt] --preset <framework|llm-provider|project> [--only <id,id>] [--choices <id=description,...>] [--default <id>] [--optional] [--step <step-id>] [--wait]
33
- Ask a standard question. --only restricts the options (one id locks it in); --default preselects one.
32
+ Ask a standard question. --only restricts the options and leaves out --choices (one id locks it in); --default preselects one.
34
33
  "framework" answers "<framework>:<language>"; "llm-provider" answers JSON with provider, model and reasoningEffort; the key itself arrives through "env".
35
34
  "project" lists the React projects you found as --choices "<path>=<what it is>,..." ahead of "New project"; it answers a path, or "new:<next|vite|react-router|tanstack-start|expo>".
36
35
  ask <prompt> --choices <id,id,...> --icons <id=icon,...> [--single] [--default <id>] [--optional] [--step <step-id>] [--wait]
@@ -586,18 +585,50 @@ export const askSeed = (
586
585
  };
587
586
  };
588
587
 
588
+ const assertEnvKey = (key: string) => {
589
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
590
+ throw new Error(`"${key}" is not an environment variable name`);
591
+ }
592
+ };
593
+
589
594
  /** Sets `key=value` in dotenv text, replacing an existing line for the key. */
590
595
  export const upsertEnvLine = (content: string, key: string, value: string) => {
596
+ assertEnvKey(key);
591
597
  const line = `${key}=${value}`;
592
598
  const lines = content === "" ? [] : content.replace(/\n$/, "").split("\n");
593
- const index = lines.findIndex((entry) =>
594
- new RegExp(`^(export\\s+)?${key}\\s*=`).test(entry),
599
+ const index = lines.findIndex(
600
+ (entry) =>
601
+ /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(entry)?.[1] === key,
595
602
  );
596
603
  if (index === -1) lines.push(line);
597
604
  else lines[index] = line;
598
605
  return `${lines.join("\n")}\n`;
599
606
  };
600
607
 
608
+ /**
609
+ * Write the secret `take` returns to `file` under `key`, leaving the file
610
+ * owner-only. The file is opened before `take` runs, because taking the
611
+ * secret consumes it and an unwritable destination must fail while it is
612
+ * still deposited; it is read after, so lines written meanwhile survive.
613
+ */
614
+ export const writeEnvSecret = async (
615
+ file: string,
616
+ key: string,
617
+ take: () => Promise<string>,
618
+ ) => {
619
+ assertEnvKey(key);
620
+ const handle = await open(file, "a+", 0o600);
621
+ try {
622
+ await handle.chmod(0o600);
623
+ const value = await take();
624
+ const current = await handle.readFile("utf8");
625
+ await handle.truncate(0);
626
+ await handle.writeFile(upsertEnvLine(current, key, value));
627
+ } finally {
628
+ await handle.close();
629
+ }
630
+ };
631
+
601
632
  const answerFields = (input: Checkout.Input, answer: string) => ({
602
633
  answer: (input.multiple && parseMultipleAnswer(answer)) || answer,
603
634
  ...(input.note !== undefined && { note: input.note }),
@@ -738,27 +769,36 @@ export const main = async (argv: readonly string[]) => {
738
769
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(envKey)) {
739
770
  fail(`"${envKey}" is not an environment variable name`);
740
771
  }
772
+ const state = requireState(client);
741
773
  const input =
742
- requireState(client).inputs.find(
743
- (candidate) => candidate.id === inputId,
744
- ) ?? fail(`no input "${inputId}"`);
774
+ state.inputs.find((candidate) => candidate.id === inputId) ??
775
+ fail(`no input "${inputId}"`);
776
+ const setupId = state.id ?? fail("the setup has not been created yet");
777
+ if (isClosed(state)) {
778
+ fail("the setup is not open");
779
+ }
745
780
  if (input.status !== "answered") {
746
781
  fail(`input "${inputId}" is ${input.status}`);
747
782
  }
748
- if (parseModelAnswer(input.answer ?? "") === undefined) {
783
+ if (
784
+ input.kind !== "model" ||
785
+ !isValidModelAnswer(input, input.answer ?? "")
786
+ ) {
749
787
  fail(`input "${inputId}" is not an llm-provider answer`);
750
788
  }
751
789
  const file = resolve(flagText(flags, "file") ?? ".env.local");
752
- const response = await fetch(
753
- `${url.replace(/\/$/, "")}/secret/${encodeURIComponent(inputId)}`,
754
- );
755
- if (response.status === 404) {
756
- fail(`the key for "${inputId}" was already taken or never deposited`);
757
- }
758
- if (!response.ok) fail(`could not fetch the key: ${response.status}`);
759
- const value = await response.text();
760
- const current = await readFile(file, "utf8").catch(() => "");
761
- await writeFile(file, upsertEnvLine(current, envKey, value));
790
+ await writeEnvSecret(file, envKey, async () => {
791
+ const response = await fetch(
792
+ `${url.replace(/\/$/, "")}/secret/${encodeURIComponent(inputId)}?setup=${encodeURIComponent(setupId)}`,
793
+ );
794
+ if (response.status === 404) {
795
+ fail(
796
+ `the key for "${inputId}" was already taken or never deposited`,
797
+ );
798
+ }
799
+ if (!response.ok) fail(`could not fetch the key: ${response.status}`);
800
+ return response.text();
801
+ });
762
802
  print({ ok: true, envKey, file });
763
803
  break;
764
804
  }
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { connectCheckout } from "./client";
3
+
4
+ const { created } = vi.hoisted(() => ({
5
+ created: [] as {
6
+ options: { onError: (error: unknown) => void };
7
+ state: unknown;
8
+ listener: () => void;
9
+ dispose: ReturnType<typeof vi.fn>;
10
+ unsubscribe: ReturnType<typeof vi.fn>;
11
+ }[],
12
+ }));
13
+
14
+ vi.mock("statewire", async (importOriginal) => ({
15
+ ...(await importOriginal()),
16
+ StatewireHttp: () => ({}),
17
+ StatewireClient: class {
18
+ state: unknown = undefined;
19
+ listener = () => {};
20
+ connection = { status: "connecting" };
21
+ dispose = vi.fn();
22
+ unsubscribe = vi.fn();
23
+ options: { onError: (error: unknown) => void };
24
+ constructor(options: { onError: (error: unknown) => void }) {
25
+ this.options = options;
26
+ created.push(this);
27
+ }
28
+ subscribe = (listener: () => void) => {
29
+ this.listener = listener;
30
+ return this.unsubscribe;
31
+ };
32
+ },
33
+ }));
34
+
35
+ describe("connectCheckout", () => {
36
+ it("disposes the client when the first connection fails", async () => {
37
+ const connecting = connectCheckout("https://checkout.test/session");
38
+ const client = created.at(-1)!;
39
+ client.options.onError(new Error("offline"));
40
+ await expect(connecting).rejects.toThrow("offline");
41
+ expect(client.unsubscribe).toHaveBeenCalled();
42
+ expect(client.dispose).toHaveBeenCalled();
43
+ });
44
+
45
+ it("keeps the client it resolved with when a later error arrives", async () => {
46
+ const connecting = connectCheckout("https://checkout.test/session");
47
+ const client = created.at(-1)!;
48
+ client.state = {};
49
+ client.listener();
50
+ await expect(connecting).resolves.toBe(client);
51
+ client.options.onError(new Error("later"));
52
+ expect(client.dispose).not.toHaveBeenCalled();
53
+ });
54
+ });
package/src/client.ts CHANGED
@@ -9,16 +9,23 @@ export type CheckoutClient = StatewireClient<
9
9
  /** Attach to a setup by its base URL and resolve once its state has arrived. */
10
10
  export const connectCheckout = (url: string): Promise<CheckoutClient> =>
11
11
  new Promise((resolve, reject) => {
12
+ let settled = false;
13
+ const fail = (error: unknown) => {
14
+ if (settled) return;
15
+ settled = true;
16
+ unsubscribe();
17
+ client.dispose();
18
+ reject(error);
19
+ };
12
20
  const client: CheckoutClient = new StatewireClient({
13
21
  transport: StatewireHttp({ url }),
14
- onError: (error) => reject(error),
22
+ onError: fail,
15
23
  });
16
24
  const settle = () => {
25
+ if (settled) return;
17
26
  if (client.state === undefined) {
18
27
  if (client.connection.status !== "stopped") return;
19
- unsubscribe();
20
- client.dispose();
21
- reject(
28
+ fail(
22
29
  new Error(
23
30
  `setup unreachable: ${client.connection.reason}${
24
31
  client.connection.message ? ` (${client.connection.message})` : ""
@@ -27,6 +34,7 @@ export const connectCheckout = (url: string): Promise<CheckoutClient> =>
27
34
  );
28
35
  return;
29
36
  }
37
+ settled = true;
30
38
  unsubscribe();
31
39
  resolve(client);
32
40
  };