atmn 2.0.42 → 2.0.44

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
@@ -4,11 +4,13 @@ import { sandboxSlug } from "../../../generated/sandboxName";
4
4
  import type { OrgInfo } from "../../env/types/orgInfo";
5
5
  import type { RemoteWebhook } from "./types";
6
6
 
7
- /** One env a pull reads webhooks from, through that env's own key. */
7
+ /** One env a pull reads webhooks from, or a push syncs them to, through that env's own key. */
8
8
  export type WebhookPullEnv = {
9
9
  keyName: SecretKeyName;
10
10
  /** How a skipped-env warning names it, before its key is known to work. */
11
11
  label: string;
12
+ /** This env's own key: push syncs each env through it. */
13
+ secretKey: string;
12
14
  /** The `url` map key; a named sandbox's slug costs one lookup. */
13
15
  envKey: () => Promise<string>;
14
16
  listWebhooks: () => Promise<{ list: RemoteWebhook[] }>;
@@ -21,10 +23,7 @@ export class ForeignOrgKeyError extends Error {}
21
23
  const mainOrgIdOf = (info: OrgInfo): string =>
22
24
  info.is_sandbox && info.created_by ? info.created_by : info.id;
23
25
 
24
- /**
25
- * Every env the loaded env files hold a key for. Live comes last, so when envs
26
- * disagree on shared fields like `events`, prod's value is the one written.
27
- */
26
+ /** Every env the loaded env files hold a key for, live last. */
28
27
  export const webhookPullEnvs = ({
29
28
  env = process.env,
30
29
  targetKeyName,
@@ -76,6 +75,7 @@ export const webhookPullEnvs = ({
76
75
  {
77
76
  keyName,
78
77
  label,
78
+ secretKey,
79
79
  envKey,
80
80
  listWebhooks: () => listWebhooks({ secretKey }),
81
81
  },
@@ -375,7 +375,7 @@ export const runPull = async ({
375
375
 
376
376
  const webhookWarnings = [...webhooks.skipped];
377
377
  let stated = lists.webhooks as StatedWebhook[] | undefined;
378
- // Only an env whose list succeeded may remove its urls from the config.
378
+ // Only an env whose list succeeded may remove its entries from the config.
379
379
  for (const { envKey, list } of webhooks.read) {
380
380
  const applied = applyWebhooksPull({
381
381
  pull: { configPath, files },
@@ -12,6 +12,7 @@ import {
12
12
  type SettingsPreview,
13
13
  settingsHaveWork,
14
14
  } from "../render/renderPreview";
15
+ import type { WebhookPullEnv } from "./pull/webhooks/webhookPullEnvs";
15
16
  import {
16
17
  backfillInternalIds,
17
18
  identityRowsFromApplied,
@@ -23,8 +24,11 @@ import {
23
24
  import { PushLanesError, settleLanes } from "./push/pushLanesError";
24
25
  import { withSettingsScopeHint } from "./sandbox/withSandboxScopeHint";
25
26
  import { applyWebhooks } from "./webhooks/applyWebhooks";
26
- import { previewWebhooks, type WebhooksLane } from "./webhooks/previewWebhooks";
27
- import type { WebhookEnv } from "./webhooks/types/webhookEnv";
27
+ import {
28
+ previewWebhookEnvs,
29
+ type WebhookClientFor,
30
+ type WebhookLanes,
31
+ } from "./webhooks/previewWebhookEnvs";
28
32
 
29
33
  export type PushResult = {
30
34
  configPath: string;
@@ -61,8 +65,12 @@ export type PushOptions = {
61
65
  /** Where to write progress. Injected so tests can capture it. */
62
66
  write?: (text: string) => void;
63
67
  migrationLinkBase?: string;
64
- /** The target env for the webhook lane; resolved only when the config states webhooks. */
65
- webhookEnv?: () => Promise<WebhookEnv>;
68
+ /** `-p`: the catalog and webhooks go to live, and no sandbox is touched. */
69
+ prod?: boolean;
70
+ /** Every env with a key; listed only when the config states webhooks. */
71
+ webhookEnvs?: () => WebhookPullEnv[];
72
+ /** A client for one env's own key. */
73
+ webhookClientFor?: WebhookClientFor;
66
74
  };
67
75
 
68
76
  type RewardBody = { id?: string; internal_id?: string };
@@ -340,7 +348,9 @@ export const runPush = async ({
340
348
  dryRun = false,
341
349
  write = (text) => process.stdout.write(text),
342
350
  migrationLinkBase,
343
- webhookEnv,
351
+ prod = false,
352
+ webhookEnvs,
353
+ webhookClientFor,
344
354
  }: PushOptions): Promise<PushResult> => {
345
355
  const project = resolveProject({ cwd, configFlag });
346
356
  const dirs = configSearchDirs({ cwd, configPath: configFlag });
@@ -362,14 +372,15 @@ export const runPush = async ({
362
372
  const previews = await settleLanes<{
363
373
  settings: SettingsPreview | undefined;
364
374
  catalog: CatalogPreview;
365
- webhooks: WebhooksLane | undefined;
375
+ webhooks: WebhookLanes | undefined;
366
376
  }>({
367
377
  settings: previewSettings({ client, body: settingsBody }),
368
378
  catalog: client.previewUpdate(wire) as Promise<CatalogPreview>,
369
- webhooks: previewWebhooks({
370
- client,
379
+ webhooks: previewWebhookEnvs({
371
380
  rows: lists.webhooks,
372
- webhookEnv,
381
+ envs: webhookEnvs,
382
+ prod,
383
+ clientFor: webhookClientFor,
373
384
  }),
374
385
  });
375
386
  const { settings, webhooks } = previews.values;
@@ -389,8 +400,16 @@ export const runPush = async ({
389
400
  const preview: CatalogPreview = {
390
401
  ...catalogPreview,
391
402
  settings,
392
- ...(webhooks === undefined ? {} : { webhooks: webhooks.preview }),
403
+ ...(webhooks === undefined
404
+ ? {}
405
+ : {
406
+ webhooks: webhooks.lanes.map((lane) => lane.preview),
407
+ productionWebhooks: webhooks.productionDiffers,
408
+ productionWebhooksUnchecked: webhooks.productionUnchecked,
409
+ }),
393
410
  };
411
+ if (webhooks !== undefined && webhooks.skipped.length > 0)
412
+ write(`${webhooks.skipped.join("\n")}\n\n`);
394
413
  write(`${renderPreview({ preview, migrationLinkBase })}\n`);
395
414
  if (catalogDeferral !== undefined)
396
415
  write(`\n${renderCatalogDeferral({ error: catalogDeferral.error })}\n`);
@@ -434,8 +453,7 @@ export const runPush = async ({
434
453
  ? settingsStep().then(catalogStep)
435
454
  : catalogStep(settled),
436
455
  webhooks: applyWebhooks({
437
- client,
438
- lane: webhooks,
456
+ lanes: webhooks?.lanes ?? [],
439
457
  envDirs: project.envDirs,
440
458
  cwd,
441
459
  write,
@@ -1,28 +1,28 @@
1
- import type { AutumnClient } from "../../generated/client";
2
1
  import { webhooksHaveWork } from "../../render/renderWebhooks";
3
- import type { WebhooksLane } from "./previewWebhooks";
2
+ import type { WebhooksLane } from "./previewWebhookEnvs";
3
+ import {
4
+ messageOf,
5
+ throwWebhookEnvFailures,
6
+ type WebhookEnvFailure,
7
+ } from "./webhookEnvFailures";
4
8
  import { writeWebhookSecrets } from "./writeWebhookSecrets";
5
9
 
6
10
  /**
7
11
  * Sync, then save every secret it returned — before reporting any per-item
8
12
  * failure, since a created webhook's secret is shown once and only here.
9
13
  */
10
- export const applyWebhooks = async ({
11
- client,
14
+ const applyLane = async ({
12
15
  lane,
13
16
  envDirs,
14
17
  cwd,
15
18
  write,
16
19
  }: {
17
- client: AutumnClient;
18
- lane: WebhooksLane | undefined;
20
+ lane: WebhooksLane;
19
21
  envDirs: string[];
20
22
  cwd: string;
21
23
  write: (text: string) => void;
22
24
  }): Promise<void> => {
23
- if (lane === undefined || !webhooksHaveWork({ webhooks: lane.preview }))
24
- return;
25
- const result = await client.syncWebhooks(lane.body);
25
+ const result = await lane.client.syncWebhooks(lane.body);
26
26
  const saved = await writeWebhookSecrets({
27
27
  secrets: result.secrets,
28
28
  env: lane.env,
@@ -43,10 +43,41 @@ export const applyWebhooks = async ({
43
43
  `Adopted ${change.id} (existing dashboard webhook); its signing secret is unchanged, nothing written`,
44
44
  );
45
45
  write(
46
- `\nApplied webhooks.\n${[...adopted, ...saved].map((line) => `${line}\n`).join("")}`,
46
+ `\nApplied webhooks to ${lane.env.key}.\n${[...adopted, ...saved].map((line) => `${line}\n`).join("")}`,
47
47
  );
48
48
  if (result.errors.length > 0)
49
49
  throw new Error(
50
50
  result.errors.map(({ id, message }) => `${id}: ${message}`).join("\n"),
51
51
  );
52
52
  };
53
+
54
+ /** Every env with work syncs in parallel; one env failing never stops another saving its secrets. */
55
+ export const applyWebhooks = async ({
56
+ lanes,
57
+ envDirs,
58
+ cwd,
59
+ write,
60
+ }: {
61
+ lanes: WebhooksLane[];
62
+ envDirs: string[];
63
+ cwd: string;
64
+ write: (text: string) => void;
65
+ }): Promise<void> => {
66
+ const withWork = lanes.filter((lane) =>
67
+ webhooksHaveWork({ webhooks: lane.preview }),
68
+ );
69
+ const results = await Promise.allSettled(
70
+ withWork.map((lane) => applyLane({ lane, envDirs, cwd, write })),
71
+ );
72
+ const failures: WebhookEnvFailure[] = results.flatMap((result, index) =>
73
+ result.status === "rejected"
74
+ ? [
75
+ {
76
+ env: withWork[index]?.env.key ?? "",
77
+ message: messageOf(result.reason),
78
+ },
79
+ ]
80
+ : [],
81
+ );
82
+ throwWebhookEnvFailures({ failures, envCount: withWork.length });
83
+ };
@@ -0,0 +1,213 @@
1
+ import type { AutumnClient, SyncWebhooksParams } from "../../generated/client";
2
+ import type { WebhooksPreview } from "../../render/renderWebhooks";
3
+ import { webhookEnvSkipWarning } from "../pull/webhooks/webhookEnvSkipWarning";
4
+ import type { WebhookPullEnv } from "../pull/webhooks/webhookPullEnvs";
5
+ import { resolveWebhooksForEnv } from "./resolveWebhooksForEnv";
6
+ import type { WebhookEnv } from "./types/webhookEnv";
7
+ import {
8
+ messageOf,
9
+ throwWebhookEnvFailures,
10
+ type WebhookEnvFailure,
11
+ } from "./webhookEnvFailures";
12
+ import { webhookSecretName } from "./webhookSecretName";
13
+
14
+ export type WebhookClient = Pick<
15
+ AutumnClient,
16
+ "previewSyncWebhooks" | "syncWebhooks"
17
+ >;
18
+
19
+ export type WebhookClientFor = (args: { secretKey: string }) => WebhookClient;
20
+
21
+ /** One env's share of a push: its preview, and exactly what the sync will send. */
22
+ export type WebhooksLane = {
23
+ preview: WebhooksPreview;
24
+ body: SyncWebhooksParams;
25
+ env: WebhookEnv;
26
+ client: WebhookClient;
27
+ };
28
+
29
+ export type WebhookLanes = {
30
+ lanes: WebhooksLane[];
31
+ /** Live webhooks a `-p` push would change; a plain push only reads them. */
32
+ productionDiffers: string[];
33
+ /** The read-only live check failed, so the preview can't claim production matches. */
34
+ productionUnchecked: boolean;
35
+ /** One warning per env whose key was rejected or belongs to another org. */
36
+ skipped: string[];
37
+ };
38
+
39
+ type EnvPreview =
40
+ | { lane: WebhooksLane; errors: { id: string; message: string }[] }
41
+ | { skipped: string }
42
+ | undefined;
43
+
44
+ const isLive = (env: WebhookPullEnv): boolean =>
45
+ env.keyName === "AUTUMN_PROD_SECRET_KEY";
46
+
47
+ /**
48
+ * Two slugs like `qa-team` and `qa_team` name one variable. A create in one
49
+ * refuses when another synced env states the same id: its saved secret would be overwritten.
50
+ */
51
+ const assertDistinctSecretNames = ({
52
+ lanes,
53
+ }: {
54
+ lanes: WebhooksLane[];
55
+ }): void => {
56
+ const secretName = ({ lane, id }: { lane: WebhooksLane; id: string }) =>
57
+ webhookSecretName({
58
+ id,
59
+ ...(lane.env.live ? {} : { envKey: lane.env.key }),
60
+ });
61
+ const clashes = new Map<string, string>();
62
+ for (const lane of lanes) {
63
+ for (const change of lane.preview.changes) {
64
+ if (change.action !== "create") continue;
65
+ const name = secretName({ lane, id: change.id });
66
+ for (const other of lanes) {
67
+ if (other === lane) continue;
68
+ const shares = other.body.webhooks.some(
69
+ (webhook) => secretName({ lane: other, id: webhook.id }) === name,
70
+ );
71
+ if (!shares) continue;
72
+ const [first, second] = [other.env.key, lane.env.key].sort();
73
+ clashes.set(
74
+ `${first}|${second}|${name}`,
75
+ `${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.`,
76
+ );
77
+ }
78
+ }
79
+ }
80
+ if (clashes.size > 0) throw new Error([...clashes.values()].join("\n"));
81
+ };
82
+
83
+ /** Undefined when the config registers nothing in this env: there is nothing to preview. */
84
+ const previewEnv = async ({
85
+ env,
86
+ rows,
87
+ clientFor,
88
+ }: {
89
+ env: WebhookPullEnv;
90
+ rows: Record<string, unknown>[];
91
+ clientFor: WebhookClientFor;
92
+ }): Promise<EnvPreview> => {
93
+ try {
94
+ const key = await env.envKey();
95
+ const webhooks = resolveWebhooksForEnv({ rows, envKey: key });
96
+ if (webhooks.length === 0) return undefined;
97
+ const client = clientFor({ secretKey: env.secretKey });
98
+ const body = { webhooks };
99
+ const { changes, errors } = await client.previewSyncWebhooks(body);
100
+ return {
101
+ lane: {
102
+ preview: { env: key, changes },
103
+ body,
104
+ env: { key, live: isLive(env) },
105
+ client,
106
+ },
107
+ errors,
108
+ };
109
+ } catch (error) {
110
+ const skipped = webhookEnvSkipWarning({ env, error });
111
+ if (skipped === undefined) throw error;
112
+ return { skipped };
113
+ }
114
+ };
115
+
116
+ /**
117
+ * A plain push syncs every sandbox env with a key, and previews live read-only
118
+ * so a production diff is named rather than hidden; `-p` syncs live alone.
119
+ * Absent when the config states no `webhooks`.
120
+ */
121
+ export const previewWebhookEnvs = async ({
122
+ rows,
123
+ envs,
124
+ prod,
125
+ clientFor,
126
+ }: {
127
+ rows: Record<string, unknown>[] | undefined;
128
+ envs: (() => WebhookPullEnv[]) | undefined;
129
+ prod: boolean;
130
+ clientFor: WebhookClientFor | undefined;
131
+ }): Promise<WebhookLanes | undefined> => {
132
+ if (rows === undefined) return undefined;
133
+ if (envs === undefined || clientFor === undefined)
134
+ throw new Error("webhooks need the env keys to pick each url.");
135
+ const all = envs();
136
+ const synced = all.filter((env) => isLive(env) === prod);
137
+ const probed = prod ? [] : all.filter(isLive);
138
+ const [syncedResults, probedResults] = await Promise.all([
139
+ Promise.allSettled(
140
+ synced.map((env) => previewEnv({ env, rows, clientFor })),
141
+ ),
142
+ Promise.allSettled(
143
+ probed.map((env) => previewEnv({ env, rows, clientFor })),
144
+ ),
145
+ ]);
146
+
147
+ const lanes: WebhooksLane[] = [];
148
+ const skipped: string[] = [];
149
+ // Live is only read here, so its failure is a warning, never a blocked sandbox push.
150
+ probedResults.forEach((result, index) => {
151
+ if (result.status === "rejected")
152
+ skipped.push(
153
+ `⚠ webhooks: couldn't check ${probed[index]?.label ?? "live"} for changes (${messageOf(result.reason)})`,
154
+ );
155
+ });
156
+ // A skipped live key (rejected, deleted, another org's) leaves production unchecked too.
157
+ const productionUnchecked = probedResults.some(
158
+ (result) =>
159
+ result.status === "rejected" ||
160
+ (result.value !== undefined && "skipped" in result.value),
161
+ );
162
+ // Refused items (several dashboard webhooks on one URL, say) fail the lane
163
+ // here, beside every other lane's errors, rather than at apply.
164
+ const failures: WebhookEnvFailure[] = [];
165
+ syncedResults.forEach((result, index) => {
166
+ const label = synced[index]?.label ?? "";
167
+ if (result.status === "rejected") {
168
+ failures.push({ env: label, message: messageOf(result.reason) });
169
+ return;
170
+ }
171
+ const preview = result.value;
172
+ if (preview === undefined) return;
173
+ if ("skipped" in preview) {
174
+ skipped.push(preview.skipped);
175
+ return;
176
+ }
177
+ lanes.push(preview.lane);
178
+ if (preview.errors.length > 0)
179
+ failures.push({
180
+ env: preview.lane.env.key,
181
+ message: preview.errors
182
+ .map(({ id, message }) => `${id}: ${message}`)
183
+ .join("\n"),
184
+ });
185
+ });
186
+ throwWebhookEnvFailures({
187
+ failures,
188
+ envCount: synced.length,
189
+ warnings: skipped,
190
+ });
191
+ assertDistinctSecretNames({ lanes });
192
+
193
+ const productionDiffers: string[] = [];
194
+ for (const result of probedResults) {
195
+ if (result.status === "rejected") continue;
196
+ const preview = result.value;
197
+ if (preview === undefined) continue;
198
+ if ("skipped" in preview) {
199
+ skipped.push(preview.skipped);
200
+ continue;
201
+ }
202
+ const changed = preview.lane.preview.changes.filter(
203
+ (change) => change.action !== "unmanaged",
204
+ );
205
+ productionDiffers.push(
206
+ ...new Set([
207
+ ...changed.map((change) => change.id),
208
+ ...preview.errors.map((error) => error.id),
209
+ ]),
210
+ );
211
+ }
212
+ return { lanes, productionDiffers, productionUnchecked, skipped };
213
+ };
@@ -3,7 +3,7 @@ import { SYNCED_LISTS } from "../../generated/emit";
3
3
 
4
4
  type WebhookParams = SyncWebhooksParams["webhooks"][number];
5
5
 
6
- /** The config's webhooks as one environment sees them; a missing key skips the entry. */
6
+ /** The config's entries for one environment, as the sync body takes them. */
7
7
  export const resolveWebhooksForEnv = ({
8
8
  rows,
9
9
  envKey,
@@ -11,19 +11,8 @@ export const resolveWebhooksForEnv = ({
11
11
  rows: Record<string, unknown>[];
12
12
  envKey: string;
13
13
  }): WebhookParams[] => {
14
- const { envKeyed } = SYNCED_LISTS.webhooks;
15
- const webhooks: WebhookParams[] = [];
16
- for (const row of rows) {
17
- const values = envKeyed.map(
18
- (field) => (row[field] as Record<string, unknown> | undefined)?.[envKey],
19
- );
20
- if (values.some((value) => typeof value !== "string")) continue;
21
- webhooks.push({
22
- ...row,
23
- ...Object.fromEntries(
24
- envKeyed.map((field, index) => [field, values[index]]),
25
- ),
26
- } as WebhookParams);
27
- }
28
- return webhooks;
14
+ const { envField } = SYNCED_LISTS.webhooks;
15
+ return rows
16
+ .filter((row) => row[envField] === envKey)
17
+ .map(({ [envField]: _env, ...row }) => row as WebhookParams);
29
18
  };
@@ -2,8 +2,6 @@
2
2
  export type WebhookEnv = {
3
3
  /** The `url` map key: `live`, `sandbox`, or the targeted sandbox's slug. */
4
4
  key: string;
5
- /** Prod secrets go to `.env.prod` without an org suffix. */
5
+ /** Prod secrets go to `.env.prod` without an env suffix. */
6
6
  live: boolean;
7
- /** The target org's id, looked up only when a secret needs naming. */
8
- orgId: () => Promise<string>;
9
7
  };
@@ -0,0 +1,27 @@
1
+ /** One env's failed webhook preview or sync. */
2
+ export type WebhookEnvFailure = { env: string; message: string };
3
+
4
+ /** Names each env only when several were in play, so a lone env reads as it always has. */
5
+ export const throwWebhookEnvFailures = ({
6
+ failures,
7
+ envCount,
8
+ warnings = [],
9
+ }: {
10
+ failures: WebhookEnvFailure[];
11
+ envCount: number;
12
+ /** Skipped-env warnings the failure would otherwise swallow. */
13
+ warnings?: string[];
14
+ }): void => {
15
+ if (failures.length === 0) return;
16
+ throw new Error(
17
+ [
18
+ ...failures.map(({ env, message }) =>
19
+ envCount > 1 ? message.replace(/^/gm, `${env}: `) : message,
20
+ ),
21
+ ...warnings,
22
+ ].join("\n"),
23
+ );
24
+ };
25
+
26
+ export const messageOf = (error: unknown): string =>
27
+ error instanceof Error ? error.message : String(error);
@@ -2,21 +2,22 @@
2
2
  const envNameSegment = (value: string): string =>
3
3
  value.toUpperCase().replace(/-/g, "_");
4
4
 
5
+ /** A sandbox slug as an env-var segment: every non-alphanumeric run becomes `_`. */
6
+ const envKeySegment = (envKey: string): string =>
7
+ envKey.toUpperCase().replace(/[^A-Z0-9]+/g, "_");
8
+
5
9
  /**
6
- * Prod: `AUTUMN_WEBHOOK_<ID>_SECRET`. A sandbox adds the first four characters
7
- * of its org id after `org_`, so every sandbox's secret can sit in one `.env`.
10
+ * Prod: `AUTUMN_WEBHOOK_<ID>_SECRET`. A sandbox adds its `url` key (`SANDBOX`,
11
+ * or its slug), so every sandbox's secret can sit in one `.env`.
8
12
  */
9
13
  export const webhookSecretName = ({
10
14
  id,
11
- orgId,
15
+ envKey,
12
16
  }: {
13
17
  id: string;
14
18
  /** Absent for prod. */
15
- orgId?: string;
19
+ envKey?: string;
16
20
  }): string => {
17
- const org4 =
18
- orgId === undefined
19
- ? ""
20
- : `_${envNameSegment(orgId.replace(/^org_/, "").slice(0, 4))}`;
21
- return `AUTUMN_WEBHOOK_${envNameSegment(id)}${org4}_SECRET`;
21
+ const suffix = envKey === undefined ? "" : `_${envKeySegment(envKey)}`;
22
+ return `AUTUMN_WEBHOOK_${envNameSegment(id)}${suffix}_SECRET`;
22
23
  };
@@ -35,9 +35,12 @@ export const writeWebhookSecrets = async ({
35
35
  cwd: string;
36
36
  }): Promise<string[]> => {
37
37
  if (secrets.length === 0) return [];
38
- const orgId = env.live ? undefined : await env.orgId();
38
+ const envKey = env.live ? undefined : env.key;
39
39
  const values = Object.fromEntries(
40
- secrets.map(({ id, secret }) => [webhookSecretName({ id, orgId }), secret]),
40
+ secrets.map(({ id, secret }) => [
41
+ webhookSecretName({ id, envKey }),
42
+ secret,
43
+ ]),
41
44
  );
42
45
  let path: string;
43
46
  if (env.live) {
@@ -225,6 +225,6 @@ export const runOAuthFlow = async ({
225
225
  }
226
226
 
227
227
  throw new Error(
228
- `All OAuth callback ports (${OAUTH_PORTS.at(0)}-${OAUTH_PORTS.at(-1)}) are in use.`,
228
+ `All OAuth callback ports (${OAUTH_PORTS[0]}-${OAUTH_PORTS[OAUTH_PORTS.length - 1]}) are in use.`,
229
229
  );
230
230
  };
package/src/cli.ts CHANGED
@@ -32,7 +32,6 @@ import {
32
32
  staleSkillsHint,
33
33
  updateSkills,
34
34
  } from "./actions/skills/skills";
35
- import { resolveWebhookEnv } from "./actions/webhooks/resolveWebhookEnv";
36
35
  import { configPackageName } from "./config/configPackageName";
37
36
  import { assertSandboxTarget } from "./env/assertSandboxTarget";
38
37
  import { loadEnvFiles } from "./env/loadEnv";
@@ -206,18 +205,26 @@ const pinnedSandboxName = async ({
206
205
  }
207
206
  };
208
207
 
209
- /** The env a push or pull's webhook lane addresses, resolved only when asked. */
210
- const webhookEnvFor =
211
- ({ target, command }: { target: Target; command: Command }) =>
208
+ /** A client for one env's own key, at the target's base URL. */
209
+ const webhookClientFor =
210
+ ({ target }: { target: Target }) =>
211
+ ({ secretKey }: { secretKey: string }) =>
212
+ createClient({
213
+ secretKey,
214
+ fetch: autumnFetch,
215
+ ...(target.baseUrl ? { baseUrl: target.baseUrl } : {}),
216
+ });
217
+
218
+ /** Every env the env files hold a key for, listed only when a webhook lane asks. */
219
+ const webhookEnvsFor =
220
+ ({ target }: { target: Target }) =>
212
221
  () =>
213
- resolveWebhookEnv({
214
- target,
215
- prod: command.optsWithGlobals<GlobalFlags>().prod === true,
216
- fetchOrgInfo: () =>
217
- fetchOrgInfo({
218
- baseUrl: targetBaseUrl({ target }),
219
- secretKey: requireSecretKey({ target }),
220
- }),
222
+ webhookPullEnvs({
223
+ targetKeyName: target.secretKeyName,
224
+ listWebhooks: ({ secretKey }) =>
225
+ webhookClientFor({ target })({ secretKey }).listWebhooks({}),
226
+ fetchOrgInfo: ({ secretKey }) =>
227
+ fetchOrgInfo({ baseUrl: targetBaseUrl({ target }), secretKey }),
221
228
  });
222
229
 
223
230
  /** `/organization/me` for the main key: what `sandbox use` and `init` report the org as. */
@@ -405,7 +412,9 @@ Linking a keyless org to an account:
405
412
  client: clientFor({ target }),
406
413
  configPath: configFlagOf({ command }),
407
414
  dryRun: !apply,
408
- webhookEnv: webhookEnvFor({ target, command }),
415
+ prod: command.optsWithGlobals<GlobalFlags>().prod === true,
416
+ webhookEnvs: webhookEnvsFor({ target }),
417
+ webhookClientFor: webhookClientFor({ target }),
409
418
  });
410
419
  // A dry run that never saw the catalog must not read as clean.
411
420
  process.exitCode = pushExitCode({ apply, result });
@@ -501,18 +510,7 @@ Linking a keyless org to an account:
501
510
  overwrite: options.overwrite === true,
502
511
  yes: options.yes === true,
503
512
  prompter: prompterFor({ command }),
504
- webhookEnvs: () =>
505
- webhookPullEnvs({
506
- targetKeyName: target.secretKeyName,
507
- listWebhooks: ({ secretKey }) =>
508
- createClient({
509
- secretKey,
510
- fetch: autumnFetch,
511
- ...(target.baseUrl ? { baseUrl: target.baseUrl } : {}),
512
- }).listWebhooks({}),
513
- fetchOrgInfo: ({ secretKey }) =>
514
- fetchOrgInfo({ baseUrl: targetBaseUrl({ target }), secretKey }),
515
- }),
513
+ webhookEnvs: webhookEnvsFor({ target }),
516
514
  });
517
515
  writeStaleSkillsHint({ command });
518
516
  },
@@ -1216,11 +1216,11 @@ export const SYNCED_LISTS: Readonly<Record<string, SyncedListSpec>> = {
1216
1216
  builder: "webhook",
1217
1217
  idField: "id",
1218
1218
  responseIdField: "id",
1219
- keys: ["id", "url", "events", "description", "disabled"],
1220
- required: ["id", "url"],
1221
- paths: ["description", "disabled", "events", "id", "url"],
1219
+ keys: ["id", "env", "url", "events", "description", "disabled"],
1220
+ required: ["env", "id", "url"],
1221
+ paths: ["description", "disabled", "env", "events", "id", "url"],
1222
1222
  pull: true,
1223
- envKeyed: ["url"],
1223
+ envField: "env",
1224
1224
  },
1225
1225
  };
1226
1226
 
@@ -94,11 +94,11 @@ export const branchSpecs = ({
94
94
  spec.branches?.map((branch) => branchSpecOf({ spec, branch })) ?? [spec];
95
95
 
96
96
  /** A list keyed by id outside the catalog: emitted like a collection item,
97
- * with fields a config states once per environment. */
97
+ * one entry per environment. */
98
98
  export type SyncedListSpec = CollectionSpec & {
99
99
  readonly wireKey: string;
100
- /** Fixture fields holding `{ live?, sandbox?, [sandboxSlug]? }` maps. */
101
- readonly envKeyed: readonly string[];
100
+ /** The fixture field naming the entry's environment. */
101
+ readonly envField: string;
102
102
  };
103
103
 
104
104
  export type SingletonSpec = {