@lowdefy/build 5.5.1 → 6.0.0

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 (107) hide show
  1. package/dist/build/buildAgents.js +70 -17
  2. package/dist/build/buildApi/buildApi.js +4 -1
  3. package/dist/build/buildApi/buildEndpoint.js +8 -2
  4. package/dist/build/buildApi/buildRoutine/countStepTypes.js +1 -1
  5. package/dist/build/buildApi/buildRoutine/setStepId.js +2 -0
  6. package/dist/build/buildApi/buildRoutine/validateStep.js +61 -0
  7. package/dist/build/buildApi/resolveEndpointSchedules.js +37 -0
  8. package/dist/build/buildApi/validateEndpoint.js +96 -1
  9. package/dist/build/buildApp.js +13 -20
  10. package/dist/build/buildAppMeta.js +130 -0
  11. package/dist/build/buildAuth/buildAgentAuth.js +69 -0
  12. package/dist/build/buildAuth/buildAuth.js +14 -0
  13. package/dist/build/buildAuth/buildAuthPlugins.js +6 -0
  14. package/dist/build/buildAuth/buildAuthStrategies.js +117 -0
  15. package/dist/build/buildAuth/buildWebsocketAuth.js +55 -0
  16. package/dist/build/buildAuth/getAgentRoles.js +39 -0
  17. package/dist/build/buildAuth/getProtectedAgents.js +29 -0
  18. package/dist/build/buildAuth/getProtectedWebsockets.js +29 -0
  19. package/dist/build/buildAuth/getWebsocketRoles.js +39 -0
  20. package/dist/build/buildAuth/rejectReservedEntityIds.js +52 -0
  21. package/dist/build/buildAuth/validateAuthConfig.js +15 -3
  22. package/dist/build/buildImports/buildImportsDev.js +15 -0
  23. package/dist/build/buildImports/buildImportsProd.js +4 -1
  24. package/dist/build/buildJs/generateClientJsModule.js +27 -0
  25. package/dist/build/buildJs/writeJs.js +3 -5
  26. package/dist/build/buildMcp.js +73 -0
  27. package/dist/build/buildModuleDefs.js +90 -48
  28. package/dist/build/buildModules.js +61 -14
  29. package/dist/build/buildNotifications.js +98 -0
  30. package/dist/build/buildPages/buildBlock/buildBlock.js +3 -1
  31. package/dist/build/buildPages/buildBlock/buildDynamicBlock.js +131 -0
  32. package/dist/build/buildPages/buildBlock/buildEvents.js +197 -22
  33. package/dist/build/buildPages/buildBlock/buildRequests.js +9 -0
  34. package/dist/build/buildPages/buildBlock/normalizeClassAndStyles.js +2 -11
  35. package/dist/build/buildPages/buildBlock/setBlockId.js +5 -2
  36. package/dist/build/buildPages/buildBlock/validateBlock.js +19 -1
  37. package/dist/build/buildPages/buildPage.js +27 -3
  38. package/dist/build/buildPages/buildSubscriptions.js +145 -0
  39. package/dist/build/buildPages/validateDynamicBlockRefs.js +30 -0
  40. package/dist/build/buildPages/validateStateReferences.js +4 -2
  41. package/dist/build/buildPages/validateWebsocketRefs.js +30 -0
  42. package/dist/build/buildRefs/buildRefs.js +21 -12
  43. package/dist/build/buildRefs/cloneWithMarkers.js +57 -0
  44. package/dist/build/buildRefs/deferredRegions.js +80 -0
  45. package/dist/build/buildRefs/deferredRegistry.js +292 -0
  46. package/dist/build/buildRefs/getKey.js +16 -5
  47. package/dist/build/buildRefs/getModuleRefContent.js +21 -6
  48. package/dist/build/buildRefs/getUserJavascriptFunction.js +1 -1
  49. package/dist/build/buildRefs/parseRefContent.js +24 -0
  50. package/dist/build/buildRefs/{evaluateStaticOperators.js → precomputeRuntimeOperators.js} +7 -6
  51. package/dist/build/buildRefs/walker.js +490 -209
  52. package/dist/build/buildTypes.js +22 -1
  53. package/dist/build/buildWebsockets.js +99 -0
  54. package/dist/build/collectPageContent.js +7 -1
  55. package/dist/build/copyAgentFileSystems.js +2 -2
  56. package/dist/build/fetchModules.js +18 -4
  57. package/dist/build/full/buildPages.js +16 -0
  58. package/dist/build/full/updateServerPackageJson.js +21 -0
  59. package/dist/build/jit/buildPageJit.js +46 -6
  60. package/dist/build/jit/collectSkeletonSourceFiles.js +14 -0
  61. package/dist/build/jit/pageContentKeys.js +1 -0
  62. package/dist/build/jit/shallowBuild.js +81 -1
  63. package/dist/build/jit/writePageJit.js +11 -8
  64. package/dist/build/registerModules.js +149 -42
  65. package/dist/build/validateCallAgentSteps.js +58 -0
  66. package/dist/build/validateConfig.js +4 -0
  67. package/dist/build/validateCronConfig.js +110 -0
  68. package/dist/build/validateRenderNotificationSteps.js +50 -0
  69. package/dist/build/writeApi.js +58 -1
  70. package/dist/build/writeI18n.js +8 -1
  71. package/dist/build/writeMcp.js +21 -0
  72. package/dist/build/writeNotifications.js +30 -0
  73. package/dist/build/writePluginImports/importPluginModule.js +39 -0
  74. package/dist/build/writePluginImports/writeActionSchemaMap.js +6 -7
  75. package/dist/build/writePluginImports/writeAuthImports.js +4 -0
  76. package/dist/build/writePluginImports/writeAvailableTypes.js +99 -0
  77. package/dist/build/writePluginImports/writeBlockSchemaMap.js +10 -9
  78. package/dist/build/writePluginImports/writeConnectionSchemaMap.js +101 -0
  79. package/dist/build/writePluginImports/writeGlobalsCss.js +20 -11
  80. package/dist/build/writePluginImports/writeNotificationImports.js +31 -0
  81. package/dist/build/writePluginImports/writeOperatorSchemaMap.js +6 -7
  82. package/dist/build/writePluginImports/writePluginImports.js +18 -4
  83. package/dist/build/writePluginImports/writeWebsocketImports.js +22 -0
  84. package/dist/build/writeWebsockets.js +30 -0
  85. package/dist/createContext.js +6 -1
  86. package/dist/defaultPackages.js +7 -1
  87. package/dist/defaultTypesMap.js +689 -386
  88. package/dist/dynamic/buildDynamicBlocks.js +120 -0
  89. package/dist/index.js +78 -8
  90. package/dist/indexDev.js +4 -0
  91. package/dist/lowdefySchema.js +861 -6
  92. package/dist/scripts/generateDefaultTypes.js +5 -2
  93. package/dist/test-utils/expectTerminates.js +33 -0
  94. package/dist/test-utils/runBuild.js +20 -1
  95. package/dist/test-utils/runBuildForSnapshots.js +34 -4
  96. package/dist/test-utils/testContext.js +8 -2
  97. package/dist/utils/countOperators.js +5 -17
  98. package/dist/utils/createPluginTypesMap.js +21 -0
  99. package/dist/utils/getCronEnvironmentNames.js +25 -0
  100. package/dist/utils/logCollectedErrors.js +44 -10
  101. package/dist/utils/serializeBuildException.js +29 -0
  102. package/dist/utils/validateCronExpression.js +131 -0
  103. package/dist/utils/validateId.js +11 -0
  104. package/dist/utils/writeBuildArtifact.js +5 -2
  105. package/package.json +66 -59
  106. package/dist/build/writePluginImports/writeServerExternalPackages.js +0 -121
  107. /package/dist/build/{resolveModuleOperators.js → buildRefs/scopeMenuItemIds.js} +0 -0
