@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.
- package/README.md +1 -1
- package/dist/index.js +573 -13
- 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
|
-
| `
|
|
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
|
|
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((
|
|
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
|
-
|
|
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
|
|
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,
|
|
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>
|
|
285
|
-
|
|
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").
|
|
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/
|
|
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(
|
|
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
|
|
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.
|
|
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.
|
|
37
|
+
"@supabase/supabase-js": "^2.105.0",
|
|
38
38
|
"commander": "^12.0.0",
|
|
39
39
|
"open": "^10.0.0"
|
|
40
40
|
},
|