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.
Files changed (40) hide show
  1. package/dist/bin.js +1408 -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 +100 -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,7 +1,11 @@
1
1
  // Generated by @autumn/atmn-generator from packages/openapi/openapi-internal.yml.
2
2
  // Do not edit — run `bun generate` in packages/atmn-generator instead.
3
3
 
4
- import type { CollectionSpec, SingletonSpec } from "./emitRuntime";
4
+ import type {
5
+ CollectionSpec,
6
+ SingletonSpec,
7
+ SyncedListSpec,
8
+ } from "./emitRuntime";
5
9
 
6
10
  export const COLLECTIONS: Readonly<Record<string, CollectionSpec>> = {
7
11
  features: {
@@ -1203,6 +1207,21 @@ export const SINGLETONS: Readonly<Record<string, SingletonSpec>> = {
1203
1207
  },
1204
1208
  };
1205
1209
 
1210
+ /** Lists keyed by id outside the catalog, emitted like collection items. */
1211
+ export const SYNCED_LISTS: Readonly<Record<string, SyncedListSpec>> = {
1212
+ webhooks: {
1213
+ wireKey: "webhooks",
1214
+ builder: "webhook",
1215
+ idField: "id",
1216
+ responseIdField: "id",
1217
+ keys: ["id", "url", "events", "description", "disabled"],
1218
+ required: ["events", "id", "url"],
1219
+ paths: ["description", "disabled", "events", "id", "url"],
1220
+ pull: true,
1221
+ envKeyed: ["url"],
1222
+ },
1223
+ };
1224
+
1206
1225
  /** Fixtures nested in a collection item: builder, id field, and where they live. */
1207
1226
  export const NESTED_FIXTURES: Readonly<
1208
1227
  Record<
@@ -93,6 +93,14 @@ export const branchSpecs = ({
93
93
  }): CollectionSpec[] =>
94
94
  spec.branches?.map((branch) => branchSpecOf({ spec, branch })) ?? [spec];
95
95
 
96
+ /** A list keyed by id outside the catalog: emitted like a collection item,
97
+ * with fields a config states once per environment. */
98
+ export type SyncedListSpec = CollectionSpec & {
99
+ readonly wireKey: string;
100
+ /** Fixture fields holding `{ live?, sandbox?, [sandboxSlug]? }` maps. */
101
+ readonly envKeyed: readonly string[];
102
+ };
103
+
96
104
  export type SingletonSpec = {
97
105
  /** The request-body field the object is sent as. */
98
106
  readonly wireKey: string;
@@ -0,0 +1,64 @@
1
+ // Copied by @autumn/atmn-generator from shared/api/webhooks/endpoints/isLocalWebhookUrl.ts.
2
+ // Do not edit — change that file and run `bun generate` instead.
3
+
4
+ const PRIVATE_IPV4_RANGES: [base: number, prefixBits: number][] = [
5
+ [0x00000000, 32], // 0.0.0.0
6
+ [0x7f000000, 8], // 127.0.0.0/8
7
+ [0x0a000000, 8], // 10.0.0.0/8
8
+ [0xac100000, 12], // 172.16.0.0/12
9
+ [0xc0a80000, 16], // 192.168.0.0/16
10
+ [0xa9fe0000, 16], // 169.254.0.0/16
11
+ ];
12
+
13
+ const parseIpv4 = (host: string): number | null => {
14
+ const parts = host.split(".");
15
+ if (parts.length !== 4) return null;
16
+ if (parts.some((part) => !/^\d+$/.test(part) || Number(part) > 255)) {
17
+ return null;
18
+ }
19
+ return parts.reduce((acc, part) => acc * 256 + Number(part), 0);
20
+ };
21
+
22
+ const isPrivateIpv4 = (address: number) =>
23
+ PRIVATE_IPV4_RANGES.some(([base, prefixBits]) => {
24
+ const mask = prefixBits === 0 ? 0 : (0xffffffff << (32 - prefixBits)) >>> 0;
25
+ return (address & mask) >>> 0 === base;
26
+ });
27
+
28
+ const isLocalIpv6 = (host: string) => {
29
+ if (host === "::1" || host === "::") return true;
30
+ if (/^fe[89ab][0-9a-f]:/.test(host)) return true;
31
+ // Unique-local fc00::/7, IPv6's private ranges
32
+ if (/^f[cd][0-9a-f]{0,2}:/.test(host)) return true;
33
+ // WHATWG URL rewrites ::ffff:127.0.0.1 to ::ffff:7f00:1
34
+ const [, high, low] =
35
+ host.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/) ?? [];
36
+ if (!high || !low) return false;
37
+ return isPrivateIpv4(
38
+ Number.parseInt(high, 16) * 0x10000 + Number.parseInt(low, 16),
39
+ );
40
+ };
41
+
42
+ /** True when the URL literally points at this machine or a private network.
43
+ * Svix delivers from the internet, so such an endpoint could never be reached. */
44
+ export const isLocalWebhookUrl = (url: string): boolean => {
45
+ let hostname: string;
46
+ try {
47
+ hostname = new URL(url).hostname.toLowerCase().replace(/\.$/, "");
48
+ } catch {
49
+ return false;
50
+ }
51
+
52
+ if (hostname.startsWith("[") && hostname.endsWith("]")) {
53
+ return isLocalIpv6(hostname.slice(1, -1));
54
+ }
55
+ if (
56
+ hostname === "localhost" ||
57
+ hostname.endsWith(".localhost") ||
58
+ hostname.endsWith(".local")
59
+ ) {
60
+ return true;
61
+ }
62
+ const ipv4 = parseIpv4(hostname);
63
+ return ipv4 !== null && isPrivateIpv4(ipv4);
64
+ };
@@ -310,6 +310,10 @@ export const LINT_RULES: LintRules = {
310
310
  minLength: 1,
311
311
  maxLength: 64,
312
312
  },
313
+ values: {
314
+ minLength: 1,
315
+ maxLength: 128,
316
+ },
313
317
  },
