0nmcp 1.6.0 → 1.7.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.
package/cli.js CHANGED
@@ -13,6 +13,7 @@
13
13
  * npx 0nmcp list List connected services
14
14
  * npx 0nmcp migrate Migrate from ~/.0nmcp to ~/.0n
15
15
  * npx 0nmcp engine Engine commands (import, verify, platforms, export, open)
16
+ * npx 0nmcp app Application commands (run, build, inspect, validate, list)
16
17
  *
17
18
  * ═══════════════════════════════════════════════════════════════════════════
18
19
  */
@@ -87,6 +88,14 @@ ${c.bright}Engine commands:${c.reset}
87
88
  ${c.cyan}npx 0nmcp engine export${c.reset} Export .0n bundle (AI Brain)
88
89
  ${c.cyan}npx 0nmcp engine open <bundle>${c.reset} Open/inspect a .0n bundle
89
90
 
91
+ ${c.bright}Application commands:${c.reset}
92
+
93
+ ${c.cyan}npx 0nmcp app run <file>${c.reset} Start application server
94
+ ${c.cyan}npx 0nmcp app build${c.reset} Build application bundle
95
+ ${c.cyan}npx 0nmcp app inspect <file>${c.reset} Show application metadata
96
+ ${c.cyan}npx 0nmcp app validate <file>${c.reset} Validate application structure
97
+ ${c.cyan}npx 0nmcp app list${c.reset} List installed applications
98
+
90
99
  ${c.bright}Serve options:${c.reset}
91
100
 
92
101
  ${c.cyan}npx 0nmcp serve --port 3000 --host 0.0.0.0${c.reset}
@@ -217,6 +226,13 @@ ${c.bright}Links:${c.reset}
217
226
  }
218
227
  }
219
228
 
229
+ // App
230
+ if (command === 'app') {
231
+ console.log(BANNER);
232
+ await handleApp(args.slice(1));
233
+ return;
234
+ }
235
+
220
236
  // Engine
