@getnoan/wizard 0.1.5 → 0.1.7

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,13 +8,14 @@ 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
15
16
 
16
- 1. **Your key.** Create one at app.getnoan.com → Settings → API → New key and paste it in (it is
17
- not echoed). The wizard checks it with `GET /me`, writes it to `.env` as `NOAN_API_KEY` and
17
+ 1. **Your key.** Create your own at app.getnoan.com → Settings → API → New key and paste it in (it is
18
+ not echoed). This is *your* key, for you and your coding assistant. The wizard checks it with `GET /me`, writes it to `.env` as `NOAN_API_KEY` and
18
19
  `NOAN_PERSONAL_API_KEY`, and makes sure `.env` is gitignored. Already have `NOAN_API_KEY` in
19
20
  your environment? It uses that and never asks.
20
21
  2. **Your coding assistants.** Whatever is installed gets the MCP address `https://mcp.getnoan.com/mcp`
@@ -30,8 +31,18 @@ Run it again any time; every write is a merge, and nothing you have is overwritt
30
31
  4. **Your fact layer.** If the workspace holds fewer than ten facts, the wizard does not guess
31
32
  facts for you. It hands the seeding to your assistant, which has the procedure from the skill:
32
33
  read your website, repo and docs, propose a structure, and write only after you say yes.
