atmn 2.0.32 → 2.0.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.js +1408 -188
- package/dist/index.js +220 -19
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/actions/pull/webhooks/appendWebhook.ts +84 -0
- package/src/actions/pull/webhooks/applyWebhooksPull.ts +100 -0
- package/src/actions/pull/webhooks/locateWebhook.ts +22 -0
- package/src/actions/pull/webhooks/removeWebhookEnv.ts +141 -0
- package/src/actions/pull/webhooks/types.ts +20 -0
- package/src/actions/pull/webhooks/updateWebhook.ts +138 -0
- package/src/actions/pull.ts +32 -2
- package/src/actions/push/pushLanesError.ts +47 -0
- package/src/actions/push.ts +220 -60
- package/src/actions/webhooks/applyWebhooks.ts +52 -0
- package/src/actions/webhooks/isDashboardWebhook.ts +9 -0
- package/src/actions/webhooks/newWebhookId.ts +4 -0
- package/src/actions/webhooks/previewWebhooks.ts +40 -0
- package/src/actions/webhooks/resolveWebhookEnv.ts +34 -0
- package/src/actions/webhooks/resolveWebhooksForEnv.ts +29 -0
- package/src/actions/webhooks/types/webhookEnv.ts +9 -0
- package/src/actions/webhooks/webhookSecretName.ts +22 -0
- package/src/actions/webhooks/writeWebhookSecrets.ts +55 -0
- package/src/cli.ts +20 -1
- package/src/config/loadConfig.ts +3 -0
- package/src/generated/apiRoutes.ts +165 -1
- package/src/generated/client.ts +239 -1
- package/src/generated/emit.ts +20 -1
- package/src/generated/emitRuntime.ts +8 -0
- package/src/generated/isLocalWebhookUrl.ts +64 -0
- package/src/generated/lintRules.ts +95 -0
- package/src/generated/lintRuntime.ts +113 -10
- package/src/generated/sandboxName.ts +52 -0
- package/src/generated/skills.ts +10 -10
- package/src/generated/webhooks.ts +36 -0
- package/src/generated/wire.ts +32 -5
- package/src/index.ts +1 -0
- package/src/render/renderPreview.ts +17 -2
- package/src/render/renderWebhooks.ts +92 -0
- package/src/surgery/appendPropertyEdit.ts +9 -3
- package/src/surgery/patchFixturePaths.ts +57 -5
package/src/actions/push.ts
CHANGED
|
@@ -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
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
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
|
-
|
|
225
|
+
const applySettings = async ({
|
|
194
226
|
client,
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
227
|
+
settingsBody,
|
|
228
|
+
settings,
|
|
229
|
+
catalogPreview,
|
|
230
|
+
wire,
|
|
231
|
+
write,
|
|
199
232
|
migrationLinkBase,
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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 (
|
|
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
|
-
|
|
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,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
|
},
|
package/src/config/loadConfig.ts
CHANGED
|
@@ -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}`);
|