atmn 2.0.33 → 2.0.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/bin.js +1409 -188
  2. package/dist/index.js +220 -19
  3. package/dist/tsconfig.tsbuildinfo +1 -1
  4. package/package.json +2 -2
  5. package/src/actions/pull/webhooks/appendWebhook.ts +84 -0
  6. package/src/actions/pull/webhooks/applyWebhooksPull.ts +111 -0
  7. package/src/actions/pull/webhooks/locateWebhook.ts +22 -0
  8. package/src/actions/pull/webhooks/removeWebhookEnv.ts +141 -0
  9. package/src/actions/pull/webhooks/types.ts +20 -0
  10. package/src/actions/pull/webhooks/updateWebhook.ts +138 -0
  11. package/src/actions/pull.ts +32 -2
  12. package/src/actions/push/pushLanesError.ts +47 -0
  13. package/src/actions/push.ts +220 -60
  14. package/src/actions/webhooks/applyWebhooks.ts +52 -0
  15. package/src/actions/webhooks/isDashboardWebhook.ts +9 -0
  16. package/src/actions/webhooks/newWebhookId.ts +4 -0
  17. package/src/actions/webhooks/previewWebhooks.ts +40 -0
  18. package/src/actions/webhooks/resolveWebhookEnv.ts +34 -0
  19. package/src/actions/webhooks/resolveWebhooksForEnv.ts +29 -0
  20. package/src/actions/webhooks/types/webhookEnv.ts +9 -0
  21. package/src/actions/webhooks/webhookSecretName.ts +22 -0
  22. package/src/actions/webhooks/writeWebhookSecrets.ts +55 -0
  23. package/src/cli.ts +20 -1
  24. package/src/config/loadConfig.ts +3 -0
  25. package/src/generated/apiRoutes.ts +165 -1
  26. package/src/generated/client.ts +239 -1
  27. package/src/generated/emit.ts +20 -1
  28. package/src/generated/emitRuntime.ts +8 -0
  29. package/src/generated/isLocalWebhookUrl.ts +64 -0
  30. package/src/generated/lintRules.ts +95 -0
  31. package/src/generated/lintRuntime.ts +113 -10
  32. package/src/generated/sandboxName.ts +52 -0
  33. package/src/generated/skills.ts +10 -10
  34. package/src/generated/webhooks.ts +36 -0
  35. package/src/generated/wire.ts +32 -5
  36. package/src/index.ts +1 -0
  37. package/src/render/renderPreview.ts +17 -2
  38. package/src/render/renderWebhooks.ts +92 -0
  39. package/src/surgery/appendPropertyEdit.ts +9 -3
  40. package/src/surgery/patchFixturePaths.ts +57 -5
@@ -1,6 +1,7 @@
1
1
  import { loadConfig } from "../config/loadConfig";
2
2
  import { loadEnvFiles } from "../env/loadEnv";
3
3
  import type { AutumnClient } from "../generated/client";
4
+ import type { LintIssue } from "../generated/lintRuntime";
4
5
  import { splitWire } from "../generated/wire";
5
6
  import { resolveProject } from "../project/resolveProject";
