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/README.md +3 -1
- package/dist/cli.d.ts +10 -3
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +153 -15
- package/dist/cli.js.map +1 -1
- package/dist/client.js +1 -2
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +4 -2
- package/dist/host.js.map +1 -1
- package/dist/presets.js +1 -2
- package/dist/protocol.d.ts +11 -2
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +5 -4
- package/dist/protocol.js.map +1 -1
- package/package.json +4 -4
- package/src/cli.test.ts +586 -0
- package/src/cli.ts +295 -11
- package/src/host.test.ts +104 -0
- package/src/host.ts +18 -0
- package/src/protocol.ts +14 -6
package/README.md
CHANGED
|
@@ -25,9 +25,11 @@ A setup opens `waiting` with its products and nothing else: no steps, no questio
|
|
|
25
25
|
|
|
26
26
|
The URL is the browser's link to its agent, not one setup. A finished or cancelled setup stays on the link, and the browser's next setup replaces it with a new `checkout/create` carrying a new `id`: the plans, steps, inputs and log start over, the agent's presence carries across when it is still connected, and the stream prints `setup.created` with the new products. The agent keeps the stream running after `finished` or `cancelled` and blocks on `wait start`, which returns once the next setup begins planning; a setup already closed when the wait begins is skipped rather than reported.
|
|
27
27
|
|
|
28
|
+
The browser sends the setup `id` and the displayed plan `revision` with each `checkout/plan` decision. The host rejects a mismatched setup or revision with `stale-plan`, so a queued decision cannot approve or revise a newer plan. Older clients may omit these fields and retain their existing behavior. The guard requires a host running this version of `setup-agent`.
|
|
29
|
+
|
|
28
30
|
## Inputs
|
|
29
31
|
|
|
30
|
-
Inputs include `text`, `choice`, `model` and `entry-point`, plus the legacy `product` kind. A choice carries `options` (`id`, `label`, `description`, `icon`, `variants`), an optional `default` and a `help` note the browser renders as a "help me choose" disclosure. Option ids are unique, and neither option nor variant ids may contain `:`. The answer is the option id, `<option>:<variant>` when the option has variants (`langgraph:python`), or the user's own text when none of the options fit (including any text with more than one `:`); the stream marks that answer `custom`. An agent's own choice is multi-select unless asked with `--single`: the user checks any number of options (none may have variants), and the answer is a JSON array of those entries, which `wait` and the stream print as an array and `status` holds as text. `--single` is for options that exclude each other (one framework, one model, yes or no) and answers one id. Every one of the agent's own options must carry an `icon` from the pack `OPTION_ICONS` exports (`--icons "slack=message,email=mail"`); the CLI refuses the question otherwise. A preset option carries a brand mark instead. Every answer may carry a `note` from the user. A model input lists providers as its options; the browser collects the provider, its API key, the model and an optional reasoning effort, and the answer is that object as JSON (`{"provider","model","reasoningEffort"?}`); the API key is deposited separately and only ever reaches the env file the agent names through `env`.
|
|
32
|
+
Inputs include `text`, `choice`, `model` and `entry-point`, plus the legacy `product` kind. A choice carries `options` (`id`, `label`, `description`, `icon`, `variants`), an optional `default` and a `help` note the browser renders as a "help me choose" disclosure. Option ids are unique, and neither option nor variant ids may contain `:`. The answer is the option id, `<option>:<variant>` when the option has variants (`langgraph:python`), or the user's own text when none of the options fit (including any text with more than one `:`); the stream marks that answer `custom`. An agent's own choice is multi-select unless asked with `--single`: the user checks any number of options (none may have variants), and the answer is a JSON array of those entries, which `wait` and the stream print as an array and `status` holds as text. `--single` is for options that exclude each other (one framework, one model, yes or no) and answers one id. Every one of the agent's own options must carry an `icon` from the pack `OPTION_ICONS` exports (`--icons "slack=message,email=mail"`); the CLI refuses the question otherwise. A preset option carries a brand mark instead. Every answer may carry a `note` from the user. A model input lists providers as its options; the browser collects the provider, its API key, the model and an optional reasoning effort, and the answer is that object as JSON (`{"provider","model","reasoningEffort"?}`); the API key is deposited separately and only ever reaches the env file the agent names through `env`. Only the CLI that asked can take it: `ask` keeps a redeem key in a private file under the user's state directory, or a per-user directory under the temp directory, and refuses a model question when neither can hold it, so `env` runs as the same user on the same machine.
|
|
31
33
|
|
|
32
34
|
When the setup puts an assistant in the app, the agent asks for its entry point after discovering the app, with `ask "<prompt>" --entry-points '<JSON array>' [--default <id>]`. Each of 1–3 options includes a stable `id`, `label`, contextual `description`, and `entryPoint: { formFactor: "modal" | "sidebar" | "full-page", placement, trigger, recommended? }`. At most one option is recommended, and the agent marks it with `recommended` rather than `--default`, which would preselect it. Two alternatives can share a form factor when their placements differ. Entry-point questions accept exactly one option id, with an optional user note; variants, custom ids, a help note and multi-selection are refused. The agent describes the actual app's workflow, placement and opening action, then incorporates the user's selection into the approved plan.
|
|
33
35
|
|
package/dist/cli.d.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import type { CheckoutClient } from "./client.js";
|
|
2
2
|
import type { Checkout } from "./protocol.js";
|
|
3
3
|
export declare const HELP: string;
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
/**
|
|
5
|
+
* The normalized setup URL, or an error saying why it is refused. A setup URL
|
|
6
|
+
* is printed into shell commands and the coding agent's prompt, and the API
|
|
7
|
+
* key travels over it, so it must be https (http only on this machine), carry
|
|
8
|
+
* no credentials, query or fragment, and hold only address characters.
|
|
9
|
+
*/
|
|
10
|
+
export declare const parseSetupUrl: (value: string) => string;
|
|
11
|
+
export declare const agentInstructions: (setupUrl: string) => string;
|
|
12
|
+
export declare const INSTRUCTIONS: (setupUrl: string) => string;
|
|
6
13
|
type Parsed = {
|
|
7
14
|
positional: string[];
|
|
8
15
|
flags: Map<string, string | true>;
|
|
@@ -65,7 +72,7 @@ export declare const upsertEnvLine: (content: string, key: string, value: string
|
|
|
65
72
|
* secret consumes it and an unwritable destination must fail while it is
|
|
66
73
|
* still deposited; it is read after, so lines written meanwhile survive.
|
|
67
74
|
*/
|
|
68
|
-
export declare const writeEnvSecret: (file: string, key: string, take: () => Promise<string
|
|
75
|
+
export declare const writeEnvSecret: (file: string, key: string, take: () => Promise<string>, projectRoot?: string) => Promise<void>;
|
|
69
76
|
export declare const main: (argv: readonly string[]) => Promise<undefined>;
|
|
70
77
|
export {};
|
|
71
78
|
//# sourceMappingURL=cli.d.ts.map
|
package/dist/cli.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,oBAAiB;
|
|
1
|
+
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"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"}
|
package/dist/cli.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { connectCheckout } from "./client.js";
|
|
2
2
|
import { INPUT_PRESETS, isPresetId, presetInput } from "./presets.js";
|
|
3
|
-
import { AGENT_HEARTBEAT_MS, OPTION_ICONS, classifyChoiceAnswer, currentPlan, isAgentPresent, isClosed, isOptionIcon, isValidModelAnswer, parseEntryPointOptions, parseMultipleAnswer, stepProgress } from "./protocol.js";
|
|
3
|
+
import { AGENT_HEARTBEAT_MS, OPTION_ICONS, classifyChoiceAnswer, currentPlan, isAgentPresent, isClosed, isLoopbackHostname, isOptionIcon, isValidModelAnswer, parseEntryPointOptions, parseMultipleAnswer, stepProgress } from "./protocol.js";
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
|
-
import {
|
|
5
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
6
|
+
import { constants } from "node:fs";
|
|
7
|
+
import { chmod, lstat, mkdir, open, readFile, realpath, unlink } from "node:fs/promises";
|
|
8
|
+
import { homedir, tmpdir, userInfo } from "node:os";
|
|
6
9
|
import { createInterface } from "node:readline/promises";
|
|
7
|
-
import { resolve } from "node:path";
|
|
8
|
-
|
|
10
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
11
|
+
// src/cli.ts
|
|
9
12
|
const HELP = `setup-agent <url> [command]
|
|
10
13
|
|
|
11
14
|
Commands (the url always comes first):
|
|
@@ -39,7 +42,33 @@ Commands (the url always comes first):
|
|
|
39
42
|
done [--summary <text>] [--preview <url>]
|
|
40
43
|
Mark every remaining step done and propose closing; the user closes it.
|
|
41
44
|
`;
|
|
42
|
-
|
|
45
|
+
/** Characters a setup URL may hold: an origin and a path, nothing a shell or a reader treats as more than an address. */
|
|
46
|
+
const SETUP_URL_PATTERN = /^https?:\/\/[A-Za-z0-9.-]+(?::[0-9]+)?(?:\/[A-Za-z0-9._~%/-]*)?$|^http:\/\/\[::1\](?::[0-9]+)?(?:\/[A-Za-z0-9._~%/-]*)?$/;
|
|
47
|
+
/**
|
|
48
|
+
* The normalized setup URL, or an error saying why it is refused. A setup URL
|
|
49
|
+
* is printed into shell commands and the coding agent's prompt, and the API
|
|
50
|
+
* key travels over it, so it must be https (http only on this machine), carry
|
|
51
|
+
* no credentials, query or fragment, and hold only address characters.
|
|
52
|
+
*/
|
|
53
|
+
const parseSetupUrl = (value) => {
|
|
54
|
+
let url;
|
|
55
|
+
try {
|
|
56
|
+
url = new URL(value);
|
|
57
|
+
} catch {
|
|
58
|
+
throw new Error(`"${value}" is not a URL`);
|
|
59
|
+
}
|
|
60
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopbackHostname(url.hostname))) throw new Error("the setup URL must use https (http only on localhost)");
|
|
61
|
+
if (url.username !== "" || url.password !== "") throw new Error("the setup URL must not carry credentials");
|
|
62
|
+
if (url.search !== "" || url.hash !== "" || value.includes("?") || value.includes("#")) throw new Error("the setup URL must not have a query or fragment");
|
|
63
|
+
const href = url.href.replace(/\/$/, "");
|
|
64
|
+
if (!SETUP_URL_PATTERN.test(href)) throw new Error("the setup URL has characters a setup URL does not use");
|
|
65
|
+
return href;
|
|
66
|
+
};
|
|
67
|
+
/** `value` as one shell word, quoted only when it holds more than plain URL characters. */
|
|
68
|
+
const shellWord = (value) => /^[A-Za-z0-9._~%/:@=+,-]+$/.test(value) ? value : `'${value.replaceAll("'", `'\\''`)}'`;
|
|
69
|
+
const agentInstructions = (setupUrl) => {
|
|
70
|
+
const url = shellWord(setupUrl);
|
|
71
|
+
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.
|
|
43
72
|
|
|
44
73
|
First, start a persistent Monitor on
|
|
45
74
|
npx setup-agent ${url} stream
|
|
@@ -82,6 +111,7 @@ Before that, start the project's dev server in the background when the project h
|
|
|
82
111
|
npx setup-agent ${url} wait start
|
|
83
112
|
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.
|
|
84
113
|
`;
|
|
114
|
+
};
|
|
85
115
|
const INSTRUCTIONS = agentInstructions;
|
|
86
116
|
const parseArgs = (argv) => {
|
|
87
117
|
const positional = [];
|
|
@@ -181,7 +211,7 @@ const chooseLauncher = async () => {
|
|
|
181
211
|
};
|
|
182
212
|
const launchAgent = async (url) => {
|
|
183
213
|
const launcher = await chooseLauncher();
|
|
184
|
-
const prompt = `Run \`npx setup-agent ${url}\` and follow the instructions it prints.`;
|
|
214
|
+
const prompt = `Run \`npx setup-agent ${shellWord(url)}\` and follow the instructions it prints.`;
|
|
185
215
|
process.stdout.write(`\nStarting ${launcher.name}...\n\n`);
|
|
186
216
|
const child = spawn(launcher.command, launcher.args(prompt), { stdio: "inherit" });
|
|
187
217
|
const code = await new Promise((resolve) => {
|
|
@@ -485,6 +515,8 @@ const assertEnvKey = (key) => {
|
|
|
485
515
|
/** Sets `key=value` in dotenv text, replacing an existing line for the key. */
|
|
486
516
|
const upsertEnvLine = (content, key, value) => {
|
|
487
517
|
assertEnvKey(key);
|
|
518
|
+
const isLineBreaking = (code) => code < 32 || code >= 127 && code <= 159 || code === 8232 || code === 8233;
|
|
519
|
+
if ([...value].some((char) => isLineBreaking(char.charCodeAt(0)))) throw new Error(`the value for ${key} must be a single line`);
|
|
488
520
|
const line = `${key}=${value}`;
|
|
489
521
|
const lines = content === "" ? [] : content.replace(/\n$/, "").split("\n");
|
|
490
522
|
const index = lines.findIndex((entry) => /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(entry)?.[1] === key);
|
|
@@ -498,19 +530,98 @@ const upsertEnvLine = (content, key, value) => {
|
|
|
498
530
|
* secret consumes it and an unwritable destination must fail while it is
|
|
499
531
|
* still deposited; it is read after, so lines written meanwhile survive.
|
|
500
532
|
*/
|
|
501
|
-
const writeEnvSecret = async (file, key, take) => {
|
|
533
|
+
const writeEnvSecret = async (file, key, take, projectRoot = process.cwd()) => {
|
|
502
534
|
assertEnvKey(key);
|
|
503
|
-
|
|
535
|
+
try {
|
|
536
|
+
if ((await lstat(file)).isSymbolicLink()) throw new Error(`${file} is a symbolic link; pass --file with its target to write there`);
|
|
537
|
+
} catch (error) {
|
|
538
|
+
if (error.code !== "ENOENT") throw error;
|
|
539
|
+
}
|
|
540
|
+
const projectRelative = relative(projectRoot, file);
|
|
541
|
+
if (projectRelative !== ".." && !projectRelative.startsWith(`..${sep}`) && !isAbsolute(projectRelative)) {
|
|
542
|
+
const root = await realpath(projectRoot);
|
|
543
|
+
const parent = await realpath(dirname(file));
|
|
544
|
+
const parentRelative = relative(root, parent);
|
|
545
|
+
if (parentRelative === ".." || parentRelative.startsWith(`..${sep}`) || isAbsolute(parentRelative)) throw new Error(`${file} has a parent directory outside the project`);
|
|
546
|
+
}
|
|
547
|
+
const handle = await open(file, constants.O_RDWR | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 384).catch((error) => {
|
|
548
|
+
if (error.code === "ELOOP") throw new Error(`${file} is a symbolic link; pass --file with its target to write there`);
|
|
549
|
+
throw error;
|
|
550
|
+
});
|
|
504
551
|
try {
|
|
505
552
|
await handle.chmod(384);
|
|
506
553
|
const value = await take();
|
|
507
554
|
const current = await handle.readFile("utf8");
|
|
555
|
+
const next = upsertEnvLine(current, key, value);
|
|
508
556
|
await handle.truncate(0);
|
|
509
|
-
await handle.writeFile(
|
|
557
|
+
await handle.writeFile(next);
|
|
510
558
|
} finally {
|
|
511
559
|
await handle.close();
|
|
512
560
|
}
|
|
513
561
|
};
|
|
562
|
+
const redeemKeyDirectories = () => {
|
|
563
|
+
const stateHome = process.platform === "win32" ? process.env.LOCALAPPDATA || join(homedir(), "AppData", "Local") : process.env.XDG_STATE_HOME || join(homedir(), ".local", "state");
|
|
564
|
+
return [join(stateHome, "setup-agent"), join(tmpdir(), `setup-agent-${process.platform === "win32" ? userInfo().username : process.getuid()}`)];
|
|
565
|
+
};
|
|
566
|
+
const checkRedeemKeyDirectory = async (directory) => {
|
|
567
|
+
if (!isAbsolute(directory)) throw new Error("state home must be an absolute path");
|
|
568
|
+
const info = await lstat(directory);
|
|
569
|
+
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`redeem key directory must not be a symbolic link: ${directory}`);
|
|
570
|
+
if (process.platform !== "win32") {
|
|
571
|
+
if (info.uid !== process.getuid()) throw new Error(`redeem key directory must be owned by the user: ${directory}`);
|
|
572
|
+
if ((info.mode & 63) !== 0) await chmod(directory, 448);
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
const prepareRedeemKeyDirectory = async (directory) => {
|
|
576
|
+
if (isAbsolute(directory)) await mkdir(directory, {
|
|
577
|
+
recursive: true,
|
|
578
|
+
mode: 448
|
|
579
|
+
});
|
|
580
|
+
await checkRedeemKeyDirectory(directory);
|
|
581
|
+
};
|
|
582
|
+
const saveRedeemKey = async (hash, key) => {
|
|
583
|
+
let lastError;
|
|
584
|
+
for (const directory of redeemKeyDirectories()) {
|
|
585
|
+
const file = join(directory, `${hash}.key`);
|
|
586
|
+
let created = false;
|
|
587
|
+
try {
|
|
588
|
+
await prepareRedeemKeyDirectory(directory);
|
|
589
|
+
const handle = await open(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 384);
|
|
590
|
+
created = true;
|
|
591
|
+
try {
|
|
592
|
+
await handle.writeFile(key);
|
|
593
|
+
} finally {
|
|
594
|
+
await handle.close();
|
|
595
|
+
}
|
|
596
|
+
return file;
|
|
597
|
+
} catch (error) {
|
|
598
|
+
if (created) await unlink(file).catch(() => {});
|
|
599
|
+
lastError = error;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
throw lastError;
|
|
603
|
+
};
|
|
604
|
+
const readRedeemKey = async (hash) => {
|
|
605
|
+
if (!/^[0-9a-f]{64}$/.test(hash)) return void 0;
|
|
606
|
+
for (const directory of redeemKeyDirectories()) try {
|
|
607
|
+
await checkRedeemKeyDirectory(directory);
|
|
608
|
+
const file = join(directory, `${hash}.key`);
|
|
609
|
+
const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
610
|
+
try {
|
|
611
|
+
const info = await handle.stat();
|
|
612
|
+
if (!info.isFile() || process.platform !== "win32" && (info.mode & 63) !== 0) throw new Error("redeem key file must be private and regular");
|
|
613
|
+
const key = await handle.readFile("utf8");
|
|
614
|
+
if (createHash("sha256").update(key).digest("hex") === hash) return {
|
|
615
|
+
key,
|
|
616
|
+
file
|
|
617
|
+
};
|
|
618
|
+
} finally {
|
|
619
|
+
await handle.close();
|
|
620
|
+
}
|
|
621
|
+
} catch {
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
};
|
|
514
625
|
const answerFields = (input, answer) => ({
|
|
515
626
|
answer: input.multiple && parseMultipleAnswer(answer) || answer,
|
|
516
627
|
...input.note !== void 0 && { note: input.note },
|
|
@@ -529,7 +640,12 @@ const main = async (argv) => {
|
|
|
529
640
|
process.stdout.write(HELP);
|
|
530
641
|
return process.exit(first === void 0 ? 1 : 0);
|
|
531
642
|
}
|
|
532
|
-
|
|
643
|
+
let url;
|
|
644
|
+
try {
|
|
645
|
+
url = parseSetupUrl(first);
|
|
646
|
+
} catch (error) {
|
|
647
|
+
return fail(error instanceof Error ? error.message : String(error));
|
|
648
|
+
}
|
|
533
649
|
if (command === "intro" && isDirectInvocation()) return launchAgent(url);
|
|
534
650
|
const client = await connectCheckout(url).catch((error) => fail(error instanceof Error ? error.message : String(error)));
|
|
535
651
|
const commands = client.commands;
|
|
@@ -599,7 +715,20 @@ const main = async (argv) => {
|
|
|
599
715
|
break;
|
|
600
716
|
}
|
|
601
717
|
case "ask": {
|
|
602
|
-
const
|
|
718
|
+
const seed = askSeed(rest, flags);
|
|
719
|
+
const redeemKey = seed.kind === "model" ? randomBytes(32).toString("hex") : void 0;
|
|
720
|
+
const redeemKeyHash = redeemKey === void 0 ? void 0 : createHash("sha256").update(redeemKey).digest("hex");
|
|
721
|
+
const keyFile = redeemKey === void 0 || redeemKeyHash === void 0 ? void 0 : await saveRedeemKey(redeemKeyHash, redeemKey).catch((error) => fail(`could not store the model redeem key: ${error instanceof Error ? error.message : String(error)}`));
|
|
722
|
+
let inputId;
|
|
723
|
+
try {
|
|
724
|
+
({inputId} = await commands["agent/ask"]({
|
|
725
|
+
...seed,
|
|
726
|
+
...redeemKeyHash !== void 0 && { redeemKeyHash }
|
|
727
|
+
}));
|
|
728
|
+
} catch (error) {
|
|
729
|
+
if (keyFile !== void 0) await unlink(keyFile).catch(() => {});
|
|
730
|
+
throw error;
|
|
731
|
+
}
|
|
603
732
|
if (!flags.has("wait")) {
|
|
604
733
|
print({
|
|
605
734
|
ok: true,
|
|
@@ -641,12 +770,22 @@ const main = async (argv) => {
|
|
|
641
770
|
if (isClosed(state)) fail("the setup is not open");
|
|
642
771
|
if (input.status !== "answered") fail(`input "${inputId}" is ${input.status}`);
|
|
643
772
|
if (input.kind !== "model" || !isValidModelAnswer(input, input.answer ?? "")) fail(`input "${inputId}" is not an llm-provider answer`);
|
|
773
|
+
const storedKey = input.redeemKeyHash === void 0 ? void 0 : await readRedeemKey(input.redeemKeyHash);
|
|
774
|
+
if (input.redeemKeyHash !== void 0 && storedKey === void 0) fail(`no local redeem key for "${inputId}"`);
|
|
644
775
|
const file = resolve(flagText(flags, "file") ?? ".env.local");
|
|
645
776
|
await writeEnvSecret(file, envKey, async () => {
|
|
646
|
-
const
|
|
777
|
+
const secretUrl = new URL(url);
|
|
778
|
+
if (secretUrl.protocol !== "https:" && !(secretUrl.protocol === "http:" && isLoopbackHostname(secretUrl.hostname))) fail("the key is only fetched over https");
|
|
779
|
+
const response = await fetch(`${url.replace(/\/$/, "")}/secret/${encodeURIComponent(inputId)}?setup=${encodeURIComponent(setupId)}`, storedKey === void 0 ? void 0 : { headers: { "Setup-Agent-Redeem-Key": storedKey.key } });
|
|
780
|
+
if (response.status === 403) fail(`redeem key rejected for "${inputId}"`);
|
|
647
781
|
if (response.status === 404) fail(`the key for "${inputId}" was already taken or never deposited`);
|
|
648
782
|
if (!response.ok) fail(`could not fetch the key: ${response.status}`);
|
|
649
|
-
|
|
783
|
+
const key = await response.text();
|
|
784
|
+
if (!/^[A-Za-z0-9._~+/=:@%,-]+$/.test(key)) fail("the deposited key is not a valid API key");
|
|
785
|
+
return key;
|
|
786
|
+
});
|
|
787
|
+
if (storedKey !== void 0) await unlink(storedKey.file).catch((error) => {
|
|
788
|
+
if (error.code !== "ENOENT") process.stderr.write(`warning: could not remove a redeem key: ${error.message}\n`);
|
|
650
789
|
});
|
|
651
790
|
print({
|
|
652
791
|
ok: true,
|
|
@@ -689,7 +828,6 @@ const main = async (argv) => {
|
|
|
689
828
|
client.dispose();
|
|
690
829
|
}
|
|
691
830
|
};
|
|
692
|
-
|
|
693
|
-
export { HELP, INSTRUCTIONS, agentInstructions, askSeed, detectAgentKind, diffEvents, isDirectInvocation, main, upsertEnvLine, waitForStart, writeEnvSecret };
|
|
831
|
+
export { HELP, INSTRUCTIONS, agentInstructions, askSeed, detectAgentKind, diffEvents, isDirectInvocation, main, parseSetupUrl, upsertEnvLine, waitForStart, writeEnvSecret };
|
|
694
832
|
|
|
695
833
|
//# sourceMappingURL=cli.js.map
|