@ak--47/dungeon-master 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/.claude/skills/analyze-soup/SKILL.md +21 -11
  2. package/.claude/skills/create-dungeon/SKILL.md +49 -10
  3. package/.claude/skills/create-project/SKILL.md +22 -3
  4. package/.claude/skills/create-project/context.mjs +89 -0
  5. package/.claude/skills/create-project/provision.mjs +1 -60
  6. package/.claude/skills/headless-build/SKILL.md +18 -1
  7. package/.claude/skills/powertools/SKILL.md +20 -1
  8. package/.claude/skills/release-check/SKILL.md +99 -0
  9. package/.claude/skills/verify-dungeon/SKILL.md +71 -16
  10. package/.claude/skills/verify-dungeon/references/counting-semantics.md +14 -0
  11. package/.claude/skills/verify-dungeon/references/report-format.md +18 -1
  12. package/.claude/skills/verify-dungeon/references/sql-recipes.md +36 -1
  13. package/.claude/skills/warehouse-metrics/GAPS-template.md +34 -0
  14. package/.claude/skills/warehouse-metrics/SKILL.md +105 -0
  15. package/.claude/skills/warehouse-metrics/deploy.mjs +651 -0
  16. package/.claude/skills/write-hooks/SKILL.md +33 -3
  17. package/CHANGELOG.md +142 -0
  18. package/HOOKS.md +105 -5
  19. package/README.md +228 -0
  20. package/docs/guides/1.8.0-upgrade-guide.md +151 -0
  21. package/dungeons/technical/warehouse.js +187 -0
  22. package/index.js +116 -2
  23. package/lib/core/config-validator.js +21 -0
  24. package/lib/core/dungeon-loader.js +1 -1
  25. package/lib/core/storage.js +51 -3
  26. package/lib/generators/standalone.js +248 -0
  27. package/lib/generators/warehouse.js +828 -0
  28. package/lib/orchestrators/mixpanel-sender.js +27 -2
  29. package/lib/orchestrators/user-loop.js +1 -0
  30. package/lib/templates/story-spec.schema.json +41 -16
  31. package/lib/utils/utils.js +37 -12
  32. package/lib/verify/index.js +1 -0
  33. package/lib/verify/story-runner.js +71 -8
  34. package/lib/verify/warehouse.js +683 -0
  35. package/package.json +4 -2
  36. package/scripts/verify-stories.mjs +150 -44
  37. package/types.d.ts +303 -4