33
- 5. **The agent pack** (optional, `--agents`). Asks what to call your agent — Verity by default, the
34
- name she answers to in NOAN — then forks [getnoan/agent-pack](https://github.com/getnoan/agent-pack)
34
+ 5. **The agent pack** (optional, `--agents`). The agents should run as **NOAN's agent identity**,
35
+ not as you: an Owner creates it once under Settings → Team → Agent, and mints its keys under
36
+ Settings → API → Agent API Keys. The wizard asks for that agent key (or takes `--agent-key` /
37
+ `NOAN_AGENT_API_KEY`) and checks it is the agent's. The agents then run on it, answer to the
38
+ agent's id (`AGENT_IDENTITY_IDS`), and take direction from you (`COMMANDERS`). Work they hand
39
+ back to a person (the weekly fact review, parked tasks, support follow-ups) goes to you: the
40
+ `PARK_ASSIGNEES_*`, `FACT_ALIGNMENT_REVIEW_ASSIGNEES`, `REPLY_HUMAN_ASSIGNEES` and
41
+ `HUMAN_IDENTITIES` variables, which a re-run keeps if you have since changed them. Your own key stays
42
+ for seeding and your assistant. Skip it and the agents run as you; the wizard warns that your own
43
+ task comments will then read as the agent's, so steer them by email.
44
+
45
+ It asks what to call your agent — Verity by default — then forks [getnoan/agent-pack](https://github.com/getnoan/agent-pack)
35
46
  to your account, verifies and stores your model and Resend keys (and optionally a Postgres URL
36
47
  and a Firecrawl key) as repository secrets, runs the pack's seed scripts so each agent's starting
37
48
  instructions are in your workspace, records the name and the block slugs as repository variables,
@@ -41,7 +52,21 @@ Run it again any time; every write is a merge, and nothing you have is overwritt
41
52
  running — `https://openrouter.ai/api`, say, or your own gateway — and the wizard verifies your key
42
53
  against *that* endpoint, stores it as `LLM_API_KEY`, and records the endpoint as a repository
43
54
  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.
55
+ 6. **The web agents** (optional, `--meetings`, `--chat`). Two agents run as always-on services on
56
+ your own domain rather than as scheduled jobs, so they live in their own repos:
57
+ [Verity Meetings](https://github.com/getnoan/verity-meetings) (booking pages) and
58
+ [Verity Chat](https://github.com/getnoan/verity-chat) (a chat widget for your website). For each
59
+ one you pick, the wizard forks the repo to your account, runs its seed scripts so its starting
60
+ instructions are in your workspace (in a stack of their own, apart from any other agent's), boots
61
+ it locally with no keys to prove it works, and writes a gitignored `.env` in the clone with the
62
+ block slugs. Deploying needs accounts only you can create — a host, a Supabase project, and for
63
+ Meetings, Google Calendar credentials — so the report hands your assistant the rest of the repo's
64
+ `INSTALL.md` and a one-click Render deploy link.
65
+
66
+ Your personal NOAN key is **not** written into either service's `.env`: these answer the open
67
+ internet, so each gets a NOAN key made for it alone, minted under your NOAN agent
68
+ (Settings → API → Agent API Keys). The report says so.
69
+ 7. **The report.** What happened, and the one thing to do next.
45
70
 
46
71
  ## For a coding assistant
47
72
 
@@ -55,7 +80,9 @@ NOAN_API_KEY=npak_… npx -y @getnoan/wizard@latest --yes --json
55
80
  (progress goes to stderr) with a `next` list an agent can act on, including the first-connect
56
81
  hand-off when the workspace is empty. `--agents` adds the pack step; it reads `ANTHROPIC_API_KEY`,
57
82
  `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.
83
+ from the environment and skips what is missing, saying so. `--meetings` and `--chat` add the web
84
+ agents; the chat reads its model key from the same variables as the pack (or reuses the pack
85
+ step's in the same run), and a `next` item per service carries the deploy hand-off.
59
86
 
60
87
  Exit codes: 0 done, 2 the key did not work, 3 cancelled, 1 anything else.
61
88
 
@@ -66,9 +93,12 @@ Exit codes: 0 done, 2 the key did not work, 3 cancelled, 1 anything else.
66
93
  | `-y`, `--yes` | non-interactive |
67
94
  | `--json` | machine-readable report (implies `--yes`) |
68
95
  | `--api-key <key>` | the NOAN key (else `NOAN_API_KEY`, else a prompt) |
96
+ | `--agent-key <key>` | an agent-owned NOAN key the agent pack runs on (else `NOAN_AGENT_API_KEY`, else a prompt) |
69
97
  | `--global` | user-scope client config instead of project scope |
70
98
  | `--dir <path>` | the project directory (default: current) |
71
99
  | `--agents` / `--no-agents` | include or skip the agent pack step |
100
+ | `--meetings` / `--no-meetings` | include or skip Verity Meetings (booking pages) |
101
+ | `--chat` / `--no-chat` | include or skip Verity Chat (a chat widget for your site) |
72
102
  | `--no-mcp`, `--no-skill` | skip a step |
73
103
  | `--dry-run` | show every write without making it |
74
104
  | `--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.7",
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/agents.mjs CHANGED
@@ -35,7 +35,8 @@ export function parseSeedOutput(text) {
35
35
  * name for the model key and one of the two is required. It is listed so a caller enumerating
36
36
  * the pack's secrets knows the name exists, not so anyone sets both. */
37
37
  export const PACK_SECRETS = ["NOAN_PERSONAL_API_KEY", "RESEND_API_KEY", "ANTHROPIC_API_KEY", "LLM_API_KEY", "DATABASE_URL", "FIRECRAWL_API_KEY", "NEWSLETTER_UNSUB_SECRET"];
38
- export const PACK_VARS = ["MAIL_FROM", "REPLY_TO", "ESCALATE_TO", "STATE_BACKEND", "ANTHROPIC_BASE_URL", "AGENT_NAME", "COMPANY_NAME", "AGENT_IDENTITY_IDS", "COMMANDERS", "REPORT_RECIPIENT_TAG"];
38
+ export const PACK_VARS = ["MAIL_FROM", "REPLY_TO", "ESCALATE_TO", "STATE_BACKEND", "ANTHROPIC_BASE_URL", "AGENT_NAME", "COMPANY_NAME", "AGENT_IDENTITY_IDS", "COMMANDERS", "REPORT_RECIPIENT_TAG",
39
+ "FACT_ALIGNMENT_REVIEW_ASSIGNEES", "PARK_ASSIGNEES_CS", "PARK_ASSIGNEES_SALES", "PARK_ASSIGNEES_ENG", "REPLY_HUMAN_ASSIGNEES", "HUMAN_IDENTITIES"];
39
40
 
40
41
  /** The pack's model endpoint, from the same variable the pack itself reads. Bare base, no
41
42
  * trailing slash; empty string means the default vendor.
@@ -122,6 +123,13 @@ export function setSecret(dest, name, value, dryRun) {
122
123
  const r = spawnSync("gh", ["secret", "set", name], { cwd: dest, input: value, encoding: "utf8" });
123
124
  return { name, action: r.status === 0 ? "set" : "failed", error: r.status === 0 ? undefined : (r.stderr || "").trim().split("\n")[0] };
124
125
  }
126
+ /** The repository variables already set on the fork, by name. A list that cannot be read is
127
+ * empty: the defaults are then written, and the gh failure shows on the set that follows. */
128
+ export function existingVariables(dest, dryRun) {
129
+ if (dryRun) return new Set();
130
+ const r = spawnSync("gh", ["variable", "list", "--json", "name", "-q", ".[].name"], { cwd: dest, encoding: "utf8" });
131
+ return new Set(r.status === 0 ? r.stdout.split("\n").map(s => s.trim()).filter(Boolean) : []);
132
+ }
125
133
  export function setVariable(dest, name, value, dryRun) {
126
134
  if (dryRun) return { name, action: "would set" };
127
135
  const r = spawnSync("gh", ["variable", "set", name, "--body", value], { cwd: dest, encoding: "utf8" });
package/src/args.mjs CHANGED
@@ -11,16 +11,22 @@ 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)
18
21
  --json machine-readable report on stdout (implies --yes)
19
22
  --api-key <key> the NOAN key (else NOAN_API_KEY / NOAN_PERSONAL_API_KEY, else a prompt)
23
+ --agent-key <key> an agent-owned NOAN key the agent pack runs on (else NOAN_AGENT_API_KEY, else a prompt)
20
24
  --global write user-scope client config (~/.claude.json, ~/.cursor/mcp.json, ...)
21
25
  instead of project scope (.mcp.json, .cursor/mcp.json, .vscode/mcp.json)
22
26
  --dir <path> the project directory (default: the current directory)
23
27
  --agents also set up the agent pack (default: ask; with --yes: skip unless given)
28
+ --meetings also set up Verity Meetings, booking pages on your own domain (same default)
29
+ --chat also set up Verity Chat, a chat widget for your website (same default)
24
30
  --no-mcp skip the client wiring
25
31
  --no-skill skip the skill install
26
32
  --dry-run show every write without making it
@@ -32,7 +38,7 @@ Exit codes: 0 done · 2 the key did not work · 3 cancelled · 1 anything else
32
38
  `;
33
39
 
34
40
  export function parseArgs(argv) {
35
- const a = { yes: false, json: false, apiKey: null, global: false, dir: process.cwd(), agents: null,
41
+ const a = { yes: false, json: false, apiKey: null, agentKey: null, global: false, dir: process.cwd(), agents: null, meetings: null, chat: null,
36
42
  mcp: true, skill: true, dryRun: false, telemetry: true, help: false, version: false, unknown: [] };
37
43
  for (let i = 0; i < argv.length; i++) {
38
44
  const t = argv[i];
@@ -41,11 +47,17 @@ export function parseArgs(argv) {
41
47
  else if (t === "--json") { a.json = true; a.yes = true; }
42
48
  else if (t === "--api-key") a.apiKey = next();
43
49
  else if (t.startsWith("--api-key=")) a.apiKey = t.slice(10);
50
+ else if (t === "--agent-key") a.agentKey = next();
51
+ else if (t.startsWith("--agent-key=")) a.agentKey = t.slice(12);
44
52
  else if (t === "--global") a.global = true;
45
53
  else if (t === "--dir") a.dir = next();
46
54
  else if (t.startsWith("--dir=")) a.dir = t.slice(6);
47
55
  else if (t === "--agents") a.agents = true;
48
56
  else if (t === "--no-agents") a.agents = false;
57
+ else if (t === "--meetings") a.meetings = true;
58
+ else if (t === "--no-meetings") a.meetings = false;
59
+ else if (t === "--chat") a.chat = true;
60
+ else if (t === "--no-chat") a.chat = false;
49
61
  else if (t === "--no-mcp") a.mcp = false;
50
62
  else if (t === "--no-skill") a.skill = false;
51
63
  else if (t === "--dry-run") a.dryRun = true;
package/src/clients.mjs CHANGED
@@ -29,7 +29,10 @@ export function mergeJsonServer(existing, container, entry) {
29
29
  export function mergeToml(text, table, lines) {
30
30
  const block = `[${table}]\n${lines.join("\n")}\n`;
31
31
  const src = text || "";
32
- const rx = new RegExp(`^\\[${table.replace(/\./g, "\\.")}\\]\\n(?:(?!\\[)[^\\n]*\\n?)*`, "m");
32
+ // Escape every regex metacharacter, not only the dot: the table name is ours today, but a
33
+ // partial escape is exactly what CodeQL js/incomplete-sanitization flags, and it is one line.
34
+ const esc = table.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
35
+ const rx = new RegExp(`^\\[${esc}\\]\\n(?:(?!\\[)[^\\n]*\\n?)*`, "m");
33
36
  if (rx.test(src)) {
34
37
  const cur = src.match(rx)[0];
35
38
  if (cur.trim() === block.trim()) return { text: src, action: "unchanged" };
package/src/noan.mjs CHANGED
@@ -3,6 +3,8 @@ export const API = "https://api.getnoan.com/v1";
3
3
  export const MCP_URL = "https://mcp.getnoan.com/mcp";
4
4
  export const APP_URL = "https://app.getnoan.com";
5
5
  export const KEY_PAGE_HINT = "app.getnoan.com → Settings → API → New key";
6
+ /** Where an AGENT-owned key is minted: NOAN's agent identity (role bot), created by an Owner. */
7
+ export const AGENT_KEY_PAGE_HINT = "app.getnoan.com → Settings → API → Agent API Keys";
6
8
 
7
9
  export function looksLikeKey(k) { return typeof k === "string" && /^npak_[A-Za-z0-9_-]{16,}$/.test(k.trim()); }
8
10
 
@@ -20,6 +22,72 @@ export async function whoAmI(key) {
20
22
  return { ok: true, project: body.project, identity: body.identity };
21
23
  }
22
24
 
25
+ /** The pack's variables naming who gets work an agent hands back: identity ids, comma-separated.
26
+ * HUMAN_IDENTITIES (email=id pairs) is the sixth, set alongside these. */
27
+ export const HAND_BACK_ASSIGNEE_VARS = ["FACT_ALIGNMENT_REVIEW_ASSIGNEES", "PARK_ASSIGNEES_CS", "PARK_ASSIGNEES_SALES", "PARK_ASSIGNEES_ENG", "REPLY_HUMAN_ASSIGNEES"];
28
+
29
+ /** Is this /me identity NOAN's agent? Its role is "bot" (and the API gives it no email). */
30
+ export function isAgentIdentity(identity) {
31
+ return identity?.role === "bot";
32
+ }
33
+
34
+ /**
35
+ * Who the agents run as, and who steers them, from GET /me on the keys the user gave.
36
+ *
37
+ * NOAN has an agent role: one agent identity per workspace (role bot, no login), which an Owner
38
+ * creates under Settings → Team → Agent; its keys are minted under Settings → API → Agent API Keys. Its id is what the agents answer to
39
+ * (AGENT_IDENTITY_IDS: tasks assigned to it trigger them, comments by it are the agent's own).
40
+ * The people who steer and approve (COMMANDERS) are humans, never the agent.
41
+ *
42
+ * Deriving both from one key's /me was wrong either way: a person's key made the person "the
43
+ * agent", so every comment they wrote was classed as the agent's own and ignored; an agent key
44
+ * made the agent the only commander, and the agent has no email to be one.
45
+ *
46
+ * person GET /me of the user's own key (step 1), when it is a person
47
+ * agent GET /me of an agent-owned key, when one was given (or step 1's, if that was the agent)
48
+ * commanders extra addresses (COMMANDERS env or a prompt), comma-separated
49
+ *
50
+ * Returns the ids/addresses to configure, which key the agents should run on ("agent" or
51
+ * "person"), who gets the work the agents hand back (handBack), and the warnings to say out loud.
52
+ *
53
+ * handBack matters only under the agent role. On a person's key the pack falls back to that
54
+ * person (fact alignment's review) or to nobody (parks never read GET /me); on the agent's key
55
+ * every fallback is deliberately refused, because handing work to the key's owner would hand it
56
+ * to the agent. So with no hand-back settings an agent-key install files its weekly review, its
57
+ * parked tasks and its support follow-ups with no person on them, and nothing errors. The person
58
+ * who ran the wizard is the default owner of all of it. On a person's key handBack stays empty:
59
+ * that person IS the agent there (AGENT_IDENTITY_IDS), so assigning them would hand the work
60
+ * straight back to the agents.
61
+ */
62
+ export function agentRoles({ person = null, agent = null, commanders = "" } = {}) {
63
+ const warnings = [];
64
+ const agentId = agent && isAgentIdentity(agent) ? agent.id : null;
65
+ const humans = new Set(String(commanders || "").split(",").map(s => s.trim().toLowerCase()).filter(Boolean));
66
+ if (person && !isAgentIdentity(person) && person.email) humans.add(String(person.email).toLowerCase());
67
+ const out = { agentIds: agentId, commanders: [...humans].join(","), runAs: agentId ? "agent" : "person", warnings };
68
+ if (!agentId) {
69
+ // No agent identity: keep the old single-account setup, but say what it costs.
70
+ out.agentIds = person?.id || null;
71
+ warnings.push(
72
+ "No agent key, so the agents run as you and answer to your own account. Every comment you write on a task " +
73
+ "will read as the agent's own, so steer and approve by email, not task comments. To fix it, create the agent " +
74
+ `(${AGENT_KEY_PAGE_HINT}) and re-run with --agent-key.`);
75
+ }
76
+ if (!out.commanders) warnings.push("No commander: nobody can steer or approve the agents until COMMANDERS lists a person's email.");
77
+ out.handBack = {};
78
+ if (out.runAs === "agent") {
79
+ const human = person && !isAgentIdentity(person) && person.id ? person : null;
80
+ if (human) {
81
+ for (const n of HAND_BACK_ASSIGNEE_VARS) out.handBack[n] = human.id;
82
+ if (human.email) out.handBack.HUMAN_IDENTITIES = `${String(human.email).toLowerCase()}=${human.id}`;
83
+ } else warnings.push(
84
+ "No person's key, so the work the agents hand back (the weekly fact review, parked tasks, support follow-ups) " +
85
+ `has nobody to go to. Set ${HAND_BACK_ASSIGNEE_VARS.join(", ")} on the fork to a person's NOAN identity id ` +
86
+ "(GET /me with their key), or re-run with your own key first and the agent key as --agent-key.");
87
+ }
88
+ return out;
89
+ }
90
+
23
91
  /** How many facts the workspace holds — the signal for "empty, hand the seeding over". */
24
92
  export async function factCount(key) {
25
93
  const { status, body } = await get("/facts?per_page=1", key);
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
@@ -2,11 +2,12 @@
2
2
  import { existsSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { makeUI } from "./ui.mjs";
5
- import { whoAmI, factCount, classifyWorkspace, looksLikeKey, APP_URL, KEY_PAGE_HINT, MCP_URL } from "./noan.mjs";
5
+ import { whoAmI, factCount, classifyWorkspace, looksLikeKey, APP_URL, KEY_PAGE_HINT, MCP_URL, isAgentIdentity, agentRoles, AGENT_KEY_PAGE_HINT } from "./noan.mjs";
6
6
  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
 
@@ -37,7 +38,10 @@ export async function run(args) {
37
38
  if (!looksLikeKey(key)) ui.warn("that does not look like a NOAN key (they start with npak_) — checking anyway");
38
39
  const me = await whoAmI(key);
39
40
  if (!me.ok) { ui.warn(me.reason); report.steps.auth = { ok: false, reason: me.reason }; return finish(2, "cancelled"); }
40
- ui.ok(`key accepted: ${me.identity?.email || "?"} in workspace "${me.project?.name || "?"}"`);
41
+ if (isAgentIdentity(me.identity)) {
42
+ ui.ok(`key accepted: your NOAN agent "${me.identity?.name || "?"}" (role bot) in workspace "${me.project?.name || "?"}"`);
43
+ ui.warn("this key belongs to the agent, not to you: your coding assistant will write as the agent too. For your own use, a personal key (" + KEY_PAGE_HINT + ") is the better fit.");
44
+ } else ui.ok(`key accepted: ${me.identity?.email || "?"} in workspace "${me.project?.name || "?"}"`);
41
45
  const env = writeEnv(dir, { NOAN_API_KEY: key, NOAN_PERSONAL_API_KEY: key }, { dryRun: args.dryRun });
42
46
  const gi = ensureGitignored(dir, { dryRun: args.dryRun });
43
47
  ui.ok(`${env.created ? "wrote" : env.changed ? "updated" : "kept"} ${env.path} (NOAN_API_KEY, NOAN_PERSONAL_API_KEY); .gitignore: ${gi.action}`);
@@ -86,17 +90,36 @@ export async function run(args) {
86
90
  ui.step(5, "The agent pack");
87
91
  let wantAgents = args.agents;
88
92
  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 });
93
+ // Carries the model key and the agent's name from the pack step to the web step, so a run that
94
+ // does both asks once. Never put in the report: the report is printed, and --json is parsed.
95
+ const shared = {};
96
+ if (wantAgents) report.steps.agents = await setupAgents({ args, ui, dir, key, me, report, shared });
90
97
  else { ui.info("skipped" + (args.yes && args.agents == null ? " (pass --agents to include it)" : "")); report.steps.agents = { skipped: true }; }
91
98
 
92
- /* 6. report */
99
+ /* 6. web agents */
100
+ ui.step(6, "The web agents");
101
+ const wantWeb = [];
102
+ for (const [id, agent] of Object.entries(web.WEB_AGENTS)) {
103
+ let want = args[id];
104
+ if (want == null) want = ui.interactive ? await ui.confirm(`Set up ${agent.label} — ${agent.what}?`, false) : false;
105
+ if (want) wantWeb.push(id);
106
+ }
107
+ if (!wantWeb.length) {
108
+ ui.info("skipped" + (args.yes && args.meetings == null && args.chat == null ? " (pass --meetings and/or --chat to include them)" : ""));
109
+ report.steps.web = { skipped: true };
110
+ } else {
111
+ report.steps.web = {};
112
+ for (const id of wantWeb) report.steps.web[id] = await setupWeb({ id, args, ui, dir, key, me, report, shared });
113
+ }
114
+
115
+ /* 7. report */
93
116
  report.next.push({ id: "open", say: `Open ${APP_URL} to see the workspace your assistant is grounded in.` });
94
117
  return finish(0);
95
118
  }
96
119
 
97
120
  async function ask(ui, q, o) { return ui.ask(q, o); }
98
121
 
99
- async function setupAgents({ args, ui, dir, key, me, report }) {
122
+ async function setupAgents({ args, ui, dir, key, me, report, shared = {} }) {
100
123
  const ready = pack.ghReady();
101
124
  if (!ready.ok) { ui.warn(ready.reason); return { ok: false, reason: ready.reason }; }
102
125
  const fork = pack.forkAndClone(dir, { dryRun: args.dryRun });
@@ -155,7 +178,32 @@ async function setupAgents({ args, ui, dir, key, me, report }) {
155
178
  ? await ui.ask(`Pronouns for ${askedName}, in ${askedName}'s own prose (blank for they/them)`)
156
179
  : "") });
157
180
 
158
- const secrets = { NOAN_PERSONAL_API_KEY: key, ...(anthropic && { [mName]: anthropic }), ...(resend && { RESEND_API_KEY: resend }),
181
+ /* Who the agents run as, and who steers them (NOAN's agent role). The agents should run on an
182
+ * AGENT-owned key, so what they write is the agent's and its id is what they answer to; the
183
+ * people who steer them are humans. Step 1's key is normally the user's own, which stays for
184
+ * seeding and the coding assistant. See agentRoles in noan.mjs. */
185
+ let agentKey = null, agentMe = null;
186
+ if (isAgentIdentity(me.identity)) { agentKey = key; agentMe = me.identity; }
187
+ else {
188
+ const given = (args.agentKey || process.env.NOAN_AGENT_API_KEY || "").trim()
189
+ || (ui.interactive ? await ui.ask(`Agent key, so the agents run as your NOAN agent rather than as you (${AGENT_KEY_PAGE_HINT}; blank to skip)`, { secret: true }) : "");
190
+ if (given) {
191
+ const am = await whoAmI(given);
192
+ if (!am.ok) ui.warn(`agent key not accepted (${am.reason}); continuing without one`);
193
+ else if (am.project?.id && me.project?.id && am.project.id !== me.project.id) ui.warn(`that agent key is for workspace "${am.project?.name}", not "${me.project?.name}"; continuing without one`);
194
+ else if (!isAgentIdentity(am.identity)) ui.warn(`that key belongs to ${am.identity?.email || "a person"}, not the agent; continuing without one`);
195
+ else { agentKey = given; agentMe = am.identity; ui.ok(`agent key accepted: "${am.identity?.name || "agent"}" (role bot)`); }
196
+ }
197
+ }
198
+ const commandersIn = (process.env.COMMANDERS || "").trim()
199
+ || (isAgentIdentity(me.identity) && ui.interactive ? await ui.ask("Email(s) of the people who steer and approve the agents, comma-separated") : "");
200
+ const roles = agentRoles({ person: isAgentIdentity(me.identity) ? null : me.identity, agent: agentMe, commanders: commandersIn });
201
+ for (const w of roles.warnings) ui.warn(w);
202
+ out.identity = { runAs: roles.runAs, agentIds: roles.agentIds, commanders: roles.commanders };
203
+ // The pack reads its NOAN key from this secret name; under the agent role it holds the agent's key.
204
+ const runKey = roles.runAs === "agent" ? agentKey : key;
205
+
206
+ const secrets = { NOAN_PERSONAL_API_KEY: runKey, ...(anthropic && { [mName]: anthropic }), ...(resend && { RESEND_API_KEY: resend }),
159
207
  ...(db && { DATABASE_URL: db }), ...(firecrawl && { FIRECRAWL_API_KEY: firecrawl }), NEWSLETTER_UNSUB_SECRET: pack.randomSecret() };
160
208
  for (const [n, v] of Object.entries(secrets)) { const r = pack.setSecret(fork.dest, n, v, args.dryRun); out.secrets.push(r); ui.ok(`secret ${n}: ${r.action}${r.error ? ` — ${r.error}` : ""}`); }
161
209
  // The endpoint is a repository VARIABLE, not a secret: without it the fork holds a gateway key
@@ -163,7 +211,7 @@ async function setupAgents({ args, ui, dir, key, me, report }) {
163
211
  // pack's own workflows had.
164
212
  const vars = { DRY_RUN: "1", STATE_BACKEND: db ? "postgres" : "local", ...(mBase && { ANTHROPIC_BASE_URL: mBase }), ...(mailFrom && { MAIL_FROM: mailFrom }), ...(replyTo && { REPLY_TO: replyTo }), ...(escalateTo && { ESCALATE_TO: escalateTo }),
165
213
  ...identity,
166
- COMPANY_NAME: me.project?.name || "", ...(me.identity?.id && { AGENT_IDENTITY_IDS: me.identity.id }), ...(me.identity?.email && { COMMANDERS: me.identity.email }) };
214
+ COMPANY_NAME: me.project?.name || "", ...(roles.agentIds && { AGENT_IDENTITY_IDS: roles.agentIds }), ...(roles.commanders && { COMMANDERS: roles.commanders }) };
167
215
  // The seeds need the key in the clone's .env; the pack's own .env.example documents the rest.
168
216
  pack.writePackEnv(fork.dest, { NOAN_PERSONAL_API_KEY: key, ...(anthropic && { [mName]: anthropic }), ...(mBase && { ANTHROPIC_BASE_URL: mBase }), ...(resend && { RESEND_API_KEY: resend }) }, args.dryRun);
169
217
  const seeds = pack.runSeeds(fork.dest, { NOAN_PERSONAL_API_KEY: key }, { dryRun: args.dryRun });
@@ -181,7 +229,20 @@ async function setupAgents({ args, ui, dir, key, me, report }) {
181
229
  report.next.push({ id: "grounding", say: `Fill the ${grounding.gaps.length} block(s) the agents read (tasks are on your NOAN board): ${grounding.gaps.map(g => g.title).join(", ")}.`, why: "an agent grounded in an empty block fails quietly" });
182
230
  } else ui.ok("every block the agents read holds a fact");
183
231
  }
232
+ // Who gets work handed back (agent role only, see agentRoles). An env value wins; one already
233
+ // set on the fork is kept, since it is a list a person may have extended with teammates.
234
+ const have = pack.existingVariables(fork.dest, args.dryRun);
235
+ const handBackKept = [], handBackToYou = [];
236
+ for (const [n, v] of Object.entries(roles.handBack)) {
237
+ const given = (process.env[n] || "").trim();
238
+ if (given) vars[n] = given;
239
+ else if (have.has(n)) handBackKept.push(n);
240
+ else { vars[n] = v; handBackToYou.push(n); }
241
+ }
242
+ out.handBack = { toYou: handBackToYou, kept: handBackKept };
184
243
  for (const [n, v] of Object.entries(vars)) { if (!v) continue; const r = pack.setVariable(fork.dest, n, v, args.dryRun); out.variables.push(r); }
244
+ if (handBackToYou.length) ui.ok(`work the agents hand back goes to ${me.identity?.email || "you"} (fact review, parked tasks, support follow-ups)`);
245
+ if (handBackKept.length) ui.ok(`kept the fork's own ${handBackKept.join(", ")}`);
185
246
  ui.ok(`${out.variables.length} repository variable(s) ${args.dryRun ? "to set" : "set"} (DRY_RUN=1: every agent stays in safe mode)`);
186
247
  // No point dispatching a run that will only fail for a missing key: say what is missing instead.
187
248
  if (!anthropic || !resend) {
@@ -196,5 +257,63 @@ async function setupAgents({ args, ui, dir, key, me, report }) {
196
257
  }
197
258
  out.repo = pack.repoSlug(fork.dest);
198
259
  out.agentName = identity.AGENT_NAME;
260
+ Object.assign(shared, { modelKey: anthropic, modelKeyName: mName, modelBase: mBase, agentName: identity.AGENT_NAME });
261
+ return out;
262
+ }
263
+
264
+ /** One web agent: fork, seed, prove it boots, write its .env, hand the deploy to the assistant.
265
+ * What it returns goes in the report, so it carries names and outcomes — never a key. */
266
+ async function setupWeb({ id, args, ui, dir, key, me, report, shared }) {
267
+ const agent = web.WEB_AGENTS[id];
268
+ ui.info(`${agent.label}:`);
269
+ const ready = pack.ghReady();
270
+ if (!ready.ok) { ui.warn(ready.reason); return { ok: false, reason: ready.reason }; }
271
+ const fork = web.forkAndCloneWeb(dir, agent, { dryRun: args.dryRun });
272
+ ui.ok(`${fork.action}: ${fork.dest}${fork.error ? ` — ${fork.error}` : ""}`);
273
+ if (fork.error) return { ok: false, reason: fork.error };
274
+ const out = { ok: true, dest: fork.dest, seeds: [], skipped: [] };
275
+
276
+ // Seeds run under the person's own key: setting up their workspace is theirs to do.
277
+ const seeds = web.runWebSeeds(fork.dest, agent, { NOAN_PERSONAL_API_KEY: key }, { dryRun: args.dryRun });
278
+ out.seeds = seeds.rows;
279
+ 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}` : ""}`);
280
+
281
+ // INSTALL.md step 0, for them: the service boots with no keys at all.
282
+ if (args.dryRun) out.local = { ok: null, reason: "dry run" };
283
+ else if (existsSync(fork.dest)) {
284
+ out.local = await web.proveLocally(fork.dest, agent);
285
+ if (out.local.ok) ui.ok(`boots locally with no keys (${out.local.probes.join(", ")} answered)`);
286
+ else ui.warn(`did not boot locally: ${out.local.reason} — INSTALL.md step 0 runs the same thing by hand`);
287
+ }
288
+
289
+ // The chat needs a model key; reuse the pack step's if this run had one, else env, else ask.
290
+ let modelKey = null, modelKeyName = "ANTHROPIC_API_KEY", modelBase = "";
291
+ if (agent.needsModelKey) {
292
+ if (shared.modelKey) ({ modelKey, modelKeyName, modelBase } = shared);
293
+ else {
294
+ try { modelBase = pack.modelBase(); } catch (e) { ui.warn(e.message); out.skipped.push({ name: "model key", why: e.message }); modelBase = null; }
295
+ if (modelBase !== null) {
296
+ modelKeyName = pack.modelKeyName();
297
+ modelKey = (process.env[modelKeyName] || process.env[modelKeyName === "LLM_API_KEY" ? "ANTHROPIC_API_KEY" : "LLM_API_KEY"] || "").trim()
298
+ || (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 }) : "");
299
+ if (modelKey) {
300
+ const verdict = await pack.verifyModelKey(modelKey, modelBase);
301
+ if (verdict === false) { ui.warn(`${modelKeyName} was not accepted by its service; not written`); out.skipped.push({ name: modelKeyName, why: "rejected" }); modelKey = null; }
302
+ } else out.skipped.push({ name: modelKeyName, why: "not provided" });
303
+ }
304
+ }
305
+ }
306
+
307
+ const agentName = shared.agentName || pack.agentIdentity({ name: process.env.AGENT_NAME }).AGENT_NAME;
308
+ const pairs = web.webEnv(agent, { slugs: seeds.slugs, company: me.project?.name || "", agentName, modelKey, modelKeyName, modelBase: modelBase || "" });
309
+ const env = web.writeWebEnv(fork.dest, pairs, { dryRun: args.dryRun });
310
+ ui.ok(`${args.dryRun ? "would write" : env.created ? "wrote" : env.changed ? "updated" : "kept"} ${env.path} (${env.keys.join(", ") || "nothing new"}); .gitignore: ${env.gitignore}`);
311
+ out.env = { path: env.path, keys: env.keys, gitignore: env.gitignore };
312
+ const missingModelKey = agent.needsModelKey && !modelKey ? modelKeyName : null;
313
+ if (missingModelKey) { ui.warn(`no ${missingModelKey} written: the chat cannot answer until it has one`); out.missing = [missingModelKey]; }
314
+
315
+ out.repo = args.dryRun ? null : pack.repoSlug(fork.dest);
316
+ out.deployUrl = web.renderDeployUrl(out.repo);
317
+ report.next.push(web.webNext(agent, { dest: fork.dest, deployUrl: out.deployUrl, missingModelKey }));
199
318
  return out;
200
319
  }
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
+ + ` Mint a NOAN key for this service alone under your NOAN agent (Settings → API → Agent API Keys) 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
+ }