314
318
  "plans.billingControls.usageLimits": {
315
319
  required: ["featureId", "interval", "limit"],
@@ -333,6 +337,10 @@ export const LINT_RULES: LintRules = {
333
337
  minLength: 1,
334
338
  maxLength: 64,
335
339
  },
340
+ values: {
341
+ minLength: 1,
342
+ maxLength: 128,
343
+ },
336
344
  },
337
345
  "plans.freeTrial": {
338
346
  required: ["durationLength"],
@@ -1315,6 +1323,10 @@ export const LINT_RULES: LintRules = {
1315
1323
  minLength: 1,
1316
1324
  maxLength: 64,
1317
1325
  },
1326
+ values: {
1327
+ minLength: 1,
1328
+ maxLength: 128,
1329
+ },
1318
1330
  },
1319
1331
  "plans.variants.customize.billingControls.usageLimits": {
1320
1332
  required: ["featureId", "interval", "limit"],
@@ -1338,6 +1350,10 @@ export const LINT_RULES: LintRules = {
1338
1350
  minLength: 1,
1339
1351
  maxLength: 64,
1340
1352
  },
1353
+ values: {
1354
+ minLength: 1,
1355
+ maxLength: 128,
1356
+ },
1341
1357
  },
1342
1358
  "plans.variants.customize.freeTrial": {
1343
1359
  required: ["durationLength"],
@@ -2187,4 +2203,83 @@ export const LINT_RULES: LintRules = {
2187
2203
  },
2188
2204
  },
2189
2205
  },
2206
+ webhooks: {
2207
+ label: "webhook",
2208
+ idField: "id",
2209
+ required: ["events", "id", "url"],
2210
+ fields: {
2211
+ id: {
2212
+ minLength: 1,
2213
+ maxLength: 256,
2214
+ pattern: "^[a-zA-Z0-9_-]+$",
2215
+ },
2216
+ events: {
2217
+ minItems: 1,
2218
+ },
2219
+ },
2220
+ rules: [
2221
+ {
2222
+ kind: "unique",
2223
+ field: "id",
2224
+ because:
2225
+ "Two webhooks claiming one id race to define the same endpoint.",
2226
+ },
2227
+ {
2228
+ kind: "unique",
2229
+ field: "id",
2230
+ asEnvName: true,
2231
+ because:
2232
+ "Each webhook's signing secret is saved as AUTUMN_WEBHOOK_<ID>_SECRET, so these two would overwrite each other's secret. Rename one.",
2233
+ },
2234
+ {
2235
+ kind: "rejects",
2236
+ field: "url",
2237
+ check: "isLocalWebhookUrl",
2238
+ because:
2239
+ "Autumn delivers from the internet, so localhost and private-network addresses can never be reached. Use a public URL or a tunnel (e.g. ngrok).",
2240
+ },
2241
+ {
2242
+ kind: "uniformPrefix",
2243
+ field: "events",
2244
+ prefix: "vercel.",
2245
+ because:
2246
+ "vercel.* events can't be mixed with other events in one webhook: they're delivered from a separate app. Make one webhook for each.",
2247
+ },
2248
+ {
2249
+ kind: "nonEmpty",
2250
+ field: "url",
2251
+ warning: true,
2252
+ because:
2253
+ "With no environment key the webhook is registered nowhere. Add `live`, `sandbox` or a sandbox's slug.",
2254
+ },
2255
+ {
2256
+ kind: "knownValues",
2257
+ field: "events",
2258
+ values: [
2259
+ "customer.products.updated",
2260
+ "customer.threshold_reached",
2261
+ "balances.usage_alert_triggered",
2262
+ "balances.limit_reached",
2263
+ "billing.auto_topup_failed",
2264
+ "billing.auto_topup_succeeded",
2265
+ "billing.updated",
2266
+ "invoice.finalized",
2267
+ "vercel.resources.deleted",
2268
+ "vercel.resources.provisioned",
2269
+ "vercel.resources.rotate_secrets",
2270
+ "vercel.webhooks.event",
2271
+ ],
2272
+ warning: true,
2273
+ because: "isn't known to this atmn version; the server will check it.",
2274
+ },
2275
+ ],
2276
+ },
2277
+ "webhooks.url": {
2278
+ keys: {
2279
+ pattern: "^[a-z0-9_-]+$",
2280
+ },
2281
+ values: {
2282
+ pattern: "^[Hh][Tt][Tt][Pp][Ss]:\\/\\/",
2283
+ },
2284
+ },
2190
2285
  };
