setup-agent 0.0.6 → 0.0.8

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
@@ -2,9 +2,20 @@ import { connectCheckout } from "./client";
2
2
  import type { CheckoutClient } from "./client";
3
3
  import { INPUT_PRESETS, isPresetId, presetInput } from "./presets";
4
4
  import { spawn } from "node:child_process";
5
- import { open, readFile } from "node:fs/promises";
5
+ import { createHash, randomBytes } from "node:crypto";
6
+ import { constants } from "node:fs";
7
+ import {
8
+ chmod,
9
+ lstat,
10
+ mkdir,
11
+ open,
12
+ readFile,
13
+ realpath,
14
+ unlink,
15
+ } from "node:fs/promises";
16
+ import { homedir, tmpdir, userInfo } from "node:os";
6
17
  import { createInterface } from "node:readline/promises";
7
- import { resolve } from "node:path";
18
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
8
19
  import {
9
20
  AGENT_HEARTBEAT_MS,
10
21
  OPTION_ICONS,
@@ -12,6 +23,7 @@ import {
12
23
  currentPlan,
13
24
  isAgentPresent,
14
25
  isClosed,
26
+ isLoopbackHostname,
15
27
  isOptionIcon,
16
28
  isValidModelAnswer,
17
29
  parseEntryPointOptions,
@@ -54,9 +66,56 @@ Commands (the url always comes first):
54
66
  Mark every remaining step done and propose closing; the user closes it.
55
67
  `;
56
68
 
57
- export const agentInstructions = (
58
- url: string,
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.
69
+ /** Characters a setup URL may hold: an origin and a path, nothing a shell or a reader treats as more than an address. */
70
+ const SETUP_URL_PATTERN =
71
+ /^https?:\/\/[A-Za-z0-9.-]+(?::[0-9]+)?(?:\/[A-Za-z0-9._~%/-]*)?$|^http:\/\/\[::1\](?::[0-9]+)?(?:\/[A-Za-z0-9._~%/-]*)?$/;
72
+
73
+ /**
74
+ * The normalized setup URL, or an error saying why it is refused. A setup URL
75
+ * is printed into shell commands and the coding agent's prompt, and the API
76
+ * key travels over it, so it must be https (http only on this machine), carry
77
+ * no credentials, query or fragment, and hold only address characters.
78
+ */
79
+ export const parseSetupUrl = (value: string): string => {
80
+ let url: URL;
81
+ try {
82
+ url = new URL(value);
83
+ } catch {
84
+ throw new Error(`"${value}" is not a URL`);
85
+ }
86
+ if (
87
+ url.protocol !== "https:" &&
88
+ !(url.protocol === "http:" && isLoopbackHostname(url.hostname))
89
+ ) {
90
+ throw new Error("the setup URL must use https (http only on localhost)");
91
+ }
92
+ if (url.username !== "" || url.password !== "") {
93
+ throw new Error("the setup URL must not carry credentials");
94
+ }
95
+ if (
96
+ url.search !== "" ||
97
+ url.hash !== "" ||
98
+ value.includes("?") ||
99
+ value.includes("#")
100
+ ) {
101
+ throw new Error("the setup URL must not have a query or fragment");
102
+ }
103
+ const href = url.href.replace(/\/$/, "");
104
+ if (!SETUP_URL_PATTERN.test(href)) {
105
+ throw new Error("the setup URL has characters a setup URL does not use");
106
+ }
107
+ return href;
108
+ };
109
+
110
+ /** `value` as one shell word, quoted only when it holds more than plain URL characters. */
111
+ const shellWord = (value: string) =>
112
+ /^[A-Za-z0-9._~%/:@=+,-]+$/.test(value)
113
+ ? value
114
+ : `'${value.replaceAll("'", `'\\''`)}'`;
115
+
116
+ export const agentInstructions = (setupUrl: string) => {
117
+ const url = shellWord(setupUrl);
118
+ return `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.
60
119
 
61
120
  First, start a persistent Monitor on
62
121
  npx setup-agent ${url} stream
@@ -99,6 +158,7 @@ Before that, start the project's dev server in the background when the project h
99
158
  npx setup-agent ${url} wait start
100
159
  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
160
  `;
161
+ };
102
162
 
103
163
  export const INSTRUCTIONS = agentInstructions;
104
164
 
@@ -231,7 +291,7 @@ const chooseLauncher = async () => {
231
291
 
232
292
  const launchAgent = async (url: string) => {
233
293
  const launcher = await chooseLauncher();
234
- const prompt = `Run \`npx setup-agent ${url}\` and follow the instructions it prints.`;
294
+ const prompt = `Run \`npx setup-agent ${shellWord(url)}\` and follow the instructions it prints.`;
235
295
  process.stdout.write(`\nStarting ${launcher.name}...\n\n`);
236
296
  const child = spawn(launcher.command, launcher.args(prompt), {
237
297
  stdio: "inherit",
@@ -643,6 +703,16 @@ const assertEnvKey = (key: string) => {
643
703
  /** Sets `key=value` in dotenv text, replacing an existing line for the key. */
644
704
  export const upsertEnvLine = (content: string, key: string, value: string) => {
645
705
  assertEnvKey(key);
706
+ // A line break or other control character would end the line and let the
707
+ // value write lines of its own.
708
+ const isLineBreaking = (code: number) =>
709
+ code < 0x20 ||
710
+ (code >= 0x7f && code <= 0x9f) ||
711
+ code === 0x2028 ||
712
+ code === 0x2029;
713
+ if ([...value].some((char) => isLineBreaking(char.charCodeAt(0)))) {
714
+ throw new Error(`the value for ${key} must be a single line`);
715
+ }
646
716
  const line = `${key}=${value}`;
647
717
  const lines = content === "" ? [] : content.replace(/\n$/, "").split("\n");
648
718
  const index = lines.findIndex(
@@ -664,20 +734,165 @@ export const writeEnvSecret = async (
664
734
  file: string,
665
735
  key: string,
666
736
  take: () => Promise<string>,
737
+ projectRoot = process.cwd(),
667
738
  ) => {
668
739
  assertEnvKey(key);
669
- const handle = await open(file, "a+", 0o600);
740
+ try {
741
+ if ((await lstat(file)).isSymbolicLink()) {
742
+ throw new Error(
743
+ `${file} is a symbolic link; pass --file with its target to write there`,
744
+ );
745
+ }
746
+ } catch (error) {
747
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
748
+ }
749
+ const projectRelative = relative(projectRoot, file);
750
+ if (
751
+ projectRelative !== ".." &&
752
+ !projectRelative.startsWith(`..${sep}`) &&
753
+ !isAbsolute(projectRelative)
754
+ ) {
755
+ const root = await realpath(projectRoot);
756
+ const parent = await realpath(dirname(file));
757
+ const parentRelative = relative(root, parent);
758
+ if (
759
+ parentRelative === ".." ||
760
+ parentRelative.startsWith(`..${sep}`) ||
761
+ isAbsolute(parentRelative)
762
+ ) {
763
+ throw new Error(`${file} has a parent directory outside the project`);
764
+ }
765
+ }
766
+ const handle = await open(
767
+ file,
768
+ constants.O_RDWR |
769
+ constants.O_APPEND |
770
+ constants.O_CREAT |
771
+ constants.O_NOFOLLOW,
772
+ 0o600,
773
+ ).catch((error: NodeJS.ErrnoException) => {
774
+ if (error.code === "ELOOP")
775
+ throw new Error(
776
+ `${file} is a symbolic link; pass --file with its target to write there`,
777
+ );
778
+ throw error;
779
+ });
670
780
  try {
671
781
  await handle.chmod(0o600);
672
782
  const value = await take();
673
783
  const current = await handle.readFile("utf8");
784
+ // Built before truncating, so a refused value leaves the file intact.
785
+ const next = upsertEnvLine(current, key, value);
674
786
  await handle.truncate(0);
675
- await handle.writeFile(upsertEnvLine(current, key, value));
787
+ await handle.writeFile(next);
676
788
  } finally {
677
789
  await handle.close();
678
790
  }
679
791
  };
680
792
 
793
+ const redeemKeyDirectories = () => {
794
+ const stateHome =
795
+ process.platform === "win32"
796
+ ? process.env.LOCALAPPDATA || join(homedir(), "AppData", "Local")
797
+ : process.env.XDG_STATE_HOME || join(homedir(), ".local", "state");
798
+ return [
799
+ join(stateHome, "setup-agent"),
800
+ join(
801
+ tmpdir(),
802
+ `setup-agent-${process.platform === "win32" ? userInfo().username : process.getuid!()}`,
803
+ ),
804
+ ];
805
+ };
806
+
807
+ const checkRedeemKeyDirectory = async (directory: string) => {
808
+ if (!isAbsolute(directory)) {
809
+ throw new Error("state home must be an absolute path");
810
+ }
811
+ const info = await lstat(directory);
812
+ if (!info.isDirectory() || info.isSymbolicLink()) {
813
+ throw new Error(
814
+ `redeem key directory must not be a symbolic link: ${directory}`,
815
+ );
816
+ }
817
+ if (process.platform !== "win32") {
818
+ if (info.uid !== process.getuid!()) {
819
+ throw new Error(
820
+ `redeem key directory must be owned by the user: ${directory}`,
821
+ );
822
+ }
823
+ if ((info.mode & 0o077) !== 0) await chmod(directory, 0o700);
824
+ }
825
+ };
826
+
827
+ const prepareRedeemKeyDirectory = async (directory: string) => {
828
+ if (isAbsolute(directory)) {
829
+ await mkdir(directory, { recursive: true, mode: 0o700 });
830
+ }
831
+ await checkRedeemKeyDirectory(directory);
832
+ };
833
+
834
+ const saveRedeemKey = async (hash: string, key: string) => {
835
+ let lastError: unknown;
836
+ for (const directory of redeemKeyDirectories()) {
837
+ const file = join(directory, `${hash}.key`);
838
+ let created = false;
839
+ try {
840
+ await prepareRedeemKeyDirectory(directory);
841
+ const handle = await open(
842
+ file,
843
+ constants.O_WRONLY |
844
+ constants.O_CREAT |
845
+ constants.O_EXCL |
846
+ constants.O_NOFOLLOW,
847
+ 0o600,
848
+ );
849
+ created = true;
850
+ try {
851
+ await handle.writeFile(key);
852
+ } finally {
853
+ await handle.close();
854
+ }
855
+ return file;
856
+ } catch (error) {
857
+ if (created) await unlink(file).catch(() => {});
858
+ lastError = error;
859
+ }
860
+ }
861
+ throw lastError;
862
+ };
863
+
864
+ const readRedeemKey = async (hash: string) => {
865
+ if (!/^[0-9a-f]{64}$/.test(hash)) return undefined;
866
+ for (const directory of redeemKeyDirectories()) {
867
+ try {
868
+ await checkRedeemKeyDirectory(directory);
869
+ const file = join(directory, `${hash}.key`);
870
+ const handle = await open(
871
+ file,
872
+ constants.O_RDONLY | constants.O_NOFOLLOW,
873
+ );
874
+ try {
875
+ const info = await handle.stat();
876
+ if (
877
+ !info.isFile() ||
878
+ (process.platform !== "win32" && (info.mode & 0o077) !== 0)
879
+ ) {
880
+ throw new Error("redeem key file must be private and regular");
881
+ }
882
+ const key = await handle.readFile("utf8");
883
+ if (createHash("sha256").update(key).digest("hex") === hash) {
884
+ return { key, file };
885
+ }
886
+ } finally {
887
+ await handle.close();
888
+ }
889
+ } catch {
890
+ continue;
891
+ }
892
+ }
893
+ return undefined;
894
+ };
895
+
681
896
  const answerFields = (input: Checkout.Input, answer: string) => ({
682
897
  answer: (input.multiple && parseMultipleAnswer(answer)) || answer,
683
898
  ...(input.note !== undefined && { note: input.note }),
@@ -701,7 +916,12 @@ export const main = async (argv: readonly string[]) => {
701
916
  process.stdout.write(HELP);
702
917
  return process.exit(first === undefined ? 1 : 0);
703
918
  }
704
- const url = first;
919
+ let url: string;
920
+ try {
921
+ url = parseSetupUrl(first);
922
+ } catch (error) {
923
+ return fail(error instanceof Error ? error.message : String(error));
924
+ }
705
925
  if (command === "intro" && isDirectInvocation()) return launchAgent(url);
706
926
 
707
927
  const client = await connectCheckout(url).catch((error: unknown) =>
@@ -775,7 +995,32 @@ export const main = async (argv: readonly string[]) => {
775
995
  break;
776
996
  }
777
997
  case "ask": {
778
- const { inputId } = await commands["agent/ask"](askSeed(rest, flags));
998
+ const seed = askSeed(rest, flags);
999
+ const redeemKey =
1000
+ seed.kind === "model" ? randomBytes(32).toString("hex") : undefined;
1001
+ const redeemKeyHash =
1002
+ redeemKey === undefined
1003
+ ? undefined
1004
+ : createHash("sha256").update(redeemKey).digest("hex");
1005
+ const keyFile =
1006
+ redeemKey === undefined || redeemKeyHash === undefined
1007
+ ? undefined
1008
+ : await saveRedeemKey(redeemKeyHash, redeemKey).catch(
1009
+ (error: unknown) =>
1010
+ fail(
1011
+ `could not store the model redeem key: ${error instanceof Error ? error.message : String(error)}`,
1012
+ ),
1013
+ );
1014
+ let inputId: string;
1015
+ try {
1016
+ ({ inputId } = await commands["agent/ask"]({
1017
+ ...seed,
1018
+ ...(redeemKeyHash !== undefined && { redeemKeyHash }),
1019
+ }));
1020
+ } catch (error) {
1021
+ if (keyFile !== undefined) await unlink(keyFile).catch(() => {});
1022
+ throw error;
1023
+ }
779
1024
  if (!flags.has("wait")) {
780
1025
  print({ ok: true, inputId });
781
1026
  break;
@@ -835,19 +1080,58 @@ export const main = async (argv: readonly string[]) => {
835
1080
  ) {
836
1081
  fail(`input "${inputId}" is not an llm-provider answer`);
837
1082
  }
1083
+ const storedKey =
1084
+ input.redeemKeyHash === undefined
1085
+ ? undefined
1086
+ : await readRedeemKey(input.redeemKeyHash);
1087
+ if (input.redeemKeyHash !== undefined && storedKey === undefined) {
1088
+ fail(`no local redeem key for "${inputId}"`);
1089
+ }
838
1090
  const file = resolve(flagText(flags, "file") ?? ".env.local");
839
1091
  await writeEnvSecret(file, envKey, async () => {
1092
+ const secretUrl = new URL(url);
1093
+ if (
1094
+ secretUrl.protocol !== "https:" &&
1095
+ !(
1096
+ secretUrl.protocol === "http:" &&
1097
+ isLoopbackHostname(secretUrl.hostname)
1098
+ )
1099
+ ) {
1100
+ fail("the key is only fetched over https");
1101
+ }
840
1102
  const response = await fetch(
841
1103
  `${url.replace(/\/$/, "")}/secret/${encodeURIComponent(inputId)}?setup=${encodeURIComponent(setupId)}`,
1104
+ storedKey === undefined
1105
+ ? undefined
1106
+ : { headers: { "Setup-Agent-Redeem-Key": storedKey.key } },
842
1107
  );
1108
+ if (response.status === 403) {
1109
+ fail(`redeem key rejected for "${inputId}"`);
1110
+ }
843
1111
  if (response.status === 404) {
844
1112
  fail(
845
1113
  `the key for "${inputId}" was already taken or never deposited`,
846
1114
  );
847
1115
  }
848
1116
  if (!response.ok) fail(`could not fetch the key: ${response.status}`);
849
- return response.text();
1117
+ const key = await response.text();
1118
+ // An API key is one word of letters, digits and a few separators;
1119
+ // anything else could break out of the env line, be read differently
1120
+ // by dotenv, or run as shell syntax when the file is sourced.
1121
+ if (!/^[A-Za-z0-9._~+/=:@%,-]+$/.test(key)) {
1122
+ fail("the deposited key is not a valid API key");
1123
+ }
1124
+ return key;
850
1125
  });
1126
+ if (storedKey !== undefined) {
1127
+ await unlink(storedKey.file).catch((error: NodeJS.ErrnoException) => {
1128
+ if (error.code !== "ENOENT") {
1129
+ process.stderr.write(
1130
+ `warning: could not remove a redeem key: ${error.message}\n`,
1131
+ );
1132
+ }
1133
+ });
1134
+ }
851
1135
  print({ ok: true, envKey, file });
852
1136
  break;
853
1137
  }
package/src/host.test.ts CHANGED
@@ -163,6 +163,70 @@ describe("CheckoutHost", () => {
163
163
  });
164
164
 
165
165
  describe("plans", () => {
166
+ it.each([
167
+ { decision: "approve" },
168
+ { decision: "revise", feedback: "Use pnpm" },
169
+ ] satisfies Checkout.PlanDecision[])(
170
+ "rejects a $decision decision for a superseded revision",
171
+ async (decision) => {
172
+ const host = mount();
173
+ await host.commands["checkout/create"](seed);
174
+ await host.commands["checkout/begin-plan"]();
175
+ await host.commands["agent/plan"]({ markdown: "Install chat" });
176
+ const pending = { ...decision, id: seed.id, revision: 1 };
177
+ await host.commands["agent/plan"]({ markdown: "Replace the API route" });
178
+
179
+ expect(await reason(host.commands["checkout/plan"](pending))).toEqual({
180
+ reason: "stale-plan",
181
+ });
182
+ expect(currentPlan(host.state)).toMatchObject({
183
+ revision: 2,
184
+ status: "proposed",
185
+ });
186
+ expect(host.state.status).toBe("planning");
187
+ expect(currentPlan(host.state)?.feedback).toBeUndefined();
188
+
189
+ await host.commands["checkout/plan"]({ ...pending, revision: 2 });
190
+ expect(currentPlan(host.state)?.status).toBe(
191
+ decision.decision === "approve" ? "approved" : "changes-requested",
192
+ );
193
+ },
194
+ );
195
+
196
+ it("rejects a decision from an earlier setup with the same plan revision", async () => {
197
+ const host = mount();
198
+ await host.commands["checkout/create"](seed);
199
+ await host.commands["checkout/begin-plan"]();
200
+ await host.commands["agent/plan"]({ markdown: "Install chat" });
201
+ await host.commands["checkout/cancel"]({ id: seed.id });
202
+ await host.commands["checkout/create"]({ ...seed, id: "replacement" });
203
+ await host.commands["checkout/begin-plan"]();
204
+ await host.commands["agent/plan"]({ markdown: "Install cloud" });
205
+
206
+ expect(
207
+ await reason(
208
+ host.commands["checkout/plan"]({
209
+ decision: "approve",
210
+ id: seed.id,
211
+ revision: 1,
212
+ }),
213
+ ),
214
+ ).toEqual({ reason: "stale-plan" });
215
+ expect(currentPlan(host.state)).toMatchObject({
216
+ revision: 1,
217
+ status: "proposed",
218
+ });
219
+ expect(host.state.status).toBe("planning");
220
+
221
+ await host.commands["checkout/plan"]({
222
+ decision: "approve",
223
+ id: "replacement",
224
+ revision: 1,
225
+ });
226
+ expect(currentPlan(host.state)?.status).toBe("approved");
227
+ expect(host.state.status).toBe("installing");
228
+ });
229
+
166
230
  it("refuses steps until the user approves a plan", async () => {
167
231
  const host = mount();
168
232
  await host.commands["checkout/create"](seed);
@@ -606,6 +670,46 @@ describe("inputs", () => {
606
670
  describe("model inputs", () => {
607
671
  const answer = { provider: "openai", model: "gpt-test" };
608
672
 
673
+ it("stores a valid redeem hash only on model inputs", async () => {
674
+ const host = mount();
675
+ await host.commands["checkout/create"](seed);
676
+ await host.commands["checkout/begin-plan"]();
677
+ const hash = "a".repeat(64);
678
+ const { inputId } = await host.commands["agent/ask"]({
679
+ ...presetInput("llm-provider"),
680
+ redeemKeyHash: hash,
681
+ });
682
+ expect(
683
+ host.state.inputs.find((input) => input.id === inputId)?.redeemKeyHash,
684
+ ).toBe(hash);
685
+ await host.commands["checkout/answer"]({
686
+ inputId,
687
+ answer: JSON.stringify(answer),
688
+ });
689
+ expect(
690
+ host.state.inputs.find((input) => input.id === inputId)?.redeemKeyHash,
691
+ ).toBe(hash);
692
+ for (const redeemKeyHash of [
693
+ "A".repeat(64),
694
+ "a".repeat(63),
695
+ "not-a-hash",
696
+ ]) {
697
+ expect(
698
+ await reason(
699
+ host.commands["agent/ask"]({
700
+ ...presetInput("llm-provider"),
701
+ redeemKeyHash,
702
+ }),
703
+ ),
704
+ ).toEqual({ reason: "invalid-input" });
705
+ }
706
+ expect(
707
+ await reason(
708
+ host.commands["agent/ask"]({ prompt: "Name?", redeemKeyHash: hash }),
709
+ ),
710
+ ).toEqual({ reason: "invalid-input" });
711
+ });
712
+
609
713
  it("parses a model answer and rejects malformed ones", () => {
610
714
  expect(parseModelAnswer(JSON.stringify(answer))).toEqual(answer);
611
715
  expect(
package/src/host.ts CHANGED
@@ -86,6 +86,12 @@ const useCheckoutHost = (restored: unknown) => {
86
86
 
87
87
  const ask = (seed: Checkout.InputSeed & { stepId?: string }) => {
88
88
  const kind = seed.kind ?? "text";
89
+ if (
90
+ seed.redeemKeyHash !== undefined &&
91
+ (kind !== "model" || !/^[0-9a-f]{64}$/.test(seed.redeemKeyHash))
92
+ ) {
93
+ throw reject("invalid-input", "invalid model redeem key hash");
94
+ }
89
95
  const options =
90
96
  kind === "entry-point"
91
97
  ? parseEntryPointOptions(seed.options)
@@ -166,6 +172,9 @@ const useCheckoutHost = (restored: unknown) => {
166
172
  ...(kind === "product" && { product: seed.product }),
167
173
  ...(seed.default !== undefined && { default: seed.default }),
168
174
  ...(seed.help !== undefined && { help: seed.help }),
175
+ ...(seed.redeemKeyHash !== undefined && {
176
+ redeemKeyHash: seed.redeemKeyHash,
177
+ }),
169
178
  optional: seed.optional ?? false,
170
179
  status: "open",
171
180
  ...(stepId !== undefined && { stepId }),
@@ -331,6 +340,15 @@ const useCheckoutHost = (restored: unknown) => {
331
340
  if (plan === undefined) {
332
341
  throw reject("no-plan", "the agent has not proposed a plan yet");
333
342
  }
343
+ if (
344
+ (params.id !== undefined && params.id !== state.id) ||
345
+ (params.revision !== undefined && params.revision !== plan.revision)
346
+ ) {
347
+ throw reject(
348
+ "stale-plan",
349
+ "review the current setup plan before deciding",
350
+ );
351
+ }
334
352
  if (plan.status !== "proposed") {
335
353
  throw reject("plan-decided", `the plan is already ${plan.status}`);
336
354
  }
package/src/protocol.ts CHANGED
@@ -101,6 +101,7 @@ export namespace Checkout {
101
101
  product?: string;
102
102
  default?: string;
103
103
  help?: InputHelp;
104
+ redeemKeyHash?: string;
104
105
  optional: boolean;
105
106
  status: InputStatus;
106
107
  answer?: string;
@@ -192,12 +193,16 @@ export namespace Checkout {
192
193
  product?: string;
193
194
  default?: string;
194
195
  help?: InputHelp;
196
+ redeemKeyHash?: string;
195
197
  optional?: boolean;
196
198
  };
197
199
 
198
- export type PlanDecision =
199
- | { decision: "approve" }
200
- | { decision: "revise"; feedback: string };
200
+ export type PlanDecision = {
201
+ /** The setup the browser reviewed; omitted by legacy clients. */
202
+ id?: string;
203
+ /** The plan revision the browser reviewed; omitted by legacy clients. */
204
+ revision?: number;
205
+ } & ({ decision: "approve" } | { decision: "revise"; feedback: string });
201
206
 
202
207
  export type Commands = {
203
208
  "checkout/create": (params: {
@@ -254,6 +259,7 @@ export namespace Checkout {
254
259
  | "plan-required"
255
260
  | "no-plan"
256
261
  | "plan-decided"
262
+ | "stale-plan"
257
263
  | "finish-not-proposed"
258
264
  | "feedback-required"
259
265
  | "empty-plan"
@@ -333,11 +339,13 @@ export const parsePreviewUrl = (value: string | undefined) => {
333
339
  }
334
340
  if (url.protocol !== "http:" && url.protocol !== "https:") return undefined;
335
341
  if (url.username !== "" || url.password !== "") return undefined;
336
- const loopback =
337
- LOOPBACK_HOSTS.has(url.hostname) || url.hostname.endsWith(".localhost");
338
- return loopback ? url : undefined;
342
+ return isLoopbackHostname(url.hostname) ? url : undefined;
339
343
  };
340
344
 
345
+ /** True for a hostname that names this machine. */
346
+ export const isLoopbackHostname = (hostname: string) =>
347
+ LOOPBACK_HOSTS.has(hostname) || hostname.endsWith(".localhost");
348
+
341
349
  export const openInputs = (state: Checkout.State) =>
342
350
  state.inputs.filter((input) => input.status === "open");
343
351