221
237
  if (command === 'engine') {
222
238
  console.log(BANNER);
@@ -256,6 +272,7 @@ function initDotOn() {
256
272
  path.join(DOT_ON_DIR, 'snapshots'),
257
273
  path.join(DOT_ON_DIR, 'history'),
258
274
  path.join(DOT_ON_DIR, 'cache'),
275
+ path.join(DOT_ON_DIR, 'apps'),
259
276
  ];
260
277
 
261
278
  console.log(`${c.bright}Initializing ~/.0n directory...${c.reset}\n`);
@@ -677,4 +694,223 @@ async function handleEngine(args) {
677
694
  process.exit(1);
678
695
  }
679
696
 
697
+ async function handleApp(args) {
698
+ const sub = args[0];
699
+
700
+ if (!sub || sub === 'help') {
701
+ console.log(`${c.bright}Application Engine — Build & Run .0n Applications${c.reset}\n`);
702
+ console.log(` ${c.cyan}run <file>${c.reset} Start application server`);
703
+ console.log(` ${c.cyan}build${c.reset} Build application bundle`);
704
+ console.log(` ${c.cyan}inspect <file>${c.reset} Show application metadata`);
705
+ console.log(` ${c.cyan}validate <file>${c.reset} Validate application structure`);
706
+ console.log(` ${c.cyan}list${c.reset} List installed applications\n`);
707
+ return;
708
+ }
709
+
710
+ if (sub === 'run') {
711
+ const appFile = args[1];
712
+ if (!appFile) {
713
+ console.log(`${c.red}Usage: npx 0nmcp app run <file.0n> [--port 3000] [--passphrase <pw>]${c.reset}`);
714
+ process.exit(1);
715
+ }
716
+
717
+ const port = Number(getFlag(args, '--port', 3000));
718
+ let passphrase = getFlag(args, '--passphrase', null);
719
+
720
+ if (!passphrase) {
721
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
722
+ const ask = (q) => new Promise(resolve => rl.question(q, resolve));
723
+ passphrase = await ask('Application passphrase: ');
724
+ rl.close();
725
+ }
726
+
727
+ if (!passphrase) {
728
+ console.log(`${c.red}Passphrase required.${c.reset}`);
729
+ process.exit(1);
730
+ }
731
+
732
+ try {
733
+ const { openApplication } = await import('./engine/app-builder.js');
734
+ const { Application } = await import('./engine/application.js');
735
+ const { ApplicationServer } = await import('./engine/app-server.js');
736
+
737
+ const bundle = openApplication(appFile, passphrase);
738
+ const application = new Application(bundle, { passphrase });
739
+ const server = new ApplicationServer(application);
740
+
741
+ await server.start({ port });
742
+
743
+ // Keep alive
744
+ process.on('SIGINT', async () => {
745
+ console.log(`\n${c.yellow}Shutting down...${c.reset}`);
746
+ await server.stop();
747
+ process.exit(0);
748
+ });
749
+ } catch (err) {
750
+ console.log(`${c.red}Error: ${err.message}${c.reset}`);
751
+ process.exit(1);
752
+ }
753
+ return;
754
+ }
755
+
756
+ if (sub === 'build') {
757
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
758
+ const ask = (q) => new Promise(resolve => rl.question(q, resolve));
759
+
760
+ try {
761
+ const appName = await ask('Application name: ') || '0n Application';
762
+ const passphrase = await ask('Bundle passphrase: ');
763
+ if (!passphrase) {
764
+ console.log(`${c.red}Passphrase required.${c.reset}`);
765
+ rl.close();
766
+ process.exit(1);
767
+ }
768
+ rl.close();
769
+
770
+ const { createApplication } = await import('./engine/app-builder.js');
771
+
772
+ // Load local connections
773
+ const connectionsDir = path.join(DOT_ON_DIR, 'connections');
774
+ const connections = {};
775
+ if (fs.existsSync(connectionsDir)) {
776
+ const files = fs.readdirSync(connectionsDir).filter(f => f.endsWith('.0n') || f.endsWith('.0n.json'));
777
+ for (const file of files) {
778
+ try {
779
+ const data = JSON.parse(fs.readFileSync(path.join(connectionsDir, file), 'utf8'));
780
+ if (data.$0n?.sealed) continue;
781
+ connections[data.service] = {
782
+ credentials: data.auth?.credentials || {},
783
+ name: data.$0n?.name || data.service,
784
+ authType: data.auth?.type || 'api_key',
785
+ environment: data.environment || 'production',
786
+ };
787
+ } catch { /* skip */ }
788
+ }
789
+ }
790
+
791
+ // Load local workflows
792
+ const { loadLocalWorkflows } = await import('./engine/index.js');
793
+ const workflows = loadLocalWorkflows();
794
+
795
+ console.log(`\n${c.bright}Building application...${c.reset}\n`);
796
+
797
+ const result = createApplication({
798
+ name: appName,
799
+ passphrase,
800
+ connections,
801
+ workflows,
802
+ });
803
+
804
+ console.log(`${c.green}${c.bright}Application built!${c.reset}\n`);
805
+ console.log(` Path: ${result.path}`);
806
+ console.log(` Connections: ${result.manifest.connection_count}`);
807
+ console.log(` Workflows: ${result.manifest.workflow_count}`);
808
+ console.log(`\n${c.bright}Run with:${c.reset} ${c.cyan}npx 0nmcp app run ${result.path}${c.reset}`);
809
+ } catch (err) {
810
+ console.log(`${c.red}Error: ${err.message}${c.reset}`);
811
+ process.exit(1);
812
+ }
813
+ return;
814
+ }
815
+
816
+ if (sub === 'inspect') {
817
+ const appFile = args[1];
818
+ if (!appFile) {
819
+ console.log(`${c.red}Usage: npx 0nmcp app inspect <file.0n>${c.reset}`);
820
+ process.exit(1);
821
+ }
822
+
823
+ try {
824
+ const { inspectApplication } = await import('./engine/app-builder.js');
825
+ const info = inspectApplication(appFile);
826
+
827
+ console.log(`${c.bright}Application: ${info.name}${c.reset}\n`);
828
+ console.log(` Version: ${info.version}`);
829
+ console.log(` Author: ${info.author || '—'}`);
830
+ console.log(` Created: ${info.created}`);
831
+ if (info.description) console.log(` Description: ${info.description}`);
832
+ console.log(`\n Connections: ${info.connections.map(c2 => c2.service).join(', ') || 'none'}`);
833
+ console.log(` Workflows: ${info.workflows.join(', ') || 'none'}`);
834
+ console.log(` Operations: ${info.operations.join(', ') || 'none'}`);
835
+ console.log(` Endpoints: ${info.endpoints.join(', ') || 'none'}`);
836
+ console.log(` Automations: ${info.automations.join(', ') || 'none'}`);
837
+ } catch (err) {
838
+ console.log(`${c.red}Error: ${err.message}${c.reset}`);
839
+ process.exit(1);
840
+ }
841
+ return;
842
+ }
843
+
844
+ if (sub === 'validate') {
845
+ const appFile = args[1];
846
+ if (!appFile) {
847
+ console.log(`${c.red}Usage: npx 0nmcp app validate <file.0n>${c.reset}`);
848
+ process.exit(1);
849
+ }
850
+
851
+ try {
852
+ const { validateApplication } = await import('./engine/app-builder.js');
853
+ const data = JSON.parse(fs.readFileSync(appFile, 'utf8'));
854
+ const result = validateApplication(data);
855
+
856
+ if (result.valid) {
857
+ console.log(`${c.green}${c.bright}Application is valid!${c.reset}`);
858
+ } else {
859
+ console.log(`${c.red}${c.bright}Validation failed:${c.reset}\n`);
860
+ for (const err of result.errors) {
861
+ console.log(` ${c.red}●${c.reset} ${err}`);
862
+ }
863
+ }
864
+
865
+ if (result.warnings?.length > 0) {
866
+ console.log(`\n${c.yellow}Warnings:${c.reset}`);
867
+ for (const w of result.warnings) {
868
+ console.log(` ${c.yellow}○${c.reset} ${w}`);
869
+ }
870
+ }
871
+ } catch (err) {
872
+ console.log(`${c.red}Error: ${err.message}${c.reset}`);
873
+ process.exit(1);
874
+ }
875
+ return;
876
+ }
877
+
878
+ if (sub === 'list') {
879
+ const appsDir = path.join(DOT_ON_DIR, 'apps');
880
+ if (!fs.existsSync(appsDir)) {
881
+ console.log(`${c.yellow}No applications installed.${c.reset}`);
882
+ return;
883
+ }
884
+
885
+ const files = fs.readdirSync(appsDir).filter(f => f.endsWith('.0n') || f.endsWith('.0n.json'));
886
+ if (files.length === 0) {
887
+ console.log(`${c.yellow}No applications installed.${c.reset}`);
888
+ return;
889
+ }
890
+
891
+ console.log(`${c.bright}Installed Applications:${c.reset}\n`);
892
+
893
+ for (const file of files) {
894
+ try {
895
+ const data = JSON.parse(fs.readFileSync(path.join(appsDir, file), 'utf8'));
896
+ if (data.$0n?.type !== 'application') continue;
897
+
898
+ const wfCount = Object.keys(data.workflows || {}).length;
899
+ const epCount = Object.keys(data.endpoints || {}).length;
900
+ console.log(` ${c.green}●${c.reset} ${c.bright}${data.$0n.name || file}${c.reset} v${data.$0n.version || '1.0.0'}`);
901
+ console.log(` ${wfCount} workflows, ${epCount} endpoints`);
902
+ console.log(` ${file}`);
903
+ console.log('');
904
+ } catch {
905
+ console.log(` ${c.red}●${c.reset} ${file} (error reading)`);
906
+ }
907
+ }
908
+ return;
909
+ }
910
+
911
+ console.log(`${c.red}Unknown app command: ${sub}${c.reset}`);
912
+ console.log(`Run ${c.cyan}npx 0nmcp app help${c.reset} for usage`);
913
+ process.exit(1);
914
+ }
915
+
680
916
  main().catch(console.error);
package/connections.js CHANGED
@@ -18,6 +18,7 @@ const SNAPSHOTS_DIR = join(DOT_ON, "snapshots");
18
18
  const HISTORY_DIR = join(DOT_ON, "history");
19
19
  const CACHE_DIR = join(DOT_ON, "cache");
20
20
  const PLUGINS_DIR = join(DOT_ON, "plugins");
21
+ const APPS_DIR = join(DOT_ON, "apps");
21
22
  const CONFIG_FILE = join(DOT_ON, "config.json");
22
23
 
23
24
  // Legacy path for migration
@@ -28,7 +29,7 @@ const LEGACY_FILE = join(LEGACY_DIR, "connections.json");
28
29
  * Initialize the ~/.0n/ directory structure.
29
30
  */
30
31
  export function initDotOn() {
31
- const dirs = [DOT_ON, CONNECTIONS_DIR, WORKFLOWS_DIR, SNAPSHOTS_DIR, HISTORY_DIR, CACHE_DIR, PLUGINS_DIR];
32
+ const dirs = [DOT_ON, CONNECTIONS_DIR, WORKFLOWS_DIR, SNAPSHOTS_DIR, HISTORY_DIR, CACHE_DIR, PLUGINS_DIR, APPS_DIR];
32
33
  for (const dir of dirs) {
33
34
  if (!existsSync(dir)) {
34
35
  mkdirSync(dir, { recursive: true });
@@ -331,3 +332,4 @@ export const CONNECTIONS_PATH = CONNECTIONS_DIR;
331
332
  export const HISTORY_PATH = HISTORY_DIR;
332
333
  export const WORKFLOWS_PATH = WORKFLOWS_DIR;
333
334
  export const SNAPSHOTS_PATH = SNAPSHOTS_DIR;
335
+ export const APPS_PATH = APPS_DIR;
@@ -0,0 +1,318 @@
1
+ // ============================================================
2
+ // 0nMCP -Engine: Application Builder
3
+ // ============================================================
4
+ // Creates, opens, inspects, and validates .0n application
5
+ // bundles ($0n.type: "application"). Analogous to bundler.js
6
+ // but for the full Application Engine format.
7
+ //
8
+ // Patent Pending: US Provisional Patent Application #63/968,814
9
+ // ============================================================
10
+
11
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
12
+ import { join, basename } from "path";
13
+ import { createHash } from "crypto";
14
+ import { homedir } from "os";
15
+ import { sealPortable, unsealPortable } from "./cipher-portable.js";
16
+ import { validateOperations } from "./operations.js";
17
+
18
+ const APPS_DIR = join(homedir(), ".0n", "apps");
19
+
20
+ /**
21
+ * Compute SHA-256 checksum of a string.
22
+ */
23
+ function sha256(data) {
24
+ return createHash("sha256").update(data).digest("hex");
25
+ }
26
+
27
+ /**
28
+ * Create a .0n application bundle.
29
+ *
30
+ * @param {object} options
31
+ * @param {string} options.name -Application name
32
+ * @param {string} options.passphrase -Encryption passphrase
33
+ * @param {Record<string, object>} [options.connections] -{ service: { credentials, authType?, ... } }
34
+ * @param {Record<string, object>} [options.operations] -Operation definitions
35
+ * @param {Record<string, object>} [options.workflows] -Workflow definitions
36
+ * @param {Record<string, object>} [options.endpoints] -Endpoint definitions
37
+ * @param {Record<string, object>} [options.automations] -Automation definitions
38
+ * @param {object} [options.environment] -{ variables, secrets, settings, feature_flags }
39
+ * @param {string} [options.output] -Output file path
40
+ * @param {string} [options.description]
41
+ * @param {string} [options.author]
42
+ * @param {string} [options.version]
43
+ * @returns {{ bundle: object, path: string, manifest: object }}
44
+ */
45
+ export function createApplication(options) {
46
+ const {
47
+ name = "0n Application",
48
+ passphrase,
49
+ connections = {},
50
+ operations = {},
51
+ workflows = {},
52
+ endpoints = {},
53
+ automations = {},
54
+ environment = {},
55
+ output,
56
+ description = "",
57
+ author = "",
58
+ version = "1.0.0",
59
+ } = options;
60
+
61
+ if (!passphrase) {
62
+ throw new Error("Passphrase is required to create an application bundle.");
63
+ }
64
+
65
+ const now = new Date().toISOString();
66
+
67
+ // Seal connections
68
+ const bundleConnections = [];
69
+ for (const [service, conn] of Object.entries(connections)) {
70
+ const credJson = JSON.stringify(conn.credentials || conn);
71
+ const { sealed } = sealPortable(credJson, passphrase);
72
+
73
+ bundleConnections.push({
74
+ service,
75
+ name: conn.name || service,
76
+ environment: conn.environment || "production",
77
+ auth_type: conn.authType || conn.auth_type || "api_key",
78
+ credential_keys: Object.keys(conn.credentials || conn),
79
+ sealed: true,
80
+ vault: {
81
+ data: sealed,
82
+ algorithm: "aes-256-gcm",
83
+ kdf: "pbkdf2-sha512-100k",
84
+ portable: true,
85
+ },
86
+ });
87
+ }
88
+
89
+ // Seal environment secrets
90
+ const bundleEnvironment = {
91
+ variables: environment.variables || {},
92
+ secrets: {},
93
+ settings: environment.settings || {},
94
+ feature_flags: environment.feature_flags || {},
95
+ };
96
+
97
+ if (environment.secrets) {
98
+ for (const [key, val] of Object.entries(environment.secrets)) {
99
+ const { sealed } = sealPortable(String(val), passphrase);
100
+ bundleEnvironment.secrets[key] = sealed;
101
+ }
102
+ }
103
+
104
+ // Build manifest
105
+ const manifest = {
106
+ bundle_version: "1.0.0",
107
+ generator: "0nmcp-engine/1.7.0",
108
+ type: "application",
109
+ connection_count: bundleConnections.length,
110
+ workflow_count: Object.keys(workflows).length,
111
+ operation_count: Object.keys(operations).length,
112
+ endpoint_count: Object.keys(endpoints).length,
113
+ automation_count: Object.keys(automations).length,
114
+ services: bundleConnections.map(c => c.service),
115
+ encryption: {
116
+ method: "portable",
117
+ algorithm: "aes-256-gcm",
118
+ kdf: "pbkdf2-sha512-100k",
119
+ },
120
+ };
121
+
122
+ // Assemble bundle
123
+ const bundle = {
124
+ $0n: {
125
+ type: "application",
126
+ version,
127
+ name,
128
+ description,
129
+ author,
130
+ created: now,
131
+ updated: now,
132
+ },
133
+ connections: bundleConnections,
134
+ environment: bundleEnvironment,
135
+ operations,
136
+ workflows,
137
+ endpoints,
138
+ automations,
139
+ platforms: {},
140
+ includes: [],
141
+ manifest,
142
+ };
143
+
144
+ // Compute checksums
145
+ manifest.checksums = {
146
+ connections: `sha256:${sha256(JSON.stringify(bundleConnections))}`,
147
+ operations: `sha256:${sha256(JSON.stringify(operations))}`,
148
+ workflows: `sha256:${sha256(JSON.stringify(workflows))}`,
149
+ endpoints: `sha256:${sha256(JSON.stringify(endpoints))}`,
150
+ automations: `sha256:${sha256(JSON.stringify(automations))}`,
151
+ };
152
+
153
+ // Write to file
154
+ if (!existsSync(APPS_DIR)) mkdirSync(APPS_DIR, { recursive: true });
155
+ const ts = now.replace(/[:.]/g, "-").slice(0, 19);
156
+ const safeName = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/-+$/, "");
157
+ const outPath = output || join(APPS_DIR, `${safeName}-${ts}.0n`);
158
+ const outDir = join(outPath, "..");
159
+ if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
160
+
161
+ writeFileSync(outPath, JSON.stringify(bundle, null, 2));
162
+
163
+ return { bundle, path: outPath, manifest };
164
+ }
165
+
166
+ /**
167
+ * Open a .0n application bundle and return parsed data.
168
+ *
169
+ * @param {string} bundlePath -Path to .0n application file
170
+ * @param {string} passphrase -Decryption passphrase
171
+ * @returns {object} Parsed and decrypted bundle
172
+ */
173
+ export function openApplication(bundlePath, passphrase) {
174
+ const raw = readFileSync(bundlePath, "utf-8");
175
+ const bundle = JSON.parse(raw);
176
+
177
+ if (!bundle.$0n || bundle.$0n.type !== "application") {
178
+ throw new Error(`Not a .0n application file. Type: ${bundle.$0n?.type || "unknown"}`);
179
+ }
180
+
181
+ // Verify connections can be decrypted
182
+ for (const conn of bundle.connections || []) {
183
+ if (conn.sealed && conn.vault?.data) {
184
+ try {
185
+ unsealPortable(conn.vault.data, passphrase);
186
+ } catch {
187
+ throw new Error(`Failed to decrypt connection "${conn.service}" -wrong passphrase.`);
188
+ }
189
+ }
190
+ }
191
+
192
+ return bundle;
193
+ }
194
+
195
+ /**
196
+ * Inspect an application bundle without passphrase.
197
+ *
198
+ * @param {string} bundlePath
199
+ * @returns {object} Application metadata
200
+ */
201
+ export function inspectApplication(bundlePath) {
202
+ const raw = readFileSync(bundlePath, "utf-8");
203
+ const bundle = JSON.parse(raw);
204
+
205
+ if (!bundle.$0n || bundle.$0n.type !== "application") {
206
+ throw new Error(`Not a .0n application file. Type: ${bundle.$0n?.type || "unknown"}`);
207
+ }
208
+
209
+ return {
210
+ name: bundle.$0n.name,
211
+ version: bundle.$0n.version,
212
+ description: bundle.$0n.description,
213
+ author: bundle.$0n.author,
214
+ created: bundle.$0n.created,
215
+ updated: bundle.$0n.updated,
216
+ connections: (bundle.connections || []).map(c => ({
217
+ service: c.service,
218
+ name: c.name,
219
+ sealed: c.sealed,
220
+ credential_keys: c.credential_keys,
221
+ })),
222
+ workflows: Object.keys(bundle.workflows || {}),
223
+ operations: Object.keys(bundle.operations || {}),
224
+ endpoints: Object.keys(bundle.endpoints || {}),
225
+ automations: Object.keys(bundle.automations || {}),
226
+ environment: {
227
+ variables: Object.keys(bundle.environment?.variables || {}),
228
+ secrets: Object.keys(bundle.environment?.secrets || {}),
229
+ settings: bundle.environment?.settings || {},
230
+ feature_flags: bundle.environment?.feature_flags || {},
231
+ },
232
+ manifest: bundle.manifest,
233
+ };
234
+ }
235
+
236
+ /**
237
+ * Validate an application bundle's cross-references.
238
+ * Ensures endpoints reference valid workflows, workflows reference valid operations, etc.
239
+ *
240
+ * @param {object} bundle -Parsed application bundle
241
+ * @returns {{ valid: boolean, errors: string[], warnings: string[] }}
242
+ */
243
+ export function validateApplication(bundle) {
244
+ const errors = [];
245
+ const warnings = [];
246
+
247
+ if (!bundle.$0n || bundle.$0n.type !== "application") {
248
+ return { valid: false, errors: ["Not a valid .0n application bundle."], warnings };
249
+ }
250
+
251
+ const workflows = bundle.workflows || {};
252
+ const operations = bundle.operations || {};
253
+ const endpoints = bundle.endpoints || {};
254
+ const automations = bundle.automations || {};
255
+
256
+ // Validate operations
257
+ if (Object.keys(operations).length > 0) {
258
+ const opValidation = validateOperations(operations);
259
+ errors.push(...opValidation.errors);
260
+ }
261
+
262
+ // Validate endpoints → workflows
263
+ for (const [route, ep] of Object.entries(endpoints)) {
264
+ if (ep.handler) continue; // Built-in handler (e.g., "health")
265
+
266
+ if (ep.workflow && !workflows[ep.workflow]) {
267
+ errors.push(`Endpoint "${route}" references unknown workflow: "${ep.workflow}"`);
268
+ }
269
+ }
270
+
271
+ // Validate automations → workflows
272
+ for (const [id, auto] of Object.entries(automations)) {
273
+ if (auto.workflow && !workflows[auto.workflow]) {
274
+ errors.push(`Automation "${id}" references unknown workflow: "${auto.workflow}"`);
275
+ }
276
+
277
+ if (auto.type === "schedule" && auto.config?.cron) {
278
+ // Validate cron expression (import synchronously -already loaded)
279
+ try {
280
+ const parts = auto.config.cron.trim().split(/\s+/);
281
+ if (parts.length !== 5) {
282
+ errors.push(`Automation "${id}" has invalid cron: must have 5 fields`);
283
+ }
284
+ } catch (err) {
285
+ errors.push(`Automation "${id}" has invalid cron: ${err.message}`);
286
+ }
287
+ }
288
+ }
289
+
290
+ // Validate workflow steps → operations
291
+ for (const [wfId, wf] of Object.entries(workflows)) {
292
+ if (!wf.steps || !Array.isArray(wf.steps)) {
293
+ errors.push(`Workflow "${wfId}" has no steps array`);
294
+ continue;
295
+ }
296
+
297
+ for (const step of wf.steps) {
298
+ if (step.operation && !operations[step.operation]) {
299
+ errors.push(`Workflow "${wfId}" step "${step.id}" references unknown operation: "${step.operation}"`);
300
+ }
301
+ }
302
+ }
303
+
304
+ // Warnings for unused operations
305
+ const usedOps = new Set();
306
+ for (const wf of Object.values(workflows)) {
307
+ for (const step of wf.steps || []) {
308
+ if (step.operation) usedOps.add(step.operation);
309
+ }
310
+ }
311
+ for (const opId of Object.keys(operations)) {
312
+ if (!usedOps.has(opId)) {
313
+ warnings.push(`Operation "${opId}" is defined but never referenced by any workflow`);
314
+ }
315
+ }
316
+
317
+ return { valid: errors.length === 0, errors, warnings };
318
+ }