atmn 2.0.42 → 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 (38) hide show
  1. package/dist/bin.js +360 -368
  2. package/dist/index.js +21 -19
  3. package/dist/tsconfig.tsbuildinfo +1 -1
  4. package/package.json +2 -2
  5. package/src/actions/pull/webhooks/appendWebhook.ts +4 -3
  6. package/src/actions/pull/webhooks/applyWebhooksPull.ts +27 -98
  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 +32 -36
  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/emit.ts +4 -4
  26. package/src/generated/emitRuntime.ts +3 -3
  27. package/src/generated/lintRules.ts +17 -18
  28. package/src/generated/lintRuntime.ts +10 -0
  29. package/src/generated/skills.ts +10 -10
  30. package/src/generated/webhooks.ts +5 -7
  31. package/src/generated/wire.ts +0 -1
  32. package/src/render/renderPreview.ts +25 -9
  33. package/src/render/renderWebhooks.ts +8 -2
  34. package/src/actions/pull/webhooks/removeWebhookEnv.ts +0 -141
  35. package/src/actions/webhooks/isDashboardWebhook.ts +0 -9
  36. package/src/actions/webhooks/newWebhookId.ts +0 -4
  37. package/src/actions/webhooks/previewWebhooks.ts +0 -40
  38. package/src/actions/webhooks/resolveWebhookEnv.ts +0 -34
package/dist/bin.js CHANGED
@@ -6245,25 +6245,38 @@ var LINT_RULES = {
6245
6245
  webhooks: {
6246
6246
  label: "webhook",
6247
6247
  idField: "id",
6248
- required: ["id", "url"],
6248
+ required: ["env", "id", "url"],
6249
6249
  fields: {
6250
6250
  id: {
6251
6251
  minLength: 1,
6252
6252
  maxLength: 256,
6253
6253
  pattern: "^[a-zA-Z0-9_-]+$"
6254
+ },
6255
+ env: {
6256
+ pattern: "^[a-z0-9_-]+$"
6257
+ },
6258
+ url: {
6259
+ pattern: "^[Hh][Tt][Tt][Pp][Ss]:\\/\\/"
6254
6260
  }
6255
6261
  },
6256
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
+ },
6257
6268
  {
6258
6269
  kind: "unique",
6259
6270
  field: "id",
6260
- 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."
6261
6273
  },
6262
6274
  {
6263
6275
  kind: "unique",
6264
6276
  field: "id",
6277
+ alongside: "env",
6265
6278
  asEnvName: true,
6266
- 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."
6267
6280
  },
6268
6281
  {
6269
6282
  kind: "rejects",
@@ -6277,12 +6290,6 @@ var LINT_RULES = {
6277
6290
  prefix: "vercel.",
6278
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."
6279
6292
  },
6280
- {
6281
- kind: "nonEmpty",
6282
- field: "url",
6283
- warning: true,
6284
- because: "With no environment key the webhook is registered nowhere. Add `live`, `sandbox` or a sandbox's slug."
6285
- },
6286
6293
  {
6287
6294
  kind: "knownValues",
6288
6295
  field: "events",
@@ -6304,14 +6311,6 @@ var LINT_RULES = {
6304
6311
  because: "isn't known to this atmn version; the server will check it."
6305
6312
  }
6306
6313
  ]
6307
- },
6308
- "webhooks.url": {
6309
- keys: {
6310
- pattern: "^[a-z0-9_-]+$"
6311
- },
6312
- values: {
6313
- pattern: "^[Hh][Tt][Tt][Pp][Ss]:\\/\\/"
6314
- }
6315
6314
  }
6316
6315
  };
6317
6316
 
