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