@@ -14,10 +14,9 @@
14
14
  limitations under the License.
15
15
  */ import path from 'path';
16
16
  import { serializer, type } from '@lowdefy/helpers';
17
- import { writeFile } from '@lowdefy/node-utils';
18
- import collectPageContent from '../collectPageContent.js';
17
+ import { writeFileIfChanged } from '@lowdefy/node-utils';
19
18
  import writeJs from '../buildJs/writeJs.js';
20
- async function writePageJit({ page, context }) {
19
+ async function writePageJit({ page, context, tailwindContent }) {
21
20
  // Write page JSON
22
21
  await context.writeBuildArtifact(`pages/${page.pageId}.json`, serializer.serializeToString(page ?? {}));
23
22
  // Write page request JSONs
@@ -43,10 +42,14 @@ async function writePageJit({ page, context }) {
43
42
  await writeJs({
44
43
  context
45
44
  });
46
- // Write per-page content file for Tailwind to scan class and property strings
47
- const pageContent = collectPageContent([
48
- page
49
- ]);
50
- await writeFile(path.join(context.directories.server, 'lowdefy-build', 'tailwind', `${encodeURIComponent(page.pageId)}.html`), '<!-- Generated by Lowdefy build -->\n' + (pageContent ?? ''));
45
+ // Write per-page content file for Tailwind to scan class and property strings.
46
+ // Content is collected by the caller before _js extraction so class
47
+ // candidates inside _js source are included. Skipping unchanged content lets
48
+ // the caller avoid touching the CSS recompilation trigger
49
+ // (tailwind-candidates.css) for builds that introduced no new class candidates.
50
+ const tailwindChanged = await writeFileIfChanged(path.join(context.directories.server, 'lowdefy-build', 'tailwind', `${encodeURIComponent(page.pageId)}.html`), '<!-- Generated by Lowdefy build -->\n' + (tailwindContent ?? ''));
51
+ return {
52
+ tailwindChanged
53
+ };
51
54
  }
52
55
  export default writePageJit;
@@ -14,15 +14,15 @@
14
14
  limitations under the License.
15
15
  */ import path from 'path';
16
16
  import semver from 'semver';
17
- import { type } from '@lowdefy/helpers';
17
+ import { ReservedKeyError, setKey, type } from '@lowdefy/helpers';
18
18
  import { ConfigError } from '@lowdefy/errors';
19
19
  import operators from '@lowdefy/operators-js/operators/build';
20
20
  import { resolve, WalkContext } from './buildRefs/walker.js';
21
21
  import getRefContent from './buildRefs/getRefContent.js';
22
22
  import makeRefDefinition from './buildRefs/makeRefDefinition.js';
23
- import evaluateStaticOperators from './buildRefs/evaluateStaticOperators.js';
24
23
  import collectDynamicIdentifiers from './collectDynamicIdentifiers.js';
25
24
  import validateOperatorsDynamic from './validateOperatorsDynamic.js';
25
+ import { makeShouldStop } from './buildRefs/deferredRegions.js';
26
26
  validateOperatorsDynamic({
27
27
  operators
28
28
  });
@@ -48,11 +48,69 @@ function validateRequiredVars(varDefs, consumerVars, entryId, source, prefix = '
48
48
  }
49
49
  }
50
50
  }
