@lukoweb/apitogo 0.1.62 → 0.1.64

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/cli/cli.js CHANGED
@@ -187,26 +187,58 @@ function parseEnvValue(raw) {
187
187
  }
188
188
  return trimmed;
189
189
  }
190
+ function screamingSnakeToCamel(segment) {
191
+ return segment.toLowerCase().split("_").filter(Boolean).map(
192
+ (part, index) => index === 0 ? part : `${part.charAt(0).toUpperCase()}${part.slice(1)}`
193
+ ).join("");
194
+ }
195
+ function normalizeEnvPathSegment(segment) {
196
+ if (!segment) {
197
+ return segment;
198
+ }
199
+ const isAllUpper = segment === segment.toUpperCase() && /[A-Z]/.test(segment);
200
+ if (!isAllUpper) {
201
+ return segment;
202
+ }
203
+ if (segment.includes("_")) {
204
+ return screamingSnakeToCamel(segment);
205
+ }
206
+ return COLLAPSED_UPPERCASE_ALIASES[segment] ?? segment.toLowerCase();
207
+ }
208
+ function resolveExistingKey(target, key) {
209
+ if (Object.prototype.hasOwnProperty.call(target, key)) {
210
+ return key;
211
+ }
212
+ const lower = key.toLowerCase();
213
+ for (const existing of Object.keys(target)) {
214
+ if (existing.toLowerCase() === lower) {
215
+ return existing;
216
+ }
217
+ }
218
+ return key;
219
+ }
190
220
  function setNestedValue(target, pathParts, value) {
191
221
  if (pathParts.length === 0) {
192
222
  return;
193
223
  }
194
224
  let current = target;
195
225
  for (let index = 0; index < pathParts.length - 1; index += 1) {
196
- const key = pathParts[index];
197
- if (!key) {
226
+ const rawKey = pathParts[index];
227
+ if (!rawKey) {
198
228
  return;
199
229
  }
230
+ const key = resolveExistingKey(current, rawKey);
200
231
  const next = current[key];
201
232
  if (next === void 0 || next === null || typeof next !== "object" || Array.isArray(next)) {
202
233
  current[key] = {};
203
234
  }
204
235
  current = current[key];
205
236
  }
206
- const leafKey = pathParts[pathParts.length - 1];
207
- if (!leafKey) {
237
+ const rawLeaf = pathParts[pathParts.length - 1];
238
+ if (!rawLeaf) {
208
239
  return;
209
240
  }
241
+ const leafKey = resolveExistingKey(current, rawLeaf);
210
242
  current[leafKey] = value;
211
243
  }
212
244
  function stripRuntimeConfigFields(config2) {
@@ -219,7 +251,7 @@ function applyEnvOverrides(config2, env = process.env) {
219
251
  if (!key.startsWith(ENV_PREFIX) || value === void 0) {
220
252
  continue;
221
253
  }
222
- const pathParts = key.slice(ENV_PREFIX.length).split("__").filter(Boolean);
254
+ const pathParts = key.slice(ENV_PREFIX.length).split("__").filter(Boolean).map(normalizeEnvPathSegment);
223
255
  if (pathParts.length === 0) {
224
256
  continue;
225
257
  }
@@ -550,7 +582,7 @@ function extractSiteConfig(config2) {
550
582
  }
551
583
  return siteConfig;
552
584
  }
553
- var APITOGO_CONFIG_FILENAME, ENV_PREFIX, DERIVED_MANIFEST_KEYS, SITE_CONFIG_KEYS;
585
+ var APITOGO_CONFIG_FILENAME, ENV_PREFIX, DERIVED_MANIFEST_KEYS, SITE_CONFIG_KEYS, COLLAPSED_UPPERCASE_ALIASES;
554
586
  var init_apitogo_config_core = __esm({
555
587
  "src/config/apitogo-config-core.ts"() {
556
588
  APITOGO_CONFIG_FILENAME = "apitogo.config.json";
@@ -583,6 +615,28 @@ var init_apitogo_config_core = __esm({
583
615
  "versions",
584
616
  "options"
585
617
  ];
618
+ COLLAPSED_UPPERCASE_ALIASES = {
619
+ SECRETKEY: "secretKey",
620
+ WEBHOOKSECRET: "webhookSecret",
621
+ APIBASEURL: "apiBaseUrl",
622
+ CLIENTID: "clientId",
623
+ CLIENTSECRET: "clientSecret",
624
+ CONNECTIONSTRING: "connectionString",
625
+ DEMOMODE: "demoMode",
626
+ DISPLAYNAME: "displayName",
627
+ PRICEAMOUNT: "priceAmount",
628
+ PRICECURRENCY: "priceCurrency",
629
+ BILLINGPERIOD: "billingPeriod",
630
+ LIMITSJSON: "limitsJson",
631
+ INCLUDEDACTIONKEYS: "includedActionKeys",
632
+ SOURCEDIRECTORY: "sourceDirectory",
633
+ PUBLISHDIRECTORY: "publishDirectory",
634
+ OPENAPIPATH: "openApiPath",
635
+ RUNTIMEVERSION: "runtimeVersion",
636
+ PROJECTID: "projectId",
637
+ SITENAME: "siteName",
638
+ LOGOURL: "logoUrl"
639
+ };
586
640
  }
587
641
  });
588
642
 
@@ -852,7 +906,10 @@ var init_ModulesSchema = __esm({
852
906
  sku: z3.string(),
853
907
  displayName: z3.string(),
854
908
  priceAmount: z3.number().default(0),
855
- priceCurrency: z3.string().optional(),
909
+ priceCurrency: z3.string().trim().toLowerCase().regex(
910
+ /^[a-z]{3}$/,
911
+ "priceCurrency must be a 3-letter ISO 4217 code (lowercase)"
912
+ ).optional(),
856
913
  billingPeriod: z3.string().optional(),
857
914
  limitsJson: z3.string().optional(),
858
915
  includedActionKeys: z3.array(z3.string()).optional()
@@ -993,17 +1050,33 @@ async function readEffectiveManifest(rootDir, _env) {
993
1050
  return null;
994
1051
  }
995
1052
  }
996
- var BillingPeriodSchema, ManifestPlanInputSchema, DevPortalLocalManifestSchema;
1053
+ var BillingPeriodSchema, PriceCurrencySchema, ALLOWED_RUNTIME_VERSIONS, ManifestPlanInputSchema, DevPortalLocalManifestSchema;
997
1054
  var init_local_manifest = __esm({
998
1055
  "src/config/local-manifest.ts"() {
999
1056
  init_apitogo_config_loader();
1000
1057
  init_landing_manifest();
1001
1058
  BillingPeriodSchema = z4.enum(["month", "year", "monthly", "annual"]);
1059
+ PriceCurrencySchema = z4.string().trim().toLowerCase().regex(
1060
+ /^[a-z]{3}$/,
1061
+ "priceCurrency must be a 3-letter ISO 4217 code (lowercase)"
1062
+ );
1063
+ ALLOWED_RUNTIME_VERSIONS = [
1064
+ "DOTNET_10",
1065
+ "DOTNET_9",
1066
+ "DOTNET_8",
1067
+ "NODE_24_LTS",
1068
+ "NODE_22_LTS",
1069
+ "PYTHON_314",
1070
+ "PYTHON_313",
1071
+ "PYTHON_312",
1072
+ "PYTHON_311",
1073
+ "PYTHON_310"
1074
+ ];
1002
1075
  ManifestPlanInputSchema = z4.object({
1003
1076
  sku: z4.string().min(1),
1004
1077
  displayName: z4.string().min(1),
1005
1078
  priceAmount: z4.number().default(0),
1006
- priceCurrency: z4.string().optional(),
1079
+ priceCurrency: PriceCurrencySchema.optional(),
1007
1080
  billingPeriod: BillingPeriodSchema.optional(),
1008
1081
  limitsJson: z4.string().optional(),
1009
1082
  includedActionKeys: z4.array(z4.string()).optional()
@@ -1042,8 +1115,17 @@ var init_local_manifest = __esm({
1042
1115
  sourceDirectory: z4.string().optional(),
1043
1116
  publishDirectory: z4.string().optional(),
1044
1117
  openApiPath: z4.string().optional(),
1045
- runtime: z4.string().optional(),
1046
- runtimeVersion: z4.string().optional(),
1118
+ runtime: z4.enum(["dotnet", "nodejs", "python"]).or(z4.string()).optional(),
1119
+ runtimeVersion: z4.string().optional().refine(
1120
+ (value) => value === void 0 || ALLOWED_RUNTIME_VERSIONS.includes(
1121
+ value.toUpperCase()
1122
+ ) || /^(8(\.0)?|9(\.0)?|10(\.0)?|net8\.0|net9\.0|net10\.0|24|22|3\.(10|11|12|13|14))$/i.test(
1123
+ value
1124
+ ),
1125
+ {
1126
+ message: `runtimeVersion must be a platform slug (${ALLOWED_RUNTIME_VERSIONS.join(", ")}) or a known alias such as 8.0`
1127
+ }
1128
+ ),
1047
1129
  deployed: z4.boolean().optional()
1048
1130
  }).optional(),
1049
1131
  gateway: z4.object({
@@ -4772,6 +4854,18 @@ import {
4772
4854
  runnerImport,
4773
4855
  loadEnv as viteLoadEnv
4774
4856
  } from "vite";
4857
+ function mergeJsonConfigWithRuntimeOverrides(fileConfig, importedConfig) {
4858
+ const jsonConfig = buildApitogoConfig({
4859
+ ...fileConfig,
4860
+ plugins: importedConfig.plugins
4861
+ });
4862
+ return {
4863
+ ...jsonConfig,
4864
+ // Custom pages carry React elements and therefore only exist in the
4865
+ // imported TSX config. They cannot be read from or cloned through JSON.
4866
+ navigation: importedConfig.navigation ?? jsonConfig.navigation
4867
+ };
4868
+ }
4775
4869
  async function getConfigFilePath(rootDir) {
4776
4870
  for (const fileName of zudokuConfigFiles) {
4777
4871
  const filepath = path5.join(rootDir, fileName);
@@ -4806,10 +4900,7 @@ async function loadZudokuConfigWithMeta(rootDir) {
4806
4900
  let config2;
4807
4901
  try {
4808
4902
  const fileConfig = loadApitogoConfig(rootDir);
4809
- config2 = buildApitogoConfig({
4810
- ...fileConfig,
4811
- plugins: importedConfig.plugins
4812
- });
4903
+ config2 = mergeJsonConfigWithRuntimeOverrides(fileConfig, importedConfig);
4813
4904
  } catch {
4814
4905
  config2 = importedConfig;
4815
4906
  }
@@ -4906,6 +4997,7 @@ async function loadZudokuConfig(configEnv, rootDir) {
4906
4997
  return { config, envPrefix, publicEnv };
4907
4998
  }
4908
4999
  ({ publicEnv, envPrefix } = loadEnv(configEnv, rootDir));
5000
+ let loadSucceeded = false;
4909
5001
  try {
4910
5002
  const loadedConfig = await loadZudokuConfigWithMeta(rootDir);
4911
5003
  const manifestEnv = resolveManifestEnv(configEnv.mode);
@@ -4943,6 +5035,7 @@ async function loadZudokuConfig(configEnv, rootDir) {
4943
5035
  { timestamp: true }
4944
5036
  );
4945
5037
  }
5038
+ loadSucceeded = true;
4946
5039
  return { config, envPrefix, publicEnv };
4947
5040
  } catch (error) {
4948
5041
  const errorMessage = error instanceof Error ? error.message : String(error);
@@ -4951,7 +5044,11 @@ async function loadZudokuConfig(configEnv, rootDir) {
4951
5044
  }
4952
5045
  throw new Error(errorMessage, { cause: error });
4953
5046
  } finally {
4954
- await updateModifiedTimes();
5047
+ if (loadSucceeded) {
5048
+ await updateModifiedTimes();
5049
+ } else {
5050
+ modifiedTimes = void 0;
5051
+ }
4955
5052
  }
4956
5053
  }
4957
5054
  var config, envPrefix, publicEnv, modifiedTimes, zudokuConfigFiles, serverConfigOutputFiles, virtualModuleStubPlugin, getCurrentConfig;
@@ -5238,7 +5335,7 @@ import {
5238
5335
  // package.json
5239
5336
  var package_default = {
5240
5337
  name: "@lukoweb/apitogo",
5241
- version: "0.1.62",
5338
+ version: "0.1.64",
5242
5339
  type: "module",
5243
5340
  sideEffects: [
5244
5341
  "**/*.css",
@@ -7583,9 +7680,7 @@ var viteConfigReloadPlugin = () => ({
7583
7680
  if (file !== currentConfig.__meta.configPath && file.endsWith(".tsx"))
7584
7681
  return;
7585
7682
  invalidate(server);
7586
- Object.values(server.environments).forEach((env) => {
7587
- env.moduleGraph.invalidateAll();
7588
- });
7683
+ reload(server);
7589
7684
  logger.info(
7590
7685
  colors3.blue(
7591
7686
  `Config ${path13.basename(currentConfig.__meta.configPath)} changed. Reloading...`
@@ -19,6 +19,7 @@ export type AuthenticationSecrets = {
19
19
  webhookSecret?: string;
20
20
  };
21
21
  };
22
+ export declare function normalizeEnvPathSegment(segment: string): string;
22
23
  export declare function applyEnvOverrides(config: ApitogoJsonConfig, env?: NodeJS.ProcessEnv): ApitogoJsonConfig;
23
24
  type PlanCatalogItem = Record<string, unknown> & {
24
25
  sku?: string;
@@ -16,6 +16,7 @@ export type ConfigWithMeta = ZudokuConfig & {
16
16
  };
17
17
  __resolvedModules: ResolvedModulesConfig;
18
18
  };
19
+ export declare function mergeJsonConfigWithRuntimeOverrides(fileConfig: Record<string, unknown>, importedConfig: ZudokuConfig): ZudokuConfig;
19
20
  export declare function findOutputPathOfServerConfig(output: RollupOutput | RollupOutput[] | RollupWatcher): string;
20
21
  export declare const getCurrentConfig: () => ConfigWithMeta;
21
22
  export declare const setCurrentConfig: (newConfig: ConfigWithMeta) => void;
@@ -8,6 +8,7 @@ export declare const APITOGO_MANIFEST_ENV_FILES: {
8
8
  };
9
9
  export { APITOGO_CONFIG_FILENAME };
10
10
  export type ApitogoManifestEnv = keyof typeof APITOGO_MANIFEST_ENV_FILES;
11
+ export declare const ALLOWED_RUNTIME_VERSIONS: readonly ["DOTNET_10", "DOTNET_9", "DOTNET_8", "NODE_24_LTS", "NODE_22_LTS", "PYTHON_314", "PYTHON_313", "PYTHON_312", "PYTHON_311", "PYTHON_310"];
11
12
  export declare const ManifestPlanInputSchema: z.ZodObject<{
12
13
  sku: z.ZodString;
13
14
  displayName: z.ZodString;
@@ -70,7 +71,11 @@ export declare const DevPortalLocalManifestSchema: z.ZodObject<{
70
71
  sourceDirectory: z.ZodOptional<z.ZodString>;
71
72
  publishDirectory: z.ZodOptional<z.ZodString>;
72
73
  openApiPath: z.ZodOptional<z.ZodString>;
73
- runtime: z.ZodOptional<z.ZodString>;
74
+ runtime: z.ZodOptional<z.ZodUnion<[z.ZodEnum<{
75
+ python: "python";
76
+ dotnet: "dotnet";
77
+ nodejs: "nodejs";
78
+ }>, z.ZodString]>>;
74
79
  runtimeVersion: z.ZodOptional<z.ZodString>;
75
80
  deployed: z.ZodOptional<z.ZodBoolean>;
76
81
  }, z.core.$strip>>;
@@ -45,7 +45,7 @@ VITE_APITOGO_AUTHENTICATION_API_BASE_URL=https://your-dev-portal-api.example.com
45
45
  ```
46
46
 
47
47
  You can also set `authentication.apiBaseUrl` in `apitogo.config.json` or
48
- `APITOGO_CONFIG_authentication__apiBaseUrl` in `.env`.
48
+ `APITOGO_CONFIG_AUTHENTICATION__API_BASE_URL` in `.env`.
49
49
 
50
50
  **Local demo mode:** In development (`npm run dev`), `demoMode` is enabled by default when the user
51
51
  panel is on. You do not need `VITE_APITOGO_AUTHENTICATION_API_BASE_URL`, OIDC, or a running
@@ -55,7 +55,7 @@ dev-portal backend to explore protected account pages locally. Sign in at `/logi
55
55
  demo@apitogo.local / password
56
56
  ```
57
57
 
58
- Disable with `APITOGO_CONFIG_demoMode=false` in `.env` or `"demoMode": false` in
58
+ Disable with `APITOGO_CONFIG_DEMO_MODE=false` in `.env` or `"demoMode": false` in
59
59
  `apitogo.config.json`. Demo mode is never active in production builds.
60
60
 
61
61
  **Local preview (demo off):** When the backend URL is a placeholder or unreachable and demo mode is
@@ -70,7 +70,7 @@ without restarting the dev server.
70
70
  | -------------------------------------------------------- | -------------------------------------------------------------------------- |
71
71
  | Site, plans, modules, branding, backend/gateway metadata | `apitogo.config.json` (+ `.env` overrides — see [Manifest](./manifest.md)) |
72
72
  | Plugins and custom code | `apitogo.config.tsx` |
73
- | OIDC and Stripe for publish | `.env.local` via `APITOGO_CONFIG_authentication__*` |
73
+ | OIDC and Stripe for publish | `.env.local` via `APITOGO_CONFIG_AUTHENTICATION__*` |
74
74
 
75
75
  **Production:** Register one OIDC app with redirect URI `https://{backend}/signin-oidc` (see MCP
76
76
  `manualSteps.oidcRedirectUri`). The frontend uses cookie sessions via `credentials: "include"` on
@@ -70,23 +70,23 @@ Public client URL (mirrors `authentication.apiBaseUrl`):
70
70
  VITE_APITOGO_AUTHENTICATION_API_BASE_URL=https://your-dev-portal-api.example.com
71
71
  ```
72
72
 
73
- Nested config overrides:
73
+ Nested config overrides (uppercase SCREAMING_SNAKE path segments; camelCase still accepted):
74
74
 
75
75
  ```sh
76
- APITOGO_CONFIG_site__title=My Local Site
77
- APITOGO_CONFIG_pricing__enabled=true
78
- APITOGO_CONFIG_projectId=your-local-project-id
79
- APITOGO_CONFIG_authentication__apiBaseUrl=https://your-dev-portal-api.example.com
76
+ APITOGO_CONFIG_SITE__TITLE=My Local Site
77
+ APITOGO_CONFIG_PRICING__ENABLED=true
78
+ APITOGO_CONFIG_PROJECT_ID=your-local-project-id
79
+ APITOGO_CONFIG_AUTHENTICATION__API_BASE_URL=https://your-dev-portal-api.example.com
80
80
  ```
81
81
 
82
82
  Publish secrets (`.env.local`, never commit):
83
83
 
84
84
  ```sh
85
- APITOGO_CONFIG_authentication__oidc__authority=https://login.example.com
86
- APITOGO_CONFIG_authentication__oidc__clientId=your-client-id
87
- APITOGO_CONFIG_authentication__oidc__clientSecret=your-client-secret
88
- APITOGO_CONFIG_authentication__stripe__secretKey=sk_test_...
89
- APITOGO_CONFIG_authentication__stripe__webhookSecret=whsec_...
85
+ APITOGO_CONFIG_AUTHENTICATION__OIDC__AUTHORITY=https://login.example.com
86
+ APITOGO_CONFIG_AUTHENTICATION__OIDC__CLIENT_ID=your-client-id
87
+ APITOGO_CONFIG_AUTHENTICATION__OIDC__CLIENT_SECRET=your-client-secret
88
+ APITOGO_CONFIG_AUTHENTICATION__STRIPE__SECRET_KEY=sk_test_...
89
+ APITOGO_CONFIG_AUTHENTICATION__STRIPE__WEBHOOK_SECRET=whsec_...
90
90
  ```
91
91
 
92
92
  ## Thin `apitogo.config.tsx`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lukoweb/apitogo",
3
- "version": "0.1.62",
3
+ "version": "0.1.64",
4
4
  "type": "module",
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -47,7 +47,7 @@ export type AuthenticationSecrets = {
47
47
  email?: {
48
48
  connectionString?: string;
49
49
  };
50
- /** Provider secrets keyed by provider `id` (from env: `authentication__providers__{id}__clientSecret`). */
50
+ /** Provider secrets keyed by provider `id` (from env: `AUTHENTICATION__PROVIDERS__{id}__CLIENT_SECRET`). */
51
51
  providers?: Record<string, { clientSecret?: string }>;
52
52
  stripe?: {
53
53
  secretKey?: string;
@@ -89,6 +89,82 @@ function parseEnvValue(raw: string | undefined): unknown {
89
89
  return trimmed;
90
90
  }
91
91
 
92
+ /** Collapsed ALLCAPS aliases for multi-word camelCase config keys. */
93
+ const COLLAPSED_UPPERCASE_ALIASES: Record<string, string> = {
94
+ SECRETKEY: "secretKey",
95
+ WEBHOOKSECRET: "webhookSecret",
96
+ APIBASEURL: "apiBaseUrl",
97
+ CLIENTID: "clientId",
98
+ CLIENTSECRET: "clientSecret",
99
+ CONNECTIONSTRING: "connectionString",
100
+ DEMOMODE: "demoMode",
101
+ DISPLAYNAME: "displayName",
102
+ PRICEAMOUNT: "priceAmount",
103
+ PRICECURRENCY: "priceCurrency",
104
+ BILLINGPERIOD: "billingPeriod",
105
+ LIMITSJSON: "limitsJson",
106
+ INCLUDEDACTIONKEYS: "includedActionKeys",
107
+ SOURCEDIRECTORY: "sourceDirectory",
108
+ PUBLISHDIRECTORY: "publishDirectory",
109
+ OPENAPIPATH: "openApiPath",
110
+ RUNTIMEVERSION: "runtimeVersion",
111
+ PROJECTID: "projectId",
112
+ SITENAME: "siteName",
113
+ LOGOURL: "logoUrl",
114
+ };
115
+
116
+ function screamingSnakeToCamel(segment: string): string {
117
+ return segment
118
+ .toLowerCase()
119
+ .split("_")
120
+ .filter(Boolean)
121
+ .map((part, index) =>
122
+ index === 0 ? part : `${part.charAt(0).toUpperCase()}${part.slice(1)}`,
123
+ )
124
+ .join("");
125
+ }
126
+
127
+ /**
128
+ * Normalize one `APITOGO_CONFIG_*` path segment to the JSON key casing.
129
+ * - Mixed/camelCase → leave as-is (legacy)
130
+ * - `SECRET_KEY` → `secretKey`
131
+ * - `AUTHENTICATION` → `authentication`
132
+ * - `SECRETKEY` → `secretKey` (collapsed alias)
133
+ */
134
+ export function normalizeEnvPathSegment(segment: string): string {
135
+ if (!segment) {
136
+ return segment;
137
+ }
138
+
139
+ const isAllUpper =
140
+ segment === segment.toUpperCase() && /[A-Z]/.test(segment);
141
+ if (!isAllUpper) {
142
+ return segment;
143
+ }
144
+
145
+ if (segment.includes("_")) {
146
+ return screamingSnakeToCamel(segment);
147
+ }
148
+
149
+ return COLLAPSED_UPPERCASE_ALIASES[segment] ?? segment.toLowerCase();
150
+ }
151
+
152
+ function resolveExistingKey(
153
+ target: Record<string, unknown>,
154
+ key: string,
155
+ ): string {
156
+ if (Object.prototype.hasOwnProperty.call(target, key)) {
157
+ return key;
158
+ }
159
+ const lower = key.toLowerCase();
160
+ for (const existing of Object.keys(target)) {
161
+ if (existing.toLowerCase() === lower) {
162
+ return existing;
163
+ }
164
+ }
165
+ return key;
166
+ }
167
+
92
168
  function setNestedValue(
93
169
  target: Record<string, unknown>,
94
170
  pathParts: string[],
@@ -100,10 +176,11 @@ function setNestedValue(
100
176
 
101
177
  let current: Record<string, unknown> = target;
102
178
  for (let index = 0; index < pathParts.length - 1; index += 1) {
103
- const key = pathParts[index];
104
- if (!key) {
179
+ const rawKey = pathParts[index];
180
+ if (!rawKey) {
105
181
  return;
106
182
  }
183
+ const key = resolveExistingKey(current, rawKey);
107
184
  const next = current[key];
108
185
  if (
109
186
  next === undefined ||
@@ -116,11 +193,12 @@ function setNestedValue(
116
193
  current = current[key] as Record<string, unknown>;
117
194
  }
118
195
 
119
- const leafKey = pathParts[pathParts.length - 1];
120
- if (!leafKey) {
196
+ const rawLeaf = pathParts[pathParts.length - 1];
197
+ if (!rawLeaf) {
121
198
  return;
122
199
  }
123
200
 
201
+ const leafKey = resolveExistingKey(current, rawLeaf);
124
202
  current[leafKey] = value;
125
203
  }
126
204
 
@@ -144,7 +222,11 @@ export function applyEnvOverrides(
144
222
  continue;
145
223
  }
146
224
 
147
- const pathParts = key.slice(ENV_PREFIX.length).split("__").filter(Boolean);
225
+ const pathParts = key
226
+ .slice(ENV_PREFIX.length)
227
+ .split("__")
228
+ .filter(Boolean)
229
+ .map(normalizeEnvPathSegment);
148
230
 
149
231
  if (pathParts.length === 0) {
150
232
  continue;
@@ -69,6 +69,23 @@ const zudokuConfigFiles = [
69
69
 
70
70
  const serverConfigOutputFiles = ["apitogo.config.js", "zudoku.config.js"];
71
71
 
72
+ export function mergeJsonConfigWithRuntimeOverrides(
73
+ fileConfig: Record<string, unknown>,
74
+ importedConfig: ZudokuConfig,
75
+ ): ZudokuConfig {
76
+ const jsonConfig = buildApitogoConfig({
77
+ ...fileConfig,
78
+ plugins: importedConfig.plugins,
79
+ });
80
+
81
+ return {
82
+ ...jsonConfig,
83
+ // Custom pages carry React elements and therefore only exist in the
84
+ // imported TSX config. They cannot be read from or cloned through JSON.
85
+ navigation: importedConfig.navigation ?? jsonConfig.navigation,
86
+ };
87
+ }
88
+
72
89
  async function getConfigFilePath(rootDir: string) {
73
90
  for (const fileName of zudokuConfigFiles) {
74
91
  const filepath = path.join(rootDir, fileName);
@@ -123,10 +140,7 @@ async function loadZudokuConfigWithMeta(
123
140
  let config: ZudokuConfig;
124
141
  try {
125
142
  const fileConfig = loadApitogoConfig(rootDir);
126
- config = buildApitogoConfig({
127
- ...fileConfig,
128
- plugins: importedConfig.plugins,
129
- });
143
+ config = mergeJsonConfigWithRuntimeOverrides(fileConfig, importedConfig);
130
144
  } catch {
131
145
  config = importedConfig;
132
146
  }
@@ -271,6 +285,7 @@ export async function loadZudokuConfig(
271
285
 
272
286
  ({ publicEnv, envPrefix } = loadEnv(configEnv, rootDir));
273
287
 
288
+ let loadSucceeded = false;
274
289
  try {
275
290
  const loadedConfig = await loadZudokuConfigWithMeta(rootDir);
276
291
  const manifestEnv = resolveManifestEnv(configEnv.mode);
@@ -313,6 +328,7 @@ export async function loadZudokuConfig(
313
328
  );
314
329
  }
315
330
 
331
+ loadSucceeded = true;
316
332
  return { config, envPrefix, publicEnv };
317
333
  } catch (error) {
318
334
  const errorMessage = error instanceof Error ? error.message : String(error);
@@ -324,7 +340,13 @@ export async function loadZudokuConfig(
324
340
 
325
341
  throw new Error(errorMessage, { cause: error });
326
342
  } finally {
327
- await updateModifiedTimes();
343
+ if (loadSucceeded) {
344
+ await updateModifiedTimes();
345
+ } else {
346
+ // A watcher may have observed a file while it was being replaced.
347
+ // Retry on the next request instead of caching the failed file mtimes.
348
+ modifiedTimes = undefined;
349
+ }
328
350
  }
329
351
  }
330
352
 
@@ -28,11 +28,33 @@ export type ApitogoManifestEnv = keyof typeof APITOGO_MANIFEST_ENV_FILES;
28
28
 
29
29
  const BillingPeriodSchema = z.enum(["month", "year", "monthly", "annual"]);
30
30
 
31
+ const PriceCurrencySchema = z
32
+ .string()
33
+ .trim()
34
+ .toLowerCase()
35
+ .regex(
36
+ /^[a-z]{3}$/,
37
+ "priceCurrency must be a 3-letter ISO 4217 code (lowercase)",
38
+ );
39
+
40
+ export const ALLOWED_RUNTIME_VERSIONS = [
41
+ "DOTNET_10",
42
+ "DOTNET_9",
43
+ "DOTNET_8",
44
+ "NODE_24_LTS",
45
+ "NODE_22_LTS",
46
+ "PYTHON_314",
47
+ "PYTHON_313",
48
+ "PYTHON_312",
49
+ "PYTHON_311",
50
+ "PYTHON_310",
51
+ ] as const;
52
+
31
53
  export const ManifestPlanInputSchema = z.object({
32
54
  sku: z.string().min(1),
33
55
  displayName: z.string().min(1),
34
56
  priceAmount: z.number().default(0),
35
- priceCurrency: z.string().optional(),
57
+ priceCurrency: PriceCurrencySchema.optional(),
36
58
  billingPeriod: BillingPeriodSchema.optional(),
37
59
  limitsJson: z.string().optional(),
38
60
  includedActionKeys: z.array(z.string()).optional(),
@@ -90,8 +112,23 @@ export const DevPortalLocalManifestSchema = z.object({
90
112
  sourceDirectory: z.string().optional(),
91
113
  publishDirectory: z.string().optional(),
92
114
  openApiPath: z.string().optional(),
93
- runtime: z.string().optional(),
94
- runtimeVersion: z.string().optional(),
115
+ runtime: z.enum(["dotnet", "nodejs", "python"]).or(z.string()).optional(),
116
+ runtimeVersion: z
117
+ .string()
118
+ .optional()
119
+ .refine(
120
+ (value) =>
121
+ value === undefined ||
122
+ (ALLOWED_RUNTIME_VERSIONS as readonly string[]).includes(
123
+ value.toUpperCase(),
124
+ ) ||
125
+ /^(8(\.0)?|9(\.0)?|10(\.0)?|net8\.0|net9\.0|net10\.0|24|22|3\.(10|11|12|13|14))$/i.test(
126
+ value,
127
+ ),
128
+ {
129
+ message: `runtimeVersion must be a platform slug (${ALLOWED_RUNTIME_VERSIONS.join(", ")}) or a known alias such as 8.0`,
130
+ },
131
+ ),
95
132
  deployed: z.boolean().optional(),
96
133
  })
97
134
  .optional(),
@@ -262,7 +262,15 @@ const PlanCatalogItemSchema = z.object({
262
262
  sku: z.string(),
263
263
  displayName: z.string(),
264
264
  priceAmount: z.number().default(0),
265
- priceCurrency: z.string().optional(),
265
+ priceCurrency: z
266
+ .string()
267
+ .trim()
268
+ .toLowerCase()
269
+ .regex(
270
+ /^[a-z]{3}$/,
271
+ "priceCurrency must be a 3-letter ISO 4217 code (lowercase)",
272
+ )
273
+ .optional(),
266
274
  billingPeriod: z.string().optional(),
267
275
  limitsJson: z.string().optional(),
268
276
  includedActionKeys: z.array(z.string()).optional(),
@@ -34,9 +34,7 @@ export const viteConfigReloadPlugin = (): Plugin => ({
34
34
  return;
35
35
 
36
36
  invalidateNavigation(server);
37
- Object.values(server.environments).forEach((env) => {
38
- env.moduleGraph.invalidateAll();
39
- });
37
+ reload(server);
40
38
 
41
39
  logger.info(
42
40
  colors.blue(