@@ -9,7 +9,8 @@
9
9
  * problem at once — a round trip per mistake is what makes one painful to write.
10
10
  */
11
11
 
12
- export type LintIssue = { path: string; message: string };
12
+ /** A warning is reported but never refuses the config. */
13
+ export type LintIssue = { path: string; message: string; warning?: true };
13
14
 
14
15
  export class ConfigError extends Error {
15
16
  readonly issues: LintIssue[];
@@ -76,6 +77,8 @@ export type LintRule =
76
77
  readonly field: string | readonly string[];
77
78
  readonly alongside?: string;
78
79
  readonly absentMeans?: string;
80
+ /** Compare as an env var name segment: uppercased, `-` read as `_`. */
81
+ readonly asEnvName?: true;
79
82
  readonly because: string;
80
83
  }
81
84
  | {
@@ -112,9 +115,35 @@ export type LintRule =
112
115
  readonly because: string;
113
116
  }
114
117
  | {
115
- /** A stated `field` must be a non-empty list; `null` is not checked here. */
118
+ /** A stated `field` must be a non-empty list or map; `null` is not checked here. */
116
119
  readonly kind: "nonEmpty";
117
120
  readonly field: string;
121
+ /** Reported without refusing the config. */
122
+ readonly warning?: true;
123
+ readonly because: string;
124
+ }
125
+ | {
126
+ /** Each name in list `field` should be one of `values`; any other is
127
+ * reported, as a warning when `warning` is set. */
128
+ readonly kind: "knownValues";
129
+ readonly field: string;
130
+ readonly values: readonly string[];
131
+ readonly warning?: true;
132
+ readonly because: string;
133
+ }
134
+ | {
135
+ /** List `field` holds names starting with `prefix` only, or none of them. */
136
+ readonly kind: "uniformPrefix";
137
+ readonly field: string;
138
+ readonly prefix: string;
139
+ readonly because: string;
140
+ }
141
+ | {
142
+ /** A stated string `field`, or every value of a map `field`, must not
143
+ * satisfy the named `check` the caller supplies to `lintDocument`. */
144
+ readonly kind: "rejects";
145
+ readonly field: string;
146
+ readonly check: string;
118
147
  readonly because: string;
119
148
  }