51
+ function getRuntimeOperatorKey(value) {
52
+ if (!type.isObject(value)) return null;
53
+ const nonTildeKeys = Object.keys(value).filter((k)=>!k.startsWith('~'));
54
+ if (nonTildeKeys.length === 1 && nonTildeKeys[0].startsWith('_')) return nonTildeKeys[0];
55
+ return null;
56
+ }
57
+ function suggestBuildOperator(operatorKey) {
58
+ // '_string.concat' -> '_build.string.concat', '_sum' -> '_build.sum'
59
+ return `_build.${operatorKey.slice(1)}`;
60
+ }
61
+ // Component bodies are preserved by config path (components.<i>.component), so
62
+ // bodies under an operator object at `components` itself or at an array element
63
+ // escape preservation and resolve eagerly during the manifest walk. That is
64
+ // harmless for static bodies (operator-composed component lists are supported —
65
+ // pinned by fixture 81-cross-module-build-op-components), but a _var in such a
66
+ // body resolves against the manifest walk's scope instead of the consumer's
67
+ // per-ref vars — a silently wrong value. Error on that combination explicitly.
68
+ // A _ref at either position is fully safe: it resolves top-down, so paths
69
+ // inside the ref'd file still match the preserve regex. An operator at
70
+ // components.<i>.component itself is inside the preserved body and is fine.
71
+ function findVarNode(value) {
72
+ if (type.isArray(value)) {
73
+ for (const item of value){
74
+ const found = findVarNode(item);
75
+ if (found) return found;
76
+ }
77
+ return null;
78
+ }
79
+ if (!type.isObject(value)) return null;
80
+ if (getRuntimeOperatorKey(value) === '_var') return value;
81
+ for (const key of Object.keys(value)){
82
+ const found = findVarNode(value[key]);
83
+ if (found) return found;
84
+ }
85
+ return null;
86
+ }
87
+ function assertStaticComponentsList({ components, entryId, filePath }) {
88
+ if (!type.isObject(components) && !type.isArray(components)) return;
89
+ const check = (value, position)=>{
90
+ const operatorKey = getRuntimeOperatorKey(value);
91
+ if (operatorKey && operatorKey !== '_ref' && findVarNode(value)) {
92
+ throw new ConfigError(`Module "${entryId}": _var inside an operator-generated components section ` + `cannot resolve per consumer. Found "${operatorKey}" at ${position} with a _var ` + `in its content. Define components whose bodies use _var as static ` + `{ id, component } list items so the bodies are preserved.`, {
93
+ filePath
94
+ });
95
+ }
96
+ };
97
+ check(components, '"components"');
98
+ if (type.isArray(components)) {
99
+ components.forEach((item, i)=>check(item, `"components.${i}"`));
100
+ }
101
+ }
51
102
  function validateVarTypes(varDefs, resolvedVarCache, entryId, source, prefix = '') {
52
103
  for (const [varName, varDef] of Object.entries(varDefs)){
53
104
  const fullName = prefix ? `${prefix}.${varName}` : varName;
54
105
  const value = resolvedVarCache[fullName];
55
106
  if (varDef.type && !type.isNone(value)) {
107
+ // A typed var must hold a concrete value — runtime operators are not allowed
108
+ // regardless of whether they are static-foldable or dynamic. Suggest the
109
+ // build-time equivalent so the user knows how to fix it.
110
+ const operatorKey = getRuntimeOperatorKey(value);
111
+ if (operatorKey) {
112
+ throw new ConfigError(`Module "${entryId}" (${source}) var "${fullName}" is typed "${varDef.type}" ` + `but received a runtime operator "${operatorKey}". ` + `Use the build-time equivalent "${suggestBuildOperator(operatorKey)}" instead.` + (varDef.description ? `\n - ${varDef.description}` : ''));
113
+ }
56
114
  if (type.typeOf(value) !== varDef.type) {
57
115
  throw new ConfigError(`Module "${entryId}" (${source}) var "${fullName}" must be type ` + `"${varDef.type}" but got "${type.typeOf(value)}".` + (varDef.description ? `\n - ${varDef.description}` : ''));
58
116
  }
@@ -69,9 +127,6 @@ async function resolveLocalManifest({ entry, resolvedPaths, context }) {
69
127
  if (entry.id.includes('/')) {
70
128
  throw new ConfigError(`Module entry id "${entry.id}" must not contain '/'. ` + `Use a flat identifier like "team-users".`);
71
129
  }
72
- if (entry.id === '__proto__' || entry.id === 'constructor' || entry.id === 'prototype') {
73
- throw new ConfigError(`Module entry id "${entry.id}" is a reserved name.`);
74
- }
75
130
  if (!entry.source || !type.isString(entry.source)) {
76
131
  throw new ConfigError(`Module entry "${entry.id}": 'source' is required and must be a string.`);
77
132
  }
@@ -88,7 +143,17 @@ async function resolveLocalManifest({ entry, resolvedPaths, context }) {
88
143
  refDef,
89
144
  referencedFrom: null
90
145
  });
91
- // Run walker with shouldStop preserving content that may contain cross-module refs
146
+ if (type.isObject(content)) {
147
+ assertStaticComponentsList({
148
+ components: content.components,
149
+ entryId: entry.id,
150
+ filePath: moduleYamlPath
151
+ });
152
+ }
153
+ // Phase A — header parse: walk only the static header keys (dependencies,
154
+ // plugins, secrets, vars definitions — defaults record-ified). Content
155
+ // sections stay raw parsed YAML; Phase C.5 record-ifies exportables and
156
+ // Phase D resolves the rest.
92
157
  const ctx = new WalkContext({
93
158
  buildContext: context,
94
159
  refId: refDef.id,
@@ -96,6 +161,9 @@ async function resolveLocalManifest({ entry, resolvedPaths, context }) {
96
161
  vars: {},
97
162
  moduleRoot,
98
163
  packageRoot,
164
+ // The entry object is created after this walk; deferred-record coordinates
165
+ // still need the owning entry id.
166
+ entryId: entry.id,
99
167
  path: '',
100
168
  currentFile: moduleYamlPath,
101
169
  refChain: new Set(refDef.path ? [
@@ -103,16 +171,9 @@ async function resolveLocalManifest({ entry, resolvedPaths, context }) {
103
171
  ] : []),
104
172
  operators,
105
173
  env: process.env,
174
+ lowdefyApp: context.appMeta,
106
175
  dynamicIdentifiers,
107
- shouldStop: (childPath)=>{
108
- if (/^vars(\.[^.]+\.properties)*\.[^.]+\.default(\..*)?$/.test(childPath)) return 'preserve';
109
- if (/^components\.\d+\.component$/.test(childPath)) return 'preserve';
110
- if (/^pages(\..*)?$/.test(childPath)) return 'preserve';
111
- if (/^api(\..*)?$/.test(childPath)) return 'preserve';
112
- if (/^connections(\..*)?$/.test(childPath)) return 'preserve';
113
- if (/^menus\.\d+\.links$/.test(childPath)) return 'preserve';
114
- return false;
115
- }
176
+ shouldStop: makeShouldStop('header')
116
177
  });
117
178
  const manifest = await resolve(content, ctx);
118
179
  // Parse dependencies array from manifest
@@ -151,22 +212,74 @@ async function resolveLocalManifest({ entry, resolvedPaths, context }) {
151
212
  throw new ConfigError(`Module "${entry.id}" requires plugin "${plugin.name}" version "${plugin.version}" ` + `but the app has version "${appVersion}" installed. ` + `Update the plugin to a compatible version.`);
152
213
  }
153
214
  }
154
- context.modules[entry.id] = {
155
- id: entry.id,
156
- source: entry.source,
157
- packageRoot,
215
+ try {
216
+ setKey(context.modules, entry.id, {
217
+ id: entry.id,
218
+ source: entry.source,
219
+ packageRoot,
220
+ moduleRoot,
221
+ isLocal,
222
+ consumerVars: entry.vars ?? {},
223
+ varDefs,
224
+ resolvedVarCache: {},
225
+ connections: entry.connections ?? {},
226
+ manifest,
227
+ dependencies,
228
+ moduleDependencies: entry.dependencies ?? {},
229
+ refDef
230
+ });
231
+ } catch (error) {
232
+ if (error instanceof ReservedKeyError) {
233
+ throw new ConfigError(`Module entry id "${entry.id}" is a reserved name.`, {
234
+ cause: error
235
+ });
236
+ }
237
+ throw error;
238
+ }
239
+ }
240
+ // Phase C.5 — record-ify exportables: walk only the components and menus
241
+ // sections in module-static scope (entry id known, but NO moduleEntry on the
242
+ // context — a _module.var at a structural position errors, because export ids
243
+ // must not vary per consumer). File refs are entered and static operators
244
+ // resolve; component bodies and menu links become records before any phase can
245
+ // consume them, so cross-entry consumption never races record creation.
246
+ async function recordifyExportables({ entryId, context }) {
247
+ const moduleEntry = context.modules[entryId];
248
+ const { manifest, packageRoot, moduleRoot, refDef } = moduleEntry;
249
+ const moduleYamlPath = path.join(moduleRoot, 'module.lowdefy.yaml');
250
+ if (type.isObject(manifest)) {
251
+ assertStaticComponentsList({
252
+ components: manifest.components,
253
+ entryId,
254
+ filePath: moduleYamlPath
255
+ });
256
+ }
257
+ const ctx = new WalkContext({
258
+ buildContext: context,
259
+ refId: refDef.id,
260
+ sourceRefId: null,
261
+ vars: {},
158
262
  moduleRoot,
159
- isLocal,
160
- consumerVars: entry.vars ?? {},
161
- varDefs,
162
- resolvedVarCache: {},
163
- connections: entry.connections ?? {},
164
- manifest,
165
- dependencies,
166
- moduleDependencies: entry.dependencies ?? {},
167
- refDef
168
- };
263
+ packageRoot,
264
+ entryId,
265
+ path: '',
266
+ currentFile: moduleYamlPath,
267
+ refChain: new Set(refDef.path ? [
268
+ refDef.path
269
+ ] : []),
270
+ operators,
271
+ env: process.env,
272
+ lowdefyApp: context.appMeta,
273
+ dynamicIdentifiers,
274
+ shouldStop: makeShouldStop('exportables')
275
+ });
276
+ moduleEntry.manifest = await resolve(manifest, ctx);
169
277
  }
278
+ // Phase D — manifest resolve: ONE full walk with the module entry in context.
279
+ // Pages, api, and connections resolve completely; component/menuLinks
280
+ // placeholders pass through the kind-aware dispatch untouched; varDefault
281
+ // placeholders keep their rule so the dispatch cannot force demand-only
282
+ // records.
170
283
  async function resolveFullManifest({ entryId, context }) {
171
284
  const moduleEntry = context.modules[entryId];
172
285
  const { manifest, packageRoot, moduleRoot, moduleDependencies, refDef } = moduleEntry;
@@ -187,24 +300,18 @@ async function resolveFullManifest({ entryId, context }) {
187
300
  ] : []),
188
301
  operators,
189
302
  env: process.env,
303
+ lowdefyApp: context.appMeta,
190
304
  dynamicIdentifiers,
191
- shouldStop: (childPath)=>{
192
- if (/^vars(\.[^.]+\.properties)*\.[^.]+\.default(\..*)?$/.test(childPath)) return 'preserve';
193
- if (/^components\.\d+\.component$/.test(childPath)) return 'preserve';
194
- return false;
195
- }
196
- });
197
- let resolved = await resolve(manifest, ctx);
198
- resolved = evaluateStaticOperators({
199
- context,
200
- input: resolved,
201
- refDef
305
+ shouldStop: makeShouldStop('manifest')
202
306
  });
307
+ const resolved = await resolve(manifest, ctx);
203
308
  // Filter null entries produced by _ref resolution failures
204
309
  for (const key of [
205
310
  'pages',
206
311
  'connections',
207
- 'api'
312
+ 'api',
313
+ 'agents',
314
+ 'notifications'
208
315
  ]){
209
316
  if (type.isArray(resolved[key])) {
210
317
  resolved[key] = resolved[key].filter((item)=>!type.isNone(item));
@@ -217,4 +324,4 @@ async function resolveFullManifest({ entryId, context }) {
217
324
  validateVarTypes(varDefs, moduleEntry.resolvedVarCache, entryId, moduleEntry.source);
218
325
  }
219
326
  }
220
- export { resolveLocalManifest, resolveFullManifest, validateRequiredVars };
327
+ export { resolveLocalManifest, recordifyExportables, resolveFullManifest, validateRequiredVars };
@@ -0,0 +1,58 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import { type } from '@lowdefy/helpers';
16
+ import { ConfigError, ConfigWarning } from '@lowdefy/errors';
17
+ function collectCallAgentSteps(routine, steps) {
18
+ if (type.isArray(routine)) {
19
+ routine.forEach((item)=>collectCallAgentSteps(item, steps));
20
+ return;
21
+ }
22
+ if (type.isObject(routine)) {
23
+ if (routine.type === 'CallAgent') {
24
+ steps.push(routine);
25
+ }
26
+ // Recurse into all values (handles control structures like :then, :else, :try, :catch)
27
+ Object.values(routine).forEach((value)=>collectCallAgentSteps(value, steps));
28
+ }
29
+ }
30
+ // Runs after buildAgents (needs context.agentIds and normalized agent.tools).
31
+ // Validates that CallAgent steps with a static agentId reference an existing
32
+ // agent, and warns when that agent has confirm tools — headless runs have no
33
+ // client to resolve an approval, so those tools auto-execute. Operator
34
+ // (dynamic) agentIds are skipped; they resolve at runtime.
35
+ function validateCallAgentSteps({ components, context }) {
36
+ (components.api ?? []).forEach((endpoint)=>{
37
+ const steps = [];
38
+ collectCallAgentSteps(endpoint.routine, steps);
39
+ steps.forEach((step)=>{
40
+ const agentId = step.properties?.agentId;
41
+ if (!type.isString(agentId)) return;
42
+ if (!context.agentIds?.has(agentId)) {
43
+ throw new ConfigError(`CallAgent step "${step.stepId}" at endpoint "${endpoint.endpointId}" references agent "${agentId}" which does not exist.`, {
44
+ configKey: step['~k']
45
+ });
46
+ }
47
+ const agent = (components.agents ?? []).find((a)=>a.agentId === agentId);
48
+ const hasConfirmTools = (agent?.tools ?? []).some((t)=>t.confirm);
49
+ if (hasConfirmTools) {
50
+ context.handleWarning(new ConfigWarning(`Agent "${agentId}" has tools with confirm: true, but tool approval is not supported when run from a CallAgent step. Tools will auto-execute.`, {
51
+ configKey: step['~k']
52
+ }));
53
+ }
54
+ });
55
+ });
56
+ return components;
57
+ }
58
+ export default validateCallAgentSteps;
@@ -13,6 +13,7 @@
13
13
  See the License for the specific language governing permissions and
14
14
  limitations under the License.
15
15
  */ import { type } from '@lowdefy/helpers';
16
+ import validateCronConfig from './validateCronConfig.js';
16
17
  function validateConfig({ components }) {
17
18
  if (type.isNone(components.config)) {
18
19
  components.config = {};
@@ -25,6 +26,9 @@ function validateConfig({ components }) {
25
26
  throw new Error('Base path must start with "/".');
26
27
  }
27
28
  }
29
+ validateCronConfig({
30
+ components
31
+ });
28
32
  return components;
29
33
  }
30
34
  export default validateConfig;
@@ -0,0 +1,110 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import { type } from '@lowdefy/helpers';
16
+ import { ConfigError } from '@lowdefy/errors';
17
+ import getCronEnvironmentNames from '../utils/getCronEnvironmentNames.js';
18
+ // Environment names become a path segment (/api/cron-forward/<environment>/<endpointId>).
19
+ const environmentNamePattern = /^[A-Za-z0-9\-_]+$/;
20
+ function isAbsoluteHttpUrl(value) {
21
+ try {
22
+ const url = new URL(value);
23
+ return url.protocol === 'http:' || url.protocol === 'https:';
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+ // Returns true when the environment is forwarded to (has a url), false for the host environment.
29
+ function validateCronEnvironment({ name, environment, configKey }) {
30
+ if (name === 'default') {
31
+ throw new ConfigError('Cron environment name "default" is reserved: it names the schedules every environment inherits.', {
32
+ configKey
33
+ });
34
+ }
35
+ if (!environmentNamePattern.test(name)) {
36
+ throw new ConfigError(`Cron environment name "${name}" is invalid. Environment names must only contain A-Z, a-z, 0-9, "-" and "_".`, {
37
+ configKey
38
+ });
39
+ }
40
+ if (!type.isObject(environment)) {
41
+ throw new ConfigError(`Cron environment "${name}" is not an object.`, {
42
+ received: environment,
43
+ configKey
44
+ });
45
+ }
46
+ if (!type.isUndefined(environment.enabled) && !type.isBoolean(environment.enabled)) {
47
+ throw new ConfigError(`Cron environment "${name}" enabled is not a boolean.`, {
48
+ received: environment.enabled,
49
+ configKey: environment['~k'] ?? configKey
50
+ });
51
+ }
52
+ if (type.isUndefined(environment.url)) {
53
+ if (!type.isUndefined(environment.secret)) {
54
+ throw new ConfigError(`Cron environment "${name}" has a secret but no url. The environment without a url is the deployment that runs the crons and needs no secret.`, {
55
+ configKey: environment['~k'] ?? configKey
56
+ });
57
+ }
58
+ return false;
59
+ }
60
+ if (!type.isString(environment.url) || !isAbsoluteHttpUrl(environment.url)) {
61
+ throw new ConfigError(`Cron environment "${name}" url is not an absolute http(s) URL, e.g. "https://staging.example.com".`, {
62
+ received: environment.url,
63
+ configKey: environment['~k'] ?? configKey
64
+ });
65
+ }
66
+ if (!type.isString(environment.secret) || environment.secret === '') {
67
+ throw new ConfigError(`Cron environment "${name}" has a url but no secret. Set secret to the Lowdefy secret name that holds the environment's CRON_SECRET.`, {
68
+ received: environment.secret,
69
+ configKey: environment['~k'] ?? configKey
70
+ });
71
+ }
72
+ return true;
73
+ }
74
+ // Validates config.cron: the deployment environments scheduled endpoints run in. Exactly one
75
+ // environment has no url — the deployment whose crons Vercel fires — and every other environment
76
+ // is forwarded to from there, so it needs a url and the secret that authorizes the forwarded call.
77
+ function validateCronConfig({ components }) {
78
+ const cron = components.config.cron;
79
+ if (type.isUndefined(cron)) return;
80
+ const configKey = cron['~k'] ?? components.config['~k'];
81
+ if (!type.isObject(cron)) {
82
+ throw new ConfigError('lowdefy.config.cron is not an object.', {
83
+ received: cron,
84
+ configKey
85
+ });
86
+ }
87
+ if (!type.isObject(cron.environments)) {
88
+ throw new ConfigError('lowdefy.config.cron.environments is not an object.', {
89
+ received: cron.environments,
90
+ configKey
91
+ });
92
+ }
93
+ const names = getCronEnvironmentNames(cron.environments);
94
+ if (names.length === 0) {
95
+ throw new ConfigError('lowdefy.config.cron.environments declares no environments.', {
96
+ configKey
97
+ });
98
+ }
99
+ const hosts = names.filter((name)=>!validateCronEnvironment({
100
+ name,
101
+ environment: cron.environments[name],
102
+ configKey
103
+ }));
104
+ if (hosts.length !== 1) {
105
+ throw new ConfigError(`Exactly one environment in lowdefy.config.cron.environments must have no url (the deployment that runs the crons). Received ${JSON.stringify(hosts)}.`, {
106
+ configKey
107
+ });
108
+ }
109
+ }
110
+ export default validateCronConfig;
@@ -0,0 +1,50 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import { type } from '@lowdefy/helpers';
16
+ import { ConfigError } from '@lowdefy/errors';
17
+ function collectRenderNotificationSteps(routine, steps) {
18
+ if (type.isArray(routine)) {
19
+ routine.forEach((item)=>collectRenderNotificationSteps(item, steps));
20
+ return;
21
+ }
22
+ if (type.isObject(routine)) {
23
+ if (routine.type === 'RenderNotification') {
24
+ steps.push(routine);
25
+ }
26
+ // Recurse into all values (handles control structures like :then, :else, :try, :catch)
27
+ Object.values(routine).forEach((value)=>collectRenderNotificationSteps(value, steps));
28
+ }
29
+ }
30
+ // Runs after buildNotifications (needs context.notificationIds). Validates that
31
+ // RenderNotification steps with a static notificationId reference an existing
32
+ // notification. Operator (dynamic) notificationIds are skipped; they resolve at
33
+ // runtime.
34
+ function validateRenderNotificationSteps({ components, context }) {
35
+ (components.api ?? []).forEach((endpoint)=>{
36
+ const steps = [];
37
+ collectRenderNotificationSteps(endpoint.routine, steps);
38
+ steps.forEach((step)=>{
39
+ const notificationId = step.properties?.notificationId;
40
+ if (!type.isString(notificationId)) return;
41
+ if (!context.notificationIds?.has(notificationId)) {
42
+ throw new ConfigError(`RenderNotification step "${step.stepId}" at endpoint "${endpoint.endpointId}" references notification "${notificationId}" which does not exist.`, {
43
+ configKey: step['~k']
44
+ });
45
+ }
46
+ });
47
+ });
48
+ return components;
49
+ }
50
+ export default validateRenderNotificationSteps;
@@ -13,9 +13,62 @@
13
13
  See the License for the specific language governing permissions and
14
14
  limitations under the License.
15
15
  */ import { type, serializer } from '@lowdefy/helpers';
16
+ import getCronEnvironmentNames from '../utils/getCronEnvironmentNames.js';
16
17
  async function writeEndpoint({ endpoint, context }) {
17
18
  await context.writeBuildArtifact(`api/${endpoint.endpointId}.json`, serializer.serializeToString(endpoint ?? {}));
18
19
  }
20
+ // Flat manifest of every scheduled endpoint, consumed by the Vercel Build Output assembly to
21
+ // generate the `crons` array in config.json. The runtime reads schedules off the endpoint artifact
22
+ // directly, so it does not depend on this file.
23
+ //
24
+ // With config.cron.environments declared there is one entry per enabled environment and schedule:
25
+ // `environment` names it and `forward` is true for environments the production deployment forwards
26
+ // to (those with a url) — Vercel fires crons only on production, so every environment's schedules
27
+ // must be registered there.
28
+ function getSchedules({ endpoint, environment }) {
29
+ if (type.isArray(endpoint.schedules)) return endpoint.schedules;
30
+ return endpoint.schedules?.[environment] ?? [];
31
+ }
32
+ function pushSchedules({ endpoint, environment, forward }) {
33
+ return getSchedules({
34
+ endpoint,
35
+ environment
36
+ }).map((schedule)=>({
37
+ endpointId: endpoint.endpointId,
38
+ cron: schedule.cron,
39
+ payload: schedule.payload ?? {},
40
+ ...type.isUndefined(environment) ? {} : {
41
+ environment,
42
+ forward
43
+ }
44
+ }));
45
+ }
46
+ async function writeSchedulesManifest({ components, context }) {
47
+ const cronEnvironments = components.config?.cron?.environments;
48
+ const environmentNames = getCronEnvironmentNames(cronEnvironments);
49
+ const schedules = [];
50
+ (components.api ?? []).forEach((endpoint)=>{
51
+ if (environmentNames.length === 0) {
52
+ schedules.push(...pushSchedules({
53
+ endpoint
54
+ }));
55
+ return;
56
+ }
57
+ environmentNames.forEach((environment)=>{
58
+ const config = cronEnvironments[environment];
59
+ if (config.enabled === false) return;
60
+ schedules.push(...pushSchedules({
61
+ endpoint,
62
+ environment,
63
+ forward: !type.isUndefined(config.url)
64
+ }));
65
+ });
66
+ });
67
+ // Only emit the manifest when something is scheduled; the Vercel assembly treats a missing
68
+ // schedules.json as "no crons".
69
+ if (schedules.length === 0) return;
70
+ await context.writeBuildArtifact('schedules.json', serializer.serializeToString(schedules));
71
+ }
19
72
  async function writeApi({ components, context }) {
20
73
  if (type.isNone(components.api)) return;
21
74
  if (!type.isArray(components.api)) {
@@ -25,6 +78,10 @@ async function writeApi({ components, context }) {
25
78
  endpoint,
26
79
  context
27
80
  }));
28
- return Promise.all(writePromises);
81
+ await Promise.all(writePromises);
82
+ await writeSchedulesManifest({
83
+ components,
84
+ context
85
+ });
29
86
  }
30
87
  export default writeApi;
@@ -13,7 +13,7 @@
13
13
  See the License for the specific language governing permissions and
14
14
  limitations under the License.
15
15
  */ import { ConfigError, ConfigWarning } from '@lowdefy/errors';
16
- import { mergeObjects, serializer, type } from '@lowdefy/helpers';
16
+ import { isReserved, mergeObjects, serializer, type } from '@lowdefy/helpers';
17
17
  function collectPluginMessages({ messagesMap, declaredCodes }) {
18
18
  const collected = {};
19
19
  Object.values(messagesMap ?? {}).forEach((pluginCatalog)=>{
@@ -57,6 +57,13 @@ async function writeI18n({ components, context }) {
57
57
  configKey: locale?.['~k'] ?? i18n['~k']
58
58
  });
59
59
  }
60
+ // The code keys the collected plugin catalog and the merged messages
61
+ // artifact, both plain objects.
62
+ if (isReserved(locale.code)) {
63
+ throw new ConfigError(`Locale code "${locale.code}" in "config.i18n.locales" is a reserved name and cannot be used as a locale code.`, {
64
+ configKey: locale['~k']
65
+ });
66
+ }
60
67
  if (codes.has(locale.code)) {
61
68
  throw new ConfigError(`Duplicate locale code "${locale.code}" in "config.i18n.locales".`, {
62
69
  configKey: locale['~k']
@@ -0,0 +1,21 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import { serializer } from '@lowdefy/helpers';
16
+ // Always written, even unconfigured - the runtime reads mcp.configured and
17
+ // never needs an existence check on the artifact.
18
+ async function writeMcp({ components, context }) {
19
+ await context.writeBuildArtifact('mcp.json', serializer.serializeToString(components.mcp));
20
+ }
21
+ export default writeMcp;