@cowliss/cli 0.9.0 → 0.11.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 (117) hide show
  1. package/{examples → blueprints}/abandoned-checkout/journeys/abandoned-checkout.ts +1 -1
  2. package/{examples → blueprints}/activity-decay/journeys/activity-decay.ts +1 -1
  3. package/blueprints/ecommerce/emails/_shell.tsx +133 -0
  4. package/blueprints/ecommerce/emails/abandoned-checkout-last-call.tsx +46 -0
  5. package/blueprints/ecommerce/emails/abandoned-checkout.tsx +46 -0
  6. package/blueprints/ecommerce/emails/order-thanks.tsx +44 -0
  7. package/blueprints/ecommerce/emails/replenishment.tsx +40 -0
  8. package/blueprints/ecommerce/emails/review-request.tsx +44 -0
  9. package/blueprints/ecommerce/emails/vip-thanks.tsx +51 -0
  10. package/blueprints/ecommerce/emails/welcome.tsx +48 -0
  11. package/blueprints/ecommerce/emails/winback-offer.tsx +41 -0
  12. package/blueprints/ecommerce/emails/winback-reminder.tsx +45 -0
  13. package/blueprints/ecommerce/journeys/_helpers.ts +14 -0
  14. package/blueprints/ecommerce/journeys/abandoned-checkout.ts +44 -0
  15. package/blueprints/ecommerce/journeys/first-order-thanks.ts +40 -0
  16. package/blueprints/ecommerce/journeys/replenishment.ts +39 -0
  17. package/blueprints/ecommerce/journeys/vip.ts +29 -0
  18. package/blueprints/ecommerce/journeys/welcome.ts +42 -0
  19. package/blueprints/ecommerce/journeys/winback.ts +35 -0
  20. package/blueprints/ecommerce/scenarios/abandoned-checkout.last-call.json +30 -0
  21. package/blueprints/ecommerce/scenarios/abandoned-checkout.recovered.json +24 -0
  22. package/blueprints/ecommerce/scenarios/abandoned-checkout.reminded.json +33 -0
  23. package/blueprints/ecommerce/scenarios/first-order-thanks.delivered.json +35 -0
  24. package/blueprints/ecommerce/scenarios/first-order-thanks.never-delivered.json +21 -0
  25. package/blueprints/ecommerce/scenarios/replenishment.due.json +21 -0
  26. package/blueprints/ecommerce/scenarios/replenishment.reordered.json +20 -0
  27. package/blueprints/ecommerce/scenarios/vip.crossed.json +22 -0
  28. package/blueprints/ecommerce/scenarios/welcome.new-shopper.json +21 -0
  29. package/blueprints/ecommerce/scenarios/winback.returned.json +26 -0
  30. package/blueprints/ecommerce/scenarios/winback.still-gone.json +30 -0
  31. package/blueprints/ecommerce/segments/first-time-buyers.ts +24 -0
  32. package/blueprints/ecommerce/segments/lapsed.ts +27 -0
  33. package/blueprints/ecommerce/segments/repeat-buyers.ts +18 -0
  34. package/blueprints/ecommerce/segments/vip-customers.ts +20 -0
  35. package/blueprints/mobile-game/emails/_shell.tsx +138 -0
  36. package/blueprints/mobile-game/emails/day-two-return.tsx +41 -0
  37. package/blueprints/mobile-game/emails/lapsed-offer.tsx +41 -0
  38. package/blueprints/mobile-game/emails/lapsed-reminder.tsx +48 -0
  39. package/blueprints/mobile-game/emails/purchase-receipt.tsx +47 -0
  40. package/blueprints/mobile-game/emails/spender-thanks.tsx +53 -0
  41. package/blueprints/mobile-game/emails/stuck-hint.tsx +48 -0
  42. package/blueprints/mobile-game/emails/tutorial-nudge.tsx +48 -0
  43. package/blueprints/mobile-game/journeys/_helpers.ts +14 -0
  44. package/blueprints/mobile-game/journeys/day-two-return.ts +40 -0
  45. package/blueprints/mobile-game/journeys/first-purchase.ts +34 -0
  46. package/blueprints/mobile-game/journeys/lapsed-player.ts +39 -0
  47. package/blueprints/mobile-game/journeys/onboarding.ts +35 -0
  48. package/blueprints/mobile-game/journeys/spender-care.ts +41 -0
  49. package/blueprints/mobile-game/journeys/stuck.ts +58 -0
  50. package/blueprints/mobile-game/scenarios/day-two-return.came-back.json +16 -0
  51. package/blueprints/mobile-game/scenarios/day-two-return.no-show.json +21 -0
  52. package/blueprints/mobile-game/scenarios/first-purchase.paid.json +21 -0
  53. package/blueprints/mobile-game/scenarios/lapsed-player.returned.json +26 -0
  54. package/blueprints/mobile-game/scenarios/lapsed-player.still-gone.json +30 -0
  55. package/blueprints/mobile-game/scenarios/onboarding.finished.json +16 -0
  56. package/blueprints/mobile-game/scenarios/onboarding.stalled.json +21 -0
  57. package/blueprints/mobile-game/scenarios/spender-care.crossed.json +22 -0
  58. package/blueprints/mobile-game/scenarios/stuck.cleared.json +16 -0
  59. package/blueprints/mobile-game/scenarios/stuck.three-fails.json +30 -0
  60. package/blueprints/mobile-game/segments/active-players.ts +30 -0
  61. package/blueprints/mobile-game/segments/lapsed-players.ts +29 -0
  62. package/blueprints/mobile-game/segments/new-players.ts +31 -0
  63. package/blueprints/mobile-game/segments/paying-players.ts +21 -0
  64. package/blueprints/saas-trial/emails/_shell.tsx +151 -0
  65. package/blueprints/saas-trial/emails/activated.tsx +61 -0
  66. package/blueprints/saas-trial/emails/activation-help.tsx +49 -0
  67. package/blueprints/saas-trial/emails/activation-nudge.tsx +48 -0
  68. package/blueprints/saas-trial/emails/invite-your-team.tsx +58 -0
  69. package/blueprints/saas-trial/emails/trial-converted.tsx +57 -0
  70. package/blueprints/saas-trial/emails/trial-ended.tsx +52 -0
  71. package/blueprints/saas-trial/emails/trial-ending-activated.tsx +53 -0
  72. package/blueprints/saas-trial/emails/trial-ending-unactivated.tsx +54 -0
  73. package/blueprints/saas-trial/emails/trial-welcome.tsx +57 -0
  74. package/blueprints/saas-trial/journeys/_helpers.ts +14 -0
  75. package/blueprints/saas-trial/journeys/activated.ts +42 -0
  76. package/blueprints/saas-trial/journeys/activation-nudge.ts +44 -0
  77. package/blueprints/saas-trial/journeys/invite-your-team.ts +42 -0
  78. package/blueprints/saas-trial/journeys/trial-ended.ts +39 -0
  79. package/blueprints/saas-trial/journeys/trial-ending.ts +58 -0
  80. package/blueprints/saas-trial/journeys/trial-welcome.ts +35 -0
  81. package/blueprints/saas-trial/scenarios/activated.first-project.json +14 -0
  82. package/blueprints/saas-trial/scenarios/activated.second-project.json +12 -0
  83. package/blueprints/saas-trial/scenarios/activation-nudge.activated-late.json +16 -0
  84. package/blueprints/saas-trial/scenarios/activation-nudge.activated.json +8 -0
  85. package/blueprints/saas-trial/scenarios/activation-nudge.stalled.json +23 -0
  86. package/blueprints/saas-trial/scenarios/invite-your-team.alone.json +16 -0
  87. package/blueprints/saas-trial/scenarios/invite-your-team.invited.json +8 -0
  88. package/blueprints/saas-trial/scenarios/trial-ended.converted.json +16 -0
  89. package/blueprints/saas-trial/scenarios/trial-ended.expired.json +13 -0
  90. package/blueprints/saas-trial/scenarios/trial-ending.activated.json +20 -0
  91. package/blueprints/saas-trial/scenarios/trial-ending.converted.json +12 -0
  92. package/blueprints/saas-trial/scenarios/trial-ending.unactivated.json +16 -0
  93. package/blueprints/saas-trial/scenarios/trial-welcome.started.json +13 -0
  94. package/blueprints/saas-trial/segments/activated-users.ts +25 -0
  95. package/blueprints/saas-trial/segments/converted.ts +28 -0
  96. package/blueprints/saas-trial/segments/trialing.ts +26 -0
  97. package/blueprints/saas-trial/segments/unactivated.ts +27 -0
  98. package/{examples → blueprints}/winback/journeys/winback.ts +18 -7
  99. package/blueprints/winback/scenarios/winback.bought-again.json +14 -0
  100. package/{examples/winback/scenarios/winback.json → blueprints/winback/scenarios/winback.lapsed.json} +2 -5
  101. package/dist/guest/{driver-poSdZIj8.js → driver-B_pjmy5g.js} +2 -1
  102. package/dist/guest/driver-emails.js +3 -2
  103. package/dist/guest/driver.d.ts +1 -1
  104. package/dist/guest/driver.js +1 -1
  105. package/dist/guest/{index-EqBCZnpq.d.ts → index-DADBwPaG.d.ts} +63 -12
  106. package/dist/guest/{journeys-F8Yk8s1P.js → journeys-v2-Djgs8U91.js} +87 -71
  107. package/dist/guest/journeys.d.ts +1 -1
  108. package/dist/guest/journeys.js +58 -1
  109. package/dist/guest/segments.d.ts +81 -0
  110. package/dist/guest/segments.js +35 -0
  111. package/dist/index.js +1191 -398
  112. package/package.json +6 -2
  113. /package/{examples → blueprints}/abandoned-checkout/emails/abandoned-checkout.tsx +0 -0
  114. /package/{examples → blueprints}/abandoned-checkout/scenarios/abandoned-checkout.recovered.json +0 -0
  115. /package/{examples → blueprints}/abandoned-checkout/scenarios/abandoned-checkout.timeout.json +0 -0
  116. /package/{examples → blueprints}/activity-decay/scenarios/activity-decay.json +0 -0
  117. /package/{examples → blueprints}/winback/emails/winback.tsx +0 -0
package/dist/index.js CHANGED
@@ -220,6 +220,12 @@ const EXECUTION_LIMITS = {
220
220
  const PUSH_LIMITS = {
221
221
  journeys: 100,
222
222
  templates: 200,
223
+ /**
224
+ * Segment files (ADR 0023). A segment is a predicate list, not a bundle, so
225
+ * the ceiling is about how many audiences one program describes rather than
226
+ * about bytes; a journey's own inline definition costs nothing here.
227
+ */
228
+ segments: 100,
223
229
  bundleBytes: 2097152,
224
230
  sourceBytes: 5242880
225
231
  };
@@ -2799,6 +2805,12 @@ var PrimaryKey = class {
2799
2805
  }
2800
2806
  };
2801
2807
 
2808
+ //#endregion
2809
+ //#region ../../node_modules/.pnpm/drizzle-orm@0.45.2_postgres@3.4.9/node_modules/drizzle-orm/sql/expressions/conditions.js
2810
+ function isNull(value) {
2811
+ return sql`${value} is null`;
2812
+ }
2813
+
2802
2814
  //#endregion
2803
2815
  //#region ../../node_modules/.pnpm/drizzle-zod@0.8.3_drizzle-orm@0.45.2_postgres@3.4.9__zod@4.4.3/node_modules/drizzle-zod/index.mjs