@@ -6532,6 +6531,10 @@ var entryRuleFailures = ({
6532
6531
  return [];
6533
6532
  return value.filter((item) => typeof item === "string" && !rule.values.includes(item)).map((item) => `\`${item}\` ${rule.because}`);
6534
6533
  }
6534
+ case "notMap": {
6535
+ const value = entry[rule.field];
6536
+ return isEntry(value) ? [`${rule.field} is a map. ${rule.because}`] : [];
6537
+ }
6535
6538
  case "uniformPrefix": {
6536
6539
  const value = entry[rule.field];
6537
6540
  if (!Array.isArray(value))
@@ -7022,8 +7025,7 @@ var CATALOG_HINTS = hintsOf({
7022
7025
  "plans.variants.customize.upsertLicenses.customize.addItems.featureOverride.creditSchema.multipliers",
7023
7026
  "plans.variants.customize.upsertLicenses.customize.addItems.featureOverride.creditSchema.multipliers.*.match",
7024
7027
  "plans.variants.customize.upsertLicenses.customize.addItems.featureOverride.markups.modelMarkups",
7025
- "plans.variants.customize.upsertLicenses.customize.addItems.featureOverride.markups.providerMarkups",
7026
- "webhooks.url"
7028
+ "plans.variants.customize.upsertLicenses.customize.addItems.featureOverride.markups.providerMarkups"
7027
7029
  ],
7028
7030
  frozenPaths: [
7029
7031
  "plans.licenses.metadata",
@@ -7321,7 +7323,7 @@ var createClient = (options) => ({
7321
7323
  });
7322
7324
 
7323
7325
  // src/version.ts
7324
- var version = "2.0.42";
7326
+ var version = "2.0.43";
7325
7327
 
7326
7328
  // src/http/autumnFetch.ts
7327
7329
  var runtime = typeof Bun !== "undefined" ? `bun ${Bun.version}` : `node ${process.versions.node}`;
@@ -9459,11 +9461,11 @@ var SYNCED_LISTS = {
9459
9461
  builder: "webhook",
9460
9462
  idField: "id",
9461
9463
  responseIdField: "id",
9462
- keys: ["id", "url", "events", "description", "disabled"],
9463
- required: ["id", "url"],
9464
- 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"],
9465
9467
  pull: true,
9466
- envKeyed: ["url"]
9468
+ envField: "env"
9467
9469
  }
9468
9470
  };
9469
9471
  var NESTED_FIXTURES = {
@@ -9578,16 +9580,16 @@ import { dirname as dirname4, join as join7 } from "node:path";
9578
9580
  import chalk3 from "chalk";
9579
9581
 
9580
9582
  // src/generated/skills.ts
9581
- var SKILLS_VERSION = "2.0.42";
9583
+ var SKILLS_VERSION = "2.0.43";
9582
9584
  var SKILLS = [
9583
9585
  {
9584
9586
  name: "autumn-setup",
9585
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.",
9586
- version: "2.0.42",
9588
+ version: "2.0.43",
9587
9589
  markdown: `---
9588
9590
  name: autumn-setup
9589
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.
9590
- version: 2.0.42
9592
+ version: 2.0.43
9591
9593
  ---
9592
9594
 
9593
9595
  # Setup
@@ -9745,11 +9747,11 @@ Then stop. Don't keep building, don't tour the dashboard, don't deploy anything.
9745
9747
  {
9746
9748
  name: "autumn-catalog",
9747
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.",
9748
- version: "2.0.42",
9750
+ version: "2.0.43",
9749
9751
  markdown: `---
9750
9752
  name: autumn-catalog
9751
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.
9752
- version: 2.0.42
9754
+ version: 2.0.43
9753
9755
  ---
9754
9756
 
9755
9757
  # Catalog
@@ -13616,7 +13618,7 @@ Pitch: "Pro $20/mo or $200/yr — 1,000 messages per month either way."
13616
13618
  },
13617
13619
  {
13618
13620
  path: "references/atmn.md",
13619
- 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.\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. 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."
13620
13622
  },
13621
13623
  {
13622
13624
  path: "references/catalog-update.md",
@@ -13631,11 +13633,11 @@ Pitch: "Pro $20/mo or $200/yr — 1,000 messages per month either way."
13631
13633
  {
13632
13634
  name: "autumn-integrate",
13633
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.",
13634
- version: "2.0.42",
13636
+ version: "2.0.43",
13635
13637
  markdown: `---
13636
13638
  name: autumn-integrate
13637
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.
13638
- version: 2.0.42
13640
+ version: 2.0.43
13639
13641
  ---
13640
13642
 
13641
13643
  # Integrate
@@ -18237,11 +18239,11 @@ Monitor your logs for this message. If you see it, check [status.useautumn.com](
18237
18239
  {
18238
18240
  name: "autumn-concepts",
18239
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.",
18240
- version: "2.0.42",
18242
+ version: "2.0.43",
18241
18243
  markdown: `---
18242
18244
  name: autumn-concepts
18243
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.
18244
- version: 2.0.42
18246
+ version: 2.0.43
18245
18247
  ---
18246
18248
 
18247
18249
  # Concepts
@@ -19429,7 +19431,7 @@ var runOAuthFlow = async ({
19429
19431
  if (outcome)
19430
19432
  return outcome;
19431
19433
  }
19432
- 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.`);
19433
19435
  };
19434
19436
 
19435
19437
  // src/actions/login.ts
@@ -22493,65 +22495,6 @@ var rewriteConfig = ({
22493
22495
  return { rewritten: [configPath, ...rewritten], kept };
22494
22496
  };
22495
22497
 
22496
- // src/actions/webhooks/isDashboardWebhook.ts
22497
- var SVIX_ENDPOINT_ID = /^ep_[0-9A-Za-z]{27}$/;
22498
- var isDashboardWebhook = ({ id }) => SVIX_ENDPOINT_ID.test(id);
22499
-
22500
- // ../ksuid/src/base62.ts
22501
- var ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
22502
- var base62Encode = ({
22503
- bytes,
22504
- padTo
22505
- }) => {
22506
- const digits = [];
22507
- const working = new Uint8Array(bytes);
22508
- let allZero = false;
22509
- while (!allZero) {
22510
- allZero = true;
22511
- let remainder = 0;
22512
- for (let i = 0;i < working.length; i++) {
22513
- const accumulator = remainder * 256 + working[i];
22514
- working[i] = Math.floor(accumulator / 62);
22515
- remainder = accumulator % 62;
22516
- if (working[i] !== 0)
22517
- allZero = false;
22518
- }
22519
- digits.push(remainder);
22520
- }
22521
- digits.reverse();
22522
- while (digits.length < padTo) {
22523
- digits.unshift(0);
22524
- }
22525
- return digits.map((d) => ALPHABET[d]).join("");
22526
- };
22527
-
22528
- // ../ksuid/src/random.ts
22529
- var getRandomBytes = ({ size }) => {
22530
- const buffer = new Uint8Array(size);
22531
- globalThis.crypto.getRandomValues(buffer);
22532
- return buffer;
22533
- };
22534
-
22535
- // ../ksuid/src/index.ts
22536
- var KSUID_EPOCH = 1400000000;
22537
- var PAYLOAD_BYTES = 20;
22538
- var ENCODED_LENGTH = 27;
22539
- var generateKsuid = ({ prefix = "" } = {}) => {
22540
- const payload = new Uint8Array(PAYLOAD_BYTES);
22541
- const timestamp = Math.floor(Date.now() / 1000) - KSUID_EPOCH;
22542
- payload[0] = timestamp >>> 24 & 255;
22543
- payload[1] = timestamp >>> 16 & 255;
22544
- payload[2] = timestamp >>> 8 & 255;
22545
- payload[3] = timestamp & 255;
22546
- const randomBytes = getRandomBytes({ size: 16 });
22547
- payload.set(randomBytes, 4);
22548
- const encoded = base62Encode({ bytes: payload, padTo: ENCODED_LENGTH });
22549
- return `${prefix}${encoded}`;
22550
- };
22551
-
22552
- // src/actions/webhooks/newWebhookId.ts
22553
- var newWebhookId = () => generateKsuid({ prefix: "wh_" });
22554
-
22555
22498
  // src/actions/pull/webhooks/appendWebhook.ts
22556
22499
  var SPEC = SYNCED_LISTS.webhooks;
22557
22500
  var COLLECTION = "webhooks";
@@ -22560,7 +22503,8 @@ var fixtureRowOf = ({
22560
22503
  envKey
22561
22504
  }) => ({
22562
22505
  id: webhook.id,
22563
- url: { [envKey]: webhook.url },
22506
+ [SPEC.envField]: envKey,
22507
+ url: webhook.url,
22564
22508
  ...webhook.events.length > 0 ? { events: webhook.events } : {},
22565
22509
  ...webhook.description ? { description: webhook.description } : {},
22566
22510
  ...webhook.disabled ? { disabled: true } : {}
@@ -22611,19 +22555,67 @@ var appendWebhook = ({
22611
22555
  var SPEC2 = SYNCED_LISTS.webhooks;
22612
22556
  var locateWebhook = ({
22613
22557
  pull,
22614
- id
22558
+ id,
22559
+ envKey
22615
22560
  }) => locateFixture({
22616
22561
  configPath: pull.configPath,
22617
22562
  files: pull.files,
22618
22563
  builder: SPEC2.builder,
22619
22564
  idField: SPEC2.idField,
22620
22565
  id,
22566
+ where: webhookWhere({ envKey }),
22621
22567
  allowDynamic: true
22622
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
+ };
22623
22615
 
22624
22616
  // src/actions/pull/webhooks/updateWebhook.ts
22625
22617
  import { isDeepStrictEqual as isDeepStrictEqual2 } from "node:util";
22626
- var SPEC3 = SYNCED_LISTS.webhooks;
22618
+ var SPEC4 = SYNCED_LISTS.webhooks;
22627
22619
  var sameEvents = (left, right) => Array.isArray(left) && isDeepStrictEqual2([...left].sort(), [...right].sort());
22628
22620
  var fieldAssignments = ({
22629
22621
  webhook,
@@ -22658,8 +22650,8 @@ var nonLiteralUrlWarning = ({
22658
22650
  }) => {
22659
22651
  const pad = " ".repeat(id.length + 4);
22660
22652
  return [
22661
- `⚠ ${id} url.${envKey} isn't a plain string, so pull left it untouched`,
22662
- `${pad}server: ${server ?? "(not registered)"}`,
22653
+ `⚠ ${id} (${envKey}) url isn't a plain string, so pull left it untouched`,
22654
+ `${pad}server: ${server}`,
22663
22655
  `${pad}your config: ${typeof config === "string" ? config : "(no value)"}`,
22664
22656
  `${pad}Update it by hand, or make it a string and pull will manage it.`
22665
22657
  ].join(`
@@ -22672,161 +22664,57 @@ var updateWebhook = ({
22672
22664
  envKey
22673
22665
  }) => {
22674
22666
  const result = { lines: [], warnings: [], unlocated: [] };
22675
- const located = locateWebhook({ pull, id: webhook.id });
22676
- if (located === null) {
22677
- result.unlocated.push({
22678
- id: webhook.id,
22679
- action: "update from the server by hand"
22680
- });
22681
- return result;
22682
- }
22683
- 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({
22684
22670
  source: located.source,
22685
- builder: SPEC3.builder,
22686
- idField: SPEC3.idField,
22671
+ builder: SPEC4.builder,
22672
+ idField: SPEC4.idField,
22687
22673
  id: webhook.id,
22674
+ where: webhookWhere({ envKey }),
22688
22675
  assignments: [
22689
- { path: ["url", envKey], text: JSON.stringify(webhook.url) },
22676
+ { path: ["url"], text: JSON.stringify(webhook.url) },
22690
22677
  ...fieldAssignments({ webhook, stated: stated2 })
22691
22678
  ],
22692
22679
  bareKeys: true
22693
22680
  });
22694
- if (patched === null) {
22681
+ if (located === null || patched === null) {
22695
22682
  result.unlocated.push({
22696
22683
  id: webhook.id,
22697
- action: "update from the server by hand"
22684
+ action: `update the ${envKey} webhook from the server by hand`
22698
22685
  });
22699
22686
  return result;
22700
22687
  }
22701
22688
  for (const { path } of patched.skipped) {
22702
22689
  const field = path.join(".");
22703
- if (field !== `url.${envKey}`) {
22704
- 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`);
22705
22692
  continue;
22706
22693
  }
22707
- const config = stated2.url?.[envKey];
22708
- if (config === webhook.url)
22694
+ if (stated2.url === webhook.url)
22709
22695
  continue;
22710
22696
  result.warnings.push(nonLiteralUrlWarning({
22711
22697
  id: webhook.id,
22712
22698
  envKey,
22713
22699
  server: webhook.url,
22714
- config
22715
- }));
22716
- }
22717
- if (patched.source === located.source)
22718
- return result;
22719
- pull.files.set(located.file, patched.source);
22720
- result.lines.push(`~ webhook ${webhook.id}`);
22721
- return result;
22722
- };
22723
-
22724
- // src/actions/pull/webhooks/removeWebhookEnv.ts
22725
- var SPEC4 = SYNCED_LISTS.webhooks;
22726
- var urlMapIsEmpty = ({
22727
- pull,
22728
- id
22729
- }) => {
22730
- const located = locateWebhook({ pull, id });
22731
- const object = located === null ? null : fixtureObjectOf(located.node);
22732
- const member = object?.namedChildren().find((child) => child.kind() === "pair" && child.field("key") !== null && fixtureKeyOf({ node: child.field("key") }) === "url");
22733
- const value = member?.field("value");
22734
- if (value === null || value === undefined)
22735
- return false;
22736
- const read = readFixtureValue({ node: value });
22737
- return typeof read === "object" && read !== null && Object.keys(read).length === 0;
22738
- };
22739
- var deleteWebhook = ({
22740
- pull,
22741
- id
22742
- }) => {
22743
- const located = locateWebhook({ pull, id });
22744
- if (located === null)
22745
- return false;
22746
- const removed = deleteFixtureLiteral({
22747
- source: located.source,
22748
- builder: SPEC4.builder,
22749
- idField: SPEC4.idField,
22750
- id
22751
- });
22752
- if (removed === null)
22753
- return false;
22754
- pull.files.set(located.file, removed.source);
22755
- if (removed.exportedName !== undefined) {
22756
- const referenceFiles = new Set([pull.configPath]);
22757
- const target = resolveCollectionTarget({
22758
- configPath: pull.configPath,
22759
- files: pull.files,
22760
- collection: "webhooks"
22761
- });
22762
- if (target !== null)
22763
- referenceFiles.add(target.file);
22764
- for (const file of referenceFiles)
22765
- pull.files.set(file, deleteReference({
22766
- source: pull.files.get(file) ?? "",
22767
- name: removed.exportedName
22768
- }));
22769
- }
22770
- return true;
22771
- };
22772
- var removeWebhookEnv = ({
22773
- pull,
22774
- stated: stated2,
22775
- envKey
22776
- }) => {
22777
- const result = { lines: [], warnings: [], unlocated: [] };
22778
- const located = locateWebhook({ pull, id: stated2.id });
22779
- const patched = located === null ? null : patchFixturePaths({
22780
- source: located.source,
22781
- builder: SPEC4.builder,
22782
- idField: SPEC4.idField,
22783
- id: stated2.id,
22784
- assignments: [{ path: ["url", envKey], text: null }]
22785
- });
22786
- if (located === null || patched === null) {
22787
- result.unlocated.push({
22788
- id: stated2.id,
22789
- action: `remove url.${envKey} by hand`
22790
- });
22791
- return result;
22792
- }
22793
- if (patched.skipped.length > 0) {
22794
- result.warnings.push(nonLiteralUrlWarning({
22795
- id: stated2.id,
22796
- envKey,
22797
- server: undefined,
22798
- config: stated2.url?.[envKey]
22700
+ config: stated2.url
22799
22701
  }));
22800
- return result;
22801
22702
  }
22802
22703
  if (patched.source === located.source)
22803
22704
  return result;
22804
22705
  pull.files.set(located.file, patched.source);
22805
- if (urlMapIsEmpty({ pull, id: stated2.id })) {
22806
- if (deleteWebhook({ pull, id: stated2.id })) {
22807
- result.lines.push(`- webhook ${stated2.id}`);
22808
- return result;
22809
- }
22810
- result.unlocated.push({
22811
- id: stated2.id,
22812
- action: "delete the webhook by hand: its url map is now empty"
22813
- });
22814
- return result;
22815
- }
22816
- result.lines.push(`- webhook ${stated2.id} url.${envKey}`);
22706
+ result.lines.push(`~ webhook ${label}`);
22817
22707
  return result;
22818
22708
  };
22819
22709
 
22820
22710
  // src/actions/pull/webhooks/applyWebhooksPull.ts
22821
- var isVercelWebhook = (events) => Array.isArray(events) && events.length > 0 && events.every((event) => typeof event === "string" && event.startsWith("vercel."));
22822
22711
  var statedFrom = ({
22823
- row,
22824
22712
  webhook,
22825
22713
  envKey
22826
22714
  }) => ({
22827
- ...row,
22828
22715
  id: webhook.id,
22829
- url: { ...row?.url, [envKey]: webhook.url },
22716
+ env: envKey,
22717
+ url: webhook.url,
22830
22718
  events: webhook.events.length > 0 ? webhook.events : undefined,
22831
22719
  description: webhook.description,
22832
22720
  disabled: webhook.disabled
@@ -22840,73 +22728,37 @@ var applyWebhooksPull = ({
22840
22728
  pull,
22841
22729
  remote,
22842
22730
  stated: stated2,
22843
- envKey,
22844
- newId = newWebhookId
22731
+ envKey
22845
22732
  }) => {
22846
22733
  const result = { lines: [], warnings: [], unlocated: [] };
22847
- const statedById = new Map((stated2 ?? []).map((row) => [row.id, row]));
22848
- const next = new Map(statedById);
22849
- const fromDashboard = (webhook) => !statedById.has(webhook.id) && isDashboardWebhook(webhook);
22850
- 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);
22851
22737
  for (const webhook of remote) {
22852
- if (fromDashboard(webhook)) {
22853
- const sameApp = (row2) => isVercelWebhook(row2.events) === isVercelWebhook(webhook.events);
22854
- 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));
22855
- if (represented !== undefined) {
22856
- present.add(represented.id);
22857
- next.set(represented.id, statedFrom({
22858
- row: represented,
22859
- webhook: { ...webhook, id: represented.id },
22860
- envKey
22861
- }));
22862
- merge(result, updateWebhook({
22863
- pull,
22864
- webhook: { ...webhook, id: represented.id },
22865
- stated: represented,
22866
- envKey
22867
- }));
22868
- continue;
22869
- }
22870
- const id = newId();
22871
- const failure2 = appendWebhook({
22872
- pull,
22873
- webhook: { ...webhook, id },
22874
- envKey
22875
- });
22876
- if (failure2 === null) {
22877
- next.set(id, statedFrom({ row: undefined, webhook: { ...webhook, id }, envKey }));
22878
- result.lines.push(`+ webhook ${id} (made in the dashboard; push adopts it by URL)`);
22879
- } else
22880
- result.unlocated.push({ id, action: failure2 });
22881
- continue;
22882
- }
22883
- present.add(webhook.id);
22884
- const row = statedById.get(webhook.id);
22885
- next.set(webhook.id, statedFrom({ row, webhook, envKey }));
22738
+ const row = inEnv.get(webhook.id);
22886
22739
  if (row !== undefined) {
22887
22740
  merge(result, updateWebhook({ pull, webhook, stated: row, envKey }));
22741
+ next.push(statedFrom({ webhook, envKey }));
22888
22742
  continue;
22889
22743
  }
22890
22744
  const failure = appendWebhook({ pull, webhook, envKey });
22891
- if (failure === null)
22892
- result.lines.push(`+ webhook ${webhook.id}`);
22893
- else
22745
+ if (failure !== null) {
22894
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 }));
22895
22751
  }
22896
- for (const row of stated2 ?? []) {
22897
- 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))
22898
22755
  continue;
22899
- const removed = removeWebhookEnv({ pull, stated: row, envKey });
22756
+ const removed = deleteWebhook({ pull, stated: row, envKey });
22900
22757
  merge(result, removed);
22901
22758
  if (removed.lines.length === 0)
22902
- continue;
22903
- const { [envKey]: _removed, ...url } = row.url;
22904
- if (Object.keys(url).length === 0)
22905
- next.delete(row.id);
22906
- else
22907
- next.set(row.id, { ...row, url });
22759
+ next.push(row);
22908
22760
  }
