atmn 2.0.41 → 2.0.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/bin.js +405 -392
  2. package/dist/index.js +20 -21
  3. package/dist/tsconfig.tsbuildinfo +1 -1
  4. package/package.json +2 -2
  5. package/src/actions/pull/webhooks/appendWebhook.ts +6 -4
  6. package/src/actions/pull/webhooks/applyWebhooksPull.ts +28 -105
  7. package/src/actions/pull/webhooks/deleteWebhook.ts +59 -0
  8. package/src/actions/pull/webhooks/locateWebhook.ts +9 -1
  9. package/src/actions/pull/webhooks/readWebhookEnvs.ts +7 -12
  10. package/src/actions/pull/webhooks/types.ts +3 -2
  11. package/src/actions/pull/webhooks/updateWebhook.ts +37 -42
  12. package/src/actions/pull/webhooks/webhookEnvSkipWarning.ts +29 -0
  13. package/src/actions/pull/webhooks/webhookPullEnvs.ts +5 -5
  14. package/src/actions/pull.ts +1 -1
  15. package/src/actions/push.ts +30 -12
  16. package/src/actions/webhooks/applyWebhooks.ts +41 -10
  17. package/src/actions/webhooks/previewWebhookEnvs.ts +213 -0
  18. package/src/actions/webhooks/resolveWebhooksForEnv.ts +5 -16
  19. package/src/actions/webhooks/types/webhookEnv.ts +1 -3
  20. package/src/actions/webhooks/webhookEnvFailures.ts +27 -0
  21. package/src/actions/webhooks/webhookSecretName.ts +10 -9
  22. package/src/actions/webhooks/writeWebhookSecrets.ts +5 -2
  23. package/src/auth/runOAuthFlow.ts +1 -1
  24. package/src/cli.ts +23 -25
  25. package/src/generated/apiRoutes.ts +43 -12
  26. package/src/generated/client.ts +10 -10
  27. package/src/generated/emit.ts +4 -4
  28. package/src/generated/emitRuntime.ts +3 -3
  29. package/src/generated/lintRules.ts +16 -20
  30. package/src/generated/lintRuntime.ts +10 -0
  31. package/src/generated/skills.ts +10 -10
  32. package/src/generated/webhooks.ts +7 -9
  33. package/src/generated/wire.ts +0 -1
  34. package/src/render/renderPreview.ts +25 -9
  35. package/src/render/renderWebhooks.ts +9 -3
  36. package/src/actions/pull/webhooks/removeWebhookEnv.ts +0 -141
  37. package/src/actions/webhooks/isDashboardWebhook.ts +0 -9
  38. package/src/actions/webhooks/newWebhookId.ts +0 -4
  39. package/src/actions/webhooks/previewWebhooks.ts +0 -40
  40. package/src/actions/webhooks/resolveWebhookEnv.ts +0 -34
package/dist/bin.js CHANGED
@@ -985,7 +985,7 @@ var API_ROUTES = [
985
985
  name: "proration_behavior",
986
986
  type: "string",
987
987
  required: false,
988
- description: "How to handle proration when updating an existing subscription. 'prorate_immediately' charges/credits prorated amounts now, 'none' skips creating any charges."
988
+ description: "How to handle proration when updating an existing subscription. 'prorate_immediately' charges/credits prorated amounts now, 'none' skips creating any charges, 'bill_difference' charges/credits the full-period price difference now without changing the billing cycle."
989
989
  },