@@ -0,0 +1,651 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'fs';
4
+ import path, { dirname, resolve } from 'path';
5
+ import { spawnSync } from 'child_process';
6
+ import { fileURLToPath, pathToFileURL } from 'url';
7
+ import dotenv from 'dotenv';
8
+
9
+ import { loadFromFile } from '../../../index.js';
10
+
11
+ const __dirname = dirname(fileURLToPath(import.meta.url));
12
+ const REPO_ROOT = resolve(__dirname, '../../../');
13
+ const PT_PATH = resolve(REPO_ROOT, '.claude/skills/powertools/pt.mjs');
14
+ const TEMPLATE_PATH = resolve(__dirname, 'GAPS-template.md');
15
+ const BQ_PROJECT = 'mixpanel-gtm-training';
16
+ const BQ_LOCATION = 'US';
17
+ const DRY_RUN_PROJECT_ID = '<project_id>';
18
+ const PREVIEW_BLOCKLIST = ['DROP', 'DELETE', 'TRUNCATE', 'ALTER', 'CREATE', 'INSERT', 'UPDATE'];
19
+ const TIMING_ONLY_KEYS = new Set(['duration_ms', 'duration_human']);
20
+ const DATASET_IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
21
+
22
+ dotenv.config({ path: resolve(REPO_ROOT, '.env') });
23
+
24
+ export function normalizeDatasetName(name) {
25
+ const normalized = String(name || 'dungeon')
26
+ .toLowerCase()
27
+ .replace(/[^a-z0-9_]+/g, '_')
28
+ .replace(/_+/g, '_')
29
+ .replace(/^_+|_+$/g, '');
30
+ return `dm_${normalized || 'dungeon'}`;
31
+ }
32
+
33
+ export function validateDatasetName(dataset) {
34
+ const text = String(dataset || '');
35
+ if (!DATASET_IDENTIFIER_PATTERN.test(text)) {
36
+ throw new Error(`dataset must be a valid BigQuery identifier using letters, numbers, and underscores: ${text || '<empty>'}`);
37
+ }
38
+ return text;
39
+ }
40
+
41
+ export function buildSchemaString(columns) {
42
+ return columns.map((column) => `${column.name}:${column.bqType}`).join(',');
43
+ }
44
+
45
+ export function buildBigQueryLoadArgs({ projectId = BQ_PROJECT, dataset, table, filePath }) {
46
+ const args = [
47
+ `--project_id=${projectId}`,
48
+ 'load',
49
+ '--replace',
50
+ ];
51
+ if (table.format === 'json') {
52
+ args.push('--source_format=NEWLINE_DELIMITED_JSON');
53
+ } else {
54
+ args.push('--source_format=CSV', '--skip_leading_rows=1');
55
+ }
56
+ args.push(`${dataset}.${table.table}`, filePath, buildSchemaString(table.columns || []));
57
+ return args;
58
+ }
59
+
60
+ export function replaceDatasetPlaceholder(sql, dataset) {
61
+ return String(sql).replaceAll('{{DATASET}}', dataset);
62
+ }
63
+
64
+ export function qualifyDataset(dataset) {
65
+ return `${BQ_PROJECT}.${dataset}`;
66
+ }
67
+
68
+ export function buildWarehouseMetricSql(sql, dataset) {
69
+ return replaceDatasetPlaceholder(sql, qualifyDataset(dataset));
70
+ }
71
+
72
+ export function mapAggregation(aggregation) {
73
+ if (aggregation === 'last value') return 'last_value';
74
+ if (aggregation === 'average') return 'average';
75
+ return String(aggregation || 'none').replace(/\s+/g, '_');
76
+ }
77
+
78
+ export function buildCreateMetricPayload({ projectId, sourceId, dataset, table }) {
79
+ return {
80
+ project_id: String(projectId),
81
+ source_id: sourceId,
82
+ name: table.table,
83
+ metric_type: 'timeseries',
84
+ sql: buildWarehouseMetricSql(table.sql, dataset),
85
+ value_column: table.valueColumn,
86
+ time_column: table.timeColumn,
87
+ aggregation: mapAggregation(table.recommendedAggregation),
88
+ refresh: table.refreshHint,
89
+ };
90
+ }
91
+
92
+ export function buildPreviewPayload({ projectId, sourceId, dataset, table }) {
93
+ const sql = buildWarehouseMetricSql(table.sql, dataset);
94
+ const blocked = findPreviewBlockedToken(sql);
95
+ if (blocked) {
96
+ throw new Error(
97
+ `previewWarehouseMetric cannot run for table "${table.table}": query contains identifier "${blocked.column}" which trips the raw ${blocked.token} substring block. Rename the time column or use a manual saved-metric flow. Aliasing only works if the blocked text does not appear anywhere in the query.`,
98
+ );
99
+ }
100
+ return {
101
+ project_id: String(projectId),
102
+ source_id: sourceId,
103
+ sql,
104
+ };
105
+ }
106
+
107
+ export function findPreviewBlockedToken(sql) {
108
+ const text = String(sql || '');
109
+ const lower = text.toLowerCase();
110
+ const matches = lower.match(/\b[a-z0-9_]+\b/g) || [];
111
+ for (const identifier of matches) {
112
+ for (const token of PREVIEW_BLOCKLIST) {
113
+ if (identifier.includes(token.toLowerCase())) {
114
+ return { token, column: identifier };
115
+ }
116
+ }
117
+ }
118
+ return null;
119
+ }
120
+
121
+ export function extractSourceId(response) {
122
+ const candidates = [
123
+ response?.result?.source_id,
124
+ response?.summary?.source_id,
125
+ ...(Array.isArray(response?.result?.steps)
126
+ ? response.result.steps.map((step) => step?.source_id)
127
+ : []),
128
+ ];
129
+ for (const candidate of candidates) {
130
+ if (candidate === undefined || candidate === null || `${candidate}` === '') continue;
131
+ if (typeof candidate === 'number') return candidate;
132
+ if (/^\d+$/.test(String(candidate))) return Number(candidate);
133
+ return String(candidate);
134
+ }
135
+ throw new Error('setup-bq-warehouse response did not include source_id in result.source_id, summary.source_id, or result.steps[].source_id');
136
+ }
137
+
138
+ export function parseWarehouseMetricListResponse(response) {
139
+ if (Array.isArray(response)) return response;
140
+ if (Array.isArray(response?.results)) return response.results;
141
+ if (!response || typeof response !== 'object') {
142
+ throw new Error(`getWarehouseMetrics returned an unexpected shape: ${JSON.stringify(response, null, 2)}`);
143
+ }
144
+ if (response.error) {
145
+ throw new Error(`getWarehouseMetrics returned an error payload: ${JSON.stringify(response, null, 2)}`);
146
+ }
147
+ const entries = Object.entries(response);
148
+ const numericEntries = entries.filter(([key]) => /^\d+$/.test(key));
149
+ const nonNumericKeys = entries
150
+ .map(([key]) => key)
151
+ .filter((key) => !/^\d+$/.test(key));
152
+ const unexpectedKeys = nonNumericKeys.filter((key) => !TIMING_ONLY_KEYS.has(key));
153
+ if (numericEntries.length > 0) {
154
+ if (unexpectedKeys.length > 0) {
155
+ throw new Error(`getWarehouseMetrics returned an unexpected shape: ${JSON.stringify(response, null, 2)}`);
156
+ }
157
+ return numericEntries
158
+ .sort((left, right) => Number(left[0]) - Number(right[0]))
159
+ .map(([, value]) => {
160
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
161
+ throw new Error(`getWarehouseMetrics returned a malformed metric row: ${JSON.stringify(response, null, 2)}`);
162
+ }
163
+ return value;
164
+ });
165
+ }
166
+ if (entries.length > 0 && unexpectedKeys.length === 0) return [];
167
+ throw new Error(`getWarehouseMetrics returned an unexpected shape: ${JSON.stringify(response, null, 2)}`);
168
+ }
169
+
170
+ export function resolveMetricActions({ tables, existingMetrics, dataset, projectId, sourceId }) {
171
+ const existingNames = new Set((existingMetrics || []).map((metric) => metric?.name).filter(Boolean));
172
+ return tables.map((table) => {
173
+ if (existingNames.has(table.table)) {
174
+ return {
175
+ table: table.table,
176
+ action: 'skip-existing',
177
+ reason: 'metric already exists',
178
+ };
179
+ }
180
+ return {
181
+ table: table.table,
182
+ action: 'preview-and-create',
183
+ previewPayload: buildPreviewPayload({ projectId, sourceId, dataset, table }),
184
+ createPayload: buildCreateMetricPayload({ projectId, sourceId, dataset, table }),
185
+ };
186
+ });
187
+ }
188
+
189
+ export function renderCommand(file, args = []) {
190
+ return [file, ...args].map(shellEscape).join(' ');
191
+ }
192
+
193
+ function shellEscape(value) {
194
+ const text = String(value);
195
+ if (/^[A-Za-z0-9_./:=@{}\-]+$/.test(text)) return text;
196
+ return `'${text.replace(/'/g, `'\\''`)}'`;
197
+ }
198
+
199
+ export function parseArgs(argv) {
200
+ const args = [...argv];
201
+ const flags = {
202
+ dryRun: pullFlag(args, '--dry-run'),
203
+ dataset: pullOption(args, '--dataset'),
204
+ dataPrefix: pullOption(args, '--data-prefix'),
205
+ };
206
+ const unknownOption = args.find((arg) => arg.startsWith('--'));
207
+ if (unknownOption) {
208
+ throw new Error(`unknown option: ${unknownOption}`);
209
+ }
210
+ const positional = args.filter((arg) => !arg.startsWith('--'));
211
+ const [dungeonArg, extraArg] = positional;
212
+ if (!dungeonArg) {
213
+ throw new Error('Usage: node .claude/skills/warehouse-metrics/deploy.mjs <dungeon-path> [--dataset dm_name] [--data-prefix path/prefix] [--dry-run]');
214
+ }
215
+ if (extraArg) {
216
+ throw new Error(`unexpected extra argument: ${extraArg}`);
217
+ }
218
+ if (flags.dataset !== undefined) validateDatasetName(flags.dataset);
219
+ return { dungeonArg, ...flags };
220
+ }
221
+
222
+ function pullFlag(args, name) {
223
+ const index = args.indexOf(name);
224
+ if (index === -1) return false;
225
+ args.splice(index, 1);
226
+ return true;
227
+ }
228
+
229
+ function pullOption(args, name) {
230
+ const index = args.indexOf(name);
231
+ if (index === -1) return undefined;
232
+ const value = args[index + 1];
233
+ if (value === undefined || value.startsWith('--')) {
234
+ throw new Error(`${name} requires a value`);
235
+ }
236
+ args.splice(index, 2);
237
+ return value;
238
+ }
239
+
240
+ function ensureFile(filePath, label) {
241
+ if (!fs.existsSync(filePath)) throw new Error(`${label} not found: ${filePath}`);
242
+ }
243
+
244
+ export function resolveProjectId(config) {
245
+ const projectId = config?.projectId
246
+ ?? config?.project_id
247
+ ?? config?.credentials?.projectId
248
+ ?? config?.credentials?.project_id;
249
+ if (!projectId) throw new Error('dungeon credentials.projectId is required. Run /create-project first or add credentials.projectId to the dungeon.');
250
+ return String(projectId);
251
+ }
252
+
253
+ function resolveWarehouseDir(dungeonPath) {
254
+ return path.join(path.dirname(dungeonPath), 'warehouse');
255
+ }
256
+
257
+ function resolveArtifactSet({ dungeonPath, configName, dataPrefix }) {
258
+ if (dataPrefix) {
259
+ return buildArtifactSet(resolve(process.cwd(), dataPrefix));
260
+ }
261
+
262
+ const dataDir = path.join(REPO_ROOT, 'data');
263
+ if (!fs.existsSync(dataDir)) {
264
+ throw new Error(`no data directory found at ${dataDir}. Run node scripts/run-dungeon.mjs ${path.relative(REPO_ROOT, dungeonPath)} first.`);
265
+ }
266
+ const manifestSuffix = '-WAREHOUSE-MANIFEST.json';
267
+ const candidates = fs.readdirSync(dataDir)
268
+ .filter((name) => name.startsWith(`${configName}-`) && name.endsWith(manifestSuffix))
269
+ .map((name) => ({
270
+ prefix: path.join(dataDir, name.slice(0, -manifestSuffix.length)),
271
+ mtimeMs: fs.statSync(path.join(dataDir, name)).mtimeMs,
272
+ }))
273
+ .sort((left, right) => right.mtimeMs - left.mtimeMs);
274
+ if (!candidates.length) {
275
+ throw new Error(`no warehouse manifest found for ${configName} under ${dataDir}. Run node scripts/run-dungeon.mjs ${path.relative(REPO_ROOT, dungeonPath)} first, or pass --data-prefix.`);
276
+ }
277
+ return buildArtifactSet(candidates[0].prefix);
278
+ }
279
+
280
+ function buildArtifactSet(prefixPath) {
281
+ const manifestPath = `${prefixPath}-WAREHOUSE-MANIFEST.json`;
282
+ ensureFile(manifestPath, 'warehouse manifest');
283
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
284
+ const tables = (manifest.tables || []).map((table) => {
285
+ const ext = table.format === 'json' ? 'json' : 'csv';
286
+ const filePath = `${prefixPath}-WAREHOUSE-${table.table}.${ext}`;
287
+ ensureFile(filePath, `warehouse table ${table.table}`);
288
+ return { ...table, filePath };
289
+ });
290
+ return { prefixPath, manifestPath, manifest, tables };
291
+ }
292
+
293
+ function writeSqlFiles({ warehouseDir, tables, dataset }) {
294
+ fs.mkdirSync(warehouseDir, { recursive: true });
295
+ const sqlFiles = [];
296
+ for (const table of tables) {
297
+ const sqlPath = path.join(warehouseDir, `${table.table}.sql`);
298
+ fs.writeFileSync(sqlPath, `${buildWarehouseMetricSql(table.sql, dataset)}\n`);
299
+ sqlFiles.push(sqlPath);
300
+ }
301
+ return sqlFiles;
302
+ }
303
+
304
+ function renderGaps({ dungeonPath, warehouseDir, dataset, sourceId, tables, note }) {
305
+ fs.mkdirSync(warehouseDir, { recursive: true });
306
+ const template = fs.readFileSync(TEMPLATE_PATH, 'utf8');
307
+ const checklist = tables.map((table) => [
308
+ `## ${table.table}`,
309
+ '',
310
+ `- source connected? ${sourceId ? `yes (${sourceId})` : 'pending'}`,
311
+ `- sql pasted? ${path.join(warehouseDir, `${table.table}.sql`)}`,
312
+ `- aggregation set per recommendedAggregation? ${table.recommendedAggregation}`,
313
+ `- refresh set per refreshHint? ${table.refreshHint}`,
314
+ `- value_column set? ${table.valueColumn}`,
315
+ `- time_column set? ${table.timeColumn}`,
316
+ ].join('\n')).join('\n\n');
317
+ const content = template
318
+ .replaceAll('{{DUNGEON_PATH}}', dungeonPath)
319
+ .replaceAll('{{WAREHOUSE_DIR}}', warehouseDir)
320
+ .replaceAll('{{DATASET}}', dataset)
321
+ .replaceAll('{{SOURCE_ID}}', sourceId ? String(sourceId) : 'pending')
322
+ .replaceAll('{{NOTE}}', note || 'warehouse metric CRUD endpoint unavailable at deploy time')
323
+ .replaceAll('{{TABLE_CHECKLIST}}', checklist);
324
+ const gapsPath = path.join(warehouseDir, 'GAPS.md');
325
+ if (fs.existsSync(gapsPath)) {
326
+ console.error(`warning: overwriting existing ${gapsPath}`);
327
+ }
328
+ fs.writeFileSync(gapsPath, content);
329
+ return gapsPath;
330
+ }
331
+
332
+ function commandPlan({ projectId, dataset, tables, sourceId = '<source_id>' }) {
333
+ const listBody = { project_id: String(projectId) };
334
+ const mkArgs = [`--project_id=${BQ_PROJECT}`, 'mk', '--dataset', `--location=${BQ_LOCATION}`, dataset];
335
+ const commands = [{ label: 'preflight:ls', command: `bq --project_id=${BQ_PROJECT} ls --max_results=1` }];
336
+ commands.push({
337
+ label: 'docs:getWarehouseMetrics',
338
+ command: `node .claude/skills/powertools/pt.mjs /crud/getWarehouseMetrics --get`,
339
+ });
340
+ commands.push({
341
+ label: 'list:getWarehouseMetrics',
342
+ command: `node .claude/skills/powertools/pt.mjs /crud/getWarehouseMetrics ${shellEscape(JSON.stringify(listBody))}`,
343
+ });
344
+ commands.push({ label: 'dataset', command: `bq ${mkArgs.join(' ')}` });
345
+ for (const table of tables) {
346
+ commands.push({
347
+ label: `load:${table.table}`,
348
+ command: `bq ${buildBigQueryLoadArgs({ projectId: BQ_PROJECT, dataset, table, filePath: table.filePath }).join(' ')}`,
349
+ });
350
+ }
351
+ commands.push({
352
+ label: 'source:setup-bq-warehouse',
353
+ command: `node .claude/skills/powertools/pt.mjs /macro/setup-bq-warehouse ${shellEscape(JSON.stringify({ project_id: String(projectId), dataset, mirror: false }))}`,
354
+ });
355
+ for (const table of tables) {
356
+ commands.push({
357
+ label: `preview:${table.table}`,
358
+ command: `node .claude/skills/powertools/pt.mjs /crud/previewWarehouseMetric ${shellEscape(JSON.stringify({ project_id: String(projectId), source_id: sourceId, sql: buildWarehouseMetricSql(table.sql, dataset) }))}`,
359
+ });
360
+ commands.push({
361
+ label: `create:${table.table}`,
362
+ command: `node .claude/skills/powertools/pt.mjs /crud/createWarehouseMetric ${shellEscape(JSON.stringify(buildCreateMetricPayload({ projectId, sourceId, dataset, table })) )}`,
363
+ });
364
+ }
365
+ return commands;
366
+ }
367
+
368
+ function printDryRunSummary({ projectId, dataset, artifactSet, sqlFiles, gapsPath }) {
369
+ console.log('DRY RUN');
370
+ console.log(`dungeon manifest: ${artifactSet.manifestPath}`);
371
+ console.log(`project_id: ${projectId}`);
372
+ console.log(`dataset: ${dataset}`);
373
+ console.log('');
374
+ console.log('planned commands');
375
+ for (const entry of commandPlan({ projectId, dataset, tables: artifactSet.tables })) {
376
+ console.log(`- ${entry.command}`);
377
+ }
378
+ console.log('');
379
+ console.log('artifacts');
380
+ for (const sqlFile of sqlFiles) console.log(`- sql: ${sqlFile}`);
381
+ console.log(`- manual fallback: ${gapsPath}`);
382
+ console.log('');
383
+ console.log('notes');
384
+ console.log('- no commands were executed');
385
+ console.log('- no bearer token or gcloud credentials were required');
386
+ console.log('- preview/create steps are shown as the live plan; manual fallback was rendered for review');
387
+ }
388
+
389
+ function runCommand(file, args, options = {}) {
390
+ const result = spawnSync(file, args, {
391
+ cwd: REPO_ROOT,
392
+ encoding: 'utf-8',
393
+ env: process.env,
394
+ ...options,
395
+ });
396
+ return {
397
+ status: result.status ?? 1,
398
+ stdout: result.stdout || '',
399
+ stderr: result.stderr || '',
400
+ error: result.error || null,
401
+ result,
402
+ };
403
+ }
404
+
405
+ function formatSpawnError(file, exec) {
406
+ if (!exec?.error) return null;
407
+ return `failed to spawn ${file}: ${exec.error.message}`;
408
+ }
409
+
410
+ function parseJsonOutput(output, label) {
411
+ try {
412
+ return JSON.parse(output);
413
+ } catch (error) {
414
+ throw new Error(`${label} returned non-JSON output:\n${output || error.message}`);
415
+ }
416
+ }
417
+
418
+ function runPt(pathname, body, { get = false } = {}) {
419
+ const args = [PT_PATH, pathname];
420
+ if (get) {
421
+ args.push('--get');
422
+ } else {
423
+ args.push(JSON.stringify(body || {}));
424
+ }
425
+ const exec = runCommand(process.execPath, args);
426
+ const spawnError = formatSpawnError(process.execPath, exec);
427
+ if (spawnError) {
428
+ throw new Error(`${spawnError}\n${exec.stdout}${exec.stderr}`.trim());
429
+ }
430
+ if (exec.status !== 0) {
431
+ throw new Error(`pt.mjs ${pathname} failed:\n${exec.stdout}${exec.stderr}`.trim());
432
+ }
433
+ return parseJsonOutput(exec.stdout, `pt.mjs ${pathname}`);
434
+ }
435
+
436
+ function probeWarehouseMetricDocs() {
437
+ const exec = runCommand(process.execPath, [PT_PATH, '/crud/getWarehouseMetrics', '--get']);
438
+ const spawnError = formatSpawnError(process.execPath, exec);
439
+ if (spawnError) {
440
+ throw new Error(`${spawnError}\n${exec.stdout}${exec.stderr}`.trim());
441
+ }
442
+ if (exec.status === 0) return { available: true, docs: parseJsonOutput(exec.stdout, 'getWarehouseMetrics docs') };
443
+ const combined = `${exec.stdout}\n${exec.stderr}`;
444
+ if (/\b404\b/.test(combined)) return { available: false, reason: combined.trim() };
445
+ throw new Error(`warehouse metric docs probe failed:\n${combined}`.trim());
446
+ }
447
+
448
+ function listWarehouseMetrics(projectId) {
449
+ const response = runPt('/crud/getWarehouseMetrics', { project_id: String(projectId) });
450
+ return parseWarehouseMetricListResponse(response);
451
+ }
452
+
453
+ function runBigQueryLs() {
454
+ const exec = runCommand('bq', [`--project_id=${BQ_PROJECT}`, 'ls', '--max_results=1']);
455
+ const spawnError = formatSpawnError('bq', exec);
456
+ if (spawnError) {
457
+ throw new Error(`${spawnError}\n${exec.stdout}${exec.stderr}`.trim());
458
+ }
459
+ if (exec.status !== 0) {
460
+ throw new Error(`bq ls preflight failed:\n${exec.stdout}${exec.stderr}`.trim());
461
+ }
462
+ }
463
+
464
+ function runBigQueryMk(dataset) {
465
+ const exec = runCommand('bq', [`--project_id=${BQ_PROJECT}`, 'mk', '--dataset', `--location=${BQ_LOCATION}`, dataset]);
466
+ const spawnError = formatSpawnError('bq', exec);
467
+ if (spawnError) {
468
+ throw new Error(`${spawnError}\n${exec.stdout}${exec.stderr}`.trim());
469
+ }
470
+ if (exec.status === 0) return;
471
+ const combined = `${exec.stdout}\n${exec.stderr}`;
472
+ if (/already exists/i.test(combined)) return;
473
+ throw new Error(`bq mk failed:\n${combined}`.trim());
474
+ }
475
+
476
+ function runBigQueryLoad(dataset, table) {
477
+ const exec = runCommand('bq', buildBigQueryLoadArgs({ projectId: BQ_PROJECT, dataset, table, filePath: table.filePath }));
478
+ const spawnError = formatSpawnError('bq', exec);
479
+ if (spawnError) {
480
+ throw new Error(`${spawnError}\n${exec.stdout}${exec.stderr}`.trim());
481
+ }
482
+ if (exec.status !== 0) {
483
+ throw new Error(`bq load failed for ${table.table}:\n${exec.stdout}${exec.stderr}`.trim());
484
+ }
485
+ }
486
+
487
+ function setupWarehouseSource(projectId, dataset) {
488
+ return runPt('/macro/setup-bq-warehouse', {
489
+ project_id: String(projectId),
490
+ dataset,
491
+ mirror: false,
492
+ });
493
+ }
494
+
495
+ function previewWarehouseMetric(payload) {
496
+ return runPt('/crud/previewWarehouseMetric', payload);
497
+ }
498
+
499
+ function createWarehouseMetric(payload) {
500
+ return runPt('/crud/createWarehouseMetric', payload);
501
+ }
502
+
503
+ export function runLiveDeploy({ projectId, dataset, tables, dungeonPath, warehouseDir, sqlFiles }, overrides = {}) {
504
+ const staleGapsPath = overrides.staleGapsPath !== undefined
505
+ ? overrides.staleGapsPath
506
+ : (fs.existsSync(path.join(warehouseDir, 'GAPS.md')) ? path.join(warehouseDir, 'GAPS.md') : null);
507
+ const deps = {
508
+ runBigQueryLs,
509
+ probeWarehouseMetricDocs,
510
+ listWarehouseMetrics,
511
+ runBigQueryMk,
512
+ runBigQueryLoad,
513
+ setupWarehouseSource,
514
+ previewWarehouseMetric,
515
+ createWarehouseMetric,
516
+ renderGaps,
517
+ extractSourceId,
518
+ resolveMetricActions,
519
+ ...overrides,
520
+ };
521
+
522
+ deps.runBigQueryLs(dataset);
523
+ const docsProbe = deps.probeWarehouseMetricDocs();
524
+ const existingMetrics = docsProbe.available ? deps.listWarehouseMetrics(projectId) : [];
525
+
526
+ deps.runBigQueryMk(dataset);
527
+ for (const table of tables) deps.runBigQueryLoad(dataset, table);
528
+
529
+ const sourceResponse = deps.setupWarehouseSource(projectId, dataset);
530
+ const sourceId = deps.extractSourceId(sourceResponse);
531
+
532
+ if (!docsProbe.available) {
533
+ const gapsPath = deps.renderGaps({
534
+ dungeonPath,
535
+ warehouseDir,
536
+ dataset,
537
+ sourceId,
538
+ tables,
539
+ note: `docs probe returned 404 for /crud/getWarehouseMetrics. ${docsProbe.reason}`,
540
+ });
541
+ return {
542
+ dataset,
543
+ sourceId,
544
+ loaded: tables.map((table) => table.table),
545
+ saved: [],
546
+ skipped: [],
547
+ sqlFiles,
548
+ gapsPath,
549
+ staleGapsPath: null,
550
+ };
551
+ }
552
+
553
+ const actions = deps.resolveMetricActions({
554
+ tables,
555
+ existingMetrics,
556
+ dataset,
557
+ projectId,
558
+ sourceId,
559
+ });
560
+ const saved = [];
561
+ const skipped = [];
562
+ for (const action of actions) {
563
+ if (action.action === 'skip-existing') {
564
+ skipped.push(action.table);
565
+ continue;
566
+ }
567
+ deps.previewWarehouseMetric(action.previewPayload);
568
+ deps.createWarehouseMetric(action.createPayload);
569
+ saved.push(action.table);
570
+ }
571
+
572
+ return {
573
+ dataset,
574
+ sourceId,
575
+ loaded: tables.map((table) => table.table),
576
+ saved,
577
+ skipped,
578
+ sqlFiles,
579
+ gapsPath: null,
580
+ staleGapsPath,
581
+ };
582
+ }
583
+
584
+ function printLiveSummary(summary) {
585
+ console.log('deploy summary');
586
+ console.log(`dataset: ${summary.dataset}`);
587
+ console.log(`source_id: ${summary.sourceId}`);
588
+ for (const table of summary.loaded) console.log(`- loaded: ${table}`);
589
+ for (const table of summary.saved) console.log(`- metric saved: ${table}`);
590
+ for (const table of summary.skipped) console.log(`- metric skipped: ${table}`);
591
+ if (summary.gapsPath) console.log(`- manual fallback: ${summary.gapsPath}`);
592
+ for (const sqlFile of summary.sqlFiles) console.log(`- sql: ${sqlFile}`);
593
+ if (summary.staleGapsPath) console.log(`- existing gaps left in place: ${summary.staleGapsPath}`);
594
+ }
595
+
596
+ async function main() {
597
+ const { dungeonArg, dryRun, dataset: datasetOverride, dataPrefix } = parseArgs(process.argv.slice(2));
598
+ const dungeonPath = resolve(process.cwd(), dungeonArg);
599
+ ensureFile(dungeonPath, 'dungeon file');
600
+ const config = await loadFromFile(dungeonPath);
601
+ if (!Array.isArray(config.warehouseMetrics) || config.warehouseMetrics.length === 0) {
602
+ throw new Error('dungeon does not declare warehouseMetrics');
603
+ }
604
+ const artifactSet = resolveArtifactSet({ dungeonPath, configName: config.name, dataPrefix });
605
+ const projectId = dryRun
606
+ ? String(
607
+ config?.projectId
608
+ ?? config?.project_id
609
+ ?? config?.credentials?.projectId
610
+ ?? config?.credentials?.project_id
611
+ ?? DRY_RUN_PROJECT_ID,
612
+ )
613
+ : resolveProjectId(config);
614
+ const dataset = validateDatasetName(datasetOverride || normalizeDatasetName(config.name));
615
+ const warehouseDir = resolveWarehouseDir(dungeonPath);
616
+ const sqlFiles = writeSqlFiles({ warehouseDir, tables: artifactSet.tables, dataset });
617
+
618
+ if (dryRun) {
619
+ const gapsPath = renderGaps({
620
+ dungeonPath,
621
+ warehouseDir,
622
+ dataset,
623
+ sourceId: null,
624
+ tables: artifactSet.tables,
625
+ note: 'dry-run rendered the manual fallback plan without probing live endpoints',
626
+ });
627
+ printDryRunSummary({ projectId, dataset, artifactSet, sqlFiles, gapsPath });
628
+ return;
629
+ }
630
+
631
+ if (!process.env.BEARER_TOKEN) {
632
+ throw new Error('BEARER_TOKEN missing from .env. Live deploy requires powertools auth.');
633
+ }
634
+ const summary = runLiveDeploy({
635
+ projectId,
636
+ dataset,
637
+ tables: artifactSet.tables,
638
+ dungeonPath,
639
+ warehouseDir,
640
+ sqlFiles,
641
+ });
642
+ printLiveSummary(summary);
643
+ }
644
+
645
+ const isEntrypoint = process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
646
+ if (isEntrypoint) {
647
+ main().catch((error) => {
648
+ console.error(error.message);
649
+ process.exit(1);
650
+ });
651
+ }