a2a-exposed 0.0.0-stage → 0.4.0

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/lib/deploy.mjs ADDED
@@ -0,0 +1,492 @@
1
+ // Non-interactive deploy helper around the Cloudflare `cf` CLI: init, deploy, wake set|unset|test|preview|fingerprint.
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { spawn, spawnSync } from "node:child_process";
6
+ import { fileURLToPath } from "node:url";
7
+ import * as C from "./config.mjs";
8
+ import { cardUrlProblem, cfErrorCode, die, fingerprint, httpJson, randomToken, upstreamCardUrlProblem, upstreamUrlProblem } from "./a2a.mjs";
9
+ import { owner } from "./commands.mjs";
10
+ import * as WD from "./workersdev.mjs";
11
+
12
+ const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
13
+ const PRESETS = ["grok-bot", "claude-code", "openclaw-wake", "openclaw-agent", "hermes", "generic"];
14
+ // WAKE_ACCESS_*: Cloudflare Access service token for a wake URL behind Access (set by `tunnel create`, or exported
15
+ // by hand for your own Access-protected endpoint)
16
+ export const WAKE_SECRETS = ["WAKE_WEBHOOK_URL", "WAKE_WEBHOOK_KEY", "WAKE_HMAC_SECRET", "WAKE_ACCESS_CLIENT_ID", "WAKE_ACCESS_CLIENT_SECRET"];
17
+ // Proxy / expose mode (--upstream): the one credential the façade presents to the private upstream (Bearer), and the
18
+ // Cloudflare Access service token for the upstream's tunnel hostname. Read from the environment only (never argv).
19
+ export const UPSTREAM_SECRETS = ["UPSTREAM_TOKEN", "UPSTREAM_ACCESS_CLIENT_ID", "UPSTREAM_ACCESS_CLIENT_SECRET"];
20
+ const DEPLOY_SECRETS = [...WAKE_SECRETS, ...UPSTREAM_SECRETS];
21
+
22
+ // Non-secret deploy settings persisted in config.env and passed to cloudflare.config.ts as env vars.
23
+ const DEPLOY_KEYS = [
24
+ "CLOUDFLARE_ACCOUNT_ID", "A2A_WORKER_NAME", "A2A_HOSTNAME", "A2A_D1_ID", "A2A_D1_NAME",
25
+ "A2A_AGENT_NAME", "A2A_AGENT_DESCRIPTION", "A2A_AGENT_VERSION", "A2A_AGENT_SKILLS",
26
+ "A2A_PROVIDER_ORGANIZATION", "A2A_PROVIDER_URL", "A2A_DOCUMENTATION_URL",
27
+ "WAKE_PRESET", "WAKE_AGENT_ID", "WAKE_KEY_HEADER", "WAKE_KEY_PREFIX", "WAKE_BODY_TEMPLATE", "WAKE_CLI_COMMAND",
28
+ "WAKE_DEBOUNCE_SECONDS", "WAKE_MAX_PER_HOUR", "A2A_MAX_BODY", "A2A_RATE_PER_MIN", "A2A_ENABLE_CRON",
29
+ "A2A_WORKERS_DEV_SUBDOMAIN", "A2A_RETIRED_HOSTNAMES", "PAIRING_APPROVAL", "A2A_PBKDF2_ITERATIONS", "A2A_WORKERS_LOGS",
30
+ "A2A_UPSTREAM_URL", "A2A_UPSTREAM_CARD_URL",
31
+ ];
32
+ // init/deploy flag -> config key
33
+ const FLAG_KEYS = {
34
+ "account-id": "CLOUDFLARE_ACCOUNT_ID", "cf-profile": "CF_PROFILE", "worker-name": "A2A_WORKER_NAME", hostname: "A2A_HOSTNAME", "d1-name": "A2A_D1_NAME",
35
+ "agent-name": "A2A_AGENT_NAME", "agent-description": "A2A_AGENT_DESCRIPTION", "agent-skills": "A2A_AGENT_SKILLS",
36
+ "provider-organization": "A2A_PROVIDER_ORGANIZATION", "provider-url": "A2A_PROVIDER_URL",
37
+ preset: "WAKE_PRESET", "agent-id": "WAKE_AGENT_ID", "key-header": "WAKE_KEY_HEADER", "key-prefix": "WAKE_KEY_PREFIX",
38
+ "body-template": "WAKE_BODY_TEMPLATE", "cli-command": "WAKE_CLI_COMMAND", debounce: "WAKE_DEBOUNCE_SECONDS", "max-per-hour": "WAKE_MAX_PER_HOUR",
39
+ "pairing-approval": "PAIRING_APPROVAL", "pbkdf2-iterations": "A2A_PBKDF2_ITERATIONS", "workers-logs": "A2A_WORKERS_LOGS",
40
+ upstream: "A2A_UPSTREAM_URL", "upstream-card-url": "A2A_UPSTREAM_CARD_URL",
41
+ };
42
+ export const PAIRING_MODES = ["human", "agent", "off"];
43
+ export const DEPLOY_FLAGS = Object.keys(FLAG_KEYS);
44
+
45
+ const step = (s) => console.error(`==> ${s}`);
46
+ // Worker (and default D1) name for new deployments. A deployment made before the rename that has no saved
47
+ // A2A_WORKER_NAME (a hand-written config.env) keeps the old default, so `deploy` never creates a second Worker.
48
+ export const LEGACY_WORKER_NAME = "a2a-over-webhook";
49
+ export const workerName = () => C.get("A2A_WORKER_NAME") || (C.get("A2A_D1_ID") ? LEGACY_WORKER_NAME : "a2a-exposed");
50
+ /** No custom hostname: the Worker is served on <worker>.<account subdomain>.workers.dev (and only there). */
51
+ const workersDevMode = () => !C.get("A2A_HOSTNAME");
52
+ export const publicBase = () =>
53
+ WD.baseUrlFor({ hostname: C.get("A2A_HOSTNAME"), worker: workerName(), subdomain: C.get("A2A_WORKERS_DEV_SUBDOMAIN") });
54
+ export const workerDir = (o) => path.resolve(o.dir || C.get("A2A_WORKER_DIR") || path.join(C.CONFIG_DIR, "worker"));
55
+
56
+ /** How to get Node 22.18+ (printed by the version check; the setup skill says the same). */
57
+ export const NODE_HELP = `How to get Node 22 (any one; no root needed):
58
+ mise (recommended; the agent skill: npx skills add telegraphic-dev/mise-skill):
59
+ one command: mise exec node@22 -- npx a2a-exposed <command>
60
+ this project: mise use node@22 (then node/npx are Node 22 in this directory)
61
+ nvm: nvm install 22 && nvm use 22
62
+ fnm: fnm install 22 && fnm use 22
63
+ or the official installer / binaries: https://nodejs.org/en/download
64
+ Then check: node --version (v22.18.0 or newer)`;
65
+
66
+ /** Dies unless `version` (default: this Node) is 22.18+; the message says how to get it. */
67
+ export function checkNode(version = process.versions.node) {
68
+ const [maj, min] = String(version).replace(/^v/, "").split(".").map(Number);
69
+ if (maj < 22 || (maj === 22 && min < 18))
70
+ die(`Node ${version} found; init/deploy need Node 22.18+ (the Cloudflare cf CLI requires it).\n${NODE_HELP}`);
71
+ }
72
+
73
+ function templateDir() {
74
+ for (const d of [path.join(PKG_ROOT, "worker"), path.join(PKG_ROOT, "..", "worker")])
75
+ if (fs.existsSync(path.join(d, "cloudflare.config.ts"))) return d;
76
+ die("worker template not found next to the CLI (reinstall the package or run from a repo checkout)");
77
+ }
78
+
79
+ function syncTemplate(dir) {
80
+ const src = templateDir();
81
+ if (path.resolve(src) === dir) return;
82
+ const skip = new Set(["node_modules", ".cloudflare", ".wrangler", "deploy.env"]);
83
+ fs.mkdirSync(dir, { recursive: true });
84
+ fs.cpSync(src, dir, { recursive: true, force: true, filter: (p) => !skip.has(path.basename(p)) });
85
+ }
86
+
87
+ function run(cmd, args, { cwd, env, capture = false, allowFail = false } = {}) {
88
+ const r = spawnSync(cmd, args, { cwd, env: env || process.env, stdio: ["ignore", capture ? "pipe" : "inherit", "inherit"], encoding: "utf8" });
89
+ if (r.error) die(`${cmd}: ${r.error.message}`);
90
+ if (r.status !== 0 && !allowFail) die(`${cmd} ${args[0] || ""} ${args[1] || ""} failed (exit ${r.status})`);
91
+ return r;
92
+ }
93
+
94
+ export function cfBin(dir) {
95
+ const local = path.join(dir, "node_modules", ".bin", "cf");
96
+ return fs.existsSync(local) ? local : "cf";
97
+ }
98
+
99
+ /** cf auth profile (--cf-profile / CF_PROFILE): appended as `--profile <name>` to every cf call. Empty = cf's own
100
+ * resolution (a profile bound to the directory with `cf auth activate`, else `default`). */
101
+ export const cfProfile = () => C.get("CF_PROFILE");
102
+ export const cfArgs = (args) => (cfProfile() ? [...args, "--profile", cfProfile()] : args);
103
+ const runCf = (dir, args, opts) => run(cfBin(dir), cfArgs(args), { cwd: dir, ...opts });
104
+
105
+ function cfJson(dir, args, env) {
106
+ const r = runCf(dir, args, { env, capture: true });
107
+ try { return JSON.parse(r.stdout); } catch { die(`unexpected output from cf ${args.join(" ")}`); }
108
+ }
109
+
110
+ function deployEnv() {
111
+ const env = { ...process.env, CI: "1" };
112
+ for (const k of DEPLOY_KEYS) {
113
+ const v = C.get(k);
114
+ if (v !== "") env[k] = v;
115
+ else if (k !== "WAKE_KEY_PREFIX") delete env[k];
116
+ }
117
+ if ("WAKE_KEY_PREFIX" in C.fileConfig()) env.WAKE_KEY_PREFIX = C.fileConfig().WAKE_KEY_PREFIX;
118
+ env.A2A_WORKER_NAME = workerName(); // explicit, so the template's default never picks the Worker
119
+ for (const k of DEPLOY_SECRETS) delete env[k]; // secrets only travel via the secrets file
120
+ // the agent card URL comes from A2A_HOSTNAME / the workers.dev subdomain only (older templates read these)
121
+ delete env.A2A_PUBLIC_URL; delete env.PUBLIC_URL;
122
+ return env;
123
+ }
124
+
125
+ /** Environment for cf calls other than deploy: account pinned, wake secrets stripped. */
126
+ export function cfEnv() {
127
+ const env = { ...process.env, CLOUDFLARE_ACCOUNT_ID: C.get("CLOUDFLARE_ACCOUNT_ID") };
128
+ for (const k of DEPLOY_SECRETS) delete env[k];
129
+ return env;
130
+ }
131
+
132
+ export async function withSecretsFile(secrets, fn) {
133
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "a2a-secrets-"));
134
+ const file = path.join(tmp, "secrets.json");
135
+ try {
136
+ fs.writeFileSync(file, JSON.stringify(secrets), { mode: 0o600 });
137
+ return await fn(file);
138
+ } finally {
139
+ fs.rmSync(tmp, { recursive: true, force: true });
140
+ }
141
+ }
142
+
143
+ export function wakeSecretsFromEnv() {
144
+ const s = {};
145
+ for (const k of WAKE_SECRETS) if (process.env[k]) s[k] = process.env[k];
146
+ return s;
147
+ }
148
+
149
+ /** Secrets init/deploy upload when exported: the wake secrets plus, for proxy mode, the upstream credentials. */
150
+ export function deploySecretsFromEnv() {
151
+ const s = wakeSecretsFromEnv();
152
+ for (const k of UPSTREAM_SECRETS) if (process.env[k]) s[k] = process.env[k];
153
+ return s;
154
+ }
155
+
156
+ const csv = (s) => String(s || "").split(",").map((x) => x.trim()).filter(Boolean);
157
+
158
+ /** Save init/deploy flags. Returns { from, to } when the public URL switches between a custom domain and
159
+ * workers.dev (or to another custom domain), so the caller can tell the user what changed. */
160
+ function applyFlags(o) {
161
+ const upd = {};
162
+ for (const [flag, key] of Object.entries(FLAG_KEYS)) if (o[flag] !== undefined) upd[key] = o[flag];
163
+ if (o.cron) upd.A2A_ENABLE_CRON = "1";
164
+ if (o["workers-dev"] && o.hostname) die("--workers-dev and --hostname are mutually exclusive (--workers-dev moves the Worker to workers.dev)");
165
+ if (upd.A2A_HOSTNAME) upd.A2A_HOSTNAME = upd.A2A_HOSTNAME.replace(/^https?:\/\//, "").replace(/\/.*$/, "").toLowerCase();
166
+ if (o["workers-dev"]) upd.A2A_HOSTNAME = null;
167
+ upd.A2A_WORKERS_DEV = null; // pre-release key ("also serve on workers.dev"); --workers-dev now switches
168
+ // cf deploys never detach a custom domain, so a hostname we move away from is retired in the Worker
169
+ // (RETIRED_HOSTNAMES: 301 for the agent card, 410 for everything else) instead of silently serving on
170
+ const oldHost = C.fileConfig().A2A_HOSTNAME || "";
171
+ const newHost = upd.A2A_HOSTNAME === null ? "" : upd.A2A_HOSTNAME ?? oldHost;
172
+ let switched = null;
173
+ if (newHost !== oldHost && C.fileConfig().A2A_D1_ID) {
174
+ const retired = new Set(csv(C.fileConfig().A2A_RETIRED_HOSTNAMES));
175
+ if (oldHost) retired.add(oldHost);
176
+ retired.delete(newHost);
177
+ upd.A2A_RETIRED_HOSTNAMES = [...retired].join(",") || null;
178
+ const sub = C.get("A2A_WORKERS_DEV_SUBDOMAIN");
179
+ upd.A2A_BASE_URL = WD.baseUrlFor({ hostname: newHost, worker: upd.A2A_WORKER_NAME || workerName(), subdomain: sub }) || null;
180
+ switched = { from: C.fileConfig().A2A_BASE_URL || (oldHost ? `https://${oldHost}` : ""), fromHost: oldHost, toHost: newHost };
181
+ } else if (newHost && csv(C.fileConfig().A2A_RETIRED_HOSTNAMES).includes(newHost)) {
182
+ upd.A2A_RETIRED_HOSTNAMES = csv(C.fileConfig().A2A_RETIRED_HOSTNAMES).filter((h) => h !== newHost).join(",") || null;
183
+ }
184
+ if (upd.CF_PROFILE !== undefined && !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(upd.CF_PROFILE)) die("--cf-profile must be a cf auth profile name");
185
+ if (o["workers-dev-subdomain"] !== undefined && !WD.LABEL_RE.test(o["workers-dev-subdomain"]))
186
+ die("--workers-dev-subdomain must be lowercase letters, digits and hyphens (a DNS label)");
187
+ if (upd.PAIRING_APPROVAL !== undefined && !PAIRING_MODES.includes(upd.PAIRING_APPROVAL))
188
+ die(`--pairing-approval must be one of ${PAIRING_MODES.join(" | ")} (human: approval password on the /device page; agent: also \`pair approve\`; off: no device-flow pairing)`);
189
+ if (upd.A2A_PBKDF2_ITERATIONS !== undefined) {
190
+ const n = Number(upd.A2A_PBKDF2_ITERATIONS);
191
+ if (!Number.isInteger(n) || n < 50000 || n > 100000)
192
+ die("--pbkdf2-iterations must be an integer from 50000 to 100000 (default 100000, the Workers maximum; lower it only if /device approvals hit Cloudflare error 1102, the CPU limit)");
193
+ upd.A2A_PBKDF2_ITERATIONS = String(n);
194
+ }
195
+ if (upd.A2A_WORKERS_LOGS !== undefined) {
196
+ if (!["on", "off"].includes(upd.A2A_WORKERS_LOGS)) die("--workers-logs must be on or off (Cloudflare Workers Logs: persisted, searchable Worker logs; query strings redacted)");
197
+ upd.A2A_WORKERS_LOGS = upd.A2A_WORKERS_LOGS === "on" ? "1" : null;
198
+ }
199
+ if (upd.WAKE_PRESET && !PRESETS.includes(upd.WAKE_PRESET)) die(`unknown preset ${upd.WAKE_PRESET} (${PRESETS.join(" | ")})`);
200
+ for (const [flag, key] of [["upstream", "A2A_UPSTREAM_URL"], ["upstream-card-url", "A2A_UPSTREAM_CARD_URL"]]) {
201
+ if (upd[key] === undefined) continue;
202
+ if (["", "none", "off"].includes(String(upd[key]).trim().toLowerCase())) { upd[key] = null; continue; }
203
+ const why = upstreamUrlProblem(String(upd[key]).trim());
204
+ if (why) die(`--${flag} ${why}`);
205
+ upd[key] = new URL(String(upd[key]).trim()).href;
206
+ }
207
+ {
208
+ // the card URL (new or saved) must stay on the upstream's origin (new or saved): the card fetch carries the upstream credentials
209
+ const eff = (k) => (upd[k] !== undefined ? upd[k] || "" : C.get(k));
210
+ const up = eff("A2A_UPSTREAM_URL"), card = eff("A2A_UPSTREAM_CARD_URL");
211
+ const why = up && card ? upstreamCardUrlProblem(card, up) : "";
212
+ if (why) die(`--upstream-card-url ${card} ${why}` + (upd.A2A_UPSTREAM_CARD_URL === undefined
213
+ ? ` (it is the saved A2A_UPSTREAM_CARD_URL; pass --upstream-card-url <URL on that origin>, or --upstream-card-url none for <upstream origin>/.well-known/agent-card.json)` : ""));
214
+ }
215
+ if (upd.A2A_AGENT_SKILLS) {
216
+ try { if (!Array.isArray(JSON.parse(upd.A2A_AGENT_SKILLS))) throw 0; } catch { die("--agent-skills must be a JSON array of A2A AgentSkill objects"); }
217
+ }
218
+ C.saveConfig(upd);
219
+ return switched;
220
+ }
221
+
222
+ /** After a deploy that moved the public URL: what stops serving and what peers must update. */
223
+ function reportSwitch(sw) {
224
+ if (!sw) return;
225
+ const to = C.fileConfig().A2A_BASE_URL || publicBase();
226
+ const card = to ? `${to}/.well-known/agent-card.json` : "(see `a2a-exposed url`)";
227
+ console.error(`\nnote: the public URL moved${sw.from ? ` from ${sw.from}` : ""} to ${to || "workers.dev"}.`);
228
+ if (sw.fromHost) {
229
+ console.error(` ${sw.fromHost} no longer serves this agent: its agent card redirects (301) to the new one and every other request gets 410 Gone.`);
230
+ console.error(` Cloudflare keeps the old custom domain attached to the Worker; to detach it completely, remove ${sw.fromHost} under Workers & Pages -> ${workerName()} -> Settings -> Domains & Routes.`);
231
+ } else console.error(" The workers.dev URL is switched off by this deploy.");
232
+ console.error(` Peers must update their URL for this agent: ${card} (A2A_BASE_URL in ${C.CONFIG_FILE} is updated).`);
233
+ if (C.get("A2A_TUNNEL_ID")) console.error(" The wake tunnel is unaffected (it has its own hostname and zone).");
234
+ }
235
+
236
+ let justRegistered = false; // a workers.dev subdomain registered in this run: its DNS takes a few minutes
237
+
238
+ /** Wait until the agent card is served, and served consistently: right after a first deploy the workers.dev edge
239
+ * can answer 200 once and then Cloudflare error 1042 (or 404) for a few seconds while the Worker propagates, so a
240
+ * card counts as up only after `confirm` OKs in a row. Returns { ok, name, problem, lastError }. */
241
+ export async function verifyCard(base, { tries = 12, intervalMs = 5000, confirm = 2, onWait = null } = {}) {
242
+ let streak = 0, last = null, lastError = "", waited = false;
243
+ for (let i = 0; i < tries; i++) {
244
+ try {
245
+ const { status, data } = await httpJson(base + "/.well-known/agent-card.json", { timeout: 10000 });
246
+ if (status === 200 && data && typeof data === "object" && data.name) {
247
+ last = data;
248
+ if (++streak >= confirm) return { ok: true, name: data.name, problem: cardUrlProblem(data, base) };
249
+ } else {
250
+ streak = 0;
251
+ const code = cfErrorCode(typeof data === "string" ? data : "");
252
+ lastError = code ? `Cloudflare error ${code}${code === 1042 ? " (the new Worker is still propagating)" : ""}` : `HTTP ${status}`;
253
+ if (!waited && onWait) { onWait(lastError); waited = true; }
254
+ }
255
+ } catch (e) { streak = 0; lastError = e.message; /* DNS / certificate may still be provisioning */ }
256
+ if (i < tries - 1) await new Promise((r) => setTimeout(r, intervalMs));
257
+ }
258
+ if (last && streak) return { ok: true, name: last.name, problem: cardUrlProblem(last, base) }; // OK on the last try
259
+ return { ok: false, lastError };
260
+ }
261
+
262
+ async function verifyDeployedCard(base) {
263
+ // 0 skips the check (tests); a brand-new workers.dev subdomain usually resolves within ~3 minutes
264
+ const tries = Number(process.env.A2A_VERIFY_TRIES ?? (justRegistered ? 60 : 24));
265
+ if (!tries) return;
266
+ if (justRegistered) step("waiting for the new workers.dev subdomain to resolve (usually 1-5 minutes)");
267
+ const r = await verifyCard(base, { tries, intervalMs: Number(process.env.A2A_VERIFY_INTERVAL_MS || 5000),
268
+ onWait: (why) => step(`agent card not served yet (${why}); a new deployment takes up to ~30 s to propagate, waiting`) });
269
+ if (r.ok && r.problem) return console.error(`warning: the agent card at ${base}/.well-known/agent-card.json does not point peers at this deployment: ${r.problem}. Check with: a2a-exposed status`);
270
+ if (r.ok) return console.error(`agent card OK: ${r.name} (${base}/.well-known/agent-card.json)`);
271
+ console.error(`warning: agent card not reachable yet at ${base} (${r.lastError || "no answer"}); ${C.get("A2A_HOSTNAME") ? "a new custom domain" : "a new workers.dev deployment or subdomain"} can take a few minutes. Check again with: a2a-exposed status`);
272
+ }
273
+
274
+ /** Apply D1 migrations quietly: cf prints a bare JSON array (often `[]`) when stdout is not a TTY. */
275
+ function applyMigrations(dir, env) {
276
+ step("applying D1 migrations");
277
+ const r = runCf(dir, ["d1", "migrations", "apply", C.get("A2A_D1_ID"), "--dir", "migrations"], { env, capture: true });
278
+ let applied = null;
279
+ try { applied = JSON.parse(r.stdout); } catch { /* not JSON: show as is */ }
280
+ if (Array.isArray(applied)) console.error(applied.length ? ` applied: ${applied.map((m) => m.name || m.id || JSON.stringify(m)).join(", ")}` : " (none pending)");
281
+ else if (r.stdout.trim()) console.error(r.stdout.trim());
282
+ }
283
+
284
+ /** Run a command, streaming its output to stderr while collecting it. */
285
+ function runTee(cmd, args, { cwd, env }) {
286
+ return new Promise((resolve, reject) => {
287
+ const ch = spawn(cmd, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
288
+ let out = "";
289
+ const on = (d) => { process.stderr.write(d); out += d; };
290
+ ch.stdout.on("data", on);
291
+ ch.stderr.on("data", on);
292
+ ch.on("error", reject);
293
+ ch.on("close", (code) => resolve({ status: code ?? 1, out }));
294
+ });
295
+ }
296
+
297
+ /** cf deploy (secrets via a temporary chmod-600 file). If the account has no workers.dev subdomain yet and
298
+ * `register` names one, retry through cf's own registration prompt. Returns cf's output. */
299
+ async function cfDeploy(dir, secrets, { register } = {}) {
300
+ const base = ["deploy", "--message", `a2a-exposed ${new Date().toISOString()}`];
301
+ const go = async (file) => {
302
+ const args = cfArgs(file ? [...base, "--secrets-file", file] : base);
303
+ let r = await runTee(cfBin(dir), args, { cwd: dir, env: deployEnv() });
304
+ if (r.status !== 0 && WD.needsSubdomain(r.out)) {
305
+ if (!register) die(WD.noSubdomainHelp(C.get("CLOUDFLARE_ACCOUNT_ID")));
306
+ step(`registering the account's workers.dev subdomain "${register}" (answering cf's prompt)`);
307
+ C.saveConfig({ A2A_WORKERS_DEV_SUBDOMAIN: register }); // so this deploy already carries the final URL
308
+ r = await WD.deployWithRegistration(cfBin(dir), args, register, { cwd: dir, env: deployEnv() })
309
+ .catch((e) => { C.saveConfig({ A2A_WORKERS_DEV_SUBDOMAIN: null }); throw e; });
310
+ if (r.status !== 0) C.saveConfig({ A2A_WORKERS_DEV_SUBDOMAIN: null });
311
+ else justRegistered = true;
312
+ }
313
+ if (r.status !== 0) die(`cf deploy failed (exit ${r.status})`);
314
+ return r.out;
315
+ };
316
+ return secrets && Object.keys(secrets).length ? withSecretsFile(secrets, go) : go(null);
317
+ }
318
+
319
+ /** Deploy, then learn the workers.dev URL from cf's output (saved as A2A_BASE_URL). If the agent card
320
+ * could not carry that URL yet (first deploy on an account whose subdomain we did not know), redeploy once. */
321
+ async function deployAndLearn(dir, secrets, o) {
322
+ const baked = () => C.get("A2A_WORKERS_DEV_SUBDOMAIN");
323
+ const out = await cfDeploy(dir, secrets, { register: o["workers-dev-subdomain"] });
324
+ const deployedWith = baked();
325
+ if (!workersDevMode()) return;
326
+ const sub = WD.parseWorkersDevSubdomain(out, workerName());
327
+ if (sub && o["workers-dev-subdomain"] && sub !== o["workers-dev-subdomain"])
328
+ console.error(`note: the account already has the workers.dev subdomain "${sub}"; using it (--workers-dev-subdomain ignored)`);
329
+ if (sub && sub !== baked()) C.saveConfig({ A2A_WORKERS_DEV_SUBDOMAIN: sub });
330
+ if (!baked()) return console.error("warning: could not find the workers.dev URL in cf's output; the Worker uses the request origin meanwhile. Check the URL in the dashboard and re-run `a2a-exposed deploy`.");
331
+ C.saveConfig({ A2A_BASE_URL: publicBase() });
332
+ if (baked() !== deployedWith) {
333
+ step(`redeploying once so the agent card advertises ${publicBase()}`);
334
+ await cfDeploy(dir, null);
335
+ }
336
+ }
337
+
338
+ /** The deployment's public URL as saved by init/deploy (an exported A2A_BASE_URL does not change it; warned about). */
339
+ function deployedBase() {
340
+ const base = (C.fileConfig().A2A_BASE_URL || publicBase()).replace(/\/$/, "");
341
+ const env = (process.env.A2A_BASE_URL || "").replace(/\/$/, "");
342
+ if (env && base && env !== base)
343
+ console.error(`warning: A2A_BASE_URL is exported as ${env}, but this deployment is ${base}; CLI commands (and push URLs sent to peers) would use the exported value: unset A2A_BASE_URL`);
344
+ return base;
345
+ }
346
+
347
+ export async function init(o) {
348
+ checkNode();
349
+ const switched = applyFlags(o);
350
+ const dir = workerDir(o);
351
+ C.saveConfig({ A2A_WORKER_DIR: dir, A2A_WORKER_NAME: workerName() });
352
+ if (workersDevMode()) {
353
+ if (!WD.LABEL_RE.test(workerName())) die("on workers.dev the --worker-name must be a DNS label (lowercase letters, digits, hyphens)");
354
+ if (!C.get("A2A_HOSTNAME")) console.error(`note: no --hostname, deploying to https://${workerName()}.<account subdomain>.workers.dev (${WD.DOCS})`);
355
+ }
356
+
357
+ step(`worker project -> ${dir}`);
358
+ syncTemplate(dir);
359
+ if (!o["skip-install"]) run("npm", ["install", "--no-audit", "--no-fund", "--loglevel=error"], { cwd: dir });
360
+
361
+ const prof = cfProfile();
362
+ step(`checking Cloudflare login (cf auth whoami${prof ? ` --profile ${prof}` : ""})`);
363
+ const who = cfJson(dir, ["auth", "whoami"]);
364
+ if (!who.authenticated) die(prof
365
+ ? `cf profile "${prof}" is not logged in: run \`npx cf auth create ${prof} --no-browser\`, open the printed URL, enter the code, then re-run init`
366
+ : "not logged in to Cloudflare: run `cf auth login --no-browser`, open the printed URL, enter the code, then re-run init");
367
+ const accts = who.accounts || [];
368
+ if (!C.get("CLOUDFLARE_ACCOUNT_ID")) {
369
+ if (accts.length !== 1) die(`${accts.length} accounts available; pass --account-id <id>:\n` + accts.map((a) => ` ${a.id} ${a.name}`).join("\n"));
370
+ C.saveConfig({ CLOUDFLARE_ACCOUNT_ID: accts[0].id });
371
+ } else if (accts.length && !accts.some((a) => a.id === C.get("CLOUDFLARE_ACCOUNT_ID"))) {
372
+ die(`account ${C.get("CLOUDFLARE_ACCOUNT_ID")} is not visible to the cf login${prof ? ` (profile "${prof}")` : ""}; pass --cf-profile for the right login or --account-id for one of:\n` + accts.map((a) => ` ${a.id} ${a.name}`).join("\n"));
373
+ }
374
+ step(`Cloudflare account: ${accts.find((a) => a.id === C.get("CLOUDFLARE_ACCOUNT_ID"))?.name || C.get("CLOUDFLARE_ACCOUNT_ID")}${prof ? ` (cf profile ${prof})` : ""}`);
375
+ const env = cfEnv();
376
+
377
+ const dbName = C.get("A2A_D1_NAME") || C.get("A2A_WORKER_NAME");
378
+ if (!C.get("A2A_D1_ID")) {
379
+ step(`D1 database "${dbName}"`);
380
+ let db = (cfJson(dir, ["d1", "list"], env) || []).find((d) => d.name === dbName);
381
+ if (!db) {
382
+ runCf(dir, ["d1", "create", "--name", dbName], { env, capture: true });
383
+ db = (cfJson(dir, ["d1", "list"], env) || []).find((d) => d.name === dbName);
384
+ }
385
+ if (!db || !db.uuid) die("could not create or find the D1 database");
386
+ C.saveConfig({ A2A_D1_ID: db.uuid, A2A_D1_NAME: dbName });
387
+ }
388
+ applyMigrations(dir, env);
389
+
390
+ let ownerToken = C.fileConfig().A2A_OWNER_TOKEN;
391
+ if (!ownerToken || o["rotate-owner-token"]) ownerToken = randomToken(32);
392
+ C.saveConfig({ A2A_OWNER_TOKEN: ownerToken, A2A_BASE_URL: publicBase() || undefined });
393
+
394
+ const secrets = { OWNER_TOKEN: ownerToken, ...deploySecretsFromEnv() };
395
+ step(`deploying (secrets uploaded: ${Object.keys(secrets).join(", ")})`);
396
+ await deployAndLearn(dir, secrets, o);
397
+ const base = deployedBase();
398
+ if (base) await verifyDeployedCard(base);
399
+ console.log(base);
400
+ reportSwitch(switched);
401
+ proxyNote();
402
+ console.error(`config saved to ${C.CONFIG_FILE} (chmod 600). Next: a2a-exposed status (shows the next setup step)`);
403
+ }
404
+
405
+ /** Proxy / expose mode reminder after a deploy (the upstream credentials are secrets: only their presence is shown). */
406
+ function proxyNote() {
407
+ const up = C.get("A2A_UPSTREAM_URL");
408
+ if (!up) return;
409
+ const sent = UPSTREAM_SECRETS.filter((k) => process.env[k]);
410
+ console.error(`proxy mode: authenticated A2A calls are forwarded to ${up}; the public card is the upstream's, rewritten to this deployment's URL.` +
411
+ ` Upstream secrets uploaded now: ${sent.join(", ") || "none (existing ones kept)"}. Check the upstream and the card with: a2a-exposed status`);
412
+ }
413
+
414
+ export async function deploy(o) {
415
+ checkNode();
416
+ if (!C.get("A2A_D1_ID")) die("no saved deployment; run `a2a-exposed init` first");
417
+ const switched = applyFlags(o);
418
+ const dir = workerDir(o);
419
+ if (workersDevMode() && !WD.LABEL_RE.test(workerName())) die("on workers.dev the worker name must be a DNS label (lowercase letters, digits, hyphens)");
420
+ guardTunnelUrl();
421
+ step(`worker project -> ${dir}${cfProfile() ? ` (cf profile ${cfProfile()})` : ""}`);
422
+ syncTemplate(dir);
423
+ if (!o["skip-install"]) run("npm", ["install", "--no-audit", "--no-fund", "--loglevel=error"], { cwd: dir });
424
+ const env = cfEnv();
425
+ applyMigrations(dir, env);
426
+ const secrets = deploySecretsFromEnv();
427
+ if (Object.keys(secrets).length && C.get("A2A_OWNER_TOKEN")) secrets.OWNER_TOKEN = C.get("A2A_OWNER_TOKEN");
428
+ step(Object.keys(secrets).length ? `deploying (secrets uploaded: ${Object.keys(secrets).join(", ")})` : "deploying (existing secrets kept)");
429
+ await deployAndLearn(dir, secrets, o);
430
+ const base = deployedBase();
431
+ if (base) await verifyDeployedCard(base);
432
+ if (switched) { console.log(base); reportSwitch(switched); }
433
+ proxyNote();
434
+ }
435
+
436
+ /** Fingerprints of the wake secrets in the local environment (what `wake set` would upload). */
437
+ const localFingerprints = () => ({
438
+ url: fingerprint(process.env.WAKE_WEBHOOK_URL), key: fingerprint(process.env.WAKE_WEBHOOK_KEY), hmacSecret: fingerprint(process.env.WAKE_HMAC_SECRET),
439
+ accessClientId: fingerprint(process.env.WAKE_ACCESS_CLIENT_ID), accessClientSecret: fingerprint(process.env.WAKE_ACCESS_CLIENT_SECRET),
440
+ });
441
+
442
+ /** A tunnel created by `tunnel create` owns WAKE_WEBHOOK_URL: refuse to overwrite it with a different host. */
443
+ function guardTunnelUrl() {
444
+ const th = C.get("A2A_TUNNEL_HOSTNAME"), url = process.env.WAKE_WEBHOOK_URL;
445
+ if (!th || !url) return;
446
+ let host = "";
447
+ try { host = new URL(url).host; } catch { /* invalid URL: reported elsewhere */ }
448
+ if (host !== th) die(`this deployment wakes through the tunnel https://${th} (tunnel create); unset WAKE_WEBHOOK_URL, or run \`a2a-exposed tunnel rm\` first`);
449
+ }
450
+
451
+ export async function wake(sub, o) {
452
+ if (sub === "fingerprint") {
453
+ const fp = localFingerprints();
454
+ if (!Object.values(fp).some(Boolean)) die(`no ${WAKE_SECRETS.join(" / ")} in the environment`);
455
+ return console.log(JSON.stringify(fp, null, 2));
456
+ }
457
+ if (sub === "preview") {
458
+ const r = await owner("GET", "/owner/wake/preview");
459
+ console.log(JSON.stringify(r, null, 2));
460
+ // compare with the local environment, if the secrets are exported here
461
+ const local = localFingerprints(), remote = r.fingerprints || {};
462
+ for (const [k, v] of Object.entries(local)) {
463
+ if (!v) continue;
464
+ if (!r.fingerprints) { console.error("# this Worker predates fingerprints; redeploy (a2a-exposed deploy) to compare"); break; }
465
+ console.error(`# ${k}: local ${v} vs uploaded ${remote[k] || "(unset)"} -> ${v === remote[k] ? "match" : "DIFFERENT"}`);
466
+ }
467
+ return;
468
+ }
469
+ if (sub === "test") {
470
+ const r = await owner("POST", "/owner/wake/test", {});
471
+ if (r.configured === false || r.info === "WAKE_WEBHOOK_URL unset")
472
+ die("no wake webhook configured on the Worker: export WAKE_WEBHOOK_URL (and WAKE_WEBHOOK_KEY or WAKE_HMAC_SECRET), then run `a2a-exposed wake set` first (or use polling)");
473
+ console.log(JSON.stringify(r, null, 2));
474
+ if (!(r.status >= 200 && r.status < 300)) process.exitCode = 1;
475
+ return;
476
+ }
477
+ if (sub === "set") {
478
+ // non-secret settings via flags; secrets only from the environment (never argv)
479
+ const secrets = wakeSecretsFromEnv();
480
+ if (!Object.keys(secrets).length && !DEPLOY_FLAGS.some((f) => o[f] !== undefined))
481
+ die("nothing to set: export WAKE_WEBHOOK_URL / WAKE_WEBHOOK_KEY / WAKE_HMAC_SECRET and/or pass --preset etc.");
482
+ await deploy(o); // wake set = save settings + upload secrets + redeploy, in one step
483
+ return console.error("wake settings deployed. Next: a2a-exposed wake preview, then a2a-exposed wake test");
484
+ }
485
+ if (sub === "unset") {
486
+ const dir = workerDir(o), name = workerName();
487
+ const env = cfEnv();
488
+ for (const k of WAKE_SECRETS) runCf(dir, ["workers", "secrets", "delete", k, "--worker", name, "--force"], { env, allowFail: true });
489
+ return console.error("wake secrets removed (wake is now a no-op; use polling)");
490
+ }
491
+ die("usage: wake set|unset|test|preview|fingerprint");
492
+ }