2804
2816
  const CONSTANTS = {
@@ -3170,8 +3182,15 @@ const insertAppSchema = createInsertSchema(apps$1);
3170
3182
  const bytea = customType({ dataType: () => "bytea" });
3171
3183
  /**
3172
3184
  * What an artifact is: a journey or template bundle as `cow build` emitted
3173
- * it, the wasm module the server compiled from it, or the gzipped source
3174
- * tarball of the repo the push was built from.
3185
+ * it, or the gzipped source tarball of the repo the push was built from.
3186
+ *
3187
+ * `module` is still a value of the Postgres enum and no longer a value here
3188
+ * (ticket 22/05). Compiled modules are not stored: a module is a
3189
+ * deterministic function of its bundle, so the rows were Postgres caching
3190
+ * what the sandbox's own disk cache already holds, at about 2MB each of
3191
+ * which 1.34MB is the QuickJS engine Javy links into every one. Dropping
3192
+ * the enum value itself would mean recreating the type and rewriting the
3193
+ * column, for a label nothing writes.
3175
3194
  */
3176
3195
  const artifactKindEnum = pgEnum("artifact_kind", [
3177
3196
  "bundle",
@@ -4113,17 +4132,22 @@ const insertQuarantineEntrySchema = createInsertSchema(quarantineEntries);
4113
4132
  * A segment belongs to one app (ADR 0022) and sees only that app's profiles
4114
4133
  * and that app's events.
4115
4134
  *
4116
- * A row is either standalone (built in the dashboard, `journey_key` null) or
4117
- * owned by one journey whose trigger inlined its definition (ADR 0016). An
4118
- * owned row is named after its journey's key, is written only by the push,
4119
- * and goes when the journey does — which is what the composite foreign key
4120
- * says: it points at `journeys(org_id, key)` and cascades. A null
4121
- * `journey_key` satisfies it vacuously (MATCH SIMPLE), so standalone rows
4122
- * are unconstrained.
4135
+ * A row comes from one of three places, which `source` and `journey_key`
4136
+ * answer together: built in the dashboard (`dashboard`, no journey), pushed
4137
+ * from a `segments/` file (`push`, no journey — ADR 0023), or owned by one
4138
+ * journey whose trigger inlined its definition (`push` and that journey —
4139
+ * ADR 0016). So `source = 'push'` is exactly "the repository writes this
4140
+ * one", which is the whole of the dashboard's edit refusal, and
4141
+ * `journey_key` says which of the two pushed forms it is. An owned row is
4142
+ * named after its journey's key and goes when the journey does — which is
4143
+ * what the composite foreign key says: it points at `journeys(org_id, key)`
4144
+ * and cascades. A null `journey_key` satisfies it vacuously (MATCH SIMPLE),
4145
+ * so the other two forms are unconstrained.
4123
4146
  *
4124
4147
  * Timestamps use millisecond precision, same rationale as apps: JS Dates
4125
4148
  * carry ms only and cursor pagination compares createdAt for equality.
4126
4149
  */
4150
+ const segmentSourceEnum = pgEnum("segment_source", ["dashboard", "push"]);
4127
4151
  const segments$1 = pgTable("segments", {
4128
4152
  id: text("id").primaryKey(),
4129
4153
  orgId: text("org_id").notNull(),
@@ -4131,8 +4155,12 @@ const segments$1 = pgTable("segments", {
4131
4155
  appId: text("app_id").notNull(),
4132
4156
  name: text("name").notNull(),
4133
4157
  description: text("description"),
4158
+ /** The author's labels on a pushed segment; empty for a dashboard one. */
4159
+ tags: text("tags").array().notNull().default(sql`'{}'::text[]`),
4134
4160
  definition: jsonb("definition").$type().notNull(),
4135
- /** The journey that owns this row, or null for a standalone segment. */
4161
+ /** Who writes this row: the dashboard, or a push. */
4162
+ source: segmentSourceEnum("source").notNull().default("dashboard"),
4163
+ /** The journey that owns this row, or null when no journey does. */
4136
4164
  journeyKey: text("journey_key"),
4137
4165
  createdAt: createdAt(),
4138
4166
  updatedAt: updatedAt()
@@ -4313,6 +4341,7 @@ const sources$1 = pgTable("sources", {
4313
4341
  }, (table) => [
4314
4342
  index("sources_org_id_idx").on(table.orgId),
4315
4343
  index("sources_app_id_idx").on(table.appId),
4344
+ uniqueIndex("sources_app_kind_live_idx").on(table.orgId, table.appId, table.kind).where(isNull(table.archivedAt)),
4316
4345
  foreignKey({
4317
4346
  columns: [table.orgId, table.appId],
4318
4347
  foreignColumns: [apps$1.orgId, apps$1.id],
@@ -4395,10 +4424,16 @@ const versionStatusEnum = pgEnum("version_status", [
4395
4424
  * use; an execution pins the journey version it started on for its whole
4396
4425
  * life (ADR 0004).
4397
4426
  *
4398
- * `moduleDigest` is the content address and is null until the compile
4399
- * settles: a version that never compiled has no module to be addressed by,
4400
- * and Postgres leaves nulls out of the unique index, so a key that fails to
4401
- * compile twice records both failures.
4427
+ * The content address is the manifest's `bundle`, which is what the
4428
+ * developer actually pushed. `moduleDigest` is a derived compile result,
4429
+ * null until the compile settles and rewritable afterwards: the toolchain
4430
+ * that produced it is ours, not the tenant's, so bumping Javy is a
4431
+ * background recompile rather than a new version in a customer's history
4432
+ * (ADR 0011, amended 2026-09-19).
4433
+ *
4434
+ * The uniqueness that follows is "one ready version per key per bundle": a
4435
+ * key whose bundle does not compile records every attempt, and the next
4436
+ * push of that bundle is a retry rather than a duplicate.
4402
4437
  *
4403
4438
  * `pushedAt` is the ordering key rather than a creation stamp, because an
4404
4439
  * unchanged key gets no new version: a push that re-sends bytes a version
@@ -4430,7 +4465,7 @@ const versions$1 = pgTable("versions", {
4430
4465
  }),
4431
4466
  error: text("error")
4432
4467
  }, (table) => [
4433
- uniqueIndex("versions_org_id_kind_key_module_digest_unique").on(table.orgId, table.kind, table.key, table.moduleDigest),
4468
+ uniqueIndex("versions_org_id_kind_key_bundle_unique").on(table.orgId, table.kind, table.key, sql`(${table.manifest}->>'bundle')`).where(sql`${table.status} = 'ready'`),
4434
4469
  index("versions_org_id_kind_key_pushed_at_idx").on(table.orgId, table.kind, table.key, table.pushedAt),
4435
4470
  index("versions_org_id_push_id_idx").on(table.orgId, table.pushId),
4436
4471
  foreignKey({
@@ -4887,20 +4922,40 @@ const patternSchema = z.union([onePatternSchema, z.array(onePatternSchema).min(1
4887
4922
  * row carries its `app_id`, and the runtime compares that with the event's.
4888
4923
  * An `appId` here would be a second way to say it, and the way to leak.
4889
4924
  *
4890
- * A segment trigger carries the predicate list, not a name: the push
4891
- * materializes one segment row per journey that inlines a definition, owned
4892
- * by the journey and named after its key, so the segment exists because the
4893
- * journey exists and there is no order to get wrong (ADR 0016). The
4894
- * definition is data — validated here, carried on the version, never
4895
- * compiled and never executed.
4925
+ * A segment trigger says which audience in two ways (ADR 0016, ADR 0023).
4926
+ * Inline, carrying the predicate list itself: the push materializes one
4927
+ * segment row per journey that does, owned by the journey and named after
4928
+ * its key. Or by name, naming a `segments/<key>.ts` file of the same push:
4929
+ * the name resolves inside the tree at build time and the push writes that
4930
+ * file's row, so there is still no order to get wrong and no row outside the
4931
+ * repository a trigger can point at. Either way the definition is data —
4932
+ * validated here, carried on the version, never compiled and never executed.
4933
+ *
4934
+ * Every member is strict, so a journey holding the retired `source` or
4935
+ * `appId` key fails to build instead of silently triggering on every app or
4936
+ * on nothing. There is deliberately no pipe filter and no app filter: the
4937
+ * journey's own app is the only narrowing there is.
4938
+ */
4939
+ const triggerSchema = z.union([
4940
+ z.strictObject({ event: patternSchema }),
4941
+ z.strictObject({ segment: segmentDefinitionSchema }),
4942
+ z.strictObject({ segment: journeyKeySchema })
4943
+ ]);
4944
+ /**
4945
+ * The name of the segment a trigger enrolls from, or null when it is an
4946
+ * event trigger. An inline definition materializes a row named after the
4947
+ * journey (ADR 0016); a named one is the segment file's own key (ADR 0023).
4948
+ * Segment names are unique per organization, so this one string is what
4949
+ * every reader — the live trigger, the enable-time enrollment job and the
4950
+ * journey page — resolves the row by.
4896
4951
  *
4897
- * Both members are strict, so a journey holding the retired `source` or
4898
- * `appId` key, or the retired `{ segment: "name" }` form, fails to build
4899
- * instead of silently triggering on every app or on nothing. There is
4900
- * deliberately no pipe filter and no app filter: the journey's own app is
4901
- * the only narrowing there is.
4952
+ * Structural in its trigger parameter so `packages/db`'s mirror of the shape
4953
+ * passes without either package importing the other.
4902
4954
  */
4903
- const triggerSchema = z.union([z.strictObject({ event: patternSchema }), z.strictObject({ segment: segmentDefinitionSchema })]);
4955
+ function triggerSegmentName(journeyKey, trigger) {
4956
+ if (!("segment" in trigger)) return null;
4957
+ return typeof trigger.segment === "string" ? trigger.segment : journeyKey;
4958
+ }
4904
4959
  /**
4905
4960
  * The address half of a from-header: a local part, an `@`, and a dotted
4906
4961
  * domain. Deliberately narrower than RFC 5322 (no quoted local parts, no
@@ -5055,6 +5110,40 @@ const manifestTemplateSchema = z.object({
5055
5110
  propsSchema: z.record(z.string(), z.unknown()),
5056
5111
  bundle: digestSchema
5057
5112
  });
5113
+ /**
5114
+ * One `segments/<key>.ts` file (ADR 0023): an audience several journeys of
5115
+ * one repository enroll from, written once instead of pasted into each
5116
+ * trigger. It carries no bundle and no digest, because it compiles to
5117
+ * nothing: a definition is a predicate list the evaluator folds, so there is
5118
+ * no code for an execution to pin and therefore no version of it.
5119
+ *
5120
+ * Plain rather than strict, for the same reason the other two entries are: a
5121
+ * stored manifest has to keep parsing after a field goes away (ADR 0011).
5122
+ */
5123
+ const manifestSegmentSchema = z.object({
5124
+ key: journeyKeySchema,
5125
+ /** The author's labels, the way a journey and a template carry them. */
5126
+ tags: tagsSchema,
5127
+ /** The author's own sentence about who is in it; absent when unwritten. */
5128
+ description: z.string().trim().max(500, `description must be at most ${500} characters`).optional(),
5129
+ definition: segmentDefinitionSchema
5130
+ });
5131
+ /**
5132
+ * One event pattern the tree reacts to, read off the tree itself: a trigger,
5133
+ * a `waitForEvent`, or an event predicate in a segment. Nobody declares it —
5134
+ * a second source of truth would disagree with the code the first time an
5135
+ * author edited a trigger — so what a build could not read literally is
5136
+ * simply absent, which makes the list incomplete and never wrong.
5137
+ *
5138
+ * The pattern is stored as written: `order.*` is one entry, not an
5139
+ * expansion, because it is what the author asked for and what any reader
5140
+ * has to match against. `usedBy` names the journeys and segments that named
5141
+ * it, sorted, so a reader can say who is waiting.
5142
+ */
5143
+ const manifestEventSchema = z.object({
5144
+ pattern: onePatternSchema,
5145
+ usedBy: z.array(z.string())
5146
+ });
5058
5147
  function uniqueKeys(items, ctx, path) {
5059
5148
  const seen = /* @__PURE__ */ new Set();
5060
5149
  for (const [index, item] of items.entries()) {
@@ -5090,6 +5179,19 @@ const manifestSchema = z.object({
5090
5179
  journeys: z.array(manifestJourneySchema).max(PUSH_LIMITS.journeys),
5091
5180
  templates: z.array(manifestTemplateSchema).max(PUSH_LIMITS.templates),
5092
5181
  /**
5182
+ * The `segments/` files this project pushes (ADR 0023). Defaulted rather
5183
+ * than required: every manifest stored before segments were files carries
5184
+ * none, and a project with no shared audience still writes the field.
5185
+ */
5186
+ segments: z.array(manifestSegmentSchema).max(PUSH_LIMITS.segments).default([]),
5187
+ /**
5188
+ * The event patterns this tree reacts to, collected from the journeys
5189
+ * and segments above rather than declared. Defaulted for the same reason
5190
+ * `segments` is: every manifest stored before the collection existed
5191
+ * carries none.
5192
+ */
5193
+ events: z.array(manifestEventSchema).default([]),
5194
+ /**
5093
5195
  * The consent purposes this project declares, copied from `cow.json`.
5094
5196
  * Optional rather than defaulted: a push made before purposes existed
5095
5197
  * carries none, and its stored manifest still parses.
@@ -5100,7 +5202,22 @@ const manifestSchema = z.object({
5100
5202
  }).superRefine((manifest, ctx) => {
5101
5203
  uniqueKeys(manifest.journeys, ctx, "journeys");
5102
5204
  uniqueKeys(manifest.templates, ctx, "templates");
5205
+ uniqueKeys(manifest.segments, ctx, "segments");
5103
5206
  uniqueKeys(manifest.purposes ?? [], ctx, "purposes");
5207
+ const segmentKeys = new Set(manifest.segments.map((segment) => segment.key));
5208
+ for (const [index, journey] of manifest.journeys.entries()) {
5209
+ const named = "segment" in journey.trigger && typeof journey.trigger.segment === "string" ? journey.trigger.segment : null;
5210
+ if (named !== null && !segmentKeys.has(named)) ctx.addIssue({
5211
+ code: "custom",
5212
+ message: `journey "${journey.key}" enrolls from the segment "${named}", which this push does not carry. Push the segments/${named}.ts file with it, or write the predicate list in the trigger.`,
5213
+ path: [
5214
+ "journeys",
5215
+ index,
5216
+ "trigger",
5217
+ "segment"
5218
+ ]
5219
+ });
5220
+ }
5104
5221
  for (const [index, journey] of manifest.journeys.entries()) if (journey.enrollment && journey.purpose === "transactional") ctx.addIssue({
5105
5222
  code: "custom",
5106
5223
  message: `journey "${journey.key}": a transactional journey enrolls on every trigger, so it takes no enrollment cooldown`,
@@ -5374,6 +5491,34 @@ const eventNameStatsDtoSchema = z.object({
5374
5491
  lastSeenAt: z.iso.datetime().nullable()
5375
5492
  }))
5376
5493
  });
5494
+ /**
5495
+ * Has this app ever received these event names
5496
+ * (GET /v1/apps/{appId}/events/seen?name=a&name=b)? What `cow build` read
5497
+ * off the tree, asked against what the app actually sends.
5498
+ *
5499
+ * "Ever" rather than a window, deliberately: a window fires on anything
5500
+ * seasonal and puts a threshold in copy that would have to be defended. The
5501
+ * last-seen stamp travels beside the answer and the developer judges whether
5502
+ * eight months ago is a problem.
5503
+ *
5504
+ * A name may be a pattern, matched the way ingestion matches one (`*` only,
5505
+ * against the whole name), so `order.*` is seen once `order.paid` has
5506
+ * arrived.
5507
+ *
5508
+ * Repeated rather than comma-joined, because an event name may hold a comma.
5509
+ * One occurrence arrives as a string and several as an array, which is what
5510
+ * the union is for.
5511
+ */
5512
+ const oneEventName = z.string().min(1).max(200);
5513
+ const eventsSeenQuerySchema = z.object({ name: z.union([oneEventName, z.array(oneEventName).max(200, "at most 200 names in one question")]).transform((value) => Array.isArray(value) ? value : [value]) });
5514
+ /**
5515
+ * One answer per name asked, in the order asked. `lastSeenAt` null is the
5516
+ * whole of "never": a name is only ever dated because a row carried it.
5517
+ */
5518
+ const eventsSeenDtoSchema = z.object({ names: z.array(z.object({
5519
+ name: z.string(),
5520
+ lastSeenAt: z.iso.datetime().nullable()
5521
+ })) });
5377
5522
 
5378
5523
  //#endregion
5379
5524
  //#region ../../packages/shared/src/batch.ts
@@ -5951,9 +6096,18 @@ function matchesPattern(pattern, name) {
5951
6096
  return (Array.isArray(pattern) ? pattern : [pattern]).some((one) => matchesOne(one, name));
5952
6097
  }
5953
6098
  function matchesOne(pattern, name) {
5954
- const literals = pattern.split("*");
5955
- if (name.startsWith("system.") && !(literals[0] ?? "").startsWith("system.")) return false;
5956
- return matchesLiterals(literals, name);
6099
+ if (name.startsWith("system.") && !isSystemPattern(pattern)) return false;
6100
+ return matchesLiterals(pattern.split("*"), name);
6101
+ }
6102
+ /**
6103
+ * Whether a pattern reaches into the system namespace: its literal prefix,
6104
+ * the text before its first `*`, is itself under SYSTEM_EVENT_PREFIX. The
6105
+ * system rule above asks it per name; `cow build` asks it per pattern, to
6106
+ * leave the events Cowliss writes itself out of the list of events a
6107
+ * developer is asked to send.
6108
+ */
6109
+ function isSystemPattern(pattern) {
6110
+ return (pattern.split("*")[0] ?? "").startsWith(SYSTEM_EVENT_PREFIX);
5957
6111
  }
5958
6112
  /**
5959
6113
  * Walk the `*`-separated literals left to right: the first anchors the start,
@@ -6343,15 +6497,23 @@ const versionSchema = selectVersionSchema.extend({
6343
6497
  * version" column shows (the short digest, when it was pushed, whether it
6344
6498
  * compiled) and the push its source archive lives on. The manifest entry is
6345
6499
  * left out, because a journey row already carries what its own entry says.
6500
+ *
6501
+ * `bundle` is lifted out of that entry because it is the version's address:
6502
+ * it is there from the moment the push lands, and it does not move when a
6503
+ * recompile rewrites `moduleDigest` (ADR 0011, amended 2026-09-19). The
6504
+ * compiled module's digest is not on the wire at all; nothing outside the
6505
+ * sandbox has a use for it.
6346
6506
  */
6347
6507
  const versionSummarySchema = selectVersionSchema.pick({
6348
6508
  id: true,
6349
6509
  status: true,
6350
- moduleDigest: true,
6351
6510
  pushId: true,
6352
6511
  appId: true,
6353
6512
  error: true
6354
- }).extend({ pushedAt: z.iso.datetime() });
6513
+ }).extend({
6514
+ pushedAt: z.iso.datetime(),
6515
+ bundle: digestSchema
6516
+ });
6355
6517
  /**
6356
6518
  * A push as the API reports it, with the versions it created: `cow push`
6357
6519
  * returns as soon as the upload is stored, so what is compiling, what is
@@ -6388,7 +6550,23 @@ const createPushBodySchema = z.object({ data: z.object({
6388
6550
  * lands silently whenever both orgs happen to hold an app of the same
6389
6551
  * slug, which is exactly the shape `apps/platform-workspace` has.
6390
6552
  */
6391
- orgId: orgIdSchema
6553
+ orgId: orgIdSchema,
6554
+ /**
6555
+ * Delete the `segments/` rows this app holds and this manifest does not
6556
+ * carry (`cow push --prune`, ADR 0024). Off by default, which is what a
6557
+ * push has always been: additive.
6558
+ *
6559
+ * Only segments, because only a push may write or unwrite one — the
6560
+ * dashboard refuses to touch a pushed row and tells the reader to change
6561
+ * the file and push again, which is this flag. Journeys and templates are
6562
+ * not here: a journey has its own delete verb, which `cow push --prune`
6563
+ * calls after the push, and a template's lifecycle belongs to the version
6564
+ * prune sweep because a live execution pins the version it started on.
6565
+ *
6566
+ * `pushes.plan` takes the same body and ignores this: a plan writes
6567
+ * nothing, and the drift it already reports is what a prune would act on.
6568
+ */
6569
+ prune: z.boolean().default(false)
6392
6570
  }) });
6393
6571
  const listPushesQuerySchema = paginationQuerySchema.extend({
6394
6572
  /** One app's own pushes; absent lists the org's. */
@@ -6832,6 +7010,142 @@ const notificationPreferencesSchema = z.object({ purposes: z.array(notificationP
6832
7010
  */
6833
7011
  const updateNotificationPreferencesBodySchema = z.object({ data: z.object({ purposes: consentPatchSchema }) });
6834
7012
 
7013
+ //#endregion
7014
+ //#region ../../packages/shared/src/stable-json.ts
7015
+ /**
7016
+ * A key-sorted JSON rendering, for comparing or hashing two values a JSON
7017
+ * round trip may have reordered. `undefined` members are dropped the way
7018
+ * `JSON.stringify` drops them.
7019
+ *
7020
+ * Its own module with no imports at all, because both users need it and
7021
+ * they sit on opposite sides of a boundary: the release digest (which pulls
7022
+ * in node:crypto) and the compile workflow (which may not pull in anything).
7023
+ */
7024
+ function stableJson(value) {
7025
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
7026
+ if (value !== null && typeof value === "object") return `{${Object.entries(value).filter(([, item]) => item !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`).join(",")}}`;
7027
+ return JSON.stringify(value) ?? "null";
7028
+ }
7029
+
7030
+ //#endregion
7031
+ //#region ../../packages/shared/src/plan.ts
7032
+ /**
7033
+ * What a push would change, computed before anything is stored (ADR 0025).
7034
+ *
7035
+ * The server is the state: a checkout holds no record of what it last
7036
+ * pushed, so the baseline is the account itself — the latest version of
7037
+ * every key and the segment rows the repository writes. The comparison
7038
+ * below is the only one there is. `pushes.plan` runs it against those rows;
7039
+ * `cow status` runs it against the same entries read back through
7040
+ * `apps.status`, so the two can never disagree about what "changed" means.
7041
+ */
7042
+ const PLAN_KINDS = [
7043
+ "journey",
7044
+ "segment",
7045
+ "template"
7046
+ ];
7047
+ const planKindSchema = z.enum(PLAN_KINDS);
7048
+ const planEntrySchema = z.object({
7049
+ kind: planKindSchema,
7050
+ key: z.string(),
7051
+ change: z.enum([
7052
+ "added",
7053
+ "changed",
7054
+ "unchanged"
7055
+ ]),
7056
+ /**
7057
+ * Which of the entry's fields differ from the one the account holds,
7058
+ * sorted; empty unless `change` is `changed`. Named rather than counted,
7059
+ * because "winback changed" and "winback's from changed" are answers to
7060
+ * different questions.
7061
+ */
7062
+ fields: z.array(z.string())
7063
+ });
7064
+ /**
7065
+ * Something the account holds that this push does not write. Reported and
7066
+ * never reconciled: the dashboard stays fully editable, and a push that
7067
+ * quietly undid what someone did there would be the worse surprise.
7068
+ *
7069
+ * `not-in-tree` is a key the repository no longer has (deleting it is its
7070
+ * own verb); `on-hold` is a journey someone put on hold, which a push
7071
+ * carrying new code for it still leaves on hold.
7072
+ */
7073
+ const planDriftSchema = z.object({
7074
+ kind: planKindSchema,
7075
+ key: z.string(),
7076
+ reason: z.enum(["not-in-tree", "on-hold"])
7077
+ });
7078
+ const pushPlanSchema = z.object({
7079
+ appId: appIdSchema,
7080
+ /** One per key the tree carries, in reading order: journeys, segments, templates. */
7081
+ entries: z.array(planEntrySchema),
7082
+ drift: z.array(planDriftSchema)
7083
+ });
7084
+ /**
7085
+ * `spine` never takes part: it is read off the same source the bundle is
7086
+ * built from, so it can only ever move together with `bundle`, and naming
7087
+ * both would say one thing twice.
7088
+ */
7089
+ function entrySide(kind, entry) {
7090
+ const { key, spine: _spine, ...fields } = entry;
7091
+ return {
7092
+ kind,
7093
+ key,
7094
+ fields
7095
+ };
7096
+ }
7097
+ /** Every key the tree carries, as the plan compares them. */
7098
+ function manifestSides(manifest) {
7099
+ return [
7100
+ ...manifest.journeys.map((journey) => entrySide("journey", journey)),
7101
+ ...manifest.segments.map((segment) => entrySide("segment", segment)),
7102
+ ...manifest.templates.map((template) => entrySide("template", template))
7103
+ ];
7104
+ }
7105
+ /** Journeys, then segments, then templates; alphabetical within each. */
7106
+ function order(one) {
7107
+ return `${PLAN_KINDS.indexOf(one.kind)}${one.key}`;
7108
+ }
7109
+ function sameKey(a) {
7110
+ return (b) => b.kind === a.kind && b.key === a.key;
7111
+ }
7112
+ /** The whole diff: what this tree would write, and what it leaves alone. */
7113
+ function pushPlan(appId, local, served) {
7114
+ const entries = local.map((side) => {
7115
+ const held = served.find(sameKey(side));
7116
+ if (!held) return {
7117
+ kind: side.kind,
7118
+ key: side.key,
7119
+ change: "added",
7120
+ fields: []
7121
+ };
7122
+ const fields = [.../* @__PURE__ */ new Set([...Object.keys(side.fields), ...Object.keys(held.fields)])].filter((field) => stableJson(side.fields[field]) !== stableJson(held.fields[field])).sort();
7123
+ return {
7124
+ kind: side.kind,
7125
+ key: side.key,
7126
+ change: fields.length === 0 ? "unchanged" : "changed",
7127
+ fields
7128
+ };
7129
+ });
7130
+ const drift = served.map((side) => {
7131
+ if (!local.some(sameKey(side))) return {
7132
+ kind: side.kind,
7133
+ key: side.key,
7134
+ reason: "not-in-tree"
7135
+ };
7136
+ return side.onHold === true ? {
7137
+ kind: side.kind,
7138
+ key: side.key,
7139
+ reason: "on-hold"
7140
+ } : null;
7141
+ });
7142
+ return {
7143
+ appId,
7144
+ entries: entries.sort((a, b) => order(a).localeCompare(order(b))),
7145
+ drift: drift.filter((one) => one !== null).sort((a, b) => order(a).localeCompare(order(b)))
7146
+ };
7147
+ }
7148
+
6835
7149
  //#endregion
6836
7150
  //#region ../../packages/shared/src/resend.ts
6837
7151
  /** Resend's own cap on one message's recipients, per list. */
@@ -7733,24 +8047,48 @@ function readCliPackage() {
7733
8047
  return cached;
7734
8048
  }
7735
8049
 
8050
+ //#endregion
8051
+ //#region src/build/events.ts
8052
+ function collectEvents(tree) {
8053
+ const named = /* @__PURE__ */ new Map();
8054
+ const add = (key, pattern) => {
8055
+ if (pattern.replaceAll("*", "") === "" || isSystemPattern(pattern)) return;
8056
+ const keys = named.get(pattern) ?? /* @__PURE__ */ new Set();
8057
+ keys.add(key);
8058
+ named.set(pattern, keys);
8059
+ };
8060
+ const addDefinition = (key, definition) => {
8061
+ for (const predicate of definition.predicates) if (predicate.kind === "event") add(key, predicate.name);
8062
+ };
8063
+ for (const journey of tree.journeys) {
8064
+ if ("event" in journey.trigger) for (const pattern of [journey.trigger.event].flat()) add(journey.key, pattern);
8065
+ else if (typeof journey.trigger.segment !== "string") addDefinition(journey.key, journey.trigger.segment);
8066
+ for (const pattern of journey.waits ?? []) add(journey.key, pattern);
8067
+ }
8068
+ for (const segment of tree.segments ?? []) addDefinition(segment.key, segment.definition);
8069
+ return [...named].map(([pattern, keys]) => ({
8070
+ pattern,
8071
+ usedBy: [...keys].sort(byCodePoint)
8072
+ })).sort((a, b) => byCodePoint(a.pattern, b.pattern));
8073
+ }
8074
+ function byCodePoint(a, b) {
8075
+ return a < b ? -1 : a > b ? 1 : 0;
8076
+ }
8077
+
7736
8078
  //#endregion
7737
8079
  //#region src/build/spine.ts
7738
8080
  /**
7739
- * The step spine, read off the source of `run` rather than recorded by
7740
- * running it: a run against stub data follows one path and never sees an
7741
- * `if` on a profile trait, which is the shape of half the journeys there
7742
- * are. Reading the code sees every branch, labelled with its condition.
7743
- *
7744
- * What it reads: `await api.*` calls (in source order, with the literal
7745
- * template, webhook, event, key, or duration when the author wrote one
7746
- * inline), `if`/`else`, `switch`, loops, `try`/`catch`, `return`, `throw`, and
7747
- * `api.restart()`. Calls behind a helper function in another file, and a
7748
- * `run` that is not an inline function on the `defineJourney` object, are
7749
- * not followed: the spine is display only, and its ceiling is the source in
7750
- * front of it. The typecheck that ran first is TypeScript's; syntax it accepts
7751
- * and this parser does not yields an empty spine, never a failed build.
7752
- */
7753
- function readSpine(source) {
8081
+ * What one journey's source says: its spine, and every event pattern an
8082
+ * `api.waitForEvent` in it names literally, in source order and with a list
8083
+ * argument flattened.
8084
+ *
8085
+ * The patterns are read here rather than off the spine because the spine's
8086
+ * `detail` is a label: a non-literal argument shows as the source text the
8087
+ * author wrote, which reads fine and would collect `name` as an event name.
8088
+ * A caller that needs the data takes this; a caller that needs the display
8089
+ * takes `readSpine`.
8090
+ */
8091
+ function readJourneySource(source) {
7754
8092
  let program;
7755
8093
  try {
7756
8094
  program = parse(source, {
@@ -7759,22 +8097,37 @@ function readSpine(source) {
7759
8097
  errorRecovery: true
7760
8098
  }).program;
7761
8099
  } catch {
7762
- return [];
8100
+ return {
8101
+ spine: [],
8102
+ waits: []
8103
+ };
7763
8104
  }
7764
8105
  const run = findRun(program);
7765
- if (!run) return [];
8106
+ if (!run) return {
8107
+ spine: [],
8108
+ waits: []
8109
+ };
7766
8110
  const api = run.params[1];
7767
- if (api?.type !== "Identifier") return [];
8111
+ if (api?.type !== "Identifier") return {
8112
+ spine: [],
8113
+ waits: []
8114
+ };
7768
8115
  const reader = new Reader(source, api.name);
7769
- if (run.body.type !== "BlockStatement") return [...reader.calls(run.body), {
7770
- name: "end",
7771
- detail: "returned"
7772
- }];
8116
+ if (run.body.type !== "BlockStatement") return {
8117
+ spine: [...reader.calls(run.body), {
8118
+ name: "end",
8119
+ detail: "returned"
8120
+ }],
8121
+ waits: reader.waitPatterns
8122
+ };
7773
8123
  const body = reader.block(run.body.body);
7774
- return body.ended ? body.entries : [...body.entries, {
7775
- name: "end",
7776
- detail: "returned"
7777
- }];
8124
+ return {
8125
+ spine: body.ended ? body.entries : [...body.entries, {
8126
+ name: "end",
8127
+ detail: "returned"
8128
+ }],
8129
+ waits: reader.waitPatterns
8130
+ };
7778
8131
  }
7779
8132
  /** The `run` function on the object `export default defineJourney({...})` receives. */
7780
8133
  function findRun(program) {
@@ -7798,6 +8151,8 @@ var Reader = class {
7798
8151
  api;
7799
8152
  /** Variables holding a `waitForEvent` result, by name, to the event waited for. */
7800
8153
  waits = /* @__PURE__ */ new Map();
8154
+ /** Every pattern a `waitForEvent` named literally, in source order. */
8155
+ waitPatterns = [];
7801
8156
  constructor(source, api) {
7802
8157
  this.source = source;
7803
8158
  this.api = api;
@@ -7948,7 +8303,9 @@ var Reader = class {
7948
8303
  detail(first ? this.literal(first) ?? this.text(first) : void 0);
7949
8304
  break;
7950
8305
  case "waitForEvent": {
7951
- detail(first ? this.literalPattern(first) ?? this.text(first) : void 0);
8306
+ const patterns = this.literalPatterns(first);
8307
+ if (patterns) this.waitPatterns.push(...patterns);
8308
+ detail(patterns ? patternLabel(patterns) : first ? this.text(first) : void 0);
7952
8309
  const timeout = this.property(args[1], "timeout");
7953
8310
  if (timeout) entry.timeout = timeout;
7954
8311
  break;
@@ -7994,12 +8351,22 @@ var Reader = class {
7994
8351
  }
7995
8352
  /** A literal pattern, one or a list, the way the trigger label shows it. */
7996
8353
  literalPattern(node) {
8354
+ const patterns = this.literalPatterns(node);
8355
+ return patterns === void 0 ? void 0 : patternLabel(patterns);
8356
+ }
8357
+ /**
8358
+ * The patterns a matcher argument names, or undefined when any of them is
8359
+ * not a literal: a variable or a helper call is a pattern this read cannot
8360
+ * know, and half a list is worse than none of it.
8361
+ */
8362
+ literalPatterns(node) {
7997
8363
  if (!node) return;
7998
8364
  if (node.type === "ArrayExpression") {
7999
8365
  const items = node.elements.map((item) => item ? this.literal(item) : void 0);
8000
- return items.every((item) => item !== void 0) ? patternLabel(items) : void 0;
8366
+ return items.every((item) => item !== void 0) ? items : void 0;
8001
8367
  }
8002
- return this.literal(node);
8368
+ const one = this.literal(node);
8369
+ return one === void 0 ? void 0 : [one];
8003
8370
  }
8004
8371
  /** The literal value of `key` in an inline object argument. */
8005
8372
  property(node, key) {
@@ -8143,6 +8510,7 @@ const CONFIG_FILES = [
8143
8510
  const GUEST_ALIAS = {
8144
8511
  "@cowliss/cli/journeys": guestFile("journeys"),
8145
8512
  "@cowliss/cli/emails": guestFile("emails"),
8513
+ "@cowliss/cli/segments": guestFile("segments"),
8146
8514
  "@cowliss/cli/guest": guestFile("guest"),
8147
8515
  "@cowliss/cli/guest-emails": guestFile("guest-emails"),
8148
8516
  "@cowliss/cli/guest-wasi": guestFile("guest-wasi"),
@@ -8183,14 +8551,15 @@ async function projectSources(projectDir) {
8183
8551
  return [
8184
8552
  ...(await Promise.all(CONFIG_FILES.map(async (file) => await stat(join(projectDir, file)).then(() => file, () => null)))).filter((file) => file !== null),
8185
8553
  ...(await listFiles(join(projectDir, "journeys"))).map((file) => `journeys/${file}`),
8186
- ...(await listFiles(join(projectDir, "emails"))).map((file) => `emails/${file}`)
8554
+ ...(await listFiles(join(projectDir, "emails"))).map((file) => `emails/${file}`),
8555
+ ...(await listFiles(join(projectDir, "segments"))).map((file) => `segments/${file}`)
8187
8556
  ].sort();
8188
8557
  }
8189
8558
  async function discover(projectDir, kind, extension) {
8190
8559
  const dir = join(projectDir, kind);
8191
8560
  return (await listFiles(dir)).filter((file) => file.endsWith(extension) && !file.endsWith(".d.ts") && !basename(file).startsWith("_")).map((file) => {
8192
8561
  const key = basename(file, extension);
8193
- if (!journeyKeySchema.safeParse(key).success) throw new Error(`${kind}/${file}: "${key}" is not a usable key. A journey or template file is named in kebab-case (a-z, 0-9, hyphens).`);
8562
+ if (!journeyKeySchema.safeParse(key).success) throw new Error(`${kind}/${file}: "${key}" is not a usable key. A journey, template or segment file is named in kebab-case (a-z, 0-9, hyphens).`);
8194
8563
  return {
8195
8564
  key,
8196
8565
  kind,
@@ -8214,6 +8583,20 @@ function assertUniqueKeys(files) {
8214
8583
  }
8215
8584
  }
8216
8585
  /**
8586
+ * A trigger that names its segment resolves inside this one tree, or the
8587
+ * build stops here (ADR 0023). This is the whole reason the by-name form is
8588
+ * safe to have back: the name is a file in the repository the author is
8589
+ * looking at, never a row somebody renamed in the dashboard. Said with the
8590
+ * file in hand and the names that do exist, because the two ways out are
8591
+ * writing the missing file and fixing a typo.
8592
+ */
8593
+ function assertSegmentResolves(relPath, trigger, segmentKeys) {
8594
+ if (!("segment" in trigger) || typeof trigger.segment !== "string") return;
8595
+ if (segmentKeys.has(trigger.segment)) return;
8596
+ const available = segmentKeys.size === 0 ? "This project has no segments yet." : `This project's segments are ${[...segmentKeys].sort().map((key) => `"${key}"`).join(", ")}.`;
8597
+ throw new Error(`${relPath}: this journey enrolls from the segment "${trigger.segment}", and there is no segments/${trigger.segment}.ts. ${available} Add that file, or write the predicate list in the trigger.`);
8598
+ }
8599
+ /**
8217
8600
  * `.cow/types.d.ts`: one `typeof import(...)` per template, merged into the
8218
8601
  * SDK's `CowTemplates`, which is what types `api.send.email`. Written before
8219
8602
  * the typecheck, because the typecheck is what it exists for.
@@ -8259,7 +8642,7 @@ async function typecheck(projectDir) {
8259
8642
  });
8260
8643
  } catch (error) {
8261
8644
  const output = error;
8262
- const report = (output.stdout ?? "").trim() || (output.stderr ?? "").trim();
8645
+ const report = (output.stdout ?? "").trim() || (output.stderr ?? "").trim() || String(error.message ?? "");
8263
8646
  throw new Error(`Typecheck failed:\n${report}`);
8264
8647
  }
8265
8648
  }
@@ -8351,12 +8734,16 @@ import { runGuest } from ${JSON.stringify(DRIVER[kind])};
8351
8734
  main(module, runGuest);
8352
8735
  `;
8353
8736
  }
8354
- /** The local twin: the same graph, importable by Node for the manifest and `cow test`. */
8737
+ /**
8738
+ * The local twin: the same graph, importable by Node for the manifest and
8739
+ * `cow test`. A segment file gets the module and no driver: it compiles to
8740
+ * nothing the sandbox ever runs, so there is no guest protocol to speak.
8741
+ */
8355
8742
  function nodeEntry(file, kind) {
8743
+ const driver = kind === "segments" ? "" : `export { runGuest } from ${JSON.stringify(DRIVER[kind])};\n`;
8356
8744
  return `import * as module from ${JSON.stringify(file)};
8357
8745
  export { module };
8358
- export { runGuest } from ${JSON.stringify(DRIVER[kind])};
8359
- `;
8746
+ ${driver}`;
8360
8747
  }
8361
8748
  let messagePortsUnreffed = false;
8362
8749
  /**
@@ -8385,15 +8772,28 @@ function unrefMessagePorts() {
8385
8772
  * rebuilt bundle from being served out of Node's module cache.
8386
8773
  */
8387
8774
  async function loadNodeBundle(projectDir, key, kind = "journeys") {
8388
- unrefMessagePorts();
8389
- const path = join(projectDir, BUILD_DIR, "node", kind, `${key}.mjs`);
8390
- const { mtimeNs } = await stat(path, { bigint: true });
8391
- const loaded = await import(`${pathToFileURL(path).href}?v=${mtimeNs}`);
8775
+ const loaded = await importNodeBundle(projectDir, key, kind);
8392
8776
  return {
8393
8777
  module: loaded.module,
8394
8778
  runGuest: loaded.runGuest
8395
8779
  };
8396
8780
  }
8781
+ async function importNodeBundle(projectDir, key, kind) {
8782
+ unrefMessagePorts();
8783
+ const path = join(projectDir, BUILD_DIR, "node", kind, `${key}.mjs`);
8784
+ const { mtimeNs } = await stat(path, { bigint: true });
8785
+ return await import(`${pathToFileURL(path).href}?v=${mtimeNs}`);
8786
+ }
8787
+ /**
8788
+ * A built segment file's own export. No driver and no guest protocol: the
8789
+ * definition is data `defineSegment` already validated at module load, so
8790
+ * reading it is an import and a shape check.
8791
+ */
8792
+ async function loadSegment(projectDir, key) {
8793
+ const { module } = await importNodeBundle(projectDir, key, "segments");
8794
+ const segment = module.default;
8795
+ return segment?.definition ? segment : void 0;
8796
+ }
8397
8797
  /**
8398
8798
  * A profile with nothing in it but the id and traits given: the base the
8399
8799
  * simulator answers `profile.get` with.
@@ -8421,6 +8821,7 @@ function emptyProfile(id = "", traits = {}, purposes = []) {
8421
8821
  function limitFailure(sizes) {
8422
8822
  if (sizes.journeys > PUSH_LIMITS.journeys) return `This project has ${sizes.journeys} journeys; a push carries at most ${PUSH_LIMITS.journeys}.`;
8423
8823
  if (sizes.templates > PUSH_LIMITS.templates) return `This project has ${sizes.templates} templates; a push carries at most ${PUSH_LIMITS.templates}.`;
8824
+ if (sizes.segments > PUSH_LIMITS.segments) return `This project has ${sizes.segments} segments; a push carries at most ${PUSH_LIMITS.segments}.`;
8424
8825
  for (const bundle of sizes.bundles) if (bundle.bytes > PUSH_LIMITS.bundleBytes) return `Bundle "${bundle.key}" is ${bundle.bytes} bytes; a bundle is at most ${PUSH_LIMITS.bundleBytes}.`;
8425
8826
  if (sizes.sourceBytes > PUSH_LIMITS.sourceBytes) return `The source tarball is ${sizes.sourceBytes} bytes; a push carries at most ${PUSH_LIMITS.sourceBytes}.`;
8426
8827
  return null;
@@ -8496,13 +8897,16 @@ async function buildProject(projectDir) {
8496
8897
  const config = await assertCowConfig(projectDir);
8497
8898
  const journeys = await discover(projectDir, "journeys", ".ts");
8498
8899
  const templates = await discover(projectDir, "emails", ".tsx");
8900
+ const segmentFiles = await discover(projectDir, "segments", ".ts");
8499
8901
  assertUniqueKeys(journeys);
8500
8902
  assertUniqueKeys(templates);
8903
+ assertUniqueKeys(segmentFiles);
8501
8904
  const outDir = join(projectDir, BUILD_DIR);
8502
8905
  for (const kind of ["journeys", "emails"]) {
8503
8906
  await mkdir(join(outDir, "bundles", kind), { recursive: true });
8504
8907
  await mkdir(join(outDir, "node", kind), { recursive: true });
8505
8908
  }
8909
+ await mkdir(join(outDir, "node", "segments"), { recursive: true });
8506
8910
  await writeFile(join(projectDir, TYPES_FILE), templateTypes(templates));
8507
8911
  await typecheck(projectDir);
8508
8912
  const files = [...journeys, ...templates];
@@ -8520,8 +8924,29 @@ async function buildProject(projectDir) {
8520
8924
  bytes: wasi.code.byteLength
8521
8925
  };
8522
8926
  }));
8927
+ const manifestSegments = await Promise.all(segmentFiles.map(async (source) => {
8928
+ const nodeFile = join(outDir, "node", source.kind, `${source.key}.mjs`);
8929
+ const node = await bundle(projectDir, nodeFile, nodeEntry(source.file, source.kind));
8930
+ await writeFile(nodeFile, node.code);
8931
+ await writeFile(`${nodeFile}.map`, node.map);
8932
+ let segment;
8933
+ try {
8934
+ segment = await loadSegment(projectDir, source.key);
8935
+ } catch (error) {
8936
+ throw new Error(`${source.relPath}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
8937
+ }
8938
+ if (!segment) throw new Error(`${source.relPath} is not a segment: a segment file default-exports defineSegment({ predicates: [ ... ] }).`);
8939
+ return {
8940
+ key: source.key,
8941
+ tags: segment.tags,
8942
+ description: segment.description,
8943
+ definition: segment.definition
8944
+ };
8945
+ }));
8946
+ const segmentKeys = new Set(manifestSegments.map((segment) => segment.key));
8523
8947
  const manifestJourneys = [];
8524
8948
  const manifestTemplates = [];
8949
+ const journeyWaits = /* @__PURE__ */ new Map();
8525
8950
  for (const built of bundles) {
8526
8951
  let report;
8527
8952
  try {
@@ -8532,18 +8957,22 @@ async function buildProject(projectDir) {
8532
8957
  }
8533
8958
  const isJourney = built.source.kind === "journeys";
8534
8959
  if (report?.kind !== (isJourney ? "journey" : "template")) throw new Error(`${built.source.relPath} is not a ${isJourney ? "journey" : "template"}: a journey default-exports defineJourney({ ... }), a template exports a component and a zod \`props\` schema.`);
8535
- if (report.kind === "journey") manifestJourneys.push({
8536
- key: built.source.key,
8537
- tags: report.tags,
8538
- trigger: report.trigger,
8539
- purpose: report.purpose,
8540
- enrollment: report.enrollment,
8541
- description: report.description,
8542
- from: report.from,
8543
- spine: readSpine(await readFile(built.source.file, "utf8")),
8544
- bundle: built.digest
8545
- });
8546
- else manifestTemplates.push({
8960
+ if (report.kind === "journey") {
8961
+ assertSegmentResolves(built.source.relPath, report.trigger, segmentKeys);
8962
+ const { spine, waits } = readJourneySource(await readFile(built.source.file, "utf8"));
8963
+ manifestJourneys.push({
8964
+ key: built.source.key,
8965
+ tags: report.tags,
8966
+ trigger: report.trigger,
8967
+ purpose: report.purpose,
8968
+ enrollment: report.enrollment,
8969
+ description: report.description,
8970
+ from: report.from,
8971
+ spine,
8972
+ bundle: built.digest
8973
+ });
8974
+ journeyWaits.set(built.source.key, waits);
8975
+ } else manifestTemplates.push({
8547
8976
  key: built.source.key,
8548
8977
  tags: report.tags,
8549
8978
  sendClass: report.sendClass,
@@ -8558,6 +8987,7 @@ async function buildProject(projectDir) {
8558
8987
  const failure = limitFailure({
8559
8988
  journeys: journeys.length,
8560
8989
  templates: templates.length,
8990
+ segments: segmentFiles.length,
8561
8991
  bundles: bundles.map((built) => ({
8562
8992
  key: built.source.key,
8563
8993
  bytes: built.bytes
@@ -8570,6 +9000,14 @@ async function buildProject(projectDir) {
8570
9000
  sdk: (await readCliPackage()).version,
8571
9001
  journeys: manifestJourneys,
8572
9002
  templates: manifestTemplates,
9003
+ segments: manifestSegments,
9004
+ events: collectEvents({
9005
+ journeys: manifestJourneys.map((journey) => ({
9006
+ ...journey,
9007
+ waits: journeyWaits.get(journey.key)
9008
+ })),
9009
+ segments: manifestSegments
9010
+ }),
8573
9011
  purposes: config.purposes,
8574
9012
  source: digestOf(await readFile(sourceFile))
8575
9013
  });
@@ -8605,8 +9043,15 @@ async function buildIfStale(projectDir) {
8605
9043
 
8606
9044
  //#endregion
8607
9045
  //#region src/lib/config.ts
8608
- const DEFAULT_API_URL = "http://localhost:3400";
8609
- const DEFAULT_WEB_URL = "http://localhost:5273";
9046
+ /**
9047
+ * Where a `cow` that was told nothing talks to: the hosted service. A
9048
+ * developer running Cowliss itself points a checkout somewhere else with
9049
+ * `COW_API_URL` or `apiUrl` in `cow.json`, which both outrank this; the
9050
+ * default is the last resort, and the last resort of a published CLI cannot
9051
+ * be a port on the reader's own machine.
9052
+ */
9053
+ const DEFAULT_API_URL = "https://api.cowliss.com";
9054
+ const DEFAULT_WEB_URL = "https://app.cowliss.com";
8610
9055
  function credentialsPath(env) {
8611
9056
  return env.COW_CREDENTIALS_PATH ?? defaultCredentialsPath();
8612
9057
  }
@@ -8658,20 +9103,20 @@ function resolveCredential(env, credentials) {
8658
9103
  * The project's own `apiUrl` outranks the credentials file because it is the
8659
9104
  * more specific answer: `cow.json` says which API this project talks to, while the
8660
9105
  * credentials only remember where somebody last logged in. Without it a
8661
- * checkout whose config names production silently fell through to
8662
- * `DEFAULT_API_URL`, which is how a production deploy reaches localhost.
9106
+ * checkout whose config names one deployment silently fell through to
9107
+ * whatever the last `cow login` had saved.
8663
9108
  */
8664
9109
  function resolveApiUrl(env, credentials, flag, projectApiUrl) {
8665
- return flag ?? env.COW_API_URL ?? projectApiUrl ?? credentials?.apiUrl ?? "http://localhost:3400";
9110
+ return flag ?? env.COW_API_URL ?? projectApiUrl ?? credentials?.apiUrl ?? "https://api.cowliss.com";
8666
9111
  }
8667
9112
  /**
8668
9113
  * The dashboard this project belongs to: the flag, the env var, the
8669
9114
  * project's own `cow.json`, then the default. Same precedence as the API URL
8670
- * and for the same reason: a checkout pointed at production must not send
8671
- * the developer (or a push's links) to localhost.
9115
+ * and for the same reason: a checkout pointed at one deployment must not
9116
+ * send the developer (or a push's links) to another.
8672
9117
  */
8673
9118
  function resolveWebUrl(env, projectWebUrl, flag) {
8674
- return flag ?? env.COW_WEB_URL ?? projectWebUrl ?? "http://localhost:5273";
9119
+ return flag ?? env.COW_WEB_URL ?? projectWebUrl ?? "https://app.cowliss.com";
8675
9120
  }
8676
9121
  /**
8677
9122
  * Decode a JWT payload without verification. Display only: the API is the
@@ -8704,9 +9149,9 @@ function decodeSessionToken(token) {
8704
9149
  //#endregion
8705
9150
  //#region src/commands/add.ts
8706
9151
  /**
8707
- * `cow add example <name>`: copy one of the gallery examples the package
9152
+ * `cow add blueprint <name>`: copy one of the gallery blueprints the package
8708
9153
  * ships into the project in the current directory. The same files back
8709
- * `cow init --example` and the docs gallery, so what the docs render is
9154
+ * `cow init --blueprint` and the docs gallery, so what the docs render is
8710
9155
  * exactly what lands in a developer's repo.
8711
9156
  *
8712
9157
  * Resolved from the package's own manifest, the self-reference
@@ -8715,29 +9160,30 @@ function decodeSessionToken(token) {
8715
9160
  * bundled `dist/index.js`, and the published layout is the one nothing in
8716
9161
  * the monorepo exercises.
8717
9162
  */
8718
- const EXAMPLES_DIR = fileURLToPath(new URL("examples/", import.meta.resolve("@cowliss/cli/package.json")));
8719
- /** An example's copyable directories, in the order files are reported. */
9163
+ const BLUEPRINTS_DIR = fileURLToPath(new URL("blueprints/", import.meta.resolve("@cowliss/cli/package.json")));
9164
+ /** A blueprint's copyable directories, in the order files are reported. */
8720
9165
  const PARTS = [
8721
9166
  "journeys",
8722
9167
  "emails",
9168
+ "segments",
8723
9169
  "scenarios"
8724
9170
  ];
8725
- async function exampleNames() {
8726
- return (await readdir(EXAMPLES_DIR, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
9171
+ async function blueprintNames() {
9172
+ return (await readdir(BLUEPRINTS_DIR, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
8727
9173
  }
8728
9174
  /**
8729
- * Copy an example's files into `dir`, returning the project-relative paths
9175
+ * Copy a blueprint's files into `dir`, returning the project-relative paths
8730
9176
  * written. Collected and checked before anything is written, so a refusal on
8731
9177
  * the last file never leaves the first half copied.
8732
9178
  */
8733
- async function copyExample(name, dir, force) {
8734
- const known = await exampleNames();
8735
- if (!known.includes(name)) throw new Error(`Unknown example "${name}". Known examples: ${known.join(", ")}`);
9179
+ async function copyBlueprint(name, dir, force) {
9180
+ const known = await blueprintNames();
9181
+ if (!known.includes(name)) throw new Error(`Unknown blueprint "${name}". Known blueprints: ${known.join(", ")}`);
8736
9182
  const files = [];
8737
9183
  for (const part of PARTS) {
8738
9184
  let entries;
8739
9185
  try {
8740
- entries = await readdir(join(EXAMPLES_DIR, name, part));
9186
+ entries = await readdir(join(BLUEPRINTS_DIR, name, part));
8741
9187
  } catch {
8742
9188
  continue;
8743
9189
  }
@@ -8747,21 +9193,21 @@ async function copyExample(name, dir, force) {
8747
9193
  if (clashes.length > 0) throw new Error(`Already in this project: ${clashes.join(", ")}. Pass --force to overwrite.`);
8748
9194
  for (const file of files) {
8749
9195
  await mkdir(dirname(join(dir, file)), { recursive: true });
8750
- await copyFile(join(EXAMPLES_DIR, name, file), join(dir, file));
9196
+ await copyFile(join(BLUEPRINTS_DIR, name, file), join(dir, file));
8751
9197
  }
8752
9198
  return files;
8753
9199
  }
8754
9200
  function registerAdd(program, io) {
8755
- program.command("add").description("add gallery example code to a cow repo").command("example <name>").description("copy an example's journeys, emails, and scenarios into this repo (local: no API call)").option("--force", "overwrite files that already exist").action(async (name, opts) => {
9201
+ program.command("add").description("add gallery blueprint code to a cow repo").command("blueprint <name>").description("copy a blueprint's journeys, emails, segments, and scenarios into this repo (local: no API call)").option("--force", "overwrite files that already exist").action(async (name, opts) => {
8756
9202
  const dir = process.cwd();
8757
9203
  await assertCowConfig(dir);
8758
- const files = await copyExample(name, dir, opts.force === true);
9204
+ const files = await copyBlueprint(name, dir, opts.force === true);
8759
9205
  emit({ data: {
8760
- example: name,
9206
+ blueprint: name,
8761
9207
  dir,
8762
9208
  files
8763
9209
  } }, io, program.opts().json);
8764
- io.stderr(`Added the ${name} example:\n${files.map((file) => ` ${file}`).join("\n")}\n`);
9210
+ io.stderr(`Added the ${name} blueprint:\n${files.map((file) => ` ${file}`).join("\n")}\n`);
8765
9211
  });
8766
9212
  }
8767
9213
 
@@ -10029,6 +10475,23 @@ const events = defineModule(defineRoute({
10029
10475
  ...sessionErrors,
10030
10476
  ...errors("validation_failed")
10031
10477
  }
10478
+ }), defineRoute({
10479
+ method: "get",
10480
+ path: "/v1/apps/{appId}/events/seen",
10481
+ operationId: "apps.events.seen",
10482
+ tags: ["events"],
10483
+ summary: "Whether an app has ever received these event names",
10484
+ security: PIPELINE_AUTH,
10485
+ surfaces: { cli: false },
10486
+ request: {
10487
+ params: z.object({ appId: z.string() }),
10488
+ query: eventsSeenQuerySchema
10489
+ },
10490
+ responses: {
10491
+ 200: envelope(eventsSeenDtoSchema),
10492
+ ...sessionErrors,
10493
+ ...errors("not_found", "validation_failed")
10494
+ }
10032
10495
  }), defineRoute({
10033
10496
  method: "get",
10034
10497
  path: "/v1/events/{id}",
@@ -10421,12 +10884,26 @@ const params$6 = z.object({ id: z.string() });
10421
10884
  * push stores the source archive and its compile creates a version of every
10422
10885
  * key whose module changed; it turns nothing on (ADR 0011).
10423
10886
  *
10424
- * `create` and `source` are hidden from the CLI and MCP surfaces because
10425
- * both only make sense next to local files: the manifest `create` takes is
10426
- * whatever `cow build` just wrote, and the tarball `source` returns is what
10427
- * `cow pull` unpacks over a working tree.
10887
+ * `plan`, `create` and `source` are hidden from the CLI and MCP surfaces
10888
+ * because all three only make sense next to local files: the manifest the
10889
+ * first two take is whatever `cow build` just wrote, and the tarball
10890
+ * `source` returns is what `cow pull` unpacks over a working tree.
10428
10891
  */
10429
10892
  const pushes = defineModule(defineRoute({
10893
+ method: "post",
10894
+ path: "/v1/pushes/plan",
10895
+ operationId: "pushes.plan",
10896
+ tags: ["pushes"],
10897
+ summary: "What a push of this tree would change",
10898
+ security: PIPELINE_AUTH,
10899
+ surfaces: HIDDEN_FROM_TOOLS,
10900
+ request: { body: jsonBody(createPushBodySchema) },
10901
+ responses: {
10902
+ 200: envelope(pushPlanSchema, "What this tree would write, and what it leaves alone"),
10903
+ ...sessionErrors,
10904
+ ...errors("validation_failed", "malformed_request")
10905
+ }
10906
+ }), defineRoute({
10430
10907
  method: "post",
10431
10908
  path: "/v1/pushes",
10432
10909
  operationId: "pushes.create",
@@ -11769,6 +12246,51 @@ function registerContractCommands(program, run) {
11769
12246
  }
11770
12247
  }
11771
12248
 
12249
+ //#endregion
12250
+ //#region src/commands/seen.ts
12251
+ function pad$1(text, width) {
12252
+ return text.padEnd(width);
12253
+ }
12254
+ /**
12255
+ * The block, as lines, or nothing at all when every name has arrived at
12256
+ * least once. Leads with a blank line, the way the other sections of
12257
+ * `cow status` do, so a caller can splice it onto whatever it printed.
12258
+ *
12259
+ * Patterns are printed as the author wrote them (`order.*`, never an
12260
+ * expansion), each beside the journeys and segments that named it: those are
12261
+ * what stay quiet.
12262
+ */
12263
+ function unseenLines(events, answers) {
12264
+ const never = new Set(answers.filter((answer) => answer.lastSeenAt === null).map((answer) => answer.name));
12265
+ const unseen = events.filter((event) => never.has(event.pattern));
12266
+ if (unseen.length === 0) return [];
12267
+ const width = Math.max(...unseen.map((event) => event.pattern.length));
12268
+ return [
12269
+ "",
12270
+ "Nothing has sent these events yet, so what waits on them stays quiet:",
12271
+ ...unseen.map((event) => ` ${pad$1(event.pattern, width)} ${event.usedBy.join(", ")}`)
12272
+ ];
12273
+ }
12274
+ /**
12275
+ * Ask the server about a tree's event names and render the answer. A tree
12276
+ * that names none skips the call.
12277
+ *
12278
+ * A failed call prints nothing rather than failing its command: this is a
12279
+ * warning beside work that has already happened, and a journey that was just
12280
+ * turned on is turned on whether or not the remark about it arrives.
12281
+ */
12282
+ async function unseenReport(client, appId, events) {
12283
+ if (events.length === 0) return [];
12284
+ try {
12285
+ return unseenLines(events, (await client.request(contract.events["apps.events.seen"], {
12286
+ params: { appId },
12287
+ query: { name: events.map((event) => event.pattern) }
12288
+ })).data.names);
12289
+ } catch {
12290
+ return [];
12291
+ }
12292
+ }
12293
+
11772
12294
  //#endregion
11773
12295
  //#region src/commands/enable.ts
11774
12296
  /**
@@ -11824,6 +12346,31 @@ function flipSummary(journeys, status) {
11824
12346
  if (journeys.length === 0) return `This app has no journeys to put ${STATE_WORD[status]}.`;
11825
12347
  return journeys.map((journey) => [`${journey.key} is ${STATE_WORD[status]}.`, ...segmentLines(journey)].join("\n")).join("\n");
11826
12348
  }
12349
+ /**
12350
+ * What the journeys just turned on are waiting on and have never arrived.
12351
+ * Only on the way on: a journey going off or on hold is not about to miss
12352
+ * anything.
12353
+ *
12354
+ * Read off the tree in front of the developer, because that is where the
12355
+ * names come from; a directory that is not a project, or one that does not
12356
+ * build, names none and the remark is simply absent rather than wrong. A
12357
+ * journey enrolling from a `segments/` file waits through that file, so the
12358
+ * segment's own key joins the set the events are filtered by.
12359
+ */
12360
+ async function unseenForKeys(client, keys) {
12361
+ const dir = process.cwd();
12362
+ const project = await assertCowConfig(dir).then(async (config) => ({
12363
+ config,
12364
+ built: await buildProject(dir)
12365
+ })).catch(() => null);
12366
+ if (!project) return [];
12367
+ const waiting = new Set(keys);
12368
+ for (const journey of project.built.manifest.journeys) {
12369
+ const segment = waiting.has(journey.key) ? triggerSegmentName(journey.key, journey.trigger) : null;
12370
+ if (segment) waiting.add(segment);
12371
+ }
12372
+ return unseenReport(client, project.config.appId, project.built.manifest.events.filter((event) => event.usedBy.some((key) => waiting.has(key))));
12373
+ }
11827
12374
  const DESCRIPTIONS = {
11828
12375
  on: "turn journeys on",
11829
12376
  off: "turn journeys off",
@@ -11838,12 +12385,14 @@ function register(program, clientFor, io, verb, status) {
11838
12385
  if (merged.all === true && keys.length > 0) throw new Error(`Name the journeys or pass --all, not both: cow ${verb} welcome.`);
11839
12386
  if (merged.all !== true && keys.length === 0) throw new Error(`Name at least one journey, or pass --all: cow ${verb} welcome.`);
11840
12387
  const selector = merged.all === true ? { appId: (await assertCowConfig(process.cwd())).appId } : { keys };
11841
- const updated = await setStatus(await clientFor(merged), selector, status);
12388
+ const client = await clientFor(merged);
12389
+ const updated = await setStatus(client, selector, status);
11842
12390
  if (merged.json === true) {
11843
12391
  emit({ data: updated }, io, true);
11844
12392
  return;
11845
12393
  }
11846
- io.stdout(`${flipSummary(updated, status)}\n`);
12394
+ const unseen = status === "on" ? await unseenForKeys(client, updated.map((journey) => journey.key)) : [];
12395
+ io.stdout(`${[flipSummary(updated, status), ...unseen].join("\n")}\n`);
11847
12396
  });
11848
12397
  }
11849
12398
  function registerEnable(program, clientFor, io) {
@@ -11935,7 +12484,7 @@ async function chooseApp(client, webUrl, flag, org, io, skipPrompts) {
11935
12484
  return { appId: await ask("App id", only ?? "", io, false) };
11936
12485
  }
11937
12486
  function registerInit(program, clientFor, env, io) {
11938
- program.command("init").argument("[dir]", "directory to create the repo in (default: here)").description("create a cow repo: cow.json, journeys/, emails/, and the files to build them").option("--org <id>", "organization id (default: the logged-in org)").option("--app <id>", "app id this repository pushes to (default: ask, or the org's only one)").option("--yes", "accept the defaults instead of prompting").option("--example <name>", "start from a gallery example").action(async (dirArg, opts) => {
12487
+ program.command("init").argument("[dir]", "directory to create the repo in (default: here)").description("create a cow repo: cow.json, journeys/, emails/, segments/, and the files to build them").option("--org <id>", "organization id (default: the logged-in org)").option("--app <id>", "app id this repository pushes to (default: ask, or the org's only one)").option("--yes", "accept the defaults instead of prompting").option("--blueprint <name>", "start from a gallery blueprint").action(async (dirArg, opts) => {
11939
12488
  const merged = {
11940
12489
  ...program.opts(),
11941
12490
  ...opts
@@ -11945,7 +12494,7 @@ function registerInit(program, clientFor, env, io) {
11945
12494
  const defaultName = basename(dirArg ? resolve(cwd, dirArg) : cwd);
11946
12495
  const name = await ask("Repo name", defaultName, io, skipPrompts);
11947
12496
  const dir = dirArg ? resolve(cwd, dirArg) : name === defaultName ? cwd : resolve(cwd, name);
11948
- if (existsSync(join(dir, "cow.json"))) throw new Error(`"${dir}" already holds a cow.json. Use \`cow add example\` to add code to it.`);
12497
+ if (existsSync(join(dir, "cow.json"))) throw new Error(`"${dir}" already holds a cow.json. Use \`cow add blueprint\` to add code to it.`);
11949
12498
  const credentials = await readCredentials(env);
11950
12499
  const claimedOrg = credentials ? decodeSessionToken(credentials.token)?.orgId ?? null : null;
11951
12500
  const orgId = typeof opts.org === "string" && opts.org !== "" ? opts.org : await ask("Organization id", claimedOrg ?? "", io, skipPrompts);
@@ -11973,19 +12522,21 @@ function registerInit(program, clientFor, env, io) {
11973
12522
  include: [
11974
12523
  "journeys",
11975
12524
  "emails",
12525
+ "segments",
11976
12526
  ".cow/types.d.ts"
11977
12527
  ]
11978
12528
  }),
11979
12529
  ".gitignore": ".cow/\nnode_modules/\n",
11980
12530
  "journeys/.gitkeep": "",
11981
- "emails/.gitkeep": ""
12531
+ "emails/.gitkeep": "",
12532
+ "segments/.gitkeep": ""
11982
12533
  };
11983
12534
  for (const [file, body] of Object.entries(contents)) {
11984
12535
  await mkdir(dirname(join(dir, file)), { recursive: true });
11985
12536
  await writeFile(join(dir, file), body, "utf8");
11986
12537
  }
11987
- const example = typeof opts.example === "string" ? opts.example : void 0;
11988
- const files = [...Object.keys(contents), ...example ? await copyExample(example, dir, false) : []].sort();
12538
+ const blueprint = typeof opts.blueprint === "string" ? opts.blueprint : void 0;
12539
+ const files = [...Object.keys(contents), ...blueprint ? await copyBlueprint(blueprint, dir, false) : []].sort();
11989
12540
  emit({ data: {
11990
12541
  dir,
11991
12542
  orgId,
@@ -11994,6 +12545,7 @@ function registerInit(program, clientFor, env, io) {
11994
12545
  } }, io, merged.json);
11995
12546
  const here = relative(cwd, dir) || ".";
11996
12547
  const scenario = files.find((file) => file.startsWith("scenarios/")) ?? "scenarios/abandoned-checkout.timeout.json";
12548
+ const journey = scenario.slice(10).split(".")[0] ?? "abandoned-checkout";
11997
12549
  const appLine = app.appId === "" ? `No app named yet (${app.reason}). Put its id in cow.json's "appId"; you will find it on the app's page at ${webUrl}/apps.` : `Pushes to ${app.appId}`;
11998
12550
  io.stderr([
11999
12551
  `Created a cow repo in ${here}`,
@@ -12002,46 +12554,515 @@ function registerInit(program, clientFor, env, io) {
12002
12554
  "Next steps:",
12003
12555
  ` cd ${here}`,
12004
12556
  " npm install",
12005
- ...example ? [] : [" cow add example abandoned-checkout"],
12557
+ ...blueprint ? [] : [" cow add blueprint abandoned-checkout"],
12006
12558
  " cow build",
12007
- ` cow test ${example ?? "abandoned-checkout"} --scenario ${scenario}`,
12559
+ ` cow test ${journey} --scenario ${scenario}`,
12008
12560
  ""
12009
12561
  ].join("\n"));
12010
12562
  });
12011
12563
  }
12012
12564
 
12013
12565
  //#endregion
12014
- //#region src/commands/mcp/tools.ts
12566
+ //#region src/commands/status.ts
12015
12567
  /**
12016
- * The fields of a request part. The container is unwrapped, its fields are
12017
- * not: a field's `.optional()` and `.default()` wrappers are what the MCP
12018
- * SDK parses tool arguments against.
12568
+ * `cow status`: what the server holds for every key of this app, and how far
12569
+ * the tree in front of the developer has moved from it. Hand-written rather
12570
+ * than derived from `apps.status`, because the drift is the half of the
12571
+ * answer no server read can know: it comes from building the repository
12572
+ * here and comparing the bundle digests with the versions the server has.
12573
+ */
12574
+ /** Largest first: the first unit the gap fills is the one that reads best. */
12575
+ const UNITS = [
12576
+ ["year", 31536e6],
12577
+ ["month", 2592e6],
12578
+ ["week", 6048e5],
12579
+ ["day", 864e5],
12580
+ ["hour", 36e5],
12581
+ ["minute", 6e4],
12582
+ ["second", 1e3]
12583
+ ];
12584
+ const relative$1 = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
12585
+ /** The gap between an instant and now, in words. */
12586
+ function ago(value, now) {
12587
+ const gap = new Date(value).getTime() - now.getTime();
12588
+ for (const [unit, ms] of UNITS) if (Math.abs(gap) >= ms) return relative$1.format(Math.round(gap / ms), unit);
12589
+ return relative$1.format(0, "second");
12590
+ }
12591
+ /**
12592
+ * The drift, computed by the one comparison there is (`pushPlan`, ADR
12593
+ * 0025), so what `cow status` calls changed and what `cow push` calls
12594
+ * changed can never be two different things.
12019
12595
  *
12020
- * Unwrapping the container is deliberate, and a change from the generated
12021
- * table this replaced: `mcpToolFor` derives `paramsKeys`/`queryKeys` through
12022
- * the same unwrap, so a wrapped container used to yield keys that `toInput`
12023
- * would pick but that the tool never declared. No route wraps one today.
12596
+ * Segments are left out of both sides: `apps.status` reports the keys that
12597
+ * run, and a segment is a row rather than a version, so the server read
12598
+ * behind this command does not carry them. `cow push` plans over all three.
12024
12599
  */
12025
- function shapeOf(schema) {
12026
- const inner = schema instanceof z.ZodType ? unwrapSchema(schema).schema : void 0;
12027
- return inner instanceof z.ZodObject ? inner.shape : {};
12600
+ function driftOf(status, manifest) {
12601
+ const served = status.keys.map((entry) => ({
12602
+ ...entry.latestVersion ? entrySide(entry.kind, entry.latestVersion.manifest) : {
12603
+ kind: entry.kind,
12604
+ key: entry.key,
12605
+ fields: {}
12606
+ },
12607
+ onHold: entry.status === "paused"
12608
+ }));
12609
+ return pushPlan(status.appId, manifestSides({
12610
+ ...manifest,
12611
+ segments: []
12612
+ }), served);
12028
12613
  }
12029
- function pick(args, keys) {
12030
- const picked = {};
12031
- for (const key of keys) {
12032
- const value = args[key];
12033
- if (value !== void 0) picked[key] = value;
12034
- }
12035
- return picked;
12614
+ /**
12615
+ * The version's short name. The bundle digest rather than the compiled
12616
+ * module's, because a version is one bundle (a push that carries a bundle
12617
+ * the org already holds creates no second version) and the bundle is there
12618
+ * from the moment the push lands, while the module digest arrives only when
12619
+ * the compile finishes.
12620
+ */
12621
+ function shortDigest(bundle) {
12622
+ return bundle.replace("sha256:", "").slice(0, 8);
12036
12623
  }
12037
- function defineTool(spec) {
12038
- const data = bodyDataSchema(spec.route);
12039
- const bodyShape = data === void 0 ? {} : data instanceof z.ZodObject ? data.shape : { data };
12040
- return {
12041
- name: spec.name,
12042
- description: spec.description,
12043
- route: spec.route,
12044
- inputSchema: {
12624
+ function bundleOf(entry) {
12625
+ const manifest = entry.latestVersion?.manifest;
12626
+ return manifest ? manifest.bundle : null;
12627
+ }
12628
+ /** "version 3f2a1b9c, pushed 2 hours ago, still compiling" */
12629
+ function versionPhrase(entry, now) {
12630
+ const version = entry.latestVersion;
12631
+ if (!version) return "no version yet";
12632
+ const state = version.status === "compiling" ? ", still compiling" : version.status === "failed" ? ", did not compile" : "";
12633
+ const bundle = bundleOf(entry);
12634
+ return `${bundle ? `version ${shortDigest(bundle)}` : "version"}, pushed ${ago(version.pushedAt, now)}${state}`;
12635
+ }
12636
+ function flagsPhrase(entry) {
12637
+ return entry.status === null ? "" : entry.status === "paused" ? "on hold" : entry.status;
12638
+ }
12639
+ function livePhrase(entry) {
12640
+ return entry.liveExecutions === 0 ? "" : `${entry.liveExecutions} running`;
12641
+ }
12642
+ function pad(text, width) {
12643
+ return text.padEnd(width);
12644
+ }
12645
+ /**
12646
+ * The whole report, as lines. Pure so a test can read it: everything it
12647
+ * needs is the server's answer, the local build, and the clock.
12648
+ *
12649
+ * A null manifest is a tree that did not build. What the server runs is
12650
+ * still the answer to most of the question, so the report keeps it and drops
12651
+ * only the comparison.
12652
+ */
12653
+ function statusLines(status, manifest, now) {
12654
+ const journeys = status.keys.filter((entry) => entry.kind === "journey");
12655
+ const templates = status.keys.filter((entry) => entry.kind === "template");
12656
+ const lines = [`${status.appId}: ${journeys.length} ${journeys.length === 1 ? "journey" : "journeys"}, ${templates.length} ${templates.length === 1 ? "template" : "templates"} on the server.`];
12657
+ const width = Math.max(0, ...status.keys.map((entry) => entry.key.length));
12658
+ const section = (title, entries) => {
12659
+ if (entries.length === 0) return;
12660
+ const flagWidth = Math.max(0, ...entries.map((entry) => flagsPhrase(entry).length));
12661
+ lines.push("", title);
12662
+ for (const entry of entries) {
12663
+ const flags = flagsPhrase(entry);
12664
+ const live = livePhrase(entry);
12665
+ const rest = [
12666
+ ...flags === "" ? [] : [pad(flags, flagWidth)],
12667
+ versionPhrase(entry, now),
12668
+ ...live === "" ? [] : [live]
12669
+ ];
12670
+ lines.push(` ${pad(entry.key, width)} ${rest.join(" ")}`.trimEnd());
12671
+ }
12672
+ };
12673
+ section("Journeys", journeys);
12674
+ section("Templates", templates);
12675
+ if (!manifest) {
12676
+ lines.push("", "This tree did not build, so nothing here is compared with it. Run cow build to see why.");
12677
+ return lines;
12678
+ }
12679
+ const plan = driftOf(status, manifest);
12680
+ const changed = plan.entries.filter((entry) => entry.change !== "unchanged");
12681
+ const missing = plan.drift.filter((one) => one.reason === "not-in-tree");
12682
+ if (changed.length > 0) lines.push("", `Changed here since the last push: ${changed.map((entry) => entry.key).join(", ")}. Run cow push.`);
12683
+ if (missing.length > 0) {
12684
+ lines.push("", "On the server and not in this tree:");
12685
+ for (const entry of missing) lines.push(` ${entry.key}, a ${entry.kind}. Delete it with cow ${entry.kind}s delete ${entry.key}.`);
12686
+ }
12687
+ if (status.warnings.length > 0) {
12688
+ lines.push("", "Names this organization does not define yet:");
12689
+ for (const warning of status.warnings) lines.push(` ${warning}`);
12690
+ }
12691
+ return lines;
12692
+ }
12693
+ /** The app's status from the server, for this directory's `cow.json`. */
12694
+ async function readStatus(client, appId) {
12695
+ return (await client.request(contract.apps["apps.status"], { params: { id: appId } })).data;
12696
+ }
12697
+ function registerStatus(program, clientFor, io) {
12698
+ program.command("status").description("what this app's journeys and templates are doing, and what changed here since the last push").action(async (opts) => {
12699
+ const merged = {
12700
+ ...program.opts(),
12701
+ ...opts
12702
+ };
12703
+ const config = await assertCowConfig(process.cwd());
12704
+ const built = await buildProject(process.cwd()).catch(() => null);
12705
+ const client = await clientFor(merged);
12706
+ const status = await readStatus(client, config.appId);
12707
+ if (merged.json === true) {
12708
+ emit({ data: status }, io, true);
12709
+ return;
12710
+ }
12711
+ const unseen = await unseenReport(client, config.appId, built?.manifest.events ?? []);
12712
+ io.stdout(`${[...statusLines(status, built?.manifest ?? null, /* @__PURE__ */ new Date()), ...unseen].join("\n")}\n`);
12713
+ });
12714
+ }
12715
+
12716
+ //#endregion
12717
+ //#region src/commands/push.ts
12718
+ /** Does the org already hold these bytes? A 404 is the answer, not a failure. */
12719
+ async function held(client, digest) {
12720
+ try {
12721
+ await client.request(contract.artifacts["artifacts.head"], { params: { digest } });
12722
+ return true;
12723
+ } catch (error) {
12724
+ if (error instanceof ApiError && error.status === 404) return false;
12725
+ throw error;
12726
+ }
12727
+ }
12728
+ /** Every artifact the manifest names, and the file `cow build` wrote it to. */
12729
+ function artifactFiles(projectDir, manifest) {
12730
+ const out = join(projectDir, BUILD_DIR);
12731
+ return [
12732
+ ...manifest.journeys.map((journey) => ({
12733
+ digest: journey.bundle,
12734
+ kind: "bundle",
12735
+ file: join(out, "bundles", "journeys", `${journey.key}.js`)
12736
+ })),
12737
+ ...manifest.templates.map((template) => ({
12738
+ digest: template.bundle,
12739
+ kind: "bundle",
12740
+ file: join(out, "bundles", "emails", `${template.key}.js`)
12741
+ })),
12742
+ {
12743
+ digest: manifest.source,
12744
+ kind: "source",
12745
+ file: join(out, "source.tgz")
12746
+ }
12747
+ ];
12748
+ }
12749
+ /**
12750
+ * The body both `pushes.plan` and `pushes.create` take: the same manifest,
12751
+ * asked about and then stored.
12752
+ *
12753
+ * A stored manifest carries any protocol (a version outlives a bump), so
12754
+ * this is the one shape that pins it to the constant. `cow build` wrote
12755
+ * exactly that; restating it is what satisfies the literal.
12756
+ */
12757
+ function pushBody({ manifest, config }, prune = false) {
12758
+ return {
12759
+ manifest: {
12760
+ ...manifest,
12761
+ protocol: 3
12762
+ },
12763
+ prune,
12764
+ appId: config.appId,
12765
+ orgId: config.orgId
12766
+ };
12767
+ }
12768
+ /**
12769
+ * What a push of this tree would change, having changed nothing. The build
12770
+ * comes back with it so the push that follows a confirmed plan uploads the
12771
+ * bundles that were planned rather than rebuilding and finding others.
12772
+ */
12773
+ async function planProject({ client, projectDir }) {
12774
+ const built = await buildProject(projectDir);
12775
+ return {
12776
+ plan: (await client.request(contract.pushes["pushes.plan"], { body: pushBody(built) })).data,
12777
+ built
12778
+ };
12779
+ }
12780
+ async function pushProject({ client, projectDir, built, prune }) {
12781
+ const build = built ?? await buildProject(projectDir);
12782
+ const { manifest } = build;
12783
+ const uploaded = [];
12784
+ for (const artifact of artifactFiles(projectDir, manifest)) {
12785
+ if (await held(client, artifact.digest)) continue;
12786
+ await client.request(contract.artifacts["artifacts.put"], {
12787
+ params: { digest: artifact.digest },
12788
+ query: { kind: artifact.kind },
12789
+ body: await readFile(artifact.file)
12790
+ });
12791
+ uploaded.push(artifact.digest);
12792
+ }
12793
+ return {
12794
+ push: (await client.request(contract.pushes["pushes.create"], { body: pushBody(build, prune) })).data,
12795
+ uploaded
12796
+ };
12797
+ }
12798
+ /**
12799
+ * The human report: what was stored, and one line per changed key with the
12800
+ * page that key now has on the dashboard. A key whose code did not change
12801
+ * gets no new version, so a push of an unchanged tree says exactly that.
12802
+ */
12803
+ function pushSummary(outcome, webUrl) {
12804
+ const { push } = outcome;
12805
+ const uploaded = outcome.uploaded.length === 0 ? "nothing new to upload" : `${outcome.uploaded.length} artifact${outcome.uploaded.length === 1 ? "" : "s"} uploaded`;
12806
+ const stored = `Push #${push.seq} stored (${uploaded}).`;
12807
+ if (push.versions.length === 0) return `${stored} Nothing changed.`;
12808
+ const width = Math.max(...push.versions.map((version) => version.key.length));
12809
+ return [`${stored} Compiling ${push.versions.length} ${push.versions.length === 1 ? "key" : "keys"}:`, ...push.versions.map((version) => ` ${version.key.padEnd(width)} ${webUrl}/${version.kind === "journey" ? "journeys" : "deliveries/templates"}/${version.key}`)].join("\n");
12810
+ }
12811
+ const MARK = {
12812
+ added: "+",
12813
+ changed: "~",
12814
+ unchanged: " "
12815
+ };
12816
+ const DRIFT = {
12817
+ "not-in-tree": "on the server, not in this tree",
12818
+ "on-hold": "on hold; this leaves it that way"
12819
+ };
12820
+ /**
12821
+ * The plan as a developer reads it: one line per key, marked `+` for a key
12822
+ * the account does not have and `~` for one this tree moves, with the
12823
+ * fields that moved named beside it. Pure, so a test reads it and so `cow
12824
+ * plan` can print the same thing without pushing.
12825
+ */
12826
+ function planLines(plan) {
12827
+ const lines = [`Plan for ${plan.appId}:`, ""];
12828
+ const width = Math.max(0, ...[...plan.entries, ...plan.drift].map((one) => one.key.length));
12829
+ const row = (kind, key, mark, rest) => `${mark} ${kind.padEnd(8)} ${key.padEnd(width)} ${rest}`.trimEnd();
12830
+ for (const entry of plan.entries) lines.push(row(entry.kind, entry.key, MARK[entry.change], entry.fields.join(", ")));
12831
+ const count = (change) => plan.entries.filter((entry) => entry.change === change).length;
12832
+ const [added, changed] = [count("added"), count("changed")];
12833
+ lines.push("", added + changed === 0 ? "Nothing to change." : `${added} to add, ${changed} to change, ${count("unchanged")} unchanged.`);
12834
+ if (plan.drift.length > 0) {
12835
+ lines.push("", "Left alone:");
12836
+ for (const one of plan.drift) lines.push(row(one.kind, one.key, " ", DRIFT[one.reason]));
12837
+ }
12838
+ return lines;
12839
+ }
12840
+ /**
12841
+ * What `--prune` acts on: drift this tree no longer carries. The other
12842
+ * drift reason (`on-hold`) is a journey the tree still has, and templates
12843
+ * are excluded outright — a live execution pins the template version it
12844
+ * started on, and the version prune sweep already owns their lifecycle.
12845
+ */
12846
+ function prunable(plan) {
12847
+ return plan.drift.filter((one) => one.reason === "not-in-tree" && one.kind !== "template");
12848
+ }
12849
+ /** The drift `--prune` would delete, with what the server says it costs. */
12850
+ function priced(drift, status) {
12851
+ return drift.map((one) => {
12852
+ const served = status.keys.find((entry) => entry.kind === "journey" && entry.key === one.key);
12853
+ return {
12854
+ kind: one.kind === "segment" ? "segment" : "journey",
12855
+ key: one.key,
12856
+ status: one.kind === "segment" ? null : served?.status ?? null,
12857
+ liveExecutions: one.kind === "segment" ? 0 : served?.liveExecutions ?? 0
12858
+ };
12859
+ });
12860
+ }
12861
+ /** How many live runs the whole prune ends. */
12862
+ function liveTotal(doomed) {
12863
+ return doomed.reduce((sum, one) => sum + one.liveExecutions, 0);
12864
+ }
12865
+ const runs = (count) => `${count} live run${count === 1 ? "" : "s"}`;
12866
+ /**
12867
+ * The bill, printed before the question and before anything is deleted:
12868
+ * one line per thing, with the journey's state and the runs it is carrying,
12869
+ * and the total underneath. Pure, so a test reads it.
12870
+ */
12871
+ function pruneLines(doomed) {
12872
+ const width = Math.max(...doomed.map((one) => one.key.length));
12873
+ const total = liveTotal(doomed);
12874
+ return [
12875
+ "",
12876
+ "No longer in this tree, so --prune deletes:",
12877
+ "",
12878
+ ...doomed.map((one) => `- ${one.kind.padEnd(7)} ${one.key.padEnd(width)} ${one.kind === "segment" ? "" : `${one.status ?? "unknown"} ${runs(one.liveExecutions)}`}`.trimEnd()),
12879
+ "",
12880
+ total === 0 ? "Nothing is running on any of them." : `${runs(total)} end the moment this is applied.`
12881
+ ];
12882
+ }
12883
+ /**
12884
+ * The refusal an unattended prune gets when the plan both adds and deletes,
12885
+ * which is what a renamed file looks like (ADR 0024). Not a heuristic: no
12886
+ * digest is compared and no pair is guessed, because two journeys may
12887
+ * legitimately share a bundle and a matching digest proves nothing. It is
12888
+ * the shape of the plan that is refused, and only when nobody is watching.
12889
+ */
12890
+ function renameRefusal(added, doomed) {
12891
+ const names = (ones) => ones.map((one) => `${one.kind} ${one.key}`).join(", ");
12892
+ return [
12893
+ "Nothing was pushed: this plan both adds and deletes, which is what renaming a file looks like,",
12894
+ "and an unattended run is exactly where nobody reads the live-run count.",
12895
+ ` adds: ${names(added)}`,
12896
+ ` deletes: ${names(doomed)}`,
12897
+ `Confirm it yourself — run \`cow push --prune\` without --yes and answer the prompt — or split the`,
12898
+ "rename into two pushes: push the new file, then prune once it is live."
12899
+ ].join("\n");
12900
+ }
12901
+ /**
12902
+ * Delete the journeys this tree dropped, through the two verbs that already
12903
+ * exist rather than a second destructive path: stop what is running, then
12904
+ * delete the journey. Both are a person's call and neither is on a pipeline
12905
+ * key's list, which is why the refusal says so in words.
12906
+ *
12907
+ * Sequential, and after the push: the tree that replaces them is stored
12908
+ * first, so a push that turns out to be invalid has destroyed nothing.
12909
+ */
12910
+ async function pruneJourneys(client, doomed) {
12911
+ for (const one of doomed) try {
12912
+ if (one.liveExecutions > 0) await client.request(contract.executions["executions.cancel"], { body: { journey: one.key } });
12913
+ await client.request(contract.journeys["journeys.delete"], { params: { key: one.key } });
12914
+ } catch (error) {
12915
+ if (error instanceof ApiError && (error.status === 401 || error.status === 403)) throw new Error(`The push landed, but "${one.key}" was not deleted: ending runs that are carrying real people takes a signed-in developer, not a deploy key. Run \`cow login\`, then \`cow push --prune\` again to finish it.`);
12916
+ throw error;
12917
+ }
12918
+ }
12919
+ /** One line for what went, after the push report. */
12920
+ function pruneSummary(doomed) {
12921
+ return `Deleted ${doomed.map((one) => `${one.kind} ${one.key}`).join(", ")}.`;
12922
+ }
12923
+ /** Ask on the terminal. The question goes to stderr, like every other prompt. */
12924
+ async function confirm(question) {
12925
+ const rl = createInterface({
12926
+ input: process.stdin,
12927
+ output: process.stderr
12928
+ });
12929
+ try {
12930
+ return /^y(es)?$/i.test((await rl.question(`${question} `)).trim());
12931
+ } finally {
12932
+ rl.close();
12933
+ }
12934
+ }
12935
+ function registerPush(program, clientFor, env, io) {
12936
+ program.command("push").description("show what this repository would change, then upload it once you agree").option("--yes", "apply without asking; what a pipeline passes").option("--prune", "also delete the journeys and segments this app has and this tree does not").action(async (opts) => {
12937
+ const merged = {
12938
+ ...program.opts(),
12939
+ ...opts
12940
+ };
12941
+ const client = await clientFor(merged);
12942
+ const projectDir = process.cwd();
12943
+ const json = merged.json === true;
12944
+ const { plan, built } = await planProject({
12945
+ client,
12946
+ projectDir
12947
+ });
12948
+ (json ? io.stderr : io.stdout)(`${planLines(plan).join("\n")}\n`);
12949
+ const unseen = await unseenReport(client, plan.appId, built.manifest.events);
12950
+ if (unseen.length > 0) (json ? io.stderr : io.stdout)(`${unseen.join("\n")}\n`);
12951
+ const prune = merged.prune === true;
12952
+ const drift = prune ? prunable(plan) : [];
12953
+ const doomed = drift.length > 0 ? priced(drift, await readStatus(client, plan.appId)) : [];
12954
+ if (doomed.length > 0) (json ? io.stderr : io.stdout)(`${pruneLines(doomed).join("\n")}\n`);
12955
+ const added = plan.entries.filter((one) => one.change === "added");
12956
+ if (merged.yes === true && doomed.length > 0 && added.length > 0) throw new Error(renameRefusal(added, doomed));
12957
+ if ((plan.entries.some((one) => one.change !== "unchanged") || doomed.length > 0) && merged.yes !== true) {
12958
+ if (!io.isTTY) throw new Error("Nothing was pushed: there is no terminal here to agree on. Re-run with --yes to push what this plan shows.");
12959
+ if (!await confirm(doomed.length > 0 ? "Push this and delete those? [y/N]" : "Push this? [y/N]")) {
12960
+ if (json) emit({ data: {
12961
+ plan,
12962
+ push: null,
12963
+ pruned: []
12964
+ } }, io, true);
12965
+ else io.stdout("Nothing was pushed.\n");
12966
+ return;
12967
+ }
12968
+ }
12969
+ const outcome = await pushProject({
12970
+ client,
12971
+ projectDir,
12972
+ built,
12973
+ prune
12974
+ });
12975
+ await pruneJourneys(client, doomed.filter((one) => one.kind === "journey"));
12976
+ if (json) {
12977
+ emit({ data: {
12978
+ plan,
12979
+ push: outcome.push,
12980
+ pruned: doomed.map((one) => ({
12981
+ kind: one.kind,
12982
+ key: one.key
12983
+ }))
12984
+ } }, io, true);
12985
+ return;
12986
+ }
12987
+ const webUrl = resolveWebUrl(env, built.config.webUrl);
12988
+ io.stdout(`${pushSummary(outcome, webUrl)}\n`);
12989
+ if (doomed.length > 0) io.stdout(`${pruneSummary(doomed)}\n`);
12990
+ });
12991
+ }
12992
+
12993
+ //#endregion
12994
+ //#region src/commands/plan.ts
12995
+ /**
12996
+ * `cow plan` (ADR 0025): the read-only half of `cow push`. It builds, asks
12997
+ * the server what a push of this tree would change, prints it, and stops.
12998
+ * Nothing is uploaded and nothing is stored, so it is the one push verb that
12999
+ * costs an account nothing to run.
13000
+ *
13001
+ * Everything it prints is `cow push`'s: the comparison is the server's
13002
+ * (`pushes.plan`), the renderer is `planLines`, and the warning under it is
13003
+ * `unseenReport`. There is no second computation that could drift from the
13004
+ * one the push itself shows.
13005
+ */
13006
+ /** The envelope `cow plan --json` prints, and what the MCP tool answers. */
13007
+ async function projectPlan(client) {
13008
+ const { plan } = await planProject({
13009
+ client,
13010
+ projectDir: process.cwd()
13011
+ });
13012
+ return { data: plan };
13013
+ }
13014
+ function registerPlan(program, clientFor, io) {
13015
+ program.command("plan").description("show what a push of this repository would change, and change nothing").action(async (opts) => {
13016
+ const merged = {
13017
+ ...program.opts(),
13018
+ ...opts
13019
+ };
13020
+ const client = await clientFor(merged);
13021
+ const { plan, built } = await planProject({
13022
+ client,
13023
+ projectDir: process.cwd()
13024
+ });
13025
+ if (merged.json === true) {
13026
+ emit({ data: plan }, io, true);
13027
+ return;
13028
+ }
13029
+ const unseen = await unseenReport(client, plan.appId, built.manifest.events);
13030
+ io.stdout(`${[...planLines(plan), ...unseen].join("\n")}\n`);
13031
+ });
13032
+ }
13033
+
13034
+ //#endregion
13035
+ //#region src/commands/mcp/tools.ts
13036
+ /**
13037
+ * The fields of a request part. The container is unwrapped, its fields are
13038
+ * not: a field's `.optional()` and `.default()` wrappers are what the MCP
13039
+ * SDK parses tool arguments against.
13040
+ *
13041
+ * Unwrapping the container is deliberate, and a change from the generated
13042
+ * table this replaced: `mcpToolFor` derives `paramsKeys`/`queryKeys` through
13043
+ * the same unwrap, so a wrapped container used to yield keys that `toInput`
13044
+ * would pick but that the tool never declared. No route wraps one today.
13045
+ */
13046
+ function shapeOf(schema) {
13047
+ const inner = schema instanceof z.ZodType ? unwrapSchema(schema).schema : void 0;
13048
+ return inner instanceof z.ZodObject ? inner.shape : {};
13049
+ }
13050
+ function pick(args, keys) {
13051
+ const picked = {};
13052
+ for (const key of keys) {
13053
+ const value = args[key];
13054
+ if (value !== void 0) picked[key] = value;
13055
+ }
13056
+ return picked;
13057
+ }
13058
+ function defineTool(spec) {
13059
+ const data = bodyDataSchema(spec.route);
13060
+ const bodyShape = data === void 0 ? {} : data instanceof z.ZodObject ? data.shape : { data };
13061
+ return {
13062
+ name: spec.name,
13063
+ description: spec.description,
13064
+ route: spec.route,
13065
+ inputSchema: {
12045
13066
  ...shapeOf(spec.route.request?.params),
12046
13067
  ...shapeOf(spec.route.request?.query),
12047
13068
  ...bodyShape
@@ -12063,11 +13084,23 @@ function planTools() {
12063
13084
 
12064
13085
  //#endregion
12065
13086
  //#region src/commands/mcp/server.ts
12066
- /**
12067
- * The cow MCP server: every admin/read capability of the dashboard API as
12068
- * a native tool. The tool table (tools.ts) is planned from the shared route
12069
- * contract at startup, so the surface cannot drift from the API.
12070
- */
13087
+ /** One tool call: the JSON it answered with, or the refusal it hit. */
13088
+ async function toolResult(run) {
13089
+ try {
13090
+ return { content: [{
13091
+ type: "text",
13092
+ text: JSON.stringify(await run())
13093
+ }] };
13094
+ } catch (error) {
13095
+ return {
13096
+ content: [{
13097
+ type: "text",
13098
+ text: errorMessage$1(error)
13099
+ }],
13100
+ isError: true
13101
+ };
13102
+ }
13103
+ }
12071
13104
  async function createMcpServer({ client }) {
12072
13105
  const { version } = await readCliPackage();
12073
13106
  const server = new McpServer({
@@ -12078,23 +13111,11 @@ async function createMcpServer({ client }) {
12078
13111
  for (const tool of planTools()) server.registerTool(tool.name, {
12079
13112
  description: tool.description,
12080
13113
  inputSchema: tool.inputSchema
12081
- }, async (args) => {
12082
- try {
12083
- const envelope = tool.route.request ? await request(tool.route, tool.toInput(args)) : await request(tool.route);
12084
- return { content: [{
12085
- type: "text",
12086
- text: JSON.stringify(envelope)
12087
- }] };
12088
- } catch (error) {
12089
- return {
12090
- content: [{
12091
- type: "text",
12092
- text: errorMessage$1(error)
12093
- }],
12094
- isError: true
12095
- };
12096
- }
12097
- });
13114
+ }, async (args) => toolResult(() => tool.route.request ? request(tool.route, tool.toInput(args)) : request(tool.route)));
13115
+ server.registerTool("pushes_plan", {
13116
+ description: "What a push of the project in the current directory would change. Builds locally; uploads and stores nothing.",
13117
+ inputSchema: {}
13118
+ }, async () => toolResult(() => projectPlan(client)));
12098
13119
  return server;
12099
13120
  }
12100
13121
  function errorMessage$1(error) {
@@ -12230,235 +13251,6 @@ function registerPull(program, clientFor, io) {
12230
13251
  });
12231
13252
  }
12232
13253
 
12233
- //#endregion
12234
- //#region src/commands/push.ts
12235
- /** Does the org already hold these bytes? A 404 is the answer, not a failure. */
12236
- async function held(client, digest) {
12237
- try {
12238
- await client.request(contract.artifacts["artifacts.head"], { params: { digest } });
12239
- return true;
12240
- } catch (error) {
12241
- if (error instanceof ApiError && error.status === 404) return false;
12242
- throw error;
12243
- }
12244
- }
12245
- /** Every artifact the manifest names, and the file `cow build` wrote it to. */
12246
- function artifactFiles(projectDir, manifest) {
12247
- const out = join(projectDir, BUILD_DIR);
12248
- return [
12249
- ...manifest.journeys.map((journey) => ({
12250
- digest: journey.bundle,
12251
- kind: "bundle",
12252
- file: join(out, "bundles", "journeys", `${journey.key}.js`)
12253
- })),
12254
- ...manifest.templates.map((template) => ({
12255
- digest: template.bundle,
12256
- kind: "bundle",
12257
- file: join(out, "bundles", "emails", `${template.key}.js`)
12258
- })),
12259
- {
12260
- digest: manifest.source,
12261
- kind: "source",
12262
- file: join(out, "source.tgz")
12263
- }
12264
- ];
12265
- }
12266
- async function pushProject({ client, projectDir }) {
12267
- const { manifest, config } = await buildProject(projectDir);
12268
- const uploaded = [];
12269
- for (const artifact of artifactFiles(projectDir, manifest)) {
12270
- if (await held(client, artifact.digest)) continue;
12271
- await client.request(contract.artifacts["artifacts.put"], {
12272
- params: { digest: artifact.digest },
12273
- query: { kind: artifact.kind },
12274
- body: await readFile(artifact.file)
12275
- });
12276
- uploaded.push(artifact.digest);
12277
- }
12278
- return {
12279
- push: (await client.request(contract.pushes["pushes.create"], { body: {
12280
- manifest: {
12281
- ...manifest,
12282
- protocol: 3
12283
- },
12284
- appId: config.appId,
12285
- orgId: config.orgId
12286
- } })).data,
12287
- uploaded
12288
- };
12289
- }
12290
- /**
12291
- * The human report: what was stored, and one line per changed key with the
12292
- * page that key now has on the dashboard. A key whose code did not change
12293
- * gets no new version, so a push of an unchanged tree says exactly that.
12294
- */
12295
- function pushSummary(outcome, webUrl) {
12296
- const { push } = outcome;
12297
- const uploaded = outcome.uploaded.length === 0 ? "nothing new to upload" : `${outcome.uploaded.length} artifact${outcome.uploaded.length === 1 ? "" : "s"} uploaded`;
12298
- const stored = `Push #${push.seq} stored (${uploaded}).`;
12299
- if (push.versions.length === 0) return `${stored} Nothing changed.`;
12300
- const width = Math.max(...push.versions.map((version) => version.key.length));
12301
- return [`${stored} Compiling ${push.versions.length} ${push.versions.length === 1 ? "key" : "keys"}:`, ...push.versions.map((version) => ` ${version.key.padEnd(width)} ${webUrl}/${version.kind === "journey" ? "journeys" : "deliveries/templates"}/${version.key}`)].join("\n");
12302
- }
12303
- function registerPush(program, clientFor, env, io) {
12304
- program.command("push").description("build this repository and upload it; the server compiles what changed").action(async (opts) => {
12305
- const merged = {
12306
- ...program.opts(),
12307
- ...opts
12308
- };
12309
- const outcome = await pushProject({
12310
- client: await clientFor(merged),
12311
- projectDir: process.cwd()
12312
- });
12313
- if (merged.json === true) {
12314
- emit({ data: outcome.push }, io, true);
12315
- return;
12316
- }
12317
- const webUrl = resolveWebUrl(env, (await readCowConfig(process.cwd()))?.webUrl);
12318
- io.stdout(`${pushSummary(outcome, webUrl)}\n`);
12319
- });
12320
- }
12321
-
12322
- //#endregion
12323
- //#region src/commands/status.ts
12324
- /**
12325
- * `cow status`: what the server holds for every key of this app, and how far
12326
- * the tree in front of the developer has moved from it. Hand-written rather
12327
- * than derived from `apps.status`, because the drift is the half of the
12328
- * answer no server read can know: it comes from building the repository
12329
- * here and comparing the bundle digests with the versions the server has.
12330
- */
12331
- /** Largest first: the first unit the gap fills is the one that reads best. */
12332
- const UNITS = [
12333
- ["year", 31536e6],
12334
- ["month", 2592e6],
12335
- ["week", 6048e5],
12336
- ["day", 864e5],
12337
- ["hour", 36e5],
12338
- ["minute", 6e4],
12339
- ["second", 1e3]
12340
- ];
12341
- const relative$1 = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
12342
- /** The gap between an instant and now, in words. */
12343
- function ago(value, now) {
12344
- const gap = new Date(value).getTime() - now.getTime();
12345
- for (const [unit, ms] of UNITS) if (Math.abs(gap) >= ms) return relative$1.format(Math.round(gap / ms), unit);
12346
- return relative$1.format(0, "second");
12347
- }
12348
- function localKeys(manifest) {
12349
- return [...manifest.journeys.map((journey) => ({
12350
- kind: "journey",
12351
- key: journey.key,
12352
- bundle: journey.bundle
12353
- })), ...manifest.templates.map((template) => ({
12354
- kind: "template",
12355
- key: template.key,
12356
- bundle: template.bundle
12357
- }))];
12358
- }
12359
- /**
12360
- * The version's short name. The bundle digest rather than the compiled
12361
- * module's, because a version is one bundle (a push that carries a bundle
12362
- * the org already holds creates no second version) and the bundle is there
12363
- * from the moment the push lands, while the module digest arrives only when
12364
- * the compile finishes.
12365
- */
12366
- function shortDigest(bundle) {
12367
- return bundle.replace("sha256:", "").slice(0, 8);
12368
- }
12369
- function bundleOf(entry) {
12370
- const manifest = entry.latestVersion?.manifest;
12371
- return manifest ? manifest.bundle : null;
12372
- }
12373
- /** "version 3f2a1b9c, pushed 2 hours ago, still compiling" */
12374
- function versionPhrase(entry, now) {
12375
- const version = entry.latestVersion;
12376
- if (!version) return "no version yet";
12377
- const state = version.status === "compiling" ? ", still compiling" : version.status === "failed" ? ", did not compile" : "";
12378
- const bundle = bundleOf(entry);
12379
- return `${bundle ? `version ${shortDigest(bundle)}` : "version"}, pushed ${ago(version.pushedAt, now)}${state}`;
12380
- }
12381
- function flagsPhrase(entry) {
12382
- return entry.status === null ? "" : entry.status === "paused" ? "on hold" : entry.status;
12383
- }
12384
- function livePhrase(entry) {
12385
- return entry.liveExecutions === 0 ? "" : `${entry.liveExecutions} running`;
12386
- }
12387
- function pad(text, width) {
12388
- return text.padEnd(width);
12389
- }
12390
- /**
12391
- * The whole report, as lines. Pure so a test can read it: everything it
12392
- * needs is the server's answer, the local build, and the clock.
12393
- *
12394
- * A null manifest is a tree that did not build. What the server runs is
12395
- * still the answer to most of the question, so the report keeps it and drops
12396
- * only the comparison.
12397
- */
12398
- function statusLines(status, manifest, now) {
12399
- const local = manifest ? localKeys(manifest) : [];
12400
- const journeys = status.keys.filter((entry) => entry.kind === "journey");
12401
- const templates = status.keys.filter((entry) => entry.kind === "template");
12402
- const lines = [`${status.appId}: ${journeys.length} ${journeys.length === 1 ? "journey" : "journeys"}, ${templates.length} ${templates.length === 1 ? "template" : "templates"} on the server.`];
12403
- const width = Math.max(0, ...status.keys.map((entry) => entry.key.length));
12404
- const section = (title, entries) => {
12405
- if (entries.length === 0) return;
12406
- const flagWidth = Math.max(0, ...entries.map((entry) => flagsPhrase(entry).length));
12407
- lines.push("", title);
12408
- for (const entry of entries) {
12409
- const flags = flagsPhrase(entry);
12410
- const live = livePhrase(entry);
12411
- const rest = [
12412
- ...flags === "" ? [] : [pad(flags, flagWidth)],
12413
- versionPhrase(entry, now),
12414
- ...live === "" ? [] : [live]
12415
- ];
12416
- lines.push(` ${pad(entry.key, width)} ${rest.join(" ")}`.trimEnd());
12417
- }
12418
- };
12419
- section("Journeys", journeys);
12420
- section("Templates", templates);
12421
- if (!manifest) {
12422
- lines.push("", "This tree did not build, so nothing here is compared with it. Run cow build to see why.");
12423
- return lines;
12424
- }
12425
- const changed = local.filter((entry) => {
12426
- const served = status.keys.find((one) => one.kind === entry.kind && one.key === entry.key);
12427
- return !served || bundleOf(served) !== entry.bundle;
12428
- });
12429
- const missing = status.keys.filter((entry) => !local.some((one) => one.kind === entry.kind && one.key === entry.key));
12430
- if (changed.length > 0) lines.push("", `Changed here since the last push: ${changed.map((entry) => entry.key).join(", ")}. Run cow push.`);
12431
- if (missing.length > 0) {
12432
- lines.push("", "On the server and not in this tree:");
12433
- for (const entry of missing) lines.push(` ${entry.key}, a ${entry.kind}. Delete it with cow ${entry.kind}s delete ${entry.key}.`);
12434
- }
12435
- if (status.warnings.length > 0) {
12436
- lines.push("", "Names this organization does not define yet:");
12437
- for (const warning of status.warnings) lines.push(` ${warning}`);
12438
- }
12439
- return lines;
12440
- }
12441
- /** The app's status from the server, for this directory's `cow.json`. */
12442
- async function readStatus(client, appId) {
12443
- return (await client.request(contract.apps["apps.status"], { params: { id: appId } })).data;
12444
- }
12445
- function registerStatus(program, clientFor, io) {
12446
- program.command("status").description("what this app's journeys and templates are doing, and what changed here since the last push").action(async (opts) => {
12447
- const merged = {
12448
- ...program.opts(),
12449
- ...opts
12450
- };
12451
- const config = await assertCowConfig(process.cwd());
12452
- const built = await buildProject(process.cwd()).catch(() => null);
12453
- const status = await readStatus(await clientFor(merged), config.appId);
12454
- if (merged.json === true) {
12455
- emit({ data: status }, io, true);
12456
- return;
12457
- }
12458
- io.stdout(`${statusLines(status, built?.manifest ?? null, /* @__PURE__ */ new Date()).join("\n")}\n`);
12459
- });
12460
- }
12461
-
12462
13254
  //#endregion
12463
13255
  //#region src/build/simulate.ts
12464
13256
  /**
@@ -12744,6 +13536,7 @@ function buildProgram(env, io) {
12744
13536
  registerInit(program, clientFor, env, io);
12745
13537
  registerAdd(program, io);
12746
13538
  registerBuild(program, io);
13539
+ registerPlan(program, clientFor, io);
12747
13540
  registerPush(program, clientFor, env, io);
12748
13541
  registerStatus(program, clientFor, io);
12749
13542
  registerEnable(program, clientFor, io);