@getnoan/wizard 0.1.7 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -46,7 +46,11 @@ Run it again any time; every write is a merge, and nothing you have is overwritt
46
46
  to your account, verifies and stores your model and Resend keys (and optionally a Postgres URL
47
47
  and a Firecrawl key) as repository secrets, runs the pack's seed scripts so each agent's starting
48
48
  instructions are in your workspace, records the name and the block slugs as repository variables,
49
- and triggers one dry run. Safe mode stays on: the wizard never sets `DRY_RUN` to 0. That switch is yours.
49
+ and triggers one dry run. Safe mode stays on: the wizard never sets `DRY_RUN` to 0, and a re-run keeps the fork's `DRY_RUN`, and its `STATE_BACKEND` unless you give a Postgres URL. That switch is yours.
50
+
51
+ The newsletter signs unsubscribe links with `NEWSLETTER_UNSUB_SECRET`, and your site has to check
52
+ them with the same value. The wizard creates it once, stores it as a repository secret and in
53
+ `noan-agent-pack/.env` so you can copy it to your site, and keeps it on every re-run.
50
54
 
51
55
  The model does not have to be Anthropic's. Set `ANTHROPIC_BASE_URL` in the environment before
52
56
  running — `https://openrouter.ai/api`, say, or your own gateway — and the wizard verifies your key
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getnoan/wizard",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
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
@@ -9,7 +9,7 @@ import { spawnSync } from "node:child_process";
9
9
  import { existsSync, readFileSync } from "node:fs";
10
10
  import path from "node:path";
11
11
  import { randomBytes } from "node:crypto";
12
- import { writeEnv } from "./env-file.mjs";
12
+ import { writeEnv, readEnvValue } from "./env-file.mjs";
13
13
 
14
14
  export const PACK_REPO = "getnoan/agent-pack";
15
15
 
@@ -130,6 +130,23 @@ export function existingVariables(dest, dryRun) {
130
130
  const r = spawnSync("gh", ["variable", "list", "--json", "name", "-q", ".[].name"], { cwd: dest, encoding: "utf8" });
131
131
  return new Set(r.status === 0 ? r.stdout.split("\n").map(s => s.trim()).filter(Boolean) : []);
132
132
  }
