@cowliss/cli 0.10.0 → 0.12.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 +1476 -638
  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
@@ -173,6 +173,8 @@ const SYSTEM_EVENT_PREFIX = "system.";
173
173
  * system.profile_deleted is the provider-recorded fact that a provider
174
174
  * (e.g. Clerk `user.deleted`) deleted the user upstream — a marker event
175
175
  * only, with no deletion semantics (hard-delete belongs to GDPR erasure);
176
+ * system.signed_up and system.logged_in are the same shape for the other end
177
+ * of that lifecycle (Clerk `user.created` and `session.created`);
176
178
  * system.email_clicked is a link click reported by SES feedback.
177
179
  *
178
180
  * Rows carrying one of these names get `system: true` so the usage meter
@@ -187,6 +189,17 @@ const SYSTEM_EVENTS = {
187
189
  consentRevoked: "system.consent_revoked",
188
190
  profileDeleted: "system.profile_deleted",
189
191
  /**
192
+ * The provider's account lifecycle, as facts rather than as profile state:
193
+ * a profile can predate its `user.created` (an SDK identify or a
194
+ * membership delivery may have created it first), so these name what the
195
+ * provider reported, not what Cowliss did. `signedUp` is the trigger for a
196
+ * welcome journey fed by a provider source, which `emailRegistered` only
197
+ * approximates (it fires on an address change too, and never for a
198
+ * profile with no address). `loggedIn` is one row per session start.
199
+ */
200
+ signedUp: "system.signed_up",
201
+ loggedIn: "system.logged_in",
202
+ /**
190
203
  * A write's identifiers named more than one profile and Cowliss merged
191
204
  * them (ticket 69). Lands on the survivor's timeline carrying
192
205
  * `mergedProfileIds`, the ids folded into it.
@@ -220,6 +233,12 @@ const EXECUTION_LIMITS = {
220
233
  const PUSH_LIMITS = {
221
234
  journeys: 100,
222
235
  templates: 200,
236
+ /**
237
+ * Segment files (ADR 0023). A segment is a predicate list, not a bundle, so
238
+ * the ceiling is about how many audiences one program describes rather than
239
+ * about bytes; a journey's own inline definition costs nothing here.
240
+ */
241
+ segments: 100,
223
242
  bundleBytes: 2097152,
224
243
  sourceBytes: 5242880
225
244
  };
@@ -2799,6 +2818,12 @@ var PrimaryKey = class {
2799
2818
  }
2800
2819
  };
2801
2820
 
2821
+ //#endregion
2822
+ //#region ../../node_modules/.pnpm/drizzle-orm@0.45.2_postgres@3.4.9/node_modules/drizzle-orm/sql/expressions/conditions.js
2823
+ function isNull(value) {
2824
+ return sql`${value} is null`;
2825
+ }
2826
+
2802
2827
  //#endregion
2803
2828
  //#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