22909
- return { ...result, stated: [...next.values()] };
22761
+ return { ...result, stated: next };
22910
22762
  };
22911
22763
 
22912
22764
  // src/generated/sandboxName.ts
@@ -22974,6 +22826,7 @@ var webhookPullEnvs = ({
22974
22826
  {
22975
22827
  keyName,
22976
22828
  label,
22829
+ secretKey,
22977
22830
  envKey,
22978
22831
  listWebhooks: () => listWebhooks({ secretKey })
22979
22832
  }
@@ -22981,8 +22834,28 @@ var webhookPullEnvs = ({
22981
22834
  });
22982
22835
  };
22983
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
+
22984
22858
  // src/actions/pull/webhooks/readWebhookEnvs.ts
22985
- var isRejectedKey = (error) => error instanceof AutumnApiError && (error.status === 401 || error.status === 403);
22986
22859
  var readWebhookEnvs = async ({
22987
22860
  envs
22988
22861
  }) => {
@@ -22994,11 +22867,10 @@ var readWebhookEnvs = async ({
22994
22867
  ]);
22995
22868
  return { envKey, list: list2 };
22996
22869
  } catch (error) {
22997
- if (error instanceof ForeignOrgKeyError)
22998
- return `⚠ webhooks: skipped ${env.label} (${env.keyName} belongs to another org)`;
22999
- if (!isRejectedKey(error))
22870
+ const warning = webhookEnvSkipWarning({ env, error });
22871
+ if (warning === undefined)
23000
22872
  throw error;
23001
- return `⚠ webhooks: skipped ${env.label} (${env.keyName} was rejected)`;
22873
+ return warning;
23002
22874
  }
23003
22875
  }));
