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/generated/emit.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
490
|
-
|
|
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
|
|
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
|
|
572
|
-
|
|
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.
|
|
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
|
+
};
|