@pebblehouse/odin-cli 0.13.0 → 0.14.1

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 (2) hide show
  1. package/dist/index.js +548 -37
  2. package/package.json +1 -1
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 Command21 } from "commander";
4
+ import { Command as Command23 } from "commander";
5
5
 
6
6
  // src/auth/login.ts
7
7
  import { createServer } from "http";
@@ -929,7 +929,7 @@ depsCmd.command("remove <slug> <bet-id>").description("Remove a blocked-by depen
929
929
 
930
930
  // src/commands/bets.ts
931
931
  var betsCmd = new Command14("bets").description("Manage bets");
932
- betsCmd.command("list <slug>").description("List bets for a pebble").option("--status <status>", "Filter by status").action(async (slug, opts) => {
932
+ betsCmd.command("list <slug>").description("List bets for a pebble").option("--status <status>", "Filter by status (pitch, shaped, betting, active, blocked, building, done, archived)").action(async (slug, opts) => {
933
933
  const params = {};
934
934
  if (opts.status) params.status = opts.status;
935
935
  const res = await apiRequest(`/pebbles/${slug}/bets`, { params });
@@ -941,7 +941,7 @@ betsCmd.command("get <slug> <id>").description("Get a bet by ID").action(async (
941
941
  process.stdout.write(JSON.stringify(res) + "\n");
942
942
  if (res.error) process.exit(1);
943
943
  });
944
- betsCmd.command("create <slug> <title>").description("Create a bet").option("--source <source>", "Source agent", "claude-code").option("--content <content>", "Bet content").option("--file <path>", "Read content from file").option("--status <status>", "Bet status").option("--priority <priority>", "Bet priority").option("--appetite <appetite>", "Bet appetite").action(async (slug, title, opts) => {
944
+ betsCmd.command("create <slug> <title>").description("Create a bet").option("--source <source>", "Source agent", "claude-code").option("--content <content>", "Bet content").option("--file <path>", "Read content from file").option("--status <status>", "Bet status (pitch, shaped, betting, active, blocked, building, done, archived)").option("--priority <priority>", "Bet priority").option("--appetite <appetite>", "Bet appetite").action(async (slug, title, opts) => {
945
945
  const content = opts.file ? readFileSync10(opts.file, "utf-8") : opts.content ?? "";
946
946
  const res = await apiRequest(`/pebbles/${slug}/bets`, {
947
947
  method: "POST",
@@ -950,7 +950,7 @@ betsCmd.command("create <slug> <title>").description("Create a bet").option("--s
950
950
  process.stdout.write(JSON.stringify(res) + "\n");
951
951
  if (res.error) process.exit(1);
952
952
  });
953
- betsCmd.command("update <slug> <id>").description("Update a bet").option("--title <title>", "New title").option("--content <content>", "New content").option("--file <path>", "Read content from file").option("--status <status>", "New status").option("--priority <priority>", "New priority").option("--appetite <appetite>", "New appetite").action(async (slug, id, opts) => {
953
+ betsCmd.command("update <slug> <id>").description("Update a bet").option("--title <title>", "New title").option("--content <content>", "New content").option("--file <path>", "Read content from file").option("--status <status>", "New status (pitch, shaped, betting, active, blocked, building, done, archived)").option("--priority <priority>", "New priority").option("--appetite <appetite>", "New appetite").action(async (slug, id, opts) => {
954
954
  const body = {};
955
955
  if (opts.title) body.title = opts.title;
956
956
  if (opts.file) body.content = readFileSync10(opts.file, "utf-8");
@@ -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;
@@ -1275,7 +1307,7 @@ apiKeysCmd.command("delete <slug> <id>").description("Permanently delete an API
1275
1307
  });
1276
1308
 
1277
1309
  // src/commands/pebble/index.ts
1278
- import { Command as Command20 } from "commander";
1310
+ import { Command as Command21 } from "commander";
1279
1311
 
1280
1312
  // src/commands/pebble/provisioner.ts
1281
1313
  async function runProvisioner(ctx, steps) {
@@ -1305,9 +1337,9 @@ async function runProvisioner(ctx, steps) {
1305
1337
  import { execFile } from "child_process";
1306
1338
  function run(cmd, args, opts = {}) {
1307
1339
  return new Promise((resolve3) => {
1308
- execFile(cmd, args, { cwd: opts.cwd, encoding: "utf-8" }, (err, stdout, stderr) => {
1340
+ execFile(cmd, args, { cwd: opts.cwd, encoding: "utf-8" }, (err, stdout2, stderr) => {
1309
1341
  const code = err && typeof err.code === "number" ? err.code : err ? 1 : 0;
1310
- resolve3({ ok: code === 0, stdout: stdout ?? "", stderr: stderr ?? "", code });
1342
+ resolve3({ ok: code === 0, stdout: stdout2 ?? "", stderr: stderr ?? "", code });
1311
1343
  });
1312
1344
  });
1313
1345
  }
@@ -1539,8 +1571,8 @@ var supabaseStep = {
1539
1571
  };
1540
1572
  }
1541
1573
  };
1542
- function parseRef(stdout) {
1543
- const m = stdout.match(/[a-z]{20}/);
1574
+ function parseRef(stdout2) {
1575
+ const m = stdout2.match(/[a-z]{20}/);
1544
1576
  return m ? m[0] : void 0;
1545
1577
  }
1546
1578
 
@@ -1688,7 +1720,7 @@ Hand off: /odin:eng-manager bet:${bet.data.id}
1688
1720
  }
1689
1721
 
1690
1722
  // src/commands/pebble/index.ts
1691
- var pebbleCmd = new Command20("pebble").description(
1723
+ var pebbleCmd = new Command21("pebble").description(
1692
1724
  "Provision and initialize pebbles (eng-infra, run-once)"
1693
1725
  );
1694
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) => {
@@ -1708,12 +1740,488 @@ pebbleCmd.command("provision <provider> <slug>").description("Run one provider s
1708
1740
  process.exit(halted && report.steps[0]?.status === "failed" ? 1 : 0);
1709
1741
  });
1710
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
+
1711
2219
  // src/index.ts
1712
- import { readFileSync as readFileSync14 } from "fs";
2220
+ import { readFileSync as readFileSync15 } from "fs";
1713
2221
  import { fileURLToPath } from "url";
1714
2222
  import { dirname as dirname2, resolve as resolve2 } from "path";
1715
2223
  var __dirname = dirname2(fileURLToPath(import.meta.url));
1716
- var pkg = JSON.parse(readFileSync14(resolve2(__dirname, "../package.json"), "utf-8"));
2224
+ var pkg = JSON.parse(readFileSync15(resolve2(__dirname, "../package.json"), "utf-8"));
1717
2225
  var GOLD = "\x1B[33m";
1718
2226
  var DIM = "\x1B[2m";
1719
2227
  var RESET = "\x1B[0m";
@@ -1731,7 +2239,7 @@ ${RESET}
1731
2239
  ${DIM}${cwd}${RESET}
1732
2240
  `);
1733
2241
  }
1734
- var program = new Command21();
2242
+ var program = new Command23();
1735
2243
  program.name("odin").description("CLI for Odin \u2014 the knowledge backbone for Pebble House").version(VERSION);
1736
2244
  program.command("login").description("Authenticate with Odin via browser").action(async () => {
1737
2245
  printBanner();
@@ -1760,12 +2268,15 @@ program.addCommand(rulesCmd);
1760
2268
  program.addCommand(briefsCmd);
1761
2269
  program.addCommand(blueprintsCmd);
1762
2270
  program.addCommand(betsCmd);
2271
+ program.addCommand(betCommentsCmd);
1763
2272
  program.addCommand(tasksCmd);
1764
2273
  program.addCommand(roadmapsCmd);
1765
2274
  program.addCommand(personaRunsCmd);
1766
2275
  program.addCommand(blogCmd);
1767
2276
  program.addCommand(apiKeysCmd);
1768
2277
  program.addCommand(pebbleCmd);
2278
+ program.addCommand(createCmd);
2279
+ program.addCommand(setupCmd);
1769
2280
  process.on("exit", () => {
1770
2281
  process.stderr.write("\n\n\n\n\n");
1771
2282
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pebblehouse/odin-cli",
3
- "version": "0.13.0",
3
+ "version": "0.14.1",
4
4
  "type": "module",
5
5
  "description": "CLI for Odin — the knowledge backbone for Pebble House",
6
6
  "bin": {