@cowliss/cli 0.4.0 → 0.5.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/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import { builtinModules, createRequire } from "node:module";
3
3
  import { z, z as z$2 } from "zod";
4
4
  import { z as z$1 } from "zod/v4";
5
5
  import { createHash } from "node:crypto";
6
- import { copyFile, mkdir, readFile, readdir, rm, stat, watch, writeFile } from "node:fs/promises";
6
+ import { copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
7
7
  import { homedir } from "node:os";
8
8
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
9
9
  import { Command, InvalidArgumentError } from "commander";
@@ -16,7 +16,6 @@ import { create, extract } from "tar";
16
16
  import { parse } from "@babel/parser";
17
17
  import { existsSync, readFileSync } from "node:fs";
18
18
  import { createServer } from "node:http";
19
- import { setTimeout as setTimeout$1 } from "node:timers/promises";
20
19
  import { createInterface } from "node:readline/promises";
21
20
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
22
21
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -64,8 +63,8 @@ const ENVIRONMENT_HEADER = "X-Cow-Environment";
64
63
  *
65
64
  * It exists so the published packages are legible in the logs. Customers
66
65
  * pin versions and old ones live for years, and knowing which are still
67
- * calling is only possible if it was collected from the first release, not
68
- * from the release where it first mattered.
66
+ * calling is only possible if it was collected from the first version, not
67
+ * from the version where it first mattered.
69
68
  *
70
69
  * A custom header rather than `User-Agent`: browsers forbid scripts from
71
70
  * setting that one and drop it in silence, and `@cowliss/sdk` runs in a
@@ -217,23 +216,14 @@ const EXECUTION_LIMITS = {
217
216
  logLines: 100,
218
217
  logLineBytes: 1024
219
218
  };
220
- /** Per release: manifest counts, bundle size, and the source tarball. */
221
- const RELEASE_LIMITS = {
219
+ /** Per push: manifest counts, bundle size, and the source tarball. */
220
+ const PUSH_LIMITS = {
222
221
  journeys: 100,
223
222
  templates: 200,
224
223
  bundleBytes: 2097152,
225
224
  sourceBytes: 5242880
226
225
  };
227
226
  /**
228
- * The whole-release compile budget: how long `compileRelease` may spend on
229
- * every bundle in a manifest before it gives up and fails the release. A
230
- * project at the 300-artifact ceiling compiling at the per-bundle timeout
231
- * would otherwise hold the sandbox queue for ten hours; 30 minutes is
232
- * generous for a realistic project (a bundle compiles in seconds) and short
233
- * enough that a stuck release frees the queue on its own.
234
- */
235
- const RELEASE_COMPILE_TIMEOUT_MS = 18e5;
236
- /**
237
227
  * Where the docs site serves the `cow.json` JSON Schema (the `$schema` a
238
228
  * project file points at). Generated from `cowConfigSchema` by the docs
239
229
  * generator; the path is stable because project files link to it.
@@ -280,10 +270,10 @@ const searchQuerySchema = z.string().trim().max(200, "q must be at most 200 char
280
270
  * always scoped to the caller's org, so echoing it would invite a client to
281
271
  * believe it can be chosen.
282
272
  *
283
- * Deploy keys (journeys v2 ticket 05) are the same shape on the wire: the
273
+ * Pipeline keys (journeys v2 ticket 05) are the same shape on the wire: the
284
274
  * only difference is the Clerk `deploy` scope, which decides which family a
285
275
  * key belongs to and which routes it may reach, never anything a client
286
- * sends or reads. So /v1/settings/deploy-keys reuses these schemas.
276
+ * sends or reads. So /v1/settings/pipeline-keys reuses these schemas.
287
277
  */
288
278
  const nameSchema$3 = z.string().trim().min(1, "name is required").max(100, "name must be at most 100 characters");
289
279
  /** Body for POST /v1/settings/api-keys. */
@@ -3370,6 +3360,16 @@ const consentPurposes = pgTable("consent_purposes", {
3370
3360
  */
3371
3361
  const deliveryChannelEnum = pgEnum("delivery_channel", ["email", "webhook"]);
3372
3362
  /**
3363
+ * Why this send happened. `journey` is every send a running journey made,
3364
+ * dry runs included. `test` is the dashboard's Send test: a manager checking
3365
+ * a template's copy against their own address, with no gate, no recipient
3366
+ * profile and no journey behind it, which is why both of those columns are
3367
+ * nullable. The meter and the quota gate exclude it (packages/delivery's
3368
+ * repository), and every per-profile read excludes it by construction,
3369
+ * because it has no profile to be found under.
3370
+ */
3371
+ const deliveryKindEnum = pgEnum("delivery_kind", ["journey", "test"]);
3372
+ /**
3373
3373
  * The delivery lifecycle, frozen with the API version.
3374
3374
  *
3375
3375
  * - `sent` is the only non-terminal status: it means SES accepted the
@@ -3419,10 +3419,15 @@ const deliveries$1 = pgTable("deliveries", {
3419
3419
  orgId: text("org_id").notNull(),
3420
3420
  /** The instance's environment, stamped from the workflow run input. */
3421
3421
  environment: environmentEnum("environment").notNull(),
3422
- /** The recipient profile (`usr_`); a text column, since a delivery is a log row and outlives nothing but erasure. */
3423
- profileId: text("profile_id").notNull(),
3424
- /** Journey name (the Temporal workflow type), not the registry id. */
3425
- journey: text("journey").notNull(),
3422
+ /**
3423
+ * The recipient profile (`usr_`); a text column, since a delivery is a
3424
+ * log row and outlives nothing but erasure. Null on a test send, which
3425
+ * goes to the member who asked for it rather than to anyone's profile.
3426
+ */
3427
+ profileId: text("profile_id"),
3428
+ /** The journey that sent this; null on a test send, which has none. */
3429
+ journey: text("journey"),
3430
+ kind: deliveryKindEnum("kind").notNull().default("journey"),
3426
3431
  /** Email template name or webhook destination name. */
3427
3432
  step: text("step").notNull(),
3428
3433
  channel: deliveryChannelEnum("channel").notNull(),
@@ -3611,13 +3616,13 @@ const executions$1 = pgTable("executions", {
3611
3616
  id: text("id").primaryKey(),
3612
3617
  orgId: text("org_id").notNull(),
3613
3618
  environment: environmentEnum("environment").notNull(),
3614
- /** The journey's key, not a foreign key: derived journey rows come and go with releases. */
3619
+ /** The journey's key, not a foreign key: derived journey rows come and go with pushes. */
3615
3620
  journeyKey: text("journey_key").notNull(),
3616
3621
  profileId: text("profile_id").notNull(),
3617
3622
  workflowId: text("workflow_id").notNull(),
3618
3623
  runId: text("run_id").notNull(),
3619
- /** The release the execution is pinned to for its whole life. */
3620
- releaseId: text("release_id").notNull(),
3624
+ /** The journey version the execution is pinned to for its whole life. */
3625
+ versionId: text("version_id").notNull(),
3621
3626
  status: executionStatusEnum("status").notNull(),
3622
3627
  /** Text summary of the command the execution is on, for the list view. */
3623
3628
  step: text("step"),
@@ -3659,7 +3664,7 @@ const executions$1 = pgTable("executions", {
3659
3664
  precision: 3
3660
3665
  })
3661
3666
  }, (table) => [
3662
- index("executions_org_id_environment_release_id_status_idx").on(table.orgId, table.environment, table.releaseId, table.status),
3667
+ index("executions_org_id_environment_version_id_status_idx").on(table.orgId, table.environment, table.versionId, table.status),
3663
3668
  uniqueIndex("executions_org_id_workflow_id_unique").on(table.orgId, table.workflowId),
3664
3669
  index("executions_org_id_environment_started_at_idx").on(table.orgId, table.environment, table.startedAt)
3665
3670
  ]);
@@ -3862,35 +3867,35 @@ const insertJourneyRunSchema = createInsertSchema(journeyRuns);
3862
3867
  //#endregion
3863
3868
  //#region ../../packages/db/src/schema/journeys.ts
3864
3869
  /**
3865
- * Derived journey rows: one per (org, environment, key), upserted from the
3866
- * manifest inside the deploy transaction. Nothing here is authored through
3867
- * the API, which is why there is no id of its own: the key is the name the
3868
- * author gave the file, and the (org, environment, key) triple is the only
3869
- * identity a journey has.
3870
+ * Derived journey rows: one per (org, key), upserted from the manifest a
3871
+ * push carried. Nothing here is authored through the API, which is why
3872
+ * there is no id of its own: the key is the name the author gave the file,
3873
+ * and the (org, key) pair is the only identity a journey has.
3870
3874
  *
3871
- * A deploy only ever adds and updates: it owns the keys its release carries
3875
+ * One row for both environments (ADR 0011): the journey's code is its
3876
+ * latest ready version, and what differs per environment is the enabled
3877
+ * flag alone. A trigger that should fire in both names both of an app's ids.
3878
+ *
3879
+ * A push only ever adds and updates: it owns the keys its manifest carries
3872
3880
  * and leaves every other row alone, because an org's journeys can come from
3873
- * several projects and a developer may deploy a release holding only some of
3881
+ * several projects and a developer may push a project holding only some of
3874
3882
  * the files. `projectId` records which project put the row here, which is
3875
- * what makes a key another project owns a refused deploy rather than a silent
3883
+ * what makes a key another project owns a refused push rather than a silent
3876
3884
  * overwrite. Removing a journey is an explicit delete (ADR 0009).
3877
3885
  *
3878
- * `active` is the author's own rollout gate, flattened from the manifest's
3879
- * `environments` list into this row's environment. The operator's kill
3880
- * switch is deliberately NOT here: it lives in journey_states, which is
3881
- * keyed the same way but outlives every release, so a redeploy never
3882
- * silently re-enables something an operator turned off.
3886
+ * Whether a journey fires is one flag and one only: journey_states, per
3887
+ * environment, which outlives every push, so a push never turns anything on
3888
+ * or off. The author has no second gate of their own (ADR 0011).
3883
3889
  *
3890
+ * The descriptive columns are what its latest ready version reported, so a
3891
+ * failed compile leaves both the code and its description as they were.
3884
3892
  * `spine` is display only and never trusted: a journey's real control flow
3885
3893
  * is whatever its code does at run time.
3886
3894
  */
3887
3895
  const journeys$1 = pgTable("journeys", {
3888
3896
  orgId: text("org_id").notNull(),
3889
- environment: environmentEnum("environment").notNull(),
3890
3897
  key: text("key").notNull(),
3891
- /** The release this row was derived from; the one its executions run. */
3892
- releaseId: text("release_id").notNull(),
3893
- /** The project whose deploy owns this key in this environment. */
3898
+ /** The project whose push owns this key. */
3894
3899
  projectId: text("project_id").notNull(),
3895
3900
  /** The author's labels, from the manifest; the dashboard's only grouping. */
3896
3901
  tags: text("tags").array().notNull().default(sql`'{}'::text[]`),
@@ -3903,28 +3908,22 @@ const journeys$1 = pgTable("journeys", {
3903
3908
  * the fixed pair.
3904
3909
  */
3905
3910
  purpose: text("purpose").notNull(),
3906
- /** The manifest's `environments` contains this environment. */
3907
- active: boolean("active").notNull(),
3908
3911
  spine: jsonb("spine").$type().notNull(),
3909
3912
  createdAt: createdAt(),
3910
3913
  updatedAt: updatedAt()
3911
- }, (table) => [primaryKey({ columns: [
3912
- table.orgId,
3913
- table.environment,
3914
- table.key
3915
- ] }), index("journeys_org_id_environment_release_id_idx").on(table.orgId, table.environment, table.releaseId)]);
3914
+ }, (table) => [primaryKey({ columns: [table.orgId, table.key] })]);
3916
3915
  const selectJourneySchema = createSelectSchema(journeys$1);
3917
3916
  const insertJourneySchema = createInsertSchema(journeys$1);
3918
3917
  /**
3919
3918
  * Per-org, per-environment enable state for a journey key; the only
3920
- * operator write on a journey. A row exists only once the org's admin has
3921
- * toggled the journey in that environment: no row means the journey is
3922
- * ENABLED there. The API applies that default at read time, so fresh orgs
3923
- * and fresh deploys start with every journey on.
3919
+ * operator write on a journey, and the one gate on it. A row exists only
3920
+ * once someone turned the journey on or off in that environment: NO ROW
3921
+ * MEANS DISABLED (ADR 0011). A push writes none, so a key is off in both
3922
+ * environments until `cow enable` or a manager's toggle.
3924
3923
  *
3925
3924
  * Keyed by key rather than by a foreign key into `journeys` on purpose: the
3926
- * derived rows are rebuilt per deploy, and the kill switch has to survive a
3927
- * key that temporarily leaves a release and comes back.
3925
+ * flag outlives every push, so it survives a key that temporarily leaves a
3926
+ * project and comes back.
3928
3927
  */
3929
3928
  const journeyStates = pgTable("journey_states", {
3930
3929
  orgId: text("org_id").notNull(),
@@ -4019,7 +4018,7 @@ const insertOrgSettingsSchema = createInsertSchema(orgSettings);
4019
4018
  //#region ../../packages/db/src/schema/projects.ts
4020
4019
  /**
4021
4020
  * An org's cow project: the folder a developer runs `cow init` in, and the
4022
- * thing releases belong to. An org may hold several, one per repo, each with
4021
+ * thing pushes belong to. An org may hold several, one per repo, each with
4023
4022
  * its own release sequence, its own current release per environment, and its
4024
4023
  * own slice of the deployed journey rows. The name is how `cow.json` picks
4025
4024
  * one, so it is unique within the org.
@@ -4034,6 +4033,56 @@ const projects = pgTable("projects", {
4034
4033
  const selectProjectSchema = createSelectSchema(projects);
4035
4034
  const insertProjectSchema = createInsertSchema(projects);
4036
4035
 
4036
+ //#endregion
4037
+ //#region ../../packages/db/src/schema/pushes.ts
4038
+ /**
4039
+ * One `cow push`: the whole project at one point in time. A push stores the
4040
+ * source archive and creates a version of every key whose bundle changed
4041
+ * (ADR 0011); it settles nothing and changes no flag, so it has no status
4042
+ * of its own. What is compiling is read off its versions.
4043
+ *
4044
+ * `seq` is the human handle and is per project, allocated inside the create
4045
+ * transaction; the unique index leads with the org id so it doubles as the
4046
+ * tenancy key.
4047
+ *
4048
+ * The manifest is kept whole rather than only sliced across the versions:
4049
+ * it names the source archive `cow pull` unpacks, and its entry count is
4050
+ * the denominator the compile progress climbs towards.
4051
+ */
4052
+ const pushes$1 = pgTable("pushes", {
4053
+ id: text("id").primaryKey(),
4054
+ orgId: text("org_id").notNull(),
4055
+ projectId: text("project_id").notNull().references(() => projects.id),
4056
+ seq: integer("seq").notNull(),
4057
+ manifest: jsonb("manifest").$type().notNull(),
4058
+ /** sha256 of the pushed manifest; the CLI compares it to skip a no-op push. */
4059
+ manifestDigest: text("manifest_digest").notNull(),
4060
+ /** Digest of the gzipped source tarball this push stored. */
4061
+ sourceDigest: text("source_digest").notNull(),
4062
+ createdBy: text("created_by").notNull(),
4063
+ createdAt: createdAt()
4064
+ }, (table) => [uniqueIndex("pushes_org_id_project_id_seq_unique").on(table.orgId, table.projectId, table.seq), index("pushes_org_id_created_at_idx").on(table.orgId, table.createdAt)]);
4065
+ const selectPushSchema = createSelectSchema(pushes$1);
4066
+ const insertPushSchema = createInsertSchema(pushes$1);
4067
+ /**
4068
+ * Which artifacts a push uploaded or compiled, so the prune sweep can delete
4069
+ * the bytes no surviving push still needs. Denormalised out of the manifest
4070
+ * deliberately: pruning is a join, not a jsonb scan.
4071
+ *
4072
+ * A push survives while any of its versions does, so a version's module
4073
+ * bytes are kept by the push that compiled them.
4074
+ */
4075
+ const pushArtifacts = pgTable("push_artifacts", {
4076
+ orgId: text("org_id").notNull(),
4077
+ pushId: text("push_id").notNull().references(() => pushes$1.id),
4078
+ digest: text("digest").notNull(),
4079
+ createdAt: createdAt()
4080
+ }, (table) => [primaryKey({ columns: [
4081
+ table.orgId,
4082
+ table.pushId,
4083
+ table.digest
4084
+ ] })]);
4085
+
4037
4086
  //#endregion
4038
4087
  //#region ../../packages/db/src/schema/violations.ts
4039
4088
  /**
@@ -4114,96 +4163,6 @@ const quarantineEntries = pgTable("quarantine_entries", {
4114
4163
  const selectQuarantineEntrySchema = createSelectSchema(quarantineEntries);
4115
4164
  const insertQuarantineEntrySchema = createInsertSchema(quarantineEntries);
4116
4165
 
4117
- //#endregion
4118
- //#region ../../packages/db/src/schema/releases.ts
4119
- /**
4120
- * `pending` until the compile workflow finishes, then `ready` with module
4121
- * digests recorded in the manifest, or `failed` with the error. Only a
4122
- * `ready` release can be deployed.
4123
- */
4124
- const releaseStatusEnum = pgEnum("release_status", [
4125
- "pending",
4126
- "ready",
4127
- "failed"
4128
- ]);
4129
- /**
4130
- * One `cow push`: the whole project at one point in time. The release, not
4131
- * the journey, is the versioning unit, because it is atomic, matches git,
4132
- * and pins a journey together with the templates it sends.
4133
- *
4134
- * `seq` is the human handle (`rel_… #12`) and is per project, allocated
4135
- * inside the create transaction; the unique index leads with the org id so
4136
- * it doubles as the tenancy key.
4137
- *
4138
- * Immutable except for the compile result, which is why there is no
4139
- * `updatedAt`: `compiledAt`, `error` and `compiledEntries` are the only
4140
- * fields that ever move. The first two move exactly once; `compiledEntries`
4141
- * climbs while the compile runs, because a push is minutes of silence
4142
- * otherwise and the manifest's own length is the denominator.
4143
- *
4144
- * A counter rather than a row per entry: the question both the CLI and the
4145
- * dashboard ask is "how far along", which one integer answers at a fraction
4146
- * of the write volume. Naming which entry is compiling needs per-entry rows
4147
- * and is a different question.
4148
- */
4149
- const releases$1 = pgTable("releases", {
4150
- id: text("id").primaryKey(),
4151
- orgId: text("org_id").notNull(),
4152
- projectId: text("project_id").notNull().references(() => projects.id),
4153
- seq: integer("seq").notNull(),
4154
- status: releaseStatusEnum("status").notNull(),
4155
- manifest: jsonb("manifest").$type().notNull(),
4156
- /** sha256 of the pushed manifest; `cow deploy` compares it to decide whether to push. */
4157
- manifestDigest: text("manifest_digest").notNull(),
4158
- createdBy: text("created_by").notNull(),
4159
- createdAt: createdAt(),
4160
- compiledAt: timestamp("compiled_at", {
4161
- withTimezone: true,
4162
- mode: "date",
4163
- precision: 3
4164
- }),
4165
- error: text("error"),
4166
- /** How many manifest entries have compiled and passed their checks. */
4167
- compiledEntries: integer("compiled_entries").notNull().default(0)
4168
- }, (table) => [uniqueIndex("releases_org_id_project_id_seq_unique").on(table.orgId, table.projectId, table.seq), index("releases_org_id_created_at_idx").on(table.orgId, table.createdAt)]);
4169
- const selectReleaseSchema = createSelectSchema(releases$1);
4170
- const insertReleaseSchema = createInsertSchema(releases$1);
4171
- /**
4172
- * Which artifacts a release references, so the prune sweep can delete the
4173
- * bytes no surviving release still needs. Denormalised out of the manifest
4174
- * deliberately: pruning is a join, not a jsonb scan.
4175
- */
4176
- const releaseArtifacts = pgTable("release_artifacts", {
4177
- orgId: text("org_id").notNull(),
4178
- releaseId: text("release_id").notNull().references(() => releases$1.id),
4179
- digest: text("digest").notNull(),
4180
- createdAt: createdAt()
4181
- }, (table) => [primaryKey({ columns: [
4182
- table.orgId,
4183
- table.releaseId,
4184
- table.digest
4185
- ] })]);
4186
- /**
4187
- * Append-only: an environment's current release, for one project, is that
4188
- * project's latest row, and rollback is another deployment of an earlier
4189
- * release rather than a mutation. That is what makes the deployment history
4190
- * readable and makes "the last two deployments" the whole of `cow rollback`.
4191
- *
4192
- * `projectId` is the release's own project, denormalised so "this project's
4193
- * latest deployment in this environment" is one indexed read.
4194
- */
4195
- const deployments$1 = pgTable("deployments", {
4196
- id: text("id").primaryKey(),
4197
- orgId: text("org_id").notNull(),
4198
- environment: environmentEnum("environment").notNull(),
4199
- releaseId: text("release_id").notNull().references(() => releases$1.id),
4200
- projectId: text("project_id").notNull().references(() => projects.id),
4201
- deployedBy: text("deployed_by").notNull(),
4202
- createdAt: createdAt()
4203
- }, (table) => [index("deployments_org_id_environment_created_at_idx").on(table.orgId, table.environment, table.createdAt), index("deployments_org_id_project_id_environment_created_at_idx").on(table.orgId, table.projectId, table.environment, table.createdAt)]);
4204
- const selectDeploymentSchema = createSelectSchema(deployments$1);
4205
- const insertDeploymentSchema = createInsertSchema(deployments$1);
4206
-
4207
4166
  //#endregion
4208
4167
  //#region ../../packages/db/src/schema/segments.ts
4209
4168
  /**
@@ -4433,6 +4392,68 @@ const suppressedAddresses = pgTable("suppressed_addresses", {
4433
4392
  const selectSuppressedAddressSchema = createSelectSchema(suppressedAddresses);
4434
4393
  const insertSuppressedAddressSchema = createInsertSchema(suppressedAddresses);
4435
4394
 
4395
+ //#endregion
4396
+ //#region ../../packages/db/src/schema/versions.ts
4397
+ const versionKindEnum = pgEnum("version_kind", ["journey", "template"]);
4398
+ /**
4399
+ * `compiling` until the push's compile workflow reaches this entry, then
4400
+ * `ready` with the module digest recorded, or `failed` with the error. Only
4401
+ * a `ready` version runs, so a failed compile leaves the previous one
4402
+ * running (ADR 0011).
4403
+ */
4404
+ const versionStatusEnum = pgEnum("version_status", [
4405
+ "compiling",
4406
+ "ready",
4407
+ "failed"
4408
+ ]);
4409
+ /**
4410
+ * An immutable, content-addressed snapshot of one journey or one template.
4411
+ * The latest ready version of a key is what new executions and new sends
4412
+ * use; an execution pins the journey version it started on for its whole
4413
+ * life (ADR 0004).
4414
+ *
4415
+ * `moduleDigest` is the content address and is null until the compile
4416
+ * settles: a version that never compiled has no module to be addressed by,
4417
+ * and Postgres leaves nulls out of the unique index, so a key that fails to
4418
+ * compile twice records both failures.
4419
+ *
4420
+ * `pushedAt` is the ordering key rather than a creation stamp, because an
4421
+ * unchanged key gets no new version: a push that re-sends bytes a version
4422
+ * already holds bumps this instead, which is what makes `git revert` plus
4423
+ * `cow push` put the reverted code back in front.
4424
+ */
4425
+ const versions$1 = pgTable("versions", {
4426
+ id: text("id").primaryKey(),
4427
+ orgId: text("org_id").notNull(),
4428
+ /** The file basename its author wrote; unique per org within a kind. */
4429
+ key: text("key").notNull(),
4430
+ kind: versionKindEnum("kind").notNull(),
4431
+ /** The project that pushed this key; ADR 0009's ownership. */
4432
+ projectId: text("project_id").notNull().references(() => projects.id),
4433
+ /** The push that compiled this version's module. */
4434
+ pushId: text("push_id").notNull().references(() => pushes$1.id),
4435
+ moduleDigest: text("module_digest"),
4436
+ manifest: jsonb("manifest").$type().notNull(),
4437
+ status: versionStatusEnum("status").notNull(),
4438
+ pushedAt: timestamp("pushed_at", {
4439
+ withTimezone: true,
4440
+ mode: "date",
4441
+ precision: 3
4442
+ }).notNull().defaultNow(),
4443
+ compiledAt: timestamp("compiled_at", {
4444
+ withTimezone: true,
4445
+ mode: "date",
4446
+ precision: 3
4447
+ }),
4448
+ error: text("error")
4449
+ }, (table) => [
4450
+ uniqueIndex("versions_org_id_kind_key_module_digest_unique").on(table.orgId, table.kind, table.key, table.moduleDigest),
4451
+ index("versions_org_id_kind_key_pushed_at_idx").on(table.orgId, table.kind, table.key, table.pushedAt),
4452
+ index("versions_org_id_push_id_idx").on(table.orgId, table.pushId)
4453
+ ]);
4454
+ const selectVersionSchema = createSelectSchema(versions$1);
4455
+ const insertVersionSchema = createInsertSchema(versions$1);
4456
+
4436
4457
  //#endregion
4437
4458
  //#region ../../packages/db/src/schema/wallets.ts
4438
4459
  /**
@@ -4589,12 +4610,11 @@ const ERROR_CODE_STATUS = {
4589
4610
  journey_error: 500,
4590
4611
  journey_output_invalid: 500,
4591
4612
  journey_nondeterministic: 500,
4592
- release_invalid: 422,
4593
- release_not_ready: 409,
4613
+ push_invalid: 422,
4614
+ version_not_ready: 409,
4594
4615
  project_exists: 409,
4595
4616
  project_missing: 404,
4596
- artifact_too_large: 413,
4597
- deploy_key_scope: 403
4617
+ artifact_too_large: 413
4598
4618
  };
4599
4619
  /** First zod issue's message: enough signal for a 422 without a novel format. */
4600
4620
  function firstIssueMessage(error) {
@@ -4657,10 +4677,10 @@ const identifierDtoSchema = z.object({
4657
4677
  //#endregion
4658
4678
  //#region ../../packages/shared/src/journeys-v2/manifest.ts
4659
4679
  /**
4660
- * The release manifest (spec: Build; Push and compile): what `cow build`
4680
+ * The pushed manifest (spec: Build; Push and compile): what `cow build`
4661
4681
  * extracts from a project and `cow push` uploads with the bundles. The
4662
- * server validates it with these schemas, compiles every bundle, and stores
4663
- * the compiled form on the release row.
4682
+ * server validates it with these schemas, compiles every bundle whose key
4683
+ * changed, and stores that entry on the version it creates.
4664
4684
  */
4665
4685
  /**
4666
4686
  * A journey or template key: the file basename under `journeys/` or
@@ -4740,7 +4760,7 @@ const patternSchema = z.union([onePatternSchema, z.array(onePatternSchema).min(1
4740
4760
  * reuses it.
4741
4761
  *
4742
4762
  * Both members are strict, so a journey holding the retired `source` key
4743
- * fails to compile a release instead of silently triggering on every app.
4763
+ * fails to compile instead of silently triggering on every app.
4744
4764
  * There is deliberately no pipe filter: a trigger narrows by the app the
4745
4765
  * write is attributed to, the same token a segment definition names.
4746
4766
  */
@@ -4809,6 +4829,13 @@ const spineEntrySchema = z.object({
4809
4829
  return z.array(spineEntrySchema).optional();
4810
4830
  }
4811
4831
  }).meta({ id: "JourneySpineEntry" });
4832
+ /**
4833
+ * One journey in a stored manifest. A plain (non-strict) object on purpose:
4834
+ * a manifest pushed before the author's rollout gate went away still carries
4835
+ * `environments`, and every stored manifest has to keep parsing for good
4836
+ * (ADR 0011). Zod strips the key, and the journey runs wherever the
4837
+ * per-environment enabled flag says it runs, which is the one gate there is.
4838
+ */
4812
4839
  const manifestJourneySchema = z.object({
4813
4840
  key: journeyKeySchema,
4814
4841
  /** The author's labels; the dashboard's only grouping. */
@@ -4816,7 +4843,7 @@ const manifestJourneySchema = z.object({
4816
4843
  trigger: triggerSchema,
4817
4844
  /**
4818
4845
  * A fixed purpose or one the project declares. Checked against the org's
4819
- * declared set at deploy time, not here: a manifest is built and pushed
4846
+ * declared set at push time, not here: a manifest is built and pushed
4820
4847
  * without ever reaching the org whose rows say what exists.
4821
4848
  */
4822
4849
  purpose: consentPurposeKeySchema,
@@ -4827,12 +4854,10 @@ const manifestJourneySchema = z.object({
4827
4854
  * development, which is the one thing a journey must not do.
4828
4855
  *
4829
4856
  * Optional here and required at `defineJourney`, exactly like `purposes`: a
4830
- * release pushed before the field existed carries none and its stored
4857
+ * version pushed before the field existed carries none and its stored
4831
4858
  * manifest still parses. The author's build is where the error is useful.
4832
4859
  */
4833
4860
  senderIdentity: destinationNameSchema.optional(),
4834
- /** The author's rollout gate: the journey is active only in these. */
4835
- environments: environmentsSchema,
4836
4861
  spine: z.array(spineEntrySchema),
4837
4862
  bundle: digestSchema
4838
4863
  });
@@ -4864,27 +4889,27 @@ function uniqueKeys(items, ctx, path) {
4864
4889
  }
4865
4890
  /**
4866
4891
  * What `cow build` writes to `.cow/build/manifest.json`, and the shape a
4867
- * release row stores for good.
4892
+ * push row stores for good, entry by entry, on the versions it creates.
4868
4893
  *
4869
4894
  * `protocol` is any positive integer rather than the current constant on
4870
- * purpose: a release is immutable and an execution stays pinned to the one it
4871
- * started on, so the day the protocol is bumped every stored release must
4872
- * still parse, or the runner, the deploy, and every API response the client
4873
- * validates all break at once for any org with history. The literal lives at
4874
- * push time only (`createReleaseBodySchema`), which is where a stale CLI is
4875
- * the developer's own fixable problem, and deploy plus the runner refuse a
4876
- * release built for another protocol with a message naming both numbers.
4895
+ * purpose: a version is immutable and an execution stays pinned to the one
4896
+ * it started on, so the day the protocol is bumped every stored push must
4897
+ * still parse, or the runner and every API response the client validates
4898
+ * break at once for any org with history. The literal lives at push time
4899
+ * only (`createPushBodySchema`), which is where a stale CLI is the
4900
+ * developer's own fixable problem, and the runner refuses a version built
4901
+ * for another protocol with a message naming both numbers.
4877
4902
  */
4878
4903
  const manifestSchema = z.object({
4879
4904
  protocol: z.number().int().positive(),
4880
4905
  /** The `@cowliss/cli` version the project was built with. */
4881
4906
  sdk: z.string().min(1),
4882
- journeys: z.array(manifestJourneySchema).max(RELEASE_LIMITS.journeys),
4883
- templates: z.array(manifestTemplateSchema).max(RELEASE_LIMITS.templates),
4907
+ journeys: z.array(manifestJourneySchema).max(PUSH_LIMITS.journeys),
4908
+ templates: z.array(manifestTemplateSchema).max(PUSH_LIMITS.templates),
4884
4909
  /**
4885
4910
  * The consent purposes this project declares, copied from `cow.json`.
4886
- * Optional rather than defaulted: a release pushed before purposes
4887
- * existed carries none, and its stored manifest still parses.
4911
+ * Optional rather than defaulted: a push made before purposes existed
4912
+ * carries none, and its stored manifest still parses.
4888
4913
  */
4889
4914
  purposes: purposesSchema.optional(),
4890
4915
  /** Digest of the gzipped source tarball. */
@@ -4895,27 +4920,12 @@ const manifestSchema = z.object({
4895
4920
  uniqueKeys(manifest.purposes ?? [], ctx, "purposes");
4896
4921
  });
4897
4922
  /**
4898
- * The manifest as the release row stores it once compilation succeeded:
4899
- * the pushed manifest plus the compiled module digest per key, and the
4900
- * digest of the Javy engine plugin the toolchain that compiled them was
4901
- * built from.
4902
- *
4903
- * Journeys and templates are keyed separately because they share a key
4904
- * space: `welcome.ts` and `welcome.tsx` are one journey and the email it
4905
- * sends in every example, and a flat map would let one overwrite the other.
4906
- *
4907
- * `plugin` is a toolchain record, not a linked artifact: modules are
4908
- * statically linked, so the plugin bytes are inside each module. It says
4909
- * which engine compiled the release, which is what a later bug report or a
4910
- * reproducible rebuild needs.
4923
+ * One entry of a stored manifest: what a version is a snapshot of. Journeys
4924
+ * and templates are kept apart because they share a key space (`welcome.ts`
4925
+ * and `welcome.tsx` are one journey and the email it sends in every
4926
+ * example), and the version's own `kind` says which of the two this is.
4911
4927
  */
4912
- const compiledManifestSchema = manifestSchema.safeExtend({
4913
- modules: z.object({
4914
- journeys: z.record(journeyKeySchema, digestSchema),
4915
- templates: z.record(journeyKeySchema, digestSchema)
4916
- }),
4917
- plugin: digestSchema
4918
- });
4928
+ const versionManifestSchema = z.union([manifestJourneySchema, manifestTemplateSchema]);
4919
4929
 
4920
4930
  //#endregion
4921
4931
  //#region ../../packages/shared/src/timestamp.ts
@@ -5543,6 +5553,8 @@ const listDeliveriesQuerySchema = paginationQuerySchema.extend({
5543
5553
  direction: sortDirectionSchema.default("desc"),
5544
5554
  status: deliveryStatusSchema.optional(),
5545
5555
  journey: z.string().trim().min(1).max(200).optional(),
5556
+ /** The template or destination this attempt was for; a template page's own log. */
5557
+ step: z.string().trim().min(1).max(200).optional(),
5546
5558
  channel: deliveryChannelSchema.optional(),
5547
5559
  appId: z.string().trim().min(1).max(200).optional(),
5548
5560
  /** One person's deliveries, merged ids included, same as the event feed. */
@@ -5703,6 +5715,20 @@ const domainDnsSetupSchema = z.object({
5703
5715
  provider: z.string().nullable()
5704
5716
  });
5705
5717
 
5718
+ //#endregion
5719
+ //#region ../../packages/shared/src/enabled.ts
5720
+ /**
5721
+ * The enable flag per environment; absent rows read as off (ADR 0011).
5722
+ *
5723
+ * Its own module because both `journeys.ts` and `pushes.ts` need it and
5724
+ * neither may import the other: a journey carries the flag, and so does the
5725
+ * project status a push is read back through.
5726
+ */
5727
+ const journeyEnabledSchema = z.object({
5728
+ development: z.boolean(),
5729
+ production: z.boolean()
5730
+ });
5731
+
5706
5732
  //#endregion
5707
5733
  //#region ../../packages/shared/src/patterns.ts
5708
5734
  /**
@@ -5999,7 +6025,6 @@ const manifestOutputSchema = z.discriminatedUnion("kind", [manifestJourneySchema
5999
6025
  trigger: true,
6000
6026
  purpose: true,
6001
6027
  senderIdentity: true,
6002
- environments: true,
6003
6028
  tags: true
6004
6029
  }).extend({ kind: z.literal("journey") }), manifestTemplateSchema.pick({
6005
6030
  sendClass: true,
@@ -6030,8 +6055,8 @@ const executionStatusSchema = z.enum(EXECUTION_STATUSES);
6030
6055
  //#endregion
6031
6056
  //#region ../../packages/shared/src/executions.ts
6032
6057
  /**
6033
- * Executions: one run of one journey for one profile, pinned to the release
6034
- * it started under. This replaces v1's journey instances, which were read
6058
+ * Executions: one run of one journey for one profile, pinned to the journey
6059
+ * version it started on. This replaces v1's journey instances, which were read
6035
6060
  * straight from Temporal: an execution row outlives its workflow's retention
6036
6061
  * and can be listed and filtered without a Temporal round trip, while
6037
6062
  * Temporal stays the source of truth for the run itself.
@@ -6065,7 +6090,8 @@ const executionDetailSchema = executionSchema.extend({
6065
6090
  const listExecutionsQuerySchema = paginationQuerySchema.extend({
6066
6091
  /** A journey key; the environment comes from the selection header. */
6067
6092
  journey: z.string().trim().min(1).max(200).optional(),
6068
- release: z.string().trim().min(1).max(200).optional(),
6093
+ /** One version's own runs, for the history rows on a journey. */
6094
+ version: z.string().trim().min(1).max(200).optional(),
6069
6095
  status: executionStatusSchema.optional(),
6070
6096
  profileId: z.string().trim().min(1).max(200).optional(),
6071
6097
  /**
@@ -6077,20 +6103,215 @@ const listExecutionsQuerySchema = paginationQuerySchema.extend({
6077
6103
  */
6078
6104
  since: z.iso.datetime().optional()
6079
6105
  });
6106
+ /**
6107
+ * What to stop: every live execution of one journey in the selected
6108
+ * environment, or the ones still pinned to one version. Cancelling is
6109
+ * explicit and per environment (ADR 0011): disabling a journey stops new
6110
+ * entries and lets what is running finish, and this is the other verb.
6111
+ */
6112
+ const cancelExecutionsBodySchema = z.object({ data: z.union([z.object({ journey: z.string().trim().min(1).max(200) }), z.object({ version: z.string().trim().min(1).max(200) })]) });
6113
+ /**
6114
+ * Cancelling is a request, not an edit: each execution stops when it reaches
6115
+ * its next step, so the answer is that the sweep is under way rather than a
6116
+ * count of what has already stopped.
6117
+ */
6118
+ const cancellingSchema = z.object({ cancelling: z.literal(true) });
6119
+
6120
+ //#endregion
6121
+ //#region ../../packages/shared/src/journeys-v2/config.ts
6122
+ /**
6123
+ * A project's name: what `cow init` asked for, and what the `projects` row
6124
+ * stores. Lives here rather than beside the release DTOs because `cow.json`
6125
+ * is the file a developer types it into; `createProjectBodySchema` reuses it.
6126
+ */
6127
+ const projectNameSchema = z.string().trim().min(1).max(200);
6128
+ /**
6129
+ * `cow.json` (spec: Project layout): the org, and which of its projects this
6130
+ * directory is. The environment is always a flag, and auth never lives in
6131
+ * the project. Strict, so a typo'd key is a build error rather than a
6132
+ * silently ignored setting.
6133
+ */
6134
+ const cowConfigSchema = z.strictObject({
6135
+ $schema: z.url().optional(),
6136
+ orgId: z.string().startsWith(CLERK_ORG_SUBJECT_PREFIX, "orgId must be a Clerk org id"),
6137
+ /**
6138
+ * Which project of the org this directory pushes to. One org can hold
6139
+ * several, one per repo, each with its own release sequence and its own
6140
+ * slice of the deployed journeys, so every project says which it is.
6141
+ */
6142
+ project: projectNameSchema,
6143
+ /**
6144
+ * The consent purposes this project declares. They are org-wide, so two
6145
+ * projects declaring one key must agree on its label and default or the
6146
+ * deploy is refused; a deploy adds and updates them and never deletes
6147
+ * one, because profiles hold answers against them.
6148
+ */
6149
+ purposes: purposesSchema.optional(),
6150
+ /** Overrides the API the CLI talks to; the hosted product needs none. */
6151
+ apiUrl: z.url().optional(),
6152
+ /**
6153
+ * Overrides the dashboard `cow login` opens. Paired with `apiUrl`: a
6154
+ * login against one Cowliss's dashboard yields a token the other's API
6155
+ * rejects, so a config that names an API names its dashboard too.
6156
+ */
6157
+ webUrl: z.url().optional()
6158
+ }).meta({
6159
+ title: "cow.json",
6160
+ description: "A cow project: the organization and the project it pushes to."
6161
+ });
6162
+
6163
+ //#endregion
6164
+ //#region ../../packages/shared/src/pushes.ts
6165
+ /**
6166
+ * The push lifecycle's wire contracts (ADR 0011): the project a developer
6167
+ * runs `cow init` in, the artifacts `cow push` uploads, the push itself, and
6168
+ * the versions its compile produces.
6169
+ *
6170
+ * Row-derived through drizzle-zod where a row is what the wire carries, so
6171
+ * the DTO cannot drift from the column set; the jsonb manifest gets its
6172
+ * explicit shared schema, because the column type is opaque to drizzle-zod.
6173
+ */
6174
+ const projectSchema = selectProjectSchema.extend({
6175
+ createdAt: z.iso.datetime(),
6176
+ updatedAt: z.iso.datetime()
6177
+ });
6178
+ /** `cow init` names the project after the folder it created. */
6179
+ const createProjectBodySchema = z.object({ data: z.object({ name: projectNameSchema }) });
6180
+ /** Which project to read: the one the caller's `cow.json` names. */
6181
+ const getProjectQuerySchema = z.object({ name: projectNameSchema });
6182
+ const artifactDigestParams = z.object({ digest: digestSchema });
6183
+ /**
6184
+ * Which kind of artifact the uploaded bytes are. Named by the client rather
6185
+ * than sniffed, because only the build knows whether a blob is a journey
6186
+ * bundle or the source tarball, and the value is metadata for the prune
6187
+ * sweep and the sandbox's cache rather than a trust boundary: what a module
6188
+ * is, is decided by the server-side compile step. `module` is absent on
6189
+ * purpose, since only that compile step ever writes one.
6190
+ */
6191
+ const putArtifactQuerySchema = z.object({ kind: z.enum(["bundle", "source"]) });
6192
+ /**
6193
+ * One version as the API reports it: the immutable snapshot of one journey
6194
+ * or one template, with the manifest entry it was built from. `manifest` is
6195
+ * the pushed entry either way; which of the two shapes it is follows `kind`.
6196
+ */
6197
+ const versionSchema = selectVersionSchema.extend({
6198
+ manifest: z.union([manifestJourneySchema, manifestTemplateSchema]),
6199
+ pushedAt: z.iso.datetime(),
6200
+ compiledAt: z.iso.datetime().nullable()
6201
+ });
6202
+ /**
6203
+ * A version as another resource points at it: what the dashboard's "latest
6204
+ * version" column shows (the short digest, when it was pushed, whether it
6205
+ * compiled) and the push its source archive lives on. The manifest entry is
6206
+ * left out, because a journey row already carries what its own entry says.
6207
+ */
6208
+ const versionSummarySchema = selectVersionSchema.pick({
6209
+ id: true,
6210
+ status: true,
6211
+ moduleDigest: true,
6212
+ pushId: true,
6213
+ projectId: true,
6214
+ error: true
6215
+ }).extend({ pushedAt: z.iso.datetime() });
6216
+ /**
6217
+ * A push as the API reports it, with the versions it created: `cow push`
6218
+ * returns as soon as the upload is stored, so what is compiling, what is
6219
+ * ready and what failed is read back from here.
6220
+ *
6221
+ * `compiledEntries` and `totalEntries` are not columns: they are the settled
6222
+ * and the total count of those versions, so a counter can never disagree
6223
+ * with the rows it counts. A key whose module did not change gets no new
6224
+ * version, which is why the denominator is the changed keys rather than the
6225
+ * manifest's length.
6226
+ */
6227
+ const pushSchema = selectPushSchema.extend({
6228
+ manifest: manifestSchema,
6229
+ versions: z.array(versionSchema),
6230
+ compiledEntries: z.number().int(),
6231
+ totalEntries: z.number().int(),
6232
+ createdAt: z.iso.datetime()
6233
+ });
6234
+ const createPushBodySchema = z.object({ data: z.object({
6235
+ /**
6236
+ * The one place the protocol is pinned to the constant. The stored shape
6237
+ * takes any positive integer, because a version outlives a bump; a push
6238
+ * is a live CLI talking to a live platform, so a mismatch here is a 422
6239
+ * the developer fixes by updating `@cowliss/cli`, and nothing is stored.
6240
+ */
6241
+ manifest: manifestSchema.safeExtend({ protocol: z.literal(2) }),
6242
+ /** The `cow.json` project this push belongs to. */
6243
+ project: projectNameSchema
6244
+ }) });
6245
+ const listPushesQuerySchema = paginationQuerySchema.extend({
6246
+ /** One project's own pushes; absent lists the org's. */
6247
+ project: projectNameSchema.optional() });
6248
+ /**
6249
+ * One key's version history, newest first. Both the key and the kind are
6250
+ * required: journeys and templates share a key space, so `welcome` alone
6251
+ * names two different histories.
6252
+ */
6253
+ const listVersionsQuerySchema = paginationQuerySchema.extend({
6254
+ key: z.string().trim().min(1).max(200),
6255
+ kind: z.enum(["journey", "template"])
6256
+ });
6257
+ /**
6258
+ * One key of a project as `cow status` and the MCP `status` tool report it:
6259
+ * the code the server holds for it, whether it is on, and what is still
6260
+ * running. Journeys and templates share the shape, because a developer asks
6261
+ * the same question of both; the two fields only a journey has are null on a
6262
+ * template.
6263
+ */
6264
+ const projectStatusKeySchema = z.object({
6265
+ kind: z.enum(["journey", "template"]),
6266
+ key: z.string(),
6267
+ /**
6268
+ * The newest version of this key, whatever its compile state, which is
6269
+ * what says whether the tree in front of the developer is the code the
6270
+ * server holds. Null only for a journey whose versions are all pruned.
6271
+ */
6272
+ latestVersion: versionSchema.nullable(),
6273
+ /** The flag per environment; null on a template, which has none. */
6274
+ enabled: journeyEnabledSchema.nullable(),
6275
+ /** Executions still running or waiting, per environment. */
6276
+ liveExecutions: z.object({
6277
+ development: z.number().int(),
6278
+ production: z.number().int()
6279
+ })
6280
+ });
6281
+ /**
6282
+ * Everything the server knows about one project's keys, in one read: the
6283
+ * whole project rather than a page of it, because a manifest holds at most
6284
+ * `PUSH_LIMITS.journeys + PUSH_LIMITS.templates` keys and the answer to "what
6285
+ * is my project doing" is useless split across cursors.
6286
+ *
6287
+ * `warnings` is what a deploy used to answer with (ADR 0011 retired the
6288
+ * deploy): a name a journey references that the environment does not define
6289
+ * yet. They never block, because a segment or a destination may be created
6290
+ * right after a push.
6291
+ */
6292
+ const projectStatusSchema = z.object({
6293
+ project: projectNameSchema,
6294
+ keys: z.array(projectStatusKeySchema),
6295
+ warnings: z.object({
6296
+ development: z.array(z.string()),
6297
+ production: z.array(z.string())
6298
+ })
6299
+ });
6080
6300
 
6081
6301
  //#endregion
6082
6302
  //#region ../../packages/shared/src/journeys.ts
6083
6303
  /**
6084
- * Journeys as the API reports them: the rows a deploy derives from the
6085
- * environment's current release, one per (org, environment, key). Nothing
6086
- * here is authored through the API, so the only write is the enable toggle.
6304
+ * Journeys as the API reports them: the rows a push derives from its
6305
+ * manifest, one per (org, key). Nothing here is authored through the API, so
6306
+ * the only write is the enable flag.
6087
6307
  *
6088
6308
  * Derived from the Drizzle table via drizzle-zod so the wire DTO and the row
6089
6309
  * share one source of truth; the jsonb columns get explicit wire schemas
6090
6310
  * because the row types are opaque to drizzle-zod. `enabled` is not a
6091
- * column: it is the effective journey_states value for the caller's org and
6092
- * environment (default true, see the schema comment on journey_states in
6093
- * packages/db).
6311
+ * column: it is the journey_states value per environment, absent meaning
6312
+ * off, and both environments are reported whichever one the caller selected,
6313
+ * because "is this on in production?" is the question the list exists to
6314
+ * answer.
6094
6315
  */
6095
6316
  const journeyTriggerSchema = triggerSchema;
6096
6317
  /**
@@ -6103,23 +6324,26 @@ const journeySchema = selectJourneySchema.extend({
6103
6324
  trigger: journeyTriggerSchema,
6104
6325
  purpose: consentPurposeKeySchema,
6105
6326
  spine: z.array(journeySpineEntrySchema),
6106
- enabled: z.boolean(),
6327
+ enabled: journeyEnabledSchema,
6328
+ /**
6329
+ * The newest version of this key, whatever it compiled to, so a list can
6330
+ * say "pushed 2 hours ago" and "compiling" without a second read. What the
6331
+ * journey RUNS is the newest one that finished compiling, which is this
6332
+ * one unless it is still compiling or failed. Null only for a key whose
6333
+ * versions have all been pruned.
6334
+ */
6335
+ latestVersion: versionSummarySchema.nullable(),
6107
6336
  createdAt: z.iso.datetime(),
6108
6337
  updatedAt: z.iso.datetime()
6109
6338
  });
6110
6339
  /**
6111
- * Enable/disable is the only operator write on a journey, and it is per org
6112
- * and per environment: the environment comes from the selection header, not
6113
- * from the body, so the toggle acts on whichever one the caller is in.
6114
- */
6115
- const updateJourneyBodySchema = z.object({ data: z.object({ enabled: z.boolean() }) });
6116
- /**
6117
6340
  * Journey list query. `q` is a substring search over the key and the tags,
6118
6341
  * the two things an author names a journey by; `tag` is "has this tag",
6119
6342
  * exact and case-sensitive, so a badge in the table is the way into it.
6120
6343
  * Both are server-side, like every other list filter.
6121
6344
  */
6122
6345
  const listJourneysQuerySchema = paginationQuerySchema.extend({
6346
+ /** On or off in the environment the call selected. */
6123
6347
  enabled: z.enum(["true", "false"]).optional(),
6124
6348
  q: z.string().trim().max(200, "q must be at most 200 characters").optional().transform((value) => value ? value : void 0),
6125
6349
  tag: z.string().trim().max(50, "tag must be at most 50 characters").optional()
@@ -6171,7 +6395,21 @@ const userJourneySchema = z.object({
6171
6395
  });
6172
6396
  const listUserJourneysQuerySchema = paginationQuerySchema;
6173
6397
  /**
6174
- * Dry run: one real profile, the environment's current release, and every
6398
+ * Which journeys the flag acts on: the keys the caller named, or every
6399
+ * journey of one project. Exactly one of the two, because a call carrying
6400
+ * both would have to decide which it meant.
6401
+ *
6402
+ * A list rather than one key per call so every surface can do what `cow
6403
+ * enable` does: name several keys, or `--all` (which is `project`, resolved
6404
+ * server-side, so an agent turning a project on is one call too).
6405
+ */
6406
+ const setJourneysEnabledBodySchema = z.object({ data: z.object({
6407
+ keys: z.array(journeyKeySchema).min(1).max(PUSH_LIMITS.journeys).optional(),
6408
+ /** Every journey of this project, by the name `cow.json` carries. */
6409
+ project: projectNameSchema.optional()
6410
+ }).refine((data) => data.keys === void 0 !== (data.project === void 0), { message: "name keys or a project, not both" }) });
6411
+ /**
6412
+ * Dry run: one real profile, the journey's latest ready version, and every
6175
6413
  * send gated into a `would_*` row instead of a message. The body is the v1
6176
6414
  * one; the response is the started execution, because executions are rows
6177
6415
  * now and a v1 instance was a Temporal read.
@@ -6179,56 +6417,13 @@ const listUserJourneysQuerySchema = paginationQuerySchema;
6179
6417
  const dryRunJourneyBodySchema = z.object({ data: z.object({ profileId: z.string().min(1) }) });
6180
6418
 
6181
6419
  //#endregion
6182
- //#region ../../packages/shared/src/journeys-v2/config.ts
6420
+ //#region ../../packages/shared/src/journeys-v2/sandbox.ts
6183
6421
  /**
6184
- * A project's name: what `cow init` asked for, and what the `projects` row
6185
- * stores. Lives here rather than beside the release DTOs because `cow.json`
6186
- * is the file a developer types it into; `createProjectBodySchema` reuses it.
6187
- */
6188
- const projectNameSchema = z.string().trim().min(1).max(200);
6189
- /**
6190
- * `cow.json` (spec: Project layout): the org, and which of its projects this
6191
- * directory is. The environment is always a flag, and auth never lives in
6192
- * the project. Strict, so a typo'd key is a build error rather than a
6193
- * silently ignored setting.
6194
- */
6195
- const cowConfigSchema = z.strictObject({
6196
- $schema: z.url().optional(),
6197
- orgId: z.string().startsWith(CLERK_ORG_SUBJECT_PREFIX, "orgId must be a Clerk org id"),
6198
- /**
6199
- * Which project of the org this directory pushes to. One org can hold
6200
- * several, one per repo, each with its own release sequence and its own
6201
- * slice of the deployed journeys, so every project says which it is.
6202
- */
6203
- project: projectNameSchema,
6204
- /**
6205
- * The consent purposes this project declares. They are org-wide, so two
6206
- * projects declaring one key must agree on its label and default or the
6207
- * deploy is refused; a deploy adds and updates them and never deletes
6208
- * one, because profiles hold answers against them.
6209
- */
6210
- purposes: purposesSchema.optional(),
6211
- /** Overrides the API the CLI talks to; the hosted product needs none. */
6212
- apiUrl: z.url().optional(),
6213
- /**
6214
- * Overrides the dashboard `cow login` opens. Paired with `apiUrl`: a
6215
- * login against one Cowliss's dashboard yields a token the other's API
6216
- * rejects, so a config that names an API names its dashboard too.
6217
- */
6218
- webUrl: z.url().optional()
6219
- }).meta({
6220
- title: "cow.json",
6221
- description: "A cow project: the organization and the project it deploys to."
6222
- });
6223
-
6224
- //#endregion
6225
- //#region ../../packages/shared/src/journeys-v2/sandbox.ts
6226
- /**
6227
- * What the sandbox worker's activities return (spec: Sandbox worker). A
6228
- * guest failure is a value, not a throw: the runner has to journal it and
6229
- * record it on the execution, and a Temporal retry would only reproduce it.
6230
- * Infrastructure failures (a missing binary, a spawn error, Postgres down)
6231
- * throw and retry on the activity's bounded policy.
6422
+ * What the sandbox worker's activities return (spec: Sandbox worker). A
6423
+ * guest failure is a value, not a throw: the runner has to journal it and
6424
+ * record it on the execution, and a Temporal retry would only reproduce it.
6425
+ * Infrastructure failures (a missing binary, a spawn error, Postgres down)
6426
+ * throw and retry on the activity's bounded policy.
6232
6427
  */
6233
6428
  /** Every way a guest invocation can fail, as the runner records it. */
6234
6429
  const SANDBOX_FAILURE_CODES = [
@@ -6320,115 +6515,6 @@ const notificationPreferencesSchema = z.object({ purposes: z.array(notificationP
6320
6515
  */
6321
6516
  const updateNotificationPreferencesBodySchema = z.object({ data: z.object({ purposes: consentPatchSchema }) });
6322
6517
 
6323
- //#endregion
6324
- //#region ../../packages/shared/src/releases.ts
6325
- /**
6326
- * The release lifecycle's wire contracts (spec: Releases, deployments,
6327
- * artifacts): the project a developer runs `cow init` in, the artifacts
6328
- * `cow push` uploads, the releases it creates, and the deployments that make
6329
- * one of them the environment's current code.
6330
- *
6331
- * Row-derived through drizzle-zod where a row is what the wire carries, so
6332
- * the DTO cannot drift from the column set; the jsonb manifest gets its
6333
- * explicit shared schema, because the column type is opaque to drizzle-zod.
6334
- */
6335
- const projectSchema = selectProjectSchema.extend({
6336
- createdAt: z.iso.datetime(),
6337
- updatedAt: z.iso.datetime()
6338
- });
6339
- /** `cow init` names the project after the folder it created. */
6340
- const createProjectBodySchema = z.object({ data: z.object({ name: projectNameSchema }) });
6341
- /** Which project to read: the one the caller's `cow.json` names. */
6342
- const getProjectQuerySchema = z.object({ name: projectNameSchema });
6343
- const artifactDigestParams = z.object({ digest: digestSchema });
6344
- /**
6345
- * Which kind of artifact the uploaded bytes are. Named by the client rather
6346
- * than sniffed, because only the build knows whether a blob is a journey
6347
- * bundle or the source tarball, and the value is metadata for the prune
6348
- * sweep and the sandbox's cache rather than a trust boundary: what a module
6349
- * is, is decided by the server-side compile step. `module` is absent on
6350
- * purpose, since only that compile step ever writes one.
6351
- */
6352
- const putArtifactQuerySchema = z.object({ kind: z.enum(["bundle", "source"]) });
6353
- /**
6354
- * A release as the API reports it. The manifest is the pushed one while the
6355
- * release is `pending` or `failed`, and the compiled one (module digests per
6356
- * key, plus the engine plugin digest) once it is `ready`; the union says so
6357
- * rather than making the compiled fields optional on one shape.
6358
- *
6359
- * `deployedIn` is not a column: it is the environments whose latest
6360
- * deployment names this release, computed on read, so it can never fall out
6361
- * of sync with the append-only deployment log. Neither is `totalEntries`:
6362
- * the manifest is already here and its journeys plus its templates are the
6363
- * denominator, so storing it beside `compiledEntries` would only invite the
6364
- * two to disagree.
6365
- */
6366
- const releaseSchema = selectReleaseSchema.extend({
6367
- manifest: z.union([compiledManifestSchema, manifestSchema]),
6368
- deployedIn: z.array(environmentSchema),
6369
- /**
6370
- * How many journeys and templates the manifest holds: the denominator
6371
- * `compiledEntries` climbs towards, counted on read rather than stored.
6372
- */
6373
- totalEntries: z.number().int(),
6374
- createdAt: z.iso.datetime(),
6375
- compiledAt: z.iso.datetime().nullable()
6376
- });
6377
- const createReleaseBodySchema = z.object({ data: z.object({
6378
- /**
6379
- * The one place the protocol is pinned to the constant. The stored shape
6380
- * takes any positive integer, because a release outlives a bump; a push
6381
- * is a live CLI talking to a live platform, so a mismatch here is a 422
6382
- * the developer fixes by updating `@cowliss/cli`, and nothing is stored.
6383
- */
6384
- manifest: manifestSchema.safeExtend({ protocol: z.literal(2) }),
6385
- /** The `cow.json` project this push belongs to. */
6386
- project: projectNameSchema
6387
- }) });
6388
- const listReleasesQuerySchema = paginationQuerySchema.extend({
6389
- /** One project's own releases; absent lists the org's. */
6390
- project: projectNameSchema.optional() });
6391
- /**
6392
- * How many live executions the cancel sweep was asked to stop, and the
6393
- * workflow doing the stopping. The count is taken when the request lands, so
6394
- * it is the size of the job rather than a result: the sweep is asynchronous
6395
- * and the runner records `cancelled` as each execution reaches its next
6396
- * step. A second call while a sweep runs answers with that sweep's id.
6397
- */
6398
- const cancelExecutionsSchema = z.object({
6399
- releaseId: z.string(),
6400
- environment: environmentSchema,
6401
- requested: z.number().int(),
6402
- workflowId: z.string()
6403
- });
6404
- /**
6405
- * A deployment, plus the warnings the deploy raised: segment names in
6406
- * triggers and destination names in spines that do not exist in the target
6407
- * environment. They never block, because either may be created afterwards.
6408
- * `left` is the other half of the same conversation: what the deploy did not
6409
- * touch, because a deploy only ever adds and updates (ADR 0009).
6410
- */
6411
- const deploymentSchema = selectDeploymentSchema.extend({
6412
- createdAt: z.iso.datetime(),
6413
- warnings: z.array(z.string()),
6414
- /**
6415
- * Journeys this project still runs in the environment that the deployed
6416
- * release does not carry. A deploy never removes one, so these keep running
6417
- * until someone deletes them; the CLI prints them and says how.
6418
- */
6419
- left: z.array(z.string()),
6420
- /** The deployed release's per-project sequence number, its human handle. */
6421
- releaseSeq: z.number().int()
6422
- });
6423
- const createDeploymentBodySchema = z.object({ data: z.object({ releaseId: z.string().min(1) }) });
6424
- const listDeploymentsQuerySchema = paginationQuerySchema.extend({
6425
- environment: environmentSchema.optional(),
6426
- /** One release's own deployment history, across both environments. */
6427
- release: z.string().trim().min(1).max(200).optional(),
6428
- /** One project's own deployments; absent lists the org's. */
6429
- project: projectNameSchema.optional()
6430
- });
6431
-
6432
6518
  //#endregion
6433
6519
  //#region ../../packages/shared/src/violations.ts
6434
6520
  /**
@@ -6807,6 +6893,54 @@ const suppressedAddressSchema = selectSuppressedAddressSchema.extend({
6807
6893
  */
6808
6894
  const listSuppressionsQuerySchema = paginationQuerySchema.extend({ q: searchQuerySchema });
6809
6895
 
6896
+ //#endregion
6897
+ //#region ../../packages/shared/src/templates.ts
6898
+ /**
6899
+ * Templates as the API reports them. A template has versions and nothing
6900
+ * else (ADR 0011): no row of its own, no environment, no flag. What is
6901
+ * reported here is one key, described by its newest version, plus the
6902
+ * journeys whose steps send it.
6903
+ *
6904
+ * The descriptive fields come from that version's manifest entry rather than
6905
+ * from a column, which is what keeps them true for a key whose latest push
6906
+ * is still compiling: the entry describes the code that was pushed, and the
6907
+ * send resolves the latest ready version on its own at send time.
6908
+ */
6909
+ const templateSchema = z.object({
6910
+ key: journeyKeySchema,
6911
+ /** The project whose push owns this key (ADR 0009). */
6912
+ projectId: z.string(),
6913
+ /** The author's labels; the dashboard's only grouping. */
6914
+ tags: tagsSchema,
6915
+ sendClass: z.enum(SEND_CLASSES),
6916
+ /** True when the template asks for a signed `verifyUrl` prop at send time. */
6917
+ verifyLink: z.boolean(),
6918
+ /** JSON Schema of the template's props, as `cow build` converted them. */
6919
+ propsSchema: z.record(z.string(), z.unknown()),
6920
+ /** The newest version of this key, whatever it compiled to. */
6921
+ latestVersion: versionSummarySchema,
6922
+ /** Keys of the journeys whose steps send this template. */
6923
+ journeys: z.array(z.string())
6924
+ });
6925
+ /**
6926
+ * Template list query. `q` is a substring search over the key, which is the
6927
+ * name the author gave the file. Tags are not a filter here the way they are
6928
+ * on journeys: a template has no row, so its tags live inside the version's
6929
+ * manifest entry, and searching inside that is a query the database layer
6930
+ * would have to grow a helper for. The badges in each row still show them.
6931
+ */
6932
+ const listTemplatesQuerySchema = paginationQuerySchema.extend({ q: z.string().trim().max(200, "q must be at most 200 characters").optional().transform((value) => value ? value : void 0) });
6933
+ /** The props a preview or a test send renders the template with. */
6934
+ const renderTemplateBodySchema = z.object({ data: z.object({ props: z.record(z.string(), z.unknown()).default({}) }) });
6935
+ /** What the sandbox rendered: the message as it would go out. */
6936
+ const templatePreviewSchema = z.object({
6937
+ subject: z.string(),
6938
+ html: z.string(),
6939
+ text: z.string(),
6940
+ /** The version that rendered it: the latest one that finished compiling. */
6941
+ versionId: z.string()
6942
+ });
6943
+
6810
6944
  //#endregion
6811
6945
  //#region ../../packages/shared/src/users.ts
6812
6946
  /**
@@ -7055,28 +7189,11 @@ async function clearCredentials$1(path = defaultCredentialsPath()) {
7055
7189
  }
7056
7190
  }
7057
7191
 
7058
- //#endregion
7059
- //#region ../../packages/shared/src/stable-json.ts
7060
- /**
7061
- * A key-sorted JSON rendering, for comparing or hashing two values a JSON
7062
- * round trip may have reordered. `undefined` members are dropped the way
7063
- * `JSON.stringify` drops them.
7064
- *
7065
- * Its own module with no imports at all, because both users need it and
7066
- * they sit on opposite sides of a boundary: the release digest (which pulls
7067
- * in node:crypto) and the compile workflow (which may not pull in anything).
7068
- */
7069
- function stableJson(value) {
7070
- if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
7071
- 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(",")}}`;
7072
- return JSON.stringify(value) ?? "null";
7073
- }
7074
-
7075
7192
  //#endregion
7076
7193
  //#region ../../packages/shared/src/digest.ts
7077
7194
  /**
7078
7195
  * `sha256:` plus the lowercase hex digest of these exact bytes: how every
7079
- * artifact in the release lifecycle is named. `cow build` computes it over
7196
+ * artifact a push uploads is named. `cow build` computes it over
7080
7197
  * each bundle and the source tarball, and the API recomputes it over an
7081
7198
  * upload's body before storing it, so the two must agree byte for byte.
7082
7199
  *
@@ -7087,16 +7204,6 @@ function stableJson(value) {
7087
7204
  function digestOf(bytes) {
7088
7205
  return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
7089
7206
  }
7090
- /**
7091
- * The digest of a release manifest, computed over a key-sorted rendering so
7092
- * `cow push` and the API agree on the value whatever order the JSON arrived
7093
- * in. It is what decides "this tree is already released": a disagreement
7094
- * would silently turn the unchanged-tree skip off, which is why neither side
7095
- * hashes `JSON.stringify(manifest)` directly.
7096
- */
7097
- function manifestDigestOf(manifest) {
7098
- return digestOf(stableJson(manifest));
7099
- }
7100
7207
 
7101
7208
  //#endregion
7102
7209
  //#region src/lib/package.ts
@@ -7492,7 +7599,7 @@ const TYPES_FILE = join(".cow", "types.d.ts");
7492
7599
  * release records the project as the repo declares it, not the local file
7493
7600
  * whoever pushed happened to point at.
7494
7601
  */
7495
- const CONFIG_FILES$1 = [
7602
+ const CONFIG_FILES = [
7496
7603
  "cow.json",
7497
7604
  "package.json",
7498
7605
  "tsconfig.json"
@@ -7553,7 +7660,7 @@ async function listFiles(dir) {
7553
7660
  */
7554
7661
  async function projectSources(projectDir) {
7555
7662
  return [
7556
- ...(await Promise.all(CONFIG_FILES$1.map(async (file) => await stat(join(projectDir, file)).then(() => file, () => null)))).filter((file) => file !== null),
7663
+ ...(await Promise.all(CONFIG_FILES.map(async (file) => await stat(join(projectDir, file)).then(() => file, () => null)))).filter((file) => file !== null),
7557
7664
  ...(await listFiles(join(projectDir, "journeys"))).map((file) => `journeys/${file}`),
7558
7665
  ...(await listFiles(join(projectDir, "emails"))).map((file) => `emails/${file}`)
7559
7666
  ].sort();
@@ -7779,12 +7886,12 @@ function emptyProfile(id = "", traits = {}, purposes = []) {
7779
7886
  segments: []
7780
7887
  };
7781
7888
  }
7782
- /** The release limits (spec: Limits), naming the offending count or size. */
7889
+ /** The push limits (spec: Limits), naming the offending count or size. */
7783
7890
  function limitFailure(sizes) {
7784
- if (sizes.journeys > RELEASE_LIMITS.journeys) return `This project has ${sizes.journeys} journeys; a release carries at most ${RELEASE_LIMITS.journeys}.`;
7785
- if (sizes.templates > RELEASE_LIMITS.templates) return `This project has ${sizes.templates} templates; a release carries at most ${RELEASE_LIMITS.templates}.`;
7786
- for (const bundle of sizes.bundles) if (bundle.bytes > RELEASE_LIMITS.bundleBytes) return `Bundle "${bundle.key}" is ${bundle.bytes} bytes; a bundle is at most ${RELEASE_LIMITS.bundleBytes}.`;
7787
- if (sizes.sourceBytes > RELEASE_LIMITS.sourceBytes) return `The source tarball is ${sizes.sourceBytes} bytes; a release carries at most ${RELEASE_LIMITS.sourceBytes}.`;
7891
+ if (sizes.journeys > PUSH_LIMITS.journeys) return `This project has ${sizes.journeys} journeys; a push carries at most ${PUSH_LIMITS.journeys}.`;
7892
+ if (sizes.templates > PUSH_LIMITS.templates) return `This project has ${sizes.templates} templates; a push carries at most ${PUSH_LIMITS.templates}.`;
7893
+ 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}.`;
7894
+ if (sizes.sourceBytes > PUSH_LIMITS.sourceBytes) return `The source tarball is ${sizes.sourceBytes} bytes; a push carries at most ${PUSH_LIMITS.sourceBytes}.`;
7788
7895
  return null;
7789
7896
  }
7790
7897
  async function writeSourceTarball(projectDir, outFile) {
@@ -7893,7 +8000,6 @@ async function buildProject(projectDir) {
7893
8000
  trigger: report.trigger,
7894
8001
  purpose: report.purpose,
7895
8002
  senderIdentity: report.senderIdentity,
7896
- environments: report.environments,
7897
8003
  spine: readSpine(await readFile(built.source.file, "utf8")),
7898
8004
  bundle: built.digest
7899
8005
  });
@@ -7974,22 +8080,22 @@ function clearCredentials(env) {
7974
8080
  }
7975
8081
  /**
7976
8082
  * The token every command sends, and where it came from. Precedence:
7977
- * `COW_DEPLOY_KEY` > `COW_TOKEN` > the cached login. The deploy key wins
7978
- * because a machine that has one is CI (or a long-running `cow dev`), and a
7979
- * stale session left in `~/.cow` on that machine must not quietly become the
8083
+ * `COW_PIPELINE_KEY` > `COW_TOKEN` > the cached login. The pipeline key wins
8084
+ * because a machine that has one is CI, and a stale session left in `~/.cow`
8085
+ * on that machine must not quietly become the
7980
8086
  * credential a pipeline runs as. `COW_TOKEN` is a session token handed over
7981
8087
  * explicitly, so it reports as one and outranks the cached file.
7982
8088
  *
7983
- * A deploy key only reaches the release lifecycle, so a `cow users list` on
7984
- * a box that exports one answers 401 rather than falling back to the
8089
+ * A pipeline key only pushes, flips journeys and reads executions, so a
8090
+ * `cow users list` on a box that exports one answers 401 rather than falling back to the
7985
8091
  * session. That is the honest failure: the two credentials are different
7986
8092
  * identities, and silently switching between them per command is how a
7987
8093
  * pipeline ends up passing locally and failing in CI.
7988
8094
  */
7989
8095
  function resolveCredential(env, credentials) {
7990
- if (env.COW_DEPLOY_KEY) return {
7991
- token: env.COW_DEPLOY_KEY,
7992
- kind: "deployKey"
8096
+ if (env.COW_PIPELINE_KEY) return {
8097
+ token: env.COW_PIPELINE_KEY,
8098
+ kind: "pipelineKey"
7993
8099
  };
7994
8100
  if (env.COW_TOKEN) return {
7995
8101
  token: env.COW_TOKEN,
@@ -8009,7 +8115,7 @@ function resolveCredential(env, credentials) {
8009
8115
  * login) > default.
8010
8116
  *
8011
8117
  * The project's own `apiUrl` outranks the credentials file because it is the
8012
- * more specific answer: `cow.json` says where this project deploys, while the
8118
+ * more specific answer: `cow.json` says which API this project talks to, while the
8013
8119
  * credentials only remember where somebody last logged in. Without it a
8014
8120
  * checkout whose config names production silently fell through to
8015
8121
  * `DEFAULT_API_URL`, which is how a production deploy reaches localhost.
@@ -8018,6 +8124,15 @@ function resolveApiUrl(env, credentials, flag, projectApiUrl) {
8018
8124
  return flag ?? env.COW_API_URL ?? projectApiUrl ?? credentials?.apiUrl ?? "http://localhost:3400";
8019
8125
  }
8020
8126
  /**
8127
+ * The dashboard this project belongs to: the flag, the env var, the
8128
+ * project's own `cow.json`, then the default. Same precedence as the API URL
8129
+ * and for the same reason: a checkout pointed at production must not send
8130
+ * the developer (or a push's links) to localhost.
8131
+ */
8132
+ function resolveWebUrl(env, projectWebUrl, flag) {
8133
+ return flag ?? env.COW_WEB_URL ?? projectWebUrl ?? "http://localhost:5273";
8134
+ }
8135
+ /**
8021
8136
  * Decode a JWT payload without verification. Display only: the API is the
8022
8137
  * verifier; the CLI just shows what it is about to send.
8023
8138
  */
@@ -8121,7 +8236,7 @@ async function login(env, options) {
8121
8236
  if (options.token !== void 0) {
8122
8237
  token = options.token.trim();
8123
8238
  if (decodeTokenPayload(token) === null) throw new Error("The provided --token value is not a JWT");
8124
- } else token = await collectTokenViaBrowser(options.webUrl ?? env.COW_WEB_URL ?? (await readCowConfig(process.cwd()))?.webUrl ?? "http://localhost:5273", 3e5, options.noOpen === true);
8239
+ } else token = await collectTokenViaBrowser(resolveWebUrl(env, (await readCowConfig(process.cwd()))?.webUrl, options.webUrl), 3e5, options.noOpen === true);
8125
8240
  const claims = decodeSessionToken(token);
8126
8241
  if (claims === null) throw new Error("Token is not a decodable JWT");
8127
8242
  if (claims.userId === null) throw new Error("Token has no subject claim. Is it a Clerk session token?");
@@ -8234,12 +8349,12 @@ function registerAuth(program, env, io) {
8234
8349
  program.command("whoami").description("show which credential commands will use, and for a session its subject, org, role, and expiry").action(async (opts) => {
8235
8350
  const credentials = await readCredentials(env);
8236
8351
  const { kind } = resolveCredential(env, credentials);
8237
- if (kind === "none") throw new Error("Not logged in. Run `cow login` first, or set COW_DEPLOY_KEY.");
8238
- if (kind === "deployKey") {
8352
+ if (kind === "none") throw new Error("Not logged in. Run `cow login` first, or set COW_PIPELINE_KEY.");
8353
+ if (kind === "pipelineKey") {
8239
8354
  emit({ data: {
8240
8355
  credential: kind,
8241
- source: "COW_DEPLOY_KEY",
8242
- note: "A deploy key reaches the release lifecycle only."
8356
+ source: "COW_PIPELINE_KEY",
8357
+ note: "A pipeline key may push, enable, disable, and read executions."
8243
8358
  } }, io, opts.json);
8244
8359
  return;
8245
8360
  }
@@ -8652,10 +8767,16 @@ const ingestionErrors = errors("invalid_key", "validation_failed", "over_quota",
8652
8767
  const SESSION_AUTH = [{ clerkSession: [] }];
8653
8768
  const API_KEY_AUTH = [{ orgApiKey: [] }];
8654
8769
  /**
8655
- * The release lifecycle: a dashboard session, or an org deploy key. Two
8656
- * alternatives, so a caller needs either one, never both.
8770
+ * What a pipeline does: push, enable, disable, and read executions. A
8771
+ * dashboard session, or an org pipeline key: two alternatives, so a caller
8772
+ * needs either one, never both.
8773
+ *
8774
+ * The gate itself is path-scoped in `apps/api/src/app.ts`, so it reaches
8775
+ * `/pushes/*` whole (a pipeline reads back what it pushed) and the two
8776
+ * journey flags by their exact paths; this block is what says so in the
8777
+ * OpenAPI document.
8657
8778
  */
8658
- const RELEASE_AUTH = [{ clerkSession: [] }, { orgDeployKey: [] }];
8779
+ const PIPELINE_AUTH = [{ clerkSession: [] }, { orgPipelineKey: [] }];
8659
8780
  /** Public routes: their own signature or token check is the authorization. */
8660
8781
  const NO_AUTH = [];
8661
8782
  /** Operations kept off the CLI and MCP surfaces. */
@@ -8683,7 +8804,7 @@ function htmlResponse(description) {
8683
8804
 
8684
8805
  //#endregion
8685
8806
  //#region ../../packages/shared/src/contract/apps.ts
8686
- const params$10 = z.object({ id: z.string() });
8807
+ const params$11 = z.object({ id: z.string() });
8687
8808
  /**
8688
8809
  * An app is the attribution unit: what a developer names, what ingestion
8689
8810
  * stamps, and what segments and journey triggers filter on. Its inbound
@@ -8722,7 +8843,7 @@ const apps = defineModule(defineRoute({
8722
8843
  tags: ["apps"],
8723
8844
  summary: "Fetch one app",
8724
8845
  security: SESSION_AUTH,
8725
- request: { params: params$10 },
8846
+ request: { params: params$11 },
8726
8847
  responses: {
8727
8848
  200: envelope(appSchema),
8728
8849
  ...sessionErrors,
@@ -8736,7 +8857,7 @@ const apps = defineModule(defineRoute({
8736
8857
  summary: "Rename or archive an app",
8737
8858
  security: SESSION_AUTH,
8738
8859
  request: {
8739
- params: params$10,
8860
+ params: params$11,
8740
8861
  body: jsonBody(updateAppBodySchema)
8741
8862
  },
8742
8863
  responses: {
@@ -8765,7 +8886,7 @@ const artifacts = defineModule(defineRoute({
8765
8886
  operationId: "artifacts.head",
8766
8887
  tags: ["artifacts"],
8767
8888
  summary: "Does this organization already have this artifact?",
8768
- security: RELEASE_AUTH,
8889
+ security: PIPELINE_AUTH,
8769
8890
  surfaces: HIDDEN_FROM_TOOLS,
8770
8891
  request: { params: artifactDigestParams },
8771
8892
  responses: {
@@ -8779,7 +8900,7 @@ const artifacts = defineModule(defineRoute({
8779
8900
  operationId: "artifacts.put",
8780
8901
  tags: ["artifacts"],
8781
8902
  summary: "Upload an artifact by its digest",
8782
- security: RELEASE_AUTH,
8903
+ security: PIPELINE_AUTH,
8783
8904
  surfaces: HIDDEN_FROM_TOOLS,
8784
8905
  request: {
8785
8906
  params: artifactDigestParams,
@@ -8852,7 +8973,7 @@ const billing = defineModule(defineRoute({
8852
8973
 
8853
8974
  //#endregion
8854
8975
  //#region ../../packages/shared/src/contract/catalog.ts
8855
- const params$9 = z.object({ name: z.string() });
8976
+ const params$10 = z.object({ name: z.string() });
8856
8977
  const writeErrors$1 = errors("validation_failed", "malformed_request");
8857
8978
  /** The tracking plan: entries are addressed by name, unique per org. */
8858
8979
  const catalog = defineModule(defineRoute({
@@ -8889,7 +9010,7 @@ const catalog = defineModule(defineRoute({
8889
9010
  tags: ["catalog"],
8890
9011
  summary: "Fetch one catalog event",
8891
9012
  security: SESSION_AUTH,
8892
- request: { params: params$9 },
9013
+ request: { params: params$10 },
8893
9014
  responses: {
8894
9015
  200: envelope(catalogEventSchema),
8895
9016
  ...sessionErrors,
@@ -8903,7 +9024,7 @@ const catalog = defineModule(defineRoute({
8903
9024
  summary: "Update a catalog event",
8904
9025
  security: SESSION_AUTH,
8905
9026
  request: {
8906
- params: params$9,
9027
+ params: params$10,
8907
9028
  body: jsonBody(updateCatalogEventBodySchema)
8908
9029
  },
8909
9030
  responses: {
@@ -8920,7 +9041,7 @@ const catalog = defineModule(defineRoute({
8920
9041
  summary: "Allow or deny an event name",
8921
9042
  security: SESSION_AUTH,
8922
9043
  request: {
8923
- params: params$9,
9044
+ params: params$10,
8924
9045
  body: jsonBody(setGovernanceStateBodySchema)
8925
9046
  },
8926
9047
  responses: {
@@ -8962,7 +9083,7 @@ const catalog = defineModule(defineRoute({
8962
9083
  tags: ["catalog"],
8963
9084
  summary: "Fetch one catalog trait",
8964
9085
  security: SESSION_AUTH,
8965
- request: { params: params$9 },
9086
+ request: { params: params$10 },
8966
9087
  responses: {
8967
9088
  200: envelope(catalogTraitSchema),
8968
9089
  ...sessionErrors,
@@ -8976,7 +9097,7 @@ const catalog = defineModule(defineRoute({
8976
9097
  summary: "Update a catalog trait",
8977
9098
  security: SESSION_AUTH,
8978
9099
  request: {
8979
- params: params$9,
9100
+ params: params$10,
8980
9101
  body: jsonBody(updateCatalogTraitBodySchema)
8981
9102
  },
8982
9103
  responses: {
@@ -8993,7 +9114,7 @@ const catalog = defineModule(defineRoute({
8993
9114
  summary: "Allow or deny a trait name",
8994
9115
  security: SESSION_AUTH,
8995
9116
  request: {
8996
- params: params$9,
9117
+ params: params$10,
8997
9118
  body: jsonBody(setGovernanceStateBodySchema)
8998
9119
  },
8999
9120
  responses: {
@@ -9064,9 +9185,8 @@ const consent = defineModule(defineRoute({
9064
9185
  * one-click unsubscribe. Those answer the caller's convention, not the
9065
9186
  * envelope.
9066
9187
  *
9067
- * The two reads take a deploy key as well as a session: `cow dev` tails
9068
- * captured emails, and a loop that needs one credential to deploy and a
9069
- * second to see what the deploy sent is not one loop.
9188
+ * The two reads are dashboard reads: `cow dev` and its pipeline-key tail are
9189
+ * gone (ADR 0011), so a pipeline key has no business in the delivery log.
9070
9190
  */
9071
9191
  const deliveries = defineModule(defineRoute({
9072
9192
  method: "get",
@@ -9074,7 +9194,7 @@ const deliveries = defineModule(defineRoute({
9074
9194
  operationId: "deliveries.list",
9075
9195
  tags: ["deliveries"],
9076
9196
  summary: "List deliveries, newest first",
9077
- security: RELEASE_AUTH,
9197
+ security: SESSION_AUTH,
9078
9198
  request: { query: listDeliveriesQuerySchema },
9079
9199
  responses: {
9080
9200
  200: list(deliverySchema),
@@ -9087,7 +9207,7 @@ const deliveries = defineModule(defineRoute({
9087
9207
  operationId: "deliveries.get",
9088
9208
  tags: ["deliveries"],
9089
9209
  summary: "Fetch one delivery with its payload",
9090
- security: RELEASE_AUTH,
9210
+ security: SESSION_AUTH,
9091
9211
  request: { params: z.object({ id: z.string() }) },
9092
9212
  responses: {
9093
9213
  200: envelope(deliveryDetailSchema),
@@ -9144,48 +9264,9 @@ all: z.literal("1").optional() }) },
9144
9264
  }
9145
9265
  }));
9146
9266
 
9147
- //#endregion
9148
- //#region ../../packages/shared/src/contract/deployments.ts
9149
- /**
9150
- * Deployments: which release an environment currently runs. Append-only, so
9151
- * the list is the whole history and there is no rollback endpoint: rolling
9152
- * back is deploying the release before the current one, which `cow rollback`
9153
- * works out from the last two rows.
9154
- *
9155
- * The target environment comes from the selection header, like every other
9156
- * environment-scoped operation, rather than from the body.
9157
- */
9158
- const deployments = defineModule(defineRoute({
9159
- method: "get",
9160
- path: "/v1/deployments",
9161
- operationId: "deployments.list",
9162
- tags: ["deployments"],
9163
- summary: "List deployments, newest first",
9164
- security: RELEASE_AUTH,
9165
- request: { query: listDeploymentsQuerySchema },
9166
- responses: {
9167
- 200: list(deploymentSchema),
9168
- ...sessionErrors,
9169
- ...errors("validation_failed")
9170
- }
9171
- }), defineRoute({
9172
- method: "post",
9173
- path: "/v1/deployments",
9174
- operationId: "deployments.create",
9175
- tags: ["deployments"],
9176
- summary: "Deploy a release to the selected environment",
9177
- security: RELEASE_AUTH,
9178
- request: { body: jsonBody(createDeploymentBodySchema) },
9179
- responses: {
9180
- 201: envelope(deploymentSchema, "The deployment, with any warnings"),
9181
- ...sessionErrors,
9182
- ...errors("not_found", "release_not_ready", "validation_failed", "malformed_request")
9183
- }
9184
- }));
9185
-
9186
9267
  //#endregion
9187
9268
  //#region ../../packages/shared/src/contract/destinations.ts
9188
- const params$8 = z.object({ id: z.string() });
9269
+ const params$9 = z.object({ id: z.string() });
9189
9270
  const destinations = defineModule(defineRoute({
9190
9271
  method: "post",
9191
9272
  path: "/v1/destinations",
@@ -9219,7 +9300,7 @@ const destinations = defineModule(defineRoute({
9219
9300
  tags: ["destinations"],
9220
9301
  summary: "Fetch one destination",
9221
9302
  security: SESSION_AUTH,
9222
- request: { params: params$8 },
9303
+ request: { params: params$9 },
9223
9304
  responses: {
9224
9305
  200: envelope(destinationSchema),
9225
9306
  ...sessionErrors,
@@ -9233,7 +9314,7 @@ const destinations = defineModule(defineRoute({
9233
9314
  summary: "Update a destination",
9234
9315
  security: SESSION_AUTH,
9235
9316
  request: {
9236
- params: params$8,
9317
+ params: params$9,
9237
9318
  body: jsonBody(updateDestinationBodySchema)
9238
9319
  },
9239
9320
  responses: {
@@ -9248,7 +9329,7 @@ const destinations = defineModule(defineRoute({
9248
9329
  tags: ["destinations"],
9249
9330
  summary: "Delete a destination",
9250
9331
  security: SESSION_AUTH,
9251
- request: { params: params$8 },
9332
+ request: { params: params$9 },
9252
9333
  responses: {
9253
9334
  200: envelope(deletedSchema),
9254
9335
  ...sessionErrors,
@@ -9258,7 +9339,7 @@ const destinations = defineModule(defineRoute({
9258
9339
 
9259
9340
  //#endregion
9260
9341
  //#region ../../packages/shared/src/contract/domains.ts
9261
- const params$7 = z.object({ id: z.string() });
9342
+ const params$8 = z.object({ id: z.string() });
9262
9343
  const domains = defineModule(defineRoute({
9263
9344
  method: "get",
9264
9345
  path: "/v1/domains",
@@ -9279,7 +9360,7 @@ const domains = defineModule(defineRoute({
9279
9360
  tags: ["domains"],
9280
9361
  summary: "Fetch one sending domain",
9281
9362
  security: SESSION_AUTH,
9282
- request: { params: params$7 },
9363
+ request: { params: params$8 },
9283
9364
  responses: {
9284
9365
  200: envelope(senderDomainSchema),
9285
9366
  ...sessionErrors,
@@ -9307,7 +9388,7 @@ const domains = defineModule(defineRoute({
9307
9388
  description: "The Domain Connect link for this domain's DNS provider, which shows the admin the records and asks them to confirm. `url` is null when the provider does not support it.",
9308
9389
  security: SESSION_AUTH,
9309
9390
  surfaces: HIDDEN_FROM_TOOLS,
9310
- request: { params: params$7 },
9391
+ request: { params: params$8 },
9311
9392
  responses: {
9312
9393
  200: envelope(domainDnsSetupSchema),
9313
9394
  ...sessionErrors,
@@ -9320,7 +9401,7 @@ const domains = defineModule(defineRoute({
9320
9401
  tags: ["domains"],
9321
9402
  summary: "Re-check DNS now",
9322
9403
  security: SESSION_AUTH,
9323
- request: { params: params$7 },
9404
+ request: { params: params$8 },
9324
9405
  responses: {
9325
9406
  200: envelope(senderDomainSchema),
9326
9407
  ...sessionErrors,
@@ -9334,7 +9415,7 @@ const domains = defineModule(defineRoute({
9334
9415
  summary: "Toggle click tracking",
9335
9416
  security: SESSION_AUTH,
9336
9417
  request: {
9337
- params: params$7,
9418
+ params: params$8,
9338
9419
  body: jsonBody(updateSenderDomainBodySchema)
9339
9420
  },
9340
9421
  responses: {
@@ -9347,9 +9428,9 @@ const domains = defineModule(defineRoute({
9347
9428
  path: "/v1/domains/{id}",
9348
9429
  operationId: "domains.delete",
9349
9430
  tags: ["domains"],
9350
- summary: "Release a domain claim",
9431
+ summary: "Give up a domain claim",
9351
9432
  security: SESSION_AUTH,
9352
- request: { params: params$7 },
9433
+ request: { params: params$8 },
9353
9434
  responses: {
9354
9435
  200: envelope(deletedSchema),
9355
9436
  ...sessionErrors,
@@ -9447,12 +9528,19 @@ const events = defineModule(defineRoute({
9447
9528
 
9448
9529
  //#endregion
9449
9530
  //#region ../../packages/shared/src/contract/executions.ts
9450
- const params$6 = z.object({ id: z.string() });
9531
+ const params$7 = z.object({ id: z.string() });
9451
9532
  /**
9452
9533
  * Executions: one run of one journey for one profile. Replaces v1's journey
9453
9534
  * instances, which were read live from Temporal and so could only be listed
9454
- * per journey; these are rows, so they filter by journey, release, status,
9535
+ * per journey; these are rows, so they filter by journey, version, status,
9455
9536
  * and profile in one place. The environment comes from the selection header.
9537
+ *
9538
+ * `cancel` is the only write: stopping is explicit and per environment
9539
+ * (ADR 0011), and each execution records `cancelled` when it reaches its
9540
+ * next step, so the answer is that the sweep started rather than a count.
9541
+ * A pipeline may push, enable, disable and READ executions; stopping runs
9542
+ * that are already carrying real people is a person's call, so cancel takes
9543
+ * a dashboard session (and org:admin in production).
9456
9544
  */
9457
9545
  const executions = defineModule(defineRoute({
9458
9546
  method: "get",
@@ -9460,21 +9548,34 @@ const executions = defineModule(defineRoute({
9460
9548
  operationId: "executions.list",
9461
9549
  tags: ["executions"],
9462
9550
  summary: "List executions, newest first",
9463
- security: RELEASE_AUTH,
9551
+ security: PIPELINE_AUTH,
9464
9552
  request: { query: listExecutionsQuerySchema },
9465
9553
  responses: {
9466
9554
  200: list(executionSchema),
9467
9555
  ...sessionErrors,
9468
9556
  ...errors("validation_failed")
9469
9557
  }
9558
+ }), defineRoute({
9559
+ method: "post",
9560
+ path: "/v1/executions/cancel",
9561
+ operationId: "executions.cancel",
9562
+ tags: ["executions"],
9563
+ summary: "Cancel live executions of a journey, or of one version",
9564
+ security: SESSION_AUTH,
9565
+ request: { body: jsonBody(cancelExecutionsBodySchema) },
9566
+ responses: {
9567
+ 200: envelope(cancellingSchema, "The sweep is under way; each execution stops at its next step"),
9568
+ ...sessionErrors,
9569
+ ...errors("not_found", "validation_failed", "malformed_request")
9570
+ }
9470
9571
  }), defineRoute({
9471
9572
  method: "get",
9472
9573
  path: "/v1/executions/{id}",
9473
9574
  operationId: "executions.get",
9474
9575
  tags: ["executions"],
9475
9576
  summary: "Inspect one execution, with its logs",
9476
- security: RELEASE_AUTH,
9477
- request: { params: params$6 },
9577
+ security: PIPELINE_AUTH,
9578
+ request: { params: params$7 },
9478
9579
  responses: {
9479
9580
  200: envelope(executionDetailSchema),
9480
9581
  ...sessionErrors,
@@ -9583,22 +9684,26 @@ const ingestion = defineModule(defineRoute({
9583
9684
  //#region ../../packages/shared/src/contract/journeys.ts
9584
9685
  /**
9585
9686
  * A journey is addressed by its key: the basename of the file its author
9586
- * wrote, unique across the project. The `jrn_` id it used to carry is
9587
- * retired, because the rows are derived from a release rather than created
9588
- * through the API, and (org, environment, key) is the only identity they
9589
- * have.
9687
+ * wrote, unique across the org. The `jrn_` id it used to carry is retired,
9688
+ * because the rows are derived from a push rather than created through the
9689
+ * API, and (org, key) is the only identity they have.
9690
+ *
9691
+ * `list` and `get` report the enabled flag of BOTH environments whichever
9692
+ * one the caller selected, because "is this on in production?" is the
9693
+ * question they exist to answer; `enable` and `disable` act on the selected
9694
+ * one, like every other environment-scoped write (docs/standards/api.md).
9590
9695
  *
9591
9696
  * `journeys.listInstances` and `journeys.getInstance` are gone: executions
9592
9697
  * are their own resource now (`executions.list` / `executions.get`), and
9593
9698
  * `journeys.dryRun` answers with the execution it started.
9594
9699
  */
9595
- const params$5 = z.object({ key: z.string() });
9700
+ const params$6 = z.object({ key: z.string() });
9596
9701
  const journeys = defineModule(defineRoute({
9597
9702
  method: "get",
9598
9703
  path: "/v1/journeys",
9599
9704
  operationId: "journeys.list",
9600
9705
  tags: ["journeys"],
9601
- summary: "List the journeys deployed in the selected environment",
9706
+ summary: "List the organization's journeys",
9602
9707
  security: SESSION_AUTH,
9603
9708
  request: { query: listJourneysQuerySchema },
9604
9709
  responses: {
@@ -9613,7 +9718,7 @@ const journeys = defineModule(defineRoute({
9613
9718
  tags: ["journeys"],
9614
9719
  summary: "Inspect one journey",
9615
9720
  security: SESSION_AUTH,
9616
- request: { params: params$5 },
9721
+ request: { params: params$6 },
9617
9722
  responses: {
9618
9723
  200: envelope(journeySchema),
9619
9724
  ...sessionErrors,
@@ -9626,36 +9731,48 @@ const journeys = defineModule(defineRoute({
9626
9731
  tags: ["journeys"],
9627
9732
  summary: "Journey operational stats",
9628
9733
  security: SESSION_AUTH,
9629
- request: { params: params$5 },
9734
+ request: { params: params$6 },
9630
9735
  responses: {
9631
9736
  200: envelope(journeyStatsSchema),
9632
9737
  ...sessionErrors,
9633
9738
  ...errors("not_found")
9634
9739
  }
9635
9740
  }), defineRoute({
9636
- method: "patch",
9637
- path: "/v1/journeys/{key}",
9638
- operationId: "journeys.update",
9741
+ method: "post",
9742
+ path: "/v1/journeys/enable",
9743
+ operationId: "journeys.enable",
9639
9744
  tags: ["journeys"],
9640
- summary: "Enable or disable a journey in the selected environment",
9641
- security: SESSION_AUTH,
9642
- request: {
9643
- params: params$5,
9644
- body: jsonBody(updateJourneyBodySchema)
9645
- },
9745
+ summary: "Turn journeys on in the selected environment",
9746
+ security: PIPELINE_AUTH,
9747
+ surfaces: { cli: false },
9748
+ request: { body: jsonBody(setJourneysEnabledBodySchema) },
9646
9749
  responses: {
9647
- 200: envelope(journeySchema),
9750
+ 200: envelope(z.array(journeySchema), "The journeys as they now stand"),
9648
9751
  ...sessionErrors,
9649
- ...errors("not_found", "validation_failed", "malformed_request")
9752
+ ...errors("not_found", "project_missing", "validation_failed", "malformed_request")
9753
+ }
9754
+ }), defineRoute({
9755
+ method: "post",
9756
+ path: "/v1/journeys/disable",
9757
+ operationId: "journeys.disable",
9758
+ tags: ["journeys"],
9759
+ summary: "Turn journeys off in the selected environment",
9760
+ security: PIPELINE_AUTH,
9761
+ surfaces: { cli: false },
9762
+ request: { body: jsonBody(setJourneysEnabledBodySchema) },
9763
+ responses: {
9764
+ 200: envelope(z.array(journeySchema), "The journeys as they now stand"),
9765
+ ...sessionErrors,
9766
+ ...errors("not_found", "project_missing", "validation_failed", "malformed_request")
9650
9767
  }
9651
9768
  }), defineRoute({
9652
9769
  method: "delete",
9653
9770
  path: "/v1/journeys/{key}",
9654
9771
  operationId: "journeys.delete",
9655
9772
  tags: ["journeys"],
9656
- summary: "Remove a journey from the selected environment",
9773
+ summary: "Delete a journey",
9657
9774
  security: SESSION_AUTH,
9658
- request: { params: params$5 },
9775
+ request: { params: params$6 },
9659
9776
  responses: {
9660
9777
  200: envelope(deletedSchema),
9661
9778
  ...sessionErrors,
@@ -9669,7 +9786,7 @@ const journeys = defineModule(defineRoute({
9669
9786
  summary: "Dry-run a journey against a real user",
9670
9787
  security: SESSION_AUTH,
9671
9788
  request: {
9672
- params: params$5,
9789
+ params: params$6,
9673
9790
  body: jsonBody(dryRunJourneyBodySchema)
9674
9791
  },
9675
9792
  responses: {
@@ -9741,20 +9858,37 @@ const project = defineModule(defineRoute({
9741
9858
  operationId: "project.get",
9742
9859
  tags: ["project"],
9743
9860
  summary: "Get one of the organization's projects",
9744
- security: RELEASE_AUTH,
9861
+ security: PIPELINE_AUTH,
9745
9862
  request: { query: getProjectQuerySchema },
9746
9863
  responses: {
9747
9864
  200: envelope(projectSchema),
9748
9865
  ...sessionErrors,
9749
9866
  ...errors("project_missing", "validation_failed", "malformed_request")
9750
9867
  }
9868
+ }), defineRoute({
9869
+ method: "get",
9870
+ path: "/v1/project/status",
9871
+ operationId: "project.status",
9872
+ tags: ["project"],
9873
+ summary: "What a project's journeys and templates are doing",
9874
+ security: PIPELINE_AUTH,
9875
+ surfaces: {
9876
+ cli: false,
9877
+ mcpName: "status"
9878
+ },
9879
+ request: { query: getProjectQuerySchema },
9880
+ responses: {
9881
+ 200: envelope(projectStatusSchema),
9882
+ ...sessionErrors,
9883
+ ...errors("project_missing", "validation_failed", "malformed_request")
9884
+ }
9751
9885
  }), defineRoute({
9752
9886
  method: "post",
9753
9887
  path: "/v1/project",
9754
9888
  operationId: "project.create",
9755
9889
  tags: ["project"],
9756
9890
  summary: "Create a project in the organization",
9757
- security: RELEASE_AUTH,
9891
+ security: PIPELINE_AUTH,
9758
9892
  request: { body: jsonBody(createProjectBodySchema) },
9759
9893
  responses: {
9760
9894
  201: envelope(projectSchema, "The created project"),
@@ -9764,87 +9898,75 @@ const project = defineModule(defineRoute({
9764
9898
  }));
9765
9899
 
9766
9900
  //#endregion
9767
- //#region ../../packages/shared/src/contract/releases.ts
9768
- const params$4 = z.object({ id: z.string() });
9901
+ //#region ../../packages/shared/src/contract/pushes.ts
9902
+ const params$5 = z.object({ id: z.string() });
9769
9903
  /**
9770
- * Releases: one `cow push` each, the whole project at one point in time.
9904
+ * Pushes: one `cow push` each, the whole project at one point in time. The
9905
+ * push stores the source archive and its compile creates a version of every
9906
+ * key whose module changed; it turns nothing on (ADR 0011).
9771
9907
  *
9772
9908
  * `create` and `source` are hidden from the CLI and MCP surfaces because
9773
9909
  * both only make sense next to local files: the manifest `create` takes is
9774
9910
  * whatever `cow build` just wrote, and the tarball `source` returns is what
9775
- * `cow pull` unpacks over a working tree. `list`, `get`, and
9776
- * `cancelExecutions` are ordinary operations an agent should have.
9911
+ * `cow pull` unpacks over a working tree.
9777
9912
  */
9778
- const releases = defineModule(defineRoute({
9913
+ const pushes = defineModule(defineRoute({
9779
9914
  method: "post",
9780
- path: "/v1/releases",
9781
- operationId: "releases.create",
9782
- tags: ["releases"],
9783
- summary: "Create a release from a built manifest",
9784
- security: RELEASE_AUTH,
9915
+ path: "/v1/pushes",
9916
+ operationId: "pushes.create",
9917
+ tags: ["pushes"],
9918
+ summary: "Push a built project",
9919
+ security: PIPELINE_AUTH,
9785
9920
  surfaces: HIDDEN_FROM_TOOLS,
9786
- request: { body: jsonBody(createReleaseBodySchema) },
9921
+ request: { body: jsonBody(createPushBodySchema) },
9787
9922
  responses: {
9788
- 201: envelope(releaseSchema, "The pending release; compilation follows"),
9923
+ 201: envelope(pushSchema, "The stored push and the versions it is compiling"),
9789
9924
  ...sessionErrors,
9790
- ...errors("project_missing", "release_invalid", "validation_failed", "malformed_request", "dependency_unavailable")
9925
+ ...errors("project_missing", "push_invalid", "validation_failed", "malformed_request", "dependency_unavailable")
9791
9926
  }
9792
9927
  }), defineRoute({
9793
9928
  method: "get",
9794
- path: "/v1/releases",
9795
- operationId: "releases.list",
9796
- tags: ["releases"],
9797
- summary: "List releases, newest first",
9798
- security: RELEASE_AUTH,
9799
- request: { query: listReleasesQuerySchema },
9929
+ path: "/v1/pushes",
9930
+ operationId: "pushes.list",
9931
+ tags: ["pushes"],
9932
+ summary: "List pushes, newest first",
9933
+ security: SESSION_AUTH,
9934
+ request: { query: listPushesQuerySchema },
9800
9935
  responses: {
9801
- 200: list(releaseSchema),
9936
+ 200: list(pushSchema),
9802
9937
  ...sessionErrors,
9803
9938
  ...errors("validation_failed")
9804
9939
  }
9805
9940
  }), defineRoute({
9806
9941
  method: "get",
9807
- path: "/v1/releases/{id}",
9808
- operationId: "releases.get",
9809
- tags: ["releases"],
9810
- summary: "Inspect one release",
9811
- security: RELEASE_AUTH,
9812
- request: { params: params$4 },
9942
+ path: "/v1/pushes/{id}",
9943
+ operationId: "pushes.get",
9944
+ tags: ["pushes"],
9945
+ summary: "Inspect one push and what it compiled",
9946
+ security: SESSION_AUTH,
9947
+ request: { params: params$5 },
9813
9948
  responses: {
9814
- 200: envelope(releaseSchema),
9949
+ 200: envelope(pushSchema),
9815
9950
  ...sessionErrors,
9816
9951
  ...errors("not_found")
9817
9952
  }
9818
9953
  }), defineRoute({
9819
9954
  method: "get",
9820
- path: "/v1/releases/{id}/source",
9821
- operationId: "releases.source",
9822
- tags: ["releases"],
9823
- summary: "Download the release's source tarball",
9824
- security: RELEASE_AUTH,
9955
+ path: "/v1/pushes/{id}/source",
9956
+ operationId: "pushes.source",
9957
+ tags: ["pushes"],
9958
+ summary: "Download the pushed source tarball",
9959
+ security: SESSION_AUTH,
9825
9960
  surfaces: HIDDEN_FROM_TOOLS,
9826
- request: { params: params$4 },
9961
+ request: { params: params$5 },
9827
9962
  responses: {
9828
9963
  200: {
9829
- description: "The gzipped source tarball the release was built from",
9964
+ description: "The gzipped source tarball the push stored",
9830
9965
  content: { "application/gzip": { schema: z.string() } }
9831
9966
  },
9832
9967
  ...sessionErrors,
9833
9968
  ...errors("not_found")
9834
9969
  }
9835
- }), defineRoute({
9836
- method: "post",
9837
- path: "/v1/releases/{id}/cancel-executions",
9838
- operationId: "releases.cancelExecutions",
9839
- tags: ["releases"],
9840
- summary: "Cancel every live execution pinned to this release",
9841
- security: RELEASE_AUTH,
9842
- request: { params: params$4 },
9843
- responses: {
9844
- 202: envelope(cancelExecutionsSchema, "The cancel sweep was started"),
9845
- ...sessionErrors,
9846
- ...errors("not_found", "dependency_unavailable")
9847
- }
9848
9970
  }));
9849
9971
 
9850
9972
  //#endregion
@@ -9872,7 +9994,7 @@ const review = defineModule(defineRoute({
9872
9994
 
9873
9995
  //#endregion
9874
9996
  //#region ../../packages/shared/src/contract/segments.ts
9875
- const params$3 = z.object({ id: z.string() });
9997
+ const params$4 = z.object({ id: z.string() });
9876
9998
  const writeErrors = errors("validation_failed", "malformed_request");
9877
9999
  /** `preview` precedes `get` so "preview" is never read as an id. */
9878
10000
  const segments = defineModule(defineRoute({
@@ -9922,7 +10044,7 @@ const segments = defineModule(defineRoute({
9922
10044
  tags: ["segments"],
9923
10045
  summary: "Fetch one segment with its member count",
9924
10046
  security: SESSION_AUTH,
9925
- request: { params: params$3 },
10047
+ request: { params: params$4 },
9926
10048
  responses: {
9927
10049
  200: envelope(segmentDetailSchema),
9928
10050
  ...sessionErrors,
@@ -9936,7 +10058,7 @@ const segments = defineModule(defineRoute({
9936
10058
  summary: "Update a segment",
9937
10059
  security: SESSION_AUTH,
9938
10060
  request: {
9939
- params: params$3,
10061
+ params: params$4,
9940
10062
  body: jsonBody(updateSegmentBodySchema)
9941
10063
  },
9942
10064
  responses: {
@@ -9952,7 +10074,7 @@ const segments = defineModule(defineRoute({
9952
10074
  tags: ["segments"],
9953
10075
  summary: "Delete a segment",
9954
10076
  security: SESSION_AUTH,
9955
- request: { params: params$3 },
10077
+ request: { params: params$4 },
9956
10078
  responses: {
9957
10079
  200: envelope(deletedSchema),
9958
10080
  ...sessionErrors,
@@ -9966,7 +10088,7 @@ const segments = defineModule(defineRoute({
9966
10088
  summary: "List a segment's members",
9967
10089
  security: SESSION_AUTH,
9968
10090
  request: {
9969
- params: params$3,
10091
+ params: params$4,
9970
10092
  query: listSegmentMembersQuerySchema
9971
10093
  },
9972
10094
  responses: {
@@ -10030,10 +10152,10 @@ const settings = defineModule(defineRoute({
10030
10152
  }
10031
10153
  }), defineRoute({
10032
10154
  method: "get",
10033
- path: "/v1/settings/deploy-keys",
10034
- operationId: "settings.deployKeys.list",
10155
+ path: "/v1/settings/pipeline-keys",
10156
+ operationId: "settings.pipelineKeys.list",
10035
10157
  tags: ["settings"],
10036
- summary: "List org deploy keys",
10158
+ summary: "List org pipeline keys",
10037
10159
  security: SESSION_AUTH,
10038
10160
  request: { query: listApiKeysQuerySchema },
10039
10161
  responses: {
@@ -10043,23 +10165,23 @@ const settings = defineModule(defineRoute({
10043
10165
  }
10044
10166
  }), defineRoute({
10045
10167
  method: "post",
10046
- path: "/v1/settings/deploy-keys",
10047
- operationId: "settings.deployKeys.create",
10168
+ path: "/v1/settings/pipeline-keys",
10169
+ operationId: "settings.pipelineKeys.create",
10048
10170
  tags: ["settings"],
10049
- summary: "Create an org deploy key",
10171
+ summary: "Create an org pipeline key",
10050
10172
  security: SESSION_AUTH,
10051
10173
  request: { body: jsonBody(createApiKeyBodySchema) },
10052
10174
  responses: {
10053
- 201: envelope(createdApiKeyDtoSchema, "The created deploy key; the only response carrying the raw secret"),
10175
+ 201: envelope(createdApiKeyDtoSchema, "The created pipeline key; the only response carrying the raw secret"),
10054
10176
  ...sessionErrors,
10055
10177
  ...errors("validation_failed", "malformed_request")
10056
10178
  }
10057
10179
  }), defineRoute({
10058
10180
  method: "post",
10059
- path: "/v1/settings/deploy-keys/{id}/revoke",
10060
- operationId: "settings.deployKeys.revoke",
10181
+ path: "/v1/settings/pipeline-keys/{id}/revoke",
10182
+ operationId: "settings.pipelineKeys.revoke",
10061
10183
  tags: ["settings"],
10062
- summary: "Revoke an org deploy key",
10184
+ summary: "Revoke an org pipeline key",
10063
10185
  security: SESSION_AUTH,
10064
10186
  request: {
10065
10187
  params: z.object({ id: z.string() }),
@@ -10091,7 +10213,7 @@ const settings = defineModule(defineRoute({
10091
10213
  //#endregion
10092
10214
  //#region ../../packages/shared/src/contract/sources.ts
10093
10215
  const appParams = z.object({ appId: z.string() });
10094
- const params$2 = z.object({ id: z.string() });
10216
+ const params$3 = z.object({ id: z.string() });
10095
10217
  /**
10096
10218
  * A source is one inbound pipe into an app. It is created and listed under
10097
10219
  * its parent app (the app is the attribution unit) and addressed by its own
@@ -10137,7 +10259,7 @@ const sources = defineModule(defineRoute({
10137
10259
  tags: ["sources"],
10138
10260
  summary: "Fetch one source",
10139
10261
  security: SESSION_AUTH,
10140
- request: { params: params$2 },
10262
+ request: { params: params$3 },
10141
10263
  responses: {
10142
10264
  200: envelope(sourceSchema),
10143
10265
  ...sessionErrors,
@@ -10151,7 +10273,7 @@ const sources = defineModule(defineRoute({
10151
10273
  summary: "Set or rotate a source's config",
10152
10274
  security: SESSION_AUTH,
10153
10275
  request: {
10154
- params: params$2,
10276
+ params: params$3,
10155
10277
  body: jsonBody(updateSourceBodySchema)
10156
10278
  },
10157
10279
  responses: {
@@ -10166,7 +10288,7 @@ const sources = defineModule(defineRoute({
10166
10288
  tags: ["sources"],
10167
10289
  summary: "Archive a source",
10168
10290
  security: SESSION_AUTH,
10169
- request: { params: params$2 },
10291
+ request: { params: params$3 },
10170
10292
  responses: {
10171
10293
  200: envelope(sourceSchema, "The archived source"),
10172
10294
  ...sessionErrors,
@@ -10223,15 +10345,104 @@ const suppressions = defineModule(defineRoute({
10223
10345
  }));
10224
10346
 
10225
10347
  //#endregion
10226
- //#region ../../packages/shared/src/contract/users.ts
10227
- const params$1 = z.object({ profileId: z.string() });
10348
+ //#region ../../packages/shared/src/contract/templates.ts
10228
10349
  /**
10229
- * The answer a merged-away profile id gets (spec: Identity): 307 to the
10230
- * same path with the survivor's id, so the method and body of a redirected
10231
- * call survive the hop. Empty body; the `Location` header is the answer.
10350
+ * Templates: the emails a journey sends, addressed by key like a journey.
10351
+ * A template has versions and nothing else (ADR 0011), so there is no
10352
+ * create and no update here: templates arrive with a push, and the only
10353
+ * writes are rendering one to look at it, sending one to yourself, and
10354
+ * deleting the key.
10355
+ *
10356
+ * `preview` and `testSend` both render the latest version that finished
10357
+ * compiling, in the sandbox, with the props the caller passes. `testSend`
10358
+ * then sends that message to the caller's own address through the
10359
+ * organization's default sender: it reaches no recipient, so it passes no
10360
+ * consent gate, counts against nothing, and is logged as a test.
10232
10361
  */
10233
- const mergedRedirect = { 307: {
10234
- description: "The profile was merged into another; Location carries the survivor's URL",
10362
+ const params$2 = z.object({ key: z.string() });
10363
+ const templates = defineModule(defineRoute({
10364
+ method: "get",
10365
+ path: "/v1/templates",
10366
+ operationId: "templates.list",
10367
+ tags: ["templates"],
10368
+ summary: "List the organization's email templates",
10369
+ security: SESSION_AUTH,
10370
+ request: { query: listTemplatesQuerySchema },
10371
+ responses: {
10372
+ 200: list(templateSchema),
10373
+ ...sessionErrors,
10374
+ ...errors("validation_failed")
10375
+ }
10376
+ }), defineRoute({
10377
+ method: "get",
10378
+ path: "/v1/templates/{key}",
10379
+ operationId: "templates.get",
10380
+ tags: ["templates"],
10381
+ summary: "Inspect one email template",
10382
+ security: SESSION_AUTH,
10383
+ request: { params: params$2 },
10384
+ responses: {
10385
+ 200: envelope(templateSchema),
10386
+ ...sessionErrors,
10387
+ ...errors("not_found")
10388
+ }
10389
+ }), defineRoute({
10390
+ method: "post",
10391
+ path: "/v1/templates/{key}/preview",
10392
+ operationId: "templates.preview",
10393
+ tags: ["templates"],
10394
+ summary: "Render a template with the props you pass",
10395
+ security: SESSION_AUTH,
10396
+ request: {
10397
+ params: params$2,
10398
+ body: jsonBody(renderTemplateBodySchema)
10399
+ },
10400
+ responses: {
10401
+ 200: envelope(templatePreviewSchema, "The rendered message"),
10402
+ ...sessionErrors,
10403
+ ...errors("not_found", "version_not_ready", "validation_failed", "malformed_request", "dependency_unavailable")
10404
+ }
10405
+ }), defineRoute({
10406
+ method: "post",
10407
+ path: "/v1/templates/{key}/test-send",
10408
+ operationId: "templates.testSend",
10409
+ tags: ["templates"],
10410
+ summary: "Send a rendered template to your own address",
10411
+ security: SESSION_AUTH,
10412
+ request: {
10413
+ params: params$2,
10414
+ body: jsonBody(renderTemplateBodySchema)
10415
+ },
10416
+ responses: {
10417
+ 201: envelope(deliverySchema, "The test send, as it was logged"),
10418
+ ...sessionErrors,
10419
+ ...errors("not_found", "version_not_ready", "validation_failed", "malformed_request", "dependency_unavailable")
10420
+ }
10421
+ }), defineRoute({
10422
+ method: "delete",
10423
+ path: "/v1/templates/{key}",
10424
+ operationId: "templates.delete",
10425
+ tags: ["templates"],
10426
+ summary: "Delete an email template",
10427
+ security: SESSION_AUTH,
10428
+ request: { params: params$2 },
10429
+ responses: {
10430
+ 200: envelope(deletedSchema),
10431
+ ...sessionErrors,
10432
+ ...errors("not_found", "conflict")
10433
+ }
10434
+ }));
10435
+
10436
+ //#endregion
10437
+ //#region ../../packages/shared/src/contract/users.ts
10438
+ const params$1 = z.object({ profileId: z.string() });
10439
+ /**
10440
+ * The answer a merged-away profile id gets (spec: Identity): 307 to the
10441
+ * same path with the survivor's id, so the method and body of a redirected
10442
+ * call survive the hop. Empty body; the `Location` header is the answer.
10443
+ */
10444
+ const mergedRedirect = { 307: {
10445
+ description: "The profile was merged into another; Location carries the survivor's URL",
10235
10446
  headers: z.object({ Location: z.string().meta({ description: "The survivor's URL" }) })
10236
10447
  } };
10237
10448
  /**
@@ -10360,6 +10571,29 @@ const users = defineModule(defineRoute({
10360
10571
  }
10361
10572
  }));
10362
10573
 
10574
+ //#endregion
10575
+ //#region ../../packages/shared/src/contract/versions.ts
10576
+ /**
10577
+ * Versions: one key's history, newest first. A version is read-only and
10578
+ * addressed through its key rather than on its own path, because "which
10579
+ * code does this journey run" is the only question asked of it, and the
10580
+ * answer is the first row.
10581
+ */
10582
+ const versions = defineModule(defineRoute({
10583
+ method: "get",
10584
+ path: "/v1/versions",
10585
+ operationId: "versions.list",
10586
+ tags: ["versions"],
10587
+ summary: "List a journey's or a template's versions, newest first",
10588
+ security: SESSION_AUTH,
10589
+ request: { query: listVersionsQuerySchema },
10590
+ responses: {
10591
+ 200: list(versionSchema),
10592
+ ...sessionErrors,
10593
+ ...errors("validation_failed")
10594
+ }
10595
+ }));
10596
+
10363
10597
  //#endregion
10364
10598
  //#region ../../packages/shared/src/contract/violations.ts
10365
10599
  const params = z.object({ id: z.string() });
@@ -10434,10 +10668,11 @@ const contract = {
10434
10668
  segments,
10435
10669
  project,
10436
10670
  artifacts,
10437
- releases,
10438
- deployments,
10671
+ pushes,
10672
+ versions,
10439
10673
  executions,
10440
10674
  journeys,
10675
+ templates,
10441
10676
  destinations,
10442
10677
  domains,
10443
10678
  deliveries,
@@ -10811,520 +11046,55 @@ function registerContractCommands(program, run) {
10811
11046
  }
10812
11047
 
10813
11048
  //#endregion
10814
- //#region src/commands/push.ts
10815
- /**
10816
- * How long to wait for a compile before giving up on the poll. A minute past
10817
- * the server's own whole-release cap, so the normal end of a stuck compile is
10818
- * the release turning `failed` with a reason rather than the CLI shrugging.
10819
- */
10820
- const POLL_BUDGET_MS = RELEASE_COMPILE_TIMEOUT_MS + 6e4;
10821
- const POLL_START_MS = 250;
10822
- const POLL_MAX_MS = 2e3;
10823
- /**
10824
- * This directory's project, created when the org has none by that name.
10825
- * `cow init` usually got there first; a project pushed from a machine that
10826
- * only ever cloned the repo has not, and creating it here is what makes that
10827
- * clone work. The name is `cow.json`'s `project`.
10828
- */
10829
- async function ensureProject(client, name) {
10830
- try {
10831
- return (await client.request(contract.project["project.get"], { query: { name } })).data;
10832
- } catch (error) {
10833
- if (!(error instanceof ApiError) || error.code !== "project_missing") throw error;
10834
- }
10835
- try {
10836
- return (await client.request(contract.project["project.create"], { body: { name } })).data;
10837
- } catch (error) {
10838
- const reason = error instanceof Error ? error.message : String(error);
10839
- throw new Error(`This organization has no project and one could not be created (${reason}). Run \`cow init\` first.`);
10840
- }
10841
- }
10842
- /** Does the org already hold these bytes? A 404 is the answer, not a failure. */
10843
- async function held(client, digest) {
10844
- try {
10845
- await client.request(contract.artifacts["artifacts.head"], { params: { digest } });
10846
- return true;
10847
- } catch (error) {
10848
- if (error instanceof ApiError && error.status === 404) return false;
10849
- throw error;
10850
- }
10851
- }
10852
- /** Every artifact the manifest names, and the file `cow build` wrote it to. */
10853
- function artifactFiles(projectDir, manifest) {
10854
- const out = join(projectDir, BUILD_DIR);
10855
- return [
10856
- ...manifest.journeys.map((journey) => ({
10857
- digest: journey.bundle,
10858
- kind: "bundle",
10859
- file: join(out, "bundles", "journeys", `${journey.key}.js`)
10860
- })),
10861
- ...manifest.templates.map((template) => ({
10862
- digest: template.bundle,
10863
- kind: "bundle",
10864
- file: join(out, "bundles", "emails", `${template.key}.js`)
10865
- })),
10866
- {
10867
- digest: manifest.source,
10868
- kind: "source",
10869
- file: join(out, "source.tgz")
10870
- }
10871
- ];
10872
- }
10873
- /**
10874
- * This project's most recent release, or null when it has none yet. Scoped to
10875
- * the project because a sibling repo's push must not make this one look
10876
- * unchanged, or changed.
10877
- */
10878
- async function latestRelease(client, project) {
10879
- return (await client.request(contract.releases["releases.list"], { query: {
10880
- limit: 1,
10881
- project
10882
- } })).data[0] ?? null;
10883
- }
11049
+ //#region src/commands/enable.ts
10884
11050
  /**
10885
- * How far a compile has got, as `18/26`. Only meaningful while the release is
10886
- * pending: a `ready` release can legitimately sit below its total (every row
10887
- * from before the counter existed reads 0, and the last progress write is
10888
- * advisory), so completion is read from the status, never from this.
10889
- */
10890
- function compiledFraction(release) {
10891
- return `${release.compiledEntries}/${release.totalEntries}`;
10892
- }
10893
- /**
10894
- * The progress writer for a terminal, or nothing at all when there is no one
10895
- * watching. It rewrites one line on stderr, so what a command prints to
10896
- * stdout is the same bytes whether or not a terminal is attached.
11051
+ * `cow enable` and `cow disable`: the one gate on a journey, per
11052
+ * environment (ADR 0011). Hand-written rather than derived from
11053
+ * `journeys.enable`, because the commands are top-level names a developer
11054
+ * types, and because of the `--env` rule below; the route itself takes the
11055
+ * same set of keys they do.
10897
11056
  *
10898
- * The flag is stdout's: a redirected push is a script reading the summary,
10899
- * and it gets no cursor codes in either stream. `--json` is silent for the
10900
- * same reason, since its whole output is one envelope.
11057
+ * `--env` is never defaulted here (`requireEnvironment`): the default
11058
+ * environment is production, and a flag nobody typed must not put code in
11059
+ * front of real recipients.
10901
11060
  */
10902
- function progressLineFor(io, json) {
10903
- if (!io.isTTY || json === true) return;
10904
- let drawn = false;
10905
- return (text) => {
10906
- if (text === "" && !drawn) return;
10907
- drawn = text !== "";
10908
- io.stderr(`\r\x1b[2K${text}`);
10909
- };
10910
- }
10911
11061
  /**
10912
- * Poll until the compile settles. Backs off from a quarter second to two,
10913
- * because a small project is ready almost at once and a large one is not
10914
- * worth asking about ten times a second.
11062
+ * One call, whichever way the keys were named: the route takes the set, and
11063
+ * `--all` is the project name, resolved on the server against the rows it
11064
+ * owns (so it reaches a key whose file the tree lost). A key that is not
11065
+ * there refuses the whole call, and nothing flips.
10915
11066
  */
10916
- async function awaitCompile(client, releaseId, sleep, progress) {
10917
- let waited = 0;
10918
- let interval = POLL_START_MS;
10919
- try {
10920
- for (;;) {
10921
- const { data } = await client.request(contract.releases["releases.get"], { params: { id: releaseId } });
10922
- if (data.status !== "pending") return data;
10923
- if (waited >= POLL_BUDGET_MS) throw new Error(`Release ${releaseId} is still compiling after ${Math.round(waited / 1e3)}s (${compiledFraction(data)} compiled). Check \`cow releases get ${releaseId}\`.`);
10924
- progress?.(`Compiling ${compiledFraction(data)}`);
10925
- await sleep(interval);
10926
- waited += interval;
10927
- interval = Math.min(interval * 2, POLL_MAX_MS);
10928
- }
10929
- } finally {
10930
- progress?.("");
10931
- }
11067
+ async function setEnabled(client, selector, enabled) {
11068
+ const route = enabled ? contract.journeys["journeys.enable"] : contract.journeys["journeys.disable"];
11069
+ return (await client.request(route, { body: selector })).data;
10932
11070
  }
10933
- async function pushProject({ client, projectDir, force, sleep = (ms) => setTimeout$1(ms), progress }) {
10934
- const { manifest, config } = await buildProject(projectDir);
10935
- const project = await ensureProject(client, config.project);
10936
- if (project.orgId !== config.orgId) throw new Error(`cow.json is for ${config.orgId} but the credential belongs to ${project.orgId}. Log in to that organization, or use its deploy key.`);
10937
- const digest = manifestDigestOf(manifest);
10938
- const latest = await latestRelease(client, project.name);
10939
- if (latest && latest.manifestDigest === digest && !force) return {
10940
- release: latest,
10941
- uploaded: [],
10942
- skipped: latest.status === "failed" ? `Nothing changed since ${latest.id} #${latest.seq}, which failed to compile. Push again with --force to retry it.` : `Nothing changed since ${latest.id} #${latest.seq} (${latest.status}). Nothing to push.`
10943
- };
10944
- const uploaded = [];
10945
- for (const artifact of artifactFiles(projectDir, manifest)) {
10946
- if (await held(client, artifact.digest)) continue;
10947
- await client.request(contract.artifacts["artifacts.put"], {
10948
- params: { digest: artifact.digest },
10949
- query: { kind: artifact.kind },
10950
- body: await readFile(artifact.file)
10951
- });
10952
- uploaded.push(artifact.digest);
10953
- }
10954
- return {
10955
- release: await awaitCompile(client, (await client.request(contract.releases["releases.create"], { body: {
10956
- manifest: {
10957
- ...manifest,
10958
- protocol: 2
10959
- },
10960
- project: project.name
10961
- } })).data.id, sleep, progress),
10962
- uploaded
10963
- };
11071
+ /** What the terminal says: one line per key, naming where it now stands. */
11072
+ function flipSummary(journeys, enabled, environment) {
11073
+ if (journeys.length === 0) return `This project has no journeys to turn ${enabled ? "on" : "off"}.`;
11074
+ return journeys.map((journey) => `${journey.key} is ${enabled ? "on" : "off"} in ${environment}.`).join("\n");
10964
11075
  }
10965
- /** The human report: one line, the same one whether it compiled or not. */
10966
- function pushSummary(outcome) {
10967
- if (outcome.skipped) return outcome.skipped;
10968
- const { release } = outcome;
10969
- const uploaded = outcome.uploaded.length === 0 ? "nothing new to upload" : `${outcome.uploaded.length} artifact${outcome.uploaded.length === 1 ? "" : "s"} uploaded`;
10970
- if (release.status === "ready") return `${release.id} #${release.seq} ready (${uploaded}).`;
10971
- return `${release.id} #${release.seq} failed: ${release.error ?? "no reason recorded"}`;
10972
- }
10973
- function registerPush(program, clientFor, io) {
10974
- program.command("push").description("build the project, upload it, and create a release the server compiles").option("--force", "push even when nothing changed since the last release").action(async (opts) => {
11076
+ function register(program, clientFor, env, io, enabled) {
11077
+ const verb = enabled ? "enable" : "disable";
11078
+ program.command(verb).description(`turn journeys ${enabled ? "on" : "off"} in one environment (many keys, or --all for this project's)`).argument("[keys...]", "journey keys").option("--all", "every journey of this project").action(async (keys, opts) => {
10975
11079
  const merged = {
10976
11080
  ...program.opts(),
10977
11081
  ...opts
10978
11082
  };
10979
- const outcome = await pushProject({
10980
- client: await clientFor(merged),
10981
- projectDir: process.cwd(),
10982
- force: opts.force === true,
10983
- progress: progressLineFor(io, merged.json === true)
10984
- });
10985
- if (merged.json === true) emit({ data: outcome.release }, io, true);
10986
- else io.stdout(`${pushSummary(outcome)}\n`);
10987
- if (outcome.release.status === "failed") process.exitCode = 1;
10988
- });
10989
- }
10990
-
10991
- //#endregion
10992
- //#region src/commands/deploy.ts
10993
- /** The release to deploy, and the push message when a push produced it. */
10994
- async function releaseToDeploy({ client, projectDir, releaseId, sleep, progress }) {
10995
- if (releaseId) {
10996
- const { data } = await client.request(contract.releases["releases.get"], { params: { id: releaseId } });
10997
- return { release: data };
10998
- }
10999
- const pushed = await pushProject({
11000
- client,
11001
- projectDir,
11002
- sleep,
11003
- progress
11004
- });
11005
- return {
11006
- release: pushed.release,
11007
- skipped: pushed.skipped
11008
- };
11009
- }
11010
- async function deployProject(options) {
11011
- const { release, skipped } = await releaseToDeploy(options);
11012
- if (release.status !== "ready") throw new Error(release.status === "failed" ? `${release.id} #${release.seq} failed to compile, so there is nothing to deploy: ${release.error ?? "no reason recorded"}` : `${release.id} #${release.seq} is still compiling (${compiledFraction(release)} compiled); wait for it to be ready.`);
11013
- const { data } = await options.client.request(contract.deployments["deployments.create"], { body: { releaseId: release.id } });
11014
- return {
11015
- deployment: data,
11016
- release,
11017
- skipped
11018
- };
11019
- }
11020
- /** The human report: what is now running, and what it references but cannot find. */
11021
- function deploySummary(outcome) {
11022
- const { deployment, release } = outcome;
11023
- const lines = [`${release.id} #${release.seq} deployed to ${deployment.environment}.`];
11024
- if (outcome.skipped) lines.unshift(outcome.skipped);
11025
- for (const warning of deployment.warnings) lines.push(`Warning: ${warning}`);
11026
- if (deployment.left.length > 0) lines.push(`Still running, not in this release: ${deployment.left.join(", ")}. Remove one with \`cow journeys delete <key> --env ${deployment.environment}\`.`);
11027
- return lines.join("\n");
11028
- }
11029
- function registerDeploy(program, clientFor, env, io) {
11030
- program.command("deploy").description("push the project when it changed, then make that release the environment's current code").option("--release <id>", "deploy this existing release instead of pushing the working tree").action(async (opts) => {
11031
- const merged = {
11032
- ...program.opts(),
11033
- ...opts
11034
- };
11035
- requireEnvironment(env, merged);
11036
- const outcome = await deployProject({
11037
- client: await clientFor(merged),
11038
- projectDir: process.cwd(),
11039
- releaseId: opts.release,
11040
- progress: progressLineFor(io, merged.json === true)
11041
- });
11083
+ const environment = requireEnvironment(env, merged);
11084
+ if (merged.all === true && keys.length > 0) throw new Error(`Name the journeys or pass --all, not both: cow ${verb} welcome --env ${environment}.`);
11085
+ if (merged.all !== true && keys.length === 0) throw new Error(`Name at least one journey, or pass --all: cow ${verb} welcome --env ${environment}.`);
11086
+ const selector = merged.all === true ? { project: (await assertCowConfig(process.cwd())).project } : { keys };
11087
+ const updated = await setEnabled(await clientFor(merged), selector, enabled);
11042
11088
  if (merged.json === true) {
11043
- emit({ data: outcome.deployment }, io, true);
11089
+ emit({ data: updated }, io, true);
11044
11090
  return;
11045
11091
  }
11046
- io.stdout(`${deploySummary(outcome)}\n`);
11092
+ io.stdout(`${flipSummary(updated, enabled, environment)}\n`);
11047
11093
  });
11048
11094
  }
11049
-
11050
- //#endregion
11051
- //#region src/commands/dev.ts
11052
- /**
11053
- * `cow dev` (spec: CLI, user story 12): the inner loop. Watch the project,
11054
- * build-push-deploy to development on every save, and tail what the journeys
11055
- * did, so the distance between a saved file and a rendered email is seconds.
11056
- *
11057
- * Two halves that never block each other: a watcher whose cycle is exactly
11058
- * `cow deploy --env development` (push skips an unchanged tree, so a save
11059
- * that changes nothing costs one build), and a poller over the two logs. A
11060
- * failure in either prints and the loop keeps running: the whole point of a
11061
- * watcher is that a typo does not end the session.
11062
- *
11063
- * ponytail: polling over server-sent events. Two seconds is under the
11064
- * latency a person notices, and SSE costs a streaming endpoint, a reconnect
11065
- * story, and a proxy that buffers it. Ceiling: neither `updatedAt` column
11066
- * is indexed, so each poll walks the org's development rows; add
11067
- * `(org_id, environment, updated_at)` to executions and deliveries when a
11068
- * development environment grows past a few thousand of them.
11069
- */
11070
- /** `cow dev` is the development loop; the root `--env` does not move it. */
11071
- const DEV_ENVIRONMENT = "development";
11072
- const DEBOUNCE_MS = 300;
11073
- const POLL_MS = 2e3;
11074
- /**
11075
- * What a save can change: the two source directories and the three config
11076
- * files. Watched as three watchers rather than one recursive watch of the
11077
- * project root, because that root holds `node_modules` (a recursive watch of
11078
- * it exhausts inotify on Linux) and `.cow/build`, which this command's own
11079
- * builds write.
11080
- */
11081
- const CONFIG_FILES = [
11082
- "cow.json",
11083
- "package.json",
11084
- "tsconfig.json"
11085
- ];
11086
- const SOURCE_DIRS = ["journeys", "emails"];
11087
- /**
11088
- * ponytail: the cursor starts from the CLIENT's clock, compared server-side
11089
- * against database timestamps. A laptop running ahead of the database blanks
11090
- * the tail until the skew burns off. A server-time field on the first
11091
- * response would fix it; nothing else in the contract needs one yet.
11092
- *
11093
- * ponytail: the three collections grow for the life of the process, roughly
11094
- * a hundred bytes per execution seen. A day's dev session is thousands, not
11095
- * millions; prune below the cursor if a session ever runs long enough to
11096
- * notice.
11097
- */
11098
- function initialTailState(from = /* @__PURE__ */ new Date()) {
11099
- const since = from.toISOString();
11100
- return {
11101
- executionsSince: since,
11102
- deliveriesSince: since,
11103
- seen: /* @__PURE__ */ new Set(),
11104
- status: /* @__PURE__ */ new Map(),
11105
- logged: /* @__PURE__ */ new Map()
11106
- };
11107
- }
11108
- /**
11109
- * Every page of a list whose rows changed since the cursor. Paged to
11110
- * exhaustion on purpose: the page is ordered by when rows STARTED and
11111
- * filtered by when they last moved, so an old execution that just completed
11112
- * can sit on page two, and advancing the cursor off page one would lose it.
11113
- */
11114
- async function drain(fetchPage) {
11115
- const rows = [];
11116
- let cursor;
11117
- do {
11118
- const page = await fetchPage(cursor);
11119
- rows.push(...page.data);
11120
- cursor = page.meta?.hasMore ? page.meta.nextCursor ?? void 0 : void 0;
11121
- } while (cursor);
11122
- return rows;
11123
- }
11124
- /** The newest `updatedAt` in a batch, or the cursor when the batch is empty. */
11125
- function advance(rows, current) {
11126
- return rows.reduce((latest, row) => row.updatedAt > latest ? row.updatedAt : latest, current);
11127
- }
11128
- function executionLine(execution) {
11129
- const step = execution.step ? ` step=${execution.step}` : "";
11130
- const error = execution.error ? ` ${execution.error}` : "";
11131
- return ` ${execution.journeyKey} ${execution.profileId} ${execution.status}${step}${error}`;
11132
- }
11133
- function deliveryLine(delivery, subject, webUrl) {
11134
- const title = subject ? ` "${subject}"` : "";
11135
- const to = delivery.recipient ? ` to ${delivery.recipient}` : "";
11136
- return ` ${delivery.journey}/${delivery.step} ${delivery.status}${to}${title} ${webUrl}/automation/deliveries/${delivery.id}`;
11137
- }
11138
- /**
11139
- * One pass over both logs. Separated from the timer so a test can call it
11140
- * twice and prove each row prints once.
11141
- */
11142
- async function pollTail({ client, io, webUrl, state }) {
11143
- const executions = await drain((cursor) => client.request(contract.executions["executions.list"], { query: {
11144
- since: state.executionsSince,
11145
- limit: 100,
11146
- cursor
11147
- } }));
11148
- for (const execution of [...executions].reverse()) {
11149
- const key = `${execution.id}:${execution.updatedAt}`;
11150
- if (state.seen.has(key)) continue;
11151
- const marker = `${execution.status}:${execution.step ?? ""}`;
11152
- if (state.status.get(execution.id) !== marker) {
11153
- state.status.set(execution.id, marker);
11154
- io.stdout(`${executionLine(execution)}\n`);
11155
- }
11156
- const { data } = await client.request(contract.executions["executions.get"], { params: { id: execution.id } });
11157
- const printed = state.logged.get(execution.id) ?? 0;
11158
- for (const line of data.logs.slice(printed)) io.stdout(` log ${line.level}: ${line.message}\n`);
11159
- state.logged.set(execution.id, Math.max(printed, data.logs.length));
11160
- state.seen.add(key);
11161
- }
11162
- state.executionsSince = advance(executions, state.executionsSince);
11163
- const captured = await drain((cursor) => client.request(contract.deliveries["deliveries.list"], { query: {
11164
- since: state.deliveriesSince,
11165
- limit: 100,
11166
- cursor,
11167
- direction: "asc"
11168
- } }));
11169
- for (const delivery of captured) {
11170
- const key = `${delivery.id}:${delivery.updatedAt}`;
11171
- if (state.seen.has(key)) continue;
11172
- let subject = null;
11173
- if (delivery.channel === "email") {
11174
- const { data } = await client.request(contract.deliveries["deliveries.get"], { params: { id: delivery.id } });
11175
- subject = data.payload.rendered?.subject ?? null;
11176
- }
11177
- io.stdout(`${deliveryLine(delivery, subject, webUrl)}\n`);
11178
- state.seen.add(key);
11179
- }
11180
- state.deliveriesSince = advance(captured, state.deliveriesSince);
11181
- }
11182
- /**
11183
- * The watcher, the cycle, and the tail, until the signal aborts. Resolves
11184
- * when it does, which is what makes `Ctrl-C` a clean exit rather than a
11185
- * killed process mid-upload.
11186
- */
11187
- async function runDev(options) {
11188
- const { client, projectDir, io, signal, debounceMs = DEBOUNCE_MS, pollMs = POLL_MS, deploy = (dir, api) => deployProject({
11189
- client: api,
11190
- projectDir: dir
11191
- }) } = options;
11192
- let running = false;
11193
- let queued = false;
11194
- async function cycle() {
11195
- if (signal.aborted) return;
11196
- if (running) {
11197
- queued = true;
11198
- return;
11199
- }
11200
- running = true;
11201
- try {
11202
- io.stdout(`${deploySummary(await deploy(projectDir, client))}\n`);
11203
- } catch (error) {
11204
- io.stderr(`${error instanceof Error ? error.message : String(error)}\n`);
11205
- } finally {
11206
- running = false;
11207
- }
11208
- if (queued && !signal.aborted) {
11209
- queued = false;
11210
- await cycle();
11211
- }
11212
- }
11213
- const tail = options.tail ? (async () => {
11214
- const state = initialTailState();
11215
- while (!signal.aborted) {
11216
- await setTimeout$1(pollMs, void 0, { signal }).catch(() => {});
11217
- if (signal.aborted) return;
11218
- try {
11219
- await pollTail({
11220
- client,
11221
- io,
11222
- webUrl: options.webUrl,
11223
- state
11224
- });
11225
- } catch (error) {
11226
- io.stderr(`Tail: ${error instanceof Error ? error.message : String(error)}\n`);
11227
- }
11228
- }
11229
- })() : Promise.resolve();
11230
- let timer;
11231
- let pending = Promise.resolve();
11232
- const schedule = () => {
11233
- clearTimeout(timer);
11234
- timer = setTimeout(() => {
11235
- pending = cycle();
11236
- }, debounceMs);
11237
- };
11238
- async function watchOne(target) {
11239
- try {
11240
- for await (const event of watch(target.path, {
11241
- recursive: target.recursive,
11242
- signal
11243
- })) {
11244
- if (target.names && !target.names.includes(event.filename ?? "")) continue;
11245
- schedule();
11246
- }
11247
- } catch (error) {
11248
- if (!signal.aborted) throw error;
11249
- }
11250
- }
11251
- const watchers = (await watchTargets(projectDir)).map(watchOne);
11252
- await cycle();
11253
- try {
11254
- await Promise.all([...watchers, tail]);
11255
- } finally {
11256
- clearTimeout(timer);
11257
- await pending;
11258
- }
11259
- }
11260
- /**
11261
- * The project root (non-recursive, filtered to the three config files) plus
11262
- * each source directory that exists. Resolved once, at start: `cow init`
11263
- * creates both directories, so the only project missing one is a hand-made
11264
- * one, and there `cow dev` has to be restarted after the first template.
11265
- * The root is watched instead of the
11266
- * files themselves because an editor that saves by writing a temp file and
11267
- * renaming it over the original leaves a per-file watch pointing at an
11268
- * inode nobody will write to again.
11269
- */
11270
- async function watchTargets(projectDir) {
11271
- const targets = [{
11272
- path: projectDir,
11273
- recursive: false,
11274
- names: CONFIG_FILES
11275
- }];
11276
- for (const dir of SOURCE_DIRS) {
11277
- const path = join(projectDir, dir);
11278
- if (await stat(path).then((s) => s.isDirectory(), () => false)) targets.push({
11279
- path,
11280
- recursive: true
11281
- });
11282
- }
11283
- return targets;
11284
- }
11285
- /**
11286
- * What `cow dev` says about the credential it is about to use, before it
11287
- * uses it (spec: deploy keys and roles, "cow dev says which it is using").
11288
- * Either credential runs the whole loop, deploys and captured emails alike.
11289
- * The session is the one that stops working partway through an afternoon,
11290
- * so it is the one that gets a warning.
11291
- */
11292
- function credentialNotices(credential) {
11293
- if (credential.kind === "deployKey") return ["Using COW_DEPLOY_KEY."];
11294
- if (credential.kind !== "session") return [];
11295
- const expiry = decodeSessionToken(credential.token ?? "")?.expiresAt;
11296
- return [`Using your cached login${expiry ? `, which expires ${expiry}` : ""}. Set COW_DEPLOY_KEY to avoid logging in again.`];
11297
- }
11298
- function registerDev(program, clientFor, env, io) {
11299
- program.command("dev").description("watch the project, build, push, and deploy to development on every save, and tail what the journeys did").option("--no-tail", "skip the execution and delivery tail").action(async (opts) => {
11300
- const merged = {
11301
- ...program.opts(),
11302
- ...opts,
11303
- env: DEV_ENVIRONMENT
11304
- };
11305
- const credential = resolveCredential(env, await readCredentials(env));
11306
- if (credential.kind === "none") throw new Error(`No credential: run \`${"cow"} login\`, or set COW_DEPLOY_KEY.`);
11307
- for (const notice of credentialNotices(credential)) io.stderr(`${notice}\n`);
11308
- const controller = new AbortController();
11309
- const stop = () => {
11310
- io.stderr("\nStopped watching.\n");
11311
- controller.abort();
11312
- };
11313
- process.once("SIGINT", stop);
11314
- try {
11315
- await runDev({
11316
- client: await clientFor(merged),
11317
- projectDir: process.cwd(),
11318
- io,
11319
- webUrl: env.COW_WEB_URL ?? "http://localhost:5273",
11320
- tail: opts.tail !== false,
11321
- signal: controller.signal
11322
- });
11323
- } finally {
11324
- controller.abort();
11325
- process.off("SIGINT", stop);
11326
- }
11327
- });
11095
+ function registerEnable(program, clientFor, env, io) {
11096
+ register(program, clientFor, env, io, true);
11097
+ register(program, clientFor, env, io, false);
11328
11098
  }
11329
11099
 
11330
11100
  //#endregion
@@ -11588,7 +11358,7 @@ function registerMcp(program, clientFor, env, io) {
11588
11358
  ...program.opts(),
11589
11359
  ...opts
11590
11360
  });
11591
- if (resolveCredential(env, await readCredentials(env)).kind === "none") io.stderr("cow mcp: not logged in. Run `cow login`, or set COW_TOKEN or COW_DEPLOY_KEY.\n");
11361
+ if (resolveCredential(env, await readCredentials(env)).kind === "none") io.stderr("cow mcp: not logged in. Run `cow login`, or set COW_TOKEN or COW_PIPELINE_KEY.\n");
11592
11362
  await (await createMcpServer({ client })).connect(new StdioServerTransport());
11593
11363
  });
11594
11364
  }
@@ -11596,7 +11366,7 @@ function registerMcp(program, clientFor, env, io) {
11596
11366
  //#endregion
11597
11367
  //#region src/commands/pull.ts
11598
11368
  /**
11599
- * `cow pull` (spec: CLI): put a release's sources back on disk, so a fresh
11369
+ * `cow pull` (spec: CLI): put a push's sources back on disk, so a fresh
11600
11370
  * machine or an agent starts from what was actually pushed.
11601
11371
  *
11602
11372
  * It only ever writes the files the tarball carries: nothing is deleted, and
@@ -11605,8 +11375,6 @@ function registerMcp(program, clientFor, env, io) {
11605
11375
  * bury.
11606
11376
  */
11607
11377
  const execFileAsync = promisify(execFile);
11608
- /** Releases read per page while looking for the newest that compiled. */
11609
- const PAGE_SIZE = 50;
11610
11378
  /**
11611
11379
  * Uncommitted changes under `dir`, when it sits in a git working tree. A
11612
11380
  * directory that is not one (or a machine with no git) has nothing to lose
@@ -11638,41 +11406,30 @@ function noGitHere(error) {
11638
11406
  return failure.code === "ENOENT" || String(failure.stderr ?? "").includes("not a git repository");
11639
11407
  }
11640
11408
  /**
11641
- * The release to restore: the one named, the one an environment runs, else the
11642
- * newest that compiled. Scoped to this directory's project, so a sibling
11643
- * repo's release is never unpacked over this one's files.
11644
- */
11645
- async function resolveRelease(client, releaseId, environment, project) {
11646
- if (releaseId) return (await client.request(contract.releases["releases.get"], { params: { id: releaseId } })).data;
11647
- if (environment) {
11648
- const current = (await client.request(contract.deployments["deployments.list"], { query: {
11649
- environment,
11650
- project,
11651
- limit: 1
11652
- } })).data[0];
11653
- if (!current) throw new Error(`Nothing has been deployed to ${environment}, so there is no release to restore. Name one with --release.`);
11654
- return (await client.request(contract.releases["releases.get"], { params: { id: current.releaseId } })).data;
11655
- }
11656
- let cursor;
11657
- do {
11658
- const page = await client.request(contract.releases["releases.list"], { query: {
11659
- limit: PAGE_SIZE,
11660
- cursor,
11661
- project
11662
- } });
11663
- const ready = page.data.find((release) => release.status === "ready");
11664
- if (ready) return ready;
11665
- cursor = page.meta?.nextCursor ?? void 0;
11666
- } while (cursor);
11667
- throw new Error("This organization has no release that compiled successfully. Run `cow push` first, or name one with --release.");
11668
- }
11669
- async function pullRelease({ client, projectDir, releaseId, environment, force }) {
11670
- const release = await resolveRelease(client, releaseId, environment, await configuredProject(projectDir));
11409
+ * The push to restore: the one named, else this project's newest. Scoped to
11410
+ * this directory's project, so a sibling repo's tree is never unpacked over
11411
+ * this one's files.
11412
+ *
11413
+ * The newest rather than the newest that compiled: a push stores the whole
11414
+ * tree and its source archive whatever its keys then compiled to, so it is
11415
+ * the last thing anyone pushed either way.
11416
+ */
11417
+ async function resolvePush(client, pushId, project) {
11418
+ if (pushId) return (await client.request(contract.pushes["pushes.get"], { params: { id: pushId } })).data;
11419
+ const newest = (await client.request(contract.pushes["pushes.list"], { query: {
11420
+ limit: 1,
11421
+ project
11422
+ } })).data[0];
11423
+ if (!newest) throw new Error("This project has never been pushed, so there is nothing to restore. Run `cow push` first, or name a push with --push.");
11424
+ return newest;
11425
+ }
11426
+ async function pullPush({ client, projectDir, pushId, force }) {
11427
+ const push = await resolvePush(client, pushId, await configuredProject(projectDir));
11671
11428
  if (!force) {
11672
11429
  const dirty = await dirtyPaths(projectDir);
11673
11430
  if (dirty.length > 0) throw new Error(`This working tree has uncommitted changes and \`cow pull\` would write over them:\n ${dirty.join("\n ")}\nCommit or stash them, or pass --force.`);
11674
11431
  }
11675
- const tarball = await client.request(contract.releases["releases.source"], { params: { id: release.id } });
11432
+ const tarball = await client.request(contract.pushes["pushes.source"], { params: { id: push.id } });
11676
11433
  const files = [];
11677
11434
  const unpack = extract({
11678
11435
  cwd: projectDir,
@@ -11684,71 +11441,281 @@ async function pullRelease({ client, projectDir, releaseId, environment, force }
11684
11441
  unpack.end(Buffer.from(tarball));
11685
11442
  });
11686
11443
  return {
11687
- release,
11444
+ push,
11688
11445
  files: files.sort()
11689
11446
  };
11690
11447
  }
11691
- function registerPull(program, clientFor, env, io) {
11692
- program.command("pull").description("restore the project's source files from a release into the current directory").option("--release <id>", "the release to restore (default: the latest ready one, or what --env runs)").option("--force", "extract even when the git working tree is dirty").action(async (opts) => {
11448
+ function registerPull(program, clientFor, io) {
11449
+ program.command("pull").description("restore the project's source files from a push into the current directory").option("--push <id>", "the push to restore (default: the newest one)").option("--force", "extract even when the git working tree is dirty").action(async (opts) => {
11693
11450
  const merged = {
11694
11451
  ...program.opts(),
11695
11452
  ...opts
11696
11453
  };
11697
- const outcome = await pullRelease({
11454
+ const outcome = await pullPush({
11698
11455
  client: await clientFor(merged),
11699
11456
  projectDir: process.cwd(),
11700
- releaseId: opts.release,
11701
- environment: merged.env === void 0 ? void 0 : environmentFor(env, merged),
11457
+ pushId: opts.push,
11702
11458
  force: opts.force === true
11703
11459
  });
11704
11460
  if (merged.json === true) {
11705
11461
  emit({ data: outcome }, io, true);
11706
11462
  return;
11707
11463
  }
11708
- io.stdout(`${outcome.release.id} #${outcome.release.seq} restored, ${outcome.files.length} files:\n${outcome.files.map((file) => ` ${file}`).join("\n")}\n`);
11464
+ io.stdout(`Push #${outcome.push.seq} restored, ${outcome.files.length} files:\n${outcome.files.map((file) => ` ${file}`).join("\n")}\n`);
11709
11465
  });
11710
11466
  }
11711
11467
 
11712
11468
  //#endregion
11713
- //#region src/commands/rollback.ts
11714
- async function rollbackEnvironment({ client, environment, project }) {
11715
- const [current, previous] = (await client.request(contract.deployments["deployments.list"], { query: {
11716
- environment,
11717
- project,
11718
- limit: 2
11719
- } })).data;
11720
- if (!current) throw new Error(`Nothing has been deployed to ${environment} yet, so there is nothing to roll back.`);
11721
- if (!previous) throw new Error(`${environment} has only ever run ${current.releaseId} #${current.releaseSeq}, so there is nothing to roll back to.`);
11722
- if (previous.releaseId === current.releaseId) throw new Error(`${environment} has run ${current.releaseId} #${current.releaseSeq} for its last two deployments, so there is nothing to roll back to. Name an earlier release with \`cow deploy --release\`.`);
11723
- const { data } = await client.request(contract.deployments["deployments.create"], { body: { releaseId: previous.releaseId } });
11469
+ //#region src/commands/push.ts
11470
+ /**
11471
+ * This directory's project, created when the org has none by that name.
11472
+ * `cow init` usually got there first; a project pushed from a machine that
11473
+ * only ever cloned the repo has not, and creating it here is what makes that
11474
+ * clone work. The name is `cow.json`'s `project`.
11475
+ */
11476
+ async function ensureProject(client, name) {
11477
+ try {
11478
+ return (await client.request(contract.project["project.get"], { query: { name } })).data;
11479
+ } catch (error) {
11480
+ if (!(error instanceof ApiError) || error.code !== "project_missing") throw error;
11481
+ }
11482
+ try {
11483
+ return (await client.request(contract.project["project.create"], { body: { name } })).data;
11484
+ } catch (error) {
11485
+ const reason = error instanceof Error ? error.message : String(error);
11486
+ throw new Error(`This organization has no project and one could not be created (${reason}). Run \`cow init\` first.`);
11487
+ }
11488
+ }
11489
+ /** Does the org already hold these bytes? A 404 is the answer, not a failure. */
11490
+ async function held(client, digest) {
11491
+ try {
11492
+ await client.request(contract.artifacts["artifacts.head"], { params: { digest } });
11493
+ return true;
11494
+ } catch (error) {
11495
+ if (error instanceof ApiError && error.status === 404) return false;
11496
+ throw error;
11497
+ }
11498
+ }
11499
+ /** Every artifact the manifest names, and the file `cow build` wrote it to. */
11500
+ function artifactFiles(projectDir, manifest) {
11501
+ const out = join(projectDir, BUILD_DIR);
11502
+ return [
11503
+ ...manifest.journeys.map((journey) => ({
11504
+ digest: journey.bundle,
11505
+ kind: "bundle",
11506
+ file: join(out, "bundles", "journeys", `${journey.key}.js`)
11507
+ })),
11508
+ ...manifest.templates.map((template) => ({
11509
+ digest: template.bundle,
11510
+ kind: "bundle",
11511
+ file: join(out, "bundles", "emails", `${template.key}.js`)
11512
+ })),
11513
+ {
11514
+ digest: manifest.source,
11515
+ kind: "source",
11516
+ file: join(out, "source.tgz")
11517
+ }
11518
+ ];
11519
+ }
11520
+ async function pushProject({ client, projectDir }) {
11521
+ const { manifest, config } = await buildProject(projectDir);
11522
+ const project = await ensureProject(client, config.project);
11523
+ if (project.orgId !== config.orgId) throw new Error(`cow.json is for ${config.orgId} but the credential belongs to ${project.orgId}. Log in to that organization, use its pipeline key, or select a config for it with --config or COW_CONFIG (for example cow.local.json).`);
11524
+ const uploaded = [];
11525
+ for (const artifact of artifactFiles(projectDir, manifest)) {
11526
+ if (await held(client, artifact.digest)) continue;
11527
+ await client.request(contract.artifacts["artifacts.put"], {
11528
+ params: { digest: artifact.digest },
11529
+ query: { kind: artifact.kind },
11530
+ body: await readFile(artifact.file)
11531
+ });
11532
+ uploaded.push(artifact.digest);
11533
+ }
11724
11534
  return {
11725
- deployment: data,
11726
- from: current,
11727
- to: previous
11535
+ push: (await client.request(contract.pushes["pushes.create"], { body: {
11536
+ manifest: {
11537
+ ...manifest,
11538
+ protocol: 2
11539
+ },
11540
+ project: project.name
11541
+ } })).data,
11542
+ uploaded
11728
11543
  };
11729
11544
  }
11730
- function rollbackSummary(outcome) {
11731
- const { deployment, from, to } = outcome;
11732
- const lines = [`${deployment.environment} rolled back from ${from.releaseId} #${from.releaseSeq} to ${to.releaseId} #${to.releaseSeq}.`];
11733
- for (const warning of deployment.warnings) lines.push(`Warning: ${warning}`);
11734
- return lines.join("\n");
11545
+ /**
11546
+ * The human report: what was stored, and one line per changed key with the
11547
+ * page that key now has on the dashboard. A key whose code did not change
11548
+ * gets no new version, so a push of an unchanged tree says exactly that.
11549
+ */
11550
+ function pushSummary(outcome, webUrl) {
11551
+ const { push } = outcome;
11552
+ const uploaded = outcome.uploaded.length === 0 ? "nothing new to upload" : `${outcome.uploaded.length} artifact${outcome.uploaded.length === 1 ? "" : "s"} uploaded`;
11553
+ const stored = `Push #${push.seq} stored (${uploaded}).`;
11554
+ if (push.versions.length === 0) return `${stored} Nothing changed.`;
11555
+ const width = Math.max(...push.versions.map((version) => version.key.length));
11556
+ 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" : "templates"}/${version.key}`)].join("\n");
11735
11557
  }
11736
- function registerRollback(program, clientFor, env, io) {
11737
- program.command("rollback").description("redeploy the release the environment ran before its current one").action(async (opts) => {
11558
+ function registerPush(program, clientFor, env, io) {
11559
+ program.command("push").description("build the project and upload it; the server compiles what changed").action(async (opts) => {
11738
11560
  const merged = {
11739
11561
  ...program.opts(),
11740
11562
  ...opts
11741
11563
  };
11742
- const outcome = await rollbackEnvironment({
11564
+ const outcome = await pushProject({
11743
11565
  client: await clientFor(merged),
11744
- environment: requireEnvironment(env, merged),
11745
- project: await configuredProject(process.cwd())
11566
+ projectDir: process.cwd()
11746
11567
  });
11747
11568
  if (merged.json === true) {
11748
- emit({ data: outcome.deployment }, io, true);
11569
+ emit({ data: outcome.push }, io, true);
11570
+ return;
11571
+ }
11572
+ const webUrl = resolveWebUrl(env, (await readCowConfig(process.cwd()))?.webUrl);
11573
+ io.stdout(`${pushSummary(outcome, webUrl)}\n`);
11574
+ });
11575
+ }
11576
+
11577
+ //#endregion
11578
+ //#region src/commands/status.ts
11579
+ /**
11580
+ * `cow status`: what the server holds for every key of this project, and how
11581
+ * far the tree in front of the developer has moved from it. Hand-written
11582
+ * rather than derived from `project.status`, because the drift is the half
11583
+ * of the answer no server read can know: it comes from building the project
11584
+ * here and comparing the bundle digests with the versions the server has.
11585
+ */
11586
+ /** Largest first: the first unit the gap fills is the one that reads best. */
11587
+ const UNITS = [
11588
+ ["year", 31536e6],
11589
+ ["month", 2592e6],
11590
+ ["week", 6048e5],
11591
+ ["day", 864e5],
11592
+ ["hour", 36e5],
11593
+ ["minute", 6e4],
11594
+ ["second", 1e3]
11595
+ ];
11596
+ const relative$1 = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
11597
+ /** The gap between an instant and now, in words. */
11598
+ function ago(value, now) {
11599
+ const gap = new Date(value).getTime() - now.getTime();
11600
+ for (const [unit, ms] of UNITS) if (Math.abs(gap) >= ms) return relative$1.format(Math.round(gap / ms), unit);
11601
+ return relative$1.format(0, "second");
11602
+ }
11603
+ function localKeys(manifest) {
11604
+ return [...manifest.journeys.map((journey) => ({
11605
+ kind: "journey",
11606
+ key: journey.key,
11607
+ bundle: journey.bundle
11608
+ })), ...manifest.templates.map((template) => ({
11609
+ kind: "template",
11610
+ key: template.key,
11611
+ bundle: template.bundle
11612
+ }))];
11613
+ }
11614
+ /**
11615
+ * The version's short name. The bundle digest rather than the compiled
11616
+ * module's, because a version is one bundle (a push that carries a bundle
11617
+ * the org already holds creates no second version) and the bundle is there
11618
+ * from the moment the push lands, while the module digest arrives only when
11619
+ * the compile finishes.
11620
+ */
11621
+ function shortDigest(bundle) {
11622
+ return bundle.replace("sha256:", "").slice(0, 8);
11623
+ }
11624
+ function bundleOf(entry) {
11625
+ const manifest = entry.latestVersion?.manifest;
11626
+ return manifest ? manifest.bundle : null;
11627
+ }
11628
+ /** "version 3f2a1b9c, pushed 2 hours ago, still compiling" */
11629
+ function versionPhrase(entry, now) {
11630
+ const version = entry.latestVersion;
11631
+ if (!version) return "no version yet";
11632
+ const state = version.status === "compiling" ? ", still compiling" : version.status === "failed" ? ", did not compile" : "";
11633
+ const bundle = bundleOf(entry);
11634
+ return `${bundle ? `version ${shortDigest(bundle)}` : "version"}, pushed ${ago(version.pushedAt, now)}${state}`;
11635
+ }
11636
+ function flagsPhrase(entry) {
11637
+ if (!entry.enabled) return "";
11638
+ const word = (on) => on ? "on" : "off";
11639
+ return `${word(entry.enabled.development)} in development, ${word(entry.enabled.production)} in production`;
11640
+ }
11641
+ function livePhrase(entry) {
11642
+ const running = (count, environment) => count === 0 ? [] : [`${count} running in ${environment}`];
11643
+ return [...running(entry.liveExecutions.development, "development"), ...running(entry.liveExecutions.production, "production")].join(", ");
11644
+ }
11645
+ function pad(text, width) {
11646
+ return text.padEnd(width);
11647
+ }
11648
+ /**
11649
+ * The whole report, as lines. Pure so a test can read it: everything it
11650
+ * needs is the server's answer, the local build, and the clock.
11651
+ *
11652
+ * A null manifest is a tree that did not build. What the server runs is
11653
+ * still the answer to most of the question, so the report keeps it and drops
11654
+ * only the comparison.
11655
+ */
11656
+ function statusLines(status, manifest, now) {
11657
+ const local = manifest ? localKeys(manifest) : [];
11658
+ const journeys = status.keys.filter((entry) => entry.kind === "journey");
11659
+ const templates = status.keys.filter((entry) => entry.kind === "template");
11660
+ const lines = [`${status.project}: ${journeys.length} ${journeys.length === 1 ? "journey" : "journeys"}, ${templates.length} ${templates.length === 1 ? "template" : "templates"} on the server.`];
11661
+ const width = Math.max(0, ...status.keys.map((entry) => entry.key.length));
11662
+ const section = (title, entries) => {
11663
+ if (entries.length === 0) return;
11664
+ const flagWidth = Math.max(0, ...entries.map((entry) => flagsPhrase(entry).length));
11665
+ lines.push("", title);
11666
+ for (const entry of entries) {
11667
+ const flags = flagsPhrase(entry);
11668
+ const live = livePhrase(entry);
11669
+ const rest = [
11670
+ ...flags === "" ? [] : [pad(flags, flagWidth)],
11671
+ versionPhrase(entry, now),
11672
+ ...live === "" ? [] : [live]
11673
+ ];
11674
+ lines.push(` ${pad(entry.key, width)} ${rest.join(" ")}`.trimEnd());
11675
+ }
11676
+ };
11677
+ section("Journeys", journeys);
11678
+ section("Templates", templates);
11679
+ if (!manifest) {
11680
+ lines.push("", "This tree did not build, so nothing here is compared with it. Run cow build to see why.");
11681
+ return lines;
11682
+ }
11683
+ const changed = local.filter((entry) => {
11684
+ const served = status.keys.find((one) => one.kind === entry.kind && one.key === entry.key);
11685
+ return !served || bundleOf(served) !== entry.bundle;
11686
+ });
11687
+ const missing = status.keys.filter((entry) => !local.some((one) => one.kind === entry.kind && one.key === entry.key));
11688
+ if (changed.length > 0) lines.push("", `Changed here since the last push: ${changed.map((entry) => entry.key).join(", ")}. Run cow push.`);
11689
+ if (missing.length > 0) {
11690
+ lines.push("", "On the server and not in this tree:");
11691
+ for (const entry of missing) lines.push(` ${entry.key}, a ${entry.kind}. Delete it with cow ${entry.kind}s delete ${entry.key}.`);
11692
+ }
11693
+ for (const environment of ["development", "production"]) {
11694
+ const warnings = status.warnings[environment];
11695
+ if (warnings.length === 0) continue;
11696
+ lines.push("", `Names ${environment} does not define yet:`);
11697
+ for (const warning of warnings) lines.push(` ${warning}`);
11698
+ }
11699
+ return lines;
11700
+ }
11701
+ /** The project's status from the server, for this directory's `cow.json`. */
11702
+ async function readStatus(client, project) {
11703
+ return (await client.request(contract.project["project.status"], { query: { name: project } })).data;
11704
+ }
11705
+ function registerStatus(program, clientFor, io) {
11706
+ program.command("status").description("what this project's journeys and templates are doing, and what changed here since the last push").action(async (opts) => {
11707
+ const merged = {
11708
+ ...program.opts(),
11709
+ ...opts
11710
+ };
11711
+ const config = await assertCowConfig(process.cwd());
11712
+ const built = await buildProject(process.cwd()).catch(() => null);
11713
+ const status = await readStatus(await clientFor(merged), config.project);
11714
+ if (merged.json === true) {
11715
+ emit({ data: status }, io, true);
11749
11716
  return;
11750
11717
  }
11751
- io.stdout(`${rollbackSummary(outcome)}\n`);
11718
+ io.stdout(`${statusLines(status, built?.manifest ?? null, /* @__PURE__ */ new Date()).join("\n")}\n`);
11752
11719
  });
11753
11720
  }
11754
11721
 
@@ -12007,9 +11974,8 @@ function registerTest(program, io) {
12007
11974
  //#region src/commands/index.ts
12008
11975
  /**
12009
11976
  * The environment a call acts on: the root `--env`, else `COW_ENVIRONMENT`,
12010
- * else production. The client sets it as the selection header; the commands
12011
- * that also need the value itself (a deployment list is filtered by a query
12012
- * parameter, not by the header) read it from here, so the two can never
11977
+ * else production. The client sets it as the selection header; a command
11978
+ * that also needs the value itself reads it from here, so the two can never
12013
11979
  * disagree.
12014
11980
  */
12015
11981
  function environmentFor(env, opts) {
@@ -12017,11 +11983,11 @@ function environmentFor(env, opts) {
12017
11983
  return flag.success ? flag.data : env.COW_ENVIRONMENT ?? "production";
12018
11984
  }
12019
11985
  /**
12020
- * The same value, but only when the caller actually chose one. `cow deploy`
12021
- * and `cow rollback` change what real users are running, and the default is
12022
- * production: a bare `cow rollback` typed while thinking about development
12023
- * would roll production back. A pipeline is unaffected, because
12024
- * `COW_ENVIRONMENT` counts as having chosen.
11986
+ * The same value, but only when the caller actually chose one. `cow enable`
11987
+ * and `cow disable` change what real recipients get, and the default is
11988
+ * production: a bare `cow enable welcome` typed while thinking about
11989
+ * development would turn it on in production. A pipeline is unaffected,
11990
+ * because `COW_ENVIRONMENT` counts as having chosen.
12025
11991
  */
12026
11992
  function requireEnvironment(env, opts) {
12027
11993
  if (opts.env === void 0 && env.COW_ENVIRONMENT === void 0) throw new Error(`Name the environment to act on: --env ${ENVIRONMENTS.join(" or --env ")}, or set COW_ENVIRONMENT.`);
@@ -12067,11 +12033,10 @@ function buildProgram(env, io) {
12067
12033
  registerInit(program, env, io);
12068
12034
  registerAdd(program, io);
12069
12035
  registerBuild(program, io);
12070
- registerPush(program, clientFor, io);
12071
- registerPull(program, clientFor, env, io);
12072
- registerDeploy(program, clientFor, env, io);
12073
- registerDev(program, clientFor, env, io);
12074
- registerRollback(program, clientFor, env, io);
12036
+ registerPush(program, clientFor, env, io);
12037
+ registerStatus(program, clientFor, io);
12038
+ registerEnable(program, clientFor, env, io);
12039
+ registerPull(program, clientFor, io);
12075
12040
  registerTest(program, io);
12076
12041
  registerMcp(program, clientFor, env, io);
12077
12042
  return program;
@@ -12089,8 +12054,8 @@ const envSchema = z.object({
12089
12054
  COW_CREDENTIALS_PATH: z.string().min(1).optional(),
12090
12055
  /** A session token passed explicitly instead of a cached `cow login`. */
12091
12056
  COW_TOKEN: z.string().min(1).optional(),
12092
- /** An org deploy key for CI; when set it wins over the cached session (ticket 05 reads it). */
12093
- COW_DEPLOY_KEY: z.string().min(1).optional(),
12057
+ /** An org pipeline key for CI; when set it wins over the cached session (ticket 05 reads it). */
12058
+ COW_PIPELINE_KEY: z.string().min(1).optional(),
12094
12059
  /** The environment admin calls select; `--env` overrides it, production is the default. */
12095
12060
  COW_ENVIRONMENT: environmentSchema.optional(),
12096
12061
  /**