133
+ /** The variables a re-run must not undo. DRY_RUN=1 only on a fork that has none, since it's the
134
+ * user's switch for taking agents live. STATE_BACKEND follows a Postgres URL given on this run,
135
+ * else keeps the fork's, so leaving the prompt blank doesn't drop the agents' state ledger. */
136
+ export function forkStateVars(have, { db = "" } = {}) {
137
+ const set = {}, kept = [];
138
+ if (have.has("DRY_RUN")) kept.push("DRY_RUN"); else set.DRY_RUN = "1";
139
+ if (db) set.STATE_BACKEND = "postgres";
140
+ else if (have.has("STATE_BACKEND")) kept.push("STATE_BACKEND");
141
+ else set.STATE_BACKEND = "local";
142
+ return { set, kept };
143
+ }
144
+ /** The repository secrets already set on the fork, by name. Same rules as existingVariables. */
145
+ export function existingSecrets(dest, dryRun) {
146
+ if (dryRun) return new Set();
147
+ const r = spawnSync("gh", ["secret", "list", "--json", "name", "-q", ".[].name"], { cwd: dest, encoding: "utf8" });
148
+ return new Set(r.status === 0 ? r.stdout.split("\n").map(s => s.trim()).filter(Boolean) : []);
149
+ }
133
150
  export function setVariable(dest, name, value, dryRun) {
134
151
  if (dryRun) return { name, action: "would set" };
135
152
  const r = spawnSync("gh", ["variable", "set", name, "--body", value], { cwd: dest, encoding: "utf8" });
@@ -196,5 +213,20 @@ export function dispatchDryRun(dest, workflow, dryRun) {
196
213
 
197
214
  export const randomSecret = () => randomBytes(24).toString("hex");
198
215
 
216
+ export const UNSUB_SECRET = "NEWSLETTER_UNSUB_SECRET";
217
+
218
+ /** The newsletter signs every unsubscribe link with this secret and the user's own site checks it
219
+ * with the same value. A GitHub secret can't be read back, so the clone's .env holds the copy the
220
+ * user reads. An existing value is kept: a new one breaks every unsubscribe link already sent. */
221
+ export function unsubSecret(dest, forkSecrets, { dryRun = false } = {}) {
222
+ const onFork = forkSecrets.has(UNSUB_SECRET);
223
+ const local = readEnvValue(dest, UNSUB_SECRET);
224
+ if (local) return { value: local, setOnFork: !onFork, action: onFork ? "kept" : "set from .env" };
225
+ if (onFork) return { value: null, setOnFork: false, action: "kept, not in .env" };
226
+ const value = randomSecret();
227
+ writeEnv(dest, { [UNSUB_SECRET]: value }, { dryRun });
228
+ return { value, setOnFork: true, action: "created" };
229
+ }
230
+
199
231
  /** Write the pack's .env in the clone so the seeds (and a local run) have what they need. */
200
232
  export function writePackEnv(dest, pairs, dryRun) { return writeEnv(dest, pairs, { dryRun }); }
package/src/env-file.mjs CHANGED
@@ -16,6 +16,14 @@ export function upsertEnv(text, pairs) {
16
16
  return out.join("\n") + "\n";
17
17
  }
18
18
 
19
+ /** One key's value from dir/.env, "" when the file or the key is missing. */
20
+ export function readEnvValue(dir, key) {
21
+ const p = path.join(dir, ".env");
22
+ if (!existsSync(p)) return "";
23
+ const m = readFileSync(p, "utf8").match(new RegExp(`^\\s*(?:export\\s+)?${key}\\s*=\\s*(\\S+)`, "m"));
24
+ return m ? m[1].replace(/^["']|["']$/g, "") : "";
25
+ }
26
+
19
27
  export function writeEnv(dir, pairs, { dryRun = false } = {}) {
20
28
  const p = path.join(dir, ".env");
21
29
  const before = existsSync(p) ? readFileSync(p, "utf8") : "";
package/src/run.mjs CHANGED
@@ -204,12 +204,21 @@ async function setupAgents({ args, ui, dir, key, me, report, shared = {} }) {
204
204
  const runKey = roles.runAs === "agent" ? agentKey : key;
205
205
 
206
206
  const secrets = { NOAN_PERSONAL_API_KEY: runKey, ...(anthropic && { [mName]: anthropic }), ...(resend && { RESEND_API_KEY: resend }),
207
- ...(db && { DATABASE_URL: db }), ...(firecrawl && { FIRECRAWL_API_KEY: firecrawl }), NEWSLETTER_UNSUB_SECRET: pack.randomSecret() };
207
+ ...(db && { DATABASE_URL: db }), ...(firecrawl && { FIRECRAWL_API_KEY: firecrawl }) };
208
+ const unsub = pack.unsubSecret(fork.dest, pack.existingSecrets(fork.dest, args.dryRun), { dryRun: args.dryRun });
209
+ if (unsub.setOnFork) secrets[pack.UNSUB_SECRET] = unsub.value;
208
210
  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}` : ""}`); }
211
+ if (!unsub.setOnFork) { out.secrets.push({ name: pack.UNSUB_SECRET, action: unsub.action }); ui.ok(`secret ${pack.UNSUB_SECRET}: ${unsub.action}`); }
212
+ if (unsub.action === "kept, not in .env") {
213
+ ui.warn(`${pack.UNSUB_SECRET} is on the fork but not in ${fork.dest}/.env, so your site can't check newsletter unsubscribe links with it`);
214
+ report.next.push({ id: "unsub-secret", say: `Before sending a newsletter, pick a new ${pack.UNSUB_SECRET} and set it in ${fork.dest}/.env, as the fork's secret, and on your site.`, why: "the site checks unsubscribe links with the same value the agents sign them with" });
215
+ }
209
216
  // The endpoint is a repository VARIABLE, not a secret: without it the fork holds a gateway key
210
217
  // and still calls the default vendor, which is the same "documented is not delivered" trap the
211
218
  // pack's own workflows had.
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 }),
219
+ const have = pack.existingVariables(fork.dest, args.dryRun);
220
+ const forkState = pack.forkStateVars(have, { db });
221
+ const vars = { ...forkState.set, ...(mBase && { ANTHROPIC_BASE_URL: mBase }), ...(mailFrom && { MAIL_FROM: mailFrom }), ...(replyTo && { REPLY_TO: replyTo }), ...(escalateTo && { ESCALATE_TO: escalateTo }),
213
222
  ...identity,
214
223
  COMPANY_NAME: me.project?.name || "", ...(roles.agentIds && { AGENT_IDENTITY_IDS: roles.agentIds }), ...(roles.commanders && { COMMANDERS: roles.commanders }) };
215
224
  // The seeds need the key in the clone's .env; the pack's own .env.example documents the rest.
@@ -231,7 +240,6 @@ async function setupAgents({ args, ui, dir, key, me, report, shared = {} }) {
231
240
  }
232
241
  // Who gets work handed back (agent role only, see agentRoles). An env value wins; one already
233
242
  // 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
243
  const handBackKept = [], handBackToYou = [];
236
244
  for (const [n, v] of Object.entries(roles.handBack)) {
237
245
  const given = (process.env[n] || "").trim();
@@ -243,7 +251,7 @@ async function setupAgents({ args, ui, dir, key, me, report, shared = {} }) {
243
251
  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
252
  if (handBackToYou.length) ui.ok(`work the agents hand back goes to ${me.identity?.email || "you"} (fact review, parked tasks, support follow-ups)`);
245
253
  if (handBackKept.length) ui.ok(`kept the fork's own ${handBackKept.join(", ")}`);
246
- ui.ok(`${out.variables.length} repository variable(s) ${args.dryRun ? "to set" : "set"} (DRY_RUN=1: every agent stays in safe mode)`);
254
+ ui.ok(`${out.variables.length} repository variable(s) ${args.dryRun ? "to set" : "set"}${forkState.set.DRY_RUN ? " (DRY_RUN=1: every agent stays in safe mode)" : ""}${forkState.kept.length ? `; kept the fork's own ${forkState.kept.join(", ")}` : ""}`);
247
255
  // No point dispatching a run that will only fail for a missing key: say what is missing instead.
248
256
  if (!anthropic || !resend) {
249
257
  const missing = [!anthropic && mName, !resend && "RESEND_API_KEY"].filter(Boolean);