@pebblehouse/odin-cli 0.12.0 → 0.14.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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/dist/index.js +979 -33
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -82,7 +82,7 @@ All commands output JSON to stdout. Errors go to stderr with non-zero exit codes
82
82
  |----------|---------|-------------|
83
83
  | `ODIN_API_URL` | `https://www.odin.mu` | API base URL |
84
84
  | `ODIN_SUPABASE_URL` | — | Supabase URL (for token refresh) |
85
- | `ODIN_SUPABASE_ANON_KEY` | — | Supabase anon key (for token refresh) |
85
+ | `ODIN_SUPABASE_PUBLISHABLE_KEY` | — | Supabase publishable key (for token refresh) |
86
86
 
87
87
  ## License
88
88
 
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { Command as Command20 } from "commander";
4
+ import { Command as Command23 } from "commander";
5
5
 
6
6
  // src/auth/login.ts
7
7
  import { createServer } from "http";
@@ -37,7 +37,7 @@ function clearCredentials() {
37
37
  // src/auth/login.ts
38
38
  var API_URL = process.env.ODIN_API_URL ?? "https://www.odin.mu";
39
39
  async function login() {
40
- return new Promise((resolve2, reject) => {
40
+ return new Promise((resolve3, reject) => {
41
41
  const server = createServer(async (req, res) => {
42
42
  if (req.method === "POST" && req.url?.startsWith("/callback")) {
43
43
  let body = "";
@@ -70,7 +70,7 @@ async function login() {
70
70
  </div>
71
71
  </body></html>`);
72
72
  server.close();
73
- resolve2();
73
+ resolve3();
74
74
  } else {
75
75
  res.writeHead(404);
76
76
  res.end();
@@ -105,12 +105,12 @@ import { createClient } from "@supabase/supabase-js";
105
105
  import { existsSync as existsSync2, writeFileSync as writeFileSync2, unlinkSync as unlinkSync2, readFileSync as readFileSync2, mkdirSync as mkdirSync2 } from "fs";
106
106
  import { dirname } from "path";
107
107
  var SUPABASE_URL = process.env.ODIN_SUPABASE_URL ?? "https://vdiwtiiksdyhlibqrngw.supabase.co";
108
- var SUPABASE_ANON_KEY = process.env.ODIN_SUPABASE_ANON_KEY ?? "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InZkaXd0aWlrc2R5aGxpYnFybmd3Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzM2ODM4NDksImV4cCI6MjA4OTI1OTg0OX0.gAccODqqhBnL_npqG7H42EvaT2CWR1P2pqo5NVjKFas";
108
+ var SUPABASE_PUBLISHABLE_KEY = process.env.ODIN_SUPABASE_PUBLISHABLE_KEY ?? "sb_publishable_qTgDHReuOlNcsxm_r7PIrg_K-EV3F7a";
109
109
  var REFRESH_THRESHOLD_SECONDS = 900;
110
110
  var LOCK_PATH = CREDENTIALS_PATH + ".lock";
111
111
  var LOCK_STALE_MS = 1e4;
112
112
  function createNodeClient() {
113
- return createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
113
+ return createClient(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, {
114
114
  auth: {
115
115
  autoRefreshToken: false,
116
116
  persistSession: false
@@ -974,9 +974,38 @@ betsCmd.command("delete <slug> <id>").description("Delete a bet").action(async (
974
974
  });
975
975
  betsCmd.addCommand(depsCmd);
976
976
 
977
- // src/commands/tasks.ts
977
+ // src/commands/bet-comments.ts
978
978
  import { Command as Command15 } from "commander";
979
- var tasksCmd = new Command15("tasks").description("Manage tasks");
979
+ import { readFileSync as readFileSync11 } from "fs";
980
+ var betCommentsCmd = new Command15("bet-comments").description("Manage bet comments");
981
+ betCommentsCmd.command("list <slug> <betId>").description("List comments for a bet (oldest first)").action(async (slug, betId) => {
982
+ const res = await apiRequest(`/pebbles/${slug}/bets/${betId}/comments`);
983
+ process.stdout.write(JSON.stringify(res) + "\n");
984
+ if (res.error) process.exit(1);
985
+ });
986
+ betCommentsCmd.command("create <slug> <betId> [body]").description("Post a comment on a bet (append-only)").option("--author-type <type>", "persona | human", "persona").option("--author-label <label>", "Verbatim author slug/handle").option("--parent <id>", "Reply to a top-level comment (one level only)").option("--kind <kind>", "metadata.kind: build-summary | escalation | note").option("--pipeline-stage <stage>", "metadata.pipeline_stage").option("--pr-url <url>", "metadata.pr_url").option("--file <path>", "Read body from file").action(async (slug, betId, body, opts) => {
987
+ const commentBody = opts.file ? readFileSync11(opts.file, "utf-8") : body ?? "";
988
+ const metadata = {};
989
+ if (opts.kind) metadata.kind = opts.kind;
990
+ if (opts.pipelineStage) metadata.pipeline_stage = opts.pipelineStage;
991
+ if (opts.prUrl) metadata.pr_url = opts.prUrl;
992
+ const res = await apiRequest(`/pebbles/${slug}/bets/${betId}/comments`, {
993
+ method: "POST",
994
+ body: {
995
+ author_type: opts.authorType,
996
+ author_label: opts.authorLabel,
997
+ body: commentBody,
998
+ parent_comment_id: opts.parent,
999
+ metadata: Object.keys(metadata).length ? metadata : void 0
1000
+ }
1001
+ });
1002
+ process.stdout.write(JSON.stringify(res) + "\n");
1003
+ if (res.error) process.exit(1);
1004
+ });
1005
+
1006
+ // src/commands/tasks.ts
1007
+ import { Command as Command16 } from "commander";
1008
+ var tasksCmd = new Command16("tasks").description("Manage tasks");
980
1009
  tasksCmd.command("list <slug>").description("List tasks for a pebble").option("--status <status>", "Filter by status").option("--bet <bet-id>", "Filter by bet ID").action(async (slug, opts) => {
981
1010
  const params = {};
982
1011
  if (opts.status) params.status = opts.status;
@@ -1021,9 +1050,9 @@ tasksCmd.command("delete <slug> <id>").description("Delete a task").action(async
1021
1050
  });
1022
1051
 
1023
1052
  // src/commands/roadmaps.ts
1024
- import { Command as Command16 } from "commander";
1025
- import { readFileSync as readFileSync11 } from "fs";
1026
- var roadmapsCmd = new Command16("roadmaps").description("Manage roadmaps");
1053
+ import { Command as Command17 } from "commander";
1054
+ import { readFileSync as readFileSync12 } from "fs";
1055
+ var roadmapsCmd = new Command17("roadmaps").description("Manage roadmaps");
1027
1056
  roadmapsCmd.command("list <slug>").description("List roadmaps for a pebble").option("--status <status>", "Filter by status").action(async (slug, opts) => {
1028
1057
  const params = {};
1029
1058
  if (opts.status) params.status = opts.status;
@@ -1037,7 +1066,7 @@ roadmapsCmd.command("get <slug> <id>").description("Get a roadmap by ID (include
1037
1066
  if (res.error) process.exit(1);
1038
1067
  });
1039
1068
  roadmapsCmd.command("create <slug> <title>").description("Create a roadmap").option("--source <source>", "Source agent", "claude-code").option("--content <content>", "Roadmap content").option("--file <path>", "Read content from file").option("--status <status>", "Roadmap status").action(async (slug, title, opts) => {
1040
- const content = opts.file ? readFileSync11(opts.file, "utf-8") : opts.content ?? "";
1069
+ const content = opts.file ? readFileSync12(opts.file, "utf-8") : opts.content ?? "";
1041
1070
  const res = await apiRequest(`/pebbles/${slug}/roadmaps`, {
1042
1071
  method: "POST",
1043
1072
  body: { title, source: opts.source, content, status: opts.status }
@@ -1048,7 +1077,7 @@ roadmapsCmd.command("create <slug> <title>").description("Create a roadmap").opt
1048
1077
  roadmapsCmd.command("update <slug> <id>").description("Update a roadmap").option("--title <title>", "New title").option("--content <content>", "New content").option("--file <path>", "Read content from file").option("--status <status>", "New status").action(async (slug, id, opts) => {
1049
1078
  const body = {};
1050
1079
  if (opts.title) body.title = opts.title;
1051
- if (opts.file) body.content = readFileSync11(opts.file, "utf-8");
1080
+ if (opts.file) body.content = readFileSync12(opts.file, "utf-8");
1052
1081
  else if (opts.content) body.content = opts.content;
1053
1082
  if (opts.status) body.status = opts.status;
1054
1083
  const res = await apiRequest(`/pebbles/${slug}/roadmaps/${id}`, {
@@ -1067,11 +1096,11 @@ roadmapsCmd.command("delete <slug> <id>").description("Delete a roadmap").action
1067
1096
  });
1068
1097
 
1069
1098
  // src/commands/persona-runs.ts
1070
- import { Command as Command17 } from "commander";
1071
- import { readFileSync as readFileSync12 } from "fs";
1099
+ import { Command as Command18 } from "commander";
1100
+ import { readFileSync as readFileSync13 } from "fs";
1072
1101
  import { join as join2 } from "path";
1073
1102
  import { homedir as homedir2 } from "os";
1074
- var personaRunsCmd = new Command17("persona-runs").description(
1103
+ var personaRunsCmd = new Command18("persona-runs").description(
1075
1104
  "Manage per-persona per-session token usage rows"
1076
1105
  );
1077
1106
  personaRunsCmd.command("list <slug>").description("List persona runs for a pebble").option("--bet-id <id>", "Filter by bet_id").option("--plan-id <id>", "Filter by plan_id").option("--persona <name>", "Filter by persona name").option("--limit <n>", "Max rows", "50").action(async (slug, opts) => {
@@ -1095,7 +1124,7 @@ personaRunsCmd.command("get <slug> <id>").description("Get a persona run by id")
1095
1124
  if (res.error) process.exit(1);
1096
1125
  });
1097
1126
  personaRunsCmd.command("create <slug>").description("Create/upsert a persona run from a JSON file").requiredOption("--file <path>", "JSON payload matching PersonaRunCreate").action(async (slug, opts) => {
1098
- const body = JSON.parse(readFileSync12(opts.file, "utf-8"));
1127
+ const body = JSON.parse(readFileSync13(opts.file, "utf-8"));
1099
1128
  const res = await apiRequest(`/pebbles/${slug}/persona-runs`, {
1100
1129
  method: "POST",
1101
1130
  body
@@ -1107,7 +1136,7 @@ personaRunsCmd.command("capture").description("Read marker + transcript, compute
1107
1136
  const markerPath = opts.marker ?? join2(homedir2(), ".claude", "odin-context", `${opts.sessionId}.json`);
1108
1137
  let marker = {};
1109
1138
  try {
1110
- marker = JSON.parse(readFileSync12(markerPath, "utf-8"));
1139
+ marker = JSON.parse(readFileSync13(markerPath, "utf-8"));
1111
1140
  } catch {
1112
1141
  process.exit(0);
1113
1142
  }
@@ -1117,7 +1146,7 @@ personaRunsCmd.command("capture").description("Read marker + transcript, compute
1117
1146
  let input = 0, output = 0, cacheRead = 0, cacheCreate = 0;
1118
1147
  let minTs = null, maxTs = null;
1119
1148
  try {
1120
- const raw = readFileSync12(opts.transcript, "utf-8");
1149
+ const raw = readFileSync13(opts.transcript, "utf-8");
1121
1150
  for (const line of raw.split("\n")) {
1122
1151
  if (!line.trim()) continue;
1123
1152
  let obj;
@@ -1168,10 +1197,10 @@ personaRunsCmd.command("capture").description("Read marker + transcript, compute
1168
1197
  });
1169
1198
 
1170
1199
  // src/commands/blog.ts
1171
- import { Command as Command18 } from "commander";
1172
- import { readFileSync as readFileSync13 } from "fs";
1173
- var blogCmd = new Command18("blog").description("Manage blog posts");
1174
- blogCmd.command("list <slug>").description("List blog posts for a pebble").option("--status <status>", "Filter by status (draft, review, published, archived)").option("--tag <tag>", "Filter by tag").option("--page <page>", "Page number", "1").option("--per-page <perPage>", "Results per page", "20").action(async (slug, opts) => {
1200
+ import { Command as Command19 } from "commander";
1201
+ import { readFileSync as readFileSync14 } from "fs";
1202
+ var blogCmd = new Command19("blog").description("Manage blog posts");
1203
+ blogCmd.command("list <slug>").description("List blog posts for a pebble").option("--status <status>", "Filter by status (draft, review, scheduled, published, archived)").option("--tag <tag>", "Filter by tag").option("--page <page>", "Page number", "1").option("--per-page <perPage>", "Results per page", "20").action(async (slug, opts) => {
1175
1204
  const params = {};
1176
1205
  if (opts.status) params.status = opts.status;
1177
1206
  if (opts.tag) params.tag = opts.tag;
@@ -1186,10 +1215,10 @@ blogCmd.command("get <slug> <idOrSlug>").description("Get a blog post by UUID or
1186
1215
  process.stdout.write(JSON.stringify(res) + "\n");
1187
1216
  if (res.error) process.exit(1);
1188
1217
  });
1189
- blogCmd.command("create <slug> <title>").description("Create a blog post").requiredOption("--post-slug <postSlug>", "URL slug for the post").option("--blocks <blocks>", "Content blocks as JSON string").option("--file <path>", "Read content blocks from JSON file").option("--status <status>", "Initial status (draft, review)", "draft").option("--metadata <metadata>", "Post metadata as JSON string").option("--campaign-id <campaignId>", "Link to a marketing campaign").action(async (slug, title, opts) => {
1218
+ blogCmd.command("create <slug> <title>").description("Create a blog post").requiredOption("--post-slug <postSlug>", "URL slug for the post").option("--blocks <blocks>", "Content blocks as JSON string").option("--file <path>", "Read content blocks from JSON file").option("--status <status>", "Initial status (draft, review, scheduled)", "draft").option("--scheduled-publish-at <iso>", "UTC ISO timestamp for scheduled publishing (required when --status scheduled)").option("--metadata <metadata>", "Post metadata as JSON string").option("--campaign-id <campaignId>", "Link to a marketing campaign").action(async (slug, title, opts) => {
1190
1219
  let blocks = [];
1191
1220
  if (opts.file) {
1192
- blocks = JSON.parse(readFileSync13(opts.file, "utf-8"));
1221
+ blocks = JSON.parse(readFileSync14(opts.file, "utf-8"));
1193
1222
  } else if (opts.blocks) {
1194
1223
  blocks = JSON.parse(opts.blocks);
1195
1224
  }
@@ -1199,6 +1228,7 @@ blogCmd.command("create <slug> <title>").description("Create a blog post").requi
1199
1228
  blocks,
1200
1229
  status: opts.status
1201
1230
  };
1231
+ if (opts.scheduledPublishAt) body.scheduled_publish_at = opts.scheduledPublishAt;
1202
1232
  if (opts.metadata) body.metadata = JSON.parse(opts.metadata);
1203
1233
  if (opts.campaignId) body.campaign_id = opts.campaignId;
1204
1234
  const res = await apiRequest(`/pebbles/${slug}/blog`, {
@@ -1208,13 +1238,15 @@ blogCmd.command("create <slug> <title>").description("Create a blog post").requi
1208
1238
  process.stdout.write(JSON.stringify(res) + "\n");
1209
1239
  if (res.error) process.exit(1);
1210
1240
  });
1211
- blogCmd.command("update <slug> <id>").description("Update a blog post").option("--title <title>", "New title").option("--post-slug <postSlug>", "New URL slug").option("--blocks <blocks>", "Content blocks as JSON string").option("--file <path>", "Read content blocks from JSON file").option("--status <status>", "New status (draft, review, published, archived)").option("--metadata <metadata>", "Post metadata as JSON string").action(async (slug, id, opts) => {
1241
+ blogCmd.command("update <slug> <id>").description("Update a blog post").option("--title <title>", "New title").option("--post-slug <postSlug>", "New URL slug").option("--blocks <blocks>", "Content blocks as JSON string").option("--file <path>", "Read content blocks from JSON file").option("--status <status>", "New status (draft, review, scheduled, published, archived)").option("--scheduled-publish-at <iso>", "UTC ISO timestamp for scheduled publishing (required when --status scheduled). Pass empty string to clear.").option("--metadata <metadata>", "Post metadata as JSON string").action(async (slug, id, opts) => {
1212
1242
  const body = {};
1213
1243
  if (opts.title) body.title = opts.title;
1214
1244
  if (opts.postSlug) body.slug = opts.postSlug;
1215
- if (opts.file) body.blocks = JSON.parse(readFileSync13(opts.file, "utf-8"));
1245
+ if (opts.file) body.blocks = JSON.parse(readFileSync14(opts.file, "utf-8"));
1216
1246
  else if (opts.blocks) body.blocks = JSON.parse(opts.blocks);
1217
1247
  if (opts.status) body.status = opts.status;
1248
+ if (opts.scheduledPublishAt !== void 0)
1249
+ body.scheduled_publish_at = opts.scheduledPublishAt === "" ? null : opts.scheduledPublishAt;
1218
1250
  if (opts.metadata) body.metadata = JSON.parse(opts.metadata);
1219
1251
  const res = await apiRequest(`/pebbles/${slug}/blog/${id}`, {
1220
1252
  method: "PUT",
@@ -1232,14 +1264,14 @@ blogCmd.command("delete <slug> <id>").description("Delete a blog post").action(a
1232
1264
  });
1233
1265
 
1234
1266
  // src/commands/api-keys.ts
1235
- import { Command as Command19 } from "commander";
1236
- var apiKeysCmd = new Command19("api-keys").description("Manage API keys for server-to-server access");
1267
+ import { Command as Command20 } from "commander";
1268
+ var apiKeysCmd = new Command20("api-keys").description("Manage API keys for server-to-server access");
1237
1269
  apiKeysCmd.command("list <slug>").description("List API keys for a pebble").action(async (slug) => {
1238
1270
  const res = await apiRequest(`/pebbles/${slug}/api-keys`);
1239
1271
  process.stdout.write(JSON.stringify(res) + "\n");
1240
1272
  if (res.error) process.exit(1);
1241
1273
  });
1242
- apiKeysCmd.command("create <slug>").description("Create a new API key").requiredOption("--name <name>", "Human-readable key name").requiredOption("--scopes <scopes>", "Comma-separated scopes (e.g. blog:read,marketing_content:read)").option("--expires-at <expiresAt>", "Expiration timestamp (ISO 8601)").action(async (slug, opts) => {
1274
+ apiKeysCmd.command("create <slug>").description("Create a new API key").requiredOption("--name <name>", "Human-readable key name").requiredOption("--scopes <scopes>", "Comma-separated scopes (e.g. blog:read,marketing_content:read,faqs:read)").option("--expires-at <expiresAt>", "Expiration timestamp (ISO 8601)").action(async (slug, opts) => {
1243
1275
  const scopes = opts.scopes.split(",").map((s) => s.trim());
1244
1276
  const body = { name: opts.name, scopes };
1245
1277
  if (opts.expiresAt) body.expires_at = opts.expiresAt;
@@ -1274,12 +1306,922 @@ apiKeysCmd.command("delete <slug> <id>").description("Permanently delete an API
1274
1306
  if (res.error) process.exit(1);
1275
1307
  });
1276
1308
 
1309
+ // src/commands/pebble/index.ts
1310
+ import { Command as Command21 } from "commander";
1311
+
1312
+ // src/commands/pebble/provisioner.ts
1313
+ async function runProvisioner(ctx, steps) {
1314
+ const report = { steps: [], captured: ctx.captured };
1315
+ for (const step of steps) {
1316
+ let result;
1317
+ const checked = await step.check(ctx);
1318
+ if (checked && checked.status === "satisfied") result = checked;
1319
+ else if (checked && checked.status === "blocked") result = checked;
1320
+ else result = await step.apply(ctx);
1321
+ if (result.data) {
1322
+ Object.assign(ctx.captured, result.data);
1323
+ }
1324
+ report.steps.push({
1325
+ id: step.id,
1326
+ label: step.label,
1327
+ status: result.status,
1328
+ detail: result.detail
1329
+ });
1330
+ if (result.status === "blocked") return { report, halted: true };
1331
+ if (result.status === "failed") return { report, halted: true };
1332
+ }
1333
+ return { report, halted: false };
1334
+ }
1335
+
1336
+ // src/commands/pebble/exec.ts
1337
+ import { execFile } from "child_process";
1338
+ function run(cmd, args, opts = {}) {
1339
+ return new Promise((resolve3) => {
1340
+ execFile(cmd, args, { cwd: opts.cwd, encoding: "utf-8" }, (err, stdout2, stderr) => {
1341
+ const code = err && typeof err.code === "number" ? err.code : err ? 1 : 0;
1342
+ resolve3({ ok: code === 0, stdout: stdout2 ?? "", stderr: stderr ?? "", code });
1343
+ });
1344
+ });
1345
+ }
1346
+
1347
+ // src/commands/pebble/steps/doppler.ts
1348
+ var dopplerStep = {
1349
+ id: "doppler",
1350
+ label: "Doppler project + dev/prd configs",
1351
+ async check(ctx) {
1352
+ const cli = await run("doppler", ["--version"]);
1353
+ if (!cli.ok) {
1354
+ return { status: "blocked", detail: "Doppler CLI not found. Install + run `doppler login`." };
1355
+ }
1356
+ const got = await run("doppler", ["projects", "get", ctx.slug]);
1357
+ return got.ok ? { status: "satisfied", detail: `Project ${ctx.slug} exists` } : null;
1358
+ },
1359
+ async apply(ctx) {
1360
+ if (ctx.dryRun) {
1361
+ return {
1362
+ status: "applied",
1363
+ detail: `[dry-run] would create Doppler project ${ctx.slug} + dev/prd configs`
1364
+ };
1365
+ }
1366
+ const c = await run("doppler", ["projects", "create", ctx.slug]);
1367
+ if (!c.ok) return { status: "failed", detail: `doppler projects create failed: ${c.stderr}` };
1368
+ await run("doppler", ["configs", "create", "dev", "-p", ctx.slug]);
1369
+ await run("doppler", ["configs", "create", "prd", "-p", ctx.slug]);
1370
+ return { status: "applied", detail: `Created Doppler project ${ctx.slug} + dev/prd configs` };
1371
+ }
1372
+ };
1373
+
1374
+ // src/commands/pebble/steps/github.ts
1375
+ import { spawn } from "child_process";
1376
+ import { resolve } from "path";
1377
+ var ORG = "pebblehouse";
1378
+ var TEMPLATE = "pebblehouse/pebble-template";
1379
+ var githubRepoStep = {
1380
+ id: "github",
1381
+ label: "GitHub repo from pebble-template",
1382
+ async check(ctx) {
1383
+ const cli = await run("gh", ["--version"]);
1384
+ if (!cli.ok) {
1385
+ return { status: "blocked", detail: "GitHub CLI not found. Install + run `gh auth login`." };
1386
+ }
1387
+ const view = await run("gh", ["repo", "view", `${ORG}/${ctx.slug}`]);
1388
+ if (view.ok) {
1389
+ const repoDir = ctx.repoDir ?? resolve(ctx.repoDir ?? ".", ctx.slug);
1390
+ return {
1391
+ status: "satisfied",
1392
+ detail: `Repo ${ORG}/${ctx.slug} exists`,
1393
+ data: { repoDir }
1394
+ };
1395
+ }
1396
+ return null;
1397
+ },
1398
+ async apply(ctx) {
1399
+ if (ctx.dryRun) {
1400
+ return {
1401
+ status: "applied",
1402
+ detail: `[dry-run] would create ${ORG}/${ctx.slug} from ${TEMPLATE}`
1403
+ };
1404
+ }
1405
+ const parent = ctx.repoDir ?? ".";
1406
+ const c = await run(
1407
+ "gh",
1408
+ [
1409
+ "repo",
1410
+ "create",
1411
+ `${ORG}/${ctx.slug}`,
1412
+ "--template",
1413
+ TEMPLATE,
1414
+ "--private",
1415
+ "--clone"
1416
+ ],
1417
+ { cwd: parent }
1418
+ );
1419
+ if (!c.ok) return { status: "failed", detail: `gh repo create failed: ${c.stderr}` };
1420
+ const repoDir = resolve(parent, ctx.slug);
1421
+ return {
1422
+ status: "applied",
1423
+ detail: `Created ${ORG}/${ctx.slug} and cloned to ${repoDir}`,
1424
+ data: { repoDir }
1425
+ };
1426
+ }
1427
+ };
1428
+ var githubSecretStep = {
1429
+ id: "github-secret",
1430
+ label: "GitHub Actions secret DOPPLER_TOKEN_PRD",
1431
+ async check(ctx) {
1432
+ const cli = await run("gh", ["--version"]);
1433
+ if (!cli.ok) {
1434
+ return { status: "blocked", detail: "GitHub CLI not found. Install + run `gh auth login`." };
1435
+ }
1436
+ const list = await run("gh", ["secret", "list", "-R", `${ORG}/${ctx.slug}`]);
1437
+ if (list.ok && /DOPPLER_TOKEN_PRD/.test(list.stdout)) {
1438
+ return { status: "satisfied", detail: "DOPPLER_TOKEN_PRD already set" };
1439
+ }
1440
+ return null;
1441
+ },
1442
+ async apply(ctx) {
1443
+ if (ctx.dryRun) {
1444
+ return {
1445
+ status: "applied",
1446
+ detail: "[dry-run] would mint scoped prd token and set DOPPLER_TOKEN_PRD"
1447
+ };
1448
+ }
1449
+ const token = await run("doppler", [
1450
+ "configs",
1451
+ "tokens",
1452
+ "create",
1453
+ "ci",
1454
+ "-p",
1455
+ ctx.slug,
1456
+ "-c",
1457
+ "prd",
1458
+ "--plain"
1459
+ ]);
1460
+ if (!token.ok) {
1461
+ return { status: "failed", detail: `doppler token mint failed: ${token.stderr}` };
1462
+ }
1463
+ const set = await setSecretViaStdin(
1464
+ ["secret", "set", "DOPPLER_TOKEN_PRD", "-R", `${ORG}/${ctx.slug}`],
1465
+ token.stdout.trim()
1466
+ );
1467
+ if (!set.ok) {
1468
+ return { status: "failed", detail: `gh secret set failed: ${set.stderr}` };
1469
+ }
1470
+ return { status: "applied", detail: "Set DOPPLER_TOKEN_PRD (read-only prd token)" };
1471
+ }
1472
+ };
1473
+ function setSecretViaStdin(args, value) {
1474
+ return new Promise((resolvePromise) => {
1475
+ const child = spawn("gh", args, { stdio: ["pipe", "ignore", "pipe"] });
1476
+ let stderr = "";
1477
+ child.stderr.on("data", (d) => {
1478
+ stderr += d.toString();
1479
+ });
1480
+ child.on("error", (e) => resolvePromise({ ok: false, stderr: e.message }));
1481
+ child.on("close", (code) => resolvePromise({ ok: code === 0, stderr }));
1482
+ child.stdin.write(value);
1483
+ child.stdin.end();
1484
+ });
1485
+ }
1486
+
1487
+ // src/commands/pebble/steps/vercel.ts
1488
+ var vercelStep = {
1489
+ id: "vercel",
1490
+ label: "Vercel project",
1491
+ async check(ctx) {
1492
+ const cli = await run("vercel", ["--version"]);
1493
+ if (!cli.ok) {
1494
+ return { status: "blocked", detail: "Vercel CLI not found. Install + run `vercel login`." };
1495
+ }
1496
+ const list = await run("vercel", ["project", "ls"]);
1497
+ if (list.ok && new RegExp(`(^|\\s)${ctx.slug}(\\s|$)`, "m").test(list.stdout)) {
1498
+ return { status: "satisfied", detail: `Vercel project ${ctx.slug} exists` };
1499
+ }
1500
+ return null;
1501
+ },
1502
+ async apply(ctx) {
1503
+ if (ctx.dryRun) {
1504
+ return { status: "applied", detail: `[dry-run] would add Vercel project ${ctx.slug}` };
1505
+ }
1506
+ const c = await run("vercel", ["project", "add", ctx.slug]);
1507
+ if (!c.ok) return { status: "failed", detail: `vercel project add failed: ${c.stderr}` };
1508
+ return { status: "applied", detail: `Added Vercel project ${ctx.slug}` };
1509
+ }
1510
+ };
1511
+
1512
+ // src/commands/pebble/steps/r2.ts
1513
+ var bucketName = (slug) => `${slug}-tfstate`;
1514
+ var r2Step = {
1515
+ id: "r2",
1516
+ label: "Cloudflare R2 tfstate bucket",
1517
+ async check(ctx) {
1518
+ const cli = await run("wrangler", ["--version"]);
1519
+ if (!cli.ok) {
1520
+ return { status: "blocked", detail: "Wrangler CLI not found. Install + run `wrangler login`." };
1521
+ }
1522
+ const list = await run("wrangler", ["r2", "bucket", "list"]);
1523
+ if (list.ok && list.stdout.includes(bucketName(ctx.slug))) {
1524
+ return { status: "satisfied", detail: `Bucket ${bucketName(ctx.slug)} exists` };
1525
+ }
1526
+ return null;
1527
+ },
1528
+ async apply(ctx) {
1529
+ if (ctx.dryRun) {
1530
+ return {
1531
+ status: "applied",
1532
+ detail: `[dry-run] would create R2 bucket ${bucketName(ctx.slug)} + store S3 creds to Doppler prd`
1533
+ };
1534
+ }
1535
+ const c = await run("wrangler", ["r2", "bucket", "create", bucketName(ctx.slug)]);
1536
+ if (!c.ok) return { status: "failed", detail: `r2 bucket create failed: ${c.stderr}` };
1537
+ return {
1538
+ status: "applied",
1539
+ detail: `Created R2 bucket ${bucketName(ctx.slug)}`,
1540
+ data: { tfstate_bucket: bucketName(ctx.slug) }
1541
+ };
1542
+ }
1543
+ };
1544
+
1545
+ // src/commands/pebble/steps/supabase.ts
1546
+ var supabaseStep = {
1547
+ id: "supabase",
1548
+ label: "Supabase project",
1549
+ async check(ctx) {
1550
+ const cli = await run("supabase", ["--version"]);
1551
+ if (!cli.ok) {
1552
+ return { status: "blocked", detail: "Supabase CLI not found. Install + run `supabase login`." };
1553
+ }
1554
+ const list = await run("supabase", ["projects", "list"]);
1555
+ if (list.ok && new RegExp(`(^|\\s)${ctx.slug}(\\s|$)`, "m").test(list.stdout)) {
1556
+ return { status: "satisfied", detail: `Supabase project ${ctx.slug} exists` };
1557
+ }
1558
+ return null;
1559
+ },
1560
+ async apply(ctx) {
1561
+ if (ctx.dryRun) {
1562
+ return { status: "applied", detail: `[dry-run] would create Supabase project ${ctx.slug}` };
1563
+ }
1564
+ const c = await run("supabase", ["projects", "create", ctx.slug]);
1565
+ if (!c.ok) return { status: "failed", detail: `supabase projects create failed: ${c.stderr}` };
1566
+ const ref = parseRef(c.stdout);
1567
+ return {
1568
+ status: "applied",
1569
+ detail: `Created Supabase project ${ctx.slug}${ref ? ` (${ref})` : ""}`,
1570
+ data: ref ? { supabase_ref: ref } : void 0
1571
+ };
1572
+ }
1573
+ };
1574
+ function parseRef(stdout2) {
1575
+ const m = stdout2.match(/[a-z]{20}/);
1576
+ return m ? m[0] : void 0;
1577
+ }
1578
+
1579
+ // src/commands/pebble/steps/resend.ts
1580
+ var resendStep = {
1581
+ id: "resend",
1582
+ label: "Resend sending domain (send-only)",
1583
+ async check(ctx) {
1584
+ const cli = await run("resend", ["--version"]);
1585
+ if (!cli.ok) {
1586
+ return {
1587
+ status: "blocked",
1588
+ detail: "Resend CLI not found / not configured. Set RESEND_API_KEY and install the CLI."
1589
+ };
1590
+ }
1591
+ const list = await run("resend", ["domains", "list"]);
1592
+ if (list.ok && list.stdout.includes(ctx.slug)) {
1593
+ return { status: "satisfied", detail: `Resend sending domain for ${ctx.slug} exists` };
1594
+ }
1595
+ return null;
1596
+ },
1597
+ async apply(ctx) {
1598
+ if (ctx.dryRun) {
1599
+ return {
1600
+ status: "applied",
1601
+ detail: `[dry-run] would create Resend sending domain for ${ctx.slug} (send-only)`
1602
+ };
1603
+ }
1604
+ const c = await run("resend", ["domains", "create", ctx.slug]);
1605
+ if (!c.ok) return { status: "failed", detail: `resend domains create failed: ${c.stderr}` };
1606
+ return { status: "applied", detail: `Created Resend sending domain for ${ctx.slug} (send-only)` };
1607
+ }
1608
+ };
1609
+
1610
+ // src/commands/pebble/steps/stripe.ts
1611
+ var stripeStep = {
1612
+ id: "stripe",
1613
+ label: "Stripe account (conditional)",
1614
+ async check() {
1615
+ return { status: "satisfied", detail: "conditional \u2014 evaluate in init bet" };
1616
+ },
1617
+ async apply() {
1618
+ return { status: "satisfied", detail: "conditional \u2014 evaluate in init bet" };
1619
+ }
1620
+ };
1621
+
1622
+ // src/commands/pebble/steps/index.ts
1623
+ var CORE_STEPS = [
1624
+ dopplerStep,
1625
+ githubRepoStep,
1626
+ vercelStep,
1627
+ r2Step,
1628
+ supabaseStep,
1629
+ resendStep,
1630
+ githubSecretStep
1631
+ ];
1632
+ var STEP_REGISTRY = {
1633
+ doppler: dopplerStep,
1634
+ github: githubRepoStep,
1635
+ vercel: vercelStep,
1636
+ r2: r2Step,
1637
+ supabase: supabaseStep,
1638
+ resend: resendStep,
1639
+ stripe: stripeStep
1640
+ };
1641
+
1642
+ // src/commands/pebble/init-bet.ts
1643
+ function renderInitBetBody(slug, report) {
1644
+ const rows = report.steps.map((s) => `| ${s.id} | ${s.status} | ${s.detail} |`).join("\n");
1645
+ return `# Initialize ${slug}
1646
+
1647
+ ## Problem
1648
+ \`${slug}\` is genesis'd from \`pebble-template\` with the full IaC spine (PLACEHOLDER_* values), but its real stack is unknown. \`infra-apply\` is OFF until the spine is tailored to the actual stack.
1649
+
1650
+ ## Solution
1651
+ 1. Interview the actual stack (which providers/workflows this pebble needs).
1652
+ 2. Trim the template's tofu modules + workflow set to match; remove dangling refs in \`main.tf\`/\`variables.tf\`.
1653
+ 3. Provision any conditional providers via \`odin pebble provision <provider> ${slug}\`.
1654
+ 4. Worktree eval + run the test suite.
1655
+ 5. Flip the deploy-ready signal so \`infra-apply\` runs on merge.
1656
+
1657
+ ## Constraints
1658
+ - Brownfield-safe; never blow away live resources.
1659
+ - No plaintext secrets or tfstate in git.
1660
+ - Reuse the codified IaC standard (blueprint 36839408 + pebble-template); no parallel layout.
1661
+
1662
+ ## Success Looks Like
1663
+ - Clean no-op \`tofu plan\`.
1664
+ - \`infra-apply\` flipped on and green on merge.
1665
+ - Test suite passes.
1666
+
1667
+ ### Provisioned day-one
1668
+ | Provider | Status | Detail |
1669
+ |---|---|---|
1670
+ ${rows}
1671
+
1672
+ ### Conditional providers to evaluate
1673
+ - [ ] stripe (only if this pebble takes payments)
1674
+ - [ ] Resend inbound / email-routing (only if this pebble receives mail)
1675
+ - [ ] any tail provider specific to the stack
1676
+ `;
1677
+ }
1678
+
1679
+ // src/commands/pebble/new.ts
1680
+ async function runNew(name, opts) {
1681
+ const slug = name.toLowerCase().replace(/[^a-z0-9-]/g, "-");
1682
+ const created = await apiRequest("/pebbles", {
1683
+ method: "POST",
1684
+ body: { slug, name }
1685
+ });
1686
+ if (created.error && !/already exists/i.test(created.error)) {
1687
+ console.error(created.error);
1688
+ return 1;
1689
+ }
1690
+ const ctx = {
1691
+ slug,
1692
+ name,
1693
+ repoDir: opts.dir ?? null,
1694
+ dryRun: !!opts.dryRun,
1695
+ captured: {}
1696
+ };
1697
+ const { report, halted } = await runProvisioner(ctx, CORE_STEPS);
1698
+ const last = report.steps[report.steps.length - 1];
1699
+ process.stdout.write(JSON.stringify({ slug, report }) + "\n");
1700
+ if (halted && last?.status === "failed") return 1;
1701
+ if (halted && last?.status === "blocked") {
1702
+ process.stderr.write(`
1703
+ Paused: ${last.detail}
1704
+ Re-run \`odin pebble new ${name}\` after acting.
1705
+ `);
1706
+ return 0;
1707
+ }
1708
+ const body = renderInitBetBody(slug, report);
1709
+ const bet = await apiRequest(`/pebbles/${slug}/bets`, {
1710
+ method: "POST",
1711
+ body: { title: `Initialize ${slug}`, source: "odin-cli", content: body, status: "shaped" }
1712
+ });
1713
+ process.stdout.write(JSON.stringify(bet) + "\n");
1714
+ if (opts.run && bet.data?.id) {
1715
+ process.stderr.write(`
1716
+ Hand off: /odin:eng-manager bet:${bet.data.id}
1717
+ `);
1718
+ }
1719
+ return 0;
1720
+ }
1721
+
1722
+ // src/commands/pebble/index.ts
1723
+ var pebbleCmd = new Command21("pebble").description(
1724
+ "Provision and initialize pebbles (eng-infra, run-once)"
1725
+ );
1726
+ pebbleCmd.command("new <name>").description("Provision a new pebble from pebble-template (day-one)").option("--run", "Hand off to eng-manager on the init bet").option("--dir <path>", "Parent dir to clone into").option("--dry-run", "Print the plan; mutate nothing").action(async (name, opts) => {
1727
+ process.exit(await runNew(name, { run: opts.run, dir: opts.dir, dryRun: opts.dryRun }));
1728
+ });
1729
+ pebbleCmd.command("provision <provider> <slug>").description("Run one provider step against an existing pebble").option("--dry-run", "Print; mutate nothing").action(async (provider, slug, opts) => {
1730
+ const step = STEP_REGISTRY[provider];
1731
+ if (!step) {
1732
+ console.error(`Unknown provider: ${provider}`);
1733
+ process.exit(1);
1734
+ }
1735
+ const { report, halted } = await runProvisioner(
1736
+ { slug, name: slug, repoDir: null, dryRun: !!opts.dryRun, captured: {} },
1737
+ [step]
1738
+ );
1739
+ process.stdout.write(JSON.stringify(report) + "\n");
1740
+ process.exit(halted && report.steps[0]?.status === "failed" ? 1 : 0);
1741
+ });
1742
+
1743
+ // src/commands/create/index.ts
1744
+ import { Command as Command22 } from "commander";
1745
+
1746
+ // src/commands/create/topology.ts
1747
+ var ENVS = ["prd", "stg", "dev"];
1748
+ var stateBucket = (slug) => `${slug}-web-tfstate`;
1749
+ function appDomainFor(env, appDomain) {
1750
+ if (env === "prd") return `app.${appDomain}`;
1751
+ if (env === "stg") return `app-staging.${appDomain}`;
1752
+ return "localhost:3000";
1753
+ }
1754
+ function productionUrlFor(env, appDomain) {
1755
+ if (env === "dev") return "http://localhost:3000";
1756
+ return `https://${appDomainFor(env, appDomain)}`;
1757
+ }
1758
+ var supabaseEnvKey = (env) => env === "prd" ? "prd" : "stg";
1759
+
1760
+ // src/commands/create/steps/manual.ts
1761
+ var domainStep = {
1762
+ id: "domain",
1763
+ label: "Domain registration + Cloudflare nameservers",
1764
+ kind: "manual",
1765
+ async run(ctx, io) {
1766
+ if (ctx.captured.appDomain) return {};
1767
+ io.info(
1768
+ "Register your project domain and point its registrar nameservers at Cloudflare."
1769
+ );
1770
+ const appDomain = await io.text("App domain (e.g. carepath.cloud):");
1771
+ return { appDomain };
1772
+ }
1773
+ };
1774
+ var accountsStep = {
1775
+ id: "accounts",
1776
+ label: "Provider accounts (Supabase / Vercel / Doppler / Cloudflare / Resend)",
1777
+ kind: "manual",
1778
+ async run(_ctx, io) {
1779
+ io.info(
1780
+ "Ensure billing-enabled accounts exist with each provider (2 Supabase projects exceed the free tier)."
1781
+ );
1782
+ await io.confirm("Accounts created and billing enabled?");
1783
+ return {};
1784
+ }
1785
+ };
1786
+ var cloudflareStep = {
1787
+ id: "cloudflare",
1788
+ label: "Cloudflare zone + account IDs + API token",
1789
+ kind: "manual",
1790
+ async run(ctx, io) {
1791
+ if (ctx.captured.cloudflareZoneId) return {};
1792
+ await io.openUrl(
1793
+ "https://dash.cloudflare.com/?to=/:account/:zone/dns",
1794
+ "Open your Cloudflare zone. Copy the Zone ID and Account ID from the overview:"
1795
+ );
1796
+ const cloudflareZoneId = await io.text("Cloudflare Zone ID:");
1797
+ const cloudflareAccountId = await io.text("Cloudflare Account ID:");
1798
+ await io.openUrl(
1799
+ "https://dash.cloudflare.com/profile/api-tokens",
1800
+ "Create a zone-scoped API token (DNS:Edit + Zone:Read), then paste it:"
1801
+ );
1802
+ const cloudflareApiToken = await io.secret("Cloudflare API Token:");
1803
+ return { cloudflareZoneId, cloudflareAccountId, cloudflareApiToken };
1804
+ }
1805
+ };
1806
+ var r2Step2 = {
1807
+ id: "r2",
1808
+ label: "Cloudflare R2 state bucket + S3 credentials",
1809
+ kind: "manual",
1810
+ async run(ctx, io) {
1811
+ if (ctx.captured.r2AccessKeyId) return {};
1812
+ const bucket = stateBucket(ctx.slug);
1813
+ await io.openUrl(
1814
+ "https://dash.cloudflare.com/?to=/:account/r2/new",
1815
+ `Create an R2 bucket named "${bucket}", then create an R2 API token (Object Read & Write):`
1816
+ );
1817
+ const r2AccessKeyId = await io.secret("R2 Access Key ID:");
1818
+ const r2SecretAccessKey = await io.secret("R2 Secret Access Key:");
1819
+ return { r2AccessKeyId, r2SecretAccessKey };
1820
+ }
1821
+ };
1822
+ var tokensStep = {
1823
+ id: "tokens",
1824
+ label: "Provider API tokens (Vercel + Supabase)",
1825
+ kind: "manual",
1826
+ async run(ctx, io) {
1827
+ if (ctx.captured.vercelApiToken) return {};
1828
+ await io.openUrl(
1829
+ "https://vercel.com/account/tokens",
1830
+ "Create a Vercel API token, then paste it:"
1831
+ );
1832
+ const vercelApiToken = await io.secret("Vercel API Token (TF_VAR_VERCEL_API_TOKEN):");
1833
+ const vercelToken = await io.secret(
1834
+ "Vercel CLI deploy token (VERCEL_TOKEN, may reuse the same value):"
1835
+ );
1836
+ const vercelTeamId = await io.text("Vercel Team ID (optional, blank for personal):");
1837
+ await io.openUrl(
1838
+ "https://supabase.com/dashboard/account/tokens",
1839
+ "Create a Supabase access token, then paste it:"
1840
+ );
1841
+ const supabaseAccessToken = await io.secret(
1842
+ "Supabase Access Token (TF_VAR_SUPABASE_ACCESS_TOKEN):"
1843
+ );
1844
+ const out = { vercelApiToken, vercelToken, supabaseAccessToken };
1845
+ if (vercelTeamId) out.vercelTeamId = vercelTeamId;
1846
+ return out;
1847
+ }
1848
+ };
1849
+ var vercelStep2 = {
1850
+ id: "vercel",
1851
+ label: "Vercel project (branch-scoped prod/staging)",
1852
+ kind: "manual",
1853
+ async run(_ctx, io) {
1854
+ io.info(
1855
+ "The Vercel project + branch-scoped environments are created by the IaC apply from your Vercel API token \u2014 no manual project creation needed."
1856
+ );
1857
+ return {};
1858
+ }
1859
+ };
1860
+ var githubProdEnvStep = {
1861
+ id: "github-prod-env",
1862
+ label: "GitHub production environment + required reviewer",
1863
+ kind: "manual",
1864
+ async run(ctx, io) {
1865
+ await io.openUrl(
1866
+ `https://github.com/pebblehouse/${ctx.slug}/settings/environments`,
1867
+ "Create a 'production' GitHub Environment and add a required reviewer (human gate for prod migrations):"
1868
+ );
1869
+ await io.confirm("production environment created with a required reviewer?");
1870
+ return {};
1871
+ }
1872
+ };
1873
+
1874
+ // src/commands/create/steps/supabase.ts
1875
+ import { randomBytes } from "crypto";
1876
+ var genPassword = () => randomBytes(18).toString("base64url");
1877
+ function supabaseStep2(env) {
1878
+ return {
1879
+ id: `supabase-${env}`,
1880
+ label: `Supabase project (${env})`,
1881
+ kind: "manual",
1882
+ async run(ctx, io) {
1883
+ if (ctx.captured[`${env}_supabaseProjectRef`]) return {};
1884
+ const projectName = `${ctx.slug}-${env}`;
1885
+ const dbPass = genPassword();
1886
+ await io.openUrl(
1887
+ "https://supabase.com/dashboard/new",
1888
+ `Create the Supabase project "${projectName}" (use this DB password: ${dbPass}), then paste its details:`
1889
+ );
1890
+ const ref = await io.text(`Supabase ${env} project ref:`);
1891
+ const supabaseUrl = await io.text(`Supabase ${env} Project URL (NEXT_PUBLIC_SUPABASE_URL):`);
1892
+ const supabasePublishableKey = await io.secret(
1893
+ `Supabase ${env} publishable key (sb_publishable_...):`
1894
+ );
1895
+ const supabaseSecretKey = await io.secret(`Supabase ${env} secret key (sb_secret_...):`);
1896
+ return {
1897
+ [`${env}_supabaseProjectRef`]: ref,
1898
+ [`${env}_supabaseUrl`]: supabaseUrl,
1899
+ [`${env}_supabasePublishableKey`]: supabasePublishableKey,
1900
+ [`${env}_supabaseSecretKey`]: supabaseSecretKey,
1901
+ [`${env}_supabaseDbPassword`]: dbPass
1902
+ };
1903
+ }
1904
+ };
1905
+ }
1906
+
1907
+ // src/commands/create/steps/index.ts
1908
+ var GUIDED_STEPS = [
1909
+ domainStep,
1910
+ accountsStep,
1911
+ cloudflareStep,
1912
+ r2Step2,
1913
+ tokensStep,
1914
+ supabaseStep2("prd"),
1915
+ supabaseStep2("stg"),
1916
+ vercelStep2,
1917
+ githubProdEnvStep
1918
+ ];
1919
+ var STEP_REGISTRY2 = Object.fromEntries(
1920
+ GUIDED_STEPS.map((s) => [s.id, s])
1921
+ );
1922
+
1923
+ // src/commands/create/manifest.ts
1924
+ function buildConfigManifest(env, inputs) {
1925
+ const s = inputs.shared;
1926
+ const e = inputs.envs[supabaseEnvKey(env)];
1927
+ const m = {
1928
+ // R2 remote state (bare — S3 backend)
1929
+ AWS_ACCESS_KEY_ID: s.r2AccessKeyId,
1930
+ AWS_SECRET_ACCESS_KEY: s.r2SecretAccessKey,
1931
+ // Tofu provider creds/config (TF_VAR_ prefixed)
1932
+ TF_VAR_VERCEL_API_TOKEN: s.vercelApiToken,
1933
+ TF_VAR_CLOUDFLARE_API_TOKEN: s.cloudflareApiToken,
1934
+ TF_VAR_SUPABASE_ACCESS_TOKEN: s.supabaseAccessToken,
1935
+ TF_VAR_CLOUDFLARE_ZONE_ID: s.cloudflareZoneId,
1936
+ TF_VAR_CLOUDFLARE_ACCOUNT_ID: s.cloudflareAccountId,
1937
+ TF_VAR_SUPABASE_PROJECT_REF: e.supabaseProjectRef,
1938
+ // drift-fix key
1939
+ TF_VAR_APP_DOMAIN: appDomainFor(env, s.appDomain),
1940
+ TF_VAR_PRODUCTION_URL: productionUrlFor(env, s.appDomain),
1941
+ // Vercel CLI deploy
1942
+ VERCEL_TOKEN: s.vercelToken,
1943
+ // App-runtime (Doppler->Vercel synced)
1944
+ NEXT_PUBLIC_SUPABASE_URL: e.supabaseUrl,
1945
+ NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: e.supabasePublishableKey,
1946
+ SUPABASE_SECRET_KEY: e.supabaseSecretKey,
1947
+ SUPABASE_DB_PASSWORD: e.supabaseDbPassword,
1948
+ SUPABASE_PROJECT_REF: e.supabaseProjectRef
1949
+ // bare — distinct consumer
1950
+ };
1951
+ if (s.vercelTeamId) m.TF_VAR_VERCEL_TEAM_ID = s.vercelTeamId;
1952
+ return m;
1953
+ }
1954
+ var REQUIRED_KEYS = (hasTeamId) => [
1955
+ "AWS_ACCESS_KEY_ID",
1956
+ "AWS_SECRET_ACCESS_KEY",
1957
+ "TF_VAR_VERCEL_API_TOKEN",
1958
+ "TF_VAR_CLOUDFLARE_API_TOKEN",
1959
+ "TF_VAR_SUPABASE_ACCESS_TOKEN",
1960
+ "TF_VAR_CLOUDFLARE_ZONE_ID",
1961
+ "TF_VAR_CLOUDFLARE_ACCOUNT_ID",
1962
+ "TF_VAR_SUPABASE_PROJECT_REF",
1963
+ "TF_VAR_APP_DOMAIN",
1964
+ "TF_VAR_PRODUCTION_URL",
1965
+ "VERCEL_TOKEN",
1966
+ "NEXT_PUBLIC_SUPABASE_URL",
1967
+ "NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY",
1968
+ "SUPABASE_SECRET_KEY",
1969
+ "SUPABASE_DB_PASSWORD",
1970
+ "SUPABASE_PROJECT_REF",
1971
+ ...hasTeamId ? ["TF_VAR_VERCEL_TEAM_ID"] : []
1972
+ ];
1973
+
1974
+ // src/commands/create/doppler-writer.ts
1975
+ import { spawn as spawn2 } from "child_process";
1976
+ var DopplerWriter = class {
1977
+ constructor(slug, dryRun = false, exec = run) {
1978
+ this.slug = slug;
1979
+ this.dryRun = dryRun;
1980
+ this.exec = exec;
1981
+ }
1982
+ async ensureConfig(config) {
1983
+ if (this.dryRun) return;
1984
+ await this.exec("doppler", ["projects", "create", this.slug]);
1985
+ await this.exec("doppler", ["configs", "create", config, "-p", this.slug]);
1986
+ }
1987
+ async readExisting(config) {
1988
+ const r = await this.exec("doppler", [
1989
+ "secrets",
1990
+ "download",
1991
+ "--no-file",
1992
+ "--format",
1993
+ "json",
1994
+ "-p",
1995
+ this.slug,
1996
+ "-c",
1997
+ config
1998
+ ]);
1999
+ if (!r.ok) return {};
2000
+ try {
2001
+ return JSON.parse(r.stdout);
2002
+ } catch {
2003
+ return {};
2004
+ }
2005
+ }
2006
+ // Returns the names actually written (missing or changed only).
2007
+ async sync(config, manifest) {
2008
+ const existing = await this.readExisting(config);
2009
+ const toWrite = Object.entries(manifest).filter(([k, v]) => existing[k] !== v);
2010
+ if (this.dryRun) return toWrite.map(([k]) => k);
2011
+ const written = [];
2012
+ for (const [name, value] of toWrite) {
2013
+ const ok = await setSecretViaStdin2(
2014
+ ["secrets", "set", name, "-p", this.slug, "-c", config],
2015
+ value
2016
+ );
2017
+ if (ok) written.push(name);
2018
+ }
2019
+ return written;
2020
+ }
2021
+ };
2022
+ function setSecretViaStdin2(args, value) {
2023
+ return new Promise((resolve3) => {
2024
+ const child = spawn2("doppler", args, { stdio: ["pipe", "ignore", "ignore"] });
2025
+ child.on("error", () => resolve3(false));
2026
+ child.on("close", (code) => resolve3(code === 0));
2027
+ child.stdin.write(value);
2028
+ child.stdin.end();
2029
+ });
2030
+ }
2031
+
2032
+ // src/commands/create/validate.ts
2033
+ async function validateConfigs(reader, hasTeamId) {
2034
+ const required = REQUIRED_KEYS(hasTeamId);
2035
+ const gaps = [];
2036
+ for (const config of ENVS) {
2037
+ const present = await reader(config);
2038
+ const missing = required.filter((k) => !(k in present) || present[k] === "");
2039
+ if (missing.length) gaps.push({ config, missing });
2040
+ }
2041
+ return gaps;
2042
+ }
2043
+ function formatGaps(gaps) {
2044
+ if (!gaps.length) return "All required secrets present in prd/stg/dev.";
2045
+ return gaps.map((g) => `${g.config}: missing ${g.missing.join(", ")}`).join("\n");
2046
+ }
2047
+
2048
+ // src/commands/create/prompt.ts
2049
+ import * as readline from "readline/promises";
2050
+ import { stdin, stdout } from "process";
2051
+ import open2 from "open";
2052
+ var RealPrompter = class {
2053
+ constructor(opener = open2) {
2054
+ this.rl = readline.createInterface({ input: stdin, output: stdout });
2055
+ this.opener = opener;
2056
+ }
2057
+ async text(q) {
2058
+ return (await this.rl.question(`${q} `)).trim();
2059
+ }
2060
+ // Mask typed input (sudo-style: nothing echoes) so pasted secrets resist shoulder-surfing.
2061
+ // Values are still never written to logs. Falls back to plain input if the private
2062
+ // readline write hook is unavailable.
2063
+ async secret(q) {
2064
+ const rl = this.rl;
2065
+ const original = rl._writeToOutput?.bind(rl);
2066
+ if (!original) return (await this.rl.question(`${q} `)).trim();
2067
+ let masking = false;
2068
+ rl._writeToOutput = (s) => {
2069
+ if (!masking || s.includes("\n") || s.includes("\r")) original(s);
2070
+ };
2071
+ try {
2072
+ const pending = this.rl.question(`${q} `);
2073
+ masking = true;
2074
+ return (await pending).trim();
2075
+ } finally {
2076
+ rl._writeToOutput = original;
2077
+ }
2078
+ }
2079
+ async confirm(q) {
2080
+ const a = (await this.rl.question(`${q} [y/N] `)).trim().toLowerCase();
2081
+ return a === "y" || a === "yes";
2082
+ }
2083
+ async openUrl(url, instruction) {
2084
+ stdout.write(`
2085
+ ${instruction}
2086
+ ${url}
2087
+ `);
2088
+ try {
2089
+ await this.opener(url);
2090
+ } catch {
2091
+ stdout.write(`(could not launch a browser, open the URL above manually)
2092
+ `);
2093
+ }
2094
+ }
2095
+ info(m) {
2096
+ stdout.write(`${m}
2097
+ `);
2098
+ }
2099
+ close() {
2100
+ this.rl.close();
2101
+ }
2102
+ };
2103
+
2104
+ // src/commands/create/create.ts
2105
+ async function runCreate(name, opts, deps = {}) {
2106
+ const io = deps.prompter ?? new RealPrompter();
2107
+ const resolvedName = name && name.trim() ? name.trim() : await io.text("Project name:");
2108
+ const slug = resolvedName.toLowerCase().replace(/[^a-z0-9-]/g, "-");
2109
+ const writer = deps.writer ?? new DopplerWriter(slug, !!opts.dryRun);
2110
+ const createPebble = deps.createPebble ?? (async (s, n) => {
2111
+ const r = await apiRequest("/pebbles", {
2112
+ method: "POST",
2113
+ body: { slug: s, name: n }
2114
+ });
2115
+ return { error: r.error && !/already exists/i.test(r.error) ? r.error : void 0 };
2116
+ });
2117
+ const id = await createPebble(slug, resolvedName);
2118
+ if (id.error) {
2119
+ io.info(`pebble create failed: ${id.error}`);
2120
+ io.close();
2121
+ return 1;
2122
+ }
2123
+ const ctx = {
2124
+ slug,
2125
+ name: resolvedName,
2126
+ dryRun: !!opts.dryRun,
2127
+ captured: capturedFromDoppler(await writer.readExisting("prd"), await writer.readExisting("stg"))
2128
+ };
2129
+ const steps = deps.steps ?? GUIDED_STEPS;
2130
+ for (const step of steps) Object.assign(ctx.captured, await step.run(ctx, io));
2131
+ const inputs = assembleInputs(ctx);
2132
+ const planned = [];
2133
+ for (const env of ENVS) {
2134
+ await writer.ensureConfig(env);
2135
+ const written = await writer.sync(env, buildConfigManifest(env, inputs));
2136
+ if (ctx.dryRun) planned.push(...written.map((k) => `${env}: ${k}`));
2137
+ }
2138
+ if (ctx.dryRun) {
2139
+ io.info(
2140
+ planned.length ? `dry-run: would write ${planned.length} secret(s):
2141
+ ${planned.join("\n")}` : "dry-run: all required secrets already present, nothing to write."
2142
+ );
2143
+ io.close();
2144
+ return 0;
2145
+ }
2146
+ const gaps = await validateConfigs((c) => writer.readExisting(c), !!inputs.shared.vercelTeamId);
2147
+ io.info(formatGaps(gaps));
2148
+ io.close();
2149
+ return gaps.length ? 1 : 0;
2150
+ }
2151
+ var SHARED_KEY_MAP = {
2152
+ TF_VAR_VERCEL_API_TOKEN: "vercelApiToken",
2153
+ TF_VAR_CLOUDFLARE_API_TOKEN: "cloudflareApiToken",
2154
+ TF_VAR_SUPABASE_ACCESS_TOKEN: "supabaseAccessToken",
2155
+ TF_VAR_CLOUDFLARE_ZONE_ID: "cloudflareZoneId",
2156
+ TF_VAR_CLOUDFLARE_ACCOUNT_ID: "cloudflareAccountId",
2157
+ TF_VAR_VERCEL_TEAM_ID: "vercelTeamId",
2158
+ VERCEL_TOKEN: "vercelToken",
2159
+ AWS_ACCESS_KEY_ID: "r2AccessKeyId",
2160
+ AWS_SECRET_ACCESS_KEY: "r2SecretAccessKey"
2161
+ };
2162
+ var ENV_KEY_MAP = {
2163
+ SUPABASE_PROJECT_REF: "supabaseProjectRef",
2164
+ NEXT_PUBLIC_SUPABASE_URL: "supabaseUrl",
2165
+ NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: "supabasePublishableKey",
2166
+ SUPABASE_SECRET_KEY: "supabaseSecretKey",
2167
+ SUPABASE_DB_PASSWORD: "supabaseDbPassword"
2168
+ };
2169
+ function capturedFromDoppler(prd, stg) {
2170
+ const out = {};
2171
+ for (const [dopplerKey, capKey] of Object.entries(SHARED_KEY_MAP)) {
2172
+ if (prd[dopplerKey]) out[capKey] = prd[dopplerKey];
2173
+ }
2174
+ if (prd.TF_VAR_APP_DOMAIN) out.appDomain = prd.TF_VAR_APP_DOMAIN.replace(/^app\./, "");
2175
+ for (const [env, cfg] of [["prd", prd], ["stg", stg]]) {
2176
+ for (const [dopplerKey, capKey] of Object.entries(ENV_KEY_MAP)) {
2177
+ if (cfg[dopplerKey]) out[`${env}_${capKey}`] = cfg[dopplerKey];
2178
+ }
2179
+ }
2180
+ return out;
2181
+ }
2182
+ function assembleInputs(ctx) {
2183
+ const c = ctx.captured;
2184
+ const shared = {
2185
+ slug: ctx.slug,
2186
+ appDomain: c.appDomain ?? "",
2187
+ vercelApiToken: c.vercelApiToken ?? "",
2188
+ cloudflareApiToken: c.cloudflareApiToken ?? "",
2189
+ supabaseAccessToken: c.supabaseAccessToken ?? "",
2190
+ cloudflareZoneId: c.cloudflareZoneId ?? "",
2191
+ cloudflareAccountId: c.cloudflareAccountId ?? "",
2192
+ vercelToken: c.vercelToken ?? "",
2193
+ r2AccessKeyId: c.r2AccessKeyId ?? "",
2194
+ r2SecretAccessKey: c.r2SecretAccessKey ?? ""
2195
+ };
2196
+ if (c.vercelTeamId) shared.vercelTeamId = c.vercelTeamId;
2197
+ const envFrom = (env) => ({
2198
+ supabaseProjectRef: c[`${env}_supabaseProjectRef`] ?? "",
2199
+ supabaseUrl: c[`${env}_supabaseUrl`] ?? "",
2200
+ supabasePublishableKey: c[`${env}_supabasePublishableKey`] ?? "",
2201
+ supabaseSecretKey: c[`${env}_supabaseSecretKey`] ?? "",
2202
+ supabaseDbPassword: c[`${env}_supabaseDbPassword`] ?? ""
2203
+ });
2204
+ const stg = envFrom("stg");
2205
+ return { shared, envs: { prd: envFrom("prd"), stg, dev: stg } };
2206
+ }
2207
+
2208
+ // src/commands/create/index.ts
2209
+ var createCmd = new Command22("create").description(
2210
+ "Create and provision a new project end to end"
2211
+ );
2212
+ createCmd.command("project [name]").description("Guided self-serve project + parallel-env provisioning").option("--dry-run", "Print the plan; mutate nothing").action(
2213
+ async (name, opts) => process.exit(await runCreate(name, { dryRun: opts.dryRun }))
2214
+ );
2215
+ var setupCmd = new Command22("setup").description("Alias for `create project`").arguments("[name]").option("--dry-run", "Print the plan; mutate nothing").action(
2216
+ async (name, opts) => process.exit(await runCreate(name, { dryRun: opts.dryRun }))
2217
+ );
2218
+
1277
2219
  // src/index.ts
1278
- import { readFileSync as readFileSync14 } from "fs";
2220
+ import { readFileSync as readFileSync15 } from "fs";
1279
2221
  import { fileURLToPath } from "url";
1280
- import { dirname as dirname2, resolve } from "path";
2222
+ import { dirname as dirname2, resolve as resolve2 } from "path";
1281
2223
  var __dirname = dirname2(fileURLToPath(import.meta.url));
1282
- var pkg = JSON.parse(readFileSync14(resolve(__dirname, "../package.json"), "utf-8"));
2224
+ var pkg = JSON.parse(readFileSync15(resolve2(__dirname, "../package.json"), "utf-8"));
1283
2225
  var GOLD = "\x1B[33m";
1284
2226
  var DIM = "\x1B[2m";
1285
2227
  var RESET = "\x1B[0m";
@@ -1297,7 +2239,7 @@ ${RESET}
1297
2239
  ${DIM}${cwd}${RESET}
1298
2240
  `);
1299
2241
  }
1300
- var program = new Command20();
2242
+ var program = new Command23();
1301
2243
  program.name("odin").description("CLI for Odin \u2014 the knowledge backbone for Pebble House").version(VERSION);
1302
2244
  program.command("login").description("Authenticate with Odin via browser").action(async () => {
1303
2245
  printBanner();
@@ -1326,11 +2268,15 @@ program.addCommand(rulesCmd);
1326
2268
  program.addCommand(briefsCmd);
1327
2269
  program.addCommand(blueprintsCmd);
1328
2270
  program.addCommand(betsCmd);
2271
+ program.addCommand(betCommentsCmd);
1329
2272
  program.addCommand(tasksCmd);
1330
2273
  program.addCommand(roadmapsCmd);
1331
2274
  program.addCommand(personaRunsCmd);
1332
2275
  program.addCommand(blogCmd);
1333
2276
  program.addCommand(apiKeysCmd);
2277
+ program.addCommand(pebbleCmd);
2278
+ program.addCommand(createCmd);
2279
+ program.addCommand(setupCmd);
1334
2280
  process.on("exit", () => {
1335
2281
  process.stderr.write("\n\n\n\n\n");
1336
2282
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pebblehouse/odin-cli",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "type": "module",
5
5
  "description": "CLI for Odin — the knowledge backbone for Pebble House",
6
6
  "bin": {
@@ -34,7 +34,7 @@
34
34
  "node": ">=18"
35
35
  },
36
36
  "dependencies": {
37
- "@supabase/supabase-js": "^2.47.10",
37
+ "@supabase/supabase-js": "^2.105.0",
38
38
  "commander": "^12.0.0",
39
39
  "open": "^10.0.0"
40
40
  },