@cowliss/cli 0.10.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 +1177 -405
- 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 = {
|
|
@@ -4120,17 +4132,22 @@ const insertQuarantineEntrySchema = createInsertSchema(quarantineEntries);
|
|
|
4120
4132
|
* A segment belongs to one app (ADR 0022) and sees only that app's profiles
|
|
4121
4133
|
* and that app's events.
|
|
4122
4134
|
*
|
|
4123
|
-
* A row
|
|
4124
|
-
*
|
|
4125
|
-
*
|
|
4126
|
-
*
|
|
4127
|
-
*
|
|
4128
|
-
*
|
|
4129
|
-
*
|
|
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.
|
|
4130
4146
|
*
|
|
4131
4147
|
* Timestamps use millisecond precision, same rationale as apps: JS Dates
|
|
4132
4148
|
* carry ms only and cursor pagination compares createdAt for equality.
|
|
4133
4149
|
*/
|
|
4150
|
+
const segmentSourceEnum = pgEnum("segment_source", ["dashboard", "push"]);
|
|
4134
4151
|
const segments$1 = pgTable("segments", {
|
|
4135
4152
|
id: text("id").primaryKey(),
|
|
4136
4153
|
orgId: text("org_id").notNull(),
|
|
@@ -4138,8 +4155,12 @@ const segments$1 = pgTable("segments", {
|
|
|
4138
4155
|
appId: text("app_id").notNull(),
|
|
4139
4156
|
name: text("name").notNull(),
|
|
4140
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[]`),
|
|
4141
4160
|
definition: jsonb("definition").$type().notNull(),
|
|
4142
|
-
/**
|
|
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. */
|
|
4143
4164
|
journeyKey: text("journey_key"),
|
|
4144
4165
|
createdAt: createdAt(),
|
|
4145
4166
|
updatedAt: updatedAt()
|
|
@@ -4320,6 +4341,7 @@ const sources$1 = pgTable("sources", {
|
|
|
4320
4341
|
}, (table) => [
|
|
4321
4342
|
index("sources_org_id_idx").on(table.orgId),
|
|
4322
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)),
|
|
4323
4345
|
foreignKey({
|
|
4324
4346
|
columns: [table.orgId, table.appId],
|
|
4325
4347
|
foreignColumns: [apps$1.orgId, apps$1.id],
|
|
@@ -4900,20 +4922,40 @@ const patternSchema = z.union([onePatternSchema, z.array(onePatternSchema).min(1
|
|
|
4900
4922
|
* row carries its `app_id`, and the runtime compares that with the event's.
|
|
4901
4923
|
* An `appId` here would be a second way to say it, and the way to leak.
|
|
4902
4924
|
*
|
|
4903
|
-
* A segment trigger
|
|
4904
|
-
*
|
|
4905
|
-
*
|
|
4906
|
-
*
|
|
4907
|
-
*
|
|
4908
|
-
*
|
|
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.
|
|
4909
4951
|
*
|
|
4910
|
-
*
|
|
4911
|
-
*
|
|
4912
|
-
* instead of silently triggering on every app or on nothing. There is
|
|
4913
|
-
* deliberately no pipe filter and no app filter: the journey's own app is
|
|
4914
|
-
* the only narrowing there is.
|
|
4952
|
+
* Structural in its trigger parameter so `packages/db`'s mirror of the shape
|
|
4953
|
+
* passes without either package importing the other.
|
|
4915
4954
|
*/
|
|
4916
|
-
|
|
4955
|
+
function triggerSegmentName(journeyKey, trigger) {
|
|
4956
|
+
if (!("segment" in trigger)) return null;
|
|
4957
|
+
return typeof trigger.segment === "string" ? trigger.segment : journeyKey;
|
|
4958
|
+
}
|
|
4917
4959
|
/**
|
|
4918
4960
|
* The address half of a from-header: a local part, an `@`, and a dotted
|
|
4919
4961
|
* domain. Deliberately narrower than RFC 5322 (no quoted local parts, no
|
|
@@ -5068,6 +5110,40 @@ const manifestTemplateSchema = z.object({
|
|
|
5068
5110
|
propsSchema: z.record(z.string(), z.unknown()),
|
|
5069
5111
|
bundle: digestSchema
|
|
5070
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
|
+
});
|
|
5071
5147
|
function uniqueKeys(items, ctx, path) {
|
|
5072
5148
|
const seen = /* @__PURE__ */ new Set();
|
|
5073
5149
|
for (const [index, item] of items.entries()) {
|
|
@@ -5103,6 +5179,19 @@ const manifestSchema = z.object({
|
|
|
5103
5179
|
journeys: z.array(manifestJourneySchema).max(PUSH_LIMITS.journeys),
|
|
5104
5180
|
templates: z.array(manifestTemplateSchema).max(PUSH_LIMITS.templates),
|
|
5105
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
|
+
/**
|
|
5106
5195
|
* The consent purposes this project declares, copied from `cow.json`.
|
|
5107
5196
|
* Optional rather than defaulted: a push made before purposes existed
|
|
5108
5197
|
* carries none, and its stored manifest still parses.
|
|
@@ -5113,7 +5202,22 @@ const manifestSchema = z.object({
|
|
|
5113
5202
|
}).superRefine((manifest, ctx) => {
|
|
5114
5203
|
uniqueKeys(manifest.journeys, ctx, "journeys");
|
|
5115
5204
|
uniqueKeys(manifest.templates, ctx, "templates");
|
|
5205
|
+
uniqueKeys(manifest.segments, ctx, "segments");
|
|
5116
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
|
+
}
|
|
5117
5221
|
for (const [index, journey] of manifest.journeys.entries()) if (journey.enrollment && journey.purpose === "transactional") ctx.addIssue({
|
|
5118
5222
|
code: "custom",
|
|
5119
5223
|
message: `journey "${journey.key}": a transactional journey enrolls on every trigger, so it takes no enrollment cooldown`,
|
|
@@ -5387,6 +5491,34 @@ const eventNameStatsDtoSchema = z.object({
|
|
|
5387
5491
|
lastSeenAt: z.iso.datetime().nullable()
|
|
5388
5492
|
}))
|
|
5389
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
|
+
})) });
|
|
5390
5522
|
|
|
5391
5523
|
//#endregion
|
|
5392
5524
|
//#region ../../packages/shared/src/batch.ts
|
|
@@ -5964,9 +6096,18 @@ function matchesPattern(pattern, name) {
|
|
|
5964
6096
|
return (Array.isArray(pattern) ? pattern : [pattern]).some((one) => matchesOne(one, name));
|
|
5965
6097
|
}
|
|
5966
6098
|
function matchesOne(pattern, name) {
|
|
5967
|
-
|
|
5968
|
-
|
|
5969
|
-
|
|
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);
|
|
5970
6111
|
}
|
|
5971
6112
|
/**
|
|
5972
6113
|
* Walk the `*`-separated literals left to right: the first anchors the start,
|
|
@@ -6409,7 +6550,23 @@ const createPushBodySchema = z.object({ data: z.object({
|
|
|
6409
6550
|
* lands silently whenever both orgs happen to hold an app of the same
|
|
6410
6551
|
* slug, which is exactly the shape `apps/platform-workspace` has.
|
|
6411
6552
|
*/
|
|
6412
|
-
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)
|
|
6413
6570
|
}) });
|
|
6414
6571
|
const listPushesQuerySchema = paginationQuerySchema.extend({
|
|
6415
6572
|
/** One app's own pushes; absent lists the org's. */
|
|
@@ -6853,6 +7010,142 @@ const notificationPreferencesSchema = z.object({ purposes: z.array(notificationP
|
|
|
6853
7010
|
*/
|
|
6854
7011
|
const updateNotificationPreferencesBodySchema = z.object({ data: z.object({ purposes: consentPatchSchema }) });
|
|
6855
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
|
+
|
|
6856
7149
|
//#endregion
|
|
6857
7150
|
//#region ../../packages/shared/src/resend.ts
|
|
6858
7151
|
/** Resend's own cap on one message's recipients, per list. */
|
|
@@ -7754,24 +8047,48 @@ function readCliPackage() {
|
|
|
7754
8047
|
return cached;
|
|
7755
8048
|
}
|
|
7756
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
|
+
|
|
7757
8078
|
//#endregion
|
|
7758
8079
|
//#region src/build/spine.ts
|
|
7759
8080
|
/**
|
|
7760
|
-
*
|
|
7761
|
-
*
|
|
7762
|
-
*
|
|
7763
|
-
*
|
|
7764
|
-
*
|
|
7765
|
-
*
|
|
7766
|
-
*
|
|
7767
|
-
*
|
|
7768
|
-
* `
|
|
7769
|
-
|
|
7770
|
-
|
|
7771
|
-
* front of it. The typecheck that ran first is TypeScript's; syntax it accepts
|
|
7772
|
-
* and this parser does not yields an empty spine, never a failed build.
|
|
7773
|
-
*/
|
|
7774
|
-
function readSpine(source) {
|
|
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) {
|
|
7775
8092
|
let program;
|
|
7776
8093
|
try {
|
|
7777
8094
|
program = parse(source, {
|
|
@@ -7780,22 +8097,37 @@ function readSpine(source) {
|
|
|
7780
8097
|
errorRecovery: true
|
|
7781
8098
|
}).program;
|
|
7782
8099
|
} catch {
|
|
7783
|
-
return
|
|
8100
|
+
return {
|
|
8101
|
+
spine: [],
|
|
8102
|
+
waits: []
|
|
8103
|
+
};
|
|
7784
8104
|
}
|
|
7785
8105
|
const run = findRun(program);
|
|
7786
|
-
if (!run) return
|
|
8106
|
+
if (!run) return {
|
|
8107
|
+
spine: [],
|
|
8108
|
+
waits: []
|
|
8109
|
+
};
|
|
7787
8110
|
const api = run.params[1];
|
|
7788
|
-
if (api?.type !== "Identifier") return
|
|
8111
|
+
if (api?.type !== "Identifier") return {
|
|
8112
|
+
spine: [],
|
|
8113
|
+
waits: []
|
|
8114
|
+
};
|
|
7789
8115
|
const reader = new Reader(source, api.name);
|
|
7790
|
-
if (run.body.type !== "BlockStatement") return
|
|
7791
|
-
|
|
7792
|
-
|
|
7793
|
-
|
|
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
|
+
};
|
|
7794
8123
|
const body = reader.block(run.body.body);
|
|
7795
|
-
return
|
|
7796
|
-
|
|
7797
|
-
|
|
7798
|
-
|
|
8124
|
+
return {
|
|
8125
|
+
spine: body.ended ? body.entries : [...body.entries, {
|
|
8126
|
+
name: "end",
|
|
8127
|
+
detail: "returned"
|
|
8128
|
+
}],
|
|
8129
|
+
waits: reader.waitPatterns
|
|
8130
|
+
};
|
|
7799
8131
|
}
|
|
7800
8132
|
/** The `run` function on the object `export default defineJourney({...})` receives. */
|
|
7801
8133
|
function findRun(program) {
|
|
@@ -7819,6 +8151,8 @@ var Reader = class {
|
|
|
7819
8151
|
api;
|
|
7820
8152
|
/** Variables holding a `waitForEvent` result, by name, to the event waited for. */
|
|
7821
8153
|
waits = /* @__PURE__ */ new Map();
|
|
8154
|
+
/** Every pattern a `waitForEvent` named literally, in source order. */
|
|
8155
|
+
waitPatterns = [];
|
|
7822
8156
|
constructor(source, api) {
|
|
7823
8157
|
this.source = source;
|
|
7824
8158
|
this.api = api;
|
|
@@ -7969,7 +8303,9 @@ var Reader = class {
|
|
|
7969
8303
|
detail(first ? this.literal(first) ?? this.text(first) : void 0);
|
|
7970
8304
|
break;
|
|
7971
8305
|
case "waitForEvent": {
|
|
7972
|
-
|
|
8306
|
+
const patterns = this.literalPatterns(first);
|
|
8307
|
+
if (patterns) this.waitPatterns.push(...patterns);
|
|
8308
|
+
detail(patterns ? patternLabel(patterns) : first ? this.text(first) : void 0);
|
|
7973
8309
|
const timeout = this.property(args[1], "timeout");
|
|
7974
8310
|
if (timeout) entry.timeout = timeout;
|
|
7975
8311
|
break;
|
|
@@ -8015,12 +8351,22 @@ var Reader = class {
|
|
|
8015
8351
|
}
|
|
8016
8352
|
/** A literal pattern, one or a list, the way the trigger label shows it. */
|
|
8017
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) {
|
|
8018
8363
|
if (!node) return;
|
|
8019
8364
|
if (node.type === "ArrayExpression") {
|
|
8020
8365
|
const items = node.elements.map((item) => item ? this.literal(item) : void 0);
|
|
8021
|
-
return items.every((item) => item !== void 0) ?
|
|
8366
|
+
return items.every((item) => item !== void 0) ? items : void 0;
|
|
8022
8367
|
}
|
|
8023
|
-
|
|
8368
|
+
const one = this.literal(node);
|
|
8369
|
+
return one === void 0 ? void 0 : [one];
|
|
8024
8370
|
}
|
|
8025
8371
|
/** The literal value of `key` in an inline object argument. */
|
|
8026
8372
|
property(node, key) {
|
|
@@ -8164,6 +8510,7 @@ const CONFIG_FILES = [
|
|
|
8164
8510
|
const GUEST_ALIAS = {
|
|
8165
8511
|
"@cowliss/cli/journeys": guestFile("journeys"),
|
|
8166
8512
|
"@cowliss/cli/emails": guestFile("emails"),
|
|
8513
|
+
"@cowliss/cli/segments": guestFile("segments"),
|
|
8167
8514
|
"@cowliss/cli/guest": guestFile("guest"),
|
|
8168
8515
|
"@cowliss/cli/guest-emails": guestFile("guest-emails"),
|
|
8169
8516
|
"@cowliss/cli/guest-wasi": guestFile("guest-wasi"),
|
|
@@ -8204,14 +8551,15 @@ async function projectSources(projectDir) {
|
|
|
8204
8551
|
return [
|
|
8205
8552
|
...(await Promise.all(CONFIG_FILES.map(async (file) => await stat(join(projectDir, file)).then(() => file, () => null)))).filter((file) => file !== null),
|
|
8206
8553
|
...(await listFiles(join(projectDir, "journeys"))).map((file) => `journeys/${file}`),
|
|
8207
|
-
...(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}`)
|
|
8208
8556
|
].sort();
|
|
8209
8557
|
}
|
|
8210
8558
|
async function discover(projectDir, kind, extension) {
|
|
8211
8559
|
const dir = join(projectDir, kind);
|
|
8212
8560
|
return (await listFiles(dir)).filter((file) => file.endsWith(extension) && !file.endsWith(".d.ts") && !basename(file).startsWith("_")).map((file) => {
|
|
8213
8561
|
const key = basename(file, extension);
|
|
8214
|
-
if (!journeyKeySchema.safeParse(key).success) throw new Error(`${kind}/${file}: "${key}" is not a usable key. A journey or
|
|
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).`);
|
|
8215
8563
|
return {
|
|
8216
8564
|
key,
|
|
8217
8565
|
kind,
|
|
@@ -8235,6 +8583,20 @@ function assertUniqueKeys(files) {
|
|
|
8235
8583
|
}
|
|
8236
8584
|
}
|
|
8237
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
|
+
/**
|
|
8238
8600
|
* `.cow/types.d.ts`: one `typeof import(...)` per template, merged into the
|
|
8239
8601
|
* SDK's `CowTemplates`, which is what types `api.send.email`. Written before
|
|
8240
8602
|
* the typecheck, because the typecheck is what it exists for.
|
|
@@ -8280,7 +8642,7 @@ async function typecheck(projectDir) {
|
|
|
8280
8642
|
});
|
|
8281
8643
|
} catch (error) {
|
|
8282
8644
|
const output = error;
|
|
8283
|
-
const report = (output.stdout ?? "").trim() || (output.stderr ?? "").trim();
|
|
8645
|
+
const report = (output.stdout ?? "").trim() || (output.stderr ?? "").trim() || String(error.message ?? "");
|
|
8284
8646
|
throw new Error(`Typecheck failed:\n${report}`);
|
|
8285
8647
|
}
|
|
8286
8648
|
}
|
|
@@ -8372,12 +8734,16 @@ import { runGuest } from ${JSON.stringify(DRIVER[kind])};
|
|
|
8372
8734
|
main(module, runGuest);
|
|
8373
8735
|
`;
|
|
8374
8736
|
}
|
|
8375
|
-
/**
|
|
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
|
+
*/
|
|
8376
8742
|
function nodeEntry(file, kind) {
|
|
8743
|
+
const driver = kind === "segments" ? "" : `export { runGuest } from ${JSON.stringify(DRIVER[kind])};\n`;
|
|
8377
8744
|
return `import * as module from ${JSON.stringify(file)};
|
|
8378
8745
|
export { module };
|
|
8379
|
-
|
|
8380
|
-
`;
|
|
8746
|
+
${driver}`;
|
|
8381
8747
|
}
|
|
8382
8748
|
let messagePortsUnreffed = false;
|
|
8383
8749
|
/**
|
|
@@ -8406,15 +8772,28 @@ function unrefMessagePorts() {
|
|
|
8406
8772
|
* rebuilt bundle from being served out of Node's module cache.
|
|
8407
8773
|
*/
|
|
8408
8774
|
async function loadNodeBundle(projectDir, key, kind = "journeys") {
|
|
8409
|
-
|
|
8410
|
-
const path = join(projectDir, BUILD_DIR, "node", kind, `${key}.mjs`);
|
|
8411
|
-
const { mtimeNs } = await stat(path, { bigint: true });
|
|
8412
|
-
const loaded = await import(`${pathToFileURL(path).href}?v=${mtimeNs}`);
|
|
8775
|
+
const loaded = await importNodeBundle(projectDir, key, kind);
|
|
8413
8776
|
return {
|
|
8414
8777
|
module: loaded.module,
|
|
8415
8778
|
runGuest: loaded.runGuest
|
|
8416
8779
|
};
|
|
8417
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
|
+
}
|
|
8418
8797
|
/**
|
|
8419
8798
|
* A profile with nothing in it but the id and traits given: the base the
|
|
8420
8799
|
* simulator answers `profile.get` with.
|
|
@@ -8442,6 +8821,7 @@ function emptyProfile(id = "", traits = {}, purposes = []) {
|
|
|
8442
8821
|
function limitFailure(sizes) {
|
|
8443
8822
|
if (sizes.journeys > PUSH_LIMITS.journeys) return `This project has ${sizes.journeys} journeys; a push carries at most ${PUSH_LIMITS.journeys}.`;
|
|
8444
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}.`;
|
|
8445
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}.`;
|
|
8446
8826
|
if (sizes.sourceBytes > PUSH_LIMITS.sourceBytes) return `The source tarball is ${sizes.sourceBytes} bytes; a push carries at most ${PUSH_LIMITS.sourceBytes}.`;
|
|
8447
8827
|
return null;
|
|
@@ -8517,13 +8897,16 @@ async function buildProject(projectDir) {
|
|
|
8517
8897
|
const config = await assertCowConfig(projectDir);
|
|
8518
8898
|
const journeys = await discover(projectDir, "journeys", ".ts");
|
|
8519
8899
|
const templates = await discover(projectDir, "emails", ".tsx");
|
|
8900
|
+
const segmentFiles = await discover(projectDir, "segments", ".ts");
|
|
8520
8901
|
assertUniqueKeys(journeys);
|
|
8521
8902
|
assertUniqueKeys(templates);
|
|
8903
|
+
assertUniqueKeys(segmentFiles);
|
|
8522
8904
|
const outDir = join(projectDir, BUILD_DIR);
|
|
8523
8905
|
for (const kind of ["journeys", "emails"]) {
|
|
8524
8906
|
await mkdir(join(outDir, "bundles", kind), { recursive: true });
|
|
8525
8907
|
await mkdir(join(outDir, "node", kind), { recursive: true });
|
|
8526
8908
|
}
|
|
8909
|
+
await mkdir(join(outDir, "node", "segments"), { recursive: true });
|
|
8527
8910
|
await writeFile(join(projectDir, TYPES_FILE), templateTypes(templates));
|
|
8528
8911
|
await typecheck(projectDir);
|
|
8529
8912
|
const files = [...journeys, ...templates];
|
|
@@ -8541,8 +8924,29 @@ async function buildProject(projectDir) {
|
|
|
8541
8924
|
bytes: wasi.code.byteLength
|
|
8542
8925
|
};
|
|
8543
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));
|
|
8544
8947
|
const manifestJourneys = [];
|
|
8545
8948
|
const manifestTemplates = [];
|
|
8949
|
+
const journeyWaits = /* @__PURE__ */ new Map();
|
|
8546
8950
|
for (const built of bundles) {
|
|
8547
8951
|
let report;
|
|
8548
8952
|
try {
|
|
@@ -8553,18 +8957,22 @@ async function buildProject(projectDir) {
|
|
|
8553
8957
|
}
|
|
8554
8958
|
const isJourney = built.source.kind === "journeys";
|
|
8555
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.`);
|
|
8556
|
-
if (report.kind === "journey")
|
|
8557
|
-
|
|
8558
|
-
|
|
8559
|
-
|
|
8560
|
-
|
|
8561
|
-
|
|
8562
|
-
|
|
8563
|
-
|
|
8564
|
-
|
|
8565
|
-
|
|
8566
|
-
|
|
8567
|
-
|
|
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({
|
|
8568
8976
|
key: built.source.key,
|
|
8569
8977
|
tags: report.tags,
|
|
8570
8978
|
sendClass: report.sendClass,
|
|
@@ -8579,6 +8987,7 @@ async function buildProject(projectDir) {
|
|
|
8579
8987
|
const failure = limitFailure({
|
|
8580
8988
|
journeys: journeys.length,
|
|
8581
8989
|
templates: templates.length,
|
|
8990
|
+
segments: segmentFiles.length,
|
|
8582
8991
|
bundles: bundles.map((built) => ({
|
|
8583
8992
|
key: built.source.key,
|
|
8584
8993
|
bytes: built.bytes
|
|
@@ -8591,6 +9000,14 @@ async function buildProject(projectDir) {
|
|
|
8591
9000
|
sdk: (await readCliPackage()).version,
|
|
8592
9001
|
journeys: manifestJourneys,
|
|
8593
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
|
+
}),
|
|
8594
9011
|
purposes: config.purposes,
|
|
8595
9012
|
source: digestOf(await readFile(sourceFile))
|
|
8596
9013
|
});
|
|
@@ -8626,8 +9043,15 @@ async function buildIfStale(projectDir) {
|
|
|
8626
9043
|
|
|
8627
9044
|
//#endregion
|
|
8628
9045
|
//#region src/lib/config.ts
|
|
8629
|
-
|
|
8630
|
-
|
|
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";
|
|
8631
9055
|
function credentialsPath(env) {
|
|
8632
9056
|
return env.COW_CREDENTIALS_PATH ?? defaultCredentialsPath();
|
|
8633
9057
|
}
|
|
@@ -8679,20 +9103,20 @@ function resolveCredential(env, credentials) {
|
|
|
8679
9103
|
* The project's own `apiUrl` outranks the credentials file because it is the
|
|
8680
9104
|
* more specific answer: `cow.json` says which API this project talks to, while the
|
|
8681
9105
|
* credentials only remember where somebody last logged in. Without it a
|
|
8682
|
-
* checkout whose config names
|
|
8683
|
-
*
|
|
9106
|
+
* checkout whose config names one deployment silently fell through to
|
|
9107
|
+
* whatever the last `cow login` had saved.
|
|
8684
9108
|
*/
|
|
8685
9109
|
function resolveApiUrl(env, credentials, flag, projectApiUrl) {
|
|
8686
|
-
return flag ?? env.COW_API_URL ?? projectApiUrl ?? credentials?.apiUrl ?? "
|
|
9110
|
+
return flag ?? env.COW_API_URL ?? projectApiUrl ?? credentials?.apiUrl ?? "https://api.cowliss.com";
|
|
8687
9111
|
}
|
|
8688
9112
|
/**
|
|
8689
9113
|
* The dashboard this project belongs to: the flag, the env var, the
|
|
8690
9114
|
* project's own `cow.json`, then the default. Same precedence as the API URL
|
|
8691
|
-
* and for the same reason: a checkout pointed at
|
|
8692
|
-
* 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.
|
|
8693
9117
|
*/
|
|
8694
9118
|
function resolveWebUrl(env, projectWebUrl, flag) {
|
|
8695
|
-
return flag ?? env.COW_WEB_URL ?? projectWebUrl ?? "
|
|
9119
|
+
return flag ?? env.COW_WEB_URL ?? projectWebUrl ?? "https://app.cowliss.com";
|
|
8696
9120
|
}
|
|
8697
9121
|
/**
|
|
8698
9122
|
* Decode a JWT payload without verification. Display only: the API is the
|
|
@@ -8725,9 +9149,9 @@ function decodeSessionToken(token) {
|
|
|
8725
9149
|
//#endregion
|
|
8726
9150
|
//#region src/commands/add.ts
|
|
8727
9151
|
/**
|
|
8728
|
-
* `cow add
|
|
9152
|
+
* `cow add blueprint <name>`: copy one of the gallery blueprints the package
|
|
8729
9153
|
* ships into the project in the current directory. The same files back
|
|
8730
|
-
* `cow init --
|
|
9154
|
+
* `cow init --blueprint` and the docs gallery, so what the docs render is
|
|
8731
9155
|
* exactly what lands in a developer's repo.
|
|
8732
9156
|
*
|
|
8733
9157
|
* Resolved from the package's own manifest, the self-reference
|
|
@@ -8736,29 +9160,30 @@ function decodeSessionToken(token) {
|
|
|
8736
9160
|
* bundled `dist/index.js`, and the published layout is the one nothing in
|
|
8737
9161
|
* the monorepo exercises.
|
|
8738
9162
|
*/
|
|
8739
|
-
const
|
|
8740
|
-
/**
|
|
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. */
|
|
8741
9165
|
const PARTS = [
|
|
8742
9166
|
"journeys",
|
|
8743
9167
|
"emails",
|
|
9168
|
+
"segments",
|
|
8744
9169
|
"scenarios"
|
|
8745
9170
|
];
|
|
8746
|
-
async function
|
|
8747
|
-
return (await readdir(
|
|
9171
|
+
async function blueprintNames() {
|
|
9172
|
+
return (await readdir(BLUEPRINTS_DIR, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
|
|
8748
9173
|
}
|
|
8749
9174
|
/**
|
|
8750
|
-
* Copy
|
|
9175
|
+
* Copy a blueprint's files into `dir`, returning the project-relative paths
|
|
8751
9176
|
* written. Collected and checked before anything is written, so a refusal on
|
|
8752
9177
|
* the last file never leaves the first half copied.
|
|
8753
9178
|
*/
|
|
8754
|
-
async function
|
|
8755
|
-
const known = await
|
|
8756
|
-
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(", ")}`);
|
|
8757
9182
|
const files = [];
|
|
8758
9183
|
for (const part of PARTS) {
|
|
8759
9184
|
let entries;
|
|
8760
9185
|
try {
|
|
8761
|
-
entries = await readdir(join(
|
|
9186
|
+
entries = await readdir(join(BLUEPRINTS_DIR, name, part));
|
|
8762
9187
|
} catch {
|
|
8763
9188
|
continue;
|
|
8764
9189
|
}
|
|
@@ -8768,21 +9193,21 @@ async function copyExample(name, dir, force) {
|
|
|
8768
9193
|
if (clashes.length > 0) throw new Error(`Already in this project: ${clashes.join(", ")}. Pass --force to overwrite.`);
|
|
8769
9194
|
for (const file of files) {
|
|
8770
9195
|
await mkdir(dirname(join(dir, file)), { recursive: true });
|
|
8771
|
-
await copyFile(join(
|
|
9196
|
+
await copyFile(join(BLUEPRINTS_DIR, name, file), join(dir, file));
|
|
8772
9197
|
}
|
|
8773
9198
|
return files;
|
|
8774
9199
|
}
|
|
8775
9200
|
function registerAdd(program, io) {
|
|
8776
|
-
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) => {
|
|
8777
9202
|
const dir = process.cwd();
|
|
8778
9203
|
await assertCowConfig(dir);
|
|
8779
|
-
const files = await
|
|
9204
|
+
const files = await copyBlueprint(name, dir, opts.force === true);
|
|
8780
9205
|
emit({ data: {
|
|
8781
|
-
|
|
9206
|
+
blueprint: name,
|
|
8782
9207
|
dir,
|
|
8783
9208
|
files
|
|
8784
9209
|
} }, io, program.opts().json);
|
|
8785
|
-
io.stderr(`Added the ${name}
|
|
9210
|
+
io.stderr(`Added the ${name} blueprint:\n${files.map((file) => ` ${file}`).join("\n")}\n`);
|
|
8786
9211
|
});
|
|
8787
9212
|
}
|
|
8788
9213
|
|
|
@@ -10050,6 +10475,23 @@ const events = defineModule(defineRoute({
|
|
|
10050
10475
|
...sessionErrors,
|
|
10051
10476
|
...errors("validation_failed")
|
|
10052
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
|
+
}
|
|
10053
10495
|
}), defineRoute({
|
|
10054
10496
|
method: "get",
|
|
10055
10497
|
path: "/v1/events/{id}",
|
|
@@ -10442,12 +10884,26 @@ const params$6 = z.object({ id: z.string() });
|
|
|
10442
10884
|
* push stores the source archive and its compile creates a version of every
|
|
10443
10885
|
* key whose module changed; it turns nothing on (ADR 0011).
|
|
10444
10886
|
*
|
|
10445
|
-
* `create` and `source` are hidden from the CLI and MCP surfaces
|
|
10446
|
-
*
|
|
10447
|
-
* whatever `cow build` just wrote, and the tarball
|
|
10448
|
-
* `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.
|
|
10449
10891
|
*/
|
|
10450
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({
|
|
10451
10907
|
method: "post",
|
|
10452
10908
|
path: "/v1/pushes",
|
|
10453
10909
|
operationId: "pushes.create",
|
|
@@ -11790,6 +12246,51 @@ function registerContractCommands(program, run) {
|
|
|
11790
12246
|
}
|
|
11791
12247
|
}
|
|
11792
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
|
+
|
|
11793
12294
|
//#endregion
|
|
11794
12295
|
//#region src/commands/enable.ts
|
|
11795
12296
|
/**
|
|
@@ -11845,6 +12346,31 @@ function flipSummary(journeys, status) {
|
|
|
11845
12346
|
if (journeys.length === 0) return `This app has no journeys to put ${STATE_WORD[status]}.`;
|
|
11846
12347
|
return journeys.map((journey) => [`${journey.key} is ${STATE_WORD[status]}.`, ...segmentLines(journey)].join("\n")).join("\n");
|
|
11847
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
|
+
}
|
|
11848
12374
|
const DESCRIPTIONS = {
|
|
11849
12375
|
on: "turn journeys on",
|
|
11850
12376
|
off: "turn journeys off",
|
|
@@ -11859,12 +12385,14 @@ function register(program, clientFor, io, verb, status) {
|
|
|
11859
12385
|
if (merged.all === true && keys.length > 0) throw new Error(`Name the journeys or pass --all, not both: cow ${verb} welcome.`);
|
|
11860
12386
|
if (merged.all !== true && keys.length === 0) throw new Error(`Name at least one journey, or pass --all: cow ${verb} welcome.`);
|
|
11861
12387
|
const selector = merged.all === true ? { appId: (await assertCowConfig(process.cwd())).appId } : { keys };
|
|
11862
|
-
const
|
|
12388
|
+
const client = await clientFor(merged);
|
|
12389
|
+
const updated = await setStatus(client, selector, status);
|
|
11863
12390
|
if (merged.json === true) {
|
|
11864
12391
|
emit({ data: updated }, io, true);
|
|
11865
12392
|
return;
|
|
11866
12393
|
}
|
|
11867
|
-
|
|
12394
|
+
const unseen = status === "on" ? await unseenForKeys(client, updated.map((journey) => journey.key)) : [];
|
|
12395
|
+
io.stdout(`${[flipSummary(updated, status), ...unseen].join("\n")}\n`);
|
|
11868
12396
|
});
|
|
11869
12397
|
}
|
|
11870
12398
|
function registerEnable(program, clientFor, io) {
|
|
@@ -11956,7 +12484,7 @@ async function chooseApp(client, webUrl, flag, org, io, skipPrompts) {
|
|
|
11956
12484
|
return { appId: await ask("App id", only ?? "", io, false) };
|
|
11957
12485
|
}
|
|
11958
12486
|
function registerInit(program, clientFor, env, io) {
|
|
11959
|
-
program.command("init").argument("[dir]", "directory to create the repo in (default: here)").description("create a cow repo: cow.json, journeys/, emails/, and the files to build them").option("--org <id>", "organization id (default: the logged-in org)").option("--app <id>", "app id this repository pushes to (default: ask, or the org's only one)").option("--yes", "accept the defaults instead of prompting").option("--
|
|
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) => {
|
|
11960
12488
|
const merged = {
|
|
11961
12489
|
...program.opts(),
|
|
11962
12490
|
...opts
|
|
@@ -11966,7 +12494,7 @@ function registerInit(program, clientFor, env, io) {
|
|
|
11966
12494
|
const defaultName = basename(dirArg ? resolve(cwd, dirArg) : cwd);
|
|
11967
12495
|
const name = await ask("Repo name", defaultName, io, skipPrompts);
|
|
11968
12496
|
const dir = dirArg ? resolve(cwd, dirArg) : name === defaultName ? cwd : resolve(cwd, name);
|
|
11969
|
-
if (existsSync(join(dir, "cow.json"))) throw new Error(`"${dir}" already holds a cow.json. Use \`cow add
|
|
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.`);
|
|
11970
12498
|
const credentials = await readCredentials(env);
|
|
11971
12499
|
const claimedOrg = credentials ? decodeSessionToken(credentials.token)?.orgId ?? null : null;
|
|
11972
12500
|
const orgId = typeof opts.org === "string" && opts.org !== "" ? opts.org : await ask("Organization id", claimedOrg ?? "", io, skipPrompts);
|
|
@@ -11994,19 +12522,21 @@ function registerInit(program, clientFor, env, io) {
|
|
|
11994
12522
|
include: [
|
|
11995
12523
|
"journeys",
|
|
11996
12524
|
"emails",
|
|
12525
|
+
"segments",
|
|
11997
12526
|
".cow/types.d.ts"
|
|
11998
12527
|
]
|
|
11999
12528
|
}),
|
|
12000
12529
|
".gitignore": ".cow/\nnode_modules/\n",
|
|
12001
12530
|
"journeys/.gitkeep": "",
|
|
12002
|
-
"emails/.gitkeep": ""
|
|
12531
|
+
"emails/.gitkeep": "",
|
|
12532
|
+
"segments/.gitkeep": ""
|
|
12003
12533
|
};
|
|
12004
12534
|
for (const [file, body] of Object.entries(contents)) {
|
|
12005
12535
|
await mkdir(dirname(join(dir, file)), { recursive: true });
|
|
12006
12536
|
await writeFile(join(dir, file), body, "utf8");
|
|
12007
12537
|
}
|
|
12008
|
-
const
|
|
12009
|
-
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();
|
|
12010
12540
|
emit({ data: {
|
|
12011
12541
|
dir,
|
|
12012
12542
|
orgId,
|
|
@@ -12015,6 +12545,7 @@ function registerInit(program, clientFor, env, io) {
|
|
|
12015
12545
|
} }, io, merged.json);
|
|
12016
12546
|
const here = relative(cwd, dir) || ".";
|
|
12017
12547
|
const scenario = files.find((file) => file.startsWith("scenarios/")) ?? "scenarios/abandoned-checkout.timeout.json";
|
|
12548
|
+
const journey = scenario.slice(10).split(".")[0] ?? "abandoned-checkout";
|
|
12018
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}`;
|
|
12019
12550
|
io.stderr([
|
|
12020
12551
|
`Created a cow repo in ${here}`,
|
|
@@ -12023,72 +12554,553 @@ function registerInit(program, clientFor, env, io) {
|
|
|
12023
12554
|
"Next steps:",
|
|
12024
12555
|
` cd ${here}`,
|
|
12025
12556
|
" npm install",
|
|
12026
|
-
...
|
|
12557
|
+
...blueprint ? [] : [" cow add blueprint abandoned-checkout"],
|
|
12027
12558
|
" cow build",
|
|
12028
|
-
` cow test ${
|
|
12559
|
+
` cow test ${journey} --scenario ${scenario}`,
|
|
12029
12560
|
""
|
|
12030
12561
|
].join("\n"));
|
|
12031
12562
|
});
|
|
12032
12563
|
}
|
|
12033
12564
|
|
|
12034
12565
|
//#endregion
|
|
12035
|
-
//#region src/commands/
|
|
12566
|
+
//#region src/commands/status.ts
|
|
12036
12567
|
/**
|
|
12037
|
-
*
|
|
12038
|
-
*
|
|
12039
|
-
*
|
|
12040
|
-
*
|
|
12041
|
-
*
|
|
12042
|
-
* table this replaced: `mcpToolFor` derives `paramsKeys`/`queryKeys` through
|
|
12043
|
-
* the same unwrap, so a wrapped container used to yield keys that `toInput`
|
|
12044
|
-
* would pick but that the tool never declared. No route wraps one today.
|
|
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.
|
|
12045
12573
|
*/
|
|
12046
|
-
|
|
12047
|
-
|
|
12048
|
-
|
|
12049
|
-
|
|
12050
|
-
|
|
12051
|
-
|
|
12052
|
-
|
|
12053
|
-
|
|
12054
|
-
|
|
12055
|
-
|
|
12056
|
-
|
|
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");
|
|
12057
12590
|
}
|
|
12058
|
-
|
|
12059
|
-
|
|
12060
|
-
|
|
12061
|
-
|
|
12062
|
-
|
|
12063
|
-
|
|
12064
|
-
|
|
12065
|
-
|
|
12066
|
-
|
|
12067
|
-
|
|
12068
|
-
|
|
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.
|
|
12595
|
+
*
|
|
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.
|
|
12599
|
+
*/
|
|
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: {}
|
|
12069
12606
|
},
|
|
12070
|
-
|
|
12071
|
-
|
|
12072
|
-
|
|
12073
|
-
|
|
12074
|
-
|
|
12075
|
-
|
|
12076
|
-
return input;
|
|
12077
|
-
}
|
|
12078
|
-
};
|
|
12607
|
+
onHold: entry.status === "paused"
|
|
12608
|
+
}));
|
|
12609
|
+
return pushPlan(status.appId, manifestSides({
|
|
12610
|
+
...manifest,
|
|
12611
|
+
segments: []
|
|
12612
|
+
}), served);
|
|
12079
12613
|
}
|
|
12080
|
-
/**
|
|
12081
|
-
|
|
12082
|
-
|
|
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);
|
|
12623
|
+
}
|
|
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
|
+
});
|
|
12083
12714
|
}
|
|
12084
12715
|
|
|
12085
12716
|
//#endregion
|
|
12086
|
-
//#region src/commands/
|
|
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
|
+
}
|
|
12087
12749
|
/**
|
|
12088
|
-
* The
|
|
12089
|
-
*
|
|
12090
|
-
*
|
|
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.
|
|
12091
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: {
|
|
13066
|
+
...shapeOf(spec.route.request?.params),
|
|
13067
|
+
...shapeOf(spec.route.request?.query),
|
|
13068
|
+
...bodyShape
|
|
13069
|
+
},
|
|
13070
|
+
toInput: (args) => {
|
|
13071
|
+
const input = {};
|
|
13072
|
+
if (spec.paramsKeys.length > 0) input.params = pick(args, spec.paramsKeys);
|
|
13073
|
+
if (spec.queryKeys.length > 0) input.query = pick(args, spec.queryKeys);
|
|
13074
|
+
if (spec.rawBody) input.body = args.data;
|
|
13075
|
+
else if (spec.route.request?.body) input.body = pick(args, spec.bodyKeys);
|
|
13076
|
+
return input;
|
|
13077
|
+
}
|
|
13078
|
+
};
|
|
13079
|
+
}
|
|
13080
|
+
/** Every mcp-visible route as a tool, in contract order. */
|
|
13081
|
+
function planTools() {
|
|
13082
|
+
return planSurfaces().mcp.map(defineTool);
|
|
13083
|
+
}
|
|
13084
|
+
|
|
13085
|
+
//#endregion
|
|
13086
|
+
//#region src/commands/mcp/server.ts
|
|
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
|
+
}
|
|
12092
13104
|
async function createMcpServer({ client }) {
|
|
12093
13105
|
const { version } = await readCliPackage();
|
|
12094
13106
|
const server = new McpServer({
|
|
@@ -12099,23 +13111,11 @@ async function createMcpServer({ client }) {
|
|
|
12099
13111
|
for (const tool of planTools()) server.registerTool(tool.name, {
|
|
12100
13112
|
description: tool.description,
|
|
12101
13113
|
inputSchema: tool.inputSchema
|
|
12102
|
-
}, async (args) =>
|
|
12103
|
-
|
|
12104
|
-
|
|
12105
|
-
|
|
12106
|
-
|
|
12107
|
-
text: JSON.stringify(envelope)
|
|
12108
|
-
}] };
|
|
12109
|
-
} catch (error) {
|
|
12110
|
-
return {
|
|
12111
|
-
content: [{
|
|
12112
|
-
type: "text",
|
|
12113
|
-
text: errorMessage$1(error)
|
|
12114
|
-
}],
|
|
12115
|
-
isError: true
|
|
12116
|
-
};
|
|
12117
|
-
}
|
|
12118
|
-
});
|
|
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)));
|
|
12119
13119
|
return server;
|
|
12120
13120
|
}
|
|
12121
13121
|
function errorMessage$1(error) {
|
|
@@ -12251,235 +13251,6 @@ function registerPull(program, clientFor, io) {
|
|
|
12251
13251
|
});
|
|
12252
13252
|
}
|
|
12253
13253
|
|
|
12254
|
-
//#endregion
|
|
12255
|
-
//#region src/commands/push.ts
|
|
12256
|
-
/** Does the org already hold these bytes? A 404 is the answer, not a failure. */
|
|
12257
|
-
async function held(client, digest) {
|
|
12258
|
-
try {
|
|
12259
|
-
await client.request(contract.artifacts["artifacts.head"], { params: { digest } });
|
|
12260
|
-
return true;
|
|
12261
|
-
} catch (error) {
|
|
12262
|
-
if (error instanceof ApiError && error.status === 404) return false;
|
|
12263
|
-
throw error;
|
|
12264
|
-
}
|
|
12265
|
-
}
|
|
12266
|
-
/** Every artifact the manifest names, and the file `cow build` wrote it to. */
|
|
12267
|
-
function artifactFiles(projectDir, manifest) {
|
|
12268
|
-
const out = join(projectDir, BUILD_DIR);
|
|
12269
|
-
return [
|
|
12270
|
-
...manifest.journeys.map((journey) => ({
|
|
12271
|
-
digest: journey.bundle,
|
|
12272
|
-
kind: "bundle",
|
|
12273
|
-
file: join(out, "bundles", "journeys", `${journey.key}.js`)
|
|
12274
|
-
})),
|
|
12275
|
-
...manifest.templates.map((template) => ({
|
|
12276
|
-
digest: template.bundle,
|
|
12277
|
-
kind: "bundle",
|
|
12278
|
-
file: join(out, "bundles", "emails", `${template.key}.js`)
|
|
12279
|
-
})),
|
|
12280
|
-
{
|
|
12281
|
-
digest: manifest.source,
|
|
12282
|
-
kind: "source",
|
|
12283
|
-
file: join(out, "source.tgz")
|
|
12284
|
-
}
|
|
12285
|
-
];
|
|
12286
|
-
}
|
|
12287
|
-
async function pushProject({ client, projectDir }) {
|
|
12288
|
-
const { manifest, config } = await buildProject(projectDir);
|
|
12289
|
-
const uploaded = [];
|
|
12290
|
-
for (const artifact of artifactFiles(projectDir, manifest)) {
|
|
12291
|
-
if (await held(client, artifact.digest)) continue;
|
|
12292
|
-
await client.request(contract.artifacts["artifacts.put"], {
|
|
12293
|
-
params: { digest: artifact.digest },
|
|
12294
|
-
query: { kind: artifact.kind },
|
|
12295
|
-
body: await readFile(artifact.file)
|
|
12296
|
-
});
|
|
12297
|
-
uploaded.push(artifact.digest);
|
|
12298
|
-
}
|
|
12299
|
-
return {
|
|
12300
|
-
push: (await client.request(contract.pushes["pushes.create"], { body: {
|
|
12301
|
-
manifest: {
|
|
12302
|
-
...manifest,
|
|
12303
|
-
protocol: 3
|
|
12304
|
-
},
|
|
12305
|
-
appId: config.appId,
|
|
12306
|
-
orgId: config.orgId
|
|
12307
|
-
} })).data,
|
|
12308
|
-
uploaded
|
|
12309
|
-
};
|
|
12310
|
-
}
|
|
12311
|
-
/**
|
|
12312
|
-
* The human report: what was stored, and one line per changed key with the
|
|
12313
|
-
* page that key now has on the dashboard. A key whose code did not change
|
|
12314
|
-
* gets no new version, so a push of an unchanged tree says exactly that.
|
|
12315
|
-
*/
|
|
12316
|
-
function pushSummary(outcome, webUrl) {
|
|
12317
|
-
const { push } = outcome;
|
|
12318
|
-
const uploaded = outcome.uploaded.length === 0 ? "nothing new to upload" : `${outcome.uploaded.length} artifact${outcome.uploaded.length === 1 ? "" : "s"} uploaded`;
|
|
12319
|
-
const stored = `Push #${push.seq} stored (${uploaded}).`;
|
|
12320
|
-
if (push.versions.length === 0) return `${stored} Nothing changed.`;
|
|
12321
|
-
const width = Math.max(...push.versions.map((version) => version.key.length));
|
|
12322
|
-
return [`${stored} Compiling ${push.versions.length} ${push.versions.length === 1 ? "key" : "keys"}:`, ...push.versions.map((version) => ` ${version.key.padEnd(width)} ${webUrl}/${version.kind === "journey" ? "journeys" : "deliveries/templates"}/${version.key}`)].join("\n");
|
|
12323
|
-
}
|
|
12324
|
-
function registerPush(program, clientFor, env, io) {
|
|
12325
|
-
program.command("push").description("build this repository and upload it; the server compiles what changed").action(async (opts) => {
|
|
12326
|
-
const merged = {
|
|
12327
|
-
...program.opts(),
|
|
12328
|
-
...opts
|
|
12329
|
-
};
|
|
12330
|
-
const outcome = await pushProject({
|
|
12331
|
-
client: await clientFor(merged),
|
|
12332
|
-
projectDir: process.cwd()
|
|
12333
|
-
});
|
|
12334
|
-
if (merged.json === true) {
|
|
12335
|
-
emit({ data: outcome.push }, io, true);
|
|
12336
|
-
return;
|
|
12337
|
-
}
|
|
12338
|
-
const webUrl = resolveWebUrl(env, (await readCowConfig(process.cwd()))?.webUrl);
|
|
12339
|
-
io.stdout(`${pushSummary(outcome, webUrl)}\n`);
|
|
12340
|
-
});
|
|
12341
|
-
}
|
|
12342
|
-
|
|
12343
|
-
//#endregion
|
|
12344
|
-
//#region src/commands/status.ts
|
|
12345
|
-
/**
|
|
12346
|
-
* `cow status`: what the server holds for every key of this app, and how far
|
|
12347
|
-
* the tree in front of the developer has moved from it. Hand-written rather
|
|
12348
|
-
* than derived from `apps.status`, because the drift is the half of the
|
|
12349
|
-
* answer no server read can know: it comes from building the repository
|
|
12350
|
-
* here and comparing the bundle digests with the versions the server has.
|
|
12351
|
-
*/
|
|
12352
|
-
/** Largest first: the first unit the gap fills is the one that reads best. */
|
|
12353
|
-
const UNITS = [
|
|
12354
|
-
["year", 31536e6],
|
|
12355
|
-
["month", 2592e6],
|
|
12356
|
-
["week", 6048e5],
|
|
12357
|
-
["day", 864e5],
|
|
12358
|
-
["hour", 36e5],
|
|
12359
|
-
["minute", 6e4],
|
|
12360
|
-
["second", 1e3]
|
|
12361
|
-
];
|
|
12362
|
-
const relative$1 = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
|
|
12363
|
-
/** The gap between an instant and now, in words. */
|
|
12364
|
-
function ago(value, now) {
|
|
12365
|
-
const gap = new Date(value).getTime() - now.getTime();
|
|
12366
|
-
for (const [unit, ms] of UNITS) if (Math.abs(gap) >= ms) return relative$1.format(Math.round(gap / ms), unit);
|
|
12367
|
-
return relative$1.format(0, "second");
|
|
12368
|
-
}
|
|
12369
|
-
function localKeys(manifest) {
|
|
12370
|
-
return [...manifest.journeys.map((journey) => ({
|
|
12371
|
-
kind: "journey",
|
|
12372
|
-
key: journey.key,
|
|
12373
|
-
bundle: journey.bundle
|
|
12374
|
-
})), ...manifest.templates.map((template) => ({
|
|
12375
|
-
kind: "template",
|
|
12376
|
-
key: template.key,
|
|
12377
|
-
bundle: template.bundle
|
|
12378
|
-
}))];
|
|
12379
|
-
}
|
|
12380
|
-
/**
|
|
12381
|
-
* The version's short name. The bundle digest rather than the compiled
|
|
12382
|
-
* module's, because a version is one bundle (a push that carries a bundle
|
|
12383
|
-
* the org already holds creates no second version) and the bundle is there
|
|
12384
|
-
* from the moment the push lands, while the module digest arrives only when
|
|
12385
|
-
* the compile finishes.
|
|
12386
|
-
*/
|
|
12387
|
-
function shortDigest(bundle) {
|
|
12388
|
-
return bundle.replace("sha256:", "").slice(0, 8);
|
|
12389
|
-
}
|
|
12390
|
-
function bundleOf(entry) {
|
|
12391
|
-
const manifest = entry.latestVersion?.manifest;
|
|
12392
|
-
return manifest ? manifest.bundle : null;
|
|
12393
|
-
}
|
|
12394
|
-
/** "version 3f2a1b9c, pushed 2 hours ago, still compiling" */
|
|
12395
|
-
function versionPhrase(entry, now) {
|
|
12396
|
-
const version = entry.latestVersion;
|
|
12397
|
-
if (!version) return "no version yet";
|
|
12398
|
-
const state = version.status === "compiling" ? ", still compiling" : version.status === "failed" ? ", did not compile" : "";
|
|
12399
|
-
const bundle = bundleOf(entry);
|
|
12400
|
-
return `${bundle ? `version ${shortDigest(bundle)}` : "version"}, pushed ${ago(version.pushedAt, now)}${state}`;
|
|
12401
|
-
}
|
|
12402
|
-
function flagsPhrase(entry) {
|
|
12403
|
-
return entry.status === null ? "" : entry.status === "paused" ? "on hold" : entry.status;
|
|
12404
|
-
}
|
|
12405
|
-
function livePhrase(entry) {
|
|
12406
|
-
return entry.liveExecutions === 0 ? "" : `${entry.liveExecutions} running`;
|
|
12407
|
-
}
|
|
12408
|
-
function pad(text, width) {
|
|
12409
|
-
return text.padEnd(width);
|
|
12410
|
-
}
|
|
12411
|
-
/**
|
|
12412
|
-
* The whole report, as lines. Pure so a test can read it: everything it
|
|
12413
|
-
* needs is the server's answer, the local build, and the clock.
|
|
12414
|
-
*
|
|
12415
|
-
* A null manifest is a tree that did not build. What the server runs is
|
|
12416
|
-
* still the answer to most of the question, so the report keeps it and drops
|
|
12417
|
-
* only the comparison.
|
|
12418
|
-
*/
|
|
12419
|
-
function statusLines(status, manifest, now) {
|
|
12420
|
-
const local = manifest ? localKeys(manifest) : [];
|
|
12421
|
-
const journeys = status.keys.filter((entry) => entry.kind === "journey");
|
|
12422
|
-
const templates = status.keys.filter((entry) => entry.kind === "template");
|
|
12423
|
-
const lines = [`${status.appId}: ${journeys.length} ${journeys.length === 1 ? "journey" : "journeys"}, ${templates.length} ${templates.length === 1 ? "template" : "templates"} on the server.`];
|
|
12424
|
-
const width = Math.max(0, ...status.keys.map((entry) => entry.key.length));
|
|
12425
|
-
const section = (title, entries) => {
|
|
12426
|
-
if (entries.length === 0) return;
|
|
12427
|
-
const flagWidth = Math.max(0, ...entries.map((entry) => flagsPhrase(entry).length));
|
|
12428
|
-
lines.push("", title);
|
|
12429
|
-
for (const entry of entries) {
|
|
12430
|
-
const flags = flagsPhrase(entry);
|
|
12431
|
-
const live = livePhrase(entry);
|
|
12432
|
-
const rest = [
|
|
12433
|
-
...flags === "" ? [] : [pad(flags, flagWidth)],
|
|
12434
|
-
versionPhrase(entry, now),
|
|
12435
|
-
...live === "" ? [] : [live]
|
|
12436
|
-
];
|
|
12437
|
-
lines.push(` ${pad(entry.key, width)} ${rest.join(" ")}`.trimEnd());
|
|
12438
|
-
}
|
|
12439
|
-
};
|
|
12440
|
-
section("Journeys", journeys);
|
|
12441
|
-
section("Templates", templates);
|
|
12442
|
-
if (!manifest) {
|
|
12443
|
-
lines.push("", "This tree did not build, so nothing here is compared with it. Run cow build to see why.");
|
|
12444
|
-
return lines;
|
|
12445
|
-
}
|
|
12446
|
-
const changed = local.filter((entry) => {
|
|
12447
|
-
const served = status.keys.find((one) => one.kind === entry.kind && one.key === entry.key);
|
|
12448
|
-
return !served || bundleOf(served) !== entry.bundle;
|
|
12449
|
-
});
|
|
12450
|
-
const missing = status.keys.filter((entry) => !local.some((one) => one.kind === entry.kind && one.key === entry.key));
|
|
12451
|
-
if (changed.length > 0) lines.push("", `Changed here since the last push: ${changed.map((entry) => entry.key).join(", ")}. Run cow push.`);
|
|
12452
|
-
if (missing.length > 0) {
|
|
12453
|
-
lines.push("", "On the server and not in this tree:");
|
|
12454
|
-
for (const entry of missing) lines.push(` ${entry.key}, a ${entry.kind}. Delete it with cow ${entry.kind}s delete ${entry.key}.`);
|
|
12455
|
-
}
|
|
12456
|
-
if (status.warnings.length > 0) {
|
|
12457
|
-
lines.push("", "Names this organization does not define yet:");
|
|
12458
|
-
for (const warning of status.warnings) lines.push(` ${warning}`);
|
|
12459
|
-
}
|
|
12460
|
-
return lines;
|
|
12461
|
-
}
|
|
12462
|
-
/** The app's status from the server, for this directory's `cow.json`. */
|
|
12463
|
-
async function readStatus(client, appId) {
|
|
12464
|
-
return (await client.request(contract.apps["apps.status"], { params: { id: appId } })).data;
|
|
12465
|
-
}
|
|
12466
|
-
function registerStatus(program, clientFor, io) {
|
|
12467
|
-
program.command("status").description("what this app's journeys and templates are doing, and what changed here since the last push").action(async (opts) => {
|
|
12468
|
-
const merged = {
|
|
12469
|
-
...program.opts(),
|
|
12470
|
-
...opts
|
|
12471
|
-
};
|
|
12472
|
-
const config = await assertCowConfig(process.cwd());
|
|
12473
|
-
const built = await buildProject(process.cwd()).catch(() => null);
|
|
12474
|
-
const status = await readStatus(await clientFor(merged), config.appId);
|
|
12475
|
-
if (merged.json === true) {
|
|
12476
|
-
emit({ data: status }, io, true);
|
|
12477
|
-
return;
|
|
12478
|
-
}
|
|
12479
|
-
io.stdout(`${statusLines(status, built?.manifest ?? null, /* @__PURE__ */ new Date()).join("\n")}\n`);
|
|
12480
|
-
});
|
|
12481
|
-
}
|
|
12482
|
-
|
|
12483
13254
|
//#endregion
|
|
12484
13255
|
//#region src/build/simulate.ts
|
|
12485
13256
|
/**
|
|
@@ -12765,6 +13536,7 @@ function buildProgram(env, io) {
|
|
|
12765
13536
|
registerInit(program, clientFor, env, io);
|
|
12766
13537
|
registerAdd(program, io);
|
|
12767
13538
|
registerBuild(program, io);
|
|
13539
|
+
registerPlan(program, clientFor, io);
|
|
12768
13540
|
registerPush(program, clientFor, env, io);
|
|
12769
13541
|
registerStatus(program, clientFor, io);
|
|
12770
13542
|
registerEnable(program, clientFor, io);
|