23004
22876
  return {
@@ -23095,7 +22967,8 @@ var renderWebhooks = ({
23095
22967
  if (rows.length === 0)
23096
22968
  return null;
23097
22969
  const count = webhooks.changes.filter((change) => change.action !== "unmanaged").length;
23098
- 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(`
23099
22972
  `);
23100
22973
  };
23101
22974
 
@@ -23522,8 +23395,10 @@ var renderPreview = ({
23522
23395
  const referralPrograms = (preview.referralPrograms ?? []).filter(rowHasWork);
23523
23396
  const migrations = preview.migrations ?? [];
23524
23397
  const settings = settingChanges(preview.settings);
23525
- const webhooks = preview.webhooks === undefined ? null : renderWebhooks({ webhooks: preview.webhooks });
23526
- 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) {
23527
23402
  return chalk5.dim("No changes. Your catalog matches your config.");
23528
23403
  }
23529
23404
  const sections = [];
@@ -23592,8 +23467,9 @@ var renderPreview = ({
23592
23467
  ].join(`
23593
23468
  `));
23594
23469
  }
23595
- if (webhooks !== null)
23596
- sections.push(webhooks);
23470
+ sections.push(...webhooks);
23471
+ if (productionHint !== null)
23472
+ sections.push(productionHint);
23597
23473
  if (migrations.length > 0) {
23598
23474
  sections.push(renderPlannedMigrations({ migrations, plans }));
23599
23475
  }
@@ -23603,7 +23479,7 @@ var renderPreview = ({
23603
23479
  };
23604
23480
  var previewIsEmpty = ({
23605
23481
  preview
23606
- }) => !(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 }));
23607
23483
 
23608
23484
  // src/actions/push/backfillInternalIds.ts
23609
23485
  import { readFileSync as readFileSync12, writeFileSync as writeFileSync8 } from "node:fs";
@@ -24059,18 +23935,35 @@ var withScopeHint = ({
24059
23935
  hint: hint2
24060
23936
  }) => error instanceof AutumnApiError && error.message.includes(SCOPE_REFUSAL) ? new Error(hint2, { cause: error }) : error;
24061
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
+
24062
23954
  // src/actions/webhooks/writeWebhookSecrets.ts
24063
23955
  import { existsSync as existsSync12, readFileSync as readFileSync13, writeFileSync as writeFileSync9 } from "node:fs";
24064
23956
  import { dirname as dirname11, join as join13, relative as relative6 } from "node:path";
24065
23957
 
24066
23958
  // src/actions/webhooks/webhookSecretName.ts
24067
23959
  var envNameSegment = (value) => value.toUpperCase().replace(/-/g, "_");
23960
+ var envKeySegment = (envKey) => envKey.toUpperCase().replace(/[^A-Z0-9]+/g, "_");
24068
23961
  var webhookSecretName = ({
24069
23962
  id,
24070
- orgId
23963
+ envKey
24071
23964
  }) => {
24072
- const org4 = orgId === undefined ? "" : `_${envNameSegment(orgId.replace(/^org_/, "").slice(0, 4))}`;
24073
- return `AUTUMN_WEBHOOK_${envNameSegment(id)}${org4}_SECRET`;
23965
+ const suffix = envKey === undefined ? "" : `_${envKeySegment(envKey)}`;
23966
+ return `AUTUMN_WEBHOOK_${envNameSegment(id)}${suffix}_SECRET`;
24074
23967
  };
24075
23968
 
24076
23969
  // src/actions/webhooks/writeWebhookSecrets.ts
@@ -24087,8 +23980,11 @@ var writeWebhookSecrets = async ({
24087
23980
  }) => {
24088
23981
  if (secrets.length === 0)
24089
23982
  return [];
24090
- const orgId = env.live ? undefined : await env.orgId();
24091
- 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
+ ]));
24092
23988
  let path;
