setup-agent 0.0.2 → 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 +10 -6
- package/dist/cli.d.ts +51 -45
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +56 -30
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +4 -7
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +13 -5
- package/dist/client.js.map +1 -1
- package/dist/host.d.ts +8 -10
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +79 -76
- 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 +21 -14
- package/src/cli.test.ts +464 -0
- package/src/cli.ts +80 -40
- package/src/client.test.ts +54 -0
- package/src/client.ts +14 -6
- package/src/host.test.ts +883 -0
- package/src/host.ts +30 -7
- 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,23 +13,23 @@ import {
|
|
|
14
13
|
isAgentPresent,
|
|
15
14
|
isClosed,
|
|
16
15
|
isOptionIcon,
|
|
17
|
-
|
|
16
|
+
isValidModelAnswer,
|
|
18
17
|
parseMultipleAnswer,
|
|
19
18
|
stepProgress,
|
|
20
19
|
} from "./protocol";
|
|
21
20
|
import type { Checkout } from "./protocol";
|
|
22
21
|
|
|
23
|
-
const HELP = `setup-agent <url> [command]
|
|
22
|
+
export const HELP = `setup-agent <url> [command]
|
|
24
23
|
|
|
25
24
|
Commands (the url always comes first):
|
|
26
|
-
status Print the
|
|
27
|
-
stream Follow the
|
|
25
|
+
status Print the setup as JSON.
|
|
26
|
+
stream Follow the setup: one JSON array of events per line.
|
|
28
27
|
log <text> Send a message to the user: a reply, something no question or step conveys, or one wrap-up when finished.
|
|
29
28
|
ack <message-id> Acknowledge a user message.
|
|
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]
|
|
@@ -39,8 +38,8 @@ Commands (the url always comes first):
|
|
|
39
38
|
--single is for options that exclude each other (one framework, one model, yes or no); the answer is then one id.
|
|
40
39
|
--icons is required and names an icon for every option, from: ${OPTION_ICONS.join(", ")}.
|
|
41
40
|
ask <prompt> --product <slug> [--wait]
|
|
42
|
-
Propose adding a product to this
|
|
43
|
-
wait start Block until the user clicks Begin plan in their browser; after a finished or cancelled
|
|
41
|
+
Propose adding a product to this setup, such as one another product needs first. Answers "added"; the product and its guide then appear in "status".
|
|
42
|
+
wait start Block until the user clicks Begin plan in their browser; after a finished or cancelled setup, until the next one begins.
|
|
44
43
|
wait <input-id> Block until the user answers or dismisses an input. A multi-select answer prints as an array.
|
|
45
44
|
plan (<markdown> | --file <path>) [--wait]
|
|
46
45
|
Propose the plan for the user to approve. --wait blocks until they decide.
|
|
@@ -55,19 +54,19 @@ Commands (the url always comes first):
|
|
|
55
54
|
Mark every remaining step done and propose closing; the user closes it.
|
|
56
55
|
`;
|
|
57
56
|
|
|
58
|
-
const INSTRUCTIONS = (
|
|
57
|
+
export const INSTRUCTIONS = (
|
|
59
58
|
url: string,
|
|
60
|
-
) => `You are the coding agent for a
|
|
59
|
+
) => `You are the coding agent for a setup the user opened in their browser. The browser shows this setup live; every command below updates it. The user watches the browser, not this terminal: once setup begins, send questions through "ask" so the user can answer in their browser.
|
|
61
60
|
|
|
62
61
|
First, start a persistent Monitor on
|
|
63
62
|
npx setup-agent ${url} stream
|
|
64
|
-
and keep it running: the browser shows you as connected only while it runs, and it is how answers reach you. The URL is the user's link to you from their browser, not one
|
|
63
|
+
and keep it running: the browser shows you as connected only while it runs, and it is how answers reach you. The URL is the user's link to you from their browser, not one setup: it outlives this one, and their next setup arrives on the same stream. Each line is a JSON array of events: "planning.started", "input.answered" ({inputId, answer, note?}; answer is an array for a choice question not asked with --single), "input.dismissed", "plan.approved", "plan.changes-requested" ({feedback}), "message.created" ({messageId, text}), "finished", "cancelled", "setup.created" ({products}).
|
|
65
64
|
|
|
66
65
|
The user can message you at any time, including while a question is waiting. Read "message.created" events, acknowledge each with "npx setup-agent ${url} ack <message-id>", and reply using "log" with what you will do. Pending messages are replayed when the stream starts and listed in "status". A message is not an answer or plan approval; keep those decisions explicit.
|
|
67
66
|
|
|
68
67
|
"log" is a message to the user, and the user reads every one: send it only to reply to their message, to tell them something no question or step conveys (a blocker, a deviation from the plan, a decision you made for them), or once as a wrap-up when the install is finished. Findings go in the plan, progress in step notes, questions in "ask"; do not narrate.
|
|
69
68
|
|
|
70
|
-
Before planning, check the current
|
|
69
|
+
Before planning, check the current setup status. If it is "waiting", tell the user in your own conversation: "I'm connected. Go back to your browser and click Begin plan. You can watch my progress, answer questions, and steer me there." Then run:
|
|
71
70
|
npx setup-agent ${url} wait start
|
|
72
71
|
Keep the stream running while you wait. Do not inspect the project, fetch guides, ask setup questions, or propose a plan until this command returns {"ok":true}. Only the user in the browser should start planning; never invoke the browser's start command yourself. "planning.started" also arrives on the stream. If the status is already "planning" or "installing", resume that phase without asking the user to begin again. If it is "done" or "cancelled", that setup is over: tell the user you are connected and waiting for their next setup, and run "wait start", which returns once the next one begins.
|
|
73
72
|
|
|
@@ -75,16 +74,16 @@ The remaining phases are plan, then install.
|
|
|
75
74
|
|
|
76
75
|
1. Plan. Investigate only; change nothing yet. If the current directory is not an app to install into (a monorepo root, an empty folder, a library), ask where to install first:
|
|
77
76
|
npx setup-agent ${url} ask "Which project should I install into?" --placeholder "An absolute path, or a path for a new Next.js app" --wait
|
|
78
|
-
Read the project: its app framework and package manager, the agent framework and model provider it already uses, where components live, any existing chat route. Fetch each product's "guide" URL from the
|
|
77
|
+
Read the project: its app framework and package manager, the agent framework and model provider it already uses, where components live, any existing chat route. Fetch each product's "guide" URL from the setup below; it is the install reference. Ask for what the project does not decide, using the standard questions so the browser can render them well:
|
|
79
78
|
npx setup-agent ${url} ask --preset framework
|
|
80
79
|
npx setup-agent ${url} ask --preset llm-provider
|
|
81
|
-
If a product's guide needs another product the project does not have yet (most need assistant-ui installed and rendering a thread), do not stop and do not install it on the side. Propose adding it to this
|
|
80
|
+
If a product's guide needs another product the project does not have yet (most need assistant-ui installed and rendering a thread), do not stop and do not install it on the side. Propose adding it to this setup:
|
|
82
81
|
npx setup-agent ${url} ask "assistant-ui is not installed in this project yet. Add it to this setup?" --product assistant-ui --wait
|
|
83
82
|
On "added", run "status", fetch the new product's guide, and install it first in your plan. If the user dismisses it, follow what the guide says about the missing product.
|
|
84
83
|
Skip a preset the code already answers (a chat route on the AI SDK, a provider key in .env.local) and log why. Ask everything at once, keep investigating, and block with "wait <input-id>" only when nothing else can proceed. A framework answer is "<framework>:<language>", for example "ai-sdk:typescript". A model answer is JSON {"provider","model","reasoningEffort"?}. An answer may carry a "note" from the user; follow it. A choice answer that matches none of the options is the user's own text.
|
|
85
84
|
Then write the plan as markdown and submit it:
|
|
86
85
|
npx setup-agent ${url} plan --file <path> --wait
|
|
87
|
-
Use these headings, each with bullets: "## What I found" (facts as \`- **Label:** value\`: app framework, package manager, agent framework, model provider and model, where components live), "## What I will install" (packages and files, one per bullet), "## Steps" (a numbered list, one line each), and "## Open questions" only if any remain. Keep it under 30 lines; no preamble. "plan --wait" prints {"decision":"approve"} or {"decision":"revise","feedback":"..."}. On revise, address the feedback (ask again if you need to) and submit a new plan; repeat until approved. Do not change the project before approval: the
|
|
86
|
+
Use these headings, each with bullets: "## What I found" (facts as \`- **Label:** value\`: app framework, package manager, agent framework, model provider and model, where components live), "## What I will install" (packages and files, one per bullet), "## Steps" (a numbered list, one line each), and "## Open questions" only if any remain. Keep it under 30 lines; no preamble. "plan --wait" prints {"decision":"approve"} or {"decision":"revise","feedback":"..."}. On revise, address the feedback (ask again if you need to) and submit a new plan; repeat until approved. Do not change the project before approval: the setup refuses steps until then.
|
|
88
87
|
|
|
89
88
|
2. Install. Declare the steps from your plan up front, in order:
|
|
90
89
|
npx setup-agent ${url} add-step "<title>" --detail "<one line>"
|
|
@@ -95,9 +94,9 @@ which writes ENV_VAR=<key> into that file. Use the answer's model in the chat ro
|
|
|
95
94
|
|
|
96
95
|
When every product is installed and verified, run
|
|
97
96
|
npx setup-agent ${url} done --summary "<one or two lines on what you installed and how to try it>" --preview http://localhost:<port>
|
|
98
|
-
Before that, start the project's dev server in the background when the project has one, check that the page you changed loads, and pass the address as --preview (the page itself when it is not the root); leave the server running so the user can try the result. Omit --preview when there is nothing to open. This proposes closing the
|
|
97
|
+
Before that, start the project's dev server in the background when the project has one, check that the page you changed loads, and pass the address as --preview (the page itself when it is not the root); leave the server running so the user can try the result. Omit --preview when there is nothing to open. This proposes closing the setup; only the user closes it. Do not stop the stream or exit: stay idle on it, because the user may follow up with a "message.created" event. Handle it, then run "done" again. The stream prints "finished" once the user closes the setup, and "cancelled" if they end it early. Keep the stream running after either: the user's next setup starts on this same URL and arrives as "setup.created" with its products. Run
|
|
99
98
|
npx setup-agent ${url} wait start
|
|
100
|
-
which returns once that setup begins, then run "status" for its products, guides and instructions and go through plan and install again. A "
|
|
99
|
+
which returns once that setup begins, then run "status" for its products, guides and instructions and go through plan and install again. A "setup.created" event while you are still working means the user replaced this setup: drop what you were doing and start over the same way.
|
|
101
100
|
`;
|
|
102
101
|
|
|
103
102
|
type Parsed = { positional: string[]; flags: Map<string, string | true> };
|
|
@@ -251,7 +250,7 @@ const launchAgent = async (url: string) => {
|
|
|
251
250
|
};
|
|
252
251
|
|
|
253
252
|
const requireState = (client: CheckoutClient): Checkout.State =>
|
|
254
|
-
client.state ?? fail("the
|
|
253
|
+
client.state ?? fail("the setup has no state");
|
|
255
254
|
|
|
256
255
|
const summary = (state: Checkout.State) => {
|
|
257
256
|
const plan = currentPlan(state);
|
|
@@ -304,7 +303,7 @@ type StreamEvent =
|
|
|
304
303
|
| { event: "plan.changes-requested"; revision: number; feedback: string }
|
|
305
304
|
| { event: "finished"; next: string }
|
|
306
305
|
| { event: "cancelled"; next: string }
|
|
307
|
-
| { event: "
|
|
306
|
+
| { event: "setup.created"; products: string[] };
|
|
308
307
|
|
|
309
308
|
const STAY_CONNECTED =
|
|
310
309
|
"This setup is over, not your connection. Keep the stream running, tell the user you are connected and waiting for their next setup, and run wait start.";
|
|
@@ -376,7 +375,7 @@ export const diffEvents = (
|
|
|
376
375
|
}
|
|
377
376
|
if (after.id !== null && after.id !== before.id) {
|
|
378
377
|
events.push({
|
|
379
|
-
event: "
|
|
378
|
+
event: "setup.created",
|
|
380
379
|
products: after.products.map((product) => product.slug),
|
|
381
380
|
});
|
|
382
381
|
}
|
|
@@ -425,7 +424,7 @@ const stream = async (client: CheckoutClient) => {
|
|
|
425
424
|
await new Promise(() => {});
|
|
426
425
|
};
|
|
427
426
|
|
|
428
|
-
/** Resolves once a
|
|
427
|
+
/** Resolves once a setup is planning or installing, or once the one being waited on is cancelled; a setup already closed when the wait begins is over, so the wait is for the next one. */
|
|
429
428
|
export const waitForStart = (
|
|
430
429
|
client: Pick<CheckoutClient, "state" | "subscribe">,
|
|
431
430
|
) =>
|
|
@@ -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 }),
|
|
@@ -637,7 +668,7 @@ export const main = async (argv: readonly string[]) => {
|
|
|
637
668
|
...(kind !== undefined && { kind }),
|
|
638
669
|
}).catch(() => {});
|
|
639
670
|
process.stdout.write(INSTRUCTIONS(url));
|
|
640
|
-
process.stdout.write("\nCurrent
|
|
671
|
+
process.stdout.write("\nCurrent setup:\n");
|
|
641
672
|
print(summary(requireState(client)));
|
|
642
673
|
break;
|
|
643
674
|
}
|
|
@@ -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
|
}
|
|
@@ -784,7 +824,7 @@ export const main = async (argv: readonly string[]) => {
|
|
|
784
824
|
print({
|
|
785
825
|
ok: true,
|
|
786
826
|
status: "proposed",
|
|
787
|
-
next: "Keep the stream running and stay idle. The user closes the
|
|
827
|
+
next: "Keep the stream running and stay idle. The user closes the setup or sends a follow-up message.",
|
|
788
828
|
});
|
|
789
829
|
break;
|
|
790
830
|
}
|
|
@@ -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
|
@@ -6,27 +6,35 @@ export type CheckoutClient = StatewireClient<
|
|
|
6
6
|
Checkout.Commands
|
|
7
7
|
>;
|
|
8
8
|
|
|
9
|
-
/** Attach to a
|
|
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})` : ""
|
|
25
32
|
}`,
|
|
26
33
|
),
|
|
27
34
|
);
|
|
28
35
|
return;
|
|
29
36
|
}
|
|
37
|
+
settled = true;
|
|
30
38
|
unsubscribe();
|
|
31
39
|
resolve(client);
|
|
32
40
|
};
|