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/LICENSE +21 -0
- package/README.md +7 -3
- package/dist/cli.d.ts +50 -46
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +39 -13
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +3 -6
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +12 -4
- package/dist/client.js.map +1 -1
- package/dist/host.d.ts +7 -9
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +75 -72
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +7 -4
- package/dist/index.d.ts.map +1 -0
- package/dist/presets.d.ts +23 -25
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +4 -3
- package/dist/presets.js.map +1 -1
- package/dist/protocol.d.ts +260 -262
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +5 -3
- package/dist/protocol.js.map +1 -1
- package/package.json +20 -13
- package/src/cli.test.ts +464 -0
- package/src/cli.ts +60 -20
- package/src/client.test.ts +54 -0
- package/src/client.ts +12 -4
- package/src/host.test.ts +883 -0
- package/src/host.ts +25 -2
- package/src/presets.ts +4 -3
- package/src/protocol.ts +10 -3
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 {
|
|
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
|
-
|
|
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(
|
|
594
|
-
|
|
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
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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 (
|
|
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
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
};
|