990
990
  {
991
991
  name: "redirect_mode",
@@ -1192,7 +1192,7 @@ var API_ROUTES = [
1192
1192
  name: "billing_behavior",
1193
1193
  type: "string",
1194
1194
  required: false,
1195
- description: "Whether to prorate the immediate phase. 'none' skips proration charges and credits."
1195
+ description: "Whether to prorate the immediate phase. 'none' skips proration charges and credits, 'bill_difference' charges/credits the full-period price difference."
1196
1196
  },
1197
1197
  {
1198
1198
  name: "no_billing_changes",
@@ -1291,7 +1291,7 @@ var API_ROUTES = [
1291
1291
  name: "billing_behavior",
1292
1292
  type: "string",
1293
1293
  required: false,
1294
- description: "How to handle billing. 'prorate_immediately' charges/credits prorated amounts now, 'none' does not charge/credit anything."
1294
+ description: "How to handle billing. 'prorate_immediately' charges/credits prorated amounts now, 'none' does not charge/credit anything, 'bill_difference' charges/credits the full-period price difference now without changing the billing cycle."
1295
1295
  },
1296
1296
  {
1297
1297
  name: "billing_cycle_anchor",
@@ -1400,7 +1400,7 @@ var API_ROUTES = [
1400
1400
  name: "proration_behavior",
1401
1401
  type: "string",
1402
1402
  required: false,
1403
- description: "How to handle proration when updating an existing subscription. 'prorate_immediately' charges/credits prorated amounts now, 'none' skips creating any charges."
1403
+ description: "How to handle proration when updating an existing subscription. 'prorate_immediately' charges/credits prorated amounts now, 'none' skips creating any charges, 'bill_difference' charges/credits the full-period price difference now without changing the billing cycle."
1404
1404
  },
1405
1405
  {
1406
1406
  name: "redirect_mode",
@@ -1601,7 +1601,7 @@ var API_ROUTES = [
1601
1601
  name: "billing_behavior",
1602
1602
  type: "string",
1603
1603
  required: false,
1604
- description: "How to handle billing. 'prorate_immediately' charges/credits prorated amounts now, 'none' does not charge/credit anything."
1604
+ description: "How to handle billing. 'prorate_immediately' charges/credits prorated amounts now, 'none' does not charge/credit anything, 'bill_difference' charges/credits the full-period price difference now without changing the billing cycle."
1605
1605
  },
1606
1606
  {
1607
1607
  name: "billing_cycle_anchor",
@@ -1710,7 +1710,7 @@ var API_ROUTES = [
1710
1710
  name: "proration_behavior",
1711
1711
  type: "string",
1712
1712
  required: false,
1713
- description: "How to handle proration when updating an existing subscription. 'prorate_immediately' charges/credits prorated amounts now, 'none' skips creating any charges."
1713
+ description: "How to handle proration when updating an existing subscription. 'prorate_immediately' charges/credits prorated amounts now, 'none' skips creating any charges, 'bill_difference' charges/credits the full-period price difference now without changing the billing cycle."
1714
1714
  },
1715
1715
  {
1716
1716
  name: "redirect_mode",
@@ -1851,7 +1851,7 @@ var API_ROUTES = [
1851
1851
  name: "proration_behavior",
1852
1852
  type: "string",
1853
1853
  required: false,
1854
- description: "How to handle proration when updating an existing subscription. 'prorate_immediately' charges/credits prorated amounts now, 'none' skips creating any charges."
1854
+ description: "How to handle proration when updating an existing subscription. 'prorate_immediately' charges/credits prorated amounts now, 'none' skips creating any charges, 'bill_difference' charges/credits the full-period price difference now without changing the billing cycle."
1855
1855
  },
1856
1856
  {
1857
1857
  name: "redirect_mode",
@@ -2091,7 +2091,7 @@ var API_ROUTES = [
2091
2091
  name: "proration_behavior",
2092
2092
  type: "string",
2093
2093
  required: false,
2094
- description: "How to handle proration when updating an existing subscription. 'prorate_immediately' charges/credits prorated amounts now, 'none' skips creating any charges."
2094
+ description: "How to handle proration when updating an existing subscription. 'prorate_immediately' charges/credits prorated amounts now, 'none' skips creating any charges, 'bill_difference' charges/credits the full-period price difference now without changing the billing cycle."
2095
2095
  },
2096
2096
  {
2097
2097
  name: "subscription_id",
@@ -2209,6 +2209,33 @@ var API_ROUTES = [
2209
2209
  }
2210
2210
  ]
2211
2211
  },
2212
+ {
2213
+ group: "billing",
2214
+ method: "verify",
2215
+ path: "/v1/billing.verify",
2216
+ description: "Checks a customer's Stripe subscriptions against Autumn's record of their plans and reports any drift. Read-only: it never changes Autumn or Stripe.",
2217
+ body: "object",
2218
+ fields: [
2219
+ {
2220
+ name: "customer_id",
2221
+ type: "string",
2222
+ required: true,
2223
+ description: "Autumn customer whose Stripe subscriptions should be checked against Autumn's customer_products."
2224
+ },
2225
+ {
2226
+ name: "subscription_ids",
2227
+ type: "json",
2228
+ required: false,
2229
+ description: "Optional whitelist of Stripe subscription IDs to verify. Defaults to every subscription linked to the customer's plans, plus any other active Stripe subscription on the customer, which is reported as not linked to Autumn."
2230
+ },
2231
+ {
2232
+ name: "strict",
2233
+ type: "boolean",
2234
+ required: false,
2235
+ description: "When true, report missing usage-based items and unexpected metered Stripe items. Defaults to false."
2236
+ }
2237
+ ]
2238
+ },
2212
2239
  {
2213
2240
  group: "billing",
2214
2241
  method: "import",
@@ -2698,7 +2725,7 @@ var API_ROUTES = [
2698
2725
  group: "balances",
2699
2726
  method: "batch_track",
2700
2727
  path: "/v1/balances.batch_track",
2701
- description: "Enqueue up to 1000 usage events for asynchronous processing. Items are validated synchronously up front; validated items are then enqueued via SQS for background deduction by workers. The response returns 202 immediately and does not include balance information. On partial enqueue failure (some items fail to enqueue, others succeed), the endpoint still returns 202 and logs the failures server-side; clients should NOT retry, because retrying re-enqueues the already-succeeded items. A 503 is returned only when zero items were successfully enqueued (queue entirely unavailable) — that case is safe to retry.",
2728
+ description: "Enqueue up to 1000 usage events for asynchronous processing. Items are validated synchronously up front; validated items are then enqueued via SQS for background deduction by workers. The response returns 200 (or 202) immediately and does not include balance information. On partial enqueue failure (some items fail to enqueue, others succeed), the endpoint still returns 200 (or 202) and logs the failures server-side; clients should NOT retry, because retrying re-enqueues the already-succeeded items. A 503 is returned only when zero items were successfully enqueued (queue entirely unavailable) — that case is safe to retry.",
2702
2729
  body: "array",
2703
2730
  fields: []
2704
2731
  },
@@ -3876,8 +3903,8 @@ var API_ROUTES = [
3876
3903
  {
3877
3904
  name: "events",
3878
3905
  type: "json",
3879
- required: true,
3880
- description: "The events sent to this webhook. At least one. `vercel.*` events can't be mixed with other events."
3906
+ required: false,
3907
+ description: "The events sent to this webhook. Leave it out to send every event. `vercel.*` events can't be mixed with other events."
3881
3908
  },
3882
3909
  {
3883
3910
  name: "description",
@@ -3939,7 +3966,7 @@ var API_ROUTES = [
3939
3966
  name: "events",
3940
3967
  type: "json",
3941
3968
  required: false,
3942
- description: "The events sent to this webhook. At least one. `vercel.*` events can't be mixed with other events."
3969
+ description: "The events sent to this webhook. Pass `[]` to send every event; omit it to keep the current list. `vercel.*` events can't be mixed with other events."
3943
3970
  },
3944
3971
  {
3945
3972
  name: "description",
@@ -6218,28 +6245,38 @@ var LINT_RULES = {
6218
6245
  webhooks: {
6219
6246
  label: "webhook",
6220
6247
  idField: "id",
6221
- required: ["events", "id", "url"],
6248
+ required: ["env", "id", "url"],
6222
6249
  fields: {
6223
6250
  id: {
6224
6251
  minLength: 1,
6225
6252
  maxLength: 256,
6226
6253
  pattern: "^[a-zA-Z0-9_-]+$"
6227
6254
  },
6228
- events: {
6229
- minItems: 1
6255
+ env: {
6256
+ pattern: "^[a-z0-9_-]+$"
6257
+ },
6258
+ url: {
6259
+ pattern: "^[Hh][Tt][Tt][Pp][Ss]:\\/\\/"
6230
6260
  }
6231
6261
  },
6232
6262
  rules: [
6263
+ {
6264
+ kind: "notMap",
6265
+ field: "url",
6266
+ because: 'Each webhook() is one endpoint in one env: split into one webhook() per env, e.g. webhook({ id, env: "live", url }).'
6267
+ },
6233
6268
  {
6234
6269
  kind: "unique",
6235
6270
  field: "id",
6236
- because: "Two webhooks claiming one id race to define the same endpoint."
6271
+ alongside: "env",
6272
+ because: "Two webhooks claiming one id in one env race to define the same endpoint."
6237
6273
  },
6238
6274
  {
6239
6275
  kind: "unique",
6240
6276
  field: "id",
6277
+ alongside: "env",
6241
6278
  asEnvName: true,
6242
- because: "Each webhook's signing secret is saved as AUTUMN_WEBHOOK_<ID>_SECRET, so these two would overwrite each other's secret. Rename one."
6279
+ because: "Each webhook's signing secret is saved under its id in an env var name, so these two would overwrite each other's secret. Rename one."
6243
6280
  },
6244
6281
  {
6245
6282
  kind: "rejects",
@@ -6253,12 +6290,6 @@ var LINT_RULES = {
6253
6290
  prefix: "vercel.",
6254
6291
  because: "vercel.* events can't be mixed with other events in one webhook: they're delivered from a separate app. Make one webhook for each."
6255
6292
  },
6256
- {
6257
- kind: "nonEmpty",
6258
- field: "url",
6259
- warning: true,
6260
- because: "With no environment key the webhook is registered nowhere. Add `live`, `sandbox` or a sandbox's slug."
6261
- },
6262
6293
  {
6263
6294
  kind: "knownValues",
6264
6295
  field: "events",
@@ -6280,14 +6311,6 @@ var LINT_RULES = {
6280
6311
  because: "isn't known to this atmn version; the server will check it."
6281
6312
  }
6282
6313
  ]
6283
- },
6284
- "webhooks.url": {
6285
- keys: {
6286
- pattern: "^[a-z0-9_-]+$"
6287
- },
6288
- values: {
6289
- pattern: "^[Hh][Tt][Tt][Pp][Ss]:\\/\\/"
6290
- }
6291
6314
  }
6292
6315
  };
6293
6316
 
@@ -6508,6 +6531,10 @@ var entryRuleFailures = ({
6508
6531
  return [];
6509
6532
  return value.filter((item) => typeof item === "string" && !rule.values.includes(item)).map((item) => `\`${item}\` ${rule.because}`);
6510
6533
  }
6534
+ case "notMap": {
6535
+ const value = entry[rule.field];
6536
+ return isEntry(value) ? [`${rule.field} is a map. ${rule.because}`] : [];
6537
+ }
6511
6538
  case "uniformPrefix": {
6512
6539
  const value = entry[rule.field];
6513
6540
  if (!Array.isArray(value))
@@ -6998,8 +7025,7 @@ var CATALOG_HINTS = hintsOf({
6998
7025
  "plans.variants.customize.upsertLicenses.customize.addItems.featureOverride.creditSchema.multipliers",
6999
7026
  "plans.variants.customize.upsertLicenses.customize.addItems.featureOverride.creditSchema.multipliers.*.match",
7000
7027
  "plans.variants.customize.upsertLicenses.customize.addItems.featureOverride.markups.modelMarkups",
7001
- "plans.variants.customize.upsertLicenses.customize.addItems.featureOverride.markups.providerMarkups",
7002
- "webhooks.url"
7028
+ "plans.variants.customize.upsertLicenses.customize.addItems.featureOverride.markups.providerMarkups"
7003
7029
  ],
7004
7030
  frozenPaths: [
7005
7031
  "plans.licenses.metadata",
@@ -7297,7 +7323,7 @@ var createClient = (options) => ({
7297
7323
  });
7298
7324
 
7299
7325
  // src/version.ts
7300
- var version = "2.0.41";
7326
+ var version = "2.0.43";
7301
7327
 
7302
7328
  // src/http/autumnFetch.ts
7303
7329
  var runtime = typeof Bun !== "undefined" ? `bun ${Bun.version}` : `node ${process.versions.node}`;
@@ -9435,11 +9461,11 @@ var SYNCED_LISTS = {
9435
9461
  builder: "webhook",
9436
9462
  idField: "id",
9437
9463
  responseIdField: "id",
9438
- keys: ["id", "url", "events", "description", "disabled"],
9439
- required: ["events", "id", "url"],
9440
- paths: ["description", "disabled", "events", "id", "url"],
9464
+ keys: ["id", "env", "url", "events", "description", "disabled"],
9465
+ required: ["env", "id", "url"],
9466
+ paths: ["description", "disabled", "env", "events", "id", "url"],
9441
9467
  pull: true,
9442
- envKeyed: ["url"]
9468
+ envField: "env"
9443
9469
  }
9444
9470
  };
9445
9471
  var NESTED_FIXTURES = {
@@ -9554,16 +9580,16 @@ import { dirname as dirname4, join as join7 } from "node:path";
9554
9580
  import chalk3 from "chalk";
9555
9581
 
9556
9582
  // src/generated/skills.ts
9557
- var SKILLS_VERSION = "2.0.41";
9583
+ var SKILLS_VERSION = "2.0.43";
9558
9584
  var SKILLS = [
9559
9585
  {
9560
9586
  name: "autumn-setup",
9561
9587
  description: "First-time Autumn setup — install the atmn CLI, connect to an org (sign in, or keyless with no account), turn the user's pricing into autumn.config.ts, and push it to a sandbox org. Use when the user is new to Autumn, pastes an Autumn setup prompt, or asks to set up Autumn, add billing, monetization, subscriptions, usage-based billing, pricing, or plans. If Autumn is already set up, use autumn-catalog instead.",
9562
- version: "2.0.41",
9588
+ version: "2.0.43",
9563
9589
  markdown: `---
9564
9590
  name: autumn-setup
9565
9591
  description: First-time Autumn setup — install the atmn CLI, connect to an org (sign in, or keyless with no account), turn the user's pricing into autumn.config.ts, and push it to a sandbox org. Use when the user is new to Autumn, pastes an Autumn setup prompt, or asks to set up Autumn, add billing, monetization, subscriptions, usage-based billing, pricing, or plans. If Autumn is already set up, use autumn-catalog instead.
9566
- version: 2.0.41
9592
+ version: 2.0.43
9567
9593
  ---
9568
9594
 
9569
9595
  # Setup
@@ -9721,11 +9747,11 @@ Then stop. Don't keep building, don't tour the dashboard, don't deploy anything.
9721
9747
  {
9722
9748
  name: "autumn-catalog",
9723
9749
  description: "Modeling a user's pricing into an Autumn catalog — deciding the structure (plans, variants, add-ons, licenses, credit systems, pooled balances) before writing config, then filling in the numbers. Use when the user describes their pricing or asks to model, change, or push a catalog.",
9724
- version: "2.0.41",
9750
+ version: "2.0.43",
9725
9751
  markdown: `---
9726
9752
  name: autumn-catalog
9727
9753
  description: Modeling a user's pricing into an Autumn catalog — deciding the structure (plans, variants, add-ons, licenses, credit systems, pooled balances) before writing config, then filling in the numbers. Use when the user describes their pricing or asks to model, change, or push a catalog.
9728
- version: 2.0.41
9754
+ version: 2.0.43
9729
9755
  ---
9730
9756
 
9731
9757
  # Catalog
@@ -13592,7 +13618,7 @@ Pitch: "Pro $20/mo or $200/yr — 1,000 messages per month either way."
13592
13618
  },
13593
13619
  {
13594
13620
  path: "references/atmn.md",
13595
- contents: "# atmn catalog flows\n\nContents: commands · when to use it · the config is the catalog's state (versions, renames, drafts) · variants and licenses · config shapes · splitting the config · update loop · pull · webhooks · sandboxes and keys · what to show the user.\n\nUse `atmn` when a project has or should have an `autumn.config.ts` source of truth.\n\nCommands — these two, not `atmn preview` (that does not exist):\n\n```sh\natmn push # lints, then previews the diff; applies nothing\natmn push --yes # apply exactly what the preview showed\n```\n\n`push` without `--yes` is always a dry run, on a clean org too, and it never asks anything: `--yes` is the only gate. The commands that do ask (`init`, `login --keyless`, `sandbox use`, `skills install`) print the flag to pass instead whenever there is no TTY, which is every agent run; `--headless` forces that in a terminal.\n\n## When to use it\n\n- New project: run `atmn init`. It connects (sign in, or keyless), places the config, pulls whatever the org already holds, and installs these skills beside it.\n- Existing project: if `autumn.config.ts` exists, run `atmn pull` first so every row carries its server ids, then edit and push. Never rewrite a config that predates you from scratch.\n- Use MCP/API directly when the user wants dashboard/API-first changes or there is no local config workflow.\n\n## The config is the catalog's state\n\nThe document is the whole desired catalog for every collection it states. A plan or feature missing from a stated `plans` / `features` is a deletion (archived when customers depend on it). A collection left out entirely is not managed. That rule reaches down to versions: **a version row missing from `plans` is deleted too**.\n\nEvery version of a plan is a row in `plans`. Rows of one plan share a `planId`; each names its version with `versionSlug`; exactly one is `active: true` and the rest `active: false`. There is no history collection and no timeline — array order means nothing. What a version *is* (a group of customers, not a step in time) is the `autumn-concepts` skill's: read `references/plan.md` in the `autumn-concepts` skill. This file owns how rows express it.\n\nTwo fields make a row addressable:\n\n- `planId` + `versionSlug` — what you write. The pair names exactly one version. `versionSlug` is required on every plan row and every variant row, including a plan's only version; an omitted slug is a lint error, never an implicit `v1`.\n- `internalId` — the server's stable id. It is minted on the first `push --yes` and written back into the fixture; `pull` writes it for every row that lacks one. A fixture carrying it can be renamed (`planId`, `featureId`, `versionSlug`) and the server treats that as a rename, not a delete plus create.\n\n**This is where atmn differs from the API and the dashboard.** Those send one plan entry plus flags — `versioning`, `active`, `propagate` — as `references/catalog-update.md` describes. A config has none of those flags. The intent is read off the rows — which rows exist, which one is active, which carry an `internalId` — and the preview reports what the server derived. If the preview says something other than what you meant, change the rows and preview again; never look for a flag.\n\n| You want | Row edit | Preview shows |\n|---|---|---|\n| Change a version in place (active or history) | edit that row; keep its `versionSlug` and `internalId` | `~ pro@v1`, a migration for its customers |\n| The same change on every version | make the edit on every row of the plan | one `~` per row |\n| A new version; customers stay on the old one | add a row: same `planId`, new `versionSlug`, `active: true`, **no `internalId`**; flip the old row to `active: false` | `+ pro@v2`, and `active: true -> false` on the old row |\n| A draft nobody can buy yet | add the row with `active: false` beside the active one | `+` with no pointer move |\n| Rename the plan id | change `planId` on **every** row of the plan (all carry `internalId`) | a rename, not `-` + `+` |\n| Rename a version | change `versionSlug` on a row that carries `internalId` | `Version slug` rename |\n| Retire a version | remove its row | `-`; refused while customers are on it (it names both exits: migrate them, or archive the whole plan) |\n\nGuards the lint applies before anything is sent: a plan with no active row or two active rows (`mark the one customers can buy active: true and the rest active: false`), a missing `versionSlug`, the same `planId` + `versionSlug` twice. A plan whose only row is `active: false` is refused — a draft needs an active sibling.\n\nNumbers vs slugs: the server numbers versions in creation order and the preview labels rows with that number (`pro@v2`). The config never states numbers, only slugs. Push a `v1` row after `v2` already exists and the server numbers it higher; the slug still says `v1`. Never read a preview number back as the slug.\n\n## Variants and licenses\n\nWhat a variant's customize can change, when a base edit reaches a variant, and how a license link anchors to a child version are catalog-wide: `references/catalog-update.md`. What is atmn's:\n\n- A variant is an entry in its base row's `variants` array and a license link an entry in the parent row's `licenses` array: `variant({...})` and `license({...})` fixtures, inline or imported from their own files, edited in place there. Pull writes new ones in that form. Every entry the config lists is a declared overlay; there is no `propagate` in a config, so a base edit reaches a variant through the entry you write, not a follow flag.\n- Minting a base version means listing the variant entries again under the new base row, each with the new `versionSlug` and no `internalId`. The old entries stay under the old base row. One variant version cannot serve two base rows; the lint names both rows and says to version and relink the variant.\n- To retire a variant, set `archived: true` on its entry. A variant left out of the array is a deletion, refused while customers hold it.\n- Every `license({...})` states `versionSlug` — the lint refuses one without it — because the link is pinned to that child version and a config that names it links the same version in every environment. Pull writes it back. Minting a child version moves no parent; relinking a parent is editing that slug.\n\n## Config shapes\n\n`autumn.config.ts` uses the atmn package types, not raw API JSON. Field names are camelCase: `featureId`, `planId`, `billingMethod`, `billingUnits`, `freeTrial`, `intervalCount`, `versionSlug`. Follow the exported types from the package when editing config. Amounts are plain dollars: $20 is `20`, never `2000`.\n\nBuilders — `feature`, `plan`, `variant`, `license`. Items are plain objects on the plan; there is no `item()` builder and fixtures expose `featureId` / `planId`, never `.id`. A plan with an active `v2`, its `v1` kept for the customers still on it, each with an annual variant:\n\n```ts\nimport { atmn, feature, plan, variant } from \"atmn\";\n\nexport const messages = feature({\n featureId: \"messages\",\n name: \"Messages\",\n type: \"metered\",\n consumable: true,\n});\n\nexport const proAnnualV2 = variant({\n variantPlanId: \"pro_annual\",\n versionSlug: \"v2\",\n name: \"Pro Annual\",\n customize: { price: { amount: 250, interval: \"year\" } },\n});\n\nexport const proAnnualV1 = variant({\n variantPlanId: \"pro_annual\",\n versionSlug: \"v1\",\n name: \"Pro Annual\",\n customize: { price: { amount: 200, interval: \"year\" } },\n});\n\nexport const proV2 = plan({\n planId: \"pro\",\n versionSlug: \"v2\",\n active: true,\n name: \"Pro\",\n price: { amount: 25, interval: \"month\" },\n items: [{ featureId: messages.featureId, included: 10000, reset: { interval: \"month\" } }],\n variants: [proAnnualV2],\n});\n\nexport const proV1 = plan({\n internalId: \"prod_…\", // written back by push --yes / pull\n planId: \"pro\",\n versionSlug: \"v1\",\n active: false,\n name: \"Pro\",\n price: { amount: 20, interval: \"month\" },\n items: [{ featureId: messages.featureId, included: 5000, reset: { interval: \"month\" } }],\n variants: [proAnnualV1],\n});\n\nexport default atmn({ features: [messages], plans: [proV2, proV1] });\n```\n\nBefore `proV2` existed, `proV1` was the active row; minting `v2` was adding `proV2` without an `internalId` and flipping `proV1` to `active: false`.\n\nUsage-priced item:\n\n```ts\n{\n featureId: messages.featureId,\n included: 10000,\n reset: { interval: \"month\" },\n price: {\n amount: 0.9,\n billingMethod: \"usage_based\",\n billingUnits: 1000,\n interval: \"month\",\n },\n}\n```\n\n## Splitting the config\n\n`atmn init` scaffolds four files: `autumn.config.ts` (the root, `export default atmn({...})`), `features.ts`, `plans.ts`, `rewards.ts`. Every version of every plan lives in the `plans` array — active and history rows side by side. The config is ordinary TypeScript, so a row can be lifted into its own file under any export name and referenced from the array; `pull` follows imports and edits each fixture where it lives, and appends rows it has to add to the imported array. What it cannot do is edit a fixture that is not a plain literal — a spread, a helper call, a `.map()` — and it says so and writes nothing rather than guess.\n\n## Update loop\n\n1. Inspect or create `autumn.config.ts`.\n2. Edit the config to represent the desired catalog.\n3. Run `atmn push` to preview changes.\n4. Show the user the plan diffs, the customer impact, which plans mint a new version, and the draft migrations it would create. If the versioning is not what they meant, change the rows (table above) and preview again.\n5. Rerun `atmn push --yes` to apply the same preview.\n6. Report created/updated/deleted/archived features and plans, and the draft migrations the output lists. `push --yes` also writes `internalId` (and any `versionSlug` the server assigned) back into the fixtures — say so; those edits are expected.\n\nTwo notes push prints that are worth relaying: a plan removed while an id-less plan appears looks like a rename — pull first so the fixture carries its id; and a config still stating a deprecated field (`entityFeatureId`) gets a note naming the replacement.\n\n## Pull\n\n`atmn pull` writes the server's catalog back into the config in place: it flips `active` where the dashboard promoted a version, appends versions the config never mentioned, and backfills `internalId` and `versionSlug`. Run it after anyone touches the dashboard, and before editing a config you did not write. With no config yet, `pull` asks which folder to create it in; headless, it prints the `-c <dir>` hint and stops, so run `atmn init` or pass `-c` instead.\n\n`atmn pull --overwrite` is different: it rewrites `autumn.config.ts` and the `features.ts`, `plans.ts` and `rewards.ts` beside it from the server. It never deletes a file, and it leaves alone any file that does not import the package. It needs `--yes`, and it is the right move only when the config describes a different org than the key — the tell is `Your config no longer matches this org's catalog`. Anywhere else, a plain `pull` is what you want.\n\n## Webhooks\n\n`webhooks` in the config lists the Autumn webhooks it manages, keyed by `id`. Unlike the catalog it is never a deletion list: `push` creates or updates the webhooks it states and leaves every other webhook alone (the preview shows them as `unmanaged`).\n\n`url` is a map keyed by environment, never one string. A missing key means \"don't register this webhook there\", which is not an error:\n\n```ts\nimport { atmn, webhook } from \"atmn\";\n\nexport default atmn({\n webhooks: [\n webhook({\n id: \"billing\",\n events: [\"billing.updated\", \"balances.limit_reached\"],\n url: {\n live: \"https://myapp.com/api/autumn\", // atmn push -p\n sandbox: \"https://staging.myapp.com/api/autumn\", // the default sandbox\n \"qa-team\": \"https://qa.myapp.com/api/autumn\", // atmn sandbox use qa-team\n },\n }),\n ],\n});\n```\n\n- Keys: `live` for `-p`, `sandbox` for the default sandbox, or a named sandbox's slug (its name lowercased; no spaces). Never copy a sandbox URL into `live`: ask the user for the production URL, or leave `live` out.\n- Every URL must be https and publicly reachable; localhost and private networks are refused, tunnels such as ngrok work. `events` needs at least one entry.\n- `id` is permanent: changing it registers a new webhook. Two ids that differ only by case or `-`/`_` are refused.\n- The preview gives a changed URL its own line (`billing url: old → new`). Read it out to the user before `--yes`.\n- `push --yes` writes each new webhook's signing secret once and prints exactly where: `AUTUMN_WEBHOOK_<ID>_SECRET` in `.env.prod` for `-p`, `AUTUMN_WEBHOOK_<ID>_<ORG4>_SECRET` in `.env.local` (or `.env`) for a sandbox. Relay the variable name and file; never print or read the secret.\n- `pull` reads webhooks from every env with a key in your env files (`AUTUMN_SECRET_KEY` → `sandbox`, `AUTUMN_PROD_SECRET_KEY` → `live`, each `AUTUMN_SANDBOX_<id>_SECRET_KEY` → its slug), whatever the target; a key that's rejected or belongs to another org skips its env with a warning and leaves that env's keys alone. Per env it adds webhooks it finds, sets a changed URL, removes the env's key when the server no longer has that webhook, and deletes a webhook whose map ends up empty. A URL written as code (`process.env.X`) is never rewritten; pull warns when it differs from the server. Webhooks already in the dashboard will be pulled and be editable in the config once you pull as usual. Since the dashboard doesn't let you set an ID, a permanent ID will be assigned to this for future reference.\n\n## Sandboxes and keys\n\n- `atmn sandbox use <name>` pins a named sandbox; every command after it targets that sandbox until `atmn sandbox use --clear`.\n- `atmn reset --yes` empties the pinned sandbox; `atmn push --yes` rebuilds it from the config.\n- `atmn env --json` says which org, sandbox and key a command would hit, with `notes` on what to do when something is off.\n- A missing key fails fast and names the fix: `AUTUMN_SECRET_KEY is not set. Run atmn login, or atmn login --keyless if you don't have an account.` — hand that to the setup flow, don't ask the user to paste a key.\n\n## What to show the user\n\n- Which plans mint a new version, and the draft migrations that come with them.\n- Feature/plan/version deletions that will archive instead because dependencies or customers exist.\n- Which sandbox is pinned before applying anything."
13621
+ contents: "# atmn catalog flows\n\nContents: commands · when to use it · the config is the catalog's state (versions, renames, drafts) · variants and licenses · config shapes · splitting the config · update loop · pull · webhooks · sandboxes and keys · what to show the user.\n\nUse `atmn` when a project has or should have an `autumn.config.ts` source of truth.\n\nCommands — these two, not `atmn preview` (that does not exist):\n\n```sh\natmn push # lints, then previews the diff; applies nothing\natmn push --yes # apply exactly what the preview showed\n```\n\n`push` without `--yes` is always a dry run, on a clean org too, and it never asks anything: `--yes` is the only gate. The commands that do ask (`init`, `login --keyless`, `sandbox use`, `skills install`) print the flag to pass instead whenever there is no TTY, which is every agent run; `--headless` forces that in a terminal.\n\n## When to use it\n\n- New project: run `atmn init`. It connects (sign in, or keyless), places the config, pulls whatever the org already holds, and installs these skills beside it.\n- Existing project: if `autumn.config.ts` exists, run `atmn pull` first so every row carries its server ids, then edit and push. Never rewrite a config that predates you from scratch.\n- Use MCP/API directly when the user wants dashboard/API-first changes or there is no local config workflow.\n\n## The config is the catalog's state\n\nThe document is the whole desired catalog for every collection it states. A plan or feature missing from a stated `plans` / `features` is a deletion (archived when customers depend on it). A collection left out entirely is not managed. That rule reaches down to versions: **a version row missing from `plans` is deleted too**.\n\nEvery version of a plan is a row in `plans`. Rows of one plan share a `planId`; each names its version with `versionSlug`; exactly one is `active: true` and the rest `active: false`. There is no history collection and no timeline — array order means nothing. What a version *is* (a group of customers, not a step in time) is the `autumn-concepts` skill's: read `references/plan.md` in the `autumn-concepts` skill. This file owns how rows express it.\n\nTwo fields make a row addressable:\n\n- `planId` + `versionSlug` — what you write. The pair names exactly one version. `versionSlug` is required on every plan row and every variant row, including a plan's only version; an omitted slug is a lint error, never an implicit `v1`.\n- `internalId` — the server's stable id. It is minted on the first `push --yes` and written back into the fixture; `pull` writes it for every row that lacks one. A fixture carrying it can be renamed (`planId`, `featureId`, `versionSlug`) and the server treats that as a rename, not a delete plus create.\n\n**This is where atmn differs from the API and the dashboard.** Those send one plan entry plus flags — `versioning`, `active`, `propagate` — as `references/catalog-update.md` describes. A config has none of those flags. The intent is read off the rows — which rows exist, which one is active, which carry an `internalId` — and the preview reports what the server derived. If the preview says something other than what you meant, change the rows and preview again; never look for a flag.\n\n| You want | Row edit | Preview shows |\n|---|---|---|\n| Change a version in place (active or history) | edit that row; keep its `versionSlug` and `internalId` | `~ pro@v1`, a migration for its customers |\n| The same change on every version | make the edit on every row of the plan | one `~` per row |\n| A new version; customers stay on the old one | add a row: same `planId`, new `versionSlug`, `active: true`, **no `internalId`**; flip the old row to `active: false` | `+ pro@v2`, and `active: true -> false` on the old row |\n| A draft nobody can buy yet | add the row with `active: false` beside the active one | `+` with no pointer move |\n| Rename the plan id | change `planId` on **every** row of the plan (all carry `internalId`) | a rename, not `-` + `+` |\n| Rename a version | change `versionSlug` on a row that carries `internalId` | `Version slug` rename |\n| Retire a version | remove its row | `-`; refused while customers are on it (it names both exits: migrate them, or archive the whole plan) |\n\nGuards the lint applies before anything is sent: a plan with no active row or two active rows (`mark the one customers can buy active: true and the rest active: false`), a missing `versionSlug`, the same `planId` + `versionSlug` twice. A plan whose only row is `active: false` is refused — a draft needs an active sibling.\n\nNumbers vs slugs: the server numbers versions in creation order and the preview labels rows with that number (`pro@v2`). The config never states numbers, only slugs. Push a `v1` row after `v2` already exists and the server numbers it higher; the slug still says `v1`. Never read a preview number back as the slug.\n\n## Variants and licenses\n\nWhat a variant's customize can change, when a base edit reaches a variant, and how a license link anchors to a child version are catalog-wide: `references/catalog-update.md`. What is atmn's:\n\n- A variant is an entry in its base row's `variants` array and a license link an entry in the parent row's `licenses` array: `variant({...})` and `license({...})` fixtures, inline or imported from their own files, edited in place there. Pull writes new ones in that form. Every entry the config lists is a declared overlay; there is no `propagate` in a config, so a base edit reaches a variant through the entry you write, not a follow flag.\n- Minting a base version means listing the variant entries again under the new base row, each with the new `versionSlug` and no `internalId`. The old entries stay under the old base row. One variant version cannot serve two base rows; the lint names both rows and says to version and relink the variant.\n- To retire a variant, set `archived: true` on its entry. A variant left out of the array is a deletion, refused while customers hold it.\n- Every `license({...})` states `versionSlug` — the lint refuses one without it — because the link is pinned to that child version and a config that names it links the same version in every environment. Pull writes it back. Minting a child version moves no parent; relinking a parent is editing that slug.\n\n## Config shapes\n\n`autumn.config.ts` uses the atmn package types, not raw API JSON. Field names are camelCase: `featureId`, `planId`, `billingMethod`, `billingUnits`, `freeTrial`, `intervalCount`, `versionSlug`. Follow the exported types from the package when editing config. Amounts are plain dollars: $20 is `20`, never `2000`.\n\nBuilders — `feature`, `plan`, `variant`, `license`. Items are plain objects on the plan; there is no `item()` builder and fixtures expose `featureId` / `planId`, never `.id`. A plan with an active `v2`, its `v1` kept for the customers still on it, each with an annual variant:\n\n```ts\nimport { atmn, feature, plan, variant } from \"atmn\";\n\nexport const messages = feature({\n featureId: \"messages\",\n name: \"Messages\",\n type: \"metered\",\n consumable: true,\n});\n\nexport const proAnnualV2 = variant({\n variantPlanId: \"pro_annual\",\n versionSlug: \"v2\",\n name: \"Pro Annual\",\n customize: { price: { amount: 250, interval: \"year\" } },\n});\n\nexport const proAnnualV1 = variant({\n variantPlanId: \"pro_annual\",\n versionSlug: \"v1\",\n name: \"Pro Annual\",\n customize: { price: { amount: 200, interval: \"year\" } },\n});\n\nexport const proV2 = plan({\n planId: \"pro\",\n versionSlug: \"v2\",\n active: true,\n name: \"Pro\",\n price: { amount: 25, interval: \"month\" },\n items: [{ featureId: messages.featureId, included: 10000, reset: { interval: \"month\" } }],\n variants: [proAnnualV2],\n});\n\nexport const proV1 = plan({\n internalId: \"prod_…\", // written back by push --yes / pull\n planId: \"pro\",\n versionSlug: \"v1\",\n active: false,\n name: \"Pro\",\n price: { amount: 20, interval: \"month\" },\n items: [{ featureId: messages.featureId, included: 5000, reset: { interval: \"month\" } }],\n variants: [proAnnualV1],\n});\n\nexport default atmn({ features: [messages], plans: [proV2, proV1] });\n```\n\nBefore `proV2` existed, `proV1` was the active row; minting `v2` was adding `proV2` without an `internalId` and flipping `proV1` to `active: false`.\n\nUsage-priced item:\n\n```ts\n{\n featureId: messages.featureId,\n included: 10000,\n reset: { interval: \"month\" },\n price: {\n amount: 0.9,\n billingMethod: \"usage_based\",\n billingUnits: 1000,\n interval: \"month\",\n },\n}\n```\n\n## Splitting the config\n\n`atmn init` scaffolds four files: `autumn.config.ts` (the root, `export default atmn({...})`), `features.ts`, `plans.ts`, `rewards.ts`. Every version of every plan lives in the `plans` array — active and history rows side by side. The config is ordinary TypeScript, so a row can be lifted into its own file under any export name and referenced from the array; `pull` follows imports and edits each fixture where it lives, and appends rows it has to add to the imported array. What it cannot do is edit a fixture that is not a plain literal — a spread, a helper call, a `.map()` — and it says so and writes nothing rather than guess.\n\n## Update loop\n\n1. Inspect or create `autumn.config.ts`.\n2. Edit the config to represent the desired catalog.\n3. Run `atmn push` to preview changes.\n4. Show the user the plan diffs, the customer impact, which plans mint a new version, and the draft migrations it would create. If the versioning is not what they meant, change the rows (table above) and preview again.\n5. Rerun `atmn push --yes` to apply the same preview.\n6. Report created/updated/deleted/archived features and plans, and the draft migrations the output lists. `push --yes` also writes `internalId` (and any `versionSlug` the server assigned) back into the fixtures — say so; those edits are expected.\n\nTwo notes push prints that are worth relaying: a plan removed while an id-less plan appears looks like a rename — pull first so the fixture carries its id; and a config still stating a deprecated field (`entityFeatureId`) gets a note naming the replacement.\n\n## Pull\n\n`atmn pull` writes the server's catalog back into the config in place: it flips `active` where the dashboard promoted a version, appends versions the config never mentioned, and backfills `internalId` and `versionSlug`. Run it after anyone touches the dashboard, and before editing a config you did not write. With no config yet, `pull` asks which folder to create it in; headless, it prints the `-c <dir>` hint and stops, so run `atmn init` or pass `-c` instead.\n\n`atmn pull --overwrite` is different: it rewrites `autumn.config.ts` and the `features.ts`, `plans.ts` and `rewards.ts` beside it from the server. It never deletes a file, and it leaves alone any file that does not import the package. It needs `--yes`, and it is the right move only when the config describes a different org than the key — the tell is `Your config no longer matches this org's catalog`. Anywhere else, a plain `pull` is what you want.\n\n## Webhooks\n\n`webhooks` in the config lists the Autumn webhooks it manages. Each `webhook()` is one endpoint in one environment, keyed by `env` and `id`. Unlike the catalog it is never a deletion list: `push` creates or updates the webhooks it states and leaves every other webhook alone (the preview shows them as `unmanaged`).\n\nTo register a webhook in several environments, write one `webhook()` per environment. The same `id` may repeat across environments:\n\n```ts\nimport { atmn, webhook } from \"atmn\";\n\nconst events = [\"billing.updated\", \"balances.limit_reached\"];\n\nexport default atmn({\n webhooks: [\n webhook({ id: \"billing\", env: \"live\", url: \"https://myapp.com/api/autumn\", events }), // atmn push -p\n webhook({ id: \"billing\", env: \"sandbox\", url: \"https://staging.myapp.com/api/autumn\", events }), // the default sandbox\n webhook({ id: \"billing\", env: \"qa-team\", url: \"https://qa.myapp.com/api/autumn\", events }), // the sandbox named qa-team\n ],\n});\n```\n\n- `env`: `live` for `-p`, `sandbox` for the default sandbox, or a named sandbox's slug (its name lowercased; no spaces). An environment with no entry has nothing registered. Never copy a sandbox URL into a `live` entry: ask the user for the production URL, or leave `live` out.\n- One `id` may appear once per `env`; a repeat in the same env is refused.\n- Configs from atmn 2.0.36–2.0.42 stated `url` as a map (`url: { live, sandbox }`). That shape is refused: split it into one `webhook()` per env.\n- Every URL must be https and publicly reachable; localhost and private networks are refused, tunnels such as ngrok work.\n- Leave `events` out to receive every event. Don't write `events: []`.\n- Vercel webhooks require their own distinct webhook and are a mutually exclusive variant to the rest. It can only listen to Vercel events and cannot later be changed to listen to non-Vercel events.\n- `id` is permanent: changing it registers a new webhook. In one env, two ids that differ only by case or `-`/`_` are refused.\n- A webhook made in the dashboard has no id of its own, so its entry uses the `ep_…` id `pull` writes. Keep that id: push updates that endpoint by it.\n- `push` syncs webhooks to every sandbox with a key in your env files (`AUTUMN_SECRET_KEY` → `sandbox`, each `AUTUMN_SANDBOX_<id>_SECRET_KEY` → its slug), each through its own key, whichever sandbox is pinned. The preview shows one `Webhooks · <env>` block per env with changes. A key that's rejected or belongs to another org skips its env with a warning. The catalog and settings still go to the targeted env only.\n- Production webhooks change only with `-p`, which syncs `live` and no sandbox. When `AUTUMN_PROD_SECRET_KEY` is set, a plain push also previews `live` read-only, and if production would change it prints `Production webhooks differ from your config (<ids>). Run atmn push -p to update production.` Relay that line, and run `-p` only when the user asks.\n- The preview gives a changed URL its own line (`billing url: old → new`). Read it out to the user before `--yes`.\n- `push --yes` writes each new webhook's signing secret once and prints exactly where: `AUTUMN_WEBHOOK_<ID>_SECRET` in `.env.prod` for `-p`, `AUTUMN_WEBHOOK_<ID>_<SANDBOX_SLUG>_SECRET` in `.env.local` (or `.env`) for each sandbox (`AUTUMN_WEBHOOK_BILLING_SANDBOX_SECRET` for the default sandbox, `AUTUMN_WEBHOOK_BILLING_QA_TEAM_SECRET` for `qa-team`). Secrets saved earlier keep their existing names. If two sandboxes' names differ only by punctuation (`qa-team`, `qa_team`), they would share a variable, so push refuses before writing; ask the user to rename one. Relay the variable name and file; never print or read the secret.\n- `pull` reads webhooks from every env with a key in your env files (`AUTUMN_SECRET_KEY` → `sandbox`, `AUTUMN_PROD_SECRET_KEY` → `live`, each `AUTUMN_SANDBOX_<id>_SECRET_KEY` → its slug), whatever the target. A key that's rejected, belongs to another org, or whose sandbox is gone skips its env with a warning, and that env's entries stay. For each env it read, pull writes one entry per endpoint: it adds new ones (dashboard ones under their `ep_…` id), updates changed fields, and deletes an entry the server no longer has in that env. A value written as code (`process.env.X`) is never rewritten; pull warns when it differs from the server.\n\n## Sandboxes and keys\n\n- `atmn sandbox use <name>` pins a named sandbox; every command after it targets that sandbox until `atmn sandbox use --clear`.\n- `atmn reset --yes` empties the pinned sandbox; `atmn push --yes` rebuilds it from the config.\n- `atmn env --json` says which org, sandbox and key a command would hit, with `notes` on what to do when something is off.\n- A missing key fails fast and names the fix: `AUTUMN_SECRET_KEY is not set. Run atmn login, or atmn login --keyless if you don't have an account.` — hand that to the setup flow, don't ask the user to paste a key.\n\n## What to show the user\n\n- Which plans mint a new version, and the draft migrations that come with them.\n- Feature/plan/version deletions that will archive instead because dependencies or customers exist.\n- Which sandbox is pinned before applying anything."
13596
13622
  },
13597
13623
  {
13598
13624
  path: "references/catalog-update.md",
@@ -13607,11 +13633,11 @@ Pitch: "Pro $20/mo or $200/yr — 1,000 messages per month either way."
13607
13633
  {
13608
13634
  name: "autumn-integrate",
13609
13635
  description: "Integrating Autumn into an app's backend — creating customers, billing flows (attach, checkout, upgrade, cancel), gating features with check, recording usage with track, and exposing billing data to the frontend. Use when the user asks to integrate Autumn, add billing/payments to their app, gate features, meter usage, or build checkout/upgrade flows. Requires a pushed catalog; use autumn-catalog first if plans don't exist yet.",
13610
- version: "2.0.41",
13636
+ version: "2.0.43",
13611
13637
  markdown: `---
13612
13638
  name: autumn-integrate
13613
13639
  description: Integrating Autumn into an app's backend — creating customers, billing flows (attach, checkout, upgrade, cancel), gating features with check, recording usage with track, and exposing billing data to the frontend. Use when the user asks to integrate Autumn, add billing/payments to their app, gate features, meter usage, or build checkout/upgrade flows. Requires a pushed catalog; use autumn-catalog first if plans don't exist yet.
13614
- version: 2.0.41
13640
+ version: 2.0.43
13615
13641
  ---
13616
13642
 
13617
13643
  # Integrate
@@ -18213,11 +18239,11 @@ Monitor your logs for this message. If you see it, check [status.useautumn.com](
18213
18239
  {
18214
18240
  name: "autumn-concepts",
18215
18241
  description: "Understanding Autumn's billing objects before acting on them — what a plan version is and what active means, when a trial starts or ends billing, how entity- and seat-scoped subscriptions differ from the customer's own, what a customize diff actually changes, and how caps, overage, and top-ups behave. Load before versioning, editing, or drafting a plan, or when a customer's state does not match the plain reading of their plan.",
18216
- version: "2.0.41",
18242
+ version: "2.0.43",
18217
18243
  markdown: `---
18218
18244
  name: autumn-concepts
18219
18245
  description: Understanding Autumn's billing objects before acting on them — what a plan version is and what active means, when a trial starts or ends billing, how entity- and seat-scoped subscriptions differ from the customer's own, what a customize diff actually changes, and how caps, overage, and top-ups behave. Load before versioning, editing, or drafting a plan, or when a customer's state does not match the plain reading of their plan.
18220
- version: 2.0.41
18246
+ version: 2.0.43
18221
18247
  ---
18222
18248
 
18223
18249
  # Concepts
@@ -19405,7 +19431,7 @@ var runOAuthFlow = async ({
19405
19431
  if (outcome)
19406
19432
  return outcome;
19407
19433
  }
19408
- throw new Error(`All OAuth callback ports (${OAUTH_PORTS.at(0)}-${OAUTH_PORTS.at(-1)}) are in use.`);
19434
+ throw new Error(`All OAuth callback ports (${OAUTH_PORTS[0]}-${OAUTH_PORTS[OAUTH_PORTS.length - 1]}) are in use.`);
19409
19435
  };
19410
19436
 
19411
19437
  // src/actions/login.ts
@@ -22469,65 +22495,6 @@ var rewriteConfig = ({
22469
22495
  return { rewritten: [configPath, ...rewritten], kept };
22470
22496
  };
22471
22497
 
22472
- // src/actions/webhooks/isDashboardWebhook.ts
22473
- var SVIX_ENDPOINT_ID = /^ep_[0-9A-Za-z]{27}$/;
22474
- var isDashboardWebhook = ({ id }) => SVIX_ENDPOINT_ID.test(id);
22475
-
22476
- // ../ksuid/src/base62.ts
22477
- var ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
22478
- var base62Encode = ({
22479
- bytes,
22480
- padTo
22481
- }) => {
22482
- const digits = [];
22483
- const working = new Uint8Array(bytes);
22484
- let allZero = false;
22485
- while (!allZero) {
22486
- allZero = true;
22487
- let remainder = 0;
22488
- for (let i = 0;i < working.length; i++) {
22489
- const accumulator = remainder * 256 + working[i];
22490
- working[i] = Math.floor(accumulator / 62);
22491
- remainder = accumulator % 62;
22492
- if (working[i] !== 0)
22493
- allZero = false;
22494
- }
22495
- digits.push(remainder);
22496
- }
22497
- digits.reverse();
22498
- while (digits.length < padTo) {
22499
- digits.unshift(0);
22500
- }
22501
- return digits.map((d) => ALPHABET[d]).join("");
22502
- };
22503
-
22504
- // ../ksuid/src/random.ts
22505
- var getRandomBytes = ({ size }) => {
22506
- const buffer = new Uint8Array(size);
22507
- globalThis.crypto.getRandomValues(buffer);
22508
- return buffer;
22509
- };
22510
-
22511
- // ../ksuid/src/index.ts
22512
- var KSUID_EPOCH = 1400000000;
22513
- var PAYLOAD_BYTES = 20;
22514
- var ENCODED_LENGTH = 27;
22515
- var generateKsuid = ({ prefix = "" } = {}) => {
22516
- const payload = new Uint8Array(PAYLOAD_BYTES);
22517
- const timestamp = Math.floor(Date.now() / 1000) - KSUID_EPOCH;
22518
- payload[0] = timestamp >>> 24 & 255;
22519
- payload[1] = timestamp >>> 16 & 255;
22520
- payload[2] = timestamp >>> 8 & 255;
22521
- payload[3] = timestamp & 255;
22522
- const randomBytes = getRandomBytes({ size: 16 });
22523
- payload.set(randomBytes, 4);
22524
- const encoded = base62Encode({ bytes: payload, padTo: ENCODED_LENGTH });
22525
- return `${prefix}${encoded}`;
22526
- };
22527
-
22528
- // src/actions/webhooks/newWebhookId.ts
22529
- var newWebhookId = () => generateKsuid({ prefix: "wh_" });
22530
-
22531
22498
  // src/actions/pull/webhooks/appendWebhook.ts
22532
22499
  var SPEC = SYNCED_LISTS.webhooks;
22533
22500
  var COLLECTION = "webhooks";
@@ -22536,8 +22503,9 @@ var fixtureRowOf = ({
22536
22503
  envKey
22537
22504
  }) => ({
22538
22505
  id: webhook.id,
22539
- url: { [envKey]: webhook.url },
22540
- events: webhook.events,
22506
+ [SPEC.envField]: envKey,
22507
+ url: webhook.url,
22508
+ ...webhook.events.length > 0 ? { events: webhook.events } : {},
22541
22509
  ...webhook.description ? { description: webhook.description } : {},
22542
22510
  ...webhook.disabled ? { disabled: true } : {}
22543
22511
  });
@@ -22587,26 +22555,77 @@ var appendWebhook = ({
22587
22555
  var SPEC2 = SYNCED_LISTS.webhooks;
22588
22556
  var locateWebhook = ({
22589
22557
  pull,
22590
- id
22558
+ id,
22559
+ envKey
22591
22560
  }) => locateFixture({
22592
22561
  configPath: pull.configPath,
22593
22562
  files: pull.files,
22594
22563
  builder: SPEC2.builder,
22595
22564
  idField: SPEC2.idField,
22596
22565
  id,
22566
+ where: webhookWhere({ envKey }),
22597
22567
  allowDynamic: true
22598
22568
  });
22569
+ var webhookWhere = ({ envKey }) => [
22570
+ { field: SPEC2.envField, equals: envKey }
22571
+ ];
22572
+
22573
+ // src/actions/pull/webhooks/deleteWebhook.ts
22574
+ var SPEC3 = SYNCED_LISTS.webhooks;
22575
+ var deleteWebhook = ({
22576
+ pull,
22577
+ stated: stated2,
22578
+ envKey
22579
+ }) => {
22580
+ const result = { lines: [], warnings: [], unlocated: [] };
22581
+ const located = locateWebhook({ pull, id: stated2.id, envKey });
22582
+ const removed = located === null ? null : deleteFixtureLiteral({
22583
+ source: located.source,
22584
+ builder: SPEC3.builder,
22585
+ idField: SPEC3.idField,
22586
+ id: stated2.id,
22587
+ where: webhookWhere({ envKey })
22588
+ });
22589
+ if (located === null || removed === null) {
22590
+ result.unlocated.push({
22591
+ id: stated2.id,
22592
+ action: `delete the ${envKey} webhook by hand`
22593
+ });
22594
+ return result;
22595
+ }
22596
+ pull.files.set(located.file, removed.source);
22597
+ if (removed.exportedName !== undefined) {
22598
+ const referenceFiles = new Set([pull.configPath]);
22599
+ const target = resolveCollectionTarget({
22600
+ configPath: pull.configPath,
22601
+ files: pull.files,
22602
+ collection: "webhooks"
22603
+ });
22604
+ if (target !== null)
22605
+ referenceFiles.add(target.file);
22606
+ for (const file of referenceFiles)
22607
+ pull.files.set(file, deleteReference({
22608
+ source: pull.files.get(file) ?? "",
22609
+ name: removed.exportedName
22610
+ }));
22611
+ }
22612
+ result.lines.push(`- webhook ${stated2.id} (${envKey})`);
22613
+ return result;
22614
+ };
22599
22615
 
22600
22616
  // src/actions/pull/webhooks/updateWebhook.ts
22601
22617
  import { isDeepStrictEqual as isDeepStrictEqual2 } from "node:util";
22602
- var SPEC3 = SYNCED_LISTS.webhooks;
22618
+ var SPEC4 = SYNCED_LISTS.webhooks;
22603
22619
  var sameEvents = (left, right) => Array.isArray(left) && isDeepStrictEqual2([...left].sort(), [...right].sort());
22604
22620
  var fieldAssignments = ({
22605
22621
  webhook,
22606
22622
  stated: stated2
22607
22623
  }) => {
22608
22624
  const assignments = [];
22609
- if (webhook.events.length > 0 && !sameEvents(stated2.events, webhook.events))
22625
+ if (webhook.events.length === 0) {
22626
+ if (stated2.events !== undefined)
22627
+ assignments.push({ path: ["events"], text: null });
22628
+ } else if (!sameEvents(stated2.events, webhook.events))
22610
22629
  assignments.push({
22611
22630
  path: ["events"],
22612
22631
  text: `[${webhook.events.map((event) => JSON.stringify(event)).join(", ")}]`
@@ -22631,8 +22650,8 @@ var nonLiteralUrlWarning = ({
22631
22650
  }) => {
22632
22651
  const pad = " ".repeat(id.length + 4);
22633
22652
  return [
22634
- `⚠ ${id} url.${envKey} isn't a plain string, so pull left it untouched`,
22635
- `${pad}server: ${server ?? "(not registered)"}`,
22653
+ `⚠ ${id} (${envKey}) url isn't a plain string, so pull left it untouched`,
22654
+ `${pad}server: ${server}`,
22636
22655
  `${pad}your config: ${typeof config === "string" ? config : "(no value)"}`,
22637
22656
  `${pad}Update it by hand, or make it a string and pull will manage it.`
22638
22657
  ].join(`
@@ -22645,164 +22664,58 @@ var updateWebhook = ({
22645
22664
  envKey
22646
22665
  }) => {
22647
22666
  const result = { lines: [], warnings: [], unlocated: [] };
22648
- if (webhook.events.length === 0 && Array.isArray(stated2.events))
22649
- result.warnings.push(`⚠ ${webhook.id} the dashboard webhook at this url receives every event; your next push narrows it to ${stated2.events.join(", ")}`);
22650
- const located = locateWebhook({ pull, id: webhook.id });
22651
- if (located === null) {
22652
- result.unlocated.push({
22653
- id: webhook.id,
22654
- action: "update from the server by hand"
22655
- });
22656
- return result;
22657
- }
22658
- const patched = patchFixturePaths({
22667
+ const label = `${webhook.id} (${envKey})`;
22668
+ const located = locateWebhook({ pull, id: webhook.id, envKey });
22669
+ const patched = located === null ? null : patchFixturePaths({
22659
22670
  source: located.source,
22660
- builder: SPEC3.builder,
22661
- idField: SPEC3.idField,
22671
+ builder: SPEC4.builder,
22672
+ idField: SPEC4.idField,
22662
22673
  id: webhook.id,
22674
+ where: webhookWhere({ envKey }),
22663
22675
  assignments: [
22664
- { path: ["url", envKey], text: JSON.stringify(webhook.url) },
22676
+ { path: ["url"], text: JSON.stringify(webhook.url) },
22665
22677
  ...fieldAssignments({ webhook, stated: stated2 })
22666
22678
  ],
22667
22679
  bareKeys: true
22668
22680
  });
22669
- if (patched === null) {
22681
+ if (located === null || patched === null) {
22670
22682
  result.unlocated.push({
22671
22683
  id: webhook.id,
22672
- action: "update from the server by hand"
22684
+ action: `update the ${envKey} webhook from the server by hand`
22673
22685
  });
22674
22686
  return result;
22675
22687
  }
22676
22688
  for (const { path } of patched.skipped) {
22677
22689
  const field = path.join(".");
22678
- if (field !== `url.${envKey}`) {
22679
- result.warnings.push(`⚠ ${webhook.id} ${field} isn't a plain literal, so pull left it untouched`);
22690
+ if (field !== "url") {
22691
+ result.warnings.push(`⚠ ${label} ${field} isn't a plain literal, so pull left it untouched`);
22680
22692
  continue;
22681
22693
  }
22682
- const config = stated2.url?.[envKey];
22683
- if (config === webhook.url)
22694
+ if (stated2.url === webhook.url)
22684
22695
  continue;
22685
22696
  result.warnings.push(nonLiteralUrlWarning({
22686
22697
  id: webhook.id,
22687
22698
  envKey,
22688
22699
  server: webhook.url,
22689
- config
22700
+ config: stated2.url
22690
22701
  }));
22691
22702
  }
22692
22703
  if (patched.source === located.source)
22693
22704
  return result;
22694
22705
  pull.files.set(located.file, patched.source);
22695
- result.lines.push(`~ webhook ${webhook.id}`);
22696
- return result;
22697
- };
22698
-
22699
- // src/actions/pull/webhooks/removeWebhookEnv.ts
22700
- var SPEC4 = SYNCED_LISTS.webhooks;
22701
- var urlMapIsEmpty = ({
22702
- pull,
22703
- id
22704
- }) => {
22705
- const located = locateWebhook({ pull, id });
22706
- const object = located === null ? null : fixtureObjectOf(located.node);
22707
- const member = object?.namedChildren().find((child) => child.kind() === "pair" && child.field("key") !== null && fixtureKeyOf({ node: child.field("key") }) === "url");
22708
- const value = member?.field("value");
22709
- if (value === null || value === undefined)
22710
- return false;
22711
- const read = readFixtureValue({ node: value });
22712
- return typeof read === "object" && read !== null && Object.keys(read).length === 0;
22713
- };
22714
- var deleteWebhook = ({
22715
- pull,
22716
- id
22717
- }) => {
22718
- const located = locateWebhook({ pull, id });
22719
- if (located === null)
22720
- return false;
22721
- const removed = deleteFixtureLiteral({
22722
- source: located.source,
22723
- builder: SPEC4.builder,
22724
- idField: SPEC4.idField,
22725
- id
22726
- });
22727
- if (removed === null)
22728
- return false;
22729
- pull.files.set(located.file, removed.source);
22730
- if (removed.exportedName !== undefined) {
22731
- const referenceFiles = new Set([pull.configPath]);
22732
- const target = resolveCollectionTarget({
22733
- configPath: pull.configPath,
22734
- files: pull.files,
22735
- collection: "webhooks"
22736
- });
22737
- if (target !== null)
22738
- referenceFiles.add(target.file);
22739
- for (const file of referenceFiles)
22740
- pull.files.set(file, deleteReference({
22741
- source: pull.files.get(file) ?? "",
22742
- name: removed.exportedName
22743
- }));
22744
- }
22745
- return true;
22746
- };
22747
- var removeWebhookEnv = ({
22748
- pull,
22749
- stated: stated2,
22750
- envKey
22751
- }) => {
22752
- const result = { lines: [], warnings: [], unlocated: [] };
22753
- const located = locateWebhook({ pull, id: stated2.id });
22754
- const patched = located === null ? null : patchFixturePaths({
22755
- source: located.source,
22756
- builder: SPEC4.builder,
22757
- idField: SPEC4.idField,
22758
- id: stated2.id,
22759
- assignments: [{ path: ["url", envKey], text: null }]
22760
- });
22761
- if (located === null || patched === null) {
22762
- result.unlocated.push({
22763
- id: stated2.id,
22764
- action: `remove url.${envKey} by hand`
22765
- });
22766
- return result;
22767
- }
22768
- if (patched.skipped.length > 0) {
22769
- result.warnings.push(nonLiteralUrlWarning({
22770
- id: stated2.id,
22771
- envKey,
22772
- server: undefined,
22773
- config: stated2.url?.[envKey]
22774
- }));
22775
- return result;
22776
- }
22777
- if (patched.source === located.source)
22778
- return result;
22779
- pull.files.set(located.file, patched.source);
22780
- if (urlMapIsEmpty({ pull, id: stated2.id })) {
22781
- if (deleteWebhook({ pull, id: stated2.id })) {
22782
- result.lines.push(`- webhook ${stated2.id}`);
22783
- return result;
22784
- }
22785
- result.unlocated.push({
22786
- id: stated2.id,
22787
- action: "delete the webhook by hand: its url map is now empty"
22788
- });
22789
- return result;
22790
- }
22791
- result.lines.push(`- webhook ${stated2.id} url.${envKey}`);
22706
+ result.lines.push(`~ webhook ${label}`);
22792
22707
  return result;
22793
22708
  };
22794
22709
 
22795
22710
  // src/actions/pull/webhooks/applyWebhooksPull.ts
22796
- var isVercelWebhook = (events) => Array.isArray(events) && events.length > 0 && events.every((event) => typeof event === "string" && event.startsWith("vercel."));
22797
22711
  var statedFrom = ({
22798
- row,
22799
22712
  webhook,
22800
22713
  envKey
22801
22714
  }) => ({
22802
- ...row,
22803
22715
  id: webhook.id,
22804
- url: { ...row?.url, [envKey]: webhook.url },
22805
- events: webhook.events.length > 0 ? webhook.events : row?.events,
22716
+ env: envKey,
22717
+ url: webhook.url,
22718
+ events: webhook.events.length > 0 ? webhook.events : undefined,
22806
22719
  description: webhook.description,
22807
22720
  disabled: webhook.disabled
22808
22721
  });
@@ -22815,77 +22728,37 @@ var applyWebhooksPull = ({
22815
22728
  pull,
22816
22729
  remote,
22817
22730
  stated: stated2,
22818
- envKey,
22819
- newId = newWebhookId
22731
+ envKey
22820
22732
  }) => {
22821
22733
  const result = { lines: [], warnings: [], unlocated: [] };
22822
- const statedById = new Map((stated2 ?? []).map((row) => [row.id, row]));
22823
- const next = new Map(statedById);
22824
- const fromDashboard = (webhook) => !statedById.has(webhook.id) && isDashboardWebhook(webhook);
22825
- const present = new Set;
22734
+ const rows = stated2 ?? [];
22735
+ const inEnv = new Map(rows.filter((row) => row.env === envKey).map((row) => [row.id, row]));
22736
+ const next = rows.filter((row) => row.env !== envKey);
22826
22737
  for (const webhook of remote) {
22827
- if (fromDashboard(webhook)) {
22828
- const sameApp = (row2) => isVercelWebhook(row2.events) === isVercelWebhook(webhook.events);
22829
- const represented = (stated2 ?? []).find((row2) => row2.url?.[envKey] === webhook.url && sameApp(row2)) ?? (stated2 ?? []).find((row2) => row2.url?.[envKey] === undefined && Object.values(row2.url ?? {}).includes(webhook.url) && sameApp(row2));
22830
- if (represented !== undefined) {
22831
- present.add(represented.id);
22832
- next.set(represented.id, statedFrom({
22833
- row: represented,
22834
- webhook: { ...webhook, id: represented.id },
22835
- envKey
22836
- }));
22837
- merge(result, updateWebhook({
22838
- pull,
22839
- webhook: { ...webhook, id: represented.id },
22840
- stated: represented,
22841
- envKey
22842
- }));
22843
- continue;
22844
- }
22845
- if (webhook.events.length === 0) {
22846
- result.lines.push(`· webhook ${webhook.id} receives every event; give it an event list in the dashboard, or add it to your config, to manage it here`);
22847
- continue;
22848
- }
22849
- const id = newId();
22850
- const failure2 = appendWebhook({
22851
- pull,
22852
- webhook: { ...webhook, id },
22853
- envKey
22854
- });
22855
- if (failure2 === null) {
22856
- next.set(id, statedFrom({ row: undefined, webhook: { ...webhook, id }, envKey }));
22857
- result.lines.push(`+ webhook ${id} (made in the dashboard; push adopts it by URL)`);
22858
- } else
22859
- result.unlocated.push({ id, action: failure2 });
22860
- continue;
22861
- }
22862
- present.add(webhook.id);
22863
- const row = statedById.get(webhook.id);
22864
- next.set(webhook.id, statedFrom({ row, webhook, envKey }));
22738
+ const row = inEnv.get(webhook.id);
22865
22739
  if (row !== undefined) {
22866
22740
  merge(result, updateWebhook({ pull, webhook, stated: row, envKey }));
22741
+ next.push(statedFrom({ webhook, envKey }));
22867
22742
  continue;
22868
22743
  }
22869
22744
  const failure = appendWebhook({ pull, webhook, envKey });
22870
- if (failure === null)
22871
- result.lines.push(`+ webhook ${webhook.id}`);
22872
- else
22745
+ if (failure !== null) {
22873
22746
  result.unlocated.push({ id: webhook.id, action: failure });
22747
+ continue;
22748
+ }
22749
+ result.lines.push(`+ webhook ${webhook.id} (${envKey})`);
22750
+ next.push(statedFrom({ webhook, envKey }));
22874
22751
  }
22875
- for (const row of stated2 ?? []) {
22876
- if (present.has(row.id) || row.url?.[envKey] === undefined)
22752
+ const remoteIds = new Set(remote.map(({ id }) => id));
22753
+ for (const row of inEnv.values()) {
22754
+ if (remoteIds.has(row.id))
22877
22755
  continue;
22878
- const removed = removeWebhookEnv({ pull, stated: row, envKey });
22756
+ const removed = deleteWebhook({ pull, stated: row, envKey });
22879
22757
  merge(result, removed);
22880
22758
  if (removed.lines.length === 0)
22881
- continue;
22882
- const { [envKey]: _removed, ...url } = row.url;
22883
- if (Object.keys(url).length === 0)
22884
- next.delete(row.id);
22885
- else
22886
- next.set(row.id, { ...row, url });
22759
+ next.push(row);
22887
22760
  }
22888
- return { ...result, stated: [...next.values()] };
22761
+ return { ...result, stated: next };
22889
22762
  };
22890
22763
 
22891
22764
  // src/generated/sandboxName.ts
@@ -22953,6 +22826,7 @@ var webhookPullEnvs = ({
22953
22826
  {
22954
22827
  keyName,
22955
22828
  label,
22829
+ secretKey,
22956
22830
  envKey,
22957
22831
  listWebhooks: () => listWebhooks({ secretKey })
22958
22832
  }
@@ -22960,8 +22834,28 @@ var webhookPullEnvs = ({
22960
22834
  });
22961
22835
  };
22962
22836
 
22837
+ // src/actions/pull/webhooks/webhookEnvSkipWarning.ts
22838
+ var skipReason = (error) => {
22839
+ if (error instanceof ForeignOrgKeyError)
22840
+ return "belongs to another org";
22841
+ if (!(error instanceof AutumnApiError))
22842
+ return null;
22843
+ if (error.status === 401 || error.status === 403)
22844
+ return "was rejected";
22845
+ if (error.status !== 404)
22846
+ return null;
22847
+ const { message } = error.body ?? {};
22848
+ return `failed: ${typeof message === "string" ? message : error.status}`;
22849
+ };
22850
+ var webhookEnvSkipWarning = ({
22851
+ env,
22852
+ error
22853
+ }) => {
22854
+ const reason = skipReason(error);
22855
+ return reason === null ? undefined : `⚠ webhooks: skipped ${env.label} (${env.keyName} ${reason})`;
22856
+ };
22857
+
22963
22858
  // src/actions/pull/webhooks/readWebhookEnvs.ts
22964
- var isRejectedKey = (error) => error instanceof AutumnApiError && (error.status === 401 || error.status === 403);
22965
22859
  var readWebhookEnvs = async ({
22966
22860
  envs
22967
22861
  }) => {
@@ -22973,11 +22867,10 @@ var readWebhookEnvs = async ({
22973
22867
  ]);
22974
22868
  return { envKey, list: list2 };
22975
22869
  } catch (error) {
22976
- if (error instanceof ForeignOrgKeyError)
22977
- return `⚠ webhooks: skipped ${env.label} (${env.keyName} belongs to another org)`;
22978
- if (!isRejectedKey(error))
22870
+ const warning = webhookEnvSkipWarning({ env, error });
22871
+ if (warning === undefined)
22979
22872
  throw error;
22980
- return `⚠ webhooks: skipped ${env.label} (${env.keyName} was rejected)`;
22873
+ return warning;
22981
22874
  }
22982
22875
  }));
22983
22876
  return {
@@ -23021,7 +22914,7 @@ var webhooksHaveWork = ({
23021
22914
  webhooks
23022
22915
  }) => (webhooks?.changes ?? []).some((change) => change.action !== "unmanaged");
23023
22916
  var DETAIL_INDENT = " ";
23024
- var list2 = (values) => values.length === 0 ? "(none)" : values.join(", ");
22917
+ var list2 = (values) => values.length === 0 ? "every event" : values.join(", ");
23025
22918
  var updateDetails = ({
23026
22919
  id,
23027
22920
  before,
@@ -23074,7 +22967,8 @@ var renderWebhooks = ({
23074
22967
  if (rows.length === 0)
23075
22968
  return null;
23076
22969
  const count = webhooks.changes.filter((change) => change.action !== "unmanaged").length;
23077
- return [chalk4.bold(`Webhooks (${count})`), ...rows].join(`
22970
+ const heading = webhooks.env === undefined ? `Webhooks (${count})` : `Webhooks · ${webhooks.env} (${count})`;
22971
+ return [chalk4.bold(heading), ...rows].join(`
23078
22972
  `);
23079
22973
  };
23080
22974
 
@@ -23501,8 +23395,10 @@ var renderPreview = ({
23501
23395
  const referralPrograms = (preview.referralPrograms ?? []).filter(rowHasWork);
23502
23396
  const migrations = preview.migrations ?? [];
23503
23397
  const settings = settingChanges(preview.settings);
23504
- const webhooks = preview.webhooks === undefined ? null : renderWebhooks({ webhooks: preview.webhooks });
23505
- if (features.length === 0 && plans.length === 0 && rewards.length === 0 && referralPrograms.length === 0 && settings.length === 0 && webhooks === null) {
23398
+ const webhooks = (preview.webhooks ?? []).map((lane) => renderWebhooks({ webhooks: lane })).filter((block) => block !== null);
23399
+ const productionWebhooks = preview.productionWebhooks ?? [];
23400
+ const productionHint = productionWebhooks.length > 0 ? chalk5.yellow(`Production webhooks differ from your config (${productionWebhooks.join(", ")}). Run atmn push -p to update production.`) : preview.productionWebhooksUnchecked === true ? chalk5.yellow("Production webhooks weren't checked, so this push can't say they match your config.") : null;
23401
+ if (features.length === 0 && plans.length === 0 && rewards.length === 0 && referralPrograms.length === 0 && settings.length === 0 && webhooks.length === 0 && productionHint === null) {
23506
23402
  return chalk5.dim("No changes. Your catalog matches your config.");
23507
23403
  }
23508
23404
  const sections = [];
@@ -23571,8 +23467,9 @@ var renderPreview = ({
23571
23467
  ].join(`
23572
23468
  `));
23573
23469
  }
23574
- if (webhooks !== null)
23575
- sections.push(webhooks);
23470
+ sections.push(...webhooks);
23471
+ if (productionHint !== null)
23472
+ sections.push(productionHint);
23576
23473
  if (migrations.length > 0) {
23577
23474
  sections.push(renderPlannedMigrations({ migrations, plans }));
23578
23475
  }
@@ -23582,7 +23479,7 @@ var renderPreview = ({
23582
23479
  };
23583
23480
  var previewIsEmpty = ({
23584
23481
  preview
23585
- }) => !(preview.features ?? []).some(rowHasWork) && !(preview.plans ?? []).some(rowHasWork) && !(preview.rewards ?? []).some(rowHasWork) && !(preview.referralPrograms ?? []).some(rowHasWork) && !settingsHaveWork({ settings: preview.settings }) && !webhooksHaveWork({ webhooks: preview.webhooks });
23482
+ }) => !(preview.features ?? []).some(rowHasWork) && !(preview.plans ?? []).some(rowHasWork) && !(preview.rewards ?? []).some(rowHasWork) && !(preview.referralPrograms ?? []).some(rowHasWork) && !settingsHaveWork({ settings: preview.settings }) && !(preview.webhooks ?? []).some((webhooks) => webhooksHaveWork({ webhooks }));
23586
23483
 
23587
23484
  // src/actions/push/backfillInternalIds.ts
23588
23485
  import { readFileSync as readFileSync12, writeFileSync as writeFileSync8 } from "node:fs";
@@ -24038,18 +23935,35 @@ var withScopeHint = ({
24038
23935
  hint: hint2
24039
23936
  }) => error instanceof AutumnApiError && error.message.includes(SCOPE_REFUSAL) ? new Error(hint2, { cause: error }) : error;
24040
23937
 
23938
+ // src/actions/webhooks/webhookEnvFailures.ts
23939
+ var throwWebhookEnvFailures = ({
23940
+ failures,
23941
+ envCount,
23942
+ warnings = []
23943
+ }) => {
23944
+ if (failures.length === 0)
23945
+ return;
23946
+ throw new Error([
23947
+ ...failures.map(({ env, message }) => envCount > 1 ? message.replace(/^/gm, `${env}: `) : message),
23948
+ ...warnings
23949
+ ].join(`
23950
+ `));
23951
+ };
23952
+ var messageOf2 = (error) => error instanceof Error ? error.message : String(error);
23953
+
24041
23954
  // src/actions/webhooks/writeWebhookSecrets.ts
24042
23955
  import { existsSync as existsSync12, readFileSync as readFileSync13, writeFileSync as writeFileSync9 } from "node:fs";
24043
23956
  import { dirname as dirname11, join as join13, relative as relative6 } from "node:path";
24044
23957
 
24045
23958
  // src/actions/webhooks/webhookSecretName.ts
24046
23959
  var envNameSegment = (value) => value.toUpperCase().replace(/-/g, "_");
23960
+ var envKeySegment = (envKey) => envKey.toUpperCase().replace(/[^A-Z0-9]+/g, "_");
24047
23961
  var webhookSecretName = ({
24048
23962
  id,
24049
- orgId
23963
+ envKey
24050
23964
  }) => {
24051
- const org4 = orgId === undefined ? "" : `_${envNameSegment(orgId.replace(/^org_/, "").slice(0, 4))}`;
24052
- return `AUTUMN_WEBHOOK_${envNameSegment(id)}${org4}_SECRET`;
23965
+ const suffix = envKey === undefined ? "" : `_${envKeySegment(envKey)}`;
23966
+ return `AUTUMN_WEBHOOK_${envNameSegment(id)}${suffix}_SECRET`;
24053
23967
  };
24054
23968
 
24055
23969
  // src/actions/webhooks/writeWebhookSecrets.ts
@@ -24066,8 +23980,11 @@ var writeWebhookSecrets = async ({
24066
23980
  }) => {
24067
23981
  if (secrets.length === 0)
24068
23982
  return [];
24069
- const orgId = env.live ? undefined : await env.orgId();
24070
- const values = Object.fromEntries(secrets.map(({ id, secret }) => [webhookSecretName({ id, orgId }), secret]));
23983
+ const envKey = env.live ? undefined : env.key;
23984
+ const values = Object.fromEntries(secrets.map(({ id, secret }) => [
23985
+ webhookSecretName({ id, envKey }),
23986
+ secret
23987
+ ]));
24071
23988
  let path;
24072
23989
  if (env.live) {
24073
23990
  path = prodEnvPath({ envDirs });
@@ -24081,16 +23998,13 @@ var writeWebhookSecrets = async ({
24081
23998
  };
24082
23999
 
24083
24000
  // src/actions/webhooks/applyWebhooks.ts
24084
- var applyWebhooks = async ({
24085
- client,
24001
+ var applyLane = async ({
24086
24002
  lane,
24087
24003
  envDirs,
24088
24004
  cwd,
24089
24005
  write
24090
24006
  }) => {
24091
- if (lane === undefined || !webhooksHaveWork({ webhooks: lane.preview }))
24092
- return;
24093
- const result = await client.syncWebhooks(lane.body);
24007
+ const result = await lane.client.syncWebhooks(lane.body);
24094
24008
  const saved = await writeWebhookSecrets({
24095
24009
  secrets: result.secrets,
24096
24010
  env: lane.env,
@@ -24103,54 +24017,168 @@ var applyWebhooks = async ({
24103
24017
  ]);
24104
24018
  const adopted = lane.preview.changes.filter((change) => change.action === "adopt" && !failed.has(change.id)).map((change) => `Adopted ${change.id} (existing dashboard webhook); its signing secret is unchanged, nothing written`);
24105
24019
  write(`
24106
- Applied webhooks.
24020
+ Applied webhooks to ${lane.env.key}.
24107
24021
  ${[...adopted, ...saved].map((line2) => `${line2}
24108
24022
  `).join("")}`);
24109
24023
  if (result.errors.length > 0)
24110
24024
  throw new Error(result.errors.map(({ id, message }) => `${id}: ${message}`).join(`
24111
24025
  `));
24112
24026
  };
24027
+ var applyWebhooks = async ({
24028
+ lanes,
24029
+ envDirs,
24030
+ cwd,
24031
+ write
24032
+ }) => {
24033
+ const withWork = lanes.filter((lane) => webhooksHaveWork({ webhooks: lane.preview }));
24034
+ const results = await Promise.allSettled(withWork.map((lane) => applyLane({ lane, envDirs, cwd, write })));
24035
+ const failures = results.flatMap((result, index) => result.status === "rejected" ? [
24036
+ {
24037
+ env: withWork[index]?.env.key ?? "",
24038
+ message: messageOf2(result.reason)
24039
+ }
24040
+ ] : []);
24041
+ throwWebhookEnvFailures({ failures, envCount: withWork.length });
24042
+ };
24113
24043
 
24114
24044
  // src/actions/webhooks/resolveWebhooksForEnv.ts
24115
24045
  var resolveWebhooksForEnv = ({
24116
24046
  rows,
24117
24047
  envKey
24118
24048
  }) => {
24119
- const { envKeyed } = SYNCED_LISTS.webhooks;
24120
- const webhooks = [];
24121
- for (const row of rows) {
24122
- const values = envKeyed.map((field) => row[field]?.[envKey]);
24123
- if (values.some((value) => typeof value !== "string"))
24124
- continue;
24125
- webhooks.push({
24126
- ...row,
24127
- ...Object.fromEntries(envKeyed.map((field, index) => [field, values[index]]))
24128
- });
24129
- }
24130
- return webhooks;
24049
+ const { envField } = SYNCED_LISTS.webhooks;
24050
+ return rows.filter((row) => row[envField] === envKey).map(({ [envField]: _env, ...row }) => row);
24131
24051
  };
24132
24052
 
24133
- // src/actions/webhooks/previewWebhooks.ts
24134
- var previewWebhooks = async ({
24135
- client,
24053
+ // src/actions/webhooks/previewWebhookEnvs.ts
24054
+ var isLive = (env) => env.keyName === "AUTUMN_PROD_SECRET_KEY";
24055
+ var assertDistinctSecretNames = ({
24056
+ lanes
24057
+ }) => {
24058
+ const secretName = ({ lane, id }) => webhookSecretName({
24059
+ id,
24060
+ ...lane.env.live ? {} : { envKey: lane.env.key }
24061
+ });
24062
+ const clashes = new Map;
24063
+ for (const lane of lanes) {
24064
+ for (const change of lane.preview.changes) {
24065
+ if (change.action !== "create")
24066
+ continue;
24067
+ const name = secretName({ lane, id: change.id });
24068
+ for (const other of lanes) {
24069
+ if (other === lane)
24070
+ continue;
24071
+ const shares = other.body.webhooks.some((webhook) => secretName({ lane: other, id: webhook.id }) === name);
24072
+ if (!shares)
24073
+ continue;
24074
+ const [first, second] = [other.env.key, lane.env.key].sort();
24075
+ clashes.set(`${first}|${second}|${name}`, `${first} and ${second} would both save ${change.id}'s signing secret as ${name}. Rename one of those sandboxes so their names differ by more than punctuation.`);
24076
+ }
24077
+ }
24078
+ }
24079
+ if (clashes.size > 0)
24080
+ throw new Error([...clashes.values()].join(`
24081
+ `));
24082
+ };
24083
+ var previewEnv = async ({
24084
+ env,
24136
24085
  rows,
24137
- webhookEnv
24086
+ clientFor
24087
+ }) => {
24088
+ try {
24089
+ const key = await env.envKey();
24090
+ const webhooks = resolveWebhooksForEnv({ rows, envKey: key });
24091
+ if (webhooks.length === 0)
24092
+ return;
24093
+ const client = clientFor({ secretKey: env.secretKey });
24094
+ const body = { webhooks };
24095
+ const { changes, errors } = await client.previewSyncWebhooks(body);
24096
+ return {
24097
+ lane: {
24098
+ preview: { env: key, changes },
24099
+ body,
24100
+ env: { key, live: isLive(env) },
24101
+ client
24102
+ },
24103
+ errors
24104
+ };
24105
+ } catch (error) {
24106
+ const skipped = webhookEnvSkipWarning({ env, error });
24107
+ if (skipped === undefined)
24108
+ throw error;
24109
+ return { skipped };
24110
+ }
24111
+ };
24112
+ var previewWebhookEnvs = async ({
24113
+ rows,
24114
+ envs,
24115
+ prod,
24116
+ clientFor
24138
24117
  }) => {
24139
24118
  if (rows === undefined)
24140
24119
  return;
24141
- if (webhookEnv === undefined)
24142
- throw new Error("webhooks need the target environment to pick each url.");
24143
- const env = await webhookEnv();
24144
- const webhooks = resolveWebhooksForEnv({
24145
- rows,
24146
- envKey: env.key
24120
+ if (envs === undefined || clientFor === undefined)
24121
+ throw new Error("webhooks need the env keys to pick each url.");
24122
+ const all = envs();
24123
+ const synced = all.filter((env) => isLive(env) === prod);
24124
+ const probed = prod ? [] : all.filter(isLive);
24125
+ const [syncedResults, probedResults] = await Promise.all([
24126
+ Promise.allSettled(synced.map((env) => previewEnv({ env, rows, clientFor }))),
24127
+ Promise.allSettled(probed.map((env) => previewEnv({ env, rows, clientFor })))
24128
+ ]);
24129
+ const lanes = [];
24130
+ const skipped = [];
24131
+ probedResults.forEach((result, index) => {
24132
+ if (result.status === "rejected")
24133
+ skipped.push(`⚠ webhooks: couldn't check ${probed[index]?.label ?? "live"} for changes (${messageOf2(result.reason)})`);
24147
24134
  });
24148
- const body = { webhooks };
24149
- const { changes, errors } = await client.previewSyncWebhooks(body);
24150
- if (errors.length > 0)
24151
- throw new Error(errors.map(({ id, message }) => `${id}: ${message}`).join(`
24152
- `));
24153
- return { preview: { changes }, body, env };
24135
+ const productionUnchecked = probedResults.some((result) => result.status === "rejected" || result.value !== undefined && ("skipped" in result.value));
24136
+ const failures = [];
24137
+ syncedResults.forEach((result, index) => {
24138
+ const label = synced[index]?.label ?? "";
24139
+ if (result.status === "rejected") {
24140
+ failures.push({ env: label, message: messageOf2(result.reason) });
24141
+ return;
24142
+ }
24143
+ const preview = result.value;
24144
+ if (preview === undefined)
24145
+ return;
24146
+ if ("skipped" in preview) {
24147
+ skipped.push(preview.skipped);
24148
+ return;
24149
+ }
24150
+ lanes.push(preview.lane);
24151
+ if (preview.errors.length > 0)
24152
+ failures.push({
24153
+ env: preview.lane.env.key,
24154
+ message: preview.errors.map(({ id, message }) => `${id}: ${message}`).join(`
24155
+ `)
24156
+ });
24157
+ });
24158
+ throwWebhookEnvFailures({
24159
+ failures,
24160
+ envCount: synced.length,
24161
+ warnings: skipped
24162
+ });
24163
+ assertDistinctSecretNames({ lanes });
24164
+ const productionDiffers = [];
24165
+ for (const result of probedResults) {
24166
+ if (result.status === "rejected")
24167
+ continue;
24168
+ const preview = result.value;
24169
+ if (preview === undefined)
24170
+ continue;
24171
+ if ("skipped" in preview) {
24172
+ skipped.push(preview.skipped);
24173
+ continue;
24174
+ }
24175
+ const changed = preview.lane.preview.changes.filter((change) => change.action !== "unmanaged");
24176
+ productionDiffers.push(...new Set([
24177
+ ...changed.map((change) => change.id),
24178
+ ...preview.errors.map((error) => error.id)
24179
+ ]));
24180
+ }
24181
+ return { lanes, productionDiffers, productionUnchecked, skipped };
24154
24182
  };
24155
24183
 
24156
24184
  // src/actions/push.ts
@@ -24316,7 +24344,9 @@ var runPush = async ({
24316
24344
  dryRun = false,
24317
24345
  write = (text) => process.stdout.write(text),
24318
24346
  migrationLinkBase,
24319
- webhookEnv
24347
+ prod = false,
24348
+ webhookEnvs,
24349
+ webhookClientFor
24320
24350
  }) => {
24321
24351
  const project = resolveProject({ cwd, configFlag });
24322
24352
  const dirs = configSearchDirs({ cwd, configPath: configFlag });
@@ -24339,10 +24369,11 @@ var runPush = async ({
24339
24369
  const previews = await settleLanes({
24340
24370
  settings: previewSettings({ client, body: settingsBody }),
24341
24371
  catalog: client.previewUpdate(wire),
24342
- webhooks: previewWebhooks({
24343
- client,
24372
+ webhooks: previewWebhookEnvs({
24344
24373
  rows: lists.webhooks,
24345
- webhookEnv
24374
+ envs: webhookEnvs,
24375
+ prod,
24376
+ clientFor: webhookClientFor
24346
24377
  })
24347
24378
  });
24348
24379
  const { settings, webhooks } = previews.values;
@@ -24354,8 +24385,17 @@ var runPush = async ({
24354
24385
  const preview = {
24355
24386
  ...catalogPreview,
24356
24387
  settings,
24357
- ...webhooks === undefined ? {} : { webhooks: webhooks.preview }
24388
+ ...webhooks === undefined ? {} : {
24389
+ webhooks: webhooks.lanes.map((lane) => lane.preview),
24390
+ productionWebhooks: webhooks.productionDiffers,
24391
+ productionWebhooksUnchecked: webhooks.productionUnchecked
24392
+ }
24358
24393
  };
24394
+ if (webhooks !== undefined && webhooks.skipped.length > 0)
24395
+ write(`${webhooks.skipped.join(`
24396
+ `)}
24397
+
24398
+ `);
24359
24399
  write(`${renderPreview({ preview, migrationLinkBase })}
24360
24400
  `);
24361
24401
  if (catalogDeferral !== undefined)
@@ -24392,8 +24432,7 @@ ${renderCatalogDeferral({ error: catalogDeferral.error })}
24392
24432
  const applied = await settleLanes({
24393
24433
  catalog: settled === undefined ? settingsStep().then(catalogStep) : catalogStep(settled),
24394
24434
  webhooks: applyWebhooks({
24395
- client,
24396
- lane: webhooks,
24435
+ lanes: webhooks?.lanes ?? [],
24397
24436
  envDirs: project.envDirs,
24398
24437
  cwd,
24399
24438
  write
@@ -25142,28 +25181,6 @@ var runSandboxUse = async ({
25142
25181
  return result;
25143
25182
  };
25144
25183
 
25145
- // src/actions/webhooks/resolveWebhookEnv.ts
25146
- var memoized = (fetchOrgInfo2) => {
25147
- let pending;
25148
- return () => {
25149
- pending ??= fetchOrgInfo2();
25150
- return pending;
25151
- };
25152
- };
25153
- var resolveWebhookEnv = async ({
25154
- target,
25155
- prod,
25156
- fetchOrgInfo: fetchOrgInfo2
25157
- }) => {
25158
- const orgInfo = memoized(fetchOrgInfo2);
25159
- const orgId = async () => (await orgInfo()).id;
25160
- if (prod)
25161
- return { key: "live", live: true, orgId };
25162
- if (target.sandboxId === undefined)
25163
- return { key: "sandbox", live: false, orgId };
25164
- return { key: sandboxSlug((await orgInfo()).name), live: false, orgId };
25165
- };
25166
-
25167
25184
  // src/cli.ts
25168
25185
  var normalizeVersionFlag = ({ argv }) => argv.map((token) => token === "-v" ? "--version" : token);
25169
25186
  var withTargetFlags = (program) => program.option("-p, --prod", "target production instead of sandbox").option("--sandbox <sandboxId>", "target a specific sandbox").option("-l, --local", "target a local server (default port 8080)").option("--port <port>", "port of a local server (implies --local)").option("-b, --base-url <url>", "send to this URL instead").option("--client-id <id>", "OAuth client id the CLI identifies as").option("-c, --config <path>", "autumn.config.ts, or the folder holding it (default: found from cwd or the root package.json)").option("--headless", "never prompt: print what a command needs and stop (default when not a TTY)");
@@ -25255,13 +25272,15 @@ var pinnedSandboxName = async ({
25255
25272
  return {};
25256
25273
  }
25257
25274
  };
25258
- var webhookEnvFor = ({ target, command }) => () => resolveWebhookEnv({
25259
- target,
25260
- prod: command.optsWithGlobals().prod === true,
25261
- fetchOrgInfo: () => fetchOrgInfo({
25262
- baseUrl: targetBaseUrl({ target }),
25263
- secretKey: requireSecretKey({ target })
25264
- })
25275
+ var webhookClientFor = ({ target }) => ({ secretKey }) => createClient({
25276
+ secretKey,
25277
+ fetch: autumnFetch,
25278
+ ...target.baseUrl ? { baseUrl: target.baseUrl } : {}
25279
+ });
25280
+ var webhookEnvsFor = ({ target }) => () => webhookPullEnvs({
25281
+ targetKeyName: target.secretKeyName,
25282
+ listWebhooks: ({ secretKey }) => webhookClientFor({ target })({ secretKey }).listWebhooks({}),
25283
+ fetchOrgInfo: ({ secretKey }) => fetchOrgInfo({ baseUrl: targetBaseUrl({ target }), secretKey })
25265
25284
  });
25266
25285
  var mainOrgInfo = ({ target }) => {
25267
25286
  const main = managementTarget({ target });
@@ -25361,7 +25380,9 @@ Linking a keyless org to an account:
25361
25380
  client: clientFor({ target }),
25362
25381
  configPath: configFlagOf({ command }),
25363
25382
  dryRun: !apply,
25364
- webhookEnv: webhookEnvFor({ target, command })
25383
+ prod: command.optsWithGlobals().prod === true,
25384
+ webhookEnvs: webhookEnvsFor({ target }),
25385
+ webhookClientFor: webhookClientFor({ target })
25365
25386
  });
25366
25387
  process.exitCode = pushExitCode({ apply, result });
25367
25388
  if (!apply && !previewIsEmpty({ preview: result.preview }))
@@ -25411,15 +25432,7 @@ Nothing applied. Re-run with --yes to apply these changes.
25411
25432
  overwrite: options.overwrite === true,
25412
25433
  yes: options.yes === true,
25413
25434
  prompter: prompterFor({ command }),
25414
- webhookEnvs: () => webhookPullEnvs({
25415
- targetKeyName: target.secretKeyName,
25416
- listWebhooks: ({ secretKey }) => createClient({
25417
- secretKey,
25418
- fetch: autumnFetch,
25419
- ...target.baseUrl ? { baseUrl: target.baseUrl } : {}
25420
- }).listWebhooks({}),
25421
- fetchOrgInfo: ({ secretKey }) => fetchOrgInfo({ baseUrl: targetBaseUrl({ target }), secretKey })
25422
- })
25435
+ webhookEnvs: webhookEnvsFor({ target })
25423
25436
  });
25424
25437
  writeStaleSkillsHint({ command });
25425
25438
  });