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