24093
23989
  if (env.live) {
24094
23990
  path = prodEnvPath({ envDirs });
@@ -24102,16 +23998,13 @@ var writeWebhookSecrets = async ({
24102
23998
  };
24103
23999
 
24104
24000
  // src/actions/webhooks/applyWebhooks.ts
24105
- var applyWebhooks = async ({
24106
- client,
24001
+ var applyLane = async ({
24107
24002
  lane,
24108
24003
  envDirs,
24109
24004
  cwd,
24110
24005
  write
24111
24006
  }) => {
24112
- if (lane === undefined || !webhooksHaveWork({ webhooks: lane.preview }))
24113
- return;
24114
- const result = await client.syncWebhooks(lane.body);
24007
+ const result = await lane.client.syncWebhooks(lane.body);
24115
24008
  const saved = await writeWebhookSecrets({
24116
24009
  secrets: result.secrets,
24117
24010
  env: lane.env,
@@ -24124,54 +24017,168 @@ var applyWebhooks = async ({
24124
24017
  ]);
24125
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`);
24126
24019
  write(`
24127
- Applied webhooks.
24020
+ Applied webhooks to ${lane.env.key}.
24128
24021
  ${[...adopted, ...saved].map((line2) => `${line2}
24129
24022
  `).join("")}`);
24130
24023
  if (result.errors.length > 0)
24131
24024
  throw new Error(result.errors.map(({ id, message }) => `${id}: ${message}`).join(`
24132
24025
  `));
24133
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
+ };
24134
24043
 
24135
24044
  // src/actions/webhooks/resolveWebhooksForEnv.ts
24136
24045
  var resolveWebhooksForEnv = ({
24137
24046
  rows,
24138
24047
  envKey
24139
24048
  }) => {
24140
- const { envKeyed } = SYNCED_LISTS.webhooks;
24141
- const webhooks = [];
24142
- for (const row of rows) {
24143
- const values = envKeyed.map((field) => row[field]?.[envKey]);
24144
- if (values.some((value) => typeof value !== "string"))
24145
- continue;
24146
- webhooks.push({
24147
- ...row,
24148
- ...Object.fromEntries(envKeyed.map((field, index) => [field, values[index]]))
24149
- });
24150
- }
24151
- return webhooks;
24049
+ const { envField } = SYNCED_LISTS.webhooks;
24050
+ return rows.filter((row) => row[envField] === envKey).map(({ [envField]: _env, ...row }) => row);
24152
24051
  };
24153
24052
 
24154
- // src/actions/webhooks/previewWebhooks.ts
24155
- var previewWebhooks = async ({
24156
- 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,
24085
+ rows,
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 ({
24157
24113
  rows,
24158
- webhookEnv
24114
+ envs,
24115
+ prod,
24116
+ clientFor
24159
24117
  }) => {
24160
24118
  if (rows === undefined)
24161
24119
  return;
24162
- if (webhookEnv === undefined)
24163
- throw new Error("webhooks need the target environment to pick each url.");
24164
- const env = await webhookEnv();
24165
- const webhooks = resolveWebhooksForEnv({
24166
- rows,
24167
- 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)})`);
24168
24134
  });
24169
- const body = { webhooks };
24170
- const { changes, errors } = await client.previewSyncWebhooks(body);
24171
- if (errors.length > 0)
24172
- throw new Error(errors.map(({ id, message }) => `${id}: ${message}`).join(`
24173
- `));
24174
- 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 };
24175
24182
  };
24176
24183
 
24177
24184
  // src/actions/push.ts
@@ -24337,7 +24344,9 @@ var runPush = async ({
24337
24344
  dryRun = false,
24338
24345
  write = (text) => process.stdout.write(text),
24339
24346
  migrationLinkBase,
24340
- webhookEnv
24347
+ prod = false,
24348
+ webhookEnvs,
24349
+ webhookClientFor
24341
24350
  }) => {
24342
24351
  const project = resolveProject({ cwd, configFlag });
24343
24352
  const dirs = configSearchDirs({ cwd, configPath: configFlag });
@@ -24360,10 +24369,11 @@ var runPush = async ({
24360
24369
  const previews = await settleLanes({
24361
24370
  settings: previewSettings({ client, body: settingsBody }),
24362
24371
  catalog: client.previewUpdate(wire),
24363
- webhooks: previewWebhooks({
24364
- client,
24372
+ webhooks: previewWebhookEnvs({
24365
24373
  rows: lists.webhooks,
24366
- webhookEnv
24374
+ envs: webhookEnvs,
24375
+ prod,
24376
+ clientFor: webhookClientFor
24367
24377
  })
24368
24378
  });
24369
24379
  const { settings, webhooks } = previews.values;
@@ -24375,8 +24385,17 @@ var runPush = async ({
24375
24385
  const preview = {
24376
24386
  ...catalogPreview,
24377
24387
  settings,
24378
- ...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
+ }
24379
24393
  };
24394
+ if (webhooks !== undefined && webhooks.skipped.length > 0)
24395
+ write(`${webhooks.skipped.join(`
24396
+ `)}
24397
+
24398
+ `);
24380
24399
  write(`${renderPreview({ preview, migrationLinkBase })}
24381
24400
  `);
24382
24401
  if (catalogDeferral !== undefined)
@@ -24413,8 +24432,7 @@ ${renderCatalogDeferral({ error: catalogDeferral.error })}
24413
24432
  const applied = await settleLanes({
24414
24433
  catalog: settled === undefined ? settingsStep().then(catalogStep) : catalogStep(settled),
24415
24434
  webhooks: applyWebhooks({
24416
- client,
24417
- lane: webhooks,
24435
+ lanes: webhooks?.lanes ?? [],
24418
24436
  envDirs: project.envDirs,
24419
24437
  cwd,
24420
24438
  write
@@ -25163,28 +25181,6 @@ var runSandboxUse = async ({
25163
25181
  return result;
25164
25182
  };
25165
25183
 
25166
- // src/actions/webhooks/resolveWebhookEnv.ts
25167
- var memoized = (fetchOrgInfo2) => {
25168
- let pending;
25169
- return () => {
25170
- pending ??= fetchOrgInfo2();
25171
- return pending;
25172
- };
25173
- };
25174
- var resolveWebhookEnv = async ({
25175
- target,
25176
- prod,
25177
- fetchOrgInfo: fetchOrgInfo2
25178
- }) => {
25179
- const orgInfo = memoized(fetchOrgInfo2);
25180
- const orgId = async () => (await orgInfo()).id;
25181
- if (prod)
25182
- return { key: "live", live: true, orgId };
25183
- if (target.sandboxId === undefined)
25184
- return { key: "sandbox", live: false, orgId };
25185
- return { key: sandboxSlug((await orgInfo()).name), live: false, orgId };
25186
- };
25187
-
25188
25184
  // src/cli.ts
25189
25185
  var normalizeVersionFlag = ({ argv }) => argv.map((token) => token === "-v" ? "--version" : token);
25190
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)");
@@ -25276,13 +25272,15 @@ var pinnedSandboxName = async ({
25276
25272
  return {};
25277
25273
  }
25278
25274
  };
25279
- var webhookEnvFor = ({ target, command }) => () => resolveWebhookEnv({
25280
- target,
25281
- prod: command.optsWithGlobals().prod === true,
25282
- fetchOrgInfo: () => fetchOrgInfo({
25283
- baseUrl: targetBaseUrl({ target }),
25284
- secretKey: requireSecretKey({ target })
25285
- })
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 })
25286
25284
  });
25287
25285
  var mainOrgInfo = ({ target }) => {
25288
25286
  const main = managementTarget({ target });
@@ -25382,7 +25380,9 @@ Linking a keyless org to an account:
25382
25380
  client: clientFor({ target }),
25383
25381
  configPath: configFlagOf({ command }),
25384
25382
  dryRun: !apply,
25385
- webhookEnv: webhookEnvFor({ target, command })
25383
+ prod: command.optsWithGlobals().prod === true,
25384
+ webhookEnvs: webhookEnvsFor({ target }),
25385
+ webhookClientFor: webhookClientFor({ target })
25386
25386
  });
25387
25387
  process.exitCode = pushExitCode({ apply, result });
25388
25388
  if (!apply && !previewIsEmpty({ preview: result.preview }))
@@ -25432,15 +25432,7 @@ Nothing applied. Re-run with --yes to apply these changes.
25432
25432
  overwrite: options.overwrite === true,
25433
25433
  yes: options.yes === true,
25434
25434
  prompter: prompterFor({ command }),
25435
- webhookEnvs: () => webhookPullEnvs({
25436
- targetKeyName: target.secretKeyName,
25437
- listWebhooks: ({ secretKey }) => createClient({
25438
- secretKey,
25439
- fetch: autumnFetch,
25440
- ...target.baseUrl ? { baseUrl: target.baseUrl } : {}
25441
- }).listWebhooks({}),
25442
- fetchOrgInfo: ({ secretKey }) => fetchOrgInfo({ baseUrl: targetBaseUrl({ target }), secretKey })
25443
- })
25435
+ webhookEnvs: webhookEnvsFor({ target })
25444
25436
  });
25445
25437
  writeStaleSkillsHint({ command });
25446
25438
  });