120
149
  | {
@@ -179,6 +208,8 @@ export type NodeRules = ShapeRules & {
179
208
  readonly idField?: string;
180
209
  /** Record keys are user data; the spec constrains them via `propertyNames`. */
181
210
  readonly keys?: FieldConstraints;
211
+ /** A record's scalar values, constrained by its `additionalProperties`. */
212
+ readonly values?: FieldConstraints;
182
213
  readonly rules?: readonly LintRule[];
183
214
  /** anyOf/oneOf alternatives, chosen by the value of `on`. */
184
215
  readonly variants?: {
@@ -192,6 +223,9 @@ export type NodeRules = ShapeRules & {
192
223
  /** Keyed by fixture path with array indices elided — `features.creditSchema`. */
193
224
  export type LintRules = Readonly<Record<string, NodeRules>>;
194
225
 
226
+ /** Predicates a `rejects` rule names: code the serialised rules cannot carry. */
227
+ export type LintChecks = Readonly<Record<string, (value: string) => boolean>>;
228
+
195
229
  export type LintHints = {
196
230
  readonly recordPaths: ReadonlySet<string>;
197
231
  readonly frozenPaths: ReadonlySet<string>;
@@ -203,6 +237,7 @@ type Walk = {
203
237
  readonly document: Entry;
204
238
  readonly rules: LintRules;
205
239
  readonly hints: LintHints;
240
+ readonly checks: LintChecks;
206
241
  readonly issues: LintIssue[];
207
242
  };
208
243
 
@@ -427,11 +462,13 @@ const entryRuleFailures = ({
427
462
  rule,
428
463
  document,
429
464
  parent,
465
+ checks,
430
466
  }: {
431
467
  entry: Entry;
432
468
  rule: LintRule;
433
469
  document: Entry;
434
470
  parent?: Entry;
471
+ checks: LintChecks;
435
472
  }): string[] => {
436
473
  switch (rule.kind) {
437
474
  case "requiredWhen": {
@@ -486,8 +523,46 @@ const entryRuleFailures = ({
486
523
  }
487
524
  case "nonEmpty": {
488
525
  const value = entry[rule.field];
489
- if (!Array.isArray(value) || value.length > 0) return [];
490
- return [`${rule.field} is empty. ${rule.because}`];
526
+ const empty = Array.isArray(value)
527
+ ? value.length === 0
528
+ : isEntry(value) && Object.keys(value).length === 0;
529
+ return empty ? [`${rule.field} is empty. ${rule.because}`] : [];
530
+ }
531
+ case "knownValues": {
532
+ const value = entry[rule.field];
533
+ if (!Array.isArray(value)) return [];
534
+ return value
535
+ .filter(
536
+ (item): item is string =>
537
+ typeof item === "string" && !rule.values.includes(item),
538
+ )
539
+ .map((item) => `\`${item}\` ${rule.because}`);
540
+ }
541
+ case "uniformPrefix": {
542
+ const value = entry[rule.field];
543
+ if (!Array.isArray(value)) return [];
544
+ const prefixed = value.filter(
545
+ (item) => typeof item === "string" && item.startsWith(rule.prefix),
546
+ ).length;
547
+ return prefixed === 0 || prefixed === value.length ? [] : [rule.because];
548
+ }
549
+ case "rejects": {
550
+ const value = entry[rule.field];
551
+ const check = checks[rule.check];
552
+ if (check === undefined) return [];
553
+ const stated =
554
+ typeof value === "string"
555
+ ? [value]
556
+ : isEntry(value)
557
+ ? Object.values(value).filter(
558
+ (item): item is string => typeof item === "string",
559
+ )
560
+ : [];
561
+ return stated
562
+ .filter((item) => check(item))
563
+ .map(
564
+ (item) => `${rule.field} ${show(item)} is refused. ${rule.because}`,
565
+ );
491
566
  }
492
567
  case "compare": {
493
568
  const a = entry[rule.field];
@@ -558,7 +633,7 @@ const checkUnique = ({
558
633
  trail,
559
634
  issues,
560
635
  }: CollectionCheck<"unique">): void => {
561
- const seen = new Set<string>();
636
+ const seen = new Map<string, string>();
562
637
  for (const [index, entry] of entries.entries()) {
563
638
  if (!isEntry(entry)) continue;
564
639
  const value = firstValueAtPaths({ entry, paths: rule.field });
@@ -568,8 +643,22 @@ const checkUnique = ({
568
643
  const pair =
569
644
  rule.alongside === undefined ? undefined : entry[rule.alongside];
570
645
  if (rule.alongside !== undefined && typeof pair !== "string") continue;
571
- const composite = rule.alongside === undefined ? value : `${value}@${pair}`;
572
- if (seen.has(composite)) {
646
+ const normalized = rule.asEnvName
647
+ ? value.toUpperCase().replace(/-/g, "_")
648
+ : value;
649
+ const composite =
650
+ rule.alongside === undefined ? normalized : `${normalized}@${pair}`;
651
+ const first = seen.get(composite);
652
+ if (rule.asEnvName && first !== undefined) {
653
+ // Identical values are the plain unique rule's to report.
654
+ if (first !== value)
655
+ issues.push({
656
+ path: render([...trail, crumbFor({ node, key, entry, index })]),
657
+ message: `${fieldName} ${show(value)} and ${show(first)} both read as ${normalized} in an env var name. ${rule.because}`,
658
+ });
659
+ continue;
660
+ }
661
+ if (first !== undefined) {
573
662
  const label =
574
663
  rule.alongside === undefined
575
664
  ? `${fieldName} ${show(value)}`
@@ -579,7 +668,7 @@ const checkUnique = ({
579
668
  message: `${label} is used more than once. ${rule.because}`,
580
669
  });
581
670
  }
582
- seen.add(composite);
671
+ seen.set(composite, value);
583
672
  }
584
673
  };
585
674
 
@@ -757,13 +846,15 @@ const checkEntry = ({
757
846
  checkVariants({ entry, variants: node.variants, at, issues: walk.issues });
758
847
  }
759
848
  for (const rule of node.rules ?? []) {
849
+ const warning = "warning" in rule && rule.warning === true;
760
850
  for (const message of entryRuleFailures({
761
851
  entry,
762
852
  rule,
763
853
  document: walk.document,
764
854
  parent,
855
+ checks: walk.checks,
765
856
  })) {
766
- walk.issues.push({ path: at, message });
857
+ walk.issues.push({ path: at, message, ...(warning ? { warning } : {}) });
767
858
  }
768
859
  }
769
860
  };
@@ -844,7 +935,17 @@ const walkValue = ({
844
935
  // values are still ours, one level down at `path.*`.
845
936
  if (walk.hints.recordPaths.has(path)) {
846
937
  const keys = walk.rules[path]?.keys;
938
+ const values = walk.rules[path]?.values;
847
939
  for (const [recordKey, child] of Object.entries(value)) {
940
+ if (values && !isEntry(child) && !Array.isArray(child)) {
941
+ for (const message of constraintFailures({
942
+ name: `${key}.${recordKey}`,
943
+ value: child,
944
+ constraints: values,
945
+ })) {
946
+ walk.issues.push({ path: render(trail), message });
947
+ }
948
+ }
848
949
  if (keys) {
849
950
  for (const message of constraintFailures({
850
951
  name: `${key} key ${show(recordKey)}`,
@@ -874,12 +975,14 @@ export const lintDocument = ({
874
975
  document,
875
976
  rules,
876
977
  hints,
978
+ checks = {},
877
979
  }: {
878
980
  document: Record<string, unknown>;
879
981
  rules: LintRules;
880
982
  hints: LintHints;
983
+ checks?: LintChecks;
881
984
  }): LintIssue[] => {
882
- const walk: Walk = { document, rules, hints, issues: [] };
985
+ const walk: Walk = { document, rules, hints, checks, issues: [] };
883
986
  walkEntry({ entry: document, path: "", trail: [], walk });
884
987
  return walk.issues;
885
988
  };
@@ -0,0 +1,52 @@
1
+ // Copied by @autumn/atmn-generator from shared/models/orgModels/sandboxName.ts.
2
+ // Do not edit — change that file and run `bun generate` instead.
3
+
4
+ // Single source of truth for sandbox-name -> URL-slug rules, consumed by the
5
+ // dashboard router and the server create/update guards. The reserved set mirrors
6
+ // the top-level /sandbox/<page> route names so a slug can't shadow a real route.
7
+ export const RESERVED_SANDBOX_SLUGS = new Set([
8
+ "products",
9
+ "customers",
10
+ "features",
11
+ "migrations",
12
+ "dev",
13
+ "analytics",
14
+ "settings",
15
+ "admin",
16
+ "chat",
17
+ "quickstart",
18
+ "onboarding",
19
+ "impersonate-redirect",
20
+ "trmnl",
21
+ ]);
22
+
23
+ // A sandbox slug can stand in for an env name, so it must never read as one.
24
+ const ENV_SANDBOX_SLUGS = new Set(["live", "sandbox"]);
25
+
26
+ export const sandboxSlug = (name: string): string =>
27
+ name
28
+ .toLowerCase()
29
+ .replace(/ /g, "-")
30
+ .replace(/[^\w\s-]/g, "");
31
+
32
+ export const SANDBOX_NAME_SPACES_MESSAGE =
33
+ "Name can't contain spaces. Use - or _ instead";
34
+
35
+ /** Rules for a new name. Existing names that break the space rule stay valid,
36
+ * so callers skip this when a name is unchanged. */
37
+ export const validateSandboxName = (name: string): string | null => {
38
+ if (/\s/.test(name)) {
39
+ return SANDBOX_NAME_SPACES_MESSAGE;
40
+ }
41
+ const slug = sandboxSlug(name);
42
+ if (!slug) {
43
+ return "Name must include at least one letter or number";
44
+ }
45
+ if (RESERVED_SANDBOX_SLUGS.has(slug)) {
46
+ return `"${name}" is a reserved name, pick another`;
47
+ }
48
+ if (ENV_SANDBOX_SLUGS.has(slug)) {
49
+ return `"${name}" is reserved for environment names, pick another`;
50
+ }
51
+ return null;
52
+ };