@ak--47/dungeon-master 1.5.3 → 1.6.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/.claude/skills/analyze-soup/SKILL.md +21 -12
- package/.claude/skills/create-dungeon/SKILL.md +65 -5
- package/.claude/skills/create-project/SKILL.md +96 -0
- package/.claude/skills/create-project/provision.mjs +336 -0
- package/.claude/skills/verify-dungeon/SKILL.md +56 -25
- package/.claude/skills/verify-dungeon/references/counting-semantics.md +26 -6
- package/.claude/skills/verify-dungeon/references/report-format.md +14 -8
- package/.claude/skills/write-hooks/SKILL.md +156 -18
- package/CHANGELOG.md +211 -0
- package/HOOKS.md +566 -112
- package/README.md +28 -9
- package/docs/guides/1.3.0-upgrade-guide.md +262 -0
- package/docs/guides/1.3.1-upgrade-guide.md +328 -0
- package/docs/guides/1.4.0-upgrade-guide.md +130 -0
- package/docs/guides/1.4.1-upgrade-guide.md +125 -0
- package/docs/guides/1.4.3-upgrade-guide.md +200 -0
- package/docs/guides/1.4.4-upgrade-guide.md +95 -0
- package/docs/guides/1.5.0-upgrade-guide.md +426 -0
- package/docs/guides/1.5.1-upgrade-guide.md +178 -0
- package/docs/guides/1.5.3-upgrade-guide.md +117 -0
- package/docs/guides/1.6.0-upgrade-guide.md +204 -0
- package/dungeons/technical/stories-verify.js +152 -0
- package/dungeons/vertical/README.md +154 -0
- package/dungeons/vertical/ai-platform/ai-platform.js +1424 -0
- package/dungeons/vertical/ai-platform/ai-platform.sql +184 -0
- package/dungeons/vertical/ai-platform/ai-platform.verify.mjs +71 -0
- package/dungeons/vertical/community/community.js +1589 -0
- package/dungeons/vertical/community/community.sql +216 -0
- package/dungeons/vertical/community/community.verify.mjs +72 -0
- package/dungeons/vertical/crypto/crypto.js +1483 -0
- package/dungeons/vertical/crypto/crypto.sql +194 -0
- package/dungeons/vertical/crypto/crypto.verify.mjs +78 -0
- package/dungeons/vertical/dating/dating.js +1540 -0
- package/dungeons/vertical/dating/dating.sql +288 -0
- package/dungeons/vertical/dating/dating.verify.mjs +72 -0
- package/dungeons/vertical/devtools/devtools.js +1524 -0
- package/dungeons/vertical/devtools/devtools.sql +161 -0
- package/dungeons/vertical/devtools/devtools.verify.mjs +73 -0
- package/dungeons/vertical/ecommerce/ecommerce.js +1480 -0
- package/dungeons/vertical/ecommerce/ecommerce.sql +218 -0
- package/dungeons/vertical/ecommerce/ecommerce.verify.mjs +70 -0
- package/dungeons/vertical/education/education.js +1763 -0
- package/dungeons/vertical/education/education.sql +243 -0
- package/dungeons/vertical/education/education.verify.mjs +75 -0
- package/dungeons/vertical/fintech/fintech.js +1410 -0
- package/dungeons/vertical/fintech/fintech.sql +175 -0
- package/dungeons/vertical/fintech/fintech.verify.mjs +70 -0
- package/dungeons/vertical/fitness/fitness.js +1495 -0
- package/dungeons/vertical/fitness/fitness.sql +206 -0
- package/dungeons/vertical/fitness/fitness.verify.mjs +71 -0
- package/dungeons/vertical/food-delivery/food-delivery.js +1641 -0
- package/dungeons/vertical/food-delivery/food-delivery.sql +183 -0
- package/dungeons/vertical/food-delivery/food-delivery.verify.mjs +75 -0
- package/dungeons/vertical/gaming/gaming.js +1741 -0
- package/dungeons/vertical/gaming/gaming.sql +249 -0
- package/dungeons/vertical/gaming/gaming.verify.mjs +70 -0
- package/dungeons/vertical/healthcare/healthcare.js +1488 -0
- package/dungeons/vertical/healthcare/healthcare.sql +193 -0
- package/dungeons/vertical/healthcare/healthcare.verify.mjs +72 -0
- package/dungeons/vertical/insurance-application/insurance-application.js +1852 -0
- package/dungeons/vertical/insurance-application/insurance-application.sql +174 -0
- package/dungeons/vertical/insurance-application/insurance-application.verify.mjs +74 -0
- package/dungeons/vertical/logistics/logistics.js +1541 -0
- package/dungeons/vertical/logistics/logistics.sql +205 -0
- package/dungeons/vertical/logistics/logistics.verify.mjs +73 -0
- package/dungeons/vertical/marketplace/marketplace.js +1438 -0
- package/dungeons/vertical/marketplace/marketplace.sql +148 -0
- package/dungeons/vertical/marketplace/marketplace.verify.mjs +74 -0
- package/dungeons/vertical/{media.js → media/media.js} +557 -58
- package/dungeons/vertical/media/media.sql +173 -0
- package/dungeons/vertical/media/media.verify.mjs +74 -0
- package/dungeons/vertical/real-estate/real-estate.js +1629 -0
- package/dungeons/vertical/real-estate/real-estate.sql +167 -0
- package/dungeons/vertical/real-estate/real-estate.verify.mjs +75 -0
- package/dungeons/vertical/sass/sass.js +1733 -0
- package/dungeons/vertical/sass/sass.sql +173 -0
- package/dungeons/vertical/sass/sass.verify.mjs +74 -0
- package/dungeons/vertical/social/social.js +1456 -0
- package/dungeons/vertical/social/social.sql +160 -0
- package/dungeons/vertical/social/social.verify.mjs +76 -0
- package/dungeons/vertical/streaming/streaming.js +677 -0
- package/dungeons/vertical/streaming/streaming.sql +101 -0
- package/dungeons/vertical/streaming/streaming.verify.mjs +75 -0
- package/dungeons/vertical/support-desk/support-desk.js +741 -0
- package/dungeons/vertical/support-desk/support-desk.sql +137 -0
- package/dungeons/vertical/support-desk/support-desk.verify.mjs +75 -0
- package/dungeons/vertical/travel/travel.js +1603 -0
- package/dungeons/vertical/travel/travel.sql +236 -0
- package/dungeons/vertical/travel/travel.verify.mjs +72 -0
- package/index.js +2 -2
- package/lib/core/config-validator.js +37 -2
- package/lib/core/context.js +4 -2
- package/lib/core/dungeon-loader.js +20 -15
- package/lib/core/dungeon-to-json.js +22 -4
- package/lib/core/storage.js +3 -3
- package/lib/generators/funnels.js +36 -9
- package/lib/hook-helpers/cohort.js +53 -0
- package/lib/hook-helpers/index.js +8 -0
- package/lib/hook-helpers/mutate.js +1 -1
- package/lib/hook-helpers/shape.js +319 -0
- package/lib/hook-patterns/aggregate-per-user-by-bin.js +13 -5
- package/lib/hook-patterns/attributed-by-source.js +60 -58
- package/lib/hook-patterns/frequency-by-frequency.js +14 -4
- package/lib/hook-patterns/funnel-frequency-breakdown.js +24 -12
- package/lib/hook-patterns/index.js +1 -1
- package/lib/hook-patterns/time-to-convert-by-segment.js +75 -13
- package/lib/orchestrators/user-loop.js +41 -0
- package/lib/templates/story-spec.schema.json +175 -0
- package/lib/utils/json-evaluator.js +1 -1
- package/lib/utils/utils.js +5 -4
- package/lib/verify/coerce.js +186 -0
- package/lib/verify/counting.js +270 -32
- package/lib/verify/emulate-breakdown.js +1285 -203
- package/lib/verify/first-time.js +89 -0
- package/lib/verify/flows.js +679 -0
- package/lib/verify/formula.js +259 -0
- package/lib/verify/funnel-engine.js +697 -113
- package/lib/verify/index.js +32 -4
- package/lib/verify/sessionize.js +275 -0
- package/lib/verify/story-runner.js +483 -0
- package/lib/verify/verify-dungeon.js +59 -37
- package/package.json +3 -2
- package/scripts/extract-dungeon-schema.mjs +2 -2
- package/scripts/run-many.mjs +2 -2
- package/scripts/verify-runner.mjs +2 -2
- package/scripts/verify-stories.mjs +244 -0
- package/types.d.ts +296 -30
- package/dungeons/vertical/ai-platform.js +0 -818
- package/dungeons/vertical/community.js +0 -966
- package/dungeons/vertical/crypto.js +0 -895
- package/dungeons/vertical/dating.js +0 -830
- package/dungeons/vertical/devtools.js +0 -1050
- package/dungeons/vertical/ecommerce.js +0 -811
- package/dungeons/vertical/education.js +0 -964
- package/dungeons/vertical/fintech.js +0 -946
- package/dungeons/vertical/fitness.js +0 -935
- package/dungeons/vertical/food-delivery.js +0 -934
- package/dungeons/vertical/gaming.js +0 -1120
- package/dungeons/vertical/healthcare.js +0 -945
- package/dungeons/vertical/insurance-application.js +0 -1039
- package/dungeons/vertical/logistics.js +0 -986
- package/dungeons/vertical/marketplace.js +0 -1019
- package/dungeons/vertical/real-estate.js +0 -872
- package/dungeons/vertical/sass.js +0 -995
- package/dungeons/vertical/social.js +0 -904
- package/dungeons/vertical/travel.js +0 -930
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: analyze-soup
|
|
3
3
|
description: Use when investigating TimeSoup parameters, diagnosing event-distribution shape, or comparing soup configs — runs a dungeon locally and analyzes time distribution at week/day/hour/minute granularities, producing a soup-analysis.md diagnostic report.
|
|
4
|
-
argument-hint: [dungeon path, e.g. dungeons/
|
|
4
|
+
argument-hint: [dungeon path, e.g. dungeons/technical/simplest.js]
|
|
5
5
|
model: claude-opus-4-6
|
|
6
6
|
effort: max
|
|
7
7
|
---
|
|
@@ -10,7 +10,7 @@ effort: max
|
|
|
10
10
|
|
|
11
11
|
Run a dungeon and analyze the time distribution of generated events to evaluate TimeSoup parameters.
|
|
12
12
|
|
|
13
|
-
**Dungeon file:** `$ARGUMENTS` (default: `dungeons/
|
|
13
|
+
**Dungeon file:** `$ARGUMENTS` (default: `dungeons/technical/simplest.js`)
|
|
14
14
|
|
|
15
15
|
## Step 1: Run the Dungeon
|
|
16
16
|
|
|
@@ -32,10 +32,19 @@ Wait for generation to complete. Note the event count and EPS.
|
|
|
32
32
|
|
|
33
33
|
Run these DuckDB queries against the generated JSONL file. Use `duckdb` CLI.
|
|
34
34
|
|
|
35
|
+
**All bucketing is UTC — do not convert timezones.** TimeSoup applies its
|
|
36
|
+
day-of-week / hour-of-day weights in UTC, the engine's calendar-day logic
|
|
37
|
+
(`avgActiveDaysPerUser`, lifecycle periods, session midnight splits) is UTC,
|
|
38
|
+
and `/create-project` pins Mixpanel projects to UTC. Casting `time::timestamp`
|
|
39
|
+
on the ISO strings keeps the UTC wall time. Converting to a local zone (e.g.
|
|
40
|
+
`America/Los_Angeles`, −8h) shifts late-evening UTC events onto the previous
|
|
41
|
+
calendar day — the DOW histogram you'd analyze would not be the one the soup
|
|
42
|
+
generated or the one Mixpanel reports.
|
|
43
|
+
|
|
35
44
|
### 2a. Week over Week
|
|
36
45
|
```bash
|
|
37
46
|
duckdb -c "
|
|
38
|
-
SELECT date_trunc('week',
|
|
47
|
+
SELECT date_trunc('week', time::timestamp) as week,
|
|
39
48
|
count(*) as events
|
|
40
49
|
FROM read_json_auto('./data/soup-analysis-EVENTS.json')
|
|
41
50
|
GROUP BY 1 ORDER BY 1;
|
|
@@ -45,7 +54,7 @@ GROUP BY 1 ORDER BY 1;
|
|
|
45
54
|
### 2b. Day over Day
|
|
46
55
|
```bash
|
|
47
56
|
duckdb -c "
|
|
48
|
-
SELECT date_trunc('day',
|
|
57
|
+
SELECT date_trunc('day', time::timestamp) as day,
|
|
49
58
|
count(*) as events
|
|
50
59
|
FROM read_json_auto('./data/soup-analysis-EVENTS.json')
|
|
51
60
|
GROUP BY 1 ORDER BY 1;
|
|
@@ -55,10 +64,10 @@ GROUP BY 1 ORDER BY 1;
|
|
|
55
64
|
### 2c. Hour over Hour (last 7 days only)
|
|
56
65
|
```bash
|
|
57
66
|
duckdb -c "
|
|
58
|
-
SELECT date_trunc('hour',
|
|
67
|
+
SELECT date_trunc('hour', time::timestamp) as hour,
|
|
59
68
|
count(*) as events
|
|
60
69
|
FROM read_json_auto('./data/soup-analysis-EVENTS.json')
|
|
61
|
-
WHERE
|
|
70
|
+
WHERE time::timestamp > (SELECT max(time::timestamp) - interval '7 days' FROM read_json_auto('./data/soup-analysis-EVENTS.json'))
|
|
62
71
|
GROUP BY 1 ORDER BY 1;
|
|
63
72
|
"
|
|
64
73
|
```
|
|
@@ -66,10 +75,10 @@ GROUP BY 1 ORDER BY 1;
|
|
|
66
75
|
### 2d. Minute over Minute (last 24 hours only)
|
|
67
76
|
```bash
|
|
68
77
|
duckdb -c "
|
|
69
|
-
SELECT date_trunc('minute',
|
|
78
|
+
SELECT date_trunc('minute', time::timestamp) as minute,
|
|
70
79
|
count(*) as events
|
|
71
80
|
FROM read_json_auto('./data/soup-analysis-EVENTS.json')
|
|
72
|
-
WHERE
|
|
81
|
+
WHERE time::timestamp > (SELECT max(time::timestamp) - interval '1 day' FROM read_json_auto('./data/soup-analysis-EVENTS.json'))
|
|
73
82
|
GROUP BY 1 ORDER BY 1;
|
|
74
83
|
"
|
|
75
84
|
```
|
|
@@ -78,14 +87,14 @@ GROUP BY 1 ORDER BY 1;
|
|
|
78
87
|
```bash
|
|
79
88
|
duckdb -c "
|
|
80
89
|
WITH daily AS (
|
|
81
|
-
SELECT date_trunc('day',
|
|
90
|
+
SELECT date_trunc('day', time::timestamp) as day, count(*) as events
|
|
82
91
|
FROM read_json_auto('./data/soup-analysis-EVENTS.json')
|
|
83
92
|
GROUP BY 1
|
|
84
93
|
),
|
|
85
94
|
hourly AS (
|
|
86
|
-
SELECT date_trunc('hour',
|
|
95
|
+
SELECT date_trunc('hour', time::timestamp) as hour, count(*) as events
|
|
87
96
|
FROM read_json_auto('./data/soup-analysis-EVENTS.json')
|
|
88
|
-
WHERE
|
|
97
|
+
WHERE time::timestamp > (SELECT max(time::timestamp) - interval '7 days' FROM read_json_auto('./data/soup-analysis-EVENTS.json'))
|
|
89
98
|
GROUP BY 1
|
|
90
99
|
)
|
|
91
100
|
SELECT 'daily' as granularity,
|
|
@@ -112,7 +121,7 @@ FROM hourly;
|
|
|
112
121
|
```bash
|
|
113
122
|
duckdb -c "
|
|
114
123
|
WITH daily AS (
|
|
115
|
-
SELECT date_trunc('day',
|
|
124
|
+
SELECT date_trunc('day', time::timestamp) as day, count(*) as events
|
|
116
125
|
FROM read_json_auto('./data/soup-analysis-EVENTS.json')
|
|
117
126
|
GROUP BY 1
|
|
118
127
|
)
|
|
@@ -51,7 +51,14 @@ Out of scope (hand off to `write-hooks`):
|
|
|
51
51
|
For an encyclopedia of hook patterns, recipes, and real-world examples:
|
|
52
52
|
see `HOOKS.md` at the project root.
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
> ⚠️ **Silently-ignored config keys — do not use.** The validator STRIPS these
|
|
55
|
+
> five keys before generation: `subscription`, `attribution`, `geo`,
|
|
56
|
+
> `features`, `anomalies`. They produce **zero data** — only a verbose-mode
|
|
57
|
+
> warning. A dungeon that "configures" churn via `subscription:` or campaign
|
|
58
|
+
> lift via `attribution:` is configuring nothing, and the omission is
|
|
59
|
+
> invisible at smoke-test scale. Recreate those behaviors with hooks via
|
|
60
|
+
> `/write-hooks` (churn cohorts, UTM biasing, geo weighting, feature-flag
|
|
61
|
+
> cohorts, anomaly windows all have recipes in `HOOKS.md`).
|
|
55
62
|
|
|
56
63
|
## Reference reading
|
|
57
64
|
|
|
@@ -62,8 +69,10 @@ Before writing any code, scan:
|
|
|
62
69
|
with full JSDoc. **Treat this as the source of truth.**
|
|
63
70
|
- `lib/utils/utils.js` — `pickAWinner`, `weighNumRange`, `initChance`, `exhaust`,
|
|
64
71
|
`takeSome` for property value distributions
|
|
65
|
-
- `dungeons/vertical/sass.js` — B2B reference dungeon with full identity model
|
|
66
|
-
- `dungeons/
|
|
72
|
+
- `dungeons/vertical/sass/sass.js` — B2B reference dungeon with full identity model
|
|
73
|
+
- `dungeons/vertical/ecommerce/ecommerce.js` — canonical consumer exemplar:
|
|
74
|
+
schema, funnels, stories export, and full hook layout (what your schema
|
|
75
|
+
becomes after `/write-hooks`)
|
|
67
76
|
- `dungeons/technical/identity-model-verify.js` — minimal identity-model fixture
|
|
68
77
|
|
|
69
78
|
## File structure
|
|
@@ -88,6 +97,7 @@ import * as v from "ak-tools";
|
|
|
88
97
|
* APP: <2-4 line description: what users do, core flow, monetization>
|
|
89
98
|
* SCALE: <numUsers> users, ~<numEvents> events, <numDays> days (<start> → <end>)
|
|
90
99
|
* CORE LOOP: <event1> → <event2> → <event3> → ...
|
|
100
|
+
* VALUE MOMENT: <the one event representing realized value — lifecycle hooks anchor here>
|
|
91
101
|
*
|
|
92
102
|
* EVENTS (N):
|
|
93
103
|
* <event name (weight)> > ... (sorted by weight desc)
|
|
@@ -160,7 +170,7 @@ KNOBS (extracted tunable constants — timing, thresholds, multipliers),
|
|
|
160
170
|
HOOK STATE (module-level Maps/Sets used across users), and HELPER FUNCTIONS
|
|
161
171
|
(per-type handlers like `handleEventHooks`, `handleEverythingHooks`).
|
|
162
172
|
`config.hook` becomes a thin dispatcher delegating to the helpers. See
|
|
163
|
-
`dungeons/vertical/ecommerce.js` as the canonical exemplar.
|
|
173
|
+
`dungeons/vertical/ecommerce/ecommerce.js` as the canonical exemplar.
|
|
164
174
|
|
|
165
175
|
## Required components
|
|
166
176
|
|
|
@@ -196,6 +206,30 @@ When using experiments, include `$experiment_started` in the events array with
|
|
|
196
206
|
}}
|
|
197
207
|
```
|
|
198
208
|
|
|
209
|
+
**Design the vocabulary for the analyses hooks will target (v1.6):**
|
|
210
|
+
|
|
211
|
+
- **Session-friendly next-events.** Flows (Sankey) reads collapse when an
|
|
212
|
+
anchor event can plausibly be followed by 15 different event types — every
|
|
213
|
+
branch gets a sliver and no engineered path clears the top level. For each
|
|
214
|
+
high-traffic anchor (post-login, post-search, post-add-to-cart), keep the
|
|
215
|
+
realistic next-event vocabulary to **≤5–6 event types**; if the app
|
|
216
|
+
genuinely has more surfaces, give the rare ones weight 1 so the top paths
|
|
217
|
+
stay legible. A path-share hook needs ~20–25% share to survive a Sankey's
|
|
218
|
+
top-3 — impossible against a 15-way fan-out.
|
|
219
|
+
- **Designate a value moment.** Pick ONE event that represents realized value
|
|
220
|
+
(first workout logged, order delivered, report generated) and record it in
|
|
221
|
+
the OVERVIEW block as `VALUE MOMENT: <event>`. Lifecycle stories
|
|
222
|
+
(dormancy → resurrection waves) anchor on the value moment — hooks gate or
|
|
223
|
+
inject THAT event, not generic page views. A schema with no obvious value
|
|
224
|
+
moment forces `write-hooks` to invent one badly.
|
|
225
|
+
- **Hidden-event hygiene.** Heartbeat/telemetry events (`ping`,
|
|
226
|
+
`app foregrounded`, `position updated`) at weight ≥5 drown every Sankey
|
|
227
|
+
level — every top path becomes anchor → heartbeat → heartbeat. Mixpanel
|
|
228
|
+
Flows (and the emulator's `topPaths` via `hiddenEvents`) can hide them at
|
|
229
|
+
query time, but the default view is what demos show. If realism demands
|
|
230
|
+
heartbeats, keep them at weight 1–2 and list them in the OVERVIEW so
|
|
231
|
+
`write-hooks` knows to pass `hiddenEvents` in flows stories.
|
|
232
|
+
|
|
199
233
|
### 2. Funnels (3–6)
|
|
200
234
|
|
|
201
235
|
- First funnel: includes `isFirstEvent` AND has `isAuthEvent: true` on the
|
|
@@ -237,6 +271,32 @@ event.
|
|
|
237
271
|
|
|
238
272
|
**Funnels representing loops need `reentry: true`.** Any funnel named "X loop" / "X cycle" / "session" / per-instance recurring behavior must declare `reentry: true`. Without it, the engine emits one sequence per user and downstream "power user" / "daily active" cohorts have no behavioral signal to bin on.
|
|
239
273
|
|
|
274
|
+
**Structural trend engineering — duplicate funnels instead of reaching for
|
|
275
|
+
hooks.** Not every trend needs a hook: initial conditions can raise or lower
|
|
276
|
+
a metric by themselves. The funnel array is a set of knobs — duplicate a
|
|
277
|
+
funnel and swap steps, props, `conversionRate`, `timeToConvert`, or `weight`
|
|
278
|
+
to architect a comparison directly into the schema:
|
|
279
|
+
|
|
280
|
+
- **Exclusion-step story**: Mixpanel funnels can exclude users who did
|
|
281
|
+
`foo` between A and B. There's no `excludeSteps` config — model it with
|
|
282
|
+
two funnels, `[A, B, C]` and `[A, B, foo, C]`, giving the second a lower
|
|
283
|
+
`conversionRate`. The report shows the conversion dip for the
|
|
284
|
+
detour-takers with zero hook code.
|
|
285
|
+
- **Segment-split story**: two copies of the same sequence with different
|
|
286
|
+
`props` (`{ checkout_version: "v1" }` vs `"v2"`) and different
|
|
287
|
+
`conversionRate` — an A/B story without the experiment machinery (use
|
|
288
|
+
`experiment` when you want `$experiment_started` + variant reports).
|
|
289
|
+
- **Speed story**: same sequence, different `timeToConvert`, split by a
|
|
290
|
+
funnel-level prop — time-to-convert deltas appear structurally.
|
|
291
|
+
- **Mix-shift story**: `weight` controls how often each variant funnel is
|
|
292
|
+
chosen; a heavy-weighted low-converting path drags the blended rate.
|
|
293
|
+
|
|
294
|
+
Prefer structure when the story is a *between-path comparison* (this path
|
|
295
|
+
converts worse / slower than that one). Reach for hooks when the story is a
|
|
296
|
+
*within-cohort behavior* (these users do more of X over time, this segment's
|
|
297
|
+
values differ). Structural trends are cheaper to verify — the knob IS the
|
|
298
|
+
expected value.
|
|
299
|
+
|
|
240
300
|
### 3. SuperProps (2–3)
|
|
241
301
|
|
|
242
302
|
Properties present on EVERY event. Common picks: `Plan`, `Region`, `Platform`,
|
|
@@ -554,7 +614,7 @@ When designing event properties, always consider which Mixpanel type best repres
|
|
|
554
614
|
app/customer, then **create `dungeons/user/<name>/` if it doesn't already exist**
|
|
555
615
|
(`mkdir -p dungeons/user/<name>`) and write the dungeon to
|
|
556
616
|
`dungeons/user/<name>/<name>.js` (e.g. `dungeons/user/acme/acme.js`). Folder and
|
|
557
|
-
file share the name, matching `
|
|
617
|
+
file share the name, matching the vertical layout (`ecommerce/ecommerce.js`).
|
|
558
618
|
|
|
559
619
|
This keeps `dungeons/user/` organized — EVERYTHING about this dungeon lives in
|
|
560
620
|
the same folder: `hook-results.md` + `hook-query-log.txt` +
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: create-project
|
|
3
|
+
description: Use when an existing dungeon needs a real Mixpanel project provisioned before sending data — creates the project, sets timezone UTC, mints a scoped service account, adds the dungeon's group keys, uploads business context (AI context), and writes the resulting credentials back into the dungeon so it "just runs". Follows create-dungeon / write-hooks / verify-dungeon.
|
|
4
|
+
argument-hint: [dungeon path, e.g. dungeons/user/shopstream/shopstream.js]
|
|
5
|
+
model: claude-opus-4-6
|
|
6
|
+
effort: max
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Create a Mixpanel Project for a Dungeon
|
|
10
|
+
|
|
11
|
+
Provision a fresh Mixpanel project for an existing dungeon and wire its credentials back in.
|
|
12
|
+
|
|
13
|
+
**Dungeon file:** `$ARGUMENTS`
|
|
14
|
+
|
|
15
|
+
This is the step after a dungeon is authored, reviewed, and tweaked. It turns a
|
|
16
|
+
local dungeon into one you can actually send to Mixpanel by creating the project
|
|
17
|
+
and stamping `credentials` back into the file.
|
|
18
|
+
|
|
19
|
+
## What it does
|
|
20
|
+
|
|
21
|
+
All work runs through the orchestrator `provision.mjs` (this skill's directory),
|
|
22
|
+
which calls the [power-tools API](https://mixpanel-power-tools-api-lmozz6xkha-uc.a.run.app)
|
|
23
|
+
in order:
|
|
24
|
+
|
|
25
|
+
1. **createProject** — name derived from the dungeon's `OVERVIEW` (`NAME:` line), region `US`, timezone `UTC` (set as a follow-up by the endpoint).
|
|
26
|
+
2. **mintServiceAccount** — `admin`, expires `+30 days`, scoped to the new project. This is what the dungeon uses to **send** data.
|
|
27
|
+
3. **addGroupKey** — one per `groupKeys` entry in the dungeon (`property_name` + a titleized `display_name`). Skipped if the dungeon has no group keys.
|
|
28
|
+
4. **setBusinessContext** — markdown built from the dungeon's `OVERVIEW` comment block plus the `stories` named export (per story: `narrative` + `mixpanelReport` + intentional deviations); dungeons without stories fall back to the `HOOK STORIES` comment scrape (via the package's `extractComments`). Plus an events/funnels/props/group-keys summary, capped at 50k chars. The dry-run plan prints which source was used.
|
|
29
|
+
5. **write-back** — replaces the dungeon's `credentials: { … }` block with `{ token, projectId, serviceAccount, serviceSecret, region }`.
|
|
30
|
+
|
|
31
|
+
**Always creates a fresh project.** Re-running mints a new project and overwrites
|
|
32
|
+
`credentials`. Auth for all four calls is the OAuth `BEARER_TOKEN`; the minted
|
|
33
|
+
service account is only written into the dungeon for later data sends.
|
|
34
|
+
|
|
35
|
+
## Prerequisites
|
|
36
|
+
|
|
37
|
+
`.env` at the repo root must contain (both are gitignored):
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
BEARER_TOKEN=<oauth token> # creates projects / mints SAs / adds group keys
|
|
41
|
+
ORG_ID=<organization id>
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
If either is missing the orchestrator exits with a clear message — tell the user
|
|
45
|
+
to add them to `.env`.
|
|
46
|
+
|
|
47
|
+
User dungeons (`dungeons/user/`, `dungeons/customers/`, `dungeons/capstone/`) are
|
|
48
|
+
gitignored, so writing plaintext credentials into them is expected and safe.
|
|
49
|
+
|
|
50
|
+
## Steps
|
|
51
|
+
|
|
52
|
+
### 1. Show the plan (dry run)
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
node .claude/skills/create-project/provision.mjs <dungeon-path> --dry-run
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
This makes **no** API calls. It prints the derived project name, group-key
|
|
59
|
+
mapping, service-account name/expiry, and a business-context preview. Show this
|
|
60
|
+
to the user.
|
|
61
|
+
|
|
62
|
+
### 2. Confirm
|
|
63
|
+
|
|
64
|
+
Creating a real project + service account is outward-facing and not easily
|
|
65
|
+
undone. Confirm with the user before the live run (one line is enough).
|
|
66
|
+
|
|
67
|
+
### 3. Provision (live)
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
node .claude/skills/create-project/provision.mjs <dungeon-path>
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
On success it writes `credentials` back into the dungeon and prints a non-secret
|
|
74
|
+
summary (project URL + id, SA username/expiry, group keys added/skipped, context
|
|
75
|
+
size). Token and secret are written into the dungeon, not printed.
|
|
76
|
+
|
|
77
|
+
### 4. Report
|
|
78
|
+
|
|
79
|
+
Relay the project URL and confirm credentials were written. Point the user at the
|
|
80
|
+
run command:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
node scripts/run-dungeon.mjs <dungeon-path>
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Error handling
|
|
87
|
+
|
|
88
|
+
- **Missing `BEARER_TOKEN` / `ORG_ID`** — orchestrator exits; have the user fix `.env`.
|
|
89
|
+
- **`createProject` fails** — nothing is provisioned; surface the power-tools error (`{ error }` or `{ errors:[{param,message}] }`) verbatim and stop.
|
|
90
|
+
- **A later step fails** (mint / group keys / context / write-back) — the orchestrator continues, writes back whatever succeeded, and lists the failed step under `⚠ warnings`. Relay those warnings; the user may re-run or fix manually.
|
|
91
|
+
|
|
92
|
+
## Notes
|
|
93
|
+
|
|
94
|
+
- Region is always `US`; timezone is always `UTC` (matches the dungeon's UTC time window so Mixpanel day-bucketing aligns).
|
|
95
|
+
- Group keys come straight from the dungeon's `groupKeys` (`[["parent_account", N, []], …]`) — author them there (via `create-dungeon`) before running this skill.
|
|
96
|
+
- The orchestrator reuses the package's own exports (`loadFromFile`, `extractComments`) — no separate parser to keep in sync.
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* create-project orchestrator.
|
|
5
|
+
*
|
|
6
|
+
* Given an existing dungeon file, provisions a fresh Mixpanel project via the
|
|
7
|
+
* power-tools API and writes the resulting credentials back into the dungeon so
|
|
8
|
+
* it can "just run":
|
|
9
|
+
*
|
|
10
|
+
* 1. createProject (sets timezone UTC as a follow-up)
|
|
11
|
+
* 2. mintServiceAccount (admin, +30d) — scoped to the new project
|
|
12
|
+
* 3. addGroupKey (only if the dungeon declares groupKeys)
|
|
13
|
+
* 4. setBusinessContext (OVERVIEW + the dungeon's `stories` export
|
|
14
|
+
* (narrative + mixpanelReport per story) — falls back to the HOOK
|
|
15
|
+
* STORIES comment scrape when the dungeon has no stories — + schema summary)
|
|
16
|
+
* 5. write `credentials: { token, projectId, serviceAccount, serviceSecret, region }`
|
|
17
|
+
* back into the dungeon (gitignored user dungeons — plaintext is fine)
|
|
18
|
+
*
|
|
19
|
+
* Auth: all calls use the OAuth BEARER_TOKEN from .env. The minted service
|
|
20
|
+
* account is for the dungeon to SEND data later, not for setup.
|
|
21
|
+
*
|
|
22
|
+
* Usage:
|
|
23
|
+
* node .claude/skills/create-project/provision.mjs <dungeon-path> [--dry-run]
|
|
24
|
+
*
|
|
25
|
+
* Env (.env at repo root):
|
|
26
|
+
* BEARER_TOKEN=<oauth token>
|
|
27
|
+
* ORG_ID=<organization id>
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
31
|
+
import path, { dirname, resolve } from 'path';
|
|
32
|
+
import { fileURLToPath, pathToFileURL } from 'url';
|
|
33
|
+
import dotenv from 'dotenv';
|
|
34
|
+
import { loadFromFile, extractComments } from '../../../index.js';
|
|
35
|
+
|
|
36
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
37
|
+
const REPO_ROOT = resolve(__dirname, '../../../');
|
|
38
|
+
|
|
39
|
+
dotenv.config({ path: resolve(REPO_ROOT, '.env') });
|
|
40
|
+
|
|
41
|
+
const BASE = 'https://mixpanel-power-tools-api-lmozz6xkha-uc.a.run.app';
|
|
42
|
+
const CLIENT_ID = 'dungeon-master';
|
|
43
|
+
const REGION = 'US';
|
|
44
|
+
const SA_TTL_DAYS = 30;
|
|
45
|
+
|
|
46
|
+
// ── args ──────────────────────────────────────────────────────────────────
|
|
47
|
+
const args = process.argv.slice(2);
|
|
48
|
+
const dryRun = args.includes('--dry-run');
|
|
49
|
+
const dungeonArg = args.find((a) => !a.startsWith('--'));
|
|
50
|
+
|
|
51
|
+
if (!dungeonArg) {
|
|
52
|
+
fail('Usage: node .claude/skills/create-project/provision.mjs <dungeon-path> [--dry-run]');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const dungeonPath = resolve(process.cwd(), dungeonArg);
|
|
56
|
+
if (!existsSync(dungeonPath)) fail(`dungeon file not found: ${dungeonPath}`);
|
|
57
|
+
|
|
58
|
+
// ── main ──────────────────────────────────────────────────────────────────
|
|
59
|
+
const config = await loadFromFile(dungeonPath);
|
|
60
|
+
const comments = extractComments(dungeonPath);
|
|
61
|
+
|
|
62
|
+
// stories is a NAMED export of JS dungeons (v1.6 story-spec) — the verified
|
|
63
|
+
// behavior contract. Preferred over the comment scrape for business context.
|
|
64
|
+
let stories = null;
|
|
65
|
+
if (/\.(js|mjs)$/i.test(dungeonPath)) {
|
|
66
|
+
try {
|
|
67
|
+
const mod = await import(pathToFileURL(dungeonPath).href);
|
|
68
|
+
if (Array.isArray(mod.stories) && mod.stories.length) stories = mod.stories;
|
|
69
|
+
} catch {
|
|
70
|
+
// loadFromFile above already imported the module; an error here would
|
|
71
|
+
// have surfaced there. Fall back to the comment scrape regardless.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const name = deriveName(comments, dungeonPath);
|
|
76
|
+
const groupKeys = Array.isArray(config.groupKeys)
|
|
77
|
+
? config.groupKeys.filter(Boolean).map(([prop]) => ({ property_name: prop, display_name: titleize(prop) }))
|
|
78
|
+
: [];
|
|
79
|
+
const saName = `${slug(name)}-dungeon-sa`.slice(0, 64);
|
|
80
|
+
const ctxSource = stories ? `stories export (${stories.length} stories)` : 'comment scrape';
|
|
81
|
+
const content = buildContext(name, config, comments, groupKeys, stories);
|
|
82
|
+
|
|
83
|
+
if (dryRun) {
|
|
84
|
+
printPlan();
|
|
85
|
+
process.exit(0);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Live run needs auth.
|
|
89
|
+
const { BEARER_TOKEN, ORG_ID } = process.env;
|
|
90
|
+
if (!BEARER_TOKEN) fail('BEARER_TOKEN missing from .env (OAuth token required to create projects).');
|
|
91
|
+
if (!ORG_ID) fail('ORG_ID missing from .env (organization id required to create projects).');
|
|
92
|
+
|
|
93
|
+
const warnings = [];
|
|
94
|
+
|
|
95
|
+
// 1. create project (timezone set to UTC as a follow-up by the endpoint)
|
|
96
|
+
let project;
|
|
97
|
+
try {
|
|
98
|
+
project = await post('/crud/createProject', { org_id: ORG_ID, name, timezone: 'UTC' });
|
|
99
|
+
} catch (err) {
|
|
100
|
+
fail(`createProject failed — nothing provisioned.\n ${err.message}`);
|
|
101
|
+
}
|
|
102
|
+
const projectId = String(project.id);
|
|
103
|
+
const projectToken = project.token;
|
|
104
|
+
|
|
105
|
+
// 2. mint service account (admin, +30d)
|
|
106
|
+
let sa = null;
|
|
107
|
+
const expires = isoInDays(SA_TTL_DAYS);
|
|
108
|
+
try {
|
|
109
|
+
sa = await post('/crud/mintServiceAccount', {
|
|
110
|
+
org_id: ORG_ID,
|
|
111
|
+
project_id: projectId,
|
|
112
|
+
name: saName,
|
|
113
|
+
role: 'admin',
|
|
114
|
+
expires,
|
|
115
|
+
});
|
|
116
|
+
} catch (err) {
|
|
117
|
+
warnings.push(`mintServiceAccount failed: ${err.message}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 3. group keys (only if declared)
|
|
121
|
+
let groupKeyResult = null;
|
|
122
|
+
if (groupKeys.length) {
|
|
123
|
+
try {
|
|
124
|
+
groupKeyResult = await post('/crud/addGroupKey', { project_id: projectId, group_keys: groupKeys });
|
|
125
|
+
} catch (err) {
|
|
126
|
+
warnings.push(`addGroupKey failed: ${err.message}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 4. business context
|
|
131
|
+
try {
|
|
132
|
+
await post('/crud/setBusinessContext', { project_id: projectId, content });
|
|
133
|
+
} catch (err) {
|
|
134
|
+
warnings.push(`setBusinessContext failed: ${err.message}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 5. write credentials back into the dungeon
|
|
138
|
+
const creds = {
|
|
139
|
+
token: projectToken,
|
|
140
|
+
projectId,
|
|
141
|
+
serviceAccount: sa?.username || '',
|
|
142
|
+
serviceSecret: sa?.secret || '',
|
|
143
|
+
region: REGION,
|
|
144
|
+
};
|
|
145
|
+
let wroteBack = true;
|
|
146
|
+
try {
|
|
147
|
+
writeBackCredentials(dungeonPath, creds);
|
|
148
|
+
} catch (err) {
|
|
149
|
+
wroteBack = false;
|
|
150
|
+
warnings.push(`credentials write-back failed: ${err.message}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
printSummary();
|
|
154
|
+
|
|
155
|
+
// ── helpers ─────────────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
async function post(pathname, body) {
|
|
158
|
+
const res = await fetch(BASE + pathname, {
|
|
159
|
+
method: 'POST',
|
|
160
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${process.env.BEARER_TOKEN}` },
|
|
161
|
+
body: JSON.stringify({ client_id: CLIENT_ID, region: REGION, ...body }),
|
|
162
|
+
});
|
|
163
|
+
const text = await res.text();
|
|
164
|
+
let json;
|
|
165
|
+
try {
|
|
166
|
+
json = JSON.parse(text);
|
|
167
|
+
} catch {
|
|
168
|
+
json = { raw: text };
|
|
169
|
+
}
|
|
170
|
+
if (!res.ok) {
|
|
171
|
+
const detail = Array.isArray(json.errors)
|
|
172
|
+
? json.errors.map((e) => `${e.param}: ${e.message}`).join('; ')
|
|
173
|
+
: json.error || text || `HTTP ${res.status}`;
|
|
174
|
+
throw new Error(`${res.status} ${detail}`);
|
|
175
|
+
}
|
|
176
|
+
return json;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function deriveName(comments, p) {
|
|
180
|
+
const m = (comments.overview || '').match(/^NAME:\s*(.+)$/m);
|
|
181
|
+
if (m) return m[1].trim();
|
|
182
|
+
return path.basename(p).replace(/\.(js|mjs|json)$/i, '');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function titleize(s) {
|
|
186
|
+
return String(s)
|
|
187
|
+
.split(/[_\s-]+/)
|
|
188
|
+
.map((w) => (w ? w[0].toUpperCase() + w.slice(1) : w))
|
|
189
|
+
.join(' ');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function slug(s) {
|
|
193
|
+
return String(s)
|
|
194
|
+
.toLowerCase()
|
|
195
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
196
|
+
.replace(/^-+|-+$/g, '');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function isoInDays(days) {
|
|
200
|
+
const ms = Date.now() + days * 24 * 60 * 60 * 1000;
|
|
201
|
+
// Mixpanel's createServiceAccount requires %Y-%m-%dT%H:%M:%SZ — seconds
|
|
202
|
+
// precision, NO milliseconds. toISOString() emits ".sssZ", which the API
|
|
203
|
+
// rejects ("Expiration date … does not match %Y-%m-%dT%H:%M:%SZ").
|
|
204
|
+
return new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function buildContext(name, config, comments, groupKeys, stories) {
|
|
208
|
+
const parts = [`# ${name}`, ''];
|
|
209
|
+
if (comments.overview) parts.push(comments.overview, '');
|
|
210
|
+
|
|
211
|
+
// Preferred source: the stories export — each story's narrative is the
|
|
212
|
+
// human-readable behavior and mixpanelReport (free-form object) points at
|
|
213
|
+
// the report where it shows. Comment scrape is the fallback for dungeons
|
|
214
|
+
// without stories (schema-only or pre-1.6).
|
|
215
|
+
if (stories?.length) {
|
|
216
|
+
parts.push('## Engineered Behaviors', '');
|
|
217
|
+
parts.push(`${stories.length} machine-verified story patterns (from the dungeon's \`stories\` export):`, '');
|
|
218
|
+
for (const s of stories) {
|
|
219
|
+
const arch = s.archetype ? ` — ${s.archetype}` : '';
|
|
220
|
+
parts.push(`### ${s.id}${arch}`, '');
|
|
221
|
+
if (s.narrative) parts.push(String(s.narrative).trim(), '');
|
|
222
|
+
if (s.mixpanelReport && typeof s.mixpanelReport === 'object') {
|
|
223
|
+
parts.push('How to see it in Mixpanel:');
|
|
224
|
+
for (const [k, v] of Object.entries(s.mixpanelReport)) {
|
|
225
|
+
parts.push(`- **${k}**: ${typeof v === 'string' ? v : JSON.stringify(v)}`);
|
|
226
|
+
}
|
|
227
|
+
parts.push('');
|
|
228
|
+
}
|
|
229
|
+
if (Array.isArray(s.intentionalDeviations) && s.intentionalDeviations.length) {
|
|
230
|
+
parts.push('Notes:', ...s.intentionalDeviations.map((d) => `- ${d}`), '');
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
} else if (comments.hookStories) {
|
|
234
|
+
parts.push('## Engineered Behaviors', '', comments.hookStories, '');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
parts.push('## Schema', '');
|
|
238
|
+
const events = config.events || [];
|
|
239
|
+
parts.push(`### Events (${events.length})`);
|
|
240
|
+
for (const e of events) {
|
|
241
|
+
const props = e.properties ? Object.keys(e.properties).join(', ') : '';
|
|
242
|
+
const weight = e.weight != null ? ` (weight ${e.weight})` : '';
|
|
243
|
+
parts.push(`- ${e.event}${weight}${props ? ` — ${props}` : ''}`);
|
|
244
|
+
}
|
|
245
|
+
parts.push('');
|
|
246
|
+
|
|
247
|
+
const funnels = config.funnels || [];
|
|
248
|
+
if (funnels.length) {
|
|
249
|
+
parts.push(`### Funnels (${funnels.length})`);
|
|
250
|
+
for (const f of funnels) {
|
|
251
|
+
const seq = (f.sequence || []).join(' → ');
|
|
252
|
+
const rate = f.conversionRate != null ? ` (${f.conversionRate}%)` : '';
|
|
253
|
+
parts.push(`- ${f.name || '(unnamed)'}: ${seq}${rate}`);
|
|
254
|
+
}
|
|
255
|
+
parts.push('');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (groupKeys.length) {
|
|
259
|
+
parts.push('### Group keys', ...groupKeys.map((g) => `- ${g.property_name} (${g.display_name})`), '');
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
let md = parts.join('\n');
|
|
263
|
+
if (md.length > 50000) md = md.slice(0, 49900) + '\n\n…(truncated to 50,000 chars)';
|
|
264
|
+
return md;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function writeBackCredentials(p, creds) {
|
|
268
|
+
let src = readFileSync(p, 'utf-8');
|
|
269
|
+
const block =
|
|
270
|
+
`credentials: { token: ${q(creds.token)}, projectId: ${q(creds.projectId)}, ` +
|
|
271
|
+
`serviceAccount: ${q(creds.serviceAccount)}, serviceSecret: ${q(creds.serviceSecret)}, region: ${q(creds.region)} }`;
|
|
272
|
+
|
|
273
|
+
const existing = /credentials\s*:\s*\{[\s\S]*?\}/;
|
|
274
|
+
if (existing.test(src)) {
|
|
275
|
+
src = src.replace(existing, block);
|
|
276
|
+
} else {
|
|
277
|
+
const opener = /(const\s+\w+\s*=\s*\{)/;
|
|
278
|
+
if (opener.test(src)) {
|
|
279
|
+
src = src.replace(opener, `$1\n\t${block},`);
|
|
280
|
+
} else {
|
|
281
|
+
throw new Error('no `credentials` block or `const X = {` opener found — add a credentials block manually.');
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
writeFileSync(p, src, 'utf-8');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function q(v) {
|
|
288
|
+
return JSON.stringify(String(v ?? ''));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function printPlan() {
|
|
292
|
+
const preview = content.length > 600 ? content.slice(0, 600) + ' …' : content;
|
|
293
|
+
console.log('── create-project plan (dry run) ────────────────────────────');
|
|
294
|
+
console.log(`dungeon: ${path.relative(process.cwd(), dungeonPath)}`);
|
|
295
|
+
console.log(`project name: ${name}`);
|
|
296
|
+
console.log(`region: ${REGION} timezone: UTC`);
|
|
297
|
+
console.log(`service acct: ${saName} (role admin, expires +${SA_TTL_DAYS}d)`);
|
|
298
|
+
console.log(`group keys: ${groupKeys.length ? groupKeys.map((g) => `${g.property_name} → "${g.display_name}"`).join(', ') : '(none)'}`);
|
|
299
|
+
console.log(`business ctx: ${content.length} chars (source: ${ctxSource})`);
|
|
300
|
+
console.log('');
|
|
301
|
+
console.log('would POST: createProject → mintServiceAccount' + (groupKeys.length ? ' → addGroupKey' : '') + ' → setBusinessContext');
|
|
302
|
+
console.log('then write credentials back into the dungeon.');
|
|
303
|
+
console.log('');
|
|
304
|
+
console.log('── business context preview ─────────────────────────────────');
|
|
305
|
+
console.log(preview);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function printSummary() {
|
|
309
|
+
console.log('── create-project: provisioned ──────────────────────────────');
|
|
310
|
+
console.log(`project: ${name} (id ${projectId})`);
|
|
311
|
+
if (project.url) console.log(`url: ${project.url}`);
|
|
312
|
+
console.log(`region: ${REGION} timezone: UTC`);
|
|
313
|
+
console.log(`service acct: ${sa ? `${sa.username} (role ${sa.role}, expires ${sa.expires})` : '(FAILED — see warnings)'}`);
|
|
314
|
+
if (groupKeys.length) {
|
|
315
|
+
// Defensive: the API's `added`/`skipped` entries may be objects, strings, or
|
|
316
|
+
// null. Never let summary formatting throw — it would swallow real warnings.
|
|
317
|
+
const fmt = (g) => (g == null ? null : typeof g === 'string' ? g : (g.property_name || g.name || null));
|
|
318
|
+
const added = (groupKeyResult?.added || []).map(fmt).filter(Boolean).join(', ') || '(none)';
|
|
319
|
+
const skipped = (groupKeyResult?.skipped || []).map(fmt).filter(Boolean).join(', ') || '(none)';
|
|
320
|
+
console.log(`group keys: added [${added}] skipped [${skipped}]`);
|
|
321
|
+
}
|
|
322
|
+
console.log(`business ctx: ${content.length} chars uploaded (source: ${ctxSource})`);
|
|
323
|
+
console.log(`credentials: ${wroteBack ? 'written back into dungeon ✓' : 'NOT written (see warnings)'}`);
|
|
324
|
+
if (warnings.length) {
|
|
325
|
+
console.log('');
|
|
326
|
+
console.log('⚠ warnings:');
|
|
327
|
+
for (const w of warnings) console.log(` - ${w}`);
|
|
328
|
+
}
|
|
329
|
+
console.log('');
|
|
330
|
+
console.log(`next: node scripts/run-dungeon.mjs ${path.relative(process.cwd(), dungeonPath)}`);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function fail(msg) {
|
|
334
|
+
console.error(`✖ ${msg}`);
|
|
335
|
+
process.exit(1);
|
|
336
|
+
}
|