@getnoan/wizard 0.1.5 → 0.1.6

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 CHANGED
@@ -8,7 +8,8 @@ npx -y @getnoan/wizard@latest
8
8
 
9
9
  It takes your NOAN API key, points your coding assistants at the NOAN MCP server, installs the
10
10
  NOAN skill, checks whether your fact layer has anything in it, and, if you want, sets up the six
11
- open-source agents on your own GitHub account. Then it tells you what it did and what to do next.
11
+ open-source agents on your own GitHub account and gets the two web agents — booking pages and a
12
+ site chat — ready to deploy. Then it tells you what it did and what to do next.
12
13
  Run it again any time; every write is a merge, and nothing you have is overwritten.
13
14
 
14
15
  ## What it does
@@ -41,7 +42,20 @@ Run it again any time; every write is a merge, and nothing you have is overwritt
41
42
  running — `https://openrouter.ai/api`, say, or your own gateway — and the wizard verifies your key
42
43
  against *that* endpoint, stores it as `LLM_API_KEY`, and records the endpoint as a repository
43
44
  variable so the fork actually uses it. Unset, nothing changes: it asks for an Anthropic key.
44
- 6. **The report.** What happened, and the one thing to do next.
45
+ 6. **The web agents** (optional, `--meetings`, `--chat`). Two agents run as always-on services on
46
+ your own domain rather than as scheduled jobs, so they live in their own repos:
47
+ [Verity Meetings](https://github.com/getnoan/verity-meetings) (booking pages) and
48
+ [Verity Chat](https://github.com/getnoan/verity-chat) (a chat widget for your website). For each
49
+ one you pick, the wizard forks the repo to your account, runs its seed scripts so its starting
50
+ instructions are in your workspace (in a stack of their own, apart from any other agent's), boots
51
+ it locally with no keys to prove it works, and writes a gitignored `.env` in the clone with the
52
+ block slugs. Deploying needs accounts only you can create — a host, a Supabase project, and for
53
+ Meetings, Google Calendar credentials — so the report hands your assistant the rest of the repo's
54
+ `INSTALL.md` and a one-click Render deploy link.
55
+
56
+ Your personal NOAN key is **not** written into either service's `.env`: these answer the open
57
+ internet, so each gets a NOAN key made for it alone. The report says so.
58
+ 7. **The report.** What happened, and the one thing to do next.
45
59
 
46
60
  ## For a coding assistant
47
61
 
@@ -55,7 +69,9 @@ NOAN_API_KEY=npak_… npx -y @getnoan/wizard@latest --yes --json
55
69
  (progress goes to stderr) with a `next` list an agent can act on, including the first-connect
56
70
  hand-off when the workspace is empty. `--agents` adds the pack step; it reads `ANTHROPIC_API_KEY`,
57
71
  `RESEND_API_KEY`, `DATABASE_URL`, `FIRECRAWL_API_KEY`, `MAIL_FROM`, `REPLY_TO` and `ESCALATE_TO`
58
- from the environment and skips what is missing, saying so.
72
+ from the environment and skips what is missing, saying so. `--meetings` and `--chat` add the web
73
+ agents; the chat reads its model key from the same variables as the pack (or reuses the pack
74
+ step's in the same run), and a `next` item per service carries the deploy hand-off.
59
75
 
60
76
  Exit codes: 0 done, 2 the key did not work, 3 cancelled, 1 anything else.
61
77
 
@@ -69,6 +85,8 @@ Exit codes: 0 done, 2 the key did not work, 3 cancelled, 1 anything else.
69
85
  | `--global` | user-scope client config instead of project scope |
70
86
  | `--dir <path>` | the project directory (default: current) |
71
87
  | `--agents` / `--no-agents` | include or skip the agent pack step |
88
+ | `--meetings` / `--no-meetings` | include or skip Verity Meetings (booking pages) |
89
+ | `--chat` / `--no-chat` | include or skip Verity Chat (a chat widget for your site) |
72
90
  | `--no-mcp`, `--no-skill` | skip a step |
73
91
  | `--dry-run` | show every write without making it |
74
92
  | `--no-telemetry` | send nothing about this run (also `NOAN_WIZARD_NO_TELEMETRY`, `DO_NOT_TRACK`, or any `CI`) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getnoan/wizard",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "One command from nothing to a grounded agent: sign in to NOAN, wire your coding assistant, install the skill, and set up the agent pack.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/args.mjs CHANGED
@@ -11,7 +11,10 @@ What it does, in order:
11
11
  4. Workspace checks whether your fact layer has anything in it; if not, hands the
12
12
  seeding to your assistant (it has the procedure) rather than guessing
13
13
  5. Agents optionally sets up the open-source agent pack on your GitHub account
14
- 6. Report says what it did, and what to do next
14
+ 6. Web optionally forks Verity Meetings (booking pages) and Verity Chat (a chat
15
+ widget for your site), seeds their facts, proves each boots locally, and
16
+ hands the deploy to your assistant
17
+ 7. Report says what it did, and what to do next
15
18
 
16
19
  Options:
17
20
  -y, --yes non-interactive: take every default, never prompt (for an agent mid-session)
@@ -21,6 +24,8 @@ Options:
21
24
  instead of project scope (.mcp.json, .cursor/mcp.json, .vscode/mcp.json)
22
25
  --dir <path> the project directory (default: the current directory)
23
26
  --agents also set up the agent pack (default: ask; with --yes: skip unless given)
27
+ --meetings also set up Verity Meetings, booking pages on your own domain (same default)
28
+ --chat also set up Verity Chat, a chat widget for your website (same default)
24
29
  --no-mcp skip the client wiring
25
30
  --no-skill skip the skill install
26
31
  --dry-run show every write without making it
@@ -32,7 +37,7 @@ Exit codes: 0 done · 2 the key did not work · 3 cancelled · 1 anything else
32
37
  `;
33
38
 
34
39
  export function parseArgs(argv) {
35
- const a = { yes: false, json: false, apiKey: null, global: false, dir: process.cwd(), agents: null,
40
+ const a = { yes: false, json: false, apiKey: null, global: false, dir: process.cwd(), agents: null, meetings: null, chat: null,
36
41
  mcp: true, skill: true, dryRun: false, telemetry: true, help: false, version: false, unknown: [] };
37
42
  for (let i = 0; i < argv.length; i++) {
38
43
  const t = argv[i];
@@ -46,6 +51,10 @@ export function parseArgs(argv) {
46
51
  else if (t.startsWith("--dir=")) a.dir = t.slice(6);
47
52
  else if (t === "--agents") a.agents = true;
48
53
  else if (t === "--no-agents") a.agents = false;
54
+ else if (t === "--meetings") a.meetings = true;
55
+ else if (t === "--no-meetings") a.meetings = false;
56
+ else if (t === "--chat") a.chat = true;
57
+ else if (t === "--no-chat") a.chat = false;
49
58
  else if (t === "--no-mcp") a.mcp = false;
50
59
  else if (t === "--no-skill") a.skill = false;
51
60
  else if (t === "--dry-run") a.dryRun = true;
package/src/report.mjs CHANGED
@@ -15,6 +15,13 @@ export function renderReport(report, { json = false } = {}) {
15
15
  if (s.skill?.rows) L.push(`skill: ${s.skill.rows.map(r => `${r.id} (${r.written} written)`).join(" · ")}; pointers: ${s.skill.pointers.map(p => p.action).join(", ")}`);
16
16
  if (s.workspace) L.push(`facts: ${s.workspace.count ?? "?"} (${s.workspace.state})`);
17
17
  if (s.agents && !s.agents.skipped) L.push(s.agents.ok ? `agents: ${s.agents.repo || s.agents.dest} — ${s.agents.agentName || "your agent"}, ${s.agents.secrets.length} secrets, ${s.agents.variables.length} variables, safe mode on` : `agents: not set up (${s.agents.reason})`);
18
+ if (s.web && !s.web.skipped) for (const [id, w] of Object.entries(s.web)) {
19
+ if (!w.ok) { L.push(`${id}: not set up (${w.reason})`); continue; }
20
+ if (report.dryRun) { L.push(`${id}: would fork to ${w.dest}, run ${w.seeds.length} seed(s), boot it locally and write its .env${w.missing?.length ? ` (still needs ${w.missing.join(", ")})` : ""}`); continue; }
21
+ const seeded = w.seeds.filter(r => r.action === "ran").length;
22
+ const local = w.local?.ok ? "boots locally" : w.local?.ok === false ? `local boot failed (${w.local.reason})` : "local boot not tried";
23
+ L.push(`${id}: ${w.repo || w.dest} — ${seeded}/${w.seeds.length} seeds ran, ${local}, .env written${w.missing?.length ? ` (still needs ${w.missing.join(", ")})` : ""}${w.deployUrl ? `, deploy: ${w.deployUrl}` : ""}`);
24
+ }
18
25
  if (report.next?.length) { L.push("", "──────── next ────────"); for (const n of report.next) L.push(`• ${n.say}${n.why ? ` (${n.why})` : ""}`); }
19
26
  L.push("");
20
27
  return L.join("\n");
package/src/run.mjs CHANGED
@@ -7,6 +7,7 @@ import { writeEnv, ensureGitignored } from "./env-file.mjs";
7
7
  import { wireClients, MANUAL_CLIENTS } from "./clients.mjs";
8
8
  import { fetchSkillFiles, skillTargets, installSkill, writePointers } from "./skill.mjs";
9
9
  import * as pack from "./agents.mjs";
10
+ import * as web from "./web.mjs";
10
11
  import { capture } from "./telemetry.mjs";
11
12
  import { renderReport } from "./report.mjs";
12
13
 
@@ -86,17 +87,36 @@ export async function run(args) {
86
87
  ui.step(5, "The agent pack");
87
88
  let wantAgents = args.agents;
88
89
  if (wantAgents == null) wantAgents = ui.interactive ? await ui.confirm("Set up the six open-source agents on your GitHub account too?", false) : false;
89
- if (wantAgents) report.steps.agents = await setupAgents({ args, ui, dir, key, me, report });
90
+ // Carries the model key and the agent's name from the pack step to the web step, so a run that
91
+ // does both asks once. Never put in the report: the report is printed, and --json is parsed.
92
+ const shared = {};
93
+ if (wantAgents) report.steps.agents = await setupAgents({ args, ui, dir, key, me, report, shared });
90
94
  else { ui.info("skipped" + (args.yes && args.agents == null ? " (pass --agents to include it)" : "")); report.steps.agents = { skipped: true }; }
91
95
 
92
- /* 6. report */
96
+ /* 6. web agents */
97
+ ui.step(6, "The web agents");
98
+ const wantWeb = [];
99
+ for (const [id, agent] of Object.entries(web.WEB_AGENTS)) {
100
+ let want = args[id];
101
+ if (want == null) want = ui.interactive ? await ui.confirm(`Set up ${agent.label} — ${agent.what}?`, false) : false;
102
+ if (want) wantWeb.push(id);
103
+ }
104
+ if (!wantWeb.length) {
105
+ ui.info("skipped" + (args.yes && args.meetings == null && args.chat == null ? " (pass --meetings and/or --chat to include them)" : ""));
106
+ report.steps.web = { skipped: true };
107
+ } else {
108
+ report.steps.web = {};
109
+ for (const id of wantWeb) report.steps.web[id] = await setupWeb({ id, args, ui, dir, key, me, report, shared });
110
+ }
111
+
112
+ /* 7. report */
93
113
  report.next.push({ id: "open", say: `Open ${APP_URL} to see the workspace your assistant is grounded in.` });
94
114
  return finish(0);
95
115
  }
96
116
 
97
117
  async function ask(ui, q, o) { return ui.ask(q, o); }
98
118
 
99
- async function setupAgents({ args, ui, dir, key, me, report }) {
119
+ async function setupAgents({ args, ui, dir, key, me, report, shared = {} }) {
100
120
  const ready = pack.ghReady();
101
121
  if (!ready.ok) { ui.warn(ready.reason); return { ok: false, reason: ready.reason }; }
102
122
  const fork = pack.forkAndClone(dir, { dryRun: args.dryRun });
@@ -196,5 +216,63 @@ async function setupAgents({ args, ui, dir, key, me, report }) {
196
216
  }
197
217
  out.repo = pack.repoSlug(fork.dest);
198
218
  out.agentName = identity.AGENT_NAME;
219
+ Object.assign(shared, { modelKey: anthropic, modelKeyName: mName, modelBase: mBase, agentName: identity.AGENT_NAME });
220
+ return out;
221
+ }
222
+
223
+ /** One web agent: fork, seed, prove it boots, write its .env, hand the deploy to the assistant.
224
+ * What it returns goes in the report, so it carries names and outcomes — never a key. */
225
+ async function setupWeb({ id, args, ui, dir, key, me, report, shared }) {
226
+ const agent = web.WEB_AGENTS[id];
227
+ ui.info(`${agent.label}:`);
228
+ const ready = pack.ghReady();
229
+ if (!ready.ok) { ui.warn(ready.reason); return { ok: false, reason: ready.reason }; }
230
+ const fork = web.forkAndCloneWeb(dir, agent, { dryRun: args.dryRun });
231
+ ui.ok(`${fork.action}: ${fork.dest}${fork.error ? ` — ${fork.error}` : ""}`);
232
+ if (fork.error) return { ok: false, reason: fork.error };
233
+ const out = { ok: true, dest: fork.dest, seeds: [], skipped: [] };
234
+
235
+ // Seeds run under the person's own key: setting up their workspace is theirs to do.
236
+ const seeds = web.runWebSeeds(fork.dest, agent, { NOAN_PERSONAL_API_KEY: key }, { dryRun: args.dryRun });
237
+ out.seeds = seeds.rows;
238
+ for (const r of seeds.rows) (r.action === "failed" || r.action === "missing" ? ui.warn : ui.ok)(`${r.seed}: ${r.action}${r.slugs != null ? ` (${r.slugs} slug(s))` : ""}${r.error ? ` — ${r.error}` : ""}`);
239
+
240
+ // INSTALL.md step 0, for them: the service boots with no keys at all.
241
+ if (args.dryRun) out.local = { ok: null, reason: "dry run" };
242
+ else if (existsSync(fork.dest)) {
243
+ out.local = await web.proveLocally(fork.dest, agent);
244
+ if (out.local.ok) ui.ok(`boots locally with no keys (${out.local.probes.join(", ")} answered)`);
245
+ else ui.warn(`did not boot locally: ${out.local.reason} — INSTALL.md step 0 runs the same thing by hand`);
246
+ }
247
+
248
+ // The chat needs a model key; reuse the pack step's if this run had one, else env, else ask.
249
+ let modelKey = null, modelKeyName = "ANTHROPIC_API_KEY", modelBase = "";
250
+ if (agent.needsModelKey) {
251
+ if (shared.modelKey) ({ modelKey, modelKeyName, modelBase } = shared);
252
+ else {
253
+ try { modelBase = pack.modelBase(); } catch (e) { ui.warn(e.message); out.skipped.push({ name: "model key", why: e.message }); modelBase = null; }
254
+ if (modelBase !== null) {
255
+ modelKeyName = pack.modelKeyName();
256
+ modelKey = (process.env[modelKeyName] || process.env[modelKeyName === "LLM_API_KEY" ? "ANTHROPIC_API_KEY" : "LLM_API_KEY"] || "").trim()
257
+ || (ui.interactive ? await ui.ask(`API key for ${modelBase ? new URL(modelBase).host : "Anthropic"} (the chat's model; blank to add it later)`, { secret: true }) : "");
258
+ if (modelKey) {
259
+ const verdict = await pack.verifyModelKey(modelKey, modelBase);
260
+ if (verdict === false) { ui.warn(`${modelKeyName} was not accepted by its service; not written`); out.skipped.push({ name: modelKeyName, why: "rejected" }); modelKey = null; }
261
+ } else out.skipped.push({ name: modelKeyName, why: "not provided" });
262
+ }
263
+ }
264
+ }
265
+
266
+ const agentName = shared.agentName || pack.agentIdentity({ name: process.env.AGENT_NAME }).AGENT_NAME;
267
+ const pairs = web.webEnv(agent, { slugs: seeds.slugs, company: me.project?.name || "", agentName, modelKey, modelKeyName, modelBase: modelBase || "" });
268
+ const env = web.writeWebEnv(fork.dest, pairs, { dryRun: args.dryRun });
269
+ ui.ok(`${args.dryRun ? "would write" : env.created ? "wrote" : env.changed ? "updated" : "kept"} ${env.path} (${env.keys.join(", ") || "nothing new"}); .gitignore: ${env.gitignore}`);
270
+ out.env = { path: env.path, keys: env.keys, gitignore: env.gitignore };
271
+ const missingModelKey = agent.needsModelKey && !modelKey ? modelKeyName : null;
272
+ if (missingModelKey) { ui.warn(`no ${missingModelKey} written: the chat cannot answer until it has one`); out.missing = [missingModelKey]; }
273
+
274
+ out.repo = args.dryRun ? null : pack.repoSlug(fork.dest);
275
+ out.deployUrl = web.renderDeployUrl(out.repo);
276
+ report.next.push(web.webNext(agent, { dest: fork.dest, deployUrl: out.deployUrl, missingModelKey }));
199
277
  return out;
200
278
  }
package/src/web.mjs ADDED
@@ -0,0 +1,169 @@
1
+ /**
2
+ * The web-facing agents: getnoan/verity-meetings (booking pages) and getnoan/verity-chat (a chat
3
+ * widget for your site). They are always-on services on the user's own domain, not scheduled
4
+ * jobs, so they live in their own repos and deploy to a host (Render by default), not to Actions.
5
+ *
6
+ * The wizard does the part that needs no account the user has not already given it:
7
+ * fork and clone → seed the service's facts (under the user's own key, a human action) →
8
+ * prove the service boots locally with NO keys → write a gitignored .env in the clone with what
9
+ * is known → hand the rest to the coding assistant, INSTALL.md's remaining steps plus a Render
10
+ * deploy link.
11
+ * The rest — a host account, a Supabase project, Google calendar credentials, DNS, the embed tag on
12
+ * their site — needs the person, and INSTALL.md marks each of those HUMAN.
13
+ *
14
+ * The personal NOAN key is NOT written into the service's .env. That key reaches the whole
15
+ * workspace, and these services answer the open internet; each gets a key made for it, and the
16
+ * hand-off says so. The seeds are different: seeding is a person setting up their own workspace.
17
+ */
18
+ import { spawn, spawnSync } from "node:child_process";
19
+ import { existsSync, readFileSync } from "node:fs";
20
+ import { createServer } from "node:net";
21
+ import path from "node:path";
22
+ import { gh, parseSeedOutput, randomSecret } from "./agents.mjs";
23
+ import { writeEnv, ensureGitignored } from "./env-file.mjs";
24
+
25
+ /** One entry per web agent. `boot` is INSTALL.md step 0 — the no-keys local run — and `probe` is
26
+ * what proves it came up; both are the repos' own CI checks, so a green CI there means this works. */
27
+ export const WEB_AGENTS = {
28
+ meetings: {
29
+ label: "Verity Meetings",
30
+ repo: "getnoan/verity-meetings",
31
+ dirName: "verity-meetings",
32
+ what: "booking pages on your own domain",
33
+ seeds: ["agents/seed-scheduling.mjs", "agents/seed-booking.mjs"],
34
+ boot: (port) => ({
35
+ args: ["booking/server.mjs"],
36
+ env: { BOOKING_ENABLED: "1", BOOKING_STORE: "memory", BOOKING_DRY_RUN: "1",
37
+ BOOKING_HOSTS: "founder@example.com,teammate@example.com",
38
+ BOOKING_PUBLIC_URL: `http://127.0.0.1:${port}`, PORT: String(port) },
39
+ }),
40
+ probe: ["/healthz", "/alex"],
41
+ needsModelKey: false,
42
+ // INSTALL.md steps left after the wizard: it has seeded (step 1's second half).
43
+ remaining: "step 2 (Google calendar credentials), step 3 (Supabase + schema.sql), step 4 (deploy), step 5 (DNS)",
44
+ after: "Then edit the Booking Types fact in NOAN: the starters use placeholder hosts, and a type whose host is not in BOOKING_HOSTS is not served.",
45
+ },
46
+ chat: {
47
+ label: "Verity Chat",
48
+ repo: "getnoan/verity-chat",
49
+ dirName: "verity-chat",
50
+ what: "a grounded chat widget for your website",
51
+ seeds: ["agents/seed-site-chat.mjs"],
52
+ boot: (port) => ({
53
+ args: ["--input-type=module", "-e", `const { server } = await import("./site-web/server.mjs"); server.listen(${port}, "127.0.0.1");`],
54
+ env: { NODE_ENV: "test" },
55
+ }),
56
+ probe: ["/healthz", "/widget.js"],
57
+ needsModelKey: true,
58
+ remaining: "step 3 (Supabase + schema.sql — the daily budget refuses every chat until it can meter), step 4 (deploy), step 5 (the embed tag on your site)",
59
+ after: "Set SITE_ALLOWED_ORIGINS to your site's origins and SITE_CHAT_GROUNDING_SLUGS to the blocks it may answer from.",
60
+ },
61
+ };
62
+
63
+ /** Fork (or reuse) and clone a web agent's repo into dir/<dirName>. */
64
+ export function forkAndCloneWeb(dir, agent, { dryRun = false } = {}) {
65
+ const dest = path.join(dir, agent.dirName);
66
+ if (existsSync(path.join(dest, ".git"))) return { dest, action: "reused existing clone" };
67
+ if (dryRun) return { dest, action: "would fork and clone" };
68
+ const r = gh(["repo", "fork", agent.repo, "--clone", "--remote=false", dest]);
69
+ if (!r.ok) return { dest, action: "fork failed", error: r.err.split("\n")[0] };
70
+ return { dest, action: "forked and cloned" };
71
+ }
72
+
73
+ /** Run a web agent's seed scripts from the repo root; collect the block slugs they print. */
74
+ export function runWebSeeds(dest, agent, env, { dryRun = false } = {}) {
75
+ const slugs = {}; const rows = [];
76
+ for (const s of agent.seeds) {
77
+ const p = path.join(dest, s);
78
+ // A dry run has no clone yet, so "missing" there would be a false alarm.
79
+ if (dryRun && !existsSync(dest)) { rows.push({ seed: s, action: "would run" }); continue; }
80
+ if (!existsSync(p)) { rows.push({ seed: s, action: "missing" }); continue; }
81
+ if (dryRun) { rows.push({ seed: s, action: "would run" }); continue; }
82
+ const r = spawnSync(process.execPath, [p], { cwd: dest, encoding: "utf8", env: { ...process.env, ...env } });
83
+ const found = parseSeedOutput((r.stdout || "") + "\n" + (r.stderr || ""));
84
+ Object.assign(slugs, found);
85
+ rows.push({ seed: s, action: r.status === 0 ? "ran" : "failed", slugs: Object.keys(found).length,
86
+ error: r.status === 0 ? undefined : (r.stderr || r.stdout || "").trim().split("\n").slice(-1)[0] });
87
+ }
88
+ return { slugs, rows };
89
+ }
90
+
91
+ export function freePort() {
92
+ return new Promise((res, rej) => {
93
+ const s = createServer(); s.unref(); s.on("error", rej);
94
+ s.listen(0, "127.0.0.1", () => { const { port } = s.address(); s.close(() => res(port)); });
95
+ });
96
+ }
97
+
98
+ /** INSTALL.md step 0, run for the user: boot the service with no keys, hit its probes, stop it.
99
+ * The child gets PATH and HOME only — never the wizard's environment — so no key it holds can
100
+ * reach the service, and a block slug in the shell cannot switch booking out of its local mode. */
101
+ export async function proveLocally(dest, agent, { timeoutMs = 15000, fetchImpl = fetch } = {}) {
102
+ const port = await freePort();
103
+ const { args, env } = agent.boot(port);
104
+ const child = spawn(process.execPath, args, { cwd: dest, stdio: ["ignore", "ignore", "pipe"],
105
+ env: { PATH: process.env.PATH || "", HOME: process.env.HOME || "", ...env } });
106
+ let stderr = ""; child.stderr.on("data", d => { stderr = (stderr + d).slice(-2000); });
107
+ const exited = new Promise(r => child.on("exit", code => r(code)));
108
+ const base = `http://127.0.0.1:${port}`;
109
+ try {
110
+ const deadline = Date.now() + timeoutMs;
111
+ let up = false;
112
+ while (Date.now() < deadline && !up) {
113
+ if (child.exitCode !== null) break;
114
+ try { up = (await fetchImpl(`${base}${agent.probe[0]}`, { signal: AbortSignal.timeout(1000) })).ok; } catch {}
115
+ if (!up) await new Promise(r => setTimeout(r, 300));
116
+ }
117
+ if (!up) return { ok: false, reason: child.exitCode !== null ? `exited (${child.exitCode}): ${stderr.trim().split("\n").slice(-1)[0] || "no output"}` : "did not answer in time" };
118
+ for (const p of agent.probe.slice(1)) {
119
+ const r = await fetchImpl(`${base}${p}`, { signal: AbortSignal.timeout(3000) }).catch(() => null);
120
+ if (!r?.ok) return { ok: false, reason: `${p} answered ${r ? r.status : "nothing"}` };
121
+ }
122
+ return { ok: true, probes: agent.probe };
123
+ } finally {
124
+ if (child.exitCode === null) { child.kill(); await Promise.race([exited, new Promise(r => setTimeout(r, 2000))]); }
125
+ }
126
+ }
127
+
128
+ /** Render's one-click deploy for a repo that carries a render.yaml. */
129
+ export const renderDeployUrl = (slug) => (slug ? `https://render.com/deploy?repo=https://github.com/${slug}` : null);
130
+
131
+ /** What goes in the clone's .env. Never the personal NOAN key (see the header). The model key goes
132
+ * under the name the service reads for the chosen endpoint (the pack's rule: ANTHROPIC_API_KEY for
133
+ * the default vendor, LLM_API_KEY plus ANTHROPIC_BASE_URL for anything else). */
134
+ export function webEnv(agent, { slugs = {}, company = "", agentName = "", modelKey = null, modelKeyName = "ANTHROPIC_API_KEY", modelBase = "" } = {}) {
135
+ return {
136
+ ...slugs,
137
+ ...(company && { COMPANY_NAME: company }),
138
+ ...(agentName && { AGENT_NAME: agentName }),
139
+ ...(agent === WEB_AGENTS.chat && { SESSION_SECRET: randomSecret() }),
140
+ ...(agent.needsModelKey && modelKey && { [modelKeyName]: modelKey }),
141
+ ...(agent.needsModelKey && modelKey && modelBase && { ANTHROPIC_BASE_URL: modelBase }),
142
+ };
143
+ }
144
+
145
+ /** Write the clone's .env: gitignored FIRST (the exported repos ship no .gitignore, and this file
146
+ * can hold a model key), and a SESSION_SECRET that is already there is kept — rotating it would
147
+ * sign out every visitor of a chat already deployed from it. */
148
+ export function writeWebEnv(dest, pairs, { dryRun = false } = {}) {
149
+ const gitignore = ensureGitignored(dest, { dryRun });
150
+ const envPath = path.join(dest, ".env");
151
+ const existing = existsSync(envPath) ? readFileSync(envPath, "utf8") : "";
152
+ const keep = { ...pairs };
153
+ if (/^\s*(?:export\s+)?SESSION_SECRET\s*=\s*\S/m.test(existing)) delete keep.SESSION_SECRET;
154
+ const env = writeEnv(dest, keep, { dryRun });
155
+ return { ...env, gitignore: gitignore.action, keys: Object.keys(keep) };
156
+ }
157
+
158
+ /** The hand-off line for the report's `next` list: what is left, and where to read how. */
159
+ export function webNext(agent, { dest, deployUrl, missingModelKey = null }) {
160
+ return {
161
+ id: `web-${agent.dirName}`,
162
+ say: `Finish ${agent.label} (${agent.what}): in ${dest}, follow INSTALL.md ${agent.remaining}.`
163
+ + (missingModelKey ? ` It needs ${missingModelKey} too — the wizard had none to write.` : "")
164
+ + (deployUrl ? ` One-click deploy: ${deployUrl}.` : "")
165
+ + ` Create a NOAN API key for this service alone and set it as NOAN_AGENT_API_KEY in the deploy env — not your personal key.`
166
+ + ` ${agent.after}`,
167
+ why: "the rest needs accounts only you can create; INSTALL.md marks those steps HUMAN",
168
+ };
169
+ }