@pebblehouse/odin-cli 0.10.2 → 0.13.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 +573 -13
  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 Command18 } from "commander";
4
+ import { Command as Command21 } 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
@@ -281,8 +281,15 @@ docsCmd.command("create <slug> <type>").description("Create a document").require
281
281
  process.stdout.write(JSON.stringify(res) + "\n");
282
282
  if (res.error) process.exit(1);
283
283
  });
284
- docsCmd.command("delete <slug> <type>").description("Delete a document").action(async (slug, type) => {
285
- const res = await apiRequest(`/pebbles/${slug}/documents/${type}`, {
284
+ docsCmd.command("delete <slug> [type]").description("Delete a document by type, or by --id (unambiguous)").option("--id <id>", "Delete a specific document by its UUID").action(async (slug, type, opts) => {
285
+ if (!opts.id && !type) {
286
+ process.stdout.write(
287
+ JSON.stringify({ data: null, error: "Provide a <type> or --id <uuid> to delete" }) + "\n"
288
+ );
289
+ process.exit(1);
290
+ }
291
+ const path = opts.id ? `/pebbles/${slug}/documents/id/${opts.id}` : `/pebbles/${slug}/documents/${type}`;
292
+ const res = await apiRequest(path, {
286
293
  method: "DELETE"
287
294
  });
288
295
  process.stdout.write(JSON.stringify(res) + "\n");
@@ -858,12 +865,21 @@ blueprintsCmd.command("deprecate <id>").description("Deprecate a blueprint").act
858
865
  process.stdout.write(JSON.stringify(res) + "\n");
859
866
  if (res.error) process.exit(1);
860
867
  });
861
- blueprintsCmd.command("add-asset <id>").description("Add an asset reference to a blueprint").requiredOption("--title <title>", "Asset title").requiredOption("--url <url>", "Asset URL").option("--type <type>", "Asset type (screenshot|diagram|code-snippet|figma-link|github-link|raw-text|other)", "other").option("--description <description>", "Asset description").action(async (id, opts) => {
868
+ blueprintsCmd.command("add-asset <id>").description("Add an asset reference or inline content to a blueprint").requiredOption("--title <title>", "Asset title").option("--url <url>", "Asset URL (optional if content provided)").option("--content <content>", "Inline content string").option("--content-file <path>", "Read content from file").option("--type <type>", "Asset type (screenshot|diagram|code-snippet|figma-link|github-link|raw-text|visual-spec|other)", "other").option("--description <description>", "Asset description").action(async (id, opts) => {
869
+ let content = opts.content;
870
+ if (opts.contentFile) {
871
+ content = readFileSync9(opts.contentFile, "utf-8");
872
+ }
873
+ if (!opts.url && !content) {
874
+ process.stderr.write("Error: at least one of --url, --content, or --content-file is required\n");
875
+ process.exit(1);
876
+ }
862
877
  const res = await apiRequest(`/blueprints/${id}/assets`, {
863
878
  method: "POST",
864
879
  body: {
865
880
  title: opts.title,
866
- asset_url: opts.url,
881
+ asset_url: opts.url || null,
882
+ content: content || null,
867
883
  type: opts.type,
868
884
  description: opts.description ?? ""
869
885
  }
@@ -1151,12 +1167,553 @@ personaRunsCmd.command("capture").description("Read marker + transcript, compute
1151
1167
  process.stdout.write(JSON.stringify(res.data) + "\n");
1152
1168
  });
1153
1169
 
1154
- // src/index.ts
1170
+ // src/commands/blog.ts
1171
+ import { Command as Command18 } from "commander";
1155
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) => {
1175
+ const params = {};
1176
+ if (opts.status) params.status = opts.status;
1177
+ if (opts.tag) params.tag = opts.tag;
1178
+ params.page = opts.page;
1179
+ params.per_page = opts.perPage;
1180
+ const res = await apiRequest(`/pebbles/${slug}/blog`, { params });
1181
+ process.stdout.write(JSON.stringify(res) + "\n");
1182
+ if (res.error) process.exit(1);
1183
+ });
1184
+ blogCmd.command("get <slug> <idOrSlug>").description("Get a blog post by UUID or slug").action(async (slug, idOrSlug) => {
1185
+ const res = await apiRequest(`/pebbles/${slug}/blog/${idOrSlug}`);
1186
+ process.stdout.write(JSON.stringify(res) + "\n");
1187
+ if (res.error) process.exit(1);
1188
+ });
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) => {
1190
+ let blocks = [];
1191
+ if (opts.file) {
1192
+ blocks = JSON.parse(readFileSync13(opts.file, "utf-8"));
1193
+ } else if (opts.blocks) {
1194
+ blocks = JSON.parse(opts.blocks);
1195
+ }
1196
+ const body = {
1197
+ title,
1198
+ slug: opts.postSlug,
1199
+ blocks,
1200
+ status: opts.status
1201
+ };
1202
+ if (opts.metadata) body.metadata = JSON.parse(opts.metadata);
1203
+ if (opts.campaignId) body.campaign_id = opts.campaignId;
1204
+ const res = await apiRequest(`/pebbles/${slug}/blog`, {
1205
+ method: "POST",
1206
+ body
1207
+ });
1208
+ process.stdout.write(JSON.stringify(res) + "\n");
1209
+ if (res.error) process.exit(1);
1210
+ });
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) => {
1212
+ const body = {};
1213
+ if (opts.title) body.title = opts.title;
1214
+ if (opts.postSlug) body.slug = opts.postSlug;
1215
+ if (opts.file) body.blocks = JSON.parse(readFileSync13(opts.file, "utf-8"));
1216
+ else if (opts.blocks) body.blocks = JSON.parse(opts.blocks);
1217
+ if (opts.status) body.status = opts.status;
1218
+ if (opts.metadata) body.metadata = JSON.parse(opts.metadata);
1219
+ const res = await apiRequest(`/pebbles/${slug}/blog/${id}`, {
1220
+ method: "PUT",
1221
+ body
1222
+ });
1223
+ process.stdout.write(JSON.stringify(res) + "\n");
1224
+ if (res.error) process.exit(1);
1225
+ });
1226
+ blogCmd.command("delete <slug> <id>").description("Delete a blog post").action(async (slug, id) => {
1227
+ const res = await apiRequest(`/pebbles/${slug}/blog/${id}`, {
1228
+ method: "DELETE"
1229
+ });
1230
+ process.stdout.write(JSON.stringify(res) + "\n");
1231
+ if (res.error) process.exit(1);
1232
+ });
1233
+
1234
+ // 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");
1237
+ apiKeysCmd.command("list <slug>").description("List API keys for a pebble").action(async (slug) => {
1238
+ const res = await apiRequest(`/pebbles/${slug}/api-keys`);
1239
+ process.stdout.write(JSON.stringify(res) + "\n");
1240
+ if (res.error) process.exit(1);
1241
+ });
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) => {
1243
+ const scopes = opts.scopes.split(",").map((s) => s.trim());
1244
+ const body = { name: opts.name, scopes };
1245
+ if (opts.expiresAt) body.expires_at = opts.expiresAt;
1246
+ const res = await apiRequest(`/pebbles/${slug}/api-keys`, {
1247
+ method: "POST",
1248
+ body
1249
+ });
1250
+ process.stdout.write(JSON.stringify(res) + "\n");
1251
+ if (res.error) process.exit(1);
1252
+ if (res.data?.key) {
1253
+ process.stderr.write(
1254
+ `
1255
+ Store this key securely -- it cannot be retrieved again:
1256
+ ${res.data.key}
1257
+
1258
+ `
1259
+ );
1260
+ }
1261
+ });
1262
+ apiKeysCmd.command("revoke <slug> <id>").description("Revoke an API key").action(async (slug, id) => {
1263
+ const res = await apiRequest(`/pebbles/${slug}/api-keys/${id}/revoke`, {
1264
+ method: "POST"
1265
+ });
1266
+ process.stdout.write(JSON.stringify(res) + "\n");
1267
+ if (res.error) process.exit(1);
1268
+ });
1269
+ apiKeysCmd.command("delete <slug> <id>").description("Permanently delete an API key").action(async (slug, id) => {
1270
+ const res = await apiRequest(`/pebbles/${slug}/api-keys/${id}`, {
1271
+ method: "DELETE"
1272
+ });
1273
+ process.stdout.write(JSON.stringify(res) + "\n");
1274
+ if (res.error) process.exit(1);
1275
+ });
1276
+
1277
+ // src/commands/pebble/index.ts
1278
+ import { Command as Command20 } from "commander";
1279
+
1280
+ // src/commands/pebble/provisioner.ts
1281
+ async function runProvisioner(ctx, steps) {
1282
+ const report = { steps: [], captured: ctx.captured };
1283
+ for (const step of steps) {
1284
+ let result;
1285
+ const checked = await step.check(ctx);
1286
+ if (checked && checked.status === "satisfied") result = checked;
1287
+ else if (checked && checked.status === "blocked") result = checked;
1288
+ else result = await step.apply(ctx);
1289
+ if (result.data) {
1290
+ Object.assign(ctx.captured, result.data);
1291
+ }
1292
+ report.steps.push({
1293
+ id: step.id,
1294
+ label: step.label,
1295
+ status: result.status,
1296
+ detail: result.detail
1297
+ });
1298
+ if (result.status === "blocked") return { report, halted: true };
1299
+ if (result.status === "failed") return { report, halted: true };
1300
+ }
1301
+ return { report, halted: false };
1302
+ }
1303
+
1304
+ // src/commands/pebble/exec.ts
1305
+ import { execFile } from "child_process";
1306
+ function run(cmd, args, opts = {}) {
1307
+ return new Promise((resolve3) => {
1308
+ execFile(cmd, args, { cwd: opts.cwd, encoding: "utf-8" }, (err, stdout, stderr) => {
1309
+ const code = err && typeof err.code === "number" ? err.code : err ? 1 : 0;
1310
+ resolve3({ ok: code === 0, stdout: stdout ?? "", stderr: stderr ?? "", code });
1311
+ });
1312
+ });
1313
+ }
1314
+
1315
+ // src/commands/pebble/steps/doppler.ts
1316
+ var dopplerStep = {
1317
+ id: "doppler",
1318
+ label: "Doppler project + dev/prd configs",
1319
+ async check(ctx) {
1320
+ const cli = await run("doppler", ["--version"]);
1321
+ if (!cli.ok) {
1322
+ return { status: "blocked", detail: "Doppler CLI not found. Install + run `doppler login`." };
1323
+ }
1324
+ const got = await run("doppler", ["projects", "get", ctx.slug]);
1325
+ return got.ok ? { status: "satisfied", detail: `Project ${ctx.slug} exists` } : null;
1326
+ },
1327
+ async apply(ctx) {
1328
+ if (ctx.dryRun) {
1329
+ return {
1330
+ status: "applied",
1331
+ detail: `[dry-run] would create Doppler project ${ctx.slug} + dev/prd configs`
1332
+ };
1333
+ }
1334
+ const c = await run("doppler", ["projects", "create", ctx.slug]);
1335
+ if (!c.ok) return { status: "failed", detail: `doppler projects create failed: ${c.stderr}` };
1336
+ await run("doppler", ["configs", "create", "dev", "-p", ctx.slug]);
1337
+ await run("doppler", ["configs", "create", "prd", "-p", ctx.slug]);
1338
+ return { status: "applied", detail: `Created Doppler project ${ctx.slug} + dev/prd configs` };
1339
+ }
1340
+ };
1341
+
1342
+ // src/commands/pebble/steps/github.ts
1343
+ import { spawn } from "child_process";
1344
+ import { resolve } from "path";
1345
+ var ORG = "pebblehouse";
1346
+ var TEMPLATE = "pebblehouse/pebble-template";
1347
+ var githubRepoStep = {
1348
+ id: "github",
1349
+ label: "GitHub repo from pebble-template",
1350
+ async check(ctx) {
1351
+ const cli = await run("gh", ["--version"]);
1352
+ if (!cli.ok) {
1353
+ return { status: "blocked", detail: "GitHub CLI not found. Install + run `gh auth login`." };
1354
+ }
1355
+ const view = await run("gh", ["repo", "view", `${ORG}/${ctx.slug}`]);
1356
+ if (view.ok) {
1357
+ const repoDir = ctx.repoDir ?? resolve(ctx.repoDir ?? ".", ctx.slug);
1358
+ return {
1359
+ status: "satisfied",
1360
+ detail: `Repo ${ORG}/${ctx.slug} exists`,
1361
+ data: { repoDir }
1362
+ };
1363
+ }
1364
+ return null;
1365
+ },
1366
+ async apply(ctx) {
1367
+ if (ctx.dryRun) {
1368
+ return {
1369
+ status: "applied",
1370
+ detail: `[dry-run] would create ${ORG}/${ctx.slug} from ${TEMPLATE}`
1371
+ };
1372
+ }
1373
+ const parent = ctx.repoDir ?? ".";
1374
+ const c = await run(
1375
+ "gh",
1376
+ [
1377
+ "repo",
1378
+ "create",
1379
+ `${ORG}/${ctx.slug}`,
1380
+ "--template",
1381
+ TEMPLATE,
1382
+ "--private",
1383
+ "--clone"
1384
+ ],
1385
+ { cwd: parent }
1386
+ );
1387
+ if (!c.ok) return { status: "failed", detail: `gh repo create failed: ${c.stderr}` };
1388
+ const repoDir = resolve(parent, ctx.slug);
1389
+ return {
1390
+ status: "applied",
1391
+ detail: `Created ${ORG}/${ctx.slug} and cloned to ${repoDir}`,
1392
+ data: { repoDir }
1393
+ };
1394
+ }
1395
+ };
1396
+ var githubSecretStep = {
1397
+ id: "github-secret",
1398
+ label: "GitHub Actions secret DOPPLER_TOKEN_PRD",
1399
+ async check(ctx) {
1400
+ const cli = await run("gh", ["--version"]);
1401
+ if (!cli.ok) {
1402
+ return { status: "blocked", detail: "GitHub CLI not found. Install + run `gh auth login`." };
1403
+ }
1404
+ const list = await run("gh", ["secret", "list", "-R", `${ORG}/${ctx.slug}`]);
1405
+ if (list.ok && /DOPPLER_TOKEN_PRD/.test(list.stdout)) {
1406
+ return { status: "satisfied", detail: "DOPPLER_TOKEN_PRD already set" };
1407
+ }
1408
+ return null;
1409
+ },
1410
+ async apply(ctx) {
1411
+ if (ctx.dryRun) {
1412
+ return {
1413
+ status: "applied",
1414
+ detail: "[dry-run] would mint scoped prd token and set DOPPLER_TOKEN_PRD"
1415
+ };
1416
+ }
1417
+ const token = await run("doppler", [
1418
+ "configs",
1419
+ "tokens",
1420
+ "create",
1421
+ "ci",
1422
+ "-p",
1423
+ ctx.slug,
1424
+ "-c",
1425
+ "prd",
1426
+ "--plain"
1427
+ ]);
1428
+ if (!token.ok) {
1429
+ return { status: "failed", detail: `doppler token mint failed: ${token.stderr}` };
1430
+ }
1431
+ const set = await setSecretViaStdin(
1432
+ ["secret", "set", "DOPPLER_TOKEN_PRD", "-R", `${ORG}/${ctx.slug}`],
1433
+ token.stdout.trim()
1434
+ );
1435
+ if (!set.ok) {
1436
+ return { status: "failed", detail: `gh secret set failed: ${set.stderr}` };
1437
+ }
1438
+ return { status: "applied", detail: "Set DOPPLER_TOKEN_PRD (read-only prd token)" };
1439
+ }
1440
+ };
1441
+ function setSecretViaStdin(args, value) {
1442
+ return new Promise((resolvePromise) => {
1443
+ const child = spawn("gh", args, { stdio: ["pipe", "ignore", "pipe"] });
1444
+ let stderr = "";
1445
+ child.stderr.on("data", (d) => {
1446
+ stderr += d.toString();
1447
+ });
1448
+ child.on("error", (e) => resolvePromise({ ok: false, stderr: e.message }));
1449
+ child.on("close", (code) => resolvePromise({ ok: code === 0, stderr }));
1450
+ child.stdin.write(value);
1451
+ child.stdin.end();
1452
+ });
1453
+ }
1454
+
1455
+ // src/commands/pebble/steps/vercel.ts
1456
+ var vercelStep = {
1457
+ id: "vercel",
1458
+ label: "Vercel project",
1459
+ async check(ctx) {
1460
+ const cli = await run("vercel", ["--version"]);
1461
+ if (!cli.ok) {
1462
+ return { status: "blocked", detail: "Vercel CLI not found. Install + run `vercel login`." };
1463
+ }
1464
+ const list = await run("vercel", ["project", "ls"]);
1465
+ if (list.ok && new RegExp(`(^|\\s)${ctx.slug}(\\s|$)`, "m").test(list.stdout)) {
1466
+ return { status: "satisfied", detail: `Vercel project ${ctx.slug} exists` };
1467
+ }
1468
+ return null;
1469
+ },
1470
+ async apply(ctx) {
1471
+ if (ctx.dryRun) {
1472
+ return { status: "applied", detail: `[dry-run] would add Vercel project ${ctx.slug}` };
1473
+ }
1474
+ const c = await run("vercel", ["project", "add", ctx.slug]);
1475
+ if (!c.ok) return { status: "failed", detail: `vercel project add failed: ${c.stderr}` };
1476
+ return { status: "applied", detail: `Added Vercel project ${ctx.slug}` };
1477
+ }
1478
+ };
1479
+
1480
+ // src/commands/pebble/steps/r2.ts
1481
+ var bucketName = (slug) => `${slug}-tfstate`;
1482
+ var r2Step = {
1483
+ id: "r2",
1484
+ label: "Cloudflare R2 tfstate bucket",
1485
+ async check(ctx) {
1486
+ const cli = await run("wrangler", ["--version"]);
1487
+ if (!cli.ok) {
1488
+ return { status: "blocked", detail: "Wrangler CLI not found. Install + run `wrangler login`." };
1489
+ }
1490
+ const list = await run("wrangler", ["r2", "bucket", "list"]);
1491
+ if (list.ok && list.stdout.includes(bucketName(ctx.slug))) {
1492
+ return { status: "satisfied", detail: `Bucket ${bucketName(ctx.slug)} exists` };
1493
+ }
1494
+ return null;
1495
+ },
1496
+ async apply(ctx) {
1497
+ if (ctx.dryRun) {
1498
+ return {
1499
+ status: "applied",
1500
+ detail: `[dry-run] would create R2 bucket ${bucketName(ctx.slug)} + store S3 creds to Doppler prd`
1501
+ };
1502
+ }
1503
+ const c = await run("wrangler", ["r2", "bucket", "create", bucketName(ctx.slug)]);
1504
+ if (!c.ok) return { status: "failed", detail: `r2 bucket create failed: ${c.stderr}` };
1505
+ return {
1506
+ status: "applied",
1507
+ detail: `Created R2 bucket ${bucketName(ctx.slug)}`,
1508
+ data: { tfstate_bucket: bucketName(ctx.slug) }
1509
+ };
1510
+ }
1511
+ };
1512
+
1513
+ // src/commands/pebble/steps/supabase.ts
1514
+ var supabaseStep = {
1515
+ id: "supabase",
1516
+ label: "Supabase project",
1517
+ async check(ctx) {
1518
+ const cli = await run("supabase", ["--version"]);
1519
+ if (!cli.ok) {
1520
+ return { status: "blocked", detail: "Supabase CLI not found. Install + run `supabase login`." };
1521
+ }
1522
+ const list = await run("supabase", ["projects", "list"]);
1523
+ if (list.ok && new RegExp(`(^|\\s)${ctx.slug}(\\s|$)`, "m").test(list.stdout)) {
1524
+ return { status: "satisfied", detail: `Supabase project ${ctx.slug} exists` };
1525
+ }
1526
+ return null;
1527
+ },
1528
+ async apply(ctx) {
1529
+ if (ctx.dryRun) {
1530
+ return { status: "applied", detail: `[dry-run] would create Supabase project ${ctx.slug}` };
1531
+ }
1532
+ const c = await run("supabase", ["projects", "create", ctx.slug]);
1533
+ if (!c.ok) return { status: "failed", detail: `supabase projects create failed: ${c.stderr}` };
1534
+ const ref = parseRef(c.stdout);
1535
+ return {
1536
+ status: "applied",
1537
+ detail: `Created Supabase project ${ctx.slug}${ref ? ` (${ref})` : ""}`,
1538
+ data: ref ? { supabase_ref: ref } : void 0
1539
+ };
1540
+ }
1541
+ };
1542
+ function parseRef(stdout) {
1543
+ const m = stdout.match(/[a-z]{20}/);
1544
+ return m ? m[0] : void 0;
1545
+ }
1546
+
1547
+ // src/commands/pebble/steps/resend.ts
1548
+ var resendStep = {
1549
+ id: "resend",
1550
+ label: "Resend sending domain (send-only)",
1551
+ async check(ctx) {
1552
+ const cli = await run("resend", ["--version"]);
1553
+ if (!cli.ok) {
1554
+ return {
1555
+ status: "blocked",
1556
+ detail: "Resend CLI not found / not configured. Set RESEND_API_KEY and install the CLI."
1557
+ };
1558
+ }
1559
+ const list = await run("resend", ["domains", "list"]);
1560
+ if (list.ok && list.stdout.includes(ctx.slug)) {
1561
+ return { status: "satisfied", detail: `Resend sending domain for ${ctx.slug} exists` };
1562
+ }
1563
+ return null;
1564
+ },
1565
+ async apply(ctx) {
1566
+ if (ctx.dryRun) {
1567
+ return {
1568
+ status: "applied",
1569
+ detail: `[dry-run] would create Resend sending domain for ${ctx.slug} (send-only)`
1570
+ };
1571
+ }
1572
+ const c = await run("resend", ["domains", "create", ctx.slug]);
1573
+ if (!c.ok) return { status: "failed", detail: `resend domains create failed: ${c.stderr}` };
1574
+ return { status: "applied", detail: `Created Resend sending domain for ${ctx.slug} (send-only)` };
1575
+ }
1576
+ };
1577
+
1578
+ // src/commands/pebble/steps/stripe.ts
1579
+ var stripeStep = {
1580
+ id: "stripe",
1581
+ label: "Stripe account (conditional)",
1582
+ async check() {
1583
+ return { status: "satisfied", detail: "conditional \u2014 evaluate in init bet" };
1584
+ },
1585
+ async apply() {
1586
+ return { status: "satisfied", detail: "conditional \u2014 evaluate in init bet" };
1587
+ }
1588
+ };
1589
+
1590
+ // src/commands/pebble/steps/index.ts
1591
+ var CORE_STEPS = [
1592
+ dopplerStep,
1593
+ githubRepoStep,
1594
+ vercelStep,
1595
+ r2Step,
1596
+ supabaseStep,
1597
+ resendStep,
1598
+ githubSecretStep
1599
+ ];
1600
+ var STEP_REGISTRY = {
1601
+ doppler: dopplerStep,
1602
+ github: githubRepoStep,
1603
+ vercel: vercelStep,
1604
+ r2: r2Step,
1605
+ supabase: supabaseStep,
1606
+ resend: resendStep,
1607
+ stripe: stripeStep
1608
+ };
1609
+
1610
+ // src/commands/pebble/init-bet.ts
1611
+ function renderInitBetBody(slug, report) {
1612
+ const rows = report.steps.map((s) => `| ${s.id} | ${s.status} | ${s.detail} |`).join("\n");
1613
+ return `# Initialize ${slug}
1614
+
1615
+ ## Problem
1616
+ \`${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.
1617
+
1618
+ ## Solution
1619
+ 1. Interview the actual stack (which providers/workflows this pebble needs).
1620
+ 2. Trim the template's tofu modules + workflow set to match; remove dangling refs in \`main.tf\`/\`variables.tf\`.
1621
+ 3. Provision any conditional providers via \`odin pebble provision <provider> ${slug}\`.
1622
+ 4. Worktree eval + run the test suite.
1623
+ 5. Flip the deploy-ready signal so \`infra-apply\` runs on merge.
1624
+
1625
+ ## Constraints
1626
+ - Brownfield-safe; never blow away live resources.
1627
+ - No plaintext secrets or tfstate in git.
1628
+ - Reuse the codified IaC standard (blueprint 36839408 + pebble-template); no parallel layout.
1629
+
1630
+ ## Success Looks Like
1631
+ - Clean no-op \`tofu plan\`.
1632
+ - \`infra-apply\` flipped on and green on merge.
1633
+ - Test suite passes.
1634
+
1635
+ ### Provisioned day-one
1636
+ | Provider | Status | Detail |
1637
+ |---|---|---|
1638
+ ${rows}
1639
+
1640
+ ### Conditional providers to evaluate
1641
+ - [ ] stripe (only if this pebble takes payments)
1642
+ - [ ] Resend inbound / email-routing (only if this pebble receives mail)
1643
+ - [ ] any tail provider specific to the stack
1644
+ `;
1645
+ }
1646
+
1647
+ // src/commands/pebble/new.ts
1648
+ async function runNew(name, opts) {
1649
+ const slug = name.toLowerCase().replace(/[^a-z0-9-]/g, "-");
1650
+ const created = await apiRequest("/pebbles", {
1651
+ method: "POST",
1652
+ body: { slug, name }
1653
+ });
1654
+ if (created.error && !/already exists/i.test(created.error)) {
1655
+ console.error(created.error);
1656
+ return 1;
1657
+ }
1658
+ const ctx = {
1659
+ slug,
1660
+ name,
1661
+ repoDir: opts.dir ?? null,
1662
+ dryRun: !!opts.dryRun,
1663
+ captured: {}
1664
+ };
1665
+ const { report, halted } = await runProvisioner(ctx, CORE_STEPS);
1666
+ const last = report.steps[report.steps.length - 1];
1667
+ process.stdout.write(JSON.stringify({ slug, report }) + "\n");
1668
+ if (halted && last?.status === "failed") return 1;
1669
+ if (halted && last?.status === "blocked") {
1670
+ process.stderr.write(`
1671
+ Paused: ${last.detail}
1672
+ Re-run \`odin pebble new ${name}\` after acting.
1673
+ `);
1674
+ return 0;
1675
+ }
1676
+ const body = renderInitBetBody(slug, report);
1677
+ const bet = await apiRequest(`/pebbles/${slug}/bets`, {
1678
+ method: "POST",
1679
+ body: { title: `Initialize ${slug}`, source: "odin-cli", content: body, status: "shaped" }
1680
+ });
1681
+ process.stdout.write(JSON.stringify(bet) + "\n");
1682
+ if (opts.run && bet.data?.id) {
1683
+ process.stderr.write(`
1684
+ Hand off: /odin:eng-manager bet:${bet.data.id}
1685
+ `);
1686
+ }
1687
+ return 0;
1688
+ }
1689
+
1690
+ // src/commands/pebble/index.ts
1691
+ var pebbleCmd = new Command20("pebble").description(
1692
+ "Provision and initialize pebbles (eng-infra, run-once)"
1693
+ );
1694
+ 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) => {
1695
+ process.exit(await runNew(name, { run: opts.run, dir: opts.dir, dryRun: opts.dryRun }));
1696
+ });
1697
+ 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) => {
1698
+ const step = STEP_REGISTRY[provider];
1699
+ if (!step) {
1700
+ console.error(`Unknown provider: ${provider}`);
1701
+ process.exit(1);
1702
+ }
1703
+ const { report, halted } = await runProvisioner(
1704
+ { slug, name: slug, repoDir: null, dryRun: !!opts.dryRun, captured: {} },
1705
+ [step]
1706
+ );
1707
+ process.stdout.write(JSON.stringify(report) + "\n");
1708
+ process.exit(halted && report.steps[0]?.status === "failed" ? 1 : 0);
1709
+ });
1710
+
1711
+ // src/index.ts
1712
+ import { readFileSync as readFileSync14 } from "fs";
1156
1713
  import { fileURLToPath } from "url";
