producs 1.0.1 → 3.0.1

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/index.js CHANGED
@@ -1,185 +1,1123 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ // ═══════════════════════════════════════════════════════════════════════════════
5
+ // ProDucs — Professional Documentation Shell DSL v3.0
6
+ //
7
+ // Pipe syntax: #$Cmd1 | #$Cmd2 | #$Cmd3
8
+ // Block syntax: #$Block['arg'] … #$End_Block
9
+ // Variables: ${name}
10
+ // Comments: // line comment
11
+ //
12
+ // Branching model (no If/Else):
13
+ // #$Set['x','10']
14
+ // #$Check_gt['x','5'] ← sets failed=0 (pass) or failed=1 (fail)
15
+ // #$Branch ← runs children only when failed=0
16
+ // #$Print['x is > 5']
17
+ // #$End_Branch
18
+ // #$BranchElse ← runs children only when failed=1
19
+ // #$Print['x is not > 5']
20
+ // #$End_BranchElse
21
+ // ═══════════════════════════════════════════════════════════════════════════════
22
+
1
23
  const { execSync } = require("child_process");
2
- const prompt = require("prompt-sync")({ sigint: true });
24
+ const crypto = require("crypto");
25
+ const fs = require("fs");
3
26
 
4
- class ProDucs {
5
- constructor() {
6
- this.commands = {
7
- ">": (text, value, promptText = "") => {
8
- const result = prompt(promptText || text || "");
9
- return { VALUE: result, failed: result ? 0 : 1 };
10
- },
11
- Check_equals: (text, value, expected) => ({
12
- VALUE: value.VALUE,
13
- failed: value.VALUE === expected ? 0 : 1
14
- }),
15
- Check_failed_error: (text, value, msg) => {
16
- if (value.failed) throw new Error(msg || "Check failed!");
17
- return value;
18
- },
19
- Shell_run: (text, value) => {
20
- execSync(value.VALUE, { stdio: "inherit" });
21
- return { VALUE: value.VALUE, failed: 0 };
22
- },
23
- Text: (text, value, str) => ({ VALUE: str || text, failed: 0 })
24
- };
25
- }
26
-
27
- // ──────────────────────────── TOKENIZER ────────────────────────────
28
- tokenize(src) {
29
- const tokens = [];
30
- let i = 0;
31
- const peek = () => src[i];
32
- const next = () => src[i++];
33
- const eof = () => i >= src.length;
34
- const skipSpaces = () => { while (!eof() && /\s/.test(peek())) next(); };
35
-
36
- const readIdent = () => {
37
- let name = "";
38
- while (!eof() && /[A-Za-z0-9_>]/.test(peek())) name += next();
39
- return name;
40
- };
41
-
42
- const readArgs = () => {
43
- const args = [];
44
- let buf = "";
45
- if (peek() === "[") {
46
- next();
47
- while (!eof()) {
48
- const c = next();
49
- if (c === "]") break;
50
- if (c === ",") {
51
- args.push(buf.trim().replace(/^'|'$/g, ""));
52
- buf = "";
53
- } else buf += c;
54
- }
55
- if (buf.trim()) args.push(buf.trim().replace(/^'|'$/g, ""));
56
- }
57
- return args;
58
- };
27
+ let promptSync;
28
+ try { promptSync = require("prompt-sync")({ sigint: true }); }
29
+ catch { promptSync = (msg) => { process.stdout.write(msg || ""); return ""; }; }
30
+
31
+ // ─── ANSI ─────────────────────────────────────────────────────────────────────
32
+ const C = {
33
+ reset:"\x1b[0m", bold:"\x1b[1m", dim:"\x1b[2m",
34
+ red:"\x1b[31m", green:"\x1b[32m", yellow:"\x1b[33m",
35
+ blue:"\x1b[34m", cyan:"\x1b[36m", white:"\x1b[37m",
36
+ bgred:"\x1b[41m",bggreen:"\x1b[42m",bgblue:"\x1b[44m",
37
+ };
38
+
39
+ // ─── JS reserved words (must not be injected as var names) ────────────────────
40
+ const RESERVED_JS = new Set([
41
+ "break","case","catch","class","const","continue","debugger","default",
42
+ "delete","do","else","export","extends","finally","for","function","if",
43
+ "import","in","instanceof","let","new","return","static","super","switch",
44
+ "this","throw","try","typeof","var","void","while","with","yield",
45
+ "enum","await","null","true","false","undefined","NaN","Infinity",
46
+ "constructor","prototype","__proto__",
47
+ ]);
48
+
49
+ // ─── Result ───────────────────────────────────────────────────────────────────
50
+ const mkResult = (VALUE="", failed=0, meta={}) =>
51
+ ({ VALUE: String(VALUE ?? ""), failed: Number(failed), ...meta });
52
+ const okResult = (VALUE="", meta={}) => mkResult(VALUE, 0, meta);
53
+ const failResult = (VALUE="", meta={}) => mkResult(VALUE, 1, meta);
54
+
55
+ const out = (s) => process.stdout.write(s);
56
+ const err = (s) => process.stderr.write(s);
57
+ const pad = (s, n) => String(s).padEnd(n).slice(0, n);
58
+
59
+ // ═══════════════════════════════════════════════════════════════════════════════
60
+ // ERRORS
61
+ // ═══════════════════════════════════════════════════════════════════════════════
62
+ class ProDucsError extends Error {
63
+ constructor(msg, loc) {
64
+ super(loc ? `[${loc.line}:${loc.col}] ${msg}` : msg);
65
+ this.name = "ProDucsError";
66
+ this.loc = loc;
67
+ }
68
+ }
69
+ class GotoSignal {
70
+ constructor(label) { this.label = label; }
71
+ }
72
+
73
+ // ═══════════════════════════════════════════════════════════════════════════════
74
+ // RUNTIME ENV (per-instance)
75
+ // ═══════════════════════════════════════════════════════════════════════════════
76
+ class RuntimeEnv {
77
+ constructor() { this.reset(); }
78
+
79
+ reset() {
80
+ this.vars = {};
81
+ this.labels = {}; // name → { node, list } — list is the actual array
82
+ this.log = [];
83
+ this.stepNum = 0;
84
+ this.startMs = Date.now();
85
+ this.debug = false;
86
+ this.strict = false;
87
+ this.colorOff = false;
88
+ }
89
+
90
+ col(code, s) { return this.colorOff ? s : `${code}${s}${C.reset}`; }
91
+ bold(s) { return this.col(C.bold, s); }
92
+ dim(s) { return this.col(C.dim, s); }
93
+ cyan(s) { return this.col(C.cyan, s); }
94
+ green(s) { return this.col(C.green,s); }
95
+ red(s) { return this.col(C.red, s); }
96
+ yellow(s) { return this.col(C.yellow,s);}
97
+ blue(s) { return this.col(C.blue, s); }
98
+
99
+ termW() { return process.stdout.columns || 80; }
100
+ hr(ch="─") { return ch.repeat(this.termW()); }
101
+
102
+ interpolate(str) {
103
+ return String(str ?? "").replace(/\$\{([^}]+)\}/g, (_, k) => this.vars[k] ?? "");
104
+ }
105
+
106
+ /** Produce safe `var x = "val";` bindings for new Function, skipping reserved names */
107
+ safeBindings(extra = {}) {
108
+ const SAFE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
109
+ return Object.entries({ ...this.vars, ...extra })
110
+ .filter(([k]) => SAFE.test(k) && !RESERVED_JS.has(k))
111
+ .map(([k, v]) => {
112
+ const n = Number(v);
113
+ const val = (v !== "" && !isNaN(n)) ? n : JSON.stringify(String(v));
114
+ return `var ${k} = ${val};`;
115
+ })
116
+ .join("\n");
117
+ }
118
+
119
+ logEntry(cmd, msg, ok=true) {
120
+ this.log.push({ t: Date.now() - this.startMs, cmd, msg, ok });
121
+ if (this.debug) err(this.dim(`[DBG] ${cmd}: ${msg}`) + "\n");
122
+ }
123
+ }
124
+
125
+ // ═══════════════════════════════════════════════════════════════════════════════
126
+ // TOKENIZER
127
+ // ═══════════════════════════════════════════════════════════════════════════════
128
+ class Tokenizer {
129
+ constructor(src) { this.src=src; this.i=0; this.line=1; this.col=1; }
59
130
 
60
- while (!eof()) {
61
- skipSpaces();
131
+ peek(n=0) { return this.src[this.i+n]; }
132
+ eof() { return this.i >= this.src.length; }
133
+ next() {
134
+ const c = this.src[this.i++];
135
+ if (c==="\n") { this.line++; this.col=1; } else this.col++;
136
+ return c;
137
+ }
138
+ skipSpaces() { while (!this.eof() && /[ \t\r\n]/.test(this.peek())) this.next(); }
139
+
140
+ readIdent() {
141
+ let s="";
142
+ while (!this.eof() && /[A-Za-z0-9_>!]/.test(this.peek())) s += this.next();
143
+ return s;
144
+ }
145
+
146
+ /** Read [...] args supporting single-quote, double-quote, or bare words */
147
+ readArgs() {
148
+ if (this.peek() !== "[") return [];
149
+ this.next();
150
+ const args=[]; let buf="", inQ=null, wasQ=false;
151
+ while (!this.eof()) {
152
+ const c = this.peek();
153
+ if (!inQ && (c==="'" || c==='"')) { inQ=c; wasQ=true; this.next(); continue; }
154
+ if (inQ && c===inQ) { inQ=null; this.next(); continue; }
155
+ if (!inQ && c===",") {
156
+ args.push(wasQ ? buf : buf.trim());
157
+ buf=""; wasQ=false; this.next(); continue;
158
+ }
159
+ if (!inQ && c==="]") { this.next(); break; }
160
+ buf += this.next();
161
+ }
162
+ // push last arg: if quoted preserve spaces, else trim; skip if empty bare word
163
+ if (wasQ || buf.trim()) args.push(wasQ ? buf : buf.trim());
164
+ return args;
165
+ }
62
166
 
63
- if (peek() === "#" && src[i + 1] === "$") {
64
- i += 2;
65
- if (src.startsWith("End_", i)) {
66
- i += 4;
67
- const name = readIdent();
68
- tokens.push({ type: "COMMAND_END", name });
167
+ tokenize() {
168
+ const tokens=[];
169
+ while (!this.eof()) {
170
+ // line comment
171
+ if (this.peek()==="/" && this.peek(1)==="/") {
172
+ while (!this.eof() && this.peek()!=="\n") this.next();
173
+ continue;
174
+ }
175
+ // explicit pipe | (not ||)
176
+ if (this.peek()==="|" && this.peek(1)!=="|") {
177
+ const loc={line:this.line,col:this.col};
178
+ this.next(); this.skipSpaces();
179
+ tokens.push({type:"PIPE",loc});
180
+ continue;
181
+ }
182
+ // command #$…
183
+ if (this.peek()==="#" && this.peek(1)==="$") {
184
+ this.next(); this.next();
185
+ const loc={line:this.line,col:this.col};
186
+ if (this.src.startsWith("End_", this.i)) {
187
+ this.i+=4;
188
+ tokens.push({type:"COMMAND_END", name:this.readIdent(), loc});
69
189
  } else {
70
- const name = readIdent();
71
- const args = readArgs();
72
- tokens.push({ type: "COMMAND_START", name, args });
190
+ const name=this.readIdent(), args=this.readArgs();
191
+ tokens.push({type:"COMMAND_START", name, args, loc});
73
192
  }
74
- } else {
75
- let text = "";
76
- while (!eof() && !(peek() === "#" && src[i + 1] === "$")) text += next();
77
- if (text.trim()) tokens.push({ type: "TEXT", value: text.trim() });
193
+ continue;
194
+ }
195
+ // raw text
196
+ let text="";
197
+ const loc={line:this.line,col:this.col};
198
+ while (!this.eof()
199
+ && !(this.peek()==="#" && this.peek(1)==="$")
200
+ && !(this.peek()==="/" && this.peek(1)==="/")
201
+ && !(this.peek()==="|" && this.peek(1)!=="|")) {
202
+ text += this.next();
78
203
  }
204
+ const trimmed=text.trim();
205
+ if (trimmed) tokens.push({type:"TEXT", value:trimmed, loc});
79
206
  }
80
-
81
207
  return tokens;
82
208
  }
209
+ }
83
210
 
84
- // ──────────────────────────── PARSER ────────────────────────────
85
- parse(tokens) {
86
- const root = [];
87
- const stack = [];
88
-
89
- for (let i = 0; i < tokens.length; i++) {
90
- const token = tokens[i];
91
-
92
- if (token.type === "COMMAND_START") {
93
- const node = {
94
- type: token.name,
95
- args: token.args,
96
- children: [],
97
- pipedCommands: []
98
- };
99
-
100
- // Inline (pipe) commands
101
- if (stack.length && !token.name.startsWith("End_")) {
102
- const parent = stack.at(-1);
103
- if (parent.type === ">" || parent.pipedCommands.length) {
104
- parent.pipedCommands.push(node);
105
- continue;
106
- }
107
- }
211
+ // ═══════════════════════════════════════════════════════════════════════════════
212
+ // PARSER
213
+ // ═══════════════════════════════════════════════════════════════════════════════
214
+ class Parser {
215
+ constructor(tokens) { this.tokens=tokens; this.pos=0; }
108
216
 
109
- // Block commands
110
- stack.push(node);
217
+ // Block commands: opened with #$X, closed with #$End_X
218
+ // Children are collected between open/close.
219
+ // Everything else is inline (pipeable).
220
+ static BLOCK_CMDS = new Set([
221
+ ">", "Branch", "BranchElse",
222
+ "Repeat", "ForEach",
223
+ "Try", "Catch",
224
+ "Section", "Step", "Group", "Table",
225
+ ]);
111
226
 
112
- } else if (token.type === "COMMAND_END") {
113
- const last = stack.pop();
227
+ peek() { return this.tokens[this.pos]; }
228
+ eof() { return this.pos >= this.tokens.length; }
229
+ mkNode(tok) {
230
+ return { type:tok.name, args:tok.args, loc:tok.loc, children:[], pipes:[] };
231
+ }
232
+
233
+ collectPipes(head) {
234
+ while (!this.eof() && this.peek().type==="PIPE") {
235
+ const pipeTok = this.tokens[this.pos++];
236
+ const next = this.peek();
237
+ if (!next || next.type!=="COMMAND_START")
238
+ throw new SyntaxError(`[${pipeTok.loc.line}:${pipeTok.loc.col}] Expected command after |`);
239
+ if (Parser.BLOCK_CMDS.has(next.name))
240
+ throw new SyntaxError(`[${next.loc.line}:${next.loc.col}] Block command '${next.name}' cannot be piped`);
241
+ this.pos++;
242
+ head.pipes.push(this.mkNode(next));
243
+ }
244
+ }
245
+
246
+ parse() {
247
+ const root=[], stack=[];
248
+ while (!this.eof()) {
249
+ const tok = this.tokens[this.pos++];
250
+
251
+ if (tok.type==="TEXT") {
252
+ const node={type:"Text", value:tok.value, loc:tok.loc};
253
+ (stack.length ? stack.at(-1).children : root).push(node);
254
+ continue;
255
+ }
256
+ if (tok.type==="PIPE")
257
+ throw new SyntaxError(`[${tok.loc.line}:${tok.loc.col}] Unexpected | — must follow a command`);
258
+
259
+ if (tok.type==="COMMAND_END") {
260
+ const last=stack.pop();
114
261
  if (!last)
115
- throw new Error(`Unexpected #$End_${token.name}`);
116
- if (last.type !== token.name)
117
- throw new Error(`Mismatched end: expected #$End_${last.type}, got #$End_${token.name}`);
118
- if (stack.length) stack.at(-1).children.push(last);
119
- else root.push(last);
120
- } else if (token.type === "TEXT") {
121
- const node = { type: "Text", value: token.value };
122
- if (stack.length) stack.at(-1).children.push(node);
123
- else root.push(node);
262
+ throw new SyntaxError(`[${tok.loc.line}:${tok.loc.col}] Unexpected #$End_${tok.name}`);
263
+ if (last.type!==tok.name)
264
+ throw new SyntaxError(`[${tok.loc.line}:${tok.loc.col}] Mismatched: expected #$End_${last.type}, got #$End_${tok.name}`);
265
+ (stack.length ? stack.at(-1).children : root).push(last);
266
+ continue;
124
267
  }
125
- }
126
268
 
127
- if (stack.length) throw new Error(`Unclosed command: ${stack.at(-1).type}`);
269
+ if (tok.type==="COMMAND_START") {
270
+ const node=this.mkNode(tok);
271
+ if (Parser.BLOCK_CMDS.has(tok.name)) {
272
+ stack.push(node);
273
+ } else {
274
+ this.collectPipes(node);
275
+ (stack.length ? stack.at(-1).children : root).push(node);
276
+ }
277
+ }
278
+ }
279
+ if (stack.length)
280
+ throw new SyntaxError(`Unclosed: #$${stack.at(-1).type} at line ${stack.at(-1).loc?.line}`);
128
281
  return root;
129
282
  }
283
+ }
284
+
285
+ // ═══════════════════════════════════════════════════════════════════════════════
286
+ // COMMAND REGISTRY
287
+ // ═══════════════════════════════════════════════════════════════════════════════
288
+ class CommandRegistry {
289
+ constructor(env) { this.env=env; this._cmds=new Map(); this._registerAll(); }
130
290
 
131
- astify(text) {
132
- return this.parse(this.tokenize(text));
291
+ register(name, fn, meta={}) { this._cmds.set(name,{fn,...meta}); return this; }
292
+ get(name) { return this._cmds.get(name); }
293
+ has(name) { return this._cmds.has(name); }
294
+ list() { return [...this._cmds.keys()].sort(); }
295
+
296
+ resolveText(node) {
297
+ const tc=node.children?.find(c=>c.type==="Text");
298
+ return tc ? this.env.interpolate(tc.value) : "";
133
299
  }
300
+ resolveArg(a) { return this.env.interpolate(a ?? ""); }
301
+ resolveArgs(args) { return (args||[]).map(a=>this.resolveArg(a)); }
134
302
 
135
- // ──────────────────────────── EXECUTION ────────────────────────────
136
- executeNode(node, input = { VALUE: "", failed: 0 }) {
137
- // Handle text nodes directly
138
- if (node.type === "Text")
139
- return { VALUE: console.log(node.value), failed: 0 };
303
+ // Safe expression evaluator — vars injected via safeBindings
304
+ evalExpr(expr, extra={}) {
305
+ const bindings = this.env.safeBindings(extra);
306
+ // eslint-disable-next-line no-new-func
307
+ return new Function(`"use strict";\n${bindings}\nreturn (${expr});`)();
308
+ }
140
309
 
141
- const cmd = this.commands[node.type];
142
- if (!cmd) throw new Error(`Unknown command: ${node.type}`);
310
+ // Coerce a value from env.vars to a number for comparisons
311
+ numVar(name) {
312
+ const v = this.env.vars[name];
313
+ if (v === undefined) throw new ProDucsError(`Variable not found: ${name}`);
314
+ const n = Number(v);
315
+ if (isNaN(n)) throw new ProDucsError(`Variable '${name}' = '${v}' is not numeric`);
316
+ return n;
317
+ }
143
318
 
144
- // Get the first text child as `text`
145
- const textChild = node.children.find(c => c.type === "Text");
146
- const text = textChild ? textChild.value : "";
319
+ _registerAll() {
320
+ const env=this.env, R=this;
321
+ const itp =(s) => env.interpolate(s);
322
+ const rA =(args) => R.resolveArgs(args);
323
+ const rT =(node) => R.resolveText(node);
147
324
 
148
- // Base execution (text, value, ...args)
149
- let current = cmd(text, input, ...node.args);
325
+ // ══════════════════════════════════════════════════════════════════════════
326
+ // I / O
327
+ // ══════════════════════════════════════════════════════════════════════════
150
328
 
151
- // Inline piped commands
152
- for (const pipe of node.pipedCommands) {
153
- const fn = this.commands[pipe.type];
154
- if (!fn) throw new Error(`Unknown piped command: ${pipe.type}`);
155
- const pipeText = pipe.children.find(c => c.type === "Text")?.value || "";
156
- current = fn(pipeText, current, ...pipe.args);
157
- }
329
+ R.register(">", (node,input) => {
330
+ const [promptText=""] = rA(node.args);
331
+ const msg = promptText || rT(node) || "";
332
+ const val = promptSync(msg ? env.cyan(msg)+" " : "");
333
+ if (val===null) throw new Error("Aborted (Ctrl-C)");
334
+ return val.trim() ? okResult(val.trim()) : failResult("",{reason:"empty"});
335
+ }, { doc:"Prompt user for input" });
158
336
 
159
- // Block children
160
- for (const child of node.children) {
161
- if (child.type !== "Text") current = this.executeNode(child, current);
162
- }
337
+ R.register("Print", (node,input) => {
338
+ const [str=""] = rA(node.args);
339
+ const msg = node.args.length > 0 ? str : (rT(node) || input.VALUE);
340
+ out(msg+"\n"); return okResult(msg);
341
+ }, { doc:"Print arg or VALUE to stdout" });
163
342
 
164
- return current;
343
+ R.register("Warn", (node,input) => {
344
+ const [str=""] = rA(node.args);
345
+ const msg = str || input.VALUE;
346
+ err(env.yellow("⚠ WARN: "+msg)+"\n");
347
+ return mkResult(msg,1);
348
+ }, { doc:"Print yellow warning to stderr (sets failed=1)" });
349
+
350
+ R.register("Error_print", (node,input) => {
351
+ const [str=""] = rA(node.args);
352
+ const msg = str || input.VALUE;
353
+ err(env.red("✖ ERROR: "+msg)+"\n");
354
+ return failResult(msg);
355
+ }, { doc:"Print red error to stderr" });
356
+
357
+ R.register("Pause", (node,input) => {
358
+ const [msg="Press ENTER to continue…"] = rA(node.args);
359
+ promptSync(env.dim(msg)); return okResult(input.VALUE);
360
+ }, { doc:"Block until ENTER pressed" });
361
+
362
+ R.register("Input", (node,input) => {
363
+ const [promptText=""] = rA(node.args);
364
+ const val = promptSync(promptText ? env.cyan(promptText)+" " : "");
365
+ if (val===null) throw new Error("Aborted (Ctrl-C)");
366
+ return val.trim() ? okResult(val.trim()) : failResult("",{reason:"empty"});
367
+ }, { doc:"Input['prompt'] — prompt for input, pipeable inline (VALUE = trimmed input)" });
368
+
369
+ R.register("Confirm", (node,input) => {
370
+ const [msg="Continue?",def="y"] = rA(node.args);
371
+ const ans = promptSync(env.cyan(msg)+env.dim(` [${def==="y"?"Y/n":"y/N"}] `)) || def;
372
+ return mkResult(ans.trim(), /^y(es)?$/i.test(ans.trim()) ? 0 : 1);
373
+ }, { doc:"y/n prompt — Confirm['message','default']" });
374
+
375
+ R.register("Select", (node,input) => {
376
+ const [label="Choose",...opts] = rA(node.args);
377
+ out(env.bold(label)+"\n");
378
+ opts.forEach((o,i)=>out(` ${env.cyan(String(i+1))}. ${o}\n`));
379
+ const ans = promptSync(env.dim("Enter number: ")) || "";
380
+ const idx = parseInt(ans.trim(),10)-1;
381
+ return (idx<0||idx>=opts.length) ? failResult(ans,{reason:"invalid"}) : okResult(opts[idx]);
382
+ }, { doc:"Select['label','opt1','opt2',...] — numbered menu" });
383
+
384
+ // ══════════════════════════════════════════════════════════════════════════
385
+ // VARIABLES
386
+ // ══════════════════════════════════════════════════════════════════════════
387
+
388
+ R.register("Set", (node,input) => {
389
+ const [name,val=""] = rA(node.args);
390
+ if (!name) return failResult(input.VALUE,{reason:"Set requires a name"});
391
+ const value = val!=="" ? val : input.VALUE;
392
+ env.vars[name]=value;
393
+ env.logEntry("Set",`${name}=${value}`);
394
+ return okResult(value);
395
+ }, { doc:"Set['name','value'] or pipe VALUE into variable" });
396
+
397
+ R.register("Get", (node, input) => {
398
+ const [name=""] = rA(node.args);
399
+ const val = env.vars[name];
400
+ if (val === undefined) return failResult("",{reason:`variable ${name} not set`});
401
+ return okResult(val);
402
+ }, { doc:"Get['name'] → VALUE" });
403
+
404
+
405
+ R.register("Unset", (node,input) => {
406
+ const [name=""] = rA(node.args);
407
+ delete env.vars[name]; return okResult(input.VALUE);
408
+ }, { doc:"Delete a variable" });
409
+
410
+ R.register("Export", (node,input) => {
411
+ const [name,val=""] = rA(node.args);
412
+ const value = val!=="" ? val : input.VALUE;
413
+ process.env[name]=value; env.vars[name]=value;
414
+ return okResult(value);
415
+ }, { doc:"Export variable to process.env" });
416
+
417
+ R.register("Env_get", (node,input) => {
418
+ const [key=""] = rA(node.args);
419
+ const val = process.env[key] ?? "";
420
+ return val ? okResult(val) : failResult("",{reason:`env ${key} not set`});
421
+ }, { doc:"Get a process.env variable → VALUE" });
422
+
423
+ R.register("Env_set", (node,input) => {
424
+ const [key=""] = rA(node.args);
425
+ process.env[key]=input.VALUE; return okResult(input.VALUE);
426
+ }, { doc:"Set process.env[key] = VALUE" });
427
+
428
+ // ══════════════════════════════════════════════════════════════════════════
429
+ // CHECKS — all set failed=0 (pass) or failed=1 (fail)
430
+ // String checks operate on VALUE.
431
+ // Numeric / variable checks take variable names as args.
432
+ // ══════════════════════════════════════════════════════════════════════════
433
+
434
+ // ── String checks (on VALUE) ──────────────────────────────────────────────
435
+
436
+ R.register("Check_eq", (node,input) => {
437
+ const [expected=""] = rA(node.args);
438
+ const ok = input.VALUE === expected;
439
+ env.logEntry("Check_eq",`"${input.VALUE}" === "${expected}" → ${ok}`);
440
+ return mkResult(input.VALUE, ok?0:1);
441
+ }, { doc:"Pass if VALUE === arg[0]" });
442
+
443
+ R.register("Check_neq", (node,input) => {
444
+ const [expected=""] = rA(node.args);
445
+ return mkResult(input.VALUE, input.VALUE!==expected?0:1);
446
+ }, { doc:"Pass if VALUE !== arg[0]" });
447
+
448
+ R.register("Check_contains", (node,input) => {
449
+ const [needle=""] = rA(node.args);
450
+ return mkResult(input.VALUE, input.VALUE.includes(needle)?0:1);
451
+ }, { doc:"Pass if VALUE contains arg[0]" });
452
+
453
+ R.register("Check_not_contains", (node,input) => {
454
+ const [needle=""] = rA(node.args);
455
+ return mkResult(input.VALUE, !input.VALUE.includes(needle)?0:1);
456
+ }, { doc:"Pass if VALUE does NOT contain arg[0]" });
457
+
458
+ R.register("Check_starts", (node,input) => {
459
+ const [prefix=""] = rA(node.args);
460
+ return mkResult(input.VALUE, input.VALUE.startsWith(prefix)?0:1);
461
+ }, { doc:"Pass if VALUE starts with arg[0]" });
462
+
463
+ R.register("Check_ends", (node,input) => {
464
+ const [suffix=""] = rA(node.args);
465
+ return mkResult(input.VALUE, input.VALUE.endsWith(suffix)?0:1);
466
+ }, { doc:"Pass if VALUE ends with arg[0]" });
467
+
468
+ R.register("Check_matches", (node,input) => {
469
+ const [pattern=".*",flags=""] = rA(node.args);
470
+ return mkResult(input.VALUE, new RegExp(pattern,flags).test(input.VALUE)?0:1);
471
+ }, { doc:"Pass if VALUE matches regex arg[0]" });
472
+
473
+ R.register("Check_empty", (node,input) => {
474
+ return mkResult(input.VALUE, input.VALUE===""?0:1);
475
+ }, { doc:"Pass if VALUE is empty string" });
476
+
477
+ R.register("Check_nonempty", (node,input) => {
478
+ return mkResult(input.VALUE, input.VALUE!==""?0:1);
479
+ }, { doc:"Pass if VALUE is not empty" });
480
+
481
+ // ── Numeric checks (arg = variable name, compared against a literal) ──────
482
+
483
+ R.register("Check_gt", (node,input) => {
484
+ // Check_gt['varname','number'] OR Check_gt['number'] (uses VALUE as number)
485
+ const [a,b] = rA(node.args);
486
+ if (b!==undefined) {
487
+ const left=Number(env.vars[a]??a), right=Number(b);
488
+ return mkResult(input.VALUE, left>right?0:1);
489
+ }
490
+ return mkResult(input.VALUE, Number(input.VALUE)>Number(a)?0:1);
491
+ }, { doc:"Pass if var/VALUE > number — Check_gt['var','n'] or Check_gt['n']" });
492
+
493
+ R.register("Check_gte", (node,input) => {
494
+ const [a,b] = rA(node.args);
495
+ if (b!==undefined) {
496
+ return mkResult(input.VALUE, Number(env.vars[a]??a)>=Number(b)?0:1);
497
+ }
498
+ return mkResult(input.VALUE, Number(input.VALUE)>=Number(a)?0:1);
499
+ }, { doc:"Pass if var/VALUE >= number" });
500
+
501
+ R.register("Check_lt", (node,input) => {
502
+ const [a,b] = rA(node.args);
503
+ if (b!==undefined) {
504
+ return mkResult(input.VALUE, Number(env.vars[a]??a)<Number(b)?0:1);
505
+ }
506
+ return mkResult(input.VALUE, Number(input.VALUE)<Number(a)?0:1);
507
+ }, { doc:"Pass if var/VALUE < number" });
508
+
509
+ R.register("Check_lte", (node,input) => {
510
+ const [a,b] = rA(node.args);
511
+ if (b!==undefined) {
512
+ return mkResult(input.VALUE, Number(env.vars[a]??a)<=Number(b)?0:1);
513
+ }
514
+ return mkResult(input.VALUE, Number(input.VALUE)<=Number(a)?0:1);
515
+ }, { doc:"Pass if var/VALUE <= number" });
516
+
517
+ R.register("Check_num_eq", (node,input) => {
518
+ const [a,b] = rA(node.args);
519
+ if (b!==undefined) {
520
+ return mkResult(input.VALUE, Number(env.vars[a]??a)===Number(b)?0:1);
521
+ }
522
+ return mkResult(input.VALUE, Number(input.VALUE)===Number(a)?0:1);
523
+ }, { doc:"Pass if var/VALUE == number (numeric equality)" });
524
+
525
+ R.register("Check_num_neq", (node,input) => {
526
+ const [a,b] = rA(node.args);
527
+ if (b!==undefined) {
528
+ return mkResult(input.VALUE, Number(env.vars[a]??a)!==Number(b)?0:1);
529
+ }
530
+ return mkResult(input.VALUE, Number(input.VALUE)!==Number(a)?0:1);
531
+ }, { doc:"Pass if var/VALUE != number" });
532
+
533
+ R.register("Check_between", (node,input) => {
534
+ const [a,lo,hi] = rA(node.args);
535
+ if (lo!==undefined && hi!==undefined) {
536
+ const v=Number(env.vars[a]??a);
537
+ return mkResult(input.VALUE, v>=Number(lo)&&v<=Number(hi)?0:1);
538
+ }
539
+ const v=Number(input.VALUE);
540
+ return mkResult(input.VALUE, v>=Number(a)&&v<=Number(lo)?0:1);
541
+ }, { doc:"Pass if var/VALUE between lo and hi (inclusive) — Check_between['var','lo','hi']" });
542
+
543
+ // ── Boolean / logical checks ──────────────────────────────────────────────
544
+
545
+ R.register("Check_failed", (node,input) => {
546
+ return mkResult(input.VALUE, input.failed?0:1);
547
+ }, { doc:"Pass if previous result was failed" });
548
+
549
+ R.register("Check_passed", (node,input) => {
550
+ return mkResult(input.VALUE, !input.failed?0:1);
551
+ }, { doc:"Pass if previous result was passed" });
552
+
553
+ R.register("Check_var_eq", (node,input) => {
554
+ // Compare two variables by name
555
+ const [a,b] = rA(node.args);
556
+ return mkResult(input.VALUE, (env.vars[a]??"")===(env.vars[b]??"")?0:1);
557
+ }, { doc:"Pass if var a === var b — Check_var_eq['a','b']" });
558
+
559
+ R.register("Check_var_neq", (node,input) => {
560
+ const [a,b] = rA(node.args);
561
+ return mkResult(input.VALUE, (env.vars[a]??"")===(env.vars[b]??"")?1:0);
562
+ }, { doc:"Pass if var a !== var b" });
563
+
564
+ // ── Error gates ───────────────────────────────────────────────────────────
565
+
566
+ R.register("Check_failed_error", (node,input) => {
567
+ const [msg="Check failed"] = rA(node.args);
568
+ if (input.failed) throw new ProDucsError(msg, node.loc);
569
+ return input;
570
+ }, { doc:"Throw if previous result is failed" });
571
+
572
+ R.register("Check_passed_error", (node,input) => {
573
+ const [msg="Unexpected pass"] = rA(node.args);
574
+ if (!input.failed) throw new ProDucsError(msg, node.loc);
575
+ return input;
576
+ }, { doc:"Throw if previous result passed" });
577
+
578
+ // ── Assert (arbitrary JS expression using vars) ───────────────────────────
579
+ R.register("Assert", (node,input) => {
580
+ const [expr="",msg="Assertion failed"] = rA(node.args);
581
+ try {
582
+ const ok = R.evalExpr(expr, {VALUE:input.VALUE});
583
+ if (!ok) throw new ProDucsError(msg, node.loc);
584
+ return input;
585
+ } catch(e) {
586
+ if (e instanceof ProDucsError) throw e;
587
+ throw new ProDucsError(`Assert eval error: ${e.message}`, node.loc);
588
+ }
589
+ }, { doc:"Assert['JS expr','msg'] — throw if expression is falsy" });
590
+
591
+ // ── Guards ────────────────────────────────────────────────────────────────
592
+ R.register("Require_env", (node,input) => {
593
+ const [key=""] = rA(node.args);
594
+ if (!process.env[key])
595
+ throw new ProDucsError(`Required env var not set: ${key}`, node.loc);
596
+ return okResult(process.env[key]);
597
+ }, { doc:"Halt if env var is missing" });
598
+
599
+ R.register("Require_cmd", (node,input) => {
600
+ const [cmd=""] = rA(node.args);
601
+ try { execSync(`command -v ${cmd}`,{stdio:"ignore"}); return okResult(cmd); }
602
+ catch { throw new ProDucsError(`Required command not found: ${cmd}`, node.loc); }
603
+ }, { doc:"Halt if shell command not in PATH" });
604
+
605
+ // ══════════════════════════════════════════════════════════════════════════
606
+ // SHELL
607
+ // ══════════════════════════════════════════════════════════════════════════
608
+
609
+ R.register("Shell_run", (node,input) => {
610
+ const cmd = itp(rT(node)||input.VALUE);
611
+ if (!cmd) return failResult("",{reason:"no command"});
612
+ try {
613
+ execSync(cmd,{stdio:"inherit",env:{...process.env,...env.vars}});
614
+ return okResult(cmd);
615
+ } catch(e) {
616
+ if (env.strict) throw e;
617
+ return failResult(cmd,{reason:e.message, code:e.status});
618
+ }
619
+ }, { doc:"Run VALUE or text child as shell command" });
620
+
621
+ R.register("Shell_capture", (node,input) => {
622
+ const cmd = itp(rT(node)||input.VALUE);
623
+ if (!cmd) return failResult("",{reason:"no command"});
624
+ try {
625
+ const stdout = execSync(cmd,{encoding:"utf8",env:{...process.env,...env.vars}});
626
+ return okResult(stdout.trim());
627
+ } catch(e) {
628
+ if (env.strict) throw e;
629
+ return failResult(e.stdout?.trim()||"",{reason:e.message});
630
+ }
631
+ }, { doc:"Run command, capture stdout → VALUE" });
632
+
633
+ // ══════════════════════════════════════════════════════════════════════════
634
+ // STRING OPS
635
+ // ══════════════════════════════════════════════════════════════════════════
636
+
637
+ R.register("Trim", (_,i)=>okResult(i.VALUE.trim()), {doc:"Trim whitespace from VALUE"});
638
+ R.register("Upper", (_,i)=>okResult(i.VALUE.toUpperCase()), {doc:"Uppercase VALUE"});
639
+ R.register("Lower", (_,i)=>okResult(i.VALUE.toLowerCase()), {doc:"Lowercase VALUE"});
640
+ R.register("Len", (_,i)=>okResult(String(i.VALUE.length)),{doc:"String length → VALUE"});
641
+
642
+ R.register("Replace", (node,input) => {
643
+ const [from="",to="",flags="g"] = rA(node.args);
644
+ return okResult(input.VALUE.replace(new RegExp(from,flags),to));
645
+ }, { doc:"Replace['pattern','replacement','flags']" });
646
+
647
+ R.register("Slice", (node,input) => {
648
+ const [start="0",end=""] = rA(node.args);
649
+ return okResult(end!=="" ? input.VALUE.slice(Number(start),Number(end)) : input.VALUE.slice(Number(start)));
650
+ }, { doc:"Slice['start','end']" });
651
+
652
+ R.register("Split", (node,input) => {
653
+ const [sep=","] = rA(node.args);
654
+ return okResult(JSON.stringify(input.VALUE.split(sep)));
655
+ }, { doc:"Split VALUE by sep → JSON array string" });
656
+
657
+ R.register("Join", (node,input) => {
658
+ const [sep=","] = rA(node.args);
659
+ let arr; try { arr=JSON.parse(input.VALUE); } catch { arr=[input.VALUE]; }
660
+ return okResult(arr.join(sep));
661
+ }, { doc:"Join JSON array VALUE by separator" });
662
+
663
+ R.register("Default", (node,input) => {
664
+ const [fallback=""] = rA(node.args);
665
+ return okResult(input.VALUE!=="" ? input.VALUE : fallback);
666
+ }, { doc:"Replace empty VALUE with fallback" });
667
+
668
+ R.register("Concat", (node,input) => {
669
+ const parts = rA(node.args);
670
+ return okResult(input.VALUE + parts.join(""));
671
+ }, { doc:"Concat['suffix1','suffix2',...] — append to VALUE" });
672
+
673
+ // ══════════════════════════════════════════════════════════════════════════
674
+ // NUMERIC / MATH
675
+ // ══════════════════════════════════════════════════════════════════════════
676
+
677
+ R.register("Int", (_,i)=>okResult(String(parseInt(i.VALUE,10)||0)));
678
+ R.register("Float", (_,i)=>okResult(String(parseFloat(i.VALUE)||0)));
679
+ R.register("Abs", (_,i)=>okResult(String(Math.abs(Number(i.VALUE)))));
680
+ R.register("Round", (_,i)=>okResult(String(Math.round(Number(i.VALUE)))));
681
+ R.register("Floor", (_,i)=>okResult(String(Math.floor(Number(i.VALUE)))));
682
+ R.register("Ceil", (_,i)=>okResult(String(Math.ceil(Number(i.VALUE)))));
683
+
684
+ R.register("Math", (node,input) => {
685
+ const [expr=""] = rA(node.args);
686
+ const expression = expr || input.VALUE;
687
+ try {
688
+ return okResult(String(R.evalExpr(expression,{VALUE:input.VALUE})));
689
+ } catch(e) {
690
+ return failResult("NaN",{reason:e.message});
691
+ }
692
+ }, { doc:"Math['expr'] — evaluate JS math using vars as identifiers" });
693
+
694
+ R.register("Inc", (node,input) => {
695
+ const [name="",step="1"] = rA(node.args);
696
+ const key = name || input.VALUE;
697
+ const newV = (Number(env.vars[key]||0)+Number(step));
698
+ env.vars[key]=String(newV);
699
+ return okResult(String(newV));
700
+ }, { doc:"Increment a variable: Inc['varname','step']" });
701
+
702
+ R.register("Dec", (node,input) => {
703
+ const [name="",step="1"] = rA(node.args);
704
+ const key = name || input.VALUE;
705
+ const newV = (Number(env.vars[key]||0)-Number(step));
706
+ env.vars[key]=String(newV);
707
+ return okResult(String(newV));
708
+ }, { doc:"Decrement a variable: Dec['varname','step']" });
709
+
710
+ // ══════════════════════════════════════════════════════════════════════════
711
+ // DATA / CRYPTO
712
+ // ══════════════════════════════════════════════════════════════════════════
713
+
714
+ R.register("Hash", (node,input) => {
715
+ const [algo="md5"] = rA(node.args);
716
+ try { return okResult(crypto.createHash(algo).update(input.VALUE).digest("hex")); }
717
+ catch(e) { return failResult("",{reason:e.message}); }
718
+ }, { doc:"Hash['algo'] VALUE (default md5)" });
719
+
720
+ R.register("Timestamp", (node,input) => {
721
+ const [fmt="iso"] = rA(node.args);
722
+ const now=new Date();
723
+ return okResult(fmt==="unix" ? String(Math.floor(now/1000)) : fmt==="ms" ? String(now.getTime()) : now.toISOString());
724
+ }, { doc:"Timestamp['iso'|'unix'|'ms']" });
725
+
726
+ R.register("Json_get", (node,input) => {
727
+ const [keyPath=""] = rA(node.args);
728
+ try {
729
+ const obj=JSON.parse(input.VALUE);
730
+ const val=keyPath.split(".").reduce((o,k)=>o?.[k],obj);
731
+ return val!==undefined ? okResult(JSON.stringify(val)) : failResult("",{reason:"key not found"});
732
+ } catch(e) { return failResult("",{reason:e.message}); }
733
+ }, { doc:"Get dot-path from JSON VALUE: Json_get['a.b.c']" });
734
+
735
+ R.register("Json_set", (node,input) => {
736
+ const [keyPath="",val=""] = rA(node.args);
737
+ try {
738
+ const obj=JSON.parse(input.VALUE);
739
+ const keys=keyPath.split(".");
740
+ let cur=obj;
741
+ for (let i=0;i<keys.length-1;i++) cur=(cur[keys[i]]??={});
742
+ cur[keys.at(-1)]=val;
743
+ return okResult(JSON.stringify(obj));
744
+ } catch(e) { return failResult(input.VALUE,{reason:e.message}); }
745
+ }, { doc:"Set dot-path in JSON VALUE: Json_set['a.b','val']" });
746
+
747
+ // ══════════════════════════════════════════════════════════════════════════
748
+ // FILE I/O
749
+ // ══════════════════════════════════════════════════════════════════════════
750
+
751
+ R.register("Read_file", (node,input) => {
752
+ const [p=""] = rA(node.args);
753
+ const path=p||input.VALUE;
754
+ try { return okResult(fs.readFileSync(path,"utf8").trim()); }
755
+ catch(e) { return failResult("",{reason:e.message}); }
756
+ }, { doc:"Read file → VALUE" });
757
+
758
+ R.register("Write_file", (node,input) => {
759
+ const [p=""] = rA(node.args);
760
+ fs.writeFileSync(p, rT(node)||input.VALUE, "utf8");
761
+ return okResult(p);
762
+ }, { doc:"Write VALUE to file: Write_file['path']" });
763
+
764
+ R.register("Append_file", (node,input) => {
765
+ const [p=""] = rA(node.args);
766
+ fs.appendFileSync(p, (rT(node)||input.VALUE)+"\n","utf8");
767
+ return okResult(p);
768
+ }, { doc:"Append VALUE to file" });
769
+
770
+ // ══════════════════════════════════════════════════════════════════════════
771
+ // CONTROL FLOW
772
+ // ══════════════════════════════════════════════════════════════════════════
773
+
774
+ R.register("Goto", (node,input) => {
775
+ const [label=""] = rA(node.args);
776
+ if (!label) throw new ProDucsError("Goto requires a label name", node.loc);
777
+ throw new GotoSignal(label);
778
+ }, { doc:"Goto['label'] — jump to matching Label" });
779
+
780
+ R.register("Label", (node,input) => input,
781
+ { doc:"Label['name'] — jump target for Goto" });
782
+
783
+ R.register("Exit", (node,input) => {
784
+ const [code="0"] = rA(node.args);
785
+ process.exit(Number(code)||0);
786
+ }, { doc:"Exit['code'] — terminate process" });
787
+
788
+ // ══════════════════════════════════════════════════════════════════════════
789
+ // TIMING
790
+ // ══════════════════════════════════════════════════════════════════════════
791
+
792
+ R.register("Wait", (node,input) => {
793
+ const [ms="0"] = rA(node.args);
794
+ const n=Math.max(0,Number(ms)||0);
795
+ const end=Date.now()+n;
796
+ while (Date.now()<end) {}
797
+ return okResult(String(n));
798
+ }, { doc:"Wait['ms'] — blocking sleep in milliseconds" });
799
+
800
+ // ══════════════════════════════════════════════════════════════════════════
801
+ // LOGGING
802
+ // ══════════════════════════════════════════════════════════════════════════
803
+
804
+ R.register("Log", (node,input) => {
805
+ const [label="LOG"] = rA(node.args);
806
+ env.logEntry(label, input.VALUE, !input.failed);
807
+ return input;
808
+ }, { doc:"Append VALUE+status to execution log" });
809
+
810
+ R.register("Dump_log", (node,input) => {
811
+ out(env.bold("\n─── Execution Log ───\n"));
812
+ env.log.forEach(e=>{
813
+ const ts=env.dim(`[+${e.t}ms]`);
814
+ const sym=e.ok ? env.green("✔") : env.red("✖");
815
+ out(`${ts} ${sym} ${env.bold(e.cmd)}: ${e.msg}\n`);
816
+ });
817
+ return okResult(String(env.log.length));
818
+ }, { doc:"Print execution log to stdout" });
819
+
820
+ // ══════════════════════════════════════════════════════════════════════════
821
+ // VISUAL
822
+ // ══════════════════════════════════════════════════════════════════════════
823
+
824
+ R.register("HR", (node,input) => {
825
+ const [ch="─"] = rA(node.args);
826
+ out(env.dim(env.hr(ch))+"\n"); return okResult(input.VALUE);
827
+ }, { doc:"Print full-width horizontal rule" });
828
+
829
+ R.register("Header", (node,input) => {
830
+ const [title=""] = rA(node.args);
831
+ const t=title||input.VALUE, line=env.hr("═");
832
+ out(`\n${env.col(C.bold+C.cyan,line)}\n`);
833
+ out(`${env.col(C.bold+C.cyan," "+t)}\n`);
834
+ out(`${env.col(C.bold+C.cyan,line)}\n\n`);
835
+ return okResult(t);
836
+ }, { doc:"Print prominent banner header" });
837
+
838
+ R.register("Color", (node,input) => {
839
+ const [colorName="white",str=""] = rA(node.args);
840
+ const msg=str||input.VALUE;
841
+ out(env.col(C[colorName]||C.white,msg)+"\n");
842
+ return okResult(msg);
843
+ }, { doc:"Color['name','text'] — print in ANSI color" });
844
+
845
+ R.register("Progress", (node,input) => {
846
+ const [pct="0",width="40",label=""] = rA(node.args);
847
+ const p=Math.min(100,Math.max(0,Number(pct)));
848
+ const w=Number(width), fill=Math.round(p/100*w);
849
+ const bar=env.col(C.green,"█".repeat(fill))+env.dim("░".repeat(w-fill));
850
+ out(`${bar} ${env.bold(String(p)+"%")} ${label}\n`);
851
+ return okResult(String(p));
852
+ }, { doc:"Progress['percent','width','label']" });
853
+
854
+ R.register("Spinner", (node,input) => {
855
+ const [msg="Working…"] = rA(node.args);
856
+ out(env.cyan("⠿ ")+msg+"\n"); return okResult(input.VALUE);
857
+ }, { doc:"Print spinner-style status line" });
858
+
859
+ R.register("Status", (node,input) => {
860
+ const [label=""] = rA(node.args);
861
+ const ok=!input.failed;
862
+ const sym=ok ? env.green("✔") : env.red("✖");
863
+ const tag=ok ? env.col(C.bggreen+C.white," OK ") : env.col(C.bgred+C.white," FAIL ");
864
+ out(`${sym} ${tag} ${label||input.VALUE}\n`);
865
+ return input;
866
+ }, { doc:"Print OK/FAIL badge for current result" });
867
+
868
+ // ══════════════════════════════════════════════════════════════════════════
869
+ // RUNTIME CONTROL
870
+ // ══════════════════════════════════════════════════════════════════════════
871
+
872
+ R.register("Debug", (node,input) => {
873
+ const [onoff="on"] = rA(node.args);
874
+ env.debug=onoff!=="off";
875
+ err(env.dim(`[debug ${env.debug?"ON":"OFF"}]\n`));
876
+ return okResult(input.VALUE);
877
+ }, { doc:"Debug['on'|'off'] — toggle debug output" });
878
+
879
+ R.register("Strict", (node,input) => {
880
+ const [onoff="on"] = rA(node.args);
881
+ env.strict=onoff!=="off"; return okResult(input.VALUE);
882
+ }, { doc:"Strict['on'|'off'] — halt on shell failure" });
883
+
884
+ R.register("Color_off", (node,input) => {
885
+ env.colorOff=true; return okResult(input.VALUE);
886
+ }, { doc:"Disable ANSI colors" });
887
+
888
+ // ══════════════════════════════════════════════════════════════════════════
889
+ // META
890
+ // ══════════════════════════════════════════════════════════════════════════
891
+
892
+ R.register("Help", (node,input) => {
893
+ out(env.col(C.bold+C.cyan,"\nProDucs Commands\n"));
894
+ out(env.dim(env.hr()+"\n"));
895
+ for (const [name,def] of this._cmds)
896
+ out(` ${env.bold(pad(name,26))}${env.dim(def.doc||"")}\n`);
897
+ out("\n"); return okResult(input.VALUE);
898
+ }, { doc:"List all commands with descriptions" });
899
+
900
+ R.register("Version", (node,input) => {
901
+ const v="ProDucs v3.0.0";
902
+ out(env.cyan(v)+"\n"); return okResult(v);
903
+ }, { doc:"Print ProDucs version" });
904
+
905
+ // Block stubs (for Help only — executed by Executor)
906
+ const stub=(name,doc)=>R.register(name,null,{isBlock:true,doc});
907
+ stub(">", "Prompt block — VALUE piped into children");
908
+ stub("Branch", "Branch — runs children only when failed=0 (check passed)");
909
+ stub("BranchElse", "BranchElse — runs children only when failed=1 (check failed)");
910
+ stub("Repeat", "Repeat['N'] — loop N times (_i available as index)");
911
+ stub("ForEach", "ForEach['var','a','b',...] — iterate over items");
912
+ stub("Try", "Try — errors caught by sibling Catch block");
913
+ stub("Catch", "Catch — runs when Try threw (_error set)");
914
+ stub("Section", "Section['title'] — formatted section heading");
915
+ stub("Step", "Step['desc'] — auto-numbered step");
916
+ stub("Group", "Group['label'] — logical grouping with bracket lines");
917
+ stub("Table", "Table — ASCII table; Row children define rows");
918
+ }
919
+ }
920
+
921
+ // ═══════════════════════════════════════════════════════════════════════════════
922
+ // EXECUTOR
923
+ // ═══════════════════════════════════════════════════════════════════════════════
924
+ class Executor {
925
+ constructor(registry, env) { this.registry=registry; this.env=env; }
926
+
927
+ /** Pre-pass: walk all node lists and register Label positions */
928
+ indexLabels(nodes) {
929
+ for (const node of nodes) {
930
+ if (node.type==="Label") {
931
+ const [name=""] = this.registry.resolveArgs(node.args);
932
+ if (name) this.env.labels[name]={node, list:nodes};
933
+ }
934
+ if (node.children?.length) this.indexLabels(node.children);
935
+ }
165
936
  }
166
937
 
167
938
  execute(ast) {
168
- let result = { VALUE: "", failed: 0 };
169
- for (const node of ast) {
170
- result = this.executeNode(node, result);
939
+ this.indexLabels(ast);
940
+ return this.executeList(ast);
941
+ }
942
+
943
+ /**
944
+ * Execute a flat node list. Catches GotoSignal if the target label's
945
+ * canonical list is THIS list; otherwise re-throws so a parent list handles it.
946
+ */
947
+ executeList(nodes, input=mkResult(), start=0, end=nodes.length) {
948
+ let result=input, i=start;
949
+ while (i<end) {
950
+ try {
951
+ result=this.executeNode(nodes[i], result);
952
+ i++;
953
+ } catch(signal) {
954
+ if (!(signal instanceof GotoSignal)) throw signal;
955
+ const entry=this.env.labels[signal.label];
956
+ if (!entry) throw new ProDucsError(`Goto: label '${signal.label}' not found`);
957
+ if (entry.list!==nodes) throw signal; // propagate to parent list
958
+ i=nodes.indexOf(entry.node)+1; // jump past the Label node
959
+ }
171
960
  }
172
961
  return result;
173
962
  }
963
+
964
+ executeNode(node, input=mkResult()) {
965
+ this.env.stepNum++;
966
+
967
+ // ── Text ──────────────────────────────────────────────────────────────────
968
+ if (node.type==="Text") {
969
+ const msg=this.env.interpolate(node.value);
970
+ if (!msg.trim()) return okResult(msg);
971
+ out(msg+"\n"); return okResult(msg);
972
+ }
973
+
974
+ // ── Branch (run if failed===0) ────────────────────────────────────────────
975
+ if (node.type==="Branch") {
976
+ if (!input.failed) return this.executeList(node.children, input);
977
+ return input;
978
+ }
979
+
980
+ // ── BranchElse (run if failed===1) ────────────────────────────────────────
981
+ if (node.type==="BranchElse") {
982
+ if (input.failed) return this.executeList(node.children, input);
983
+ return input;
984
+ }
985
+
986
+ // ── Repeat ────────────────────────────────────────────────────────────────
987
+ if (node.type==="Repeat") {
988
+ const [n="1"] = this.registry.resolveArgs(node.args);
989
+ const count=Math.max(0,parseInt(this.env.interpolate(n),10)||0);
990
+ let cur=input;
991
+ for (let i=0;i<count;i++) {
992
+ this.env.vars["_i"]=String(i);
993
+ cur=this.executeList(node.children,cur);
994
+ }
995
+ return cur;
996
+ }
997
+
998
+ // ── ForEach ───────────────────────────────────────────────────────────────
999
+ if (node.type==="ForEach") {
1000
+ const [varName="_item",...items] = this.registry.resolveArgs(node.args);
1001
+ let cur=input;
1002
+ for (const item of items) {
1003
+ this.env.vars[varName]=this.env.interpolate(item);
1004
+ cur=this.executeList(node.children,cur);
1005
+ }
1006
+ return cur;
1007
+ }
1008
+
1009
+ // ── Try / Catch ───────────────────────────────────────────────────────────
1010
+ if (node.type==="Try") {
1011
+ const catchIdx=node.children.findIndex(c=>c.type==="Catch");
1012
+ const tryEnd=catchIdx===-1 ? node.children.length : catchIdx;
1013
+ let cur=input;
1014
+ try {
1015
+ cur=this.executeList(node.children,cur,0,tryEnd);
1016
+ } catch(e) {
1017
+ if (e instanceof GotoSignal) throw e;
1018
+ if (catchIdx!==-1) {
1019
+ this.env.vars["_error"]=e.message;
1020
+ cur=this.executeList(node.children[catchIdx].children, failResult(e.message));
1021
+ } else throw e;
1022
+ }
1023
+ return cur;
1024
+ }
1025
+
1026
+ // ── Section ───────────────────────────────────────────────────────────────
1027
+ if (node.type==="Section") {
1028
+ const [title=""] = this.registry.resolveArgs(node.args);
1029
+ const t=this.env.interpolate(title);
1030
+ out(`\n${this.env.col(C.bold+C.blue,"▶ "+t)}\n`);
1031
+ out(this.env.col(C.blue,this.env.hr("─"))+"\n");
1032
+ return this.executeList(node.children, input);
1033
+ }
1034
+
1035
+ // ── Step ──────────────────────────────────────────────────────────────────
1036
+ if (node.type==="Step") {
1037
+ const [desc=""] = this.registry.resolveArgs(node.args);
1038
+ out(`\n${this.env.col(C.bold+C.cyan,`[${this.env.stepNum}]`)} `+
1039
+ `${this.env.bold(this.env.interpolate(desc))}\n`);
1040
+ return this.executeList(node.children, input);
1041
+ }
1042
+
1043
+ // ── Group ─────────────────────────────────────────────────────────────────
1044
+ if (node.type==="Group") {
1045
+ const [label=""] = this.registry.resolveArgs(node.args);
1046
+ const l=this.env.interpolate(label);
1047
+ if (l) out(this.env.dim(`┌── ${l} ──\n`));
1048
+ const cur=this.executeList(node.children, input);
1049
+ if (l) out(this.env.dim("└──\n"));
1050
+ return cur;
1051
+ }
1052
+
1053
+ // ── Table ─────────────────────────────────────────────────────────────────
1054
+ if (node.type==="Table") {
1055
+ const rowNodes=node.children.filter(c=>c.type==="Row");
1056
+ if (!rowNodes.length) return input;
1057
+ const rows=rowNodes.map(r=>this.registry.resolveArgs(r.args));
1058
+ const ncols=rows[0].length;
1059
+ const widths=Array.from({length:ncols},(_,ci)=>Math.max(...rows.map(r=>(r[ci]||"").length)));
1060
+ const sep="┼"+widths.map(w=>"─".repeat(w+2)).join("┼")+"┼";
1061
+ const fmt=(row,isHdr)=>"│"+row.map((c,i)=>{
1062
+ const s=pad(c||"",widths[i]);
1063
+ return " "+(isHdr?this.env.bold(s):s)+" ";
1064
+ }).join("│")+"│";
1065
+ out("\n");
1066
+ rows.forEach((row,ri)=>{
1067
+ if (ri===1) out(this.env.dim(sep)+"\n");
1068
+ out(fmt(row,ri===0)+"\n");
1069
+ });
1070
+ out("\n");
1071
+ return okResult(String(rows.length));
1072
+ }
1073
+
1074
+ // ── Inline command + pipe chain ───────────────────────────────────────────
1075
+ const def=this.registry.get(node.type);
1076
+ if (!def) throw new ProDucsError(`Unknown command: ${node.type}`, node.loc);
1077
+ if (def.isBlock) throw new ProDucsError(
1078
+ `Block command '${node.type}' must be closed with #$End_${node.type}`, node.loc);
1079
+
1080
+ let current=def.fn(node,input);
1081
+ for (const pipe of (node.pipes||[])) {
1082
+ const pdef=this.registry.get(pipe.type);
1083
+ if (!pdef) throw new ProDucsError(`Unknown piped command: ${pipe.type}`, pipe.loc);
1084
+ if (pdef.isBlock) throw new ProDucsError(
1085
+ `Block command '${pipe.type}' cannot be a pipe target`, pipe.loc);
1086
+ current=pdef.fn(pipe,current);
1087
+ }
1088
+ return current;
1089
+ }
174
1090
  }
175
1091
 
176
- // ──────────────────────────── Example ────────────────────────────
177
- const src = `
178
- Hi
179
- #$> Type your command: #$Check_equals['ls -la'] #$Check_failed_error['expected ls -la!'] #$Shell_run #$End_>
180
- `;
1092
+ // ═══════════════════════════════════════════════════════════════════════════════
1093
+ // PUBLIC API
1094
+ // ═══════════════════════════════════════════════════════════════════════════════
1095
+ class ProDucs {
1096
+ constructor(opts={}) {
1097
+ this.env = new RuntimeEnv();
1098
+ this.registry = new CommandRegistry(this.env);
1099
+ this.executor = new Executor(this.registry, this.env);
1100
+ if (opts.debug) this.env.debug = true;
1101
+ if (opts.strict) this.env.strict = true;
1102
+ if (opts.noColor) this.env.colorOff = true;
1103
+ }
1104
+
1105
+ register(name,fn,meta={}) { this.registry.register(name,fn,meta); return this; }
1106
+ tokenize(src) { return new Tokenizer(src).tokenize(); }
1107
+ parse(tokens) { return new Parser(tokens).parse(); }
1108
+ astify(src) { return this.parse(this.tokenize(src)); }
1109
+ execute(ast) { return this.executor.execute(ast); }
1110
+
1111
+ run(src) {
1112
+ this.env.reset();
1113
+ return this.execute(this.astify(src));
1114
+ }
1115
+
1116
+ set(name,value) { this.env.vars[name]=String(value); return this; }
1117
+ vars() { return {...this.env.vars}; }
1118
+ }
181
1119
 
182
- const prod = new ProDucs();
183
- const ast = prod.astify(src);
184
- console.log("AST:", JSON.stringify(ast, null, 2));
185
- prod.execute(ast);
1120
+ module.exports = {
1121
+ ProDucs, ProDucsError, GotoSignal,
1122
+ RuntimeEnv, Tokenizer, Parser, Executor, CommandRegistry,
1123
+ };