6
7
  import {
@@ -19,7 +20,11 @@ import {
19
20
  deprecatedUsesIn,
20
21
  renderDeprecatedUses,
21
22
  } from "./push/deprecatedFields";
23
+ import { PushLanesError, settleLanes } from "./push/pushLanesError";
22
24
  import { withSettingsScopeHint } from "./sandbox/withSandboxScopeHint";
25
+ import { applyWebhooks } from "./webhooks/applyWebhooks";
26
+ import { previewWebhooks, type WebhooksLane } from "./webhooks/previewWebhooks";
27
+ import type { WebhookEnv } from "./webhooks/types/webhookEnv";
23
28
 
24
29
  export type PushResult = {
25
30
  configPath: string;
@@ -27,6 +32,8 @@ export type PushResult = {
27
32
  /** Absent on a dry run, or when the preview showed nothing to do. */
28
33
  applied?: unknown;
29
34
  migrationIds: string[];
35
+ /** The catalog preview waits on settings: a dry run shows no catalog at all. */
36
+ catalogDeferred?: boolean;
30
37
  };
31
38
 
32
39
  /** The settings lane of one push: absent when the config states no `settings`. */
@@ -54,6 +61,8 @@ export type PushOptions = {
54
61
  /** Where to write progress. Injected so tests can capture it. */
55
62
  write?: (text: string) => void;
56
63
  migrationLinkBase?: string;
64
+ /** The target env for the webhook lane; resolved only when the config states webhooks. */
65
+ webhookEnv?: () => Promise<WebhookEnv>;
57
66
  };
58
67
 
59
68
  type RewardBody = { id?: string; internal_id?: string };
@@ -185,71 +194,94 @@ export const configSearchDirs = ({
185
194
  return [...new Set([project.configDir, cwd, ...project.envDirs])];
186
195
  };
187
196
 
197
+ /** A preview that never saw the catalog must not read as clean. */
198
+ export const pushExitCode = ({
199
+ apply,
200
+ result,
201
+ }: {
202
+ apply: boolean;
203
+ result: Pick<PushResult, "catalogDeferred">;
204
+ }): number => (!apply && result.catalogDeferred === true ? 1 : 0);
205
+
206
+ /** Why the catalog is missing from the preview, and what `--yes` does about it. */
207
+ const renderCatalogDeferral = ({ error }: { error: unknown }): string =>
208
+ [
209
+ "Catalog: not previewed yet. It depends on the settings above, so it is previewed again once they apply.",
210
+ ` Against the current settings it fails: ${error instanceof Error ? error.message : String(error)}`,
211
+ " With --yes: settings apply, then the catalog is previewed again and applied if that preview passes.",
212
+ ].join("\n");
213
+
214
+ /** A lint warning never refuses the config; it is printed above the preview. */
215
+ const renderLintWarnings = ({ warnings }: { warnings: LintIssue[] }): string =>
216
+ warnings
217
+ .map((warning) => `⚠ ${warning.path}\n ${warning.message}`)
218
+ .join("\n");
219
+
188
220
  /**
189
- * Preview, then apply. The CLI decides nothing here — it sends the same
190
- * document twice and renders what comes back, which is why a clean preview is
191
- * a real guarantee rather than a guess.
221
+ * A flag like multi_currency changes what the catalog accepts, so after a
222
+ * settings write the catalog is previewed again against the settings as they
223
+ * now are. The catalog preview to apply comes back.
192
224
  */
193
- export const runPush = async ({
225
+ const applySettings = async ({
194
226
  client,
195
- cwd = process.cwd(),
196
- configPath: configFlag,
197
- dryRun = false,
198
- write = (text) => process.stdout.write(text),
227
+ settingsBody,
228
+ settings,
229
+ catalogPreview,
230
+ wire,
231
+ write,
199
232
  migrationLinkBase,
200
- }: PushOptions): Promise<PushResult> => {
201
- const project = resolveProject({ cwd, configFlag });
202
- const dirs = configSearchDirs({ cwd, configPath: configFlag });
203
- loadEnvFiles({ dirs: project.envDirs });
204
-
205
- const { path: configPath, wire: document } = await loadConfig({
206
- dirs,
207
- ...(project.configPath === null ? {} : { configPath: project.configPath }),
208
- });
209
- // One document, two operations: the catalog and each singleton go their own way.
210
- const { catalog: wire, singletons } = splitWire(document);
211
-
212
- const deprecated = deprecatedUsesIn({ wire });
213
- if (deprecated.length > 0)
214
- write(`${renderDeprecatedUses({ uses: deprecated })}\n\n`);
215
-
216
- // Settings go first, on their own: a flag like multi_currency changes what
217
- // the catalog accepts, so the catalog is previewed against the settings as
218
- // they will be, and a settings write can never fail on the catalog's account.
219
- const settingsBody = singletons.settings;
220
- const settings = await previewSettings({ client, body: settingsBody });
221
- const settingsWork =
222
- settingsBody !== undefined && settingsHaveWork({ settings });
223
- if (settingsWork) {
224
- write(`${renderPreview({ preview: { settings } })}\n`);
225
- if (!dryRun) {
226
- try {
227
- await client.updateOrganization(settingsBody);
228
- } catch (error) {
229
- throw withSettingsScopeHint({ error });
230
- }
231
- write("\nApplied settings.\n\n");
232
- }
233
+ catalogDeferred,
234
+ }: {
235
+ client: AutumnClient;
236
+ settingsBody: Record<string, unknown> | undefined;
237
+ settings: SettingsPreview | undefined;
238
+ catalogPreview: CatalogPreview;
239
+ wire: Record<string, unknown>;
240
+ write: (text: string) => void;
241
+ migrationLinkBase?: string;
242
+ /** The catalog was never shown: its re-preview is printed, and a failure names what was left unapplied. */
243
+ catalogDeferred: boolean;
244
+ }): Promise<CatalogPreview> => {
245
+ if (settingsBody === undefined || !settingsHaveWork({ settings }))
246
+ return catalogPreview;
247
+ try {
248
+ await client.updateOrganization(settingsBody);
249
+ } catch (error) {
250
+ throw withSettingsScopeHint({ error });
233
251
  }
234
-
235
- const catalogPreview = (await client.previewUpdate(wire)) as CatalogPreview;
236
- // The settings lane is already printed when it applied; the unmanaged
237
- // notes still belong beside the catalog's own rows.
238
- const preview: CatalogPreview = {
239
- ...catalogPreview,
240
- ...(settingsWork ? {} : { settings }),
241
- };
242
-
243
- write(`${renderPreview({ preview, migrationLinkBase })}\n`);
244
-
245
- const renameHint = possibleRenameHint({ preview, wire: wire as WireLike });
246
- if (renameHint !== null) write(`${renameHint}\n\n`);
247
-
248
- const fullPreview: CatalogPreview = { ...catalogPreview, settings };
249
- if (previewIsEmpty({ preview: catalogPreview })) {
250
- return { configPath, preview: fullPreview, migrationIds: [] };
252
+ write("\nApplied settings.\n");
253
+ let preview: CatalogPreview;
254
+ try {
255
+ preview = (await client.previewUpdate(wire)) as CatalogPreview;
256
+ } catch (error) {
257
+ if (!catalogDeferred) throw error;
258
+ throw new Error(
259
+ `Applied settings, but the catalog still fails its preview, so the catalog and webhooks were not applied:\n ${error instanceof Error ? error.message : String(error)}`,
260
+ { cause: error },
261
+ );
251
262
  }
252
- if (dryRun) return { configPath, preview: fullPreview, migrationIds: [] };
263
+ if (catalogDeferred)
264
+ write(`\n${renderPreview({ preview, migrationLinkBase })}\n`);
265
+ return preview;
266
+ };
267
+
268
+ /** Apply the catalog preview settings left behind, when it has work. */
269
+ const applyCatalog = async ({
270
+ client,
271
+ preview,
272
+ wire,
273
+ configPath,
274
+ write,
275
+ migrationLinkBase,
276
+ }: {
277
+ client: AutumnClient;
278
+ preview: CatalogPreview;
279
+ wire: Record<string, unknown>;
280
+ configPath: string;
281
+ write: (text: string) => void;
282
+ migrationLinkBase?: string;
283
+ }): Promise<{ applied?: unknown; migrationIds: string[] }> => {
284
+ if (previewIsEmpty({ preview })) return { migrationIds: [] };
253
285
 
254
286
  const applied = (await client.update(wire)) as {
255
287
  migrations?: { id?: string }[];
@@ -289,6 +321,134 @@ export const runPush = async ({
289
321
  if (backfilled.length > 0 || slugged.length > 0) {
290
322
  write(backfillSummary({ backfilled, slugged }));
291
323
  }
324
+ return { applied, migrationIds };
325
+ };
326
+
327
+ /**
328
+ * Preview, then apply. The CLI decides nothing here — it sends the same
329
+ * document twice and renders what comes back, which is why a clean preview is
330
+ * a real guarantee rather than a guess.
331
+ *
332
+ * Settings, catalog and webhooks preview together and render as one; every
333
+ * failing lane is reported at once. Applying runs settings → catalog as one
334
+ * chain and webhooks → secrets beside it.
335
+ */
336
+ export const runPush = async ({
337
+ client,
338
+ cwd = process.cwd(),
339
+ configPath: configFlag,
340
+ dryRun = false,
341
+ write = (text) => process.stdout.write(text),
342
+ migrationLinkBase,
343
+ webhookEnv,
344
+ }: PushOptions): Promise<PushResult> => {
345
+ const project = resolveProject({ cwd, configFlag });
346
+ const dirs = configSearchDirs({ cwd, configPath: configFlag });
347
+ loadEnvFiles({ dirs: project.envDirs });
348
+
349
+ const { path: configPath, wire: document } = await loadConfig({
350
+ dirs,
351
+ ...(project.configPath === null ? {} : { configPath: project.configPath }),
352
+ });
353
+ // One document, three destinations: the catalog, each singleton and each synced list.
354
+ const { catalog: wire, singletons, lists, warnings } = splitWire(document);
355
+ if (warnings.length > 0) write(`${renderLintWarnings({ warnings })}\n\n`);
356
+
357
+ const deprecated = deprecatedUsesIn({ wire });
358
+ if (deprecated.length > 0)
359
+ write(`${renderDeprecatedUses({ uses: deprecated })}\n\n`);
360
+
361
+ const settingsBody = singletons.settings;
362
+ const previews = await settleLanes<{
363
+ settings: SettingsPreview | undefined;
364
+ catalog: CatalogPreview;
365
+ webhooks: WebhooksLane | undefined;
366
+ }>({
367
+ settings: previewSettings({ client, body: settingsBody }),
368
+ catalog: client.previewUpdate(wire) as Promise<CatalogPreview>,
369
+ webhooks: previewWebhooks({
370
+ client,
371
+ rows: lists.webhooks,
372
+ webhookEnv,
373
+ }),
374
+ });
375
+ const { settings, webhooks } = previews.values;
376
+ // A catalog that needs a pending setting (multi_currency, say) can only be
377
+ // previewed once that setting applies: its error waits for the re-preview.
378
+ const catalogDeferral =
379
+ settingsBody !== undefined && settingsHaveWork({ settings })
380
+ ? previews.failures.find((failure) => failure.lane === "catalog")
381
+ : undefined;
382
+ const failures = previews.failures.filter(
383
+ (failure) => failure !== catalogDeferral,
384
+ );
385
+ if (failures.length > 0)
386
+ throw new PushLanesError({ stage: "preview", failures });
387
+ const catalogPreview = previews.values.catalog ?? {};
292
388
 
293
- return { configPath, preview: fullPreview, applied, migrationIds };
389
+ const preview: CatalogPreview = {
390
+ ...catalogPreview,
391
+ settings,
392
+ ...(webhooks === undefined ? {} : { webhooks: webhooks.preview }),
393
+ };
394
+ write(`${renderPreview({ preview, migrationLinkBase })}\n`);
395
+ if (catalogDeferral !== undefined)
396
+ write(`\n${renderCatalogDeferral({ error: catalogDeferral.error })}\n`);
397
+
398
+ const renameHint = possibleRenameHint({ preview, wire: wire as WireLike });
399
+ if (renameHint !== null) write(`${renameHint}\n\n`);
400
+
401
+ const catalogDeferred = catalogDeferral !== undefined;
402
+ if (dryRun || previewIsEmpty({ preview }))
403
+ return { configPath, preview, migrationIds: [], catalogDeferred };
404
+
405
+ const settingsStep = () =>
406
+ applySettings({
407
+ client,
408
+ settingsBody,
409
+ settings,
410
+ catalogPreview,
411
+ wire,
412
+ write,
413
+ migrationLinkBase,
414
+ catalogDeferred,
415
+ });
416
+ const catalogStep = (previewAfterSettings: CatalogPreview) =>
417
+ applyCatalog({
418
+ client,
419
+ preview: previewAfterSettings,
420
+ wire,
421
+ configPath,
422
+ write,
423
+ migrationLinkBase,
424
+ });
425
+ // A deferred catalog is unproven until settings apply: webhooks wait on its
426
+ // re-preview. Otherwise settings → catalog runs beside the webhooks sync.
427
+ const settled = catalogDeferred ? await settingsStep() : undefined;
428
+ const applied = await settleLanes<{
429
+ catalog: { applied?: unknown; migrationIds: string[] };
430
+ webhooks: unknown;
431
+ }>({
432
+ catalog:
433
+ settled === undefined
434
+ ? settingsStep().then(catalogStep)
435
+ : catalogStep(settled),
436
+ webhooks: applyWebhooks({
437
+ client,
438
+ lane: webhooks,
439
+ envDirs: project.envDirs,
440
+ cwd,
441
+ write,
442
+ }),
443
+ });
444
+ if (applied.failures.length > 0)
445
+ throw new PushLanesError({ stage: "apply", failures: applied.failures });
446
+
447
+ return {
448
+ configPath,
449
+ preview,
450
+ applied: applied.values.catalog?.applied,
451
+ migrationIds: applied.values.catalog?.migrationIds ?? [],
452
+ catalogDeferred,
453
+ };
294
454
  };
@@ -0,0 +1,52 @@
1
+ import type { AutumnClient } from "../../generated/client";
2
+ import { webhooksHaveWork } from "../../render/renderWebhooks";
3
+ import type { WebhooksLane } from "./previewWebhooks";
4
+ import { writeWebhookSecrets } from "./writeWebhookSecrets";
5
+
6
+ /**
7
+ * Sync, then save every secret it returned — before reporting any per-item
8
+ * failure, since a created webhook's secret is shown once and only here.
9
+ */
10
+ export const applyWebhooks = async ({
11
+ client,
12
+ lane,
13
+ envDirs,
14
+ cwd,
15
+ write,
16
+ }: {
17
+ client: AutumnClient;
18
+ lane: WebhooksLane | undefined;
19
+ envDirs: string[];
20
+ cwd: string;
21
+ write: (text: string) => void;
22
+ }): Promise<void> => {
23
+ if (lane === undefined || !webhooksHaveWork({ webhooks: lane.preview }))
24
+ return;
25
+ const result = await client.syncWebhooks(lane.body);
26
+ const saved = await writeWebhookSecrets({
27
+ secrets: result.secrets,
28
+ env: lane.env,
29
+ envDirs,
30
+ cwd,
31
+ });
32
+ // A secret means the server created the webhook after all (its dashboard
33
+ // twin moved since the preview): only the rest were adopted.
34
+ const failed = new Set([
35
+ ...result.errors.map(({ id }) => id),
36
+ ...result.secrets.map(({ id }) => id),
37
+ ]);
38
+ // An adopted webhook keeps its signing secret, so nothing is written for it.
39
+ const adopted = lane.preview.changes
40
+ .filter((change) => change.action === "adopt" && !failed.has(change.id))
41
+ .map(
42
+ (change) =>
43
+ `Adopted ${change.id} (existing dashboard webhook); its signing secret is unchanged, nothing written`,
44
+ );
45
+ write(
46
+ `\nApplied webhooks.\n${[...adopted, ...saved].map((line) => `${line}\n`).join("")}`,
47
+ );
48
+ if (result.errors.length > 0)
49
+ throw new Error(
50
+ result.errors.map(({ id, message }) => `${id}: ${message}`).join("\n"),
51
+ );
52
+ };
@@ -0,0 +1,9 @@
1
+ /** Svix's own endpoint id: `ep_` and a 27-character KSUID. */
2
+ const SVIX_ENDPOINT_ID = /^ep_[0-9A-Za-z]{27}$/;
3
+
4
+ /**
5
+ * A webhook made in the dashboard has no id of ours, so the API lists it under
6
+ * Svix's endpoint id. A config never manages one.
7
+ */
8
+ export const isDashboardWebhook = ({ id }: { id: string }): boolean =>
9
+ SVIX_ENDPOINT_ID.test(id);
@@ -0,0 +1,4 @@
1
+ import { generateKsuid } from "@autumn/ksuid";
2
+
3
+ /** The shape the server's `generateId("wh")` mints: `wh_` and a KSUID. */
4
+ export const newWebhookId = (): string => generateKsuid({ prefix: "wh_" });
@@ -0,0 +1,40 @@
1
+ import type { AutumnClient, SyncWebhooksParams } from "../../generated/client";
2
+ import type { WebhooksPreview } from "../../render/renderWebhooks";
3
+ import { resolveWebhooksForEnv } from "./resolveWebhooksForEnv";
4
+ import type { WebhookEnv } from "./types/webhookEnv";
5
+
6
+ export type WebhooksLane = {
7
+ preview: WebhooksPreview;
8
+ /** Exactly what the preview was asked about, so the sync applies the same set. */
9
+ body: SyncWebhooksParams;
10
+ env: WebhookEnv;
11
+ };
12
+
13
+ /** Absent when the config states no `webhooks`: an omitted list manages nothing. */
14
+ export const previewWebhooks = async ({
15
+ client,
16
+ rows,
17
+ webhookEnv,
18
+ }: {
19
+ client: AutumnClient;
20
+ rows: Record<string, unknown>[] | undefined;
21
+ webhookEnv: (() => Promise<WebhookEnv>) | undefined;
22
+ }): Promise<WebhooksLane | undefined> => {
23
+ if (rows === undefined) return undefined;
24
+ if (webhookEnv === undefined)
25
+ throw new Error("webhooks need the target environment to pick each url.");
26
+ const env = await webhookEnv();
27
+ const webhooks = resolveWebhooksForEnv({
28
+ rows,
29
+ envKey: env.key,
30
+ });
31
+ const body = { webhooks };
32
+ const { changes, errors } = await client.previewSyncWebhooks(body);
33
+ // Refused items (several dashboard webhooks on one URL, say) fail the lane
34
+ // here, beside every other lane's errors, rather than at apply.
35
+ if (errors.length > 0)
36
+ throw new Error(
37
+ errors.map(({ id, message }) => `${id}: ${message}`).join("\n"),
38
+ );
39
+ return { preview: { changes }, body, env };
40
+ };
@@ -0,0 +1,34 @@
1
+ import type { Target } from "../../env/resolveTarget";
2
+ import { sandboxSlug } from "../../generated/sandboxName";
3
+ import type { FetchOrgInfo, OrgInfo } from "../env/types/orgInfo";
4
+ import type { WebhookEnv } from "./types/webhookEnv";
5
+
6
+ /** `/organization/me`, asked at most once per command. */
7
+ const memoized = (fetchOrgInfo: FetchOrgInfo): FetchOrgInfo => {
8
+ let pending: Promise<OrgInfo> | undefined;
9
+ return () => {
10
+ pending ??= fetchOrgInfo();
11
+ return pending;
12
+ };
13
+ };
14
+
15
+ /**
16
+ * `-p` is `live` and the default sandbox is `sandbox`, with no lookup. A named
17
+ * sandbox is keyed by its slug, which only its name gives: one lookup.
18
+ */
19
+ export const resolveWebhookEnv = async ({
20
+ target,
21
+ prod,
22
+ fetchOrgInfo,
23
+ }: {
24
+ target: Target;
25
+ prod: boolean;
26
+ fetchOrgInfo: FetchOrgInfo;
27
+ }): Promise<WebhookEnv> => {
28
+ const orgInfo = memoized(fetchOrgInfo);
29
+ const orgId = async () => (await orgInfo()).id;
30
+ if (prod) return { key: "live", live: true, orgId };
31
+ if (target.sandboxId === undefined)
32
+ return { key: "sandbox", live: false, orgId };
33
+ return { key: sandboxSlug((await orgInfo()).name), live: false, orgId };
34
+ };
@@ -0,0 +1,29 @@
1
+ import type { SyncWebhooksParams } from "../../generated/client";
2
+ import { SYNCED_LISTS } from "../../generated/emit";
3
+
4
+ type WebhookParams = SyncWebhooksParams["webhooks"][number];
5
+
6
+ /** The config's webhooks as one environment sees them; a missing key skips the entry. */
7
+ export const resolveWebhooksForEnv = ({
8
+ rows,
9
+ envKey,
10
+ }: {
11
+ rows: Record<string, unknown>[];
12
+ envKey: string;
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;
29
+ };
@@ -0,0 +1,9 @@
1
+ /** The environment a push or pull addresses, as the webhook lane needs it. */
2
+ export type WebhookEnv = {
3
+ /** The `url` map key: `live`, `sandbox`, or the targeted sandbox's slug. */
4
+ key: string;
5
+ /** Prod secrets go to `.env.prod` without an org suffix. */
6
+ live: boolean;
7
+ /** The target org's id, looked up only when a secret needs naming. */
8
+ orgId: () => Promise<string>;
9
+ };
@@ -0,0 +1,22 @@
1
+ /** `-` reads as `_`, so the lint refuses two ids that collide once uppercased. */
2
+ const envNameSegment = (value: string): string =>
3
+ value.toUpperCase().replace(/-/g, "_");
4
+
5
+ /**
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`.
8
+ */
9
+ export const webhookSecretName = ({
10
+ id,
11
+ orgId,
12
+ }: {
13
+ id: string;
14
+ /** Absent for prod. */
15
+ orgId?: string;
16
+ }): string => {
17
+ const org4 =
18
+ orgId === undefined
19
+ ? ""
20
+ : `_${envNameSegment(orgId.replace(/^org_/, "").slice(0, 4))}`;
21
+ return `AUTUMN_WEBHOOK_${envNameSegment(id)}${org4}_SECRET`;
22
+ };
@@ -0,0 +1,55 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join, relative } from "node:path";
3
+ import {
4
+ findEnvFile,
5
+ upsertEnvContent,
6
+ writeEnvValues,
7
+ } from "../../env/loadEnv";
8
+ import type { WebhookEnv } from "./types/webhookEnv";
9
+ import { webhookSecretName } from "./webhookSecretName";
10
+
11
+ const PROD_ENV_FILE = ".env.prod";
12
+
13
+ /** Beside the env file atmn reads first, so a project keeps its env files together. */
14
+ const prodEnvPath = ({ envDirs }: { envDirs: string[] }): string => {
15
+ const nearest = findEnvFile({ dirs: envDirs });
16
+ return join(
17
+ nearest === undefined ? (envDirs[0] ?? process.cwd()) : dirname(nearest),
18
+ PROD_ENV_FILE,
19
+ );
20
+ };
21
+
22
+ /**
23
+ * Prod secrets go to `.env.prod`; a sandbox's go where atmn writes its keys
24
+ * (`.env.local`, else `.env`). One printed line per secret: its name and file.
25
+ */
26
+ export const writeWebhookSecrets = async ({
27
+ secrets,
28
+ env,
29
+ envDirs,
30
+ cwd,
31
+ }: {
32
+ secrets: { id: string; secret: string }[];
33
+ env: WebhookEnv;
34
+ envDirs: string[];
35
+ cwd: string;
36
+ }): Promise<string[]> => {
37
+ if (secrets.length === 0) return [];
38
+ const orgId = env.live ? undefined : await env.orgId();
39
+ const values = Object.fromEntries(
40
+ secrets.map(({ id, secret }) => [webhookSecretName({ id, orgId }), secret]),
41
+ );
42
+ let path: string;
43
+ if (env.live) {
44
+ path = prodEnvPath({ envDirs });
45
+ const content = existsSync(path) ? readFileSync(path, "utf8") : "";
46
+ // A new prod file holds a live signing secret: owner-only from the start.
47
+ writeFileSync(path, upsertEnvContent({ content, values }), { mode: 0o600 });
48
+ } else {
49
+ path = writeEnvValues({ dirs: envDirs, values });
50
+ }
51
+ const file = relative(cwd, path) || path;
52
+ return Object.keys(values).map(
53
+ (name) => `Saved webhook secret as ${name} in ${file}`,
54
+ );
55
+ };
package/src/cli.ts CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  runKeylessLogin,
15
15
  } from "./actions/login/keyless";
16
16
  import { runPull } from "./actions/pull";
17
- import { runPush } from "./actions/push";
17
+ import { pushExitCode, runPush } from "./actions/push";
18
18
  import { runReset } from "./actions/reset/runReset";
19
19
  import { runSandboxCreate } from "./actions/sandbox/createSandbox";
20
20
  import { runSandboxDelete } from "./actions/sandbox/deleteSandbox";
@@ -31,6 +31,7 @@ import {
31
31
  staleSkillsHint,
32
32
  updateSkills,
33
33
  } from "./actions/skills/skills";
34
+ import { resolveWebhookEnv } from "./actions/webhooks/resolveWebhookEnv";
34
35
  import { configPackageName } from "./config/configPackageName";
35
36
  import { assertSandboxTarget } from "./env/assertSandboxTarget";
36
37
  import { loadEnvFiles } from "./env/loadEnv";
@@ -204,6 +205,20 @@ const pinnedSandboxName = async ({
204
205
  }
205
206
  };
206
207
 
208
+ /** The env a push or pull's webhook lane addresses, resolved only when asked. */
209
+ const webhookEnvFor =
210
+ ({ target, command }: { target: Target; command: Command }) =>
211
+ () =>
212
+ resolveWebhookEnv({
213
+ target,
214
+ prod: command.optsWithGlobals<GlobalFlags>().prod === true,
215
+ fetchOrgInfo: () =>
216
+ fetchOrgInfo({
217
+ baseUrl: targetBaseUrl({ target }),
218
+ secretKey: requireSecretKey({ target }),
219
+ }),
220
+ });
221
+
207
222
  /** `/organization/me` for the main key: what `sandbox use` and `init` report the org as. */
208
223
  const mainOrgInfo = ({ target }: { target: Target }) => {
209
224
  const main = managementTarget({ target });
@@ -389,7 +404,10 @@ Linking a keyless org to an account:
389
404
  client: clientFor({ target }),
390
405
  configPath: configFlagOf({ command }),
391
406
  dryRun: !apply,
407
+ webhookEnv: webhookEnvFor({ target, command }),
392
408
  });
409
+ // A dry run that never saw the catalog must not read as clean.
410
+ process.exitCode = pushExitCode({ apply, result });
393
411
  if (!apply && !previewIsEmpty({ preview: result.preview }))
394
412
  process.stdout.write(
395
413
  options.dryRun === true
@@ -482,6 +500,7 @@ Linking a keyless org to an account:
482
500
  overwrite: options.overwrite === true,
483
501
  yes: options.yes === true,
484
502
  prompter: prompterFor({ command }),
503
+ webhookEnv: webhookEnvFor({ target, command }),
485
504
  });
486
505
  writeStaleSkillsHint({ command });
487
506
  },
@@ -122,6 +122,9 @@ const importConfigModule = async ({
122
122
  const result = spawnSync(process.execPath, importConfigArgs({ path }), {
123
123
  cwd: dirname(path),
124
124
  encoding: "utf8",
125
+ // Bun hands a child its startup env, not process.env: without this, a
126
+ // config reading a value atmn loaded from .env sees undefined.
127
+ env: process.env,
125
128
  });
126
129
  if (result.status !== 0 || result.stdout.length === 0) {
127
130
  throw new Error(result.stderr.trim() || `Failed to load ${path}`);