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