2829
  const CONSTANTS = {
@@ -4120,17 +4145,22 @@ const insertQuarantineEntrySchema = createInsertSchema(quarantineEntries);
4120
4145
  * A segment belongs to one app (ADR 0022) and sees only that app's profiles
4121
4146
  * and that app's events.
4122
4147
  *
4123
- * A row is either standalone (built in the dashboard, `journey_key` null) or
4124
- * owned by one journey whose trigger inlined its definition (ADR 0016). An
4125
- * owned row is named after its journey's key, is written only by the push,
4126
- * and goes when the journey does — which is what the composite foreign key
4127
- * says: it points at `journeys(org_id, key)` and cascades. A null
4128
- * `journey_key` satisfies it vacuously (MATCH SIMPLE), so standalone rows
4129
- * are unconstrained.
4148
+ * A row comes from one of three places, which `source` and `journey_key`
4149
+ * answer together: built in the dashboard (`dashboard`, no journey), pushed
4150
+ * from a `segments/` file (`push`, no journey — ADR 0023), or owned by one
4151
+ * journey whose trigger inlined its definition (`push` and that journey —
4152
+ * ADR 0016). So `source = 'push'` is exactly "the repository writes this
4153
+ * one", which is the whole of the dashboard's edit refusal, and
4154
+ * `journey_key` says which of the two pushed forms it is. An owned row is
4155
+ * named after its journey's key and goes when the journey does — which is
4156
+ * what the composite foreign key says: it points at `journeys(org_id, key)`
4157
+ * and cascades. A null `journey_key` satisfies it vacuously (MATCH SIMPLE),
4158
+ * so the other two forms are unconstrained.
4130
4159
  *
4131
4160
  * Timestamps use millisecond precision, same rationale as apps: JS Dates
4132
4161
  * carry ms only and cursor pagination compares createdAt for equality.
4133
4162
  */
4163
+ const segmentSourceEnum = pgEnum("segment_source", ["dashboard", "push"]);
4134
4164
  const segments$1 = pgTable("segments", {
4135
4165
  id: text("id").primaryKey(),
4136
4166
  orgId: text("org_id").notNull(),
@@ -4138,8 +4168,12 @@ const segments$1 = pgTable("segments", {
4138
4168
  appId: text("app_id").notNull(),
4139
4169
  name: text("name").notNull(),
4140
4170
  description: text("description"),
4171
+ /** The author's labels on a pushed segment; empty for a dashboard one. */
4172
+ tags: text("tags").array().notNull().default(sql`'{}'::text[]`),
4141
4173
  definition: jsonb("definition").$type().notNull(),
4142
- /** The journey that owns this row, or null for a standalone segment. */
4174
+ /** Who writes this row: the dashboard, or a push. */
4175
+ source: segmentSourceEnum("source").notNull().default("dashboard"),
4176
+ /** The journey that owns this row, or null when no journey does. */
4143
4177
  journeyKey: text("journey_key"),
4144
4178
  createdAt: createdAt(),
4145
4179
  updatedAt: updatedAt()
@@ -4320,6 +4354,7 @@ const sources$1 = pgTable("sources", {
4320
4354
  }, (table) => [
4321
4355
  index("sources_org_id_idx").on(table.orgId),
4322
4356
  index("sources_app_id_idx").on(table.appId),
4357
+ uniqueIndex("sources_app_kind_live_idx").on(table.orgId, table.appId, table.kind).where(isNull(table.archivedAt)),
4323
4358
  foreignKey({
4324
4359
  columns: [table.orgId, table.appId],
4325
4360
  foreignColumns: [apps$1.orgId, apps$1.id],
@@ -4900,20 +4935,40 @@ const patternSchema = z.union([onePatternSchema, z.array(onePatternSchema).min(1
4900
4935
  * row carries its `app_id`, and the runtime compares that with the event's.
4901
4936
  * An `appId` here would be a second way to say it, and the way to leak.
4902
4937
  *
4903
- * A segment trigger carries the predicate list, not a name: the push
4904
- * materializes one segment row per journey that inlines a definition, owned
4905
- * by the journey and named after its key, so the segment exists because the
4906
- * journey exists and there is no order to get wrong (ADR 0016). The
4907
- * definition is data — validated here, carried on the version, never
4908
- * compiled and never executed.
4938
+ * A segment trigger says which audience in two ways (ADR 0016, ADR 0023).
4939
+ * Inline, carrying the predicate list itself: the push materializes one
4940
+ * segment row per journey that does, owned by the journey and named after
4941
+ * its key. Or by name, naming a `segments/<key>.ts` file of the same push:
4942
+ * the name resolves inside the tree at build time and the push writes that
4943
+ * file's row, so there is still no order to get wrong and no row outside the
4944
+ * repository a trigger can point at. Either way the definition is data —
4945
+ * validated here, carried on the version, never compiled and never executed.
4946
+ *
4947
+ * Every member is strict, so a journey holding the retired `source` or
4948
+ * `appId` key fails to build instead of silently triggering on every app or
4949
+ * on nothing. There is deliberately no pipe filter and no app filter: the
4950
+ * journey's own app is the only narrowing there is.
4951
+ */
4952
+ const triggerSchema = z.union([
4953
+ z.strictObject({ event: patternSchema }),
4954
+ z.strictObject({ segment: segmentDefinitionSchema }),
4955
+ z.strictObject({ segment: journeyKeySchema })
4956
+ ]);
4957
+ /**
4958
+ * The name of the segment a trigger enrolls from, or null when it is an
4959
+ * event trigger. An inline definition materializes a row named after the
4960
+ * journey (ADR 0016); a named one is the segment file's own key (ADR 0023).
4961
+ * Segment names are unique per organization, so this one string is what
4962
+ * every reader — the live trigger, the enable-time enrollment job and the
4963
+ * journey page — resolves the row by.
4909
4964
  *
4910
- * Both members are strict, so a journey holding the retired `source` or
4911
- * `appId` key, or the retired `{ segment: "name" }` form, fails to build
4912
- * instead of silently triggering on every app or on nothing. There is
4913
- * deliberately no pipe filter and no app filter: the journey's own app is
4914
- * the only narrowing there is.
4965
+ * Structural in its trigger parameter so `packages/db`'s mirror of the shape
4966
+ * passes without either package importing the other.
4915
4967
  */
4916
- const triggerSchema = z.union([z.strictObject({ event: patternSchema }), z.strictObject({ segment: segmentDefinitionSchema })]);
4968
+ function triggerSegmentName(journeyKey, trigger) {
4969
+ if (!("segment" in trigger)) return null;
4970
+ return typeof trigger.segment === "string" ? trigger.segment : journeyKey;
4971
+ }
4917
4972
  /**
4918
4973
  * The address half of a from-header: a local part, an `@`, and a dotted
4919
4974
  * domain. Deliberately narrower than RFC 5322 (no quoted local parts, no
@@ -5068,6 +5123,40 @@ const manifestTemplateSchema = z.object({
5068
5123
  propsSchema: z.record(z.string(), z.unknown()),
5069
5124
  bundle: digestSchema
5070
5125
  });
5126
+ /**
5127
+ * One `segments/<key>.ts` file (ADR 0023): an audience several journeys of
5128
+ * one repository enroll from, written once instead of pasted into each
5129
+ * trigger. It carries no bundle and no digest, because it compiles to
5130
+ * nothing: a definition is a predicate list the evaluator folds, so there is
5131
+ * no code for an execution to pin and therefore no version of it.
5132
+ *
5133
+ * Plain rather than strict, for the same reason the other two entries are: a
5134
+ * stored manifest has to keep parsing after a field goes away (ADR 0011).
5135
+ */
5136
+ const manifestSegmentSchema = z.object({
5137
+ key: journeyKeySchema,
5138
+ /** The author's labels, the way a journey and a template carry them. */
5139
+ tags: tagsSchema,
5140
+ /** The author's own sentence about who is in it; absent when unwritten. */
5141
+ description: z.string().trim().max(500, `description must be at most ${500} characters`).optional(),
5142
+ definition: segmentDefinitionSchema
5143
+ });
5144
+ /**
5145
+ * One event pattern the tree reacts to, read off the tree itself: a trigger,
5146
+ * a `waitForEvent`, or an event predicate in a segment. Nobody declares it —
5147
+ * a second source of truth would disagree with the code the first time an
5148
+ * author edited a trigger — so what a build could not read literally is
5149
+ * simply absent, which makes the list incomplete and never wrong.
5150
+ *
5151
+ * The pattern is stored as written: `order.*` is one entry, not an
5152
+ * expansion, because it is what the author asked for and what any reader
5153
+ * has to match against. `usedBy` names the journeys and segments that named
5154
+ * it, sorted, so a reader can say who is waiting.
5155
+ */
5156
+ const manifestEventSchema = z.object({
5157
+ pattern: onePatternSchema,
5158
+ usedBy: z.array(z.string())
5159
+ });
5071
5160
  function uniqueKeys(items, ctx, path) {
5072
5161
  const seen = /* @__PURE__ */ new Set();
5073
5162
  for (const [index, item] of items.entries()) {
@@ -5103,6 +5192,19 @@ const manifestSchema = z.object({
5103
5192
  journeys: z.array(manifestJourneySchema).max(PUSH_LIMITS.journeys),
5104
5193
  templates: z.array(manifestTemplateSchema).max(PUSH_LIMITS.templates),
5105
5194
  /**
5195
+ * The `segments/` files this project pushes (ADR 0023). Defaulted rather
5196
+ * than required: every manifest stored before segments were files carries
5197
+ * none, and a project with no shared audience still writes the field.
5198
+ */
5199
+ segments: z.array(manifestSegmentSchema).max(PUSH_LIMITS.segments).default([]),
5200
+ /**
5201
+ * The event patterns this tree reacts to, collected from the journeys
5202
+ * and segments above rather than declared. Defaulted for the same reason
5203
+ * `segments` is: every manifest stored before the collection existed
5204
+ * carries none.
5205
+ */
5206
+ events: z.array(manifestEventSchema).default([]),
5207
+ /**
5106
5208
  * The consent purposes this project declares, copied from `cow.json`.
5107
5209
  * Optional rather than defaulted: a push made before purposes existed
5108
5210
  * carries none, and its stored manifest still parses.
@@ -5113,7 +5215,22 @@ const manifestSchema = z.object({
5113
5215
  }).superRefine((manifest, ctx) => {
5114
5216
  uniqueKeys(manifest.journeys, ctx, "journeys");
5115
5217
  uniqueKeys(manifest.templates, ctx, "templates");
5218
+ uniqueKeys(manifest.segments, ctx, "segments");
5116
5219
  uniqueKeys(manifest.purposes ?? [], ctx, "purposes");
5220
+ const segmentKeys = new Set(manifest.segments.map((segment) => segment.key));
5221
+ for (const [index, journey] of manifest.journeys.entries()) {
5222
+ const named = "segment" in journey.trigger && typeof journey.trigger.segment === "string" ? journey.trigger.segment : null;
5223
+ if (named !== null && !segmentKeys.has(named)) ctx.addIssue({
5224
+ code: "custom",
5225
+ 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.`,
5226
+ path: [
5227
+ "journeys",
5228
+ index,
5229
+ "trigger",
5230
+ "segment"
5231
+ ]
5232
+ });
5233
+ }
5117
5234
  for (const [index, journey] of manifest.journeys.entries()) if (journey.enrollment && journey.purpose === "transactional") ctx.addIssue({
5118
5235
  code: "custom",
5119
5236
  message: `journey "${journey.key}": a transactional journey enrolls on every trigger, so it takes no enrollment cooldown`,
@@ -5387,6 +5504,34 @@ const eventNameStatsDtoSchema = z.object({
5387
5504
  lastSeenAt: z.iso.datetime().nullable()
5388
5505
  }))
5389
5506
  });
5507
+ /**
5508
+ * Has this app ever received these event names
5509
+ * (GET /v1/apps/{appId}/events/seen?name=a&name=b)? What `cow build` read
5510
+ * off the tree, asked against what the app actually sends.
5511
+ *
5512
+ * "Ever" rather than a window, deliberately: a window fires on anything
5513
+ * seasonal and puts a threshold in copy that would have to be defended. The
5514
+ * last-seen stamp travels beside the answer and the developer judges whether
5515
+ * eight months ago is a problem.
5516
+ *
5517
+ * A name may be a pattern, matched the way ingestion matches one (`*` only,
5518
+ * against the whole name), so `order.*` is seen once `order.paid` has
5519
+ * arrived.
5520
+ *
5521
+ * Repeated rather than comma-joined, because an event name may hold a comma.
5522
+ * One occurrence arrives as a string and several as an array, which is what
5523
+ * the union is for.
5524
+ */
5525
+ const oneEventName = z.string().min(1).max(200);
5526
+ 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]) });
5527
+ /**
5528
+ * One answer per name asked, in the order asked. `lastSeenAt` null is the
5529
+ * whole of "never": a name is only ever dated because a row carried it.
5530
+ */
5531
+ const eventsSeenDtoSchema = z.object({ names: z.array(z.object({
5532
+ name: z.string(),
5533
+ lastSeenAt: z.iso.datetime().nullable()
5534
+ })) });
5390
5535
 
5391
5536
  //#endregion
5392
5537
  //#region ../../packages/shared/src/batch.ts
@@ -5964,9 +6109,18 @@ function matchesPattern(pattern, name) {
5964
6109
  return (Array.isArray(pattern) ? pattern : [pattern]).some((one) => matchesOne(one, name));
5965
6110
  }
5966
6111
  function matchesOne(pattern, name) {
5967
- const literals = pattern.split("*");
5968
- if (name.startsWith("system.") && !(literals[0] ?? "").startsWith("system.")) return false;
5969
- return matchesLiterals(literals, name);
6112
+ if (name.startsWith("system.") && !isSystemPattern(pattern)) return false;
6113
+ return matchesLiterals(pattern.split("*"), name);
6114
+ }
6115
+ /**
6116
+ * Whether a pattern reaches into the system namespace: its literal prefix,
6117
+ * the text before its first `*`, is itself under SYSTEM_EVENT_PREFIX. The
6118
+ * system rule above asks it per name; `cow build` asks it per pattern, to
6119
+ * leave the events Cowliss writes itself out of the list of events a
6120
+ * developer is asked to send.
6121
+ */
6122
+ function isSystemPattern(pattern) {
6123
+ return (pattern.split("*")[0] ?? "").startsWith(SYSTEM_EVENT_PREFIX);
5970
6124
  }
5971
6125
  /**
5972
6126
  * Walk the `*`-separated literals left to right: the first anchors the start,
@@ -6409,7 +6563,23 @@ const createPushBodySchema = z.object({ data: z.object({
6409
6563
  * lands silently whenever both orgs happen to hold an app of the same
6410
6564
  * slug, which is exactly the shape `apps/platform-workspace` has.
6411
6565
  */
6412
- orgId: orgIdSchema
6566
+ orgId: orgIdSchema,
6567
+ /**
6568
+ * Delete the `segments/` rows this app holds and this manifest does not
6569
+ * carry (`cow push --prune`, ADR 0024). Off by default, which is what a
6570
+ * push has always been: additive.
6571
+ *
6572
+ * Only segments, because only a push may write or unwrite one — the
6573
+ * dashboard refuses to touch a pushed row and tells the reader to change
6574
+ * the file and push again, which is this flag. Journeys and templates are
6575
+ * not here: a journey has its own delete verb, which `cow push --prune`
6576
+ * calls after the push, and a template's lifecycle belongs to the version
6577
+ * prune sweep because a live execution pins the version it started on.
6578
+ *
6579
+ * `pushes.plan` takes the same body and ignores this: a plan writes
6580
+ * nothing, and the drift it already reports is what a prune would act on.
6581
+ */
6582
+ prune: z.boolean().default(false)
6413
6583
  }) });
6414
6584
  const listPushesQuerySchema = paginationQuerySchema.extend({
6415
6585
  /** One app's own pushes; absent lists the org's. */
@@ -6853,6 +7023,142 @@ const notificationPreferencesSchema = z.object({ purposes: z.array(notificationP
6853
7023
  */
6854
7024
  const updateNotificationPreferencesBodySchema = z.object({ data: z.object({ purposes: consentPatchSchema }) });
6855
7025
 
7026
+ //#endregion
7027
+ //#region ../../packages/shared/src/stable-json.ts
7028
+ /**
7029
+ * A key-sorted JSON rendering, for comparing or hashing two values a JSON
7030
+ * round trip may have reordered. `undefined` members are dropped the way
7031
+ * `JSON.stringify` drops them.
7032
+ *
7033
+ * Its own module with no imports at all, because both users need it and
7034
+ * they sit on opposite sides of a boundary: the release digest (which pulls
7035
+ * in node:crypto) and the compile workflow (which may not pull in anything).
7036
+ */
7037
+ function stableJson(value) {
7038
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
7039
+ 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(",")}}`;
7040
+ return JSON.stringify(value) ?? "null";
7041
+ }
7042
+
7043
+ //#endregion
7044
+ //#region ../../packages/shared/src/plan.ts
7045
+ /**
7046
+ * What a push would change, computed before anything is stored (ADR 0025).
7047
+ *
7048
+ * The server is the state: a checkout holds no record of what it last
7049
+ * pushed, so the baseline is the account itself — the latest version of
7050
+ * every key and the segment rows the repository writes. The comparison
7051
+ * below is the only one there is. `pushes.plan` runs it against those rows;
7052
+ * `cow status` runs it against the same entries read back through
7053
+ * `apps.status`, so the two can never disagree about what "changed" means.
7054
+ */
7055
+ const PLAN_KINDS = [
7056
+ "journey",
7057
+ "segment",
7058
+ "template"
7059
+ ];
7060
+ const planKindSchema = z.enum(PLAN_KINDS);
7061
+ const planEntrySchema = z.object({
7062
+ kind: planKindSchema,
7063
+ key: z.string(),
7064
+ change: z.enum([
7065
+ "added",
7066
+ "changed",
7067
+ "unchanged"
7068
+ ]),
7069
+ /**
7070
+ * Which of the entry's fields differ from the one the account holds,
7071
+ * sorted; empty unless `change` is `changed`. Named rather than counted,
7072
+ * because "winback changed" and "winback's from changed" are answers to
7073
+ * different questions.
7074
+ */
7075
+ fields: z.array(z.string())
7076
+ });
7077
+ /**
7078
+ * Something the account holds that this push does not write. Reported and
7079
+ * never reconciled: the dashboard stays fully editable, and a push that
7080
+ * quietly undid what someone did there would be the worse surprise.
7081
+ *
7082
+ * `not-in-tree` is a key the repository no longer has (deleting it is its
7083
+ * own verb); `on-hold` is a journey someone put on hold, which a push
7084
+ * carrying new code for it still leaves on hold.
7085
+ */
7086
+ const planDriftSchema = z.object({
7087
+ kind: planKindSchema,
7088
+ key: z.string(),
7089
+ reason: z.enum(["not-in-tree", "on-hold"])
7090
+ });
7091
+ const pushPlanSchema = z.object({
7092
+ appId: appIdSchema,
7093
+ /** One per key the tree carries, in reading order: journeys, segments, templates. */
7094
+ entries: z.array(planEntrySchema),
7095
+ drift: z.array(planDriftSchema)
7096
+ });
7097
+ /**
7098
+ * `spine` never takes part: it is read off the same source the bundle is
7099
+ * built from, so it can only ever move together with `bundle`, and naming
7100
+ * both would say one thing twice.
7101
+ */
7102
+ function entrySide(kind, entry) {
7103
+ const { key, spine: _spine, ...fields } = entry;
7104
+ return {
7105
+ kind,
7106
+ key,
7107
+ fields
7108
+ };
7109
+ }
7110
+ /** Every key the tree carries, as the plan compares them. */
7111
+ function manifestSides(manifest) {
7112
+ return [
7113
+ ...manifest.journeys.map((journey) => entrySide("journey", journey)),
7114
+ ...manifest.segments.map((segment) => entrySide("segment", segment)),
7115
+ ...manifest.templates.map((template) => entrySide("template", template))
7116
+ ];
7117
+ }
7118
+ /** Journeys, then segments, then templates; alphabetical within each. */
7119
+ function order(one) {
7120
+ return `${PLAN_KINDS.indexOf(one.kind)}${one.key}`;
7121
+ }
7122
+ function sameKey(a) {
7123
+ return (b) => b.kind === a.kind && b.key === a.key;
7124
+ }
7125
+ /** The whole diff: what this tree would write, and what it leaves alone. */
7126
+ function pushPlan(appId, local, served) {
7127
+ const entries = local.map((side) => {
7128
+ const held = served.find(sameKey(side));
7129
+ if (!held) return {
7130
+ kind: side.kind,
7131
+ key: side.key,
7132
+ change: "added",
7133
+ fields: []
7134
+ };
7135
+ const fields = [.../* @__PURE__ */ new Set([...Object.keys(side.fields), ...Object.keys(held.fields)])].filter((field) => stableJson(side.fields[field]) !== stableJson(held.fields[field])).sort();
7136
+ return {
7137
+ kind: side.kind,
7138
+ key: side.key,
7139
+ change: fields.length === 0 ? "unchanged" : "changed",
7140
+ fields
7141
+ };
7142
+ });
7143
+ const drift = served.map((side) => {
7144
+ if (!local.some(sameKey(side))) return {
7145
+ kind: side.kind,
7146
+ key: side.key,
7147
+ reason: "not-in-tree"
7148
+ };
7149
+ return side.onHold === true ? {
7150
+ kind: side.kind,
7151
+ key: side.key,
7152
+ reason: "on-hold"
7153
+ } : null;
7154
+ });
7155
+ return {
7156
+ appId,
7157
+ entries: entries.sort((a, b) => order(a).localeCompare(order(b))),
7158
+ drift: drift.filter((one) => one !== null).sort((a, b) => order(a).localeCompare(order(b)))
7159
+ };
7160
+ }
7161
+
6856
7162
  //#endregion
6857
7163
  //#region ../../packages/shared/src/resend.ts
6858
7164
  /** Resend's own cap on one message's recipients, per list. */
@@ -7580,6 +7886,36 @@ const updateWebhookBodySchema = z.object({ data: z.object({
7580
7886
  /** `q` is a substring search over the webhook's name. */
7581
7887
  const listWebhooksQuerySchema = paginationQuerySchema.extend({ q: searchQuerySchema });
7582
7888
 
7889
+ //#endregion
7890
+ //#region ../../packages/shared/src/whoami.ts
7891
+ /**
7892
+ * What an ingestion key plus one source id write into. An org API key is
7893
+ * org-scoped and carries no app of its own (`ApiKeyClaims`), so any live
7894
+ * `src_` in the organization is accepted by the write path: a well-formed id
7895
+ * belonging to another app lands profiles there with a 200. The id the
7896
+ * caller is about to send is therefore the input, and this answers where it
7897
+ * goes before anything is written.
7898
+ *
7899
+ * Scoped to that one source's app on purpose: the key stays a write
7900
+ * credential, and a leaked one must not enumerate the org's other apps
7901
+ * (.scratch/24-onboarding-dx/spec.md).
7902
+ */
7903
+ const whoamiQuerySchema = z.object({ sourceId: sourceIdSchema });
7904
+ const whoamiSchema = z.object({
7905
+ orgId: z.string(),
7906
+ app: z.object({
7907
+ id: z.string(),
7908
+ name: z.string()
7909
+ }),
7910
+ /** Every live pipe into that app, so "a Clerk source already feeds this" is visible. */
7911
+ sources: z.array(sourceSchema.pick({
7912
+ id: true,
7913
+ kind: true,
7914
+ configured: true,
7915
+ lastReceivedAt: true
7916
+ }))
7917
+ });
7918
+
7583
7919
  //#endregion
7584
7920
  //#region ../../packages/api-client/src/client.ts
7585
7921
  var ApiError = class extends Error {
@@ -7604,7 +7940,7 @@ async function send(baseUrl, init) {
7604
7940
  body: typeof init.body === "string" || init.body === null ? init.body : Uint8Array.from(init.body)
7605
7941
  });
7606
7942
  } catch {
7607
- throw new ApiError("network_error", `Could not reach the API at ${baseUrl}. Is apps/api running?`, 0);
7943
+ throw new ApiError("network_error", `Could not reach the API at ${baseUrl}. Check the address, or set COW_API_URL to the one you meant.`, 0);
7608
7944
  }
7609
7945
  const requestId = response.headers.get("X-Request-Id") ?? void 0;
7610
7946
  if (init.binary && response.ok) return {
@@ -7754,24 +8090,48 @@ function readCliPackage() {
7754
8090
  return cached;
7755
8091
  }
7756
8092
 
8093
+ //#endregion
8094
+ //#region src/build/events.ts
8095
+ function collectEvents(tree) {
8096
+ const named = /* @__PURE__ */ new Map();
8097
+ const add = (key, pattern) => {
8098
+ if (pattern.replaceAll("*", "") === "" || isSystemPattern(pattern)) return;
8099
+ const keys = named.get(pattern) ?? /* @__PURE__ */ new Set();
8100
+ keys.add(key);
8101
+ named.set(pattern, keys);
8102
+ };
8103
+ const addDefinition = (key, definition) => {
8104
+ for (const predicate of definition.predicates) if (predicate.kind === "event") add(key, predicate.name);
8105
+ };
8106
+ for (const journey of tree.journeys) {
8107
+ if ("event" in journey.trigger) for (const pattern of [journey.trigger.event].flat()) add(journey.key, pattern);
8108
+ else if (typeof journey.trigger.segment !== "string") addDefinition(journey.key, journey.trigger.segment);
8109
+ for (const pattern of journey.waits ?? []) add(journey.key, pattern);
8110
+ }
8111
+ for (const segment of tree.segments ?? []) addDefinition(segment.key, segment.definition);
8112
+ return [...named].map(([pattern, keys]) => ({
8113
+ pattern,
8114
+ usedBy: [...keys].sort(byCodePoint)
8115
+ })).sort((a, b) => byCodePoint(a.pattern, b.pattern));
8116
+ }
8117
+ function byCodePoint(a, b) {
8118
+ return a < b ? -1 : a > b ? 1 : 0;
8119
+ }
8120
+
7757
8121
  //#endregion
7758
8122
  //#region src/build/spine.ts
7759
8123
  /**
7760
- * The step spine, read off the source of `run` rather than recorded by
7761
- * running it: a run against stub data follows one path and never sees an
7762
- * `if` on a profile trait, which is the shape of half the journeys there
7763
- * are. Reading the code sees every branch, labelled with its condition.
7764
- *
7765
- * What it reads: `await api.*` calls (in source order, with the literal
7766
- * template, webhook, event, key, or duration when the author wrote one
7767
- * inline), `if`/`else`, `switch`, loops, `try`/`catch`, `return`, `throw`, and
7768
- * `api.restart()`. Calls behind a helper function in another file, and a
7769
- * `run` that is not an inline function on the `defineJourney` object, are
7770
- * not followed: the spine is display only, and its ceiling is the source in
7771
- * front of it. The typecheck that ran first is TypeScript's; syntax it accepts
7772
- * and this parser does not yields an empty spine, never a failed build.
7773
- */
7774
- function readSpine(source) {
8124
+ * What one journey's source says: its spine, and every event pattern an
8125
+ * `api.waitForEvent` in it names literally, in source order and with a list
8126
+ * argument flattened.
8127
+ *
8128
+ * The patterns are read here rather than off the spine because the spine's
8129
+ * `detail` is a label: a non-literal argument shows as the source text the
8130
+ * author wrote, which reads fine and would collect `name` as an event name.
8131
+ * A caller that needs the data takes this; a caller that needs the display
8132
+ * takes `readSpine`.
8133
+ */
8134
+ function readJourneySource(source) {
7775
8135
  let program;
7776
8136
  try {
7777
8137
  program = parse(source, {
@@ -7780,22 +8140,37 @@ function readSpine(source) {
7780
8140
  errorRecovery: true
7781
8141
  }).program;
7782
8142
  } catch {
7783
- return [];
8143
+ return {
8144
+ spine: [],
8145
+ waits: []
8146
+ };
7784
8147
  }
7785
8148
  const run = findRun(program);
7786
- if (!run) return [];
8149
+ if (!run) return {
8150
+ spine: [],
8151
+ waits: []
8152
+ };
7787
8153
  const api = run.params[1];
7788
- if (api?.type !== "Identifier") return [];
8154
+ if (api?.type !== "Identifier") return {
8155
+ spine: [],
8156
+ waits: []
8157
+ };
7789
8158
  const reader = new Reader(source, api.name);
7790
- if (run.body.type !== "BlockStatement") return [...reader.calls(run.body), {
7791
- name: "end",
7792
- detail: "returned"
7793
- }];
8159
+ if (run.body.type !== "BlockStatement") return {
8160
+ spine: [...reader.calls(run.body), {
8161
+ name: "end",
8162
+ detail: "returned"
8163
+ }],
8164
+ waits: reader.waitPatterns
8165
+ };
7794
8166
  const body = reader.block(run.body.body);
7795
- return body.ended ? body.entries : [...body.entries, {
7796
- name: "end",
7797
- detail: "returned"
7798
- }];
8167
+ return {
8168
+ spine: body.ended ? body.entries : [...body.entries, {
8169
+ name: "end",
8170
+ detail: "returned"
8171
+ }],
8172
+ waits: reader.waitPatterns
8173
+ };
7799
8174
  }
7800
8175
  /** The `run` function on the object `export default defineJourney({...})` receives. */
7801
8176
  function findRun(program) {
@@ -7819,6 +8194,8 @@ var Reader = class {
7819
8194
  api;
7820
8195
  /** Variables holding a `waitForEvent` result, by name, to the event waited for. */
7821
8196
  waits = /* @__PURE__ */ new Map();
8197
+ /** Every pattern a `waitForEvent` named literally, in source order. */
8198
+ waitPatterns = [];
7822
8199
  constructor(source, api) {
7823
8200
  this.source = source;
7824
8201
  this.api = api;
@@ -7969,7 +8346,9 @@ var Reader = class {
7969
8346
  detail(first ? this.literal(first) ?? this.text(first) : void 0);
7970
8347
  break;
7971
8348
  case "waitForEvent": {
7972
- detail(first ? this.literalPattern(first) ?? this.text(first) : void 0);
8349
+ const patterns = this.literalPatterns(first);
8350
+ if (patterns) this.waitPatterns.push(...patterns);
8351
+ detail(patterns ? patternLabel(patterns) : first ? this.text(first) : void 0);
7973
8352
  const timeout = this.property(args[1], "timeout");
7974
8353
  if (timeout) entry.timeout = timeout;
7975
8354
  break;
@@ -8015,12 +8394,22 @@ var Reader = class {
8015
8394
  }
8016
8395
  /** A literal pattern, one or a list, the way the trigger label shows it. */
8017
8396
  literalPattern(node) {
8397
+ const patterns = this.literalPatterns(node);
8398
+ return patterns === void 0 ? void 0 : patternLabel(patterns);
8399
+ }
8400
+ /**
8401
+ * The patterns a matcher argument names, or undefined when any of them is
8402
+ * not a literal: a variable or a helper call is a pattern this read cannot
8403
+ * know, and half a list is worse than none of it.
8404
+ */
8405
+ literalPatterns(node) {
8018
8406
  if (!node) return;
8019
8407
  if (node.type === "ArrayExpression") {
8020
8408
  const items = node.elements.map((item) => item ? this.literal(item) : void 0);
8021
- return items.every((item) => item !== void 0) ? patternLabel(items) : void 0;
8409
+ return items.every((item) => item !== void 0) ? items : void 0;
8022
8410
  }
8023
- return this.literal(node);
8411
+ const one = this.literal(node);
8412
+ return one === void 0 ? void 0 : [one];
8024
8413
  }
8025
8414
  /** The literal value of `key` in an inline object argument. */
8026
8415
  property(node, key) {
@@ -8164,6 +8553,7 @@ const CONFIG_FILES = [
8164
8553
  const GUEST_ALIAS = {
8165
8554
  "@cowliss/cli/journeys": guestFile("journeys"),
8166
8555
  "@cowliss/cli/emails": guestFile("emails"),
8556
+ "@cowliss/cli/segments": guestFile("segments"),
8167
8557
  "@cowliss/cli/guest": guestFile("guest"),
8168
8558
  "@cowliss/cli/guest-emails": guestFile("guest-emails"),
8169
8559
  "@cowliss/cli/guest-wasi": guestFile("guest-wasi"),
@@ -8204,14 +8594,15 @@ async function projectSources(projectDir) {
8204
8594
  return [
8205
8595
  ...(await Promise.all(CONFIG_FILES.map(async (file) => await stat(join(projectDir, file)).then(() => file, () => null)))).filter((file) => file !== null),
8206
8596
  ...(await listFiles(join(projectDir, "journeys"))).map((file) => `journeys/${file}`),
8207
- ...(await listFiles(join(projectDir, "emails"))).map((file) => `emails/${file}`)
8597
+ ...(await listFiles(join(projectDir, "emails"))).map((file) => `emails/${file}`),
8598
+ ...(await listFiles(join(projectDir, "segments"))).map((file) => `segments/${file}`)
8208
8599
  ].sort();
8209
8600
  }
8210
8601
  async function discover(projectDir, kind, extension) {
8211
8602
  const dir = join(projectDir, kind);
8212
8603
  return (await listFiles(dir)).filter((file) => file.endsWith(extension) && !file.endsWith(".d.ts") && !basename(file).startsWith("_")).map((file) => {
8213
8604
  const key = basename(file, extension);
8214
- 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).`);
8605
+ 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).`);
8215
8606
  return {
8216
8607
  key,
8217
8608
  kind,
@@ -8235,6 +8626,20 @@ function assertUniqueKeys(files) {
8235
8626
  }
8236
8627
  }
8237
8628
  /**
8629
+ * A trigger that names its segment resolves inside this one tree, or the
8630
+ * build stops here (ADR 0023). This is the whole reason the by-name form is
8631
+ * safe to have back: the name is a file in the repository the author is
8632
+ * looking at, never a row somebody renamed in the dashboard. Said with the
8633
+ * file in hand and the names that do exist, because the two ways out are
8634
+ * writing the missing file and fixing a typo.
8635
+ */
8636
+ function assertSegmentResolves(relPath, trigger, segmentKeys) {
8637
+ if (!("segment" in trigger) || typeof trigger.segment !== "string") return;
8638
+ if (segmentKeys.has(trigger.segment)) return;
8639
+ const available = segmentKeys.size === 0 ? "This project has no segments yet." : `This project's segments are ${[...segmentKeys].sort().map((key) => `"${key}"`).join(", ")}.`;
8640
+ 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.`);
8641
+ }
8642
+ /**
8238
8643
  * `.cow/types.d.ts`: one `typeof import(...)` per template, merged into the
8239
8644
  * SDK's `CowTemplates`, which is what types `api.send.email`. Written before
8240
8645
  * the typecheck, because the typecheck is what it exists for.
@@ -8280,7 +8685,7 @@ async function typecheck(projectDir) {
8280
8685
  });
8281
8686
  } catch (error) {
8282
8687
  const output = error;
8283
- const report = (output.stdout ?? "").trim() || (output.stderr ?? "").trim();
8688
+ const report = (output.stdout ?? "").trim() || (output.stderr ?? "").trim() || String(error.message ?? "");
8284
8689
  throw new Error(`Typecheck failed:\n${report}`);
8285
8690
  }
8286
8691
  }
@@ -8372,12 +8777,16 @@ import { runGuest } from ${JSON.stringify(DRIVER[kind])};
8372
8777
  main(module, runGuest);
8373
8778
  `;
8374
8779
  }
8375
- /** The local twin: the same graph, importable by Node for the manifest and `cow test`. */
8780
+ /**
8781
+ * The local twin: the same graph, importable by Node for the manifest and
8782
+ * `cow test`. A segment file gets the module and no driver: it compiles to
8783
+ * nothing the sandbox ever runs, so there is no guest protocol to speak.
8784
+ */
8376
8785
  function nodeEntry(file, kind) {
8786
+ const driver = kind === "segments" ? "" : `export { runGuest } from ${JSON.stringify(DRIVER[kind])};\n`;
8377
8787
  return `import * as module from ${JSON.stringify(file)};
8378
8788
  export { module };
8379
- export { runGuest } from ${JSON.stringify(DRIVER[kind])};
8380
- `;
8789
+ ${driver}`;
8381
8790
  }
8382
8791
  let messagePortsUnreffed = false;
8383
8792
  /**
@@ -8406,15 +8815,28 @@ function unrefMessagePorts() {
8406
8815
  * rebuilt bundle from being served out of Node's module cache.
8407
8816
  */
8408
8817
  async function loadNodeBundle(projectDir, key, kind = "journeys") {
8409
- unrefMessagePorts();
8410
- const path = join(projectDir, BUILD_DIR, "node", kind, `${key}.mjs`);
8411
- const { mtimeNs } = await stat(path, { bigint: true });
8412
- const loaded = await import(`${pathToFileURL(path).href}?v=${mtimeNs}`);
8818
+ const loaded = await importNodeBundle(projectDir, key, kind);
8413
8819
  return {
8414
8820
  module: loaded.module,
8415
8821
  runGuest: loaded.runGuest
8416
8822
  };
8417
8823
  }
8824
+ async function importNodeBundle(projectDir, key, kind) {
8825
+ unrefMessagePorts();
8826
+ const path = join(projectDir, BUILD_DIR, "node", kind, `${key}.mjs`);
8827
+ const { mtimeNs } = await stat(path, { bigint: true });
8828
+ return await import(`${pathToFileURL(path).href}?v=${mtimeNs}`);
8829
+ }
8830
+ /**
8831
+ * A built segment file's own export. No driver and no guest protocol: the
8832
+ * definition is data `defineSegment` already validated at module load, so
8833
+ * reading it is an import and a shape check.
8834
+ */
8835
+ async function loadSegment(projectDir, key) {
8836
+ const { module } = await importNodeBundle(projectDir, key, "segments");
8837
+ const segment = module.default;
8838
+ return segment?.definition ? segment : void 0;
8839
+ }
8418
8840
  /**
8419
8841
  * A profile with nothing in it but the id and traits given: the base the
8420
8842
  * simulator answers `profile.get` with.
@@ -8442,6 +8864,7 @@ function emptyProfile(id = "", traits = {}, purposes = []) {
8442
8864
  function limitFailure(sizes) {
8443
8865
  if (sizes.journeys > PUSH_LIMITS.journeys) return `This project has ${sizes.journeys} journeys; a push carries at most ${PUSH_LIMITS.journeys}.`;
8444
8866
  if (sizes.templates > PUSH_LIMITS.templates) return `This project has ${sizes.templates} templates; a push carries at most ${PUSH_LIMITS.templates}.`;
8867
+ if (sizes.segments > PUSH_LIMITS.segments) return `This project has ${sizes.segments} segments; a push carries at most ${PUSH_LIMITS.segments}.`;
8445
8868
  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}.`;
8446
8869
  if (sizes.sourceBytes > PUSH_LIMITS.sourceBytes) return `The source tarball is ${sizes.sourceBytes} bytes; a push carries at most ${PUSH_LIMITS.sourceBytes}.`;
8447
8870
  return null;
@@ -8517,13 +8940,16 @@ async function buildProject(projectDir) {
8517
8940
  const config = await assertCowConfig(projectDir);
8518
8941
  const journeys = await discover(projectDir, "journeys", ".ts");
8519
8942
  const templates = await discover(projectDir, "emails", ".tsx");
8943
+ const segmentFiles = await discover(projectDir, "segments", ".ts");
8520
8944
  assertUniqueKeys(journeys);
8521
8945
  assertUniqueKeys(templates);
8946
+ assertUniqueKeys(segmentFiles);
8522
8947
  const outDir = join(projectDir, BUILD_DIR);
8523
8948
  for (const kind of ["journeys", "emails"]) {
8524
8949
  await mkdir(join(outDir, "bundles", kind), { recursive: true });
8525
8950
  await mkdir(join(outDir, "node", kind), { recursive: true });
8526
8951
  }
8952
+ await mkdir(join(outDir, "node", "segments"), { recursive: true });
8527
8953
  await writeFile(join(projectDir, TYPES_FILE), templateTypes(templates));
8528
8954
  await typecheck(projectDir);
8529
8955
  const files = [...journeys, ...templates];
@@ -8541,8 +8967,29 @@ async function buildProject(projectDir) {
8541
8967
  bytes: wasi.code.byteLength
8542
8968
  };
8543
8969
  }));
8970
+ const manifestSegments = await Promise.all(segmentFiles.map(async (source) => {
8971
+ const nodeFile = join(outDir, "node", source.kind, `${source.key}.mjs`);
8972
+ const node = await bundle(projectDir, nodeFile, nodeEntry(source.file, source.kind));
8973
+ await writeFile(nodeFile, node.code);
8974
+ await writeFile(`${nodeFile}.map`, node.map);
8975
+ let segment;
8976
+ try {
8977
+ segment = await loadSegment(projectDir, source.key);
8978
+ } catch (error) {
8979
+ throw new Error(`${source.relPath}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
8980
+ }
8981
+ if (!segment) throw new Error(`${source.relPath} is not a segment: a segment file default-exports defineSegment({ predicates: [ ... ] }).`);
8982
+ return {
8983
+ key: source.key,
8984
+ tags: segment.tags,
8985
+ description: segment.description,
8986
+ definition: segment.definition
8987
+ };
8988
+ }));
8989
+ const segmentKeys = new Set(manifestSegments.map((segment) => segment.key));
8544
8990
  const manifestJourneys = [];
8545
8991
  const manifestTemplates = [];
8992
+ const journeyWaits = /* @__PURE__ */ new Map();
8546
8993
  for (const built of bundles) {
8547
8994
  let report;
8548
8995
  try {
@@ -8553,18 +9000,22 @@ async function buildProject(projectDir) {
8553
9000
  }
8554
9001
  const isJourney = built.source.kind === "journeys";
8555
9002
  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.`);
8556
- if (report.kind === "journey") manifestJourneys.push({
8557
- key: built.source.key,
8558
- tags: report.tags,
8559
- trigger: report.trigger,
8560
- purpose: report.purpose,
8561
- enrollment: report.enrollment,
8562
- description: report.description,
8563
- from: report.from,
8564
- spine: readSpine(await readFile(built.source.file, "utf8")),
8565
- bundle: built.digest
8566
- });
8567
- else manifestTemplates.push({
9003
+ if (report.kind === "journey") {
9004
+ assertSegmentResolves(built.source.relPath, report.trigger, segmentKeys);
9005
+ const { spine, waits } = readJourneySource(await readFile(built.source.file, "utf8"));
9006
+ manifestJourneys.push({
9007
+ key: built.source.key,
9008
+ tags: report.tags,
9009
+ trigger: report.trigger,
9010
+ purpose: report.purpose,
9011
+ enrollment: report.enrollment,
9012
+ description: report.description,
9013
+ from: report.from,
9014
+ spine,
9015
+ bundle: built.digest
9016
+ });
9017
+ journeyWaits.set(built.source.key, waits);
9018
+ } else manifestTemplates.push({
8568
9019
  key: built.source.key,
8569
9020
  tags: report.tags,
8570
9021
  sendClass: report.sendClass,
@@ -8579,6 +9030,7 @@ async function buildProject(projectDir) {
8579
9030
  const failure = limitFailure({
8580
9031
  journeys: journeys.length,
8581
9032
  templates: templates.length,
9033
+ segments: segmentFiles.length,
8582
9034
  bundles: bundles.map((built) => ({
8583
9035
  key: built.source.key,
8584
9036
  bytes: built.bytes
@@ -8591,6 +9043,14 @@ async function buildProject(projectDir) {
8591
9043
  sdk: (await readCliPackage()).version,
8592
9044
  journeys: manifestJourneys,
8593
9045
  templates: manifestTemplates,
9046
+ segments: manifestSegments,
9047
+ events: collectEvents({
9048
+ journeys: manifestJourneys.map((journey) => ({
9049
+ ...journey,
9050
+ waits: journeyWaits.get(journey.key)
9051
+ })),
9052
+ segments: manifestSegments
9053
+ }),
8594
9054
  purposes: config.purposes,
8595
9055
  source: digestOf(await readFile(sourceFile))
8596
9056
  });
@@ -8626,8 +9086,15 @@ async function buildIfStale(projectDir) {
8626
9086
 
8627
9087
  //#endregion
8628
9088
  //#region src/lib/config.ts
8629
- const DEFAULT_API_URL = "http://localhost:3400";
8630
- const DEFAULT_WEB_URL = "http://localhost:5273";
9089
+ /**
9090
+ * Where a `cow` that was told nothing talks to: the hosted service. A
9091
+ * developer running Cowliss itself points a checkout somewhere else with
9092
+ * `COW_API_URL` or `apiUrl` in `cow.json`, which both outrank this; the
9093
+ * default is the last resort, and the last resort of a published CLI cannot
9094
+ * be a port on the reader's own machine.
9095
+ */
9096
+ const DEFAULT_API_URL = "https://api.cowliss.com";
9097
+ const DEFAULT_WEB_URL = "https://app.cowliss.com";
8631
9098
  function credentialsPath(env) {
8632
9099
  return env.COW_CREDENTIALS_PATH ?? defaultCredentialsPath();
8633
9100
  }
@@ -8679,20 +9146,20 @@ function resolveCredential(env, credentials) {
8679
9146
  * The project's own `apiUrl` outranks the credentials file because it is the
8680
9147
  * more specific answer: `cow.json` says which API this project talks to, while the
8681
9148
  * credentials only remember where somebody last logged in. Without it a
8682
- * checkout whose config names production silently fell through to
8683
- * `DEFAULT_API_URL`, which is how a production deploy reaches localhost.
9149
+ * checkout whose config names one deployment silently fell through to
9150
+ * whatever the last `cow login` had saved.
8684
9151
  */
8685
9152
  function resolveApiUrl(env, credentials, flag, projectApiUrl) {
8686
- return flag ?? env.COW_API_URL ?? projectApiUrl ?? credentials?.apiUrl ?? "http://localhost:3400";
9153
+ return flag ?? env.COW_API_URL ?? projectApiUrl ?? credentials?.apiUrl ?? "https://api.cowliss.com";
8687
9154
  }
8688
9155
  /**
8689
9156
  * The dashboard this project belongs to: the flag, the env var, the
8690
9157
  * project's own `cow.json`, then the default. Same precedence as the API URL
8691
- * and for the same reason: a checkout pointed at production must not send
8692
- * the developer (or a push's links) to localhost.
9158
+ * and for the same reason: a checkout pointed at one deployment must not
9159
+ * send the developer (or a push's links) to another.
8693
9160
  */
8694
9161
  function resolveWebUrl(env, projectWebUrl, flag) {
8695
- return flag ?? env.COW_WEB_URL ?? projectWebUrl ?? "http://localhost:5273";
9162
+ return flag ?? env.COW_WEB_URL ?? projectWebUrl ?? "https://app.cowliss.com";
8696
9163
  }
8697
9164
  /**
8698
9165
  * Decode a JWT payload without verification. Display only: the API is the
@@ -8725,9 +9192,9 @@ function decodeSessionToken(token) {
8725
9192
  //#endregion
8726
9193
  //#region src/commands/add.ts
8727
9194
  /**
8728
- * `cow add example <name>`: copy one of the gallery examples the package
9195
+ * `cow add blueprint <name>`: copy one of the gallery blueprints the package
8729
9196
  * ships into the project in the current directory. The same files back
8730
- * `cow init --example` and the docs gallery, so what the docs render is
9197
+ * `cow init --blueprint` and the docs gallery, so what the docs render is
8731
9198
  * exactly what lands in a developer's repo.
8732
9199
  *
8733
9200
  * Resolved from the package's own manifest, the self-reference
@@ -8736,29 +9203,30 @@ function decodeSessionToken(token) {
8736
9203
  * bundled `dist/index.js`, and the published layout is the one nothing in
8737
9204
  * the monorepo exercises.
8738
9205
  */
8739
- const EXAMPLES_DIR = fileURLToPath(new URL("examples/", import.meta.resolve("@cowliss/cli/package.json")));
8740
- /** An example's copyable directories, in the order files are reported. */
9206
+ const BLUEPRINTS_DIR = fileURLToPath(new URL("blueprints/", import.meta.resolve("@cowliss/cli/package.json")));
9207
+ /** A blueprint's copyable directories, in the order files are reported. */
8741
9208
  const PARTS = [
8742
9209
  "journeys",
8743
9210
  "emails",
9211
+ "segments",
8744
9212
  "scenarios"
8745
9213
  ];
8746
- async function exampleNames() {
8747
- return (await readdir(EXAMPLES_DIR, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
9214
+ async function blueprintNames() {
9215
+ return (await readdir(BLUEPRINTS_DIR, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
8748
9216
  }
8749
9217
  /**
8750
- * Copy an example's files into `dir`, returning the project-relative paths
9218
+ * Copy a blueprint's files into `dir`, returning the project-relative paths
8751
9219
  * written. Collected and checked before anything is written, so a refusal on
8752
9220
  * the last file never leaves the first half copied.
8753
9221
  */
8754
- async function copyExample(name, dir, force) {
8755
- const known = await exampleNames();
8756
- if (!known.includes(name)) throw new Error(`Unknown example "${name}". Known examples: ${known.join(", ")}`);
9222
+ async function copyBlueprint(name, dir, force) {
9223
+ const known = await blueprintNames();
9224
+ if (!known.includes(name)) throw new Error(`Unknown blueprint "${name}". Known blueprints: ${known.join(", ")}`);
8757
9225
  const files = [];
8758
9226
  for (const part of PARTS) {
8759
9227
  let entries;
8760
9228
  try {
8761
- entries = await readdir(join(EXAMPLES_DIR, name, part));
9229
+ entries = await readdir(join(BLUEPRINTS_DIR, name, part));
8762
9230
  } catch {
8763
9231
  continue;
8764
9232
  }
@@ -8768,244 +9236,80 @@ async function copyExample(name, dir, force) {
8768
9236
  if (clashes.length > 0) throw new Error(`Already in this project: ${clashes.join(", ")}. Pass --force to overwrite.`);
8769
9237
  for (const file of files) {
8770
9238
  await mkdir(dirname(join(dir, file)), { recursive: true });
8771
- await copyFile(join(EXAMPLES_DIR, name, file), join(dir, file));
9239
+ await copyFile(join(BLUEPRINTS_DIR, name, file), join(dir, file));
8772
9240
  }
8773
9241
  return files;
8774
9242
  }
8775
9243
  function registerAdd(program, io) {
8776
- 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) => {
9244
+ 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) => {
8777
9245
  const dir = process.cwd();
8778
9246
  await assertCowConfig(dir);
8779
- const files = await copyExample(name, dir, opts.force === true);
9247
+ const files = await copyBlueprint(name, dir, opts.force === true);
8780
9248
  emit({ data: {
8781
- example: name,
9249
+ blueprint: name,
8782
9250
  dir,
8783
9251
  files
8784
9252
  } }, io, program.opts().json);
8785
- io.stderr(`Added the ${name} example:\n${files.map((file) => ` ${file}`).join("\n")}\n`);
9253
+ io.stderr(`Added the ${name} blueprint:\n${files.map((file) => ` ${file}`).join("\n")}\n`);
8786
9254
  });
8787
9255
  }
8788
9256
 
8789
9257
  //#endregion
8790
- //#region src/lib/login.ts
8791
- /**
8792
- * Login via the dashboard's /cli-auth bridge: start a loopback server, open
8793
- * the web app there, and the signed-in browser POSTs the Clerk session token
8794
- * back. `--token <jwt>` bypasses the browser entirely (agents, CI).
8795
- */
8796
- async function login(env, options) {
8797
- let token;
8798
- if (options.token !== void 0) {
8799
- token = options.token.trim();
8800
- if (decodeTokenPayload(token) === null) throw new Error("The provided --token value is not a JWT");
8801
- } else token = await collectTokenViaBrowser(resolveWebUrl(env, (await readCowConfig(process.cwd()))?.webUrl, options.webUrl), 3e5, options.noOpen === true);
8802
- const claims = decodeSessionToken(token);
8803
- if (claims === null) throw new Error("Token is not a decodable JWT");
8804
- if (claims.userId === null) throw new Error("Token has no subject claim. Is it a Clerk session token?");
8805
- const apiUrl = options.apiFlag ?? resolveApiUrl(env, null, void 0, (await readCowConfig(process.cwd()))?.apiUrl);
8806
- return {
8807
- credentialsPath: await writeCredentials(env, {
8808
- token,
8809
- apiUrl
8810
- }),
8811
- apiUrl,
8812
- userId: claims.userId,
8813
- orgId: claims.orgId,
8814
- role: claims.role,
8815
- hasOrg: claims.orgId !== null
8816
- };
8817
- }
8818
- /** Open the dashboard bridge and await one POST of { token }. */
8819
- function collectTokenViaBrowser(webUrl, timeoutMs = 18e4, noOpen = false) {
8820
- return new Promise((resolve, reject) => {
8821
- let server = null;
8822
- const timer = setTimeout(() => {
8823
- server?.close();
8824
- reject(/* @__PURE__ */ new Error(`Timed out waiting for the browser to sign in (${Math.round(timeoutMs / 1e3)}s). Or paste a token: \`cow login --token <jwt>\`.`));
8825
- }, timeoutMs);
8826
- server = createServer((req, res) => {
8827
- res.setHeader("Access-Control-Allow-Origin", "*");
8828
- res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
8829
- res.setHeader("Access-Control-Allow-Headers", "Content-Type");
8830
- if (req.method === "OPTIONS") {
8831
- res.writeHead(204).end();
8832
- return;
8833
- }
8834
- if (req.method !== "POST") {
8835
- res.writeHead(405).end();
8836
- return;
8837
- }
8838
- const chunks = [];
8839
- req.on("data", (chunk) => chunks.push(chunk));
8840
- req.on("end", () => {
8841
- clearTimeout(timer);
8842
- try {
8843
- const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
8844
- const field = (name) => typeof body === "object" && body !== null && name in body ? body[name] : void 0;
8845
- if (field("denied") === true) {
8846
- res.writeHead(200, { "Content-Type": "application/json" });
8847
- res.end(JSON.stringify({ ok: true }));
8848
- reject(/* @__PURE__ */ new Error("Sign-in was denied in the browser. No access was granted."));
8849
- return;
8850
- }
8851
- const token = field("token");
8852
- if (typeof token !== "string" || token.length === 0) {
8853
- res.writeHead(400, { "Content-Type": "application/json" });
8854
- res.end(JSON.stringify({ error: "missing token" }));
8855
- reject(/* @__PURE__ */ new Error("The browser bridge sent no token"));
8856
- return;
8857
- }
8858
- res.writeHead(200, { "Content-Type": "application/json" });
8859
- res.end(JSON.stringify({ ok: true }));
8860
- resolve(token);
8861
- } catch (error) {
8862
- reject(error instanceof Error ? error : new Error(String(error)));
8863
- } finally {
8864
- server?.close();
8865
- }
8866
- });
8867
- });
8868
- server.listen(0, "127.0.0.1", () => {
8869
- const address = server?.address();
8870
- if (address === null || typeof address !== "object") {
8871
- reject(/* @__PURE__ */ new Error("Could not bind a loopback port"));
8872
- return;
8873
- }
8874
- const callbackUrl = `http://127.0.0.1:${address.port}/cb`;
8875
- const target = `${webUrl}/cli-auth?cb=${encodeURIComponent(callbackUrl)}`;
8876
- if (!noOpen) openBrowser(target);
8877
- process.stderr.write(`Waiting for sign-in at:\n ${target}\n`);
8878
- });
8879
- server.on("error", (error) => {
8880
- clearTimeout(timer);
8881
- reject(error);
8882
- });
8883
- });
8884
- }
8885
- function openBrowser(url) {
8886
- const cmd = process.platform === "darwin" ? "open" : process.platform === "linux" ? "xdg-open" : null;
8887
- if (cmd === null) return;
8888
- spawn(cmd, [url], {
8889
- stdio: "ignore",
8890
- detached: true
8891
- }).unref();
8892
- }
8893
-
8894
- //#endregion
8895
- //#region src/commands/auth.ts
8896
- /** login/logout/whoami: session-token management, no contract route. */
8897
- function registerAuth(program, env, io) {
8898
- program.command("login").description("authenticate via the dashboard browser flow (or --token) and cache the session token").option("--token <jwt>", "paste a session token instead of the browser flow").option("--web <url>", `dashboard URL; overrides COW_WEB_URL and webUrl in cow.json (default ${DEFAULT_WEB_URL})`).option("--no-open", "do not open the browser; print the URL and wait (headless/agent use)").action(async (opts) => {
8899
- const outcome = await login(env, {
8900
- token: opts.token,
8901
- webUrl: opts.web,
8902
- apiFlag: opts.api,
8903
- noOpen: opts.open === false
8904
- });
8905
- emit({ data: {
8906
- ...outcome,
8907
- warnings: outcome.hasOrg ? void 0 : ["The token carries no active organization. Select one in the dashboard and log in again."]
8908
- } }, io, opts.json);
8909
- });
8910
- program.command("logout").description("delete the cached session token").action(async (opts) => {
8911
- const removed = await clearCredentials(env);
8912
- emit({ data: { removed } }, io, opts.json);
8913
- });
8914
- program.command("whoami").description("show which credential commands will use, and for a session its subject, org, role, and expiry").action(async (opts) => {
8915
- const credentials = await readCredentials(env);
8916
- const { kind } = resolveCredential(env, credentials);
8917
- if (kind === "none") throw new Error("Not logged in. Run `cow login` first, or set COW_PIPELINE_KEY.");
8918
- if (kind === "pipelineKey") {
8919
- emit({ data: {
8920
- credential: kind,
8921
- source: "COW_PIPELINE_KEY",
8922
- note: "A pipeline key may push, enable, disable, and read executions."
8923
- } }, io, opts.json);
8924
- return;
8925
- }
8926
- const claims = decodeSessionToken(credentials?.token ?? "");
8927
- if (!claims) throw new Error("Cached token is not decodable. Run `cow login` again.");
8928
- const projectApiUrl = (await readCowConfig(process.cwd()))?.apiUrl;
8929
- const apiUrl = resolveApiUrl(env, credentials, opts.api, projectApiUrl);
8930
- const mintedFor = credentials?.apiUrl;
8931
- emit({ data: {
8932
- credential: kind,
8933
- apiUrl,
8934
- mintedFor: mintedFor ?? null,
8935
- ...mintedFor !== void 0 && mintedFor !== apiUrl ? { warning: `This token was minted for ${mintedFor}, but commands here talk to ${apiUrl}. Run \`cow login\` again from this directory.` } : {},
8936
- ...claims
8937
- } }, io, opts.json);
8938
- });
8939
- }
8940
-
8941
- //#endregion
8942
- //#region src/commands/build.ts
8943
- /**
8944
- * `cow build`: typecheck, bundle, and write `.cow/build` for the project in
8945
- * the current directory. Local only, no API call and no network.
8946
- */
8947
- function registerBuild(program, io) {
8948
- program.command("build").description("typecheck and bundle the repo in the current directory, writing .cow/build (local: no API call)").action(async (opts) => {
8949
- emit({ data: await buildProject(process.cwd()) }, io, opts.json);
8950
- });
8951
- }
8952
-
8953
- //#endregion
8954
- //#region ../../node_modules/.pnpm/@asteasolutions+zod-to-openapi@9.1.0_zod@4.4.3/node_modules/@asteasolutions/zod-to-openapi/dist/index.cjs
8955
- var require_dist = /* @__PURE__ */ __commonJSMin(((exports) => {
8956
- /******************************************************************************
8957
- Copyright (c) Microsoft Corporation.
8958
-
8959
- Permission to use, copy, modify, and/or distribute this software for any
8960
- purpose with or without fee is hereby granted.
8961
-
8962
- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
8963
- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
8964
- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
8965
- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
8966
- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
8967
- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
8968
- PERFORMANCE OF THIS SOFTWARE.
8969
- ***************************************************************************** */
8970
- function __rest(s, e) {
8971
- var t = {};
8972
- for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) t[p] = s[p];
8973
- if (s != null && typeof Object.getOwnPropertySymbols === "function") {
8974
- for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) t[p[i]] = s[p[i]];
8975
- }
8976
- return t;
8977
- }
8978
- const ZodTypeKeys = {
8979
- ZodAny: "any",
8980
- ZodArray: "array",
8981
- ZodBigInt: "bigint",
8982
- ZodBoolean: "boolean",
8983
- ZodDefault: "default",
8984
- ZodPrefault: "prefault",
8985
- ZodTransform: "transform",
8986
- ZodEnum: "enum",
8987
- ZodIntersection: "intersection",
8988
- ZodLazy: "lazy",
8989
- ZodLiteral: "literal",
8990
- ZodNever: "never",
8991
- ZodNull: "null",
8992
- ZodNullable: "nullable",
8993
- ZodNumber: "number",
8994
- ZodNonOptional: "nonoptional",
8995
- ZodObject: "object",
8996
- ZodOptional: "optional",
8997
- ZodPipe: "pipe",
8998
- ZodReadonly: "readonly",
8999
- ZodRecord: "record",
9000
- ZodString: "string",
9001
- ZodTuple: "tuple",
9002
- ZodType: "type",
9003
- ZodUnion: "union",
9004
- ZodDiscriminatedUnion: "union",
9005
- ZodUnknown: "unknown",
9006
- ZodVoid: "void",
9007
- ZodDate: "date",
9008
- ZodTemplateLiteral: "template_literal"
9258
+ //#region ../../node_modules/.pnpm/@asteasolutions+zod-to-openapi@9.1.0_zod@4.4.3/node_modules/@asteasolutions/zod-to-openapi/dist/index.cjs
9259
+ var require_dist = /* @__PURE__ */ __commonJSMin(((exports) => {
9260
+ /******************************************************************************
9261
+ Copyright (c) Microsoft Corporation.
9262
+
9263
+ Permission to use, copy, modify, and/or distribute this software for any
9264
+ purpose with or without fee is hereby granted.
9265
+
9266
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
9267
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
9268
+ AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
9269
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
9270
+ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
9271
+ OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
9272
+ PERFORMANCE OF THIS SOFTWARE.
9273
+ ***************************************************************************** */
9274
+ function __rest(s, e) {
9275
+ var t = {};
9276
+ for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) t[p] = s[p];
9277
+ if (s != null && typeof Object.getOwnPropertySymbols === "function") {
9278
+ for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) t[p[i]] = s[p[i]];
9279
+ }
9280
+ return t;
9281
+ }
9282
+ const ZodTypeKeys = {
9283
+ ZodAny: "any",
9284
+ ZodArray: "array",
9285
+ ZodBigInt: "bigint",
9286
+ ZodBoolean: "boolean",
9287
+ ZodDefault: "default",
9288
+ ZodPrefault: "prefault",
9289
+ ZodTransform: "transform",
9290
+ ZodEnum: "enum",
9291
+ ZodIntersection: "intersection",
9292
+ ZodLazy: "lazy",
9293
+ ZodLiteral: "literal",
9294
+ ZodNever: "never",
9295
+ ZodNull: "null",
9296
+ ZodNullable: "nullable",
9297
+ ZodNumber: "number",
9298
+ ZodNonOptional: "nonoptional",
9299
+ ZodObject: "object",
9300
+ ZodOptional: "optional",
9301
+ ZodPipe: "pipe",
9302
+ ZodReadonly: "readonly",
9303
+ ZodRecord: "record",
9304
+ ZodString: "string",
9305
+ ZodTuple: "tuple",
9306
+ ZodType: "type",
9307
+ ZodUnion: "union",
9308
+ ZodDiscriminatedUnion: "union",
9309
+ ZodUnknown: "unknown",
9310
+ ZodVoid: "void",
9311
+ ZodDate: "date",
9312
+ ZodTemplateLiteral: "template_literal"
9009
9313
  };
9010
9314
  function isZodType(schema, typeNames) {
9011
9315
  return (Array.isArray(typeNames) ? typeNames : [typeNames]).some((typeName) => {
@@ -10050,6 +10354,23 @@ const events = defineModule(defineRoute({
10050
10354
  ...sessionErrors,
10051
10355
  ...errors("validation_failed")
10052
10356
  }
10357
+ }), defineRoute({
10358
+ method: "get",
10359
+ path: "/v1/apps/{appId}/events/seen",
10360
+ operationId: "apps.events.seen",
10361
+ tags: ["events"],
10362
+ summary: "Whether an app has ever received these event names",
10363
+ security: PIPELINE_AUTH,
10364
+ surfaces: { cli: false },
10365
+ request: {
10366
+ params: z.object({ appId: z.string() }),
10367
+ query: eventsSeenQuerySchema
10368
+ },
10369
+ responses: {
10370
+ 200: envelope(eventsSeenDtoSchema),
10371
+ ...sessionErrors,
10372
+ ...errors("not_found", "validation_failed")
10373
+ }
10053
10374
  }), defineRoute({
10054
10375
  method: "get",
10055
10376
  path: "/v1/events/{id}",
@@ -10217,6 +10538,19 @@ const ingestion = defineModule(defineRoute({
10217
10538
  200: envelope(batchResultDtoSchema, "Per-item outcomes, positionally aligned"),
10218
10539
  ...ingestionErrors
10219
10540
  }
10541
+ }), defineRoute({
10542
+ method: "get",
10543
+ path: "/v1/whoami",
10544
+ operationId: "ingestion.whoami",
10545
+ tags: ["ingestion"],
10546
+ summary: "Which app a source id writes into",
10547
+ security: API_KEY_AUTH,
10548
+ surfaces: HIDDEN_FROM_TOOLS,
10549
+ request: { query: whoamiQuerySchema },
10550
+ responses: {
10551
+ 200: envelope(whoamiSchema, "The source's org, app, and sibling sources"),
10552
+ ...errors("invalid_key", "not_found", "validation_failed", "rate_limited")
10553
+ }
10220
10554
  }));
10221
10555
 
10222
10556
  //#endregion
@@ -10442,12 +10776,26 @@ const params$6 = z.object({ id: z.string() });
10442
10776
  * push stores the source archive and its compile creates a version of every
10443
10777
  * key whose module changed; it turns nothing on (ADR 0011).
10444
10778
  *
10445
- * `create` and `source` are hidden from the CLI and MCP surfaces because
10446
- * both only make sense next to local files: the manifest `create` takes is
10447
- * whatever `cow build` just wrote, and the tarball `source` returns is what
10448
- * `cow pull` unpacks over a working tree.
10779
+ * `plan`, `create` and `source` are hidden from the CLI and MCP surfaces
10780
+ * because all three only make sense next to local files: the manifest the
10781
+ * first two take is whatever `cow build` just wrote, and the tarball
10782
+ * `source` returns is what `cow pull` unpacks over a working tree.
10449
10783
  */
10450
10784
  const pushes = defineModule(defineRoute({
10785
+ method: "post",
10786
+ path: "/v1/pushes/plan",
10787
+ operationId: "pushes.plan",
10788
+ tags: ["pushes"],
10789
+ summary: "What a push of this tree would change",
10790
+ security: PIPELINE_AUTH,
10791
+ surfaces: HIDDEN_FROM_TOOLS,
10792
+ request: { body: jsonBody(createPushBodySchema) },
10793
+ responses: {
10794
+ 200: envelope(pushPlanSchema, "What this tree would write, and what it leaves alone"),
10795
+ ...sessionErrors,
10796
+ ...errors("validation_failed", "malformed_request")
10797
+ }
10798
+ }), defineRoute({
10451
10799
  method: "post",
10452
10800
  path: "/v1/pushes",
10453
10801
  operationId: "pushes.create",
@@ -11432,43 +11780,217 @@ const allRoutes = Object.values(contract).flatMap((module) => Object.values(modu
11432
11780
  const tags = [...new Set(allRoutes.flatMap((route) => route.tags ?? []))];
11433
11781
 
11434
11782
  //#endregion
11435
- //#region ../../packages/shared/src/contract/surfaces.ts
11436
- /** camelCase field name → kebab-case flag body (`sourceId` → `source-id`). */
11437
- function kebabCase(name) {
11438
- return name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
11439
- }
11783
+ //#region src/lib/login.ts
11440
11784
  /**
11441
- * Unwrap the wrapper types that only affect nullability/defaults
11442
- * (ZodOptional/ZodNullable/ZodDefault) plus ZodPipe (`.transform()`, so CLI
11443
- * users provide the pipe's input side). A field is optional when a
11444
- * ZodOptional or ZodDefault appears anywhere in the chain.
11785
+ * Login via the dashboard's /cli-auth bridge: start a loopback server, open
11786
+ * the web app there, and the signed-in browser POSTs the Clerk session token
11787
+ * back. `--token <jwt>` bypasses the browser entirely (agents, CI).
11445
11788
  */
11446
- function unwrapSchema(schema) {
11447
- let current = schema;
11448
- let required = true;
11449
- for (;;) if (current instanceof z.ZodOptional || current instanceof z.ZodDefault) {
11450
- required = false;
11451
- current = current.unwrap();
11452
- } else if (current instanceof z.ZodNullable) current = current.unwrap();
11453
- else if (current instanceof z.ZodPipe) current = current.def.in;
11454
- else return {
11455
- schema: current,
11456
- required
11789
+ async function login(env, options) {
11790
+ let token;
11791
+ if (options.token !== void 0) {
11792
+ token = options.token.trim();
11793
+ if (decodeTokenPayload(token) === null) throw new Error("The provided --token value is not a JWT");
11794
+ } else token = await collectTokenViaBrowser(resolveWebUrl(env, (await readCowConfig(process.cwd()))?.webUrl, options.webUrl), 3e5, options.noOpen === true);
11795
+ const claims = decodeSessionToken(token);
11796
+ if (claims === null) throw new Error("Token is not a decodable JWT");
11797
+ if (claims.userId === null) throw new Error("Token has no subject claim. Is it a Clerk session token?");
11798
+ const apiUrl = options.apiFlag ?? resolveApiUrl(env, null, void 0, (await readCowConfig(process.cwd()))?.apiUrl);
11799
+ return {
11800
+ credentialsPath: await writeCredentials(env, {
11801
+ token,
11802
+ apiUrl
11803
+ }),
11804
+ apiUrl,
11805
+ userId: claims.userId,
11806
+ orgId: claims.orgId,
11807
+ role: claims.role,
11808
+ hasOrg: claims.orgId !== null
11457
11809
  };
11458
11810
  }
11459
- /**
11460
- * The fixed string choices a schema offers, if it has any. An enum, a string
11461
- * literal (zod holds a literal's values as a Set, since one can name
11462
- * several), or a union of those: `violationStatusSchema.or(z.literal("all"))`
11463
- * is the shape that makes the union arm worth walking. Anything else has no
11464
- * closed set of values and belongs on a JSON flag.
11465
- */
11466
- function stringChoices(schema) {
11467
- const inner = unwrapSchema(schema).schema;
11468
- if (inner instanceof z.ZodEnum) return [...inner.options].map(String);
11469
- if (inner instanceof z.ZodLiteral) {
11470
- const values = [...inner.values];
11471
- return values.every((value) => typeof value === "string") ? values : void 0;
11811
+ /** Open the dashboard bridge and await one POST of { token }. */
11812
+ function collectTokenViaBrowser(webUrl, timeoutMs = 18e4, noOpen = false) {
11813
+ return new Promise((resolve, reject) => {
11814
+ let server = null;
11815
+ const timer = setTimeout(() => {
11816
+ server?.close();
11817
+ reject(/* @__PURE__ */ new Error(`Timed out waiting for the browser to sign in (${Math.round(timeoutMs / 1e3)}s). Or paste a token: \`cow login --token <jwt>\`.`));
11818
+ }, timeoutMs);
11819
+ server = createServer((req, res) => {
11820
+ res.setHeader("Access-Control-Allow-Origin", "*");
11821
+ res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
11822
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
11823
+ if (req.method === "OPTIONS") {
11824
+ res.writeHead(204).end();
11825
+ return;
11826
+ }
11827
+ if (req.method !== "POST") {
11828
+ res.writeHead(405).end();
11829
+ return;
11830
+ }
11831
+ const chunks = [];
11832
+ req.on("data", (chunk) => chunks.push(chunk));
11833
+ req.on("end", () => {
11834
+ clearTimeout(timer);
11835
+ try {
11836
+ const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
11837
+ const field = (name) => typeof body === "object" && body !== null && name in body ? body[name] : void 0;
11838
+ if (field("denied") === true) {
11839
+ res.writeHead(200, { "Content-Type": "application/json" });
11840
+ res.end(JSON.stringify({ ok: true }));
11841
+ reject(/* @__PURE__ */ new Error("Sign-in was denied in the browser. No access was granted."));
11842
+ return;
11843
+ }
11844
+ const token = field("token");
11845
+ if (typeof token !== "string" || token.length === 0) {
11846
+ res.writeHead(400, { "Content-Type": "application/json" });
11847
+ res.end(JSON.stringify({ error: "missing token" }));
11848
+ reject(/* @__PURE__ */ new Error("The browser bridge sent no token"));
11849
+ return;
11850
+ }
11851
+ res.writeHead(200, { "Content-Type": "application/json" });
11852
+ res.end(JSON.stringify({ ok: true }));
11853
+ resolve(token);
11854
+ } catch (error) {
11855
+ reject(error instanceof Error ? error : new Error(String(error)));
11856
+ } finally {
11857
+ server?.close();
11858
+ }
11859
+ });
11860
+ });
11861
+ server.listen(0, "127.0.0.1", () => {
11862
+ const address = server?.address();
11863
+ if (address === null || typeof address !== "object") {
11864
+ reject(/* @__PURE__ */ new Error("Could not bind a loopback port"));
11865
+ return;
11866
+ }
11867
+ const callbackUrl = `http://127.0.0.1:${address.port}/cb`;
11868
+ const target = `${webUrl}/cli-auth?cb=${encodeURIComponent(callbackUrl)}`;
11869
+ if (!noOpen) openBrowser(target);
11870
+ process.stderr.write(`Waiting for sign-in at:\n ${target}\n`);
11871
+ });
11872
+ server.on("error", (error) => {
11873
+ clearTimeout(timer);
11874
+ reject(error);
11875
+ });
11876
+ });
11877
+ }
11878
+ function openBrowser(url) {
11879
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "linux" ? "xdg-open" : null;
11880
+ if (cmd === null) return;
11881
+ spawn(cmd, [url], {
11882
+ stdio: "ignore",
11883
+ detached: true
11884
+ }).unref();
11885
+ }
11886
+
11887
+ //#endregion
11888
+ //#region src/commands/auth.ts
11889
+ /** login/logout/whoami: session-token management, no contract route. */
11890
+ function registerAuth(program, env, io) {
11891
+ program.command("login").description("authenticate via the dashboard browser flow (or --token) and cache the session token").option("--token <jwt>", "paste a session token instead of the browser flow").option("--web <url>", `dashboard URL; overrides COW_WEB_URL and webUrl in cow.json (default ${DEFAULT_WEB_URL})`).option("--no-open", "do not open the browser; print the URL and wait (headless/agent use)").action(async (opts) => {
11892
+ const outcome = await login(env, {
11893
+ token: opts.token,
11894
+ webUrl: opts.web,
11895
+ apiFlag: opts.api,
11896
+ noOpen: opts.open === false
11897
+ });
11898
+ emit({ data: {
11899
+ ...outcome,
11900
+ warnings: outcome.hasOrg ? void 0 : ["The token carries no active organization. Select one in the dashboard and log in again."]
11901
+ } }, io, opts.json);
11902
+ });
11903
+ program.command("logout").description("delete the cached session token").action(async (opts) => {
11904
+ const removed = await clearCredentials(env);
11905
+ emit({ data: { removed } }, io, opts.json);
11906
+ });
11907
+ program.command("whoami").description("show which credential commands will use, and for a session its subject, org, role, and expiry").option("--key <ak_...>", "ask the API what an ingestion key writes into, instead of reading the cached session").option("--source <src_...>", "the source id that key is about to send to (required with --key)").action(async (opts) => {
11908
+ if (opts.key) {
11909
+ if (!opts.source) throw new Error("`--key` needs `--source <src_...>`: the id you are about to send to is what this checks.");
11910
+ const client = createClient({
11911
+ baseUrl: resolveApiUrl(env, await readCredentials(env), opts.api, (await readCowConfig(process.cwd()))?.apiUrl),
11912
+ auth: () => opts.key ?? null,
11913
+ headers: { [CLIENT_HEADER]: `@cowliss/cli/${(await readCliPackage()).version}` }
11914
+ });
11915
+ emit(await client.request(contract.ingestion["ingestion.whoami"], { query: { sourceId: opts.source } }), io, opts.json);
11916
+ return;
11917
+ }
11918
+ const credentials = await readCredentials(env);
11919
+ const { kind } = resolveCredential(env, credentials);
11920
+ if (kind === "none") throw new Error("Not logged in. Run `cow login` first, or set COW_PIPELINE_KEY.");
11921
+ if (kind === "pipelineKey") {
11922
+ emit({ data: {
11923
+ credential: kind,
11924
+ source: "COW_PIPELINE_KEY",
11925
+ note: "A pipeline key may push, enable, disable, and read executions."
11926
+ } }, io, opts.json);
11927
+ return;
11928
+ }
11929
+ const claims = decodeSessionToken(credentials?.token ?? "");
11930
+ if (!claims) throw new Error("Cached token is not decodable. Run `cow login` again.");
11931
+ const projectApiUrl = (await readCowConfig(process.cwd()))?.apiUrl;
11932
+ const apiUrl = resolveApiUrl(env, credentials, opts.api, projectApiUrl);
11933
+ const mintedFor = credentials?.apiUrl;
11934
+ emit({ data: {
11935
+ credential: kind,
11936
+ apiUrl,
11937
+ mintedFor: mintedFor ?? null,
11938
+ ...mintedFor !== void 0 && mintedFor !== apiUrl ? { warning: `This token was minted for ${mintedFor}, but commands here talk to ${apiUrl}. Run \`cow login\` again from this directory.` } : {},
11939
+ ...claims
11940
+ } }, io, opts.json);
11941
+ });
11942
+ }
11943
+
11944
+ //#endregion
11945
+ //#region src/commands/build.ts
11946
+ /**
11947
+ * `cow build`: typecheck, bundle, and write `.cow/build` for the project in
11948
+ * the current directory. Local only, no API call and no network.
11949
+ */
11950
+ function registerBuild(program, io) {
11951
+ program.command("build").description("typecheck and bundle the repo in the current directory, writing .cow/build (local: no API call)").action(async (opts) => {
11952
+ emit({ data: await buildProject(process.cwd()) }, io, opts.json);
11953
+ });
11954
+ }
11955
+
11956
+ //#endregion
11957
+ //#region ../../packages/shared/src/contract/surfaces.ts
11958
+ /** camelCase field name → kebab-case flag body (`sourceId` → `source-id`). */
11959
+ function kebabCase(name) {
11960
+ return name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
11961
+ }
11962
+ /**
11963
+ * Unwrap the wrapper types that only affect nullability/defaults
11964
+ * (ZodOptional/ZodNullable/ZodDefault) plus ZodPipe (`.transform()`, so CLI
11965
+ * users provide the pipe's input side). A field is optional when a
11966
+ * ZodOptional or ZodDefault appears anywhere in the chain.
11967
+ */
11968
+ function unwrapSchema(schema) {
11969
+ let current = schema;
11970
+ let required = true;
11971
+ for (;;) if (current instanceof z.ZodOptional || current instanceof z.ZodDefault) {
11972
+ required = false;
11973
+ current = current.unwrap();
11974
+ } else if (current instanceof z.ZodNullable) current = current.unwrap();
11975
+ else if (current instanceof z.ZodPipe) current = current.def.in;
11976
+ else return {
11977
+ schema: current,
11978
+ required
11979
+ };
11980
+ }
11981
+ /**
11982
+ * The fixed string choices a schema offers, if it has any. An enum, a string
11983
+ * literal (zod holds a literal's values as a Set, since one can name
11984
+ * several), or a union of those: `violationStatusSchema.or(z.literal("all"))`
11985
+ * is the shape that makes the union arm worth walking. Anything else has no
11986
+ * closed set of values and belongs on a JSON flag.
11987
+ */
11988
+ function stringChoices(schema) {
11989
+ const inner = unwrapSchema(schema).schema;
11990
+ if (inner instanceof z.ZodEnum) return [...inner.options].map(String);
11991
+ if (inner instanceof z.ZodLiteral) {
11992
+ const values = [...inner.values];
11993
+ return values.every((value) => typeof value === "string") ? values : void 0;
11472
11994
  }
11473
11995
  if (inner instanceof z.ZodUnion) {
11474
11996
  const choices = [];
@@ -11790,6 +12312,51 @@ function registerContractCommands(program, run) {
11790
12312
  }
11791
12313
  }
11792
12314
 
12315
+ //#endregion
12316
+ //#region src/commands/seen.ts
12317
+ function pad$1(text, width) {
12318
+ return text.padEnd(width);
12319
+ }
12320
+ /**
12321
+ * The block, as lines, or nothing at all when every name has arrived at
12322
+ * least once. Leads with a blank line, the way the other sections of
12323
+ * `cow status` do, so a caller can splice it onto whatever it printed.
12324
+ *
12325
+ * Patterns are printed as the author wrote them (`order.*`, never an
12326
+ * expansion), each beside the journeys and segments that named it: those are
12327
+ * what stay quiet.
12328
+ */
12329
+ function unseenLines(events, answers) {
12330
+ const never = new Set(answers.filter((answer) => answer.lastSeenAt === null).map((answer) => answer.name));
12331
+ const unseen = events.filter((event) => never.has(event.pattern));
12332
+ if (unseen.length === 0) return [];
12333
+ const width = Math.max(...unseen.map((event) => event.pattern.length));
12334
+ return [
12335
+ "",
12336
+ "Nothing has sent these events yet, so what waits on them stays quiet:",
12337
+ ...unseen.map((event) => ` ${pad$1(event.pattern, width)} ${event.usedBy.join(", ")}`)
12338
+ ];
12339
+ }
12340
+ /**
12341
+ * Ask the server about a tree's event names and render the answer. A tree
12342
+ * that names none skips the call.
12343
+ *
12344
+ * A failed call prints nothing rather than failing its command: this is a
12345
+ * warning beside work that has already happened, and a journey that was just
12346
+ * turned on is turned on whether or not the remark about it arrives.
12347
+ */
12348
+ async function unseenReport(client, appId, events) {
12349
+ if (events.length === 0) return [];
12350
+ try {
12351
+ return unseenLines(events, (await client.request(contract.events["apps.events.seen"], {
12352
+ params: { appId },
12353
+ query: { name: events.map((event) => event.pattern) }
12354
+ })).data.names);
12355
+ } catch {
12356
+ return [];
12357
+ }
12358
+ }
12359
+
11793
12360
  //#endregion
11794
12361
  //#region src/commands/enable.ts
11795
12362
  /**
@@ -11845,6 +12412,31 @@ function flipSummary(journeys, status) {
11845
12412
  if (journeys.length === 0) return `This app has no journeys to put ${STATE_WORD[status]}.`;
11846
12413
  return journeys.map((journey) => [`${journey.key} is ${STATE_WORD[status]}.`, ...segmentLines(journey)].join("\n")).join("\n");
11847
12414
  }
12415
+ /**
12416
+ * What the journeys just turned on are waiting on and have never arrived.
12417
+ * Only on the way on: a journey going off or on hold is not about to miss
12418
+ * anything.
12419
+ *
12420
+ * Read off the tree in front of the developer, because that is where the
12421
+ * names come from; a directory that is not a project, or one that does not
12422
+ * build, names none and the remark is simply absent rather than wrong. A
12423
+ * journey enrolling from a `segments/` file waits through that file, so the
12424
+ * segment's own key joins the set the events are filtered by.
12425
+ */
12426
+ async function unseenForKeys(client, keys) {
12427
+ const dir = process.cwd();
12428
+ const project = await assertCowConfig(dir).then(async (config) => ({
12429
+ config,
12430
+ built: await buildProject(dir)
12431
+ })).catch(() => null);
12432
+ if (!project) return [];
12433
+ const waiting = new Set(keys);
12434
+ for (const journey of project.built.manifest.journeys) {
12435
+ const segment = waiting.has(journey.key) ? triggerSegmentName(journey.key, journey.trigger) : null;
12436
+ if (segment) waiting.add(segment);
12437
+ }
12438
+ return unseenReport(client, project.config.appId, project.built.manifest.events.filter((event) => event.usedBy.some((key) => waiting.has(key))));
12439
+ }
11848
12440
  const DESCRIPTIONS = {
11849
12441
  on: "turn journeys on",
11850
12442
  off: "turn journeys off",
@@ -11859,12 +12451,14 @@ function register(program, clientFor, io, verb, status) {
11859
12451
  if (merged.all === true && keys.length > 0) throw new Error(`Name the journeys or pass --all, not both: cow ${verb} welcome.`);
11860
12452
  if (merged.all !== true && keys.length === 0) throw new Error(`Name at least one journey, or pass --all: cow ${verb} welcome.`);
11861
12453
  const selector = merged.all === true ? { appId: (await assertCowConfig(process.cwd())).appId } : { keys };
11862
- const updated = await setStatus(await clientFor(merged), selector, status);
12454
+ const client = await clientFor(merged);
12455
+ const updated = await setStatus(client, selector, status);
11863
12456
  if (merged.json === true) {
11864
12457
  emit({ data: updated }, io, true);
11865
12458
  return;
11866
12459
  }
11867
- io.stdout(`${flipSummary(updated, status)}\n`);
12460
+ const unseen = status === "on" ? await unseenForKeys(client, updated.map((journey) => journey.key)) : [];
12461
+ io.stdout(`${[flipSummary(updated, status), ...unseen].join("\n")}\n`);
11868
12462
  });
11869
12463
  }
11870
12464
  function registerEnable(program, clientFor, io) {
@@ -11956,7 +12550,7 @@ async function chooseApp(client, webUrl, flag, org, io, skipPrompts) {
11956
12550
  return { appId: await ask("App id", only ?? "", io, false) };
11957
12551
  }
11958
12552
  function registerInit(program, clientFor, env, io) {
11959
- 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) => {
12553
+ 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) => {
11960
12554
  const merged = {
11961
12555
  ...program.opts(),
11962
12556
  ...opts
@@ -11966,7 +12560,7 @@ function registerInit(program, clientFor, env, io) {
11966
12560
  const defaultName = basename(dirArg ? resolve(cwd, dirArg) : cwd);
11967
12561
  const name = await ask("Repo name", defaultName, io, skipPrompts);
11968
12562
  const dir = dirArg ? resolve(cwd, dirArg) : name === defaultName ? cwd : resolve(cwd, name);
11969
- if (existsSync(join(dir, "cow.json"))) throw new Error(`"${dir}" already holds a cow.json. Use \`cow add example\` to add code to it.`);
12563
+ if (existsSync(join(dir, "cow.json"))) throw new Error(`"${dir}" already holds a cow.json. Use \`cow add blueprint\` to add code to it.`);
11970
12564
  const credentials = await readCredentials(env);
11971
12565
  const claimedOrg = credentials ? decodeSessionToken(credentials.token)?.orgId ?? null : null;
11972
12566
  const orgId = typeof opts.org === "string" && opts.org !== "" ? opts.org : await ask("Organization id", claimedOrg ?? "", io, skipPrompts);
@@ -11994,19 +12588,21 @@ function registerInit(program, clientFor, env, io) {
11994
12588
  include: [
11995
12589
  "journeys",
11996
12590
  "emails",
12591
+ "segments",
11997
12592
  ".cow/types.d.ts"
11998
12593
  ]
11999
12594
  }),
12000
12595
  ".gitignore": ".cow/\nnode_modules/\n",
12001
12596
  "journeys/.gitkeep": "",
12002
- "emails/.gitkeep": ""
12597
+ "emails/.gitkeep": "",
12598
+ "segments/.gitkeep": ""
12003
12599
  };
12004
12600
  for (const [file, body] of Object.entries(contents)) {
12005
12601
  await mkdir(dirname(join(dir, file)), { recursive: true });
12006
12602
  await writeFile(join(dir, file), body, "utf8");
12007
12603
  }
12008
- const example = typeof opts.example === "string" ? opts.example : void 0;
12009
- const files = [...Object.keys(contents), ...example ? await copyExample(example, dir, false) : []].sort();
12604
+ const blueprint = typeof opts.blueprint === "string" ? opts.blueprint : void 0;
12605
+ const files = [...Object.keys(contents), ...blueprint ? await copyBlueprint(blueprint, dir, false) : []].sort();
12010
12606
  emit({ data: {
12011
12607
  dir,
12012
12608
  orgId,
@@ -12015,6 +12611,7 @@ function registerInit(program, clientFor, env, io) {
12015
12611
  } }, io, merged.json);
12016
12612
  const here = relative(cwd, dir) || ".";
12017
12613
  const scenario = files.find((file) => file.startsWith("scenarios/")) ?? "scenarios/abandoned-checkout.timeout.json";
12614
+ const journey = scenario.slice(10).split(".")[0] ?? "abandoned-checkout";
12018
12615
  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}`;
12019
12616
  io.stderr([
12020
12617
  `Created a cow repo in ${here}`,
@@ -12023,41 +12620,510 @@ function registerInit(program, clientFor, env, io) {
12023
12620
  "Next steps:",
12024
12621
  ` cd ${here}`,
12025
12622
  " npm install",
12026
- ...example ? [] : [" cow add example abandoned-checkout"],
12623
+ ...blueprint ? [] : [" cow add blueprint abandoned-checkout"],
12027
12624
  " cow build",
12028
- ` cow test ${example ?? "abandoned-checkout"} --scenario ${scenario}`,
12625
+ ` cow test ${journey} --scenario ${scenario}`,
12029
12626
  ""
12030
12627
  ].join("\n"));
12031
12628
  });
12032
12629
  }
12033
12630
 
12034
12631
  //#endregion
12035
- //#region src/commands/mcp/tools.ts
12632
+ //#region src/commands/status.ts
12036
12633
  /**
12037
- * The fields of a request part. The container is unwrapped, its fields are
12038
- * not: a field's `.optional()` and `.default()` wrappers are what the MCP
12039
- * SDK parses tool arguments against.
12634
+ * `cow status`: what the server holds for every key of this app, and how far
12635
+ * the tree in front of the developer has moved from it. Hand-written rather
12636
+ * than derived from `apps.status`, because the drift is the half of the
12637
+ * answer no server read can know: it comes from building the repository
12638
+ * here and comparing the bundle digests with the versions the server has.
12639
+ */
12640
+ /** Largest first: the first unit the gap fills is the one that reads best. */
12641
+ const UNITS = [
12642
+ ["year", 31536e6],
12643
+ ["month", 2592e6],
12644
+ ["week", 6048e5],
12645
+ ["day", 864e5],
12646
+ ["hour", 36e5],
12647
+ ["minute", 6e4],
12648
+ ["second", 1e3]
12649
+ ];
12650
+ const relative$1 = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
12651
+ /** The gap between an instant and now, in words. */
12652
+ function ago(value, now) {
12653
+ const gap = new Date(value).getTime() - now.getTime();
12654
+ for (const [unit, ms] of UNITS) if (Math.abs(gap) >= ms) return relative$1.format(Math.round(gap / ms), unit);
12655
+ return relative$1.format(0, "second");
12656
+ }
12657
+ /**
12658
+ * The drift, computed by the one comparison there is (`pushPlan`, ADR
12659
+ * 0025), so what `cow status` calls changed and what `cow push` calls
12660
+ * changed can never be two different things.
12040
12661
  *
12041
- * Unwrapping the container is deliberate, and a change from the generated
12042
- * table this replaced: `mcpToolFor` derives `paramsKeys`/`queryKeys` through
12043
- * the same unwrap, so a wrapped container used to yield keys that `toInput`
12044
- * would pick but that the tool never declared. No route wraps one today.
12662
+ * Segments are left out of both sides: `apps.status` reports the keys that
12663
+ * run, and a segment is a row rather than a version, so the server read
12664
+ * behind this command does not carry them. `cow push` plans over all three.
12045
12665
  */
12046
- function shapeOf(schema) {
12047
- const inner = schema instanceof z.ZodType ? unwrapSchema(schema).schema : void 0;
12048
- return inner instanceof z.ZodObject ? inner.shape : {};
12666
+ function driftOf(status, manifest) {
12667
+ const served = status.keys.map((entry) => ({
12668
+ ...entry.latestVersion ? entrySide(entry.kind, entry.latestVersion.manifest) : {
12669
+ kind: entry.kind,
12670
+ key: entry.key,
12671
+ fields: {}
12672
+ },
12673
+ onHold: entry.status === "paused"
12674
+ }));
12675
+ return pushPlan(status.appId, manifestSides({
12676
+ ...manifest,
12677
+ segments: []
12678
+ }), served);
12049
12679
  }
12050
- function pick(args, keys) {
12051
- const picked = {};
12052
- for (const key of keys) {
12053
- const value = args[key];
12054
- if (value !== void 0) picked[key] = value;
12055
- }
12056
- return picked;
12680
+ /**
12681
+ * The version's short name. The bundle digest rather than the compiled
12682
+ * module's, because a version is one bundle (a push that carries a bundle
12683
+ * the org already holds creates no second version) and the bundle is there
12684
+ * from the moment the push lands, while the module digest arrives only when
12685
+ * the compile finishes.
12686
+ */
12687
+ function shortDigest(bundle) {
12688
+ return bundle.replace("sha256:", "").slice(0, 8);
12057
12689
  }
12058
- function defineTool(spec) {
12059
- const data = bodyDataSchema(spec.route);
12060
- const bodyShape = data === void 0 ? {} : data instanceof z.ZodObject ? data.shape : { data };
12690
+ function bundleOf(entry) {
12691
+ const manifest = entry.latestVersion?.manifest;
12692
+ return manifest ? manifest.bundle : null;
12693
+ }
12694
+ /** "version 3f2a1b9c, pushed 2 hours ago, still compiling" */
12695
+ function versionPhrase(entry, now) {
12696
+ const version = entry.latestVersion;
12697
+ if (!version) return "no version yet";
12698
+ const state = version.status === "compiling" ? ", still compiling" : version.status === "failed" ? ", did not compile" : "";
12699
+ const bundle = bundleOf(entry);
12700
+ return `${bundle ? `version ${shortDigest(bundle)}` : "version"}, pushed ${ago(version.pushedAt, now)}${state}`;
12701
+ }
12702
+ function flagsPhrase(entry) {
12703
+ return entry.status === null ? "" : entry.status === "paused" ? "on hold" : entry.status;
12704
+ }
12705
+ function livePhrase(entry) {
12706
+ return entry.liveExecutions === 0 ? "" : `${entry.liveExecutions} running`;
12707
+ }
12708
+ function pad(text, width) {
12709
+ return text.padEnd(width);
12710
+ }
12711
+ /**
12712
+ * The whole report, as lines. Pure so a test can read it: everything it
12713
+ * needs is the server's answer, the local build, and the clock.
12714
+ *
12715
+ * A null manifest is a tree that did not build. What the server runs is
12716
+ * still the answer to most of the question, so the report keeps it and drops
12717
+ * only the comparison.
12718
+ */
12719
+ function statusLines(status, manifest, now) {
12720
+ const journeys = status.keys.filter((entry) => entry.kind === "journey");
12721
+ const templates = status.keys.filter((entry) => entry.kind === "template");
12722
+ const lines = [`${status.appId}: ${journeys.length} ${journeys.length === 1 ? "journey" : "journeys"}, ${templates.length} ${templates.length === 1 ? "template" : "templates"} on the server.`];
12723
+ const width = Math.max(0, ...status.keys.map((entry) => entry.key.length));
12724
+ const section = (title, entries) => {
12725
+ if (entries.length === 0) return;
12726
+ const flagWidth = Math.max(0, ...entries.map((entry) => flagsPhrase(entry).length));
12727
+ lines.push("", title);
12728
+ for (const entry of entries) {
12729
+ const flags = flagsPhrase(entry);
12730
+ const live = livePhrase(entry);
12731
+ const rest = [
12732
+ ...flags === "" ? [] : [pad(flags, flagWidth)],
12733
+ versionPhrase(entry, now),
12734
+ ...live === "" ? [] : [live]
12735
+ ];
12736
+ lines.push(` ${pad(entry.key, width)} ${rest.join(" ")}`.trimEnd());
12737
+ }
12738
+ };
12739
+ section("Journeys", journeys);
12740
+ section("Templates", templates);
12741
+ if (!manifest) {
12742
+ lines.push("", "This tree did not build, so nothing here is compared with it. Run cow build to see why.");
12743
+ return lines;
12744
+ }
12745
+ const plan = driftOf(status, manifest);
12746
+ const changed = plan.entries.filter((entry) => entry.change !== "unchanged");
12747
+ const missing = plan.drift.filter((one) => one.reason === "not-in-tree");
12748
+ if (changed.length > 0) lines.push("", `Changed here since the last push: ${changed.map((entry) => entry.key).join(", ")}. Run cow push.`);
12749
+ if (missing.length > 0) {
12750
+ lines.push("", "On the server and not in this tree:");
12751
+ for (const entry of missing) lines.push(` ${entry.key}, a ${entry.kind}. Delete it with cow ${entry.kind}s delete ${entry.key}.`);
12752
+ }
12753
+ if (status.warnings.length > 0) {
12754
+ lines.push("", "Names this organization does not define yet:");
12755
+ for (const warning of status.warnings) lines.push(` ${warning}`);
12756
+ }
12757
+ return lines;
12758
+ }
12759
+ /** The app's status from the server, for this directory's `cow.json`. */
12760
+ async function readStatus(client, appId) {
12761
+ return (await client.request(contract.apps["apps.status"], { params: { id: appId } })).data;
12762
+ }
12763
+ function registerStatus(program, clientFor, io) {
12764
+ program.command("status").description("what this app's journeys and templates are doing, and what changed here since the last push").action(async (opts) => {
12765
+ const merged = {
12766
+ ...program.opts(),
12767
+ ...opts
12768
+ };
12769
+ const config = await assertCowConfig(process.cwd());
12770
+ const built = await buildProject(process.cwd()).catch(() => null);
12771
+ const client = await clientFor(merged);
12772
+ const status = await readStatus(client, config.appId);
12773
+ if (merged.json === true) {
12774
+ emit({ data: status }, io, true);
12775
+ return;
12776
+ }
12777
+ const unseen = await unseenReport(client, config.appId, built?.manifest.events ?? []);
12778
+ io.stdout(`${[...statusLines(status, built?.manifest ?? null, /* @__PURE__ */ new Date()), ...unseen].join("\n")}\n`);
12779
+ });
12780
+ }
12781
+
12782
+ //#endregion
12783
+ //#region src/commands/push.ts
12784
+ /** Does the org already hold these bytes? A 404 is the answer, not a failure. */
12785
+ async function held(client, digest) {
12786
+ try {
12787
+ await client.request(contract.artifacts["artifacts.head"], { params: { digest } });
12788
+ return true;
12789
+ } catch (error) {
12790
+ if (error instanceof ApiError && error.status === 404) return false;
12791
+ throw error;
12792
+ }
12793
+ }
12794
+ /** Every artifact the manifest names, and the file `cow build` wrote it to. */
12795
+ function artifactFiles(projectDir, manifest) {
12796
+ const out = join(projectDir, BUILD_DIR);
12797
+ return [
12798
+ ...manifest.journeys.map((journey) => ({
12799
+ digest: journey.bundle,
12800
+ kind: "bundle",
12801
+ file: join(out, "bundles", "journeys", `${journey.key}.js`)
12802
+ })),
12803
+ ...manifest.templates.map((template) => ({
12804
+ digest: template.bundle,
12805
+ kind: "bundle",
12806
+ file: join(out, "bundles", "emails", `${template.key}.js`)
12807
+ })),
12808
+ {
12809
+ digest: manifest.source,
12810
+ kind: "source",
12811
+ file: join(out, "source.tgz")
12812
+ }
12813
+ ];
12814
+ }
12815
+ /**
12816
+ * The body both `pushes.plan` and `pushes.create` take: the same manifest,
12817
+ * asked about and then stored.
12818
+ *
12819
+ * A stored manifest carries any protocol (a version outlives a bump), so
12820
+ * this is the one shape that pins it to the constant. `cow build` wrote
12821
+ * exactly that; restating it is what satisfies the literal.
12822
+ */
12823
+ function pushBody({ manifest, config }, prune = false) {
12824
+ return {
12825
+ manifest: {
12826
+ ...manifest,
12827
+ protocol: 3
12828
+ },
12829
+ prune,
12830
+ appId: config.appId,
12831
+ orgId: config.orgId
12832
+ };
12833
+ }
12834
+ /**
12835
+ * What a push of this tree would change, having changed nothing. The build
12836
+ * comes back with it so the push that follows a confirmed plan uploads the
12837
+ * bundles that were planned rather than rebuilding and finding others.
12838
+ */
12839
+ async function planProject({ client, projectDir }) {
12840
+ const built = await buildProject(projectDir);
12841
+ return {
12842
+ plan: (await client.request(contract.pushes["pushes.plan"], { body: pushBody(built) })).data,
12843
+ built
12844
+ };
12845
+ }
12846
+ async function pushProject({ client, projectDir, built, prune }) {
12847
+ const build = built ?? await buildProject(projectDir);
12848
+ const { manifest } = build;
12849
+ const uploaded = [];
12850
+ for (const artifact of artifactFiles(projectDir, manifest)) {
12851
+ if (await held(client, artifact.digest)) continue;
12852
+ await client.request(contract.artifacts["artifacts.put"], {
12853
+ params: { digest: artifact.digest },
12854
+ query: { kind: artifact.kind },
12855
+ body: await readFile(artifact.file)
12856
+ });
12857
+ uploaded.push(artifact.digest);
12858
+ }
12859
+ return {
12860
+ push: (await client.request(contract.pushes["pushes.create"], { body: pushBody(build, prune) })).data,
12861
+ uploaded
12862
+ };
12863
+ }
12864
+ /**
12865
+ * The human report: what was stored, and one line per changed key with the
12866
+ * page that key now has on the dashboard. A key whose code did not change
12867
+ * gets no new version, so a push of an unchanged tree says exactly that.
12868
+ */
12869
+ function pushSummary(outcome, webUrl) {
12870
+ const { push } = outcome;
12871
+ const uploaded = outcome.uploaded.length === 0 ? "nothing new to upload" : `${outcome.uploaded.length} artifact${outcome.uploaded.length === 1 ? "" : "s"} uploaded`;
12872
+ const stored = `Push #${push.seq} stored (${uploaded}).`;
12873
+ if (push.versions.length === 0) return `${stored} Nothing changed.`;
12874
+ const width = Math.max(...push.versions.map((version) => version.key.length));
12875
+ 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");
12876
+ }
12877
+ const MARK = {
12878
+ added: "+",
12879
+ changed: "~",
12880
+ unchanged: " "
12881
+ };
12882
+ const DRIFT = {
12883
+ "not-in-tree": "on the server, not in this tree",
12884
+ "on-hold": "on hold; this leaves it that way"
12885
+ };
12886
+ /**
12887
+ * The plan as a developer reads it: one line per key, marked `+` for a key
12888
+ * the account does not have and `~` for one this tree moves, with the
12889
+ * fields that moved named beside it. Pure, so a test reads it and so `cow
12890
+ * plan` can print the same thing without pushing.
12891
+ */
12892
+ function planLines(plan) {
12893
+ const lines = [`Plan for ${plan.appId}:`, ""];
12894
+ const width = Math.max(0, ...[...plan.entries, ...plan.drift].map((one) => one.key.length));
12895
+ const row = (kind, key, mark, rest) => `${mark} ${kind.padEnd(8)} ${key.padEnd(width)} ${rest}`.trimEnd();
12896
+ for (const entry of plan.entries) lines.push(row(entry.kind, entry.key, MARK[entry.change], entry.fields.join(", ")));
12897
+ const count = (change) => plan.entries.filter((entry) => entry.change === change).length;
12898
+ const [added, changed] = [count("added"), count("changed")];
12899
+ lines.push("", added + changed === 0 ? "Nothing to change." : `${added} to add, ${changed} to change, ${count("unchanged")} unchanged.`);
12900
+ if (plan.drift.length > 0) {
12901
+ lines.push("", "Left alone:");
12902
+ for (const one of plan.drift) lines.push(row(one.kind, one.key, " ", DRIFT[one.reason]));
12903
+ }
12904
+ return lines;
12905
+ }
12906
+ /**
12907
+ * What `--prune` acts on: drift this tree no longer carries. The other
12908
+ * drift reason (`on-hold`) is a journey the tree still has, and templates
12909
+ * are excluded outright — a live execution pins the template version it
12910
+ * started on, and the version prune sweep already owns their lifecycle.
12911
+ */
12912
+ function prunable(plan) {
12913
+ return plan.drift.filter((one) => one.reason === "not-in-tree" && one.kind !== "template");
12914
+ }
12915
+ /** The drift `--prune` would delete, with what the server says it costs. */
12916
+ function priced(drift, status) {
12917
+ return drift.map((one) => {
12918
+ const served = status.keys.find((entry) => entry.kind === "journey" && entry.key === one.key);
12919
+ return {
12920
+ kind: one.kind === "segment" ? "segment" : "journey",
12921
+ key: one.key,
12922
+ status: one.kind === "segment" ? null : served?.status ?? null,
12923
+ liveExecutions: one.kind === "segment" ? 0 : served?.liveExecutions ?? 0
12924
+ };
12925
+ });
12926
+ }
12927
+ /** How many live runs the whole prune ends. */
12928
+ function liveTotal(doomed) {
12929
+ return doomed.reduce((sum, one) => sum + one.liveExecutions, 0);
12930
+ }
12931
+ const runs = (count) => `${count} live run${count === 1 ? "" : "s"}`;
12932
+ /**
12933
+ * The bill, printed before the question and before anything is deleted:
12934
+ * one line per thing, with the journey's state and the runs it is carrying,
12935
+ * and the total underneath. Pure, so a test reads it.
12936
+ */
12937
+ function pruneLines(doomed) {
12938
+ const width = Math.max(...doomed.map((one) => one.key.length));
12939
+ const total = liveTotal(doomed);
12940
+ return [
12941
+ "",
12942
+ "No longer in this tree, so --prune deletes:",
12943
+ "",
12944
+ ...doomed.map((one) => `- ${one.kind.padEnd(7)} ${one.key.padEnd(width)} ${one.kind === "segment" ? "" : `${one.status ?? "unknown"} ${runs(one.liveExecutions)}`}`.trimEnd()),
12945
+ "",
12946
+ total === 0 ? "Nothing is running on any of them." : `${runs(total)} end the moment this is applied.`
12947
+ ];
12948
+ }
12949
+ /**
12950
+ * The refusal an unattended prune gets when the plan both adds and deletes,
12951
+ * which is what a renamed file looks like (ADR 0024). Not a heuristic: no
12952
+ * digest is compared and no pair is guessed, because two journeys may
12953
+ * legitimately share a bundle and a matching digest proves nothing. It is
12954
+ * the shape of the plan that is refused, and only when nobody is watching.
12955
+ */
12956
+ function renameRefusal(added, doomed) {
12957
+ const names = (ones) => ones.map((one) => `${one.kind} ${one.key}`).join(", ");
12958
+ return [
12959
+ "Nothing was pushed: this plan both adds and deletes, which is what renaming a file looks like,",
12960
+ "and an unattended run is exactly where nobody reads the live-run count.",
12961
+ ` adds: ${names(added)}`,
12962
+ ` deletes: ${names(doomed)}`,
12963
+ `Confirm it yourself — run \`cow push --prune\` without --yes and answer the prompt — or split the`,
12964
+ "rename into two pushes: push the new file, then prune once it is live."
12965
+ ].join("\n");
12966
+ }
12967
+ /**
12968
+ * Delete the journeys this tree dropped, through the two verbs that already
12969
+ * exist rather than a second destructive path: stop what is running, then
12970
+ * delete the journey. Both are a person's call and neither is on a pipeline
12971
+ * key's list, which is why the refusal says so in words.
12972
+ *
12973
+ * Sequential, and after the push: the tree that replaces them is stored
12974
+ * first, so a push that turns out to be invalid has destroyed nothing.
12975
+ */
12976
+ async function pruneJourneys(client, doomed) {
12977
+ for (const one of doomed) try {
12978
+ if (one.liveExecutions > 0) await client.request(contract.executions["executions.cancel"], { body: { journey: one.key } });
12979
+ await client.request(contract.journeys["journeys.delete"], { params: { key: one.key } });
12980
+ } catch (error) {
12981
+ 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.`);
12982
+ throw error;
12983
+ }
12984
+ }
12985
+ /** One line for what went, after the push report. */
12986
+ function pruneSummary(doomed) {
12987
+ return `Deleted ${doomed.map((one) => `${one.kind} ${one.key}`).join(", ")}.`;
12988
+ }
12989
+ /** Ask on the terminal. The question goes to stderr, like every other prompt. */
12990
+ async function confirm(question) {
12991
+ const rl = createInterface({
12992
+ input: process.stdin,
12993
+ output: process.stderr
12994
+ });
12995
+ try {
12996
+ return /^y(es)?$/i.test((await rl.question(`${question} `)).trim());
12997
+ } finally {
12998
+ rl.close();
12999
+ }
13000
+ }
13001
+ function registerPush(program, clientFor, env, io) {
13002
+ 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) => {
13003
+ const merged = {
13004
+ ...program.opts(),
13005
+ ...opts
13006
+ };
13007
+ const client = await clientFor(merged);
13008
+ const projectDir = process.cwd();
13009
+ const json = merged.json === true;
13010
+ const { plan, built } = await planProject({
13011
+ client,
13012
+ projectDir
13013
+ });
13014
+ (json ? io.stderr : io.stdout)(`${planLines(plan).join("\n")}\n`);
13015
+ const unseen = await unseenReport(client, plan.appId, built.manifest.events);
13016
+ if (unseen.length > 0) (json ? io.stderr : io.stdout)(`${unseen.join("\n")}\n`);
13017
+ const prune = merged.prune === true;
13018
+ const drift = prune ? prunable(plan) : [];
13019
+ const doomed = drift.length > 0 ? priced(drift, await readStatus(client, plan.appId)) : [];
13020
+ if (doomed.length > 0) (json ? io.stderr : io.stdout)(`${pruneLines(doomed).join("\n")}\n`);
13021
+ const added = plan.entries.filter((one) => one.change === "added");
13022
+ if (merged.yes === true && doomed.length > 0 && added.length > 0) throw new Error(renameRefusal(added, doomed));
13023
+ if ((plan.entries.some((one) => one.change !== "unchanged") || doomed.length > 0) && merged.yes !== true) {
13024
+ 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.");
13025
+ if (!await confirm(doomed.length > 0 ? "Push this and delete those? [y/N]" : "Push this? [y/N]")) {
13026
+ if (json) emit({ data: {
13027
+ plan,
13028
+ push: null,
13029
+ pruned: []
13030
+ } }, io, true);
13031
+ else io.stdout("Nothing was pushed.\n");
13032
+ return;
13033
+ }
13034
+ }
13035
+ const outcome = await pushProject({
13036
+ client,
13037
+ projectDir,
13038
+ built,
13039
+ prune
13040
+ });
13041
+ await pruneJourneys(client, doomed.filter((one) => one.kind === "journey"));
13042
+ if (json) {
13043
+ emit({ data: {
13044
+ plan,
13045
+ push: outcome.push,
13046
+ pruned: doomed.map((one) => ({
13047
+ kind: one.kind,
13048
+ key: one.key
13049
+ }))
13050
+ } }, io, true);
13051
+ return;
13052
+ }
13053
+ const webUrl = resolveWebUrl(env, built.config.webUrl);
13054
+ io.stdout(`${pushSummary(outcome, webUrl)}\n`);
13055
+ if (doomed.length > 0) io.stdout(`${pruneSummary(doomed)}\n`);
13056
+ });
13057
+ }
13058
+
13059
+ //#endregion
13060
+ //#region src/commands/plan.ts
13061
+ /**
13062
+ * `cow plan` (ADR 0025): the read-only half of `cow push`. It builds, asks
13063
+ * the server what a push of this tree would change, prints it, and stops.
13064
+ * Nothing is uploaded and nothing is stored, so it is the one push verb that
13065
+ * costs an account nothing to run.
13066
+ *
13067
+ * Everything it prints is `cow push`'s: the comparison is the server's
13068
+ * (`pushes.plan`), the renderer is `planLines`, and the warning under it is
13069
+ * `unseenReport`. There is no second computation that could drift from the
13070
+ * one the push itself shows.
13071
+ */
13072
+ /** The envelope `cow plan --json` prints, and what the MCP tool answers. */
13073
+ async function projectPlan(client) {
13074
+ const { plan } = await planProject({
13075
+ client,
13076
+ projectDir: process.cwd()
13077
+ });
13078
+ return { data: plan };
13079
+ }
13080
+ function registerPlan(program, clientFor, io) {
13081
+ program.command("plan").description("show what a push of this repository would change, and change nothing").action(async (opts) => {
13082
+ const merged = {
13083
+ ...program.opts(),
13084
+ ...opts
13085
+ };
13086
+ const client = await clientFor(merged);
13087
+ const { plan, built } = await planProject({
13088
+ client,
13089
+ projectDir: process.cwd()
13090
+ });
13091
+ if (merged.json === true) {
13092
+ emit({ data: plan }, io, true);
13093
+ return;
13094
+ }
13095
+ const unseen = await unseenReport(client, plan.appId, built.manifest.events);
13096
+ io.stdout(`${[...planLines(plan), ...unseen].join("\n")}\n`);
13097
+ });
13098
+ }
13099
+
13100
+ //#endregion
13101
+ //#region src/commands/mcp/tools.ts
13102
+ /**
13103
+ * The fields of a request part. The container is unwrapped, its fields are
13104
+ * not: a field's `.optional()` and `.default()` wrappers are what the MCP
13105
+ * SDK parses tool arguments against.
13106
+ *
13107
+ * Unwrapping the container is deliberate, and a change from the generated
13108
+ * table this replaced: `mcpToolFor` derives `paramsKeys`/`queryKeys` through
13109
+ * the same unwrap, so a wrapped container used to yield keys that `toInput`
13110
+ * would pick but that the tool never declared. No route wraps one today.
13111
+ */
13112
+ function shapeOf(schema) {
13113
+ const inner = schema instanceof z.ZodType ? unwrapSchema(schema).schema : void 0;
13114
+ return inner instanceof z.ZodObject ? inner.shape : {};
13115
+ }
13116
+ function pick(args, keys) {
13117
+ const picked = {};
13118
+ for (const key of keys) {
13119
+ const value = args[key];
13120
+ if (value !== void 0) picked[key] = value;
13121
+ }
13122
+ return picked;
13123
+ }
13124
+ function defineTool(spec) {
13125
+ const data = bodyDataSchema(spec.route);
13126
+ const bodyShape = data === void 0 ? {} : data instanceof z.ZodObject ? data.shape : { data };
12061
13127
  return {
12062
13128
  name: spec.name,
12063
13129
  description: spec.description,
@@ -12084,11 +13150,23 @@ function planTools() {
12084
13150
 
12085
13151
  //#endregion
12086
13152
  //#region src/commands/mcp/server.ts
12087
- /**
12088
- * The cow MCP server: every admin/read capability of the dashboard API as
12089
- * a native tool. The tool table (tools.ts) is planned from the shared route
12090
- * contract at startup, so the surface cannot drift from the API.
12091
- */
13153
+ /** One tool call: the JSON it answered with, or the refusal it hit. */
13154
+ async function toolResult(run) {
13155
+ try {
13156
+ return { content: [{
13157
+ type: "text",
13158
+ text: JSON.stringify(await run())
13159
+ }] };
13160
+ } catch (error) {
13161
+ return {
13162
+ content: [{
13163
+ type: "text",
13164
+ text: errorMessage$1(error)
13165
+ }],
13166
+ isError: true
13167
+ };
13168
+ }
13169
+ }
12092
13170
  async function createMcpServer({ client }) {
12093
13171
  const { version } = await readCliPackage();
12094
13172
  const server = new McpServer({
@@ -12099,23 +13177,11 @@ async function createMcpServer({ client }) {
12099
13177
  for (const tool of planTools()) server.registerTool(tool.name, {
12100
13178
  description: tool.description,
12101
13179
  inputSchema: tool.inputSchema
12102
- }, async (args) => {
12103
- try {
12104
- const envelope = tool.route.request ? await request(tool.route, tool.toInput(args)) : await request(tool.route);
12105
- return { content: [{
12106
- type: "text",
12107
- text: JSON.stringify(envelope)
12108
- }] };
12109
- } catch (error) {
12110
- return {
12111
- content: [{
12112
- type: "text",
12113
- text: errorMessage$1(error)
12114
- }],
12115
- isError: true
12116
- };
12117
- }
12118
- });
13180
+ }, async (args) => toolResult(() => tool.route.request ? request(tool.route, tool.toInput(args)) : request(tool.route)));
13181
+ server.registerTool("pushes_plan", {
13182
+ description: "What a push of the project in the current directory would change. Builds locally; uploads and stores nothing.",
13183
+ inputSchema: {}
13184
+ }, async () => toolResult(() => projectPlan(client)));
12119
13185
  return server;
12120
13186
  }
12121
13187
  function errorMessage$1(error) {
@@ -12251,235 +13317,6 @@ function registerPull(program, clientFor, io) {
12251
13317
  });
12252
13318
  }
12253
13319
 
12254
- //#endregion
12255
- //#region src/commands/push.ts
12256
- /** Does the org already hold these bytes? A 404 is the answer, not a failure. */
12257
- async function held(client, digest) {
12258
- try {
12259
- await client.request(contract.artifacts["artifacts.head"], { params: { digest } });
12260
- return true;
12261
- } catch (error) {
12262
- if (error instanceof ApiError && error.status === 404) return false;
12263
- throw error;
12264
- }
12265
- }
12266
- /** Every artifact the manifest names, and the file `cow build` wrote it to. */
12267
- function artifactFiles(projectDir, manifest) {
12268
- const out = join(projectDir, BUILD_DIR);
12269
- return [
12270
- ...manifest.journeys.map((journey) => ({
12271
- digest: journey.bundle,
12272
- kind: "bundle",
12273
- file: join(out, "bundles", "journeys", `${journey.key}.js`)
12274
- })),
12275
- ...manifest.templates.map((template) => ({
12276
- digest: template.bundle,
12277
- kind: "bundle",
12278
- file: join(out, "bundles", "emails", `${template.key}.js`)
12279
- })),
12280
- {
12281
- digest: manifest.source,
12282
- kind: "source",
12283
- file: join(out, "source.tgz")
12284
- }
12285
- ];
12286
- }
12287
- async function pushProject({ client, projectDir }) {
12288
- const { manifest, config } = await buildProject(projectDir);
12289
- const uploaded = [];
12290
- for (const artifact of artifactFiles(projectDir, manifest)) {
12291
- if (await held(client, artifact.digest)) continue;
12292
- await client.request(contract.artifacts["artifacts.put"], {
12293
- params: { digest: artifact.digest },
12294
- query: { kind: artifact.kind },
12295
- body: await readFile(artifact.file)
12296
- });
12297
- uploaded.push(artifact.digest);
12298
- }
12299
- return {
12300
- push: (await client.request(contract.pushes["pushes.create"], { body: {
12301
- manifest: {
12302
- ...manifest,
12303
- protocol: 3
12304
- },
12305
- appId: config.appId,
12306
- orgId: config.orgId
12307
- } })).data,
12308
- uploaded
12309
- };
12310
- }
12311
- /**
12312
- * The human report: what was stored, and one line per changed key with the
12313
- * page that key now has on the dashboard. A key whose code did not change
12314
- * gets no new version, so a push of an unchanged tree says exactly that.
12315
- */
12316
- function pushSummary(outcome, webUrl) {
12317
- const { push } = outcome;
12318
- const uploaded = outcome.uploaded.length === 0 ? "nothing new to upload" : `${outcome.uploaded.length} artifact${outcome.uploaded.length === 1 ? "" : "s"} uploaded`;
12319
- const stored = `Push #${push.seq} stored (${uploaded}).`;
12320
- if (push.versions.length === 0) return `${stored} Nothing changed.`;
12321
- const width = Math.max(...push.versions.map((version) => version.key.length));
12322
- 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");
12323
- }
12324
- function registerPush(program, clientFor, env, io) {
12325
- program.command("push").description("build this repository and upload it; the server compiles what changed").action(async (opts) => {
12326
- const merged = {
12327
- ...program.opts(),
12328
- ...opts
12329
- };
12330
- const outcome = await pushProject({
12331
- client: await clientFor(merged),
12332
- projectDir: process.cwd()
12333
- });
12334
- if (merged.json === true) {
12335
- emit({ data: outcome.push }, io, true);
12336
- return;
12337
- }
12338
- const webUrl = resolveWebUrl(env, (await readCowConfig(process.cwd()))?.webUrl);
12339
- io.stdout(`${pushSummary(outcome, webUrl)}\n`);
12340
- });
12341
- }
12342
-
12343
- //#endregion
12344
- //#region src/commands/status.ts
12345
- /**
12346
- * `cow status`: what the server holds for every key of this app, and how far
12347
- * the tree in front of the developer has moved from it. Hand-written rather
12348
- * than derived from `apps.status`, because the drift is the half of the
12349
- * answer no server read can know: it comes from building the repository
12350
- * here and comparing the bundle digests with the versions the server has.
12351
- */
12352
- /** Largest first: the first unit the gap fills is the one that reads best. */
12353
- const UNITS = [
12354
- ["year", 31536e6],
12355
- ["month", 2592e6],
12356
- ["week", 6048e5],
12357
- ["day", 864e5],
12358
- ["hour", 36e5],
12359
- ["minute", 6e4],
12360
- ["second", 1e3]
12361
- ];
12362
- const relative$1 = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
12363
- /** The gap between an instant and now, in words. */
12364
- function ago(value, now) {
12365
- const gap = new Date(value).getTime() - now.getTime();
12366
- for (const [unit, ms] of UNITS) if (Math.abs(gap) >= ms) return relative$1.format(Math.round(gap / ms), unit);
12367
- return relative$1.format(0, "second");
12368
- }
12369
- function localKeys(manifest) {
12370
- return [...manifest.journeys.map((journey) => ({
12371
- kind: "journey",
12372
- key: journey.key,
12373
- bundle: journey.bundle
12374
- })), ...manifest.templates.map((template) => ({
12375
- kind: "template",
12376
- key: template.key,
12377
- bundle: template.bundle
12378
- }))];
12379
- }
12380
- /**
12381
- * The version's short name. The bundle digest rather than the compiled
12382
- * module's, because a version is one bundle (a push that carries a bundle
12383
- * the org already holds creates no second version) and the bundle is there
12384
- * from the moment the push lands, while the module digest arrives only when
12385
- * the compile finishes.
12386
- */
12387
- function shortDigest(bundle) {
12388
- return bundle.replace("sha256:", "").slice(0, 8);
12389
- }
12390
- function bundleOf(entry) {
12391
- const manifest = entry.latestVersion?.manifest;
12392
- return manifest ? manifest.bundle : null;
12393
- }
12394
- /** "version 3f2a1b9c, pushed 2 hours ago, still compiling" */
12395
- function versionPhrase(entry, now) {
12396
- const version = entry.latestVersion;
12397
- if (!version) return "no version yet";
12398
- const state = version.status === "compiling" ? ", still compiling" : version.status === "failed" ? ", did not compile" : "";
12399
- const bundle = bundleOf(entry);
12400
- return `${bundle ? `version ${shortDigest(bundle)}` : "version"}, pushed ${ago(version.pushedAt, now)}${state}`;
12401
- }
12402
- function flagsPhrase(entry) {
12403
- return entry.status === null ? "" : entry.status === "paused" ? "on hold" : entry.status;
12404
- }
12405
- function livePhrase(entry) {
12406
- return entry.liveExecutions === 0 ? "" : `${entry.liveExecutions} running`;
12407
- }
12408
- function pad(text, width) {
12409
- return text.padEnd(width);
12410
- }
12411
- /**
12412
- * The whole report, as lines. Pure so a test can read it: everything it
12413
- * needs is the server's answer, the local build, and the clock.
12414
- *
12415
- * A null manifest is a tree that did not build. What the server runs is
12416
- * still the answer to most of the question, so the report keeps it and drops
12417
- * only the comparison.
12418
- */
12419
- function statusLines(status, manifest, now) {
12420
- const local = manifest ? localKeys(manifest) : [];
12421
- const journeys = status.keys.filter((entry) => entry.kind === "journey");
12422
- const templates = status.keys.filter((entry) => entry.kind === "template");
12423
- const lines = [`${status.appId}: ${journeys.length} ${journeys.length === 1 ? "journey" : "journeys"}, ${templates.length} ${templates.length === 1 ? "template" : "templates"} on the server.`];
12424
- const width = Math.max(0, ...status.keys.map((entry) => entry.key.length));
12425
- const section = (title, entries) => {
12426
- if (entries.length === 0) return;
12427
- const flagWidth = Math.max(0, ...entries.map((entry) => flagsPhrase(entry).length));
12428
- lines.push("", title);
12429
- for (const entry of entries) {
12430
- const flags = flagsPhrase(entry);
12431
- const live = livePhrase(entry);
12432
- const rest = [
12433
- ...flags === "" ? [] : [pad(flags, flagWidth)],
12434
- versionPhrase(entry, now),
12435
- ...live === "" ? [] : [live]
12436
- ];
12437
- lines.push(` ${pad(entry.key, width)} ${rest.join(" ")}`.trimEnd());
12438
- }
12439
- };
12440
- section("Journeys", journeys);
12441
- section("Templates", templates);
12442
- if (!manifest) {
12443
- lines.push("", "This tree did not build, so nothing here is compared with it. Run cow build to see why.");
12444
- return lines;
12445
- }
12446
- const changed = local.filter((entry) => {
12447
- const served = status.keys.find((one) => one.kind === entry.kind && one.key === entry.key);
12448
- return !served || bundleOf(served) !== entry.bundle;
12449
- });
12450
- const missing = status.keys.filter((entry) => !local.some((one) => one.kind === entry.kind && one.key === entry.key));
12451
- if (changed.length > 0) lines.push("", `Changed here since the last push: ${changed.map((entry) => entry.key).join(", ")}. Run cow push.`);
12452
- if (missing.length > 0) {
12453
- lines.push("", "On the server and not in this tree:");
12454
- for (const entry of missing) lines.push(` ${entry.key}, a ${entry.kind}. Delete it with cow ${entry.kind}s delete ${entry.key}.`);
12455
- }
12456
- if (status.warnings.length > 0) {
12457
- lines.push("", "Names this organization does not define yet:");
12458
- for (const warning of status.warnings) lines.push(` ${warning}`);
12459
- }
12460
- return lines;
12461
- }
12462
- /** The app's status from the server, for this directory's `cow.json`. */
12463
- async function readStatus(client, appId) {
12464
- return (await client.request(contract.apps["apps.status"], { params: { id: appId } })).data;
12465
- }
12466
- function registerStatus(program, clientFor, io) {
12467
- program.command("status").description("what this app's journeys and templates are doing, and what changed here since the last push").action(async (opts) => {
12468
- const merged = {
12469
- ...program.opts(),
12470
- ...opts
12471
- };
12472
- const config = await assertCowConfig(process.cwd());
12473
- const built = await buildProject(process.cwd()).catch(() => null);
12474
- const status = await readStatus(await clientFor(merged), config.appId);
12475
- if (merged.json === true) {
12476
- emit({ data: status }, io, true);
12477
- return;
12478
- }
12479
- io.stdout(`${statusLines(status, built?.manifest ?? null, /* @__PURE__ */ new Date()).join("\n")}\n`);
12480
- });
12481
- }
12482
-
12483
13320
  //#endregion
12484
13321
  //#region src/build/simulate.ts
12485
13322
  /**
@@ -12765,6 +13602,7 @@ function buildProgram(env, io) {
12765
13602
  registerInit(program, clientFor, env, io);
12766
13603
  registerAdd(program, io);
12767
13604
  registerBuild(program, io);
13605
+ registerPlan(program, clientFor, io);
12768
13606
  registerPush(program, clientFor, env, io);
12769
13607
  registerStatus(program, clientFor, io);
12770
13608
  registerEnable(program, clientFor, io);
@@ -12819,7 +13657,7 @@ function errorMessage(error) {
12819
13657
  code: error.code,
12820
13658
  message: error.message
12821
13659
  } }, null, 2);
12822
- if (error.code === "invalid_key") return `${envelope}\n(hint: run \`cow login\` to refresh the session token)`;
13660
+ if (error.code === "invalid_key" && !process.argv.includes("--key")) return `${envelope}\n(hint: run \`cow login\` to refresh the session token)`;
12823
13661
  return envelope;
12824
13662
  }
12825
13663
  if (error instanceof Error) return `error: ${error.message}`;