1157
- import { dirname as dirname2, resolve } from "path";
1714
+ import { dirname as dirname2, resolve as resolve2 } from "path";
1158
1715
  var __dirname = dirname2(fileURLToPath(import.meta.url));
1159
- var pkg = JSON.parse(readFileSync13(resolve(__dirname, "../package.json"), "utf-8"));
1716
+ var pkg = JSON.parse(readFileSync14(resolve2(__dirname, "../package.json"), "utf-8"));
1160
1717
  var GOLD = "\x1B[33m";
1161
1718
  var DIM = "\x1B[2m";
1162
1719
  var RESET = "\x1B[0m";
@@ -1174,7 +1731,7 @@ ${RESET}
1174
1731
  ${DIM}${cwd}${RESET}
1175
1732
  `);
1176
1733
  }
1177
- var program = new Command18();
1734
+ var program = new Command21();
1178
1735
  program.name("odin").description("CLI for Odin \u2014 the knowledge backbone for Pebble House").version(VERSION);
1179
1736
  program.command("login").description("Authenticate with Odin via browser").action(async () => {
1180
1737
  printBanner();
@@ -1206,6 +1763,9 @@ program.addCommand(betsCmd);
1206
1763
  program.addCommand(tasksCmd);
1207
1764
  program.addCommand(roadmapsCmd);
1208
1765
  program.addCommand(personaRunsCmd);
1766
+ program.addCommand(blogCmd);
1767
+ program.addCommand(apiKeysCmd);
1768
+ program.addCommand(pebbleCmd);
1209
1769
  process.on("exit", () => {
1210
1770
  process.stderr.write("\n\n\n\n\n");
1211
1771
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pebblehouse/odin-cli",
3
- "version": "0.10.2",
3
+ "version": "0.13.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
  },