novac 2.0.1 → 2.2.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 (161) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +1574 -597
  3. package/bin/novac +468 -171
  4. package/bin/nvc +522 -0
  5. package/bin/nvml +78 -17
  6. package/demo.nv +0 -0
  7. package/demo_builtins.nv +0 -0
  8. package/demo_http.nv +0 -0
  9. package/examples/bf.nv +69 -0
  10. package/examples/math.nv +21 -0
  11. package/kits/birdAPI/kitdef.js +954 -0
  12. package/kits/kitRNG/kitdef.js +740 -0
  13. package/kits/kitSSH/kitdef.js +1272 -0
  14. package/kits/kitadb/kitdef.js +606 -0
  15. package/kits/kitai/kitdef.js +2185 -0
  16. package/kits/kitansi/kitdef.js +1402 -0
  17. package/kits/kitcanvas/kitdef.js +914 -0
  18. package/kits/kitclippy/kitdef.js +925 -0
  19. package/kits/kitformat/kitdef.js +1485 -0
  20. package/kits/kitgps/kitdef.js +1862 -0
  21. package/kits/kitlibproc/kitdef.js +3 -2
  22. package/kits/kitmatrix/ex.js +19 -0
  23. package/kits/kitmatrix/kitdef.js +960 -0
  24. package/kits/kitmorse/kitdef.js +229 -0
  25. package/kits/kitmpatch/kitdef.js +906 -0
  26. package/kits/kitnet/kitdef.js +1401 -0
  27. package/kits/kitnovacweb/README.md +1416 -143
  28. package/kits/kitnovacweb/kitdef.js +92 -2
  29. package/kits/kitnovacweb/nvml/executor.js +578 -176
  30. package/kits/kitnovacweb/nvml/index.js +2 -2
  31. package/kits/kitnovacweb/nvml/lexer.js +72 -69
  32. package/kits/kitnovacweb/nvml/parser.js +328 -159
  33. package/kits/kitnovacweb/nvml/renderer.js +770 -270
  34. package/kits/kitparse/kitdef.js +1688 -0
  35. package/kits/kitproto/kitdef.js +613 -0
  36. package/kits/kitqr/kitdef.js +637 -0
  37. package/kits/kitregex++/kitdef.js +1353 -0
  38. package/kits/kitrequire/kitdef.js +1599 -0
  39. package/kits/kitx11/kitdef.js +1 -0
  40. package/kits/kitx11/kitx11.js +2472 -0
  41. package/kits/kitx11/kitx11_conn.js +948 -0
  42. package/kits/kitx11/kitx11_worker.js +121 -0
  43. package/kits/libtea/kitdef.js +2691 -0
  44. package/kits/libterm/ex.js +285 -0
  45. package/kits/libterm/kitdef.js +1927 -0
  46. package/novac/LICENSE +21 -0
  47. package/novac/README.md +1823 -0
  48. package/novac/bin/novac +950 -0
  49. package/novac/bin/nvc +522 -0
  50. package/novac/bin/nvml +542 -0
  51. package/novac/demo.nv +245 -0
  52. package/novac/demo_builtins.nv +209 -0
  53. package/novac/demo_http.nv +62 -0
  54. package/novac/examples/bf.nv +69 -0
  55. package/novac/examples/math.nv +21 -0
  56. package/novac/kits/kitai/kitdef.js +2185 -0
  57. package/novac/kits/kitansi/kitdef.js +1402 -0
  58. package/novac/kits/kitformat/kitdef.js +1485 -0
  59. package/novac/kits/kitgps/kitdef.js +1862 -0
  60. package/novac/kits/kitlibfs/kitdef.js +231 -0
  61. package/{examples/example-project/nova_modules → novac/kits}/kitlibproc/kitdef.js +3 -2
  62. package/novac/kits/kitmatrix/ex.js +19 -0
  63. package/novac/kits/kitmatrix/kitdef.js +960 -0
  64. package/novac/kits/kitmpatch/kitdef.js +906 -0
  65. package/novac/kits/kitnovacweb/README.md +1572 -0
  66. package/novac/kits/kitnovacweb/demo.nv +12 -0
  67. package/novac/kits/kitnovacweb/demo.nvml +71 -0
  68. package/novac/kits/kitnovacweb/index.nova +12 -0
  69. package/novac/kits/kitnovacweb/kitdef.js +692 -0
  70. package/novac/kits/kitnovacweb/nova.kit.json +8 -0
  71. package/novac/kits/kitnovacweb/nvml/executor.js +739 -0
  72. package/novac/kits/kitnovacweb/nvml/index.js +67 -0
  73. package/novac/kits/kitnovacweb/nvml/lexer.js +263 -0
  74. package/novac/kits/kitnovacweb/nvml/parser.js +508 -0
  75. package/novac/kits/kitnovacweb/nvml/renderer.js +924 -0
  76. package/novac/kits/kitparse/kitdef.js +1688 -0
  77. package/novac/kits/kitregex++/kitdef.js +1353 -0
  78. package/novac/kits/kitrequire/kitdef.js +1599 -0
  79. package/novac/kits/kitx11/kitdef.js +1 -0
  80. package/novac/kits/kitx11/kitx11.js +2472 -0
  81. package/novac/kits/kitx11/kitx11_conn.js +948 -0
  82. package/novac/kits/kitx11/kitx11_worker.js +121 -0
  83. package/novac/kits/libtea/tf.js +2691 -0
  84. package/novac/kits/libterm/ex.js +285 -0
  85. package/novac/kits/libterm/kitdef.js +1927 -0
  86. package/novac/node_modules/chalk/license +9 -0
  87. package/novac/node_modules/chalk/package.json +83 -0
  88. package/novac/node_modules/chalk/readme.md +297 -0
  89. package/novac/node_modules/chalk/source/index.d.ts +325 -0
  90. package/novac/node_modules/chalk/source/index.js +225 -0
  91. package/novac/node_modules/chalk/source/utilities.js +33 -0
  92. package/novac/node_modules/chalk/source/vendor/ansi-styles/index.d.ts +236 -0
  93. package/novac/node_modules/chalk/source/vendor/ansi-styles/index.js +223 -0
  94. package/novac/node_modules/chalk/source/vendor/supports-color/browser.d.ts +1 -0
  95. package/novac/node_modules/chalk/source/vendor/supports-color/browser.js +34 -0
  96. package/novac/node_modules/chalk/source/vendor/supports-color/index.d.ts +55 -0
  97. package/novac/node_modules/chalk/source/vendor/supports-color/index.js +190 -0
  98. package/novac/node_modules/commander/LICENSE +22 -0
  99. package/novac/node_modules/commander/Readme.md +1176 -0
  100. package/novac/node_modules/commander/esm.mjs +16 -0
  101. package/novac/node_modules/commander/index.js +24 -0
  102. package/novac/node_modules/commander/lib/argument.js +150 -0
  103. package/novac/node_modules/commander/lib/command.js +2777 -0
  104. package/novac/node_modules/commander/lib/error.js +39 -0
  105. package/novac/node_modules/commander/lib/help.js +747 -0
  106. package/novac/node_modules/commander/lib/option.js +380 -0
  107. package/novac/node_modules/commander/lib/suggestSimilar.js +101 -0
  108. package/novac/node_modules/commander/package-support.json +19 -0
  109. package/novac/node_modules/commander/package.json +82 -0
  110. package/novac/node_modules/commander/typings/esm.d.mts +3 -0
  111. package/novac/node_modules/commander/typings/index.d.ts +1113 -0
  112. package/novac/node_modules/node-addon-api/LICENSE.md +9 -0
  113. package/novac/node_modules/node-addon-api/README.md +95 -0
  114. package/novac/node_modules/node-addon-api/common.gypi +21 -0
  115. package/novac/node_modules/node-addon-api/except.gypi +25 -0
  116. package/novac/node_modules/node-addon-api/index.js +14 -0
  117. package/novac/node_modules/node-addon-api/napi-inl.deprecated.h +186 -0
  118. package/novac/node_modules/node-addon-api/napi-inl.h +7165 -0
  119. package/novac/node_modules/node-addon-api/napi.h +3364 -0
  120. package/novac/node_modules/node-addon-api/node_addon_api.gyp +42 -0
  121. package/novac/node_modules/node-addon-api/node_api.gyp +9 -0
  122. package/novac/node_modules/node-addon-api/noexcept.gypi +26 -0
  123. package/novac/node_modules/node-addon-api/package-support.json +21 -0
  124. package/novac/node_modules/node-addon-api/package.json +480 -0
  125. package/novac/node_modules/node-addon-api/tools/README.md +73 -0
  126. package/novac/node_modules/node-addon-api/tools/check-napi.js +99 -0
  127. package/novac/node_modules/node-addon-api/tools/clang-format.js +71 -0
  128. package/novac/node_modules/node-addon-api/tools/conversion.js +301 -0
  129. package/novac/node_modules/serialize-javascript/LICENSE +27 -0
  130. package/novac/node_modules/serialize-javascript/README.md +149 -0
  131. package/novac/node_modules/serialize-javascript/index.js +297 -0
  132. package/novac/node_modules/serialize-javascript/package.json +33 -0
  133. package/novac/package.json +27 -0
  134. package/novac/scripts/update-bin.js +24 -0
  135. package/novac/src/core/bstd.js +1035 -0
  136. package/novac/src/core/config.js +155 -0
  137. package/novac/src/core/describe.js +187 -0
  138. package/novac/src/core/emitter.js +499 -0
  139. package/novac/src/core/error.js +86 -0
  140. package/novac/src/core/executor.js +5606 -0
  141. package/novac/src/core/formatter.js +686 -0
  142. package/novac/src/core/lexer.js +1026 -0
  143. package/novac/src/core/nova_builtins.js +717 -0
  144. package/novac/src/core/nova_thread_worker.js +166 -0
  145. package/novac/src/core/parser.js +2181 -0
  146. package/novac/src/core/types.js +112 -0
  147. package/novac/src/index.js +28 -0
  148. package/novac/src/runtime/stdlib.js +244 -0
  149. package/package.json +6 -3
  150. package/scripts/update-bin.js +0 -0
  151. package/src/core/bstd.js +838 -362
  152. package/src/core/executor.js +2578 -170
  153. package/src/core/lexer.js +502 -54
  154. package/src/core/nova_builtins.js +21 -3
  155. package/src/core/parser.js +413 -72
  156. package/src/core/types.js +30 -2
  157. package/src/index.js +0 -0
  158. package/examples/example-project/README.md +0 -3
  159. package/examples/example-project/src/main.nova +0 -3
  160. package/src/core/environment.js +0 -0
  161. /package/{examples/example-project/bin/example-project.nv → novac/node_modules/node-addon-api/nothing.c} +0 -0
@@ -0,0 +1,950 @@
1
+ #!/usr/bin/env node
2
+ const { Command } = require('commander');
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const readline = require('readline');
6
+ const { run, tokenize, parse, format, Executor } = require('../src/index');
7
+ const stdlib = require('../src/runtime/stdlib');
8
+ const { execSync } = require('child_process');
9
+ const os = require('os');
10
+ const chalk = require('chalk').default;
11
+ const { getConfig, setConfig, interactiveSetup } = require('../src/core/config');
12
+
13
+ // ─── Wildcard helpers ─────────────────────────────────────────────────────────
14
+
15
+ /**
16
+ * Expand a glob pattern (e.g. "src/*.nova", "**\/*.nv") into real file paths.
17
+ * Falls back gracefully: if the pattern matches no files and contains no glob
18
+ * characters it is returned as-is so exact paths still work.
19
+ */
20
+ function expandGlob(pattern) {
21
+ if (!isGlobPattern(pattern)) return [pattern];
22
+ try {
23
+ const matches = fs.globSync(pattern, { cwd: process.cwd() });
24
+ return matches.length > 0 ? matches.map(m => path.resolve(m)) : [];
25
+ } catch {
26
+ return [pattern];
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Expand an array of file arguments, resolving any wildcards in each entry.
32
+ * Returns a flat, deduplicated array of absolute paths.
33
+ */
34
+ function expandFileArgs(args) {
35
+ const seen = new Set();
36
+ const out = [];
37
+ for (const arg of args) {
38
+ for (const f of expandGlob(arg)) {
39
+ const abs = path.resolve(f);
40
+ if (!seen.has(abs)) { seen.add(abs); out.push(abs); }
41
+ }
42
+ }
43
+ return out;
44
+ }
45
+
46
+ /** True when a string contains glob metacharacters */
47
+ function isGlobPattern(str) {
48
+ return /[*?{}\[\]]/.test(str);
49
+ }
50
+
51
+ /**
52
+ * Expand a name list (module names, kit names) that may include wildcards
53
+ * against an installed directory. A bare "*" expands to all entries.
54
+ */
55
+ function expandNamesGlob(patterns, baseDir) {
56
+ if (!fs.existsSync(baseDir)) return patterns;
57
+ const installed = fs.readdirSync(baseDir, { withFileTypes: true })
58
+ .filter(e => e.isDirectory())
59
+ .map(e => e.name);
60
+ const out = [];
61
+ for (const pat of patterns) {
62
+ if (!isGlobPattern(pat)) { out.push(pat); continue; }
63
+ const re = globToRegex(pat);
64
+ const hits = installed.filter(n => re.test(n));
65
+ if (hits.length > 0) out.push(...hits);
66
+ else out.push(pat); // pass through so the caller gives a "not found" error
67
+ }
68
+ return [...new Set(out)];
69
+ }
70
+
71
+ /** Convert a simple shell-style glob (*, ?) to a RegExp */
72
+ function globToRegex(pattern) {
73
+ const escaped = pattern
74
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
75
+ .replace(/\*/g, '.*')
76
+ .replace(/\?/g, '.');
77
+ return new RegExp(`^${escaped}$`);
78
+ }
79
+
80
+ // ─── Program ──────────────────────────────────────────────────────────────────
81
+
82
+ const program = new Command();
83
+ program.name('novac').description('Nova Language v2').version('2.0.0').enablePositionalOptions();
84
+
85
+ program
86
+ .argument('[file]')
87
+ .argument('[args...]')
88
+ .allowUnknownOption(true)
89
+ .passThroughOptions()
90
+ .action((file, args, options, command) => {
91
+ // No file given → start REPL
92
+ if (!file) {
93
+ console.log(chalk.gray('No file specified. Starting REPL. Type .help for commands, .exit to quit.\n'));
94
+ return program.commands.find(c => c.name() === 'repl')._actionHandler([]);
95
+ }
96
+
97
+ const filePath = path.resolve(file);
98
+ if (!fs.existsSync(filePath)) {
99
+ console.error(chalk.red(`Error: File not found: ${file}`));
100
+ process.exit(1);
101
+ }
102
+
103
+ let src;
104
+ try {
105
+ src = fs.readFileSync(filePath, 'utf8');
106
+ } catch (e) {
107
+ console.error(chalk.red(`Error reading file "${file}": ${e.message}`));
108
+ process.exit(1);
109
+ }
110
+
111
+ let cli = {
112
+ args: args.filter((a) => !(a.startsWith('-') || a.startsWith('@') || a.startsWith('+'))) || [],
113
+ options,
114
+ addrs: {},
115
+ additions: {},
116
+ raw: process.argv,
117
+ };
118
+ const unknown = process.argv.slice(2).slice(options.length);
119
+ for (let i = 0; i < unknown.length; i++) {
120
+ const arg = unknown[i];
121
+ if (arg.startsWith('--')) {
122
+ const key = arg.slice(2);
123
+ const next = unknown[i + 1];
124
+ if (next && !next.startsWith('-')) {
125
+ cli.options[key] = next;
126
+ i++;
127
+ } else {
128
+ if (key.includes('=')) {
129
+ const [k, v] = key.split('=');
130
+ cli.options[k] = v;
131
+ } else {
132
+ cli.options[key] = true;
133
+ }
134
+ }
135
+ } else if (arg.startsWith('-')) {
136
+ const key = arg.slice(1);
137
+ cli.options[key] = true;
138
+ } else if (arg.startsWith('@')) {
139
+ const key = arg.slice(1);
140
+ cli.addrs[key] = true;
141
+ } else if (arg.startsWith('+')) {
142
+ const key = arg.slice(1);
143
+ cli.additions[key] = true;
144
+ }
145
+ }
146
+ try { const _r = run(src, { cli }); if (_r instanceof Promise) _r.catch(e => { console.error(chalk.red(e.message || e)); process.exit(1); }); }
147
+ catch (e) { console.error(chalk.red(e.message || e)); process.exit(1); }
148
+ });
149
+
150
+ // ─── novac run <file> [args...] ───────────────────────────────────────────────
151
+ program
152
+ .command('run <file> [args...]')
153
+ .description('Run a Nova file (explicit alias for: novac <file>)')
154
+ .allowUnknownOption(true)
155
+ .action((file, args, options) => {
156
+ const filePath = path.resolve(file);
157
+ if (!fs.existsSync(filePath)) {
158
+ console.error(chalk.red(`Error: File not found: ${file}`));
159
+ process.exit(1);
160
+ }
161
+ let src;
162
+ try {
163
+ src = fs.readFileSync(filePath, 'utf8');
164
+ } catch (e) {
165
+ console.error(chalk.red(`Error reading file "${file}": ${e.message}`));
166
+ process.exit(1);
167
+ }
168
+ const cli = { args, options, addrs: {}, additions: {}, raw: process.argv };
169
+ try { const _r = run(src, { cli }); if (_r instanceof Promise) _r.catch(e => { console.error(chalk.red(e.message || e)); process.exit(1); }); }
170
+ catch (e) { console.error(chalk.red(e.message || e)); process.exit(1); }
171
+ });
172
+
173
+ // ─── novac test <file|glob> [file|glob ...] ───────────────────────────────────
174
+ program
175
+ .command('test <files...>')
176
+ .description('Test Nova file(s) by checking syntax — supports globs e.g. "src/*.nova"')
177
+ .action((files) => {
178
+ const resolved = expandFileArgs(files);
179
+ if (resolved.length === 0) {
180
+ console.error(chalk.red('No files matched:', files.join(', ')));
181
+ process.exit(1);
182
+ }
183
+ let ok = 0, fail = 0;
184
+ for (const file of resolved) {
185
+ let src;
186
+ try {
187
+ src = fs.readFileSync(file, 'utf8');
188
+ } catch (e) {
189
+ console.error(chalk.red('✗'), chalk.gray(path.relative(process.cwd(), file)),
190
+ chalk.red('— cannot read file:', e.message));
191
+ fail++; continue;
192
+ }
193
+ try {
194
+ parse(src);
195
+ console.log(chalk.green('✓'), chalk.gray(path.relative(process.cwd(), file)));
196
+ ok++;
197
+ } catch (e) {
198
+ console.error(chalk.red('✗'), chalk.gray(path.relative(process.cwd(), file)),
199
+ chalk.red('—', e.message || e));
200
+ fail++;
201
+ }
202
+ }
203
+ if (resolved.length > 1) {
204
+ console.log(chalk.blue(`\n${ok} passed, ${fail} failed`));
205
+ }
206
+ if (fail > 0) process.exit(1);
207
+ });
208
+
209
+ // ─── novac format [file|glob ...] ────────────────────────────────────────────
210
+ program.command('format [files...]')
211
+ .description('Format Nova file(s) — supports globs e.g. "src/**/*.nova"')
212
+ .option('-w, --write', 'Write formatted output back to files instead of printing')
213
+ .option('-c, --check', 'Check if files are formatted; exit non-zero if any need changes (useful for CI)')
214
+ .action((files, opts) => {
215
+ if (files && files.length > 0) {
216
+ const resolved = expandFileArgs(files);
217
+ if (resolved.length === 0) {
218
+ console.error(chalk.red('No files matched:', files.join(', ')));
219
+ process.exit(1);
220
+ }
221
+ let anyError = false;
222
+ let needsFormat = 0;
223
+ for (const file of resolved) {
224
+ let src;
225
+ try {
226
+ src = fs.readFileSync(file, 'utf8');
227
+ } catch (e) {
228
+ console.error(chalk.red(`Error reading "${path.relative(process.cwd(), file)}": ${e.message}`));
229
+ anyError = true; continue;
230
+ }
231
+ try {
232
+ const out = format(src);
233
+ if (opts.check) {
234
+ if (src !== out) {
235
+ console.log(chalk.red('✗ needs formatting:'), chalk.gray(path.relative(process.cwd(), file)));
236
+ needsFormat++;
237
+ } else {
238
+ console.log(chalk.green('✓'), chalk.gray(path.relative(process.cwd(), file)));
239
+ }
240
+ } else if (opts.write) {
241
+ fs.writeFileSync(file, out, 'utf8');
242
+ console.log(chalk.green('formatted'), chalk.gray(path.relative(process.cwd(), file)));
243
+ } else {
244
+ if (resolved.length > 1) console.log(chalk.bold(`\n// ── ${path.relative(process.cwd(), file)} ──`));
245
+ console.log(out);
246
+ }
247
+ } catch (e) {
248
+ console.error(chalk.red('Error in', path.relative(process.cwd(), file) + ':'), e.message || e);
249
+ anyError = true;
250
+ }
251
+ }
252
+ if (opts.check && needsFormat > 0) {
253
+ console.log(chalk.red(`\n${needsFormat} file(s) need formatting. Run: novac format --write`));
254
+ process.exit(1);
255
+ }
256
+ if (anyError) process.exit(1);
257
+ } else {
258
+ // stdin REPL mode
259
+ let input = '';
260
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
261
+ rl.on('line', (line) => { input += line + '\n'; });
262
+ rl.on('close', () => {
263
+ try { console.log(format(input)); }
264
+ catch (e) { console.error(e.message || e); process.exit(1); }
265
+ });
266
+ }
267
+ });
268
+
269
+ // ─── novac eval <code> ────────────────────────────────────────────────────────
270
+ program.command('eval <code>')
271
+ .description('Evaluate Nova code inline')
272
+ .action((code) => {
273
+ try { run(code); }
274
+ catch (e) { console.error(e.message || e); process.exit(1); }
275
+ });
276
+
277
+ // ─── novac tokens <file|glob> [file|glob ...] ─────────────────────────────────
278
+ program.command('tokens <files...>')
279
+ .description('Print token stream of Nova file(s) — supports globs e.g. "src/*.nova"')
280
+ .action((files) => {
281
+ const resolved = expandFileArgs(files);
282
+ if (resolved.length === 0) {
283
+ console.error(chalk.red('No files matched:', files.join(', ')));
284
+ process.exit(1);
285
+ }
286
+ for (const file of resolved) {
287
+ const src = fs.readFileSync(file, 'utf8');
288
+ try {
289
+ if (resolved.length > 1) console.log(chalk.bold(`\n// ── ${path.relative(process.cwd(), file)} ──`));
290
+ console.log(JSON.stringify(tokenize(src), null, 2));
291
+ } catch (e) { console.error(e.message || e); process.exit(1); }
292
+ }
293
+ });
294
+
295
+ // ─── novac ast <file|glob> [file|glob ...] ────────────────────────────────────
296
+ program.command('ast <files...>')
297
+ .description('Print AST of Nova file(s) — supports globs e.g. "src/*.nova"')
298
+ .action((files) => {
299
+ const resolved = expandFileArgs(files);
300
+ if (resolved.length === 0) {
301
+ console.error(chalk.red('No files matched:', files.join(', ')));
302
+ process.exit(1);
303
+ }
304
+ for (const file of resolved) {
305
+ const src = fs.readFileSync(file, 'utf8');
306
+ try {
307
+ if (resolved.length > 1) console.log(chalk.bold(`\n// ── ${path.relative(process.cwd(), file)} ──`));
308
+ console.log(JSON.stringify(parse(src), null, 2));
309
+ } catch (e) { console.error(e.message || e); process.exit(1); }
310
+ }
311
+ });
312
+
313
+ // ─── novac repl ───────────────────────────────────────────────────────────────
314
+ program.command('repl')
315
+ .description('Start interactive REPL')
316
+ .action(() => {
317
+ const executor = new Executor('', stdlib);
318
+ const historyFile = path.join(process.env.HOME || process.env.USERPROFILE || '.', '.nova_repl_history');
319
+
320
+ console.log('Nova REPL v2.0.0');
321
+ console.log('Type .exit to exit\n');
322
+
323
+ let history = [];
324
+ try {
325
+ const historyContent = fs.readFileSync(historyFile, 'utf8');
326
+ history = historyContent.split('\n').filter(line => line.trim().length > 0);
327
+ } catch (e) { }
328
+
329
+ const rl = readline.createInterface({
330
+ input: process.stdin,
331
+ output: process.stdout,
332
+ prompt: 'nova> ',
333
+ history,
334
+ });
335
+
336
+ rl.prompt();
337
+
338
+ rl.on('line', async (input) => {
339
+ const trimmed = input.trim();
340
+
341
+ if (trimmed) fs.appendFileSync(historyFile, trimmed + '\n');
342
+
343
+ if (trimmed === '.exit') {
344
+ rl.close();
345
+ } else if (trimmed === '.clear') {
346
+ console.clear();
347
+ } else if (trimmed === '.reset') {
348
+ // Re-create executor to wipe all defined variables/functions
349
+ Object.keys(executor).forEach(k => delete executor[k]);
350
+ Object.assign(executor, new Executor('', stdlib));
351
+ console.log(chalk.green('REPL state reset.'));
352
+ } else if (trimmed.startsWith('.format')) {
353
+ const code = trimmed.slice(7).trim();
354
+ if (!code) { console.error('Usage: .format <code>'); rl.prompt(); return; }
355
+ try {
356
+ console.log(format(code));
357
+ } catch (e) { console.error('Error:', e.message || e); }
358
+ } else if (trimmed.startsWith('.ast')) {
359
+ try {
360
+ const ast = parse(trimmed.slice(4).trim());
361
+ console.log(JSON.stringify(ast, null, 2));
362
+ } catch (e) { console.error('Error:', e.message || e); }
363
+ } else if (trimmed.startsWith('.tokens')) {
364
+ try {
365
+ const toks = tokenize(trimmed.slice(7).trim());
366
+ console.log(JSON.stringify(toks, null, 2));
367
+ } catch (e) { console.error('Error:', e.message || e); }
368
+ } else if (trimmed.startsWith('.load')) {
369
+ // .load <file|glob> — load one or more Nova files into REPL
370
+ const pattern = trimmed.slice(5).trim();
371
+ if (!pattern) { console.error('Usage: .load <file|glob>'); rl.prompt(); return; }
372
+ const files = expandFileArgs([pattern]);
373
+ if (files.length === 0) { console.error(chalk.red('No files matched:', pattern)); rl.prompt(); return; }
374
+ for (const file of files) {
375
+ try {
376
+ const src = fs.readFileSync(file, 'utf8');
377
+ const ast = parse(src);
378
+ let result = executor.run(ast);
379
+ if (result instanceof Promise) result = await result;
380
+ console.log(chalk.green('loaded'), chalk.gray(path.relative(process.cwd(), file)));
381
+ } catch (e) { console.error(chalk.red('Error loading', path.basename(file) + ':'), e.message || e); }
382
+ }
383
+ } else if (trimmed.startsWith('.help')) {
384
+ console.log('Nova REPL Commands:');
385
+ console.log(' .exit Exit REPL');
386
+ console.log(' .clear Clear screen');
387
+ console.log(' .reset Reset all variables and definitions');
388
+ console.log(' .ast <code> Show AST for code');
389
+ console.log(' .tokens <code> Show token stream for code');
390
+ console.log(' .format <code> Show formatted version of code');
391
+ console.log(' .load <file|glob> Load Nova file(s) into REPL — supports wildcards');
392
+ console.log(' .help Show this help');
393
+ } else if (trimmed !== '') {
394
+ try {
395
+ const ast = parse(trimmed);
396
+ let result = executor.run(ast);
397
+ // Top-level await / async fn call — resolve the Promise before printing
398
+ if (result instanceof Promise) {
399
+ rl.pause();
400
+ try {
401
+ result = await result;
402
+ } catch (e) {
403
+ console.error(chalk.red('Async error:'), e && e.message ? e.message.split('\n')[0] : e);
404
+ rl.resume();
405
+ rl.prompt();
406
+ return;
407
+ }
408
+ rl.resume();
409
+ }
410
+ if (result !== undefined && result !== null && result !== '')
411
+ console.log(executor.stringify(result));
412
+ } catch (e) { console.error('Error:', e.message ? e.message.split('\n')[0] : e.message || e); }
413
+ }
414
+
415
+ rl.prompt();
416
+ });
417
+
418
+ rl.on('close', () => {
419
+ console.log('\nGoodbye!');
420
+ process.exit(0);
421
+ });
422
+ });
423
+
424
+ // ─── novac new <project> ──────────────────────────────────────────────────────
425
+ program
426
+ .command('new <project>')
427
+ .description('Create a new Nova project')
428
+ .option('--cd', 'Print the cd command to enter the new project directory')
429
+ .action((project, opts) => {
430
+ const projectPath = path.resolve(project);
431
+ if (fs.existsSync(projectPath)) {
432
+ console.error(chalk.red('Directory already exists:', projectPath));
433
+ process.exit(1);
434
+ }
435
+ fs.mkdirSync(projectPath);
436
+ fs.mkdirSync(path.join(projectPath, 'src'));
437
+ fs.mkdirSync(path.join(projectPath, 'bin'));
438
+ fs.mkdirSync(path.join(projectPath, 'nova_modules'));
439
+ fs.writeFileSync(path.join(projectPath, 'src', 'main.nova'), `// Welcome to Nova!\n\nprint("Hello, Nova!");\n`, 'utf8');
440
+ const runFile = path.join(projectPath, 'bin', `${project}.nv`);
441
+ fs.writeFileSync(runFile, ``, { encoding: 'utf8', mode: 0o755 });
442
+ fs.writeFileSync(path.join(projectPath, 'README.md'), `# ${project}\n\nThis is a Nova project.`, 'utf8');
443
+ fs.writeFileSync(path.join(projectPath, '.gitignore'), `nova_modules/\n`, 'utf8');
444
+ const projectConfig = {
445
+ name: project,
446
+ version: '1.0.0',
447
+ description: '',
448
+ author: '',
449
+ license: 'MIT',
450
+ main: 'src/main.nova',
451
+ srcDir: 'src',
452
+ scripts: { run: `novac src/main.nova` },
453
+ dependencies: {},
454
+ devDependencies: {},
455
+ };
456
+ fs.writeFileSync(path.join(projectPath, 'nova.config.json'), JSON.stringify(projectConfig, null, 2), 'utf8');
457
+ console.log(chalk.green(`✓ Project created: ${projectPath}`));
458
+ console.log(chalk.gray(` src/main.nova — entry point`));
459
+ console.log(chalk.gray(` nova.config.json — project config`));
460
+ if (opts.cd) {
461
+ // Note: a CLI child process cannot change the parent shell's directory.
462
+ // We print the command for the user to run instead.
463
+ console.log(chalk.blue(`\nRun: cd ${project}`));
464
+ } else {
465
+ console.log(chalk.blue(`\nGet started:\n cd ${project}\n novac src/main.nova`));
466
+ }
467
+ });
468
+
469
+ // ─── novac config ─────────────────────────────────────────────────────────────
470
+ program
471
+ .command('config <action>')
472
+ .argument('[key]', 'Configuration key (e.g., name, version, scripts.build)')
473
+ .argument('[value...]', 'Configuration value (optional)')
474
+ .description('Manage Nova configuration')
475
+ .action((action, key, value) => {
476
+ try {
477
+ if (action === 'get') {
478
+ if (!key) {
479
+ const allConfig = getConfig();
480
+ console.log(JSON.stringify(allConfig, null, 2));
481
+ } else {
482
+ const val = getConfig(key);
483
+ if (val === undefined) {
484
+ console.log(chalk.yellow(`Config key "${key}" not found`));
485
+ } else {
486
+ if (typeof val === 'object') {
487
+ console.log(JSON.stringify(val, null, 2));
488
+ } else {
489
+ console.log(val);
490
+ }
491
+ }
492
+ }
493
+ } else if (action === 'set') {
494
+ if (!key || value.length === 0) {
495
+ console.error(chalk.red('Usage: novac config set <key> <value>'));
496
+ process.exit(1);
497
+ }
498
+ const valueStr = value.join(' ');
499
+ setConfig(key, valueStr);
500
+ console.log(chalk.green(`Config updated: ${key} = ${valueStr}`));
501
+ } else if (action === 'init') {
502
+ console.log('Initializing Nova configuration...\n');
503
+ interactiveSetup();
504
+ } else {
505
+ console.error(chalk.red(`Unknown action: ${action}. Use "get", "set", or "init".`));
506
+ process.exit(1);
507
+ }
508
+ } catch (error) {
509
+ console.error(chalk.red('Config error:', error.message));
510
+ process.exit(1);
511
+ }
512
+ });
513
+
514
+ // ─── novac etc <cmd> ─────────────────────────────────────────────────────────
515
+ program
516
+ .command('etc <cmd> [args...]')
517
+ .description('Run extra Nova commands (e.g., notices, describe, kit)')
518
+ .allowUnknownOption(true)
519
+ .action((cmd, args) => {
520
+ try {
521
+ if (cmd === 'notices') {
522
+ const notices = stdlib.getNotices();
523
+ if (notices.length === 0) {
524
+ console.log(chalk.green('No notices'));
525
+ } else {
526
+ console.log(chalk.yellow('Notices:'));
527
+ notices.forEach((n, i) => console.log(chalk.yellow(`${i + 1}. ${n}`)));
528
+ }
529
+ } else if (cmd === 'describe') {
530
+ // describe supports globs: novac etc describe src/*.nova
531
+ const pattern = args[0];
532
+ if (!pattern) { console.error(chalk.red('Usage: novac etc describe <file|glob>')); process.exit(1); }
533
+ const files = expandFileArgs([pattern]);
534
+ if (files.length === 0) { console.error(chalk.red('No files matched:', pattern)); process.exit(1); }
535
+ const describer = require('../src/core/describe');
536
+ for (const file of files) {
537
+ if (files.length > 1) console.log(chalk.bold(`\n── ${path.relative(process.cwd(), file)} ──`));
538
+ const content = fs.readFileSync(file, 'utf8');
539
+ const ast = parse(content);
540
+ console.log(describer.describe(ast));
541
+ }
542
+ } else if (cmd === 'kit') {
543
+ // Supports multiple names and wildcards: novac etc kit math* utils --global
544
+ const isGlobal = args.includes('--global') || args.includes('-g');
545
+ const kitNames = args.filter(a => a !== '--global' && a !== '-g');
546
+ if (kitNames.length === 0) {
547
+ console.error(chalk.red('Usage: novac etc kit <kitname|glob> [...] [--global]'));
548
+ process.exit(1);
549
+ }
550
+ const kitsRoot = path.join(__dirname, '..', 'kits');
551
+ const availableKits = fs.existsSync(kitsRoot)
552
+ ? fs.readdirSync(kitsRoot, { withFileTypes: true }).filter(e => e.isDirectory()).map(e => e.name)
553
+ : [];
554
+
555
+ // Expand wildcards against available kits
556
+ const resolvedKits = [];
557
+ for (const pat of kitNames) {
558
+ if (isGlobPattern(pat)) {
559
+ const re = globToRegex(pat);
560
+ const hits = availableKits.filter(n => re.test(n));
561
+ if (hits.length === 0) {
562
+ console.error(chalk.red(`No kits matched pattern "${pat}". Available: ${availableKits.join(', ')}`));
563
+ process.exit(1);
564
+ }
565
+ resolvedKits.push(...hits);
566
+ } else {
567
+ resolvedKits.push(pat);
568
+ }
569
+ }
570
+
571
+ const baseDir = isGlobal
572
+ ? path.join(os.homedir(), '.novac', 'nova_modules')
573
+ : path.join(process.cwd(), 'nova_modules');
574
+
575
+ let ok = 0, fail = 0;
576
+ for (const kitName of [...new Set(resolvedKits)]) {
577
+ const kitDir = path.join(kitsRoot, kitName);
578
+ if (!fs.existsSync(kitDir) || !fs.statSync(kitDir).isDirectory()) {
579
+ console.error(chalk.red(` fail "${kitName}" — not found. Available: ${availableKits.join(', ')}`));
580
+ fail++; continue;
581
+ }
582
+ const destPath = path.join(baseDir, kitName);
583
+ if (isGlobal) console.log(chalk.blue(`Installing globally to ${destPath}`));
584
+ if (fs.existsSync(destPath)) {
585
+ console.error(chalk.red(` skip "${kitName}" — already installed at ${destPath}`));
586
+ fail++; continue;
587
+ }
588
+ const kitdefPath = path.join(kitDir, 'kitdef.js');
589
+ if (!fs.existsSync(kitdefPath)) {
590
+ console.error(chalk.red(` fail "${kitName}" — no kitdef.js`));
591
+ fail++; continue;
592
+ }
593
+ fs.mkdirSync(destPath, { recursive: true });
594
+ fs.copyFileSync(kitdefPath, path.join(destPath, 'kitdef.js'));
595
+ console.log(chalk.green(` ok "${kitName}" → ${destPath}`));
596
+ console.log(chalk.blue(` Usage in Nova: import "${kitName}"`));
597
+ ok++;
598
+ }
599
+ if (resolvedKits.length > 1) console.log(chalk.blue(`\nDone. ${ok} installed, ${fail} failed/skipped.`));
600
+ } else {
601
+ console.error(chalk.red(`Unknown command: ${cmd}. Use "notices", "describe", or "kit".`));
602
+ process.exit(1);
603
+ }
604
+ } catch (error) {
605
+ console.error(chalk.red('Error:', error.message));
606
+ process.exit(1);
607
+ }
608
+ });
609
+
610
+ // ─── novac new-kit <dirname> ──────────────────────────────────────────────────
611
+ program
612
+ .command('new-kit <dirname>')
613
+ .description('Scaffold a new Nova kit in the current directory')
614
+ .action((dirname) => {
615
+ const kitPath = path.resolve(dirname);
616
+ if (fs.existsSync(kitPath)) {
617
+ console.error(chalk.red(`Directory already exists: ${kitPath}`));
618
+ process.exit(1);
619
+ }
620
+ fs.mkdirSync(kitPath, { recursive: true });
621
+
622
+ const kitdefSrc = `// ${dirname} — Nova kit
623
+ // Export all public API on the kitdef object.
624
+ // Each key becomes accessible after: import "${dirname}"
625
+
626
+ const kit = {
627
+ // TODO: implement your kit API here
628
+ hello() {
629
+ return 'Hello from ${dirname}!';
630
+ },
631
+ };
632
+
633
+ module.exports = { kitdef: kit };
634
+ `;
635
+ fs.writeFileSync(path.join(kitPath, 'kitdef.js'), kitdefSrc, 'utf8');
636
+
637
+ const indexNova = `// ${dirname} kit — Nova wrapper
638
+ // Import this file from Nova: import "${dirname}"
639
+ // Then call: hello()
640
+
641
+ let _kit = __js_require("./${dirname}/kitdef.js").kitdef;
642
+ export hello = _kit.hello;
643
+ `;
644
+ fs.writeFileSync(path.join(kitPath, 'index.nova'), indexNova, 'utf8');
645
+
646
+ const manifest = {
647
+ name: dirname,
648
+ version: '1.0.0',
649
+ description: '',
650
+ main: 'index.nova',
651
+ author: '',
652
+ license: 'MIT',
653
+ };
654
+ fs.writeFileSync(path.join(kitPath, 'nova.kit.json'), JSON.stringify(manifest, null, 2), 'utf8');
655
+ fs.writeFileSync(path.join(kitPath, 'README.md'), `# ${dirname}\n\nA Nova kit.\n\n## Usage\n\n\`\`\`nova\nimport "${dirname}"\nhello()\n\`\`\`\n`, 'utf8');
656
+
657
+ console.log(chalk.green(`Kit scaffolded at ${kitPath}`));
658
+ console.log(chalk.blue(' Edit kitdef.js to implement your API.'));
659
+ console.log(chalk.blue(' Update index.nova exports to match.'));
660
+ });
661
+
662
+ // ─── novac init build | PATH ──────────────────────────────────────────────────
663
+ program
664
+ .command('init')
665
+ .description('Initialize novac settings or build project module')
666
+ .argument('<option>', 'Option: PATH | build')
667
+ .action((option) => {
668
+ if (option.toUpperCase() === 'BUILD') {
669
+ const configFile = path.join(process.cwd(), 'nova.config.json');
670
+ if (!fs.existsSync(configFile)) {
671
+ console.error(chalk.red('No nova.config.json found in current directory.'));
672
+ console.error(chalk.yellow('Run: novac config init to create one, or create nova.config.json manually.'));
673
+ process.exit(1);
674
+ }
675
+ const config = JSON.parse(fs.readFileSync(configFile, 'utf8'));
676
+ const name = config.name || path.basename(process.cwd());
677
+ const version = config.version || '1.0.0';
678
+ const main = config.main || 'src/main.nova';
679
+ const srcDir = config.srcDir || 'src';
680
+
681
+ function collectNova(dir, base = dir) {
682
+ const results = {};
683
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
684
+ const full = path.join(dir, entry.name);
685
+ const rel = path.relative(base, full);
686
+ if (entry.isDirectory()) Object.assign(results, collectNova(full, base));
687
+ else if (entry.name.endsWith('.nova') || entry.name.endsWith('.nv')) {
688
+ results[rel] = fs.readFileSync(full, 'utf8');
689
+ }
690
+ }
691
+ return results;
692
+ }
693
+
694
+ const srcAbsDir = path.join(process.cwd(), srcDir);
695
+ if (!fs.existsSync(srcAbsDir)) {
696
+ console.error(chalk.red(`Source directory not found: ${srcDir}`));
697
+ process.exit(1);
698
+ }
699
+
700
+ const sources = collectNova(srcAbsDir);
701
+ if (!sources[main] && !sources[path.relative(srcAbsDir, path.join(process.cwd(), main))]) {
702
+ const mainRel = path.relative(srcAbsDir, path.join(process.cwd(), main));
703
+ if (!sources[mainRel] && !sources[main]) {
704
+ console.error(chalk.red(`Main entry "${main}" not found in collected sources.`));
705
+ console.error(chalk.yellow('Sources found: ' + Object.keys(sources).join(', ')));
706
+ process.exit(1);
707
+ }
708
+ }
709
+
710
+ const bundle = {
711
+ manifest: { name, version, main: Object.keys(sources)[0], ...config },
712
+ sources,
713
+ buildTime: new Date().toISOString(),
714
+ };
715
+
716
+ const mainKey = sources[main] ? main
717
+ : sources[path.relative(srcAbsDir, path.join(process.cwd(), main))]
718
+ ? path.relative(srcAbsDir, path.join(process.cwd(), main))
719
+ : Object.keys(sources)[0];
720
+ bundle.manifest.main = mainKey;
721
+
722
+ const outFile = path.join(process.cwd(), `${name}.novamod`);
723
+ fs.writeFileSync(outFile, JSON.stringify(bundle, null, 2), 'utf8');
724
+ console.log(chalk.green(`Built module: ${name}.novamod`));
725
+ console.log(chalk.blue(` ${Object.keys(sources).length} source file(s) bundled`));
726
+ console.log(chalk.blue(` Entry: ${mainKey}`));
727
+ console.log(chalk.blue(` Install in another project: novac module install ./${name}.novamod`));
728
+ } else if (option.toUpperCase() === 'PATH') {
729
+ const platform = os.platform();
730
+ const npmPrefix = execSync('npm config get prefix', { encoding: 'utf8' }).trim();
731
+ const binPath = path.join(npmPrefix, 'bin');
732
+ console.log(chalk.blue(`Adding ${binPath} to PATH for ${platform}...`));
733
+ try {
734
+ if (platform === 'win32') {
735
+ execSync(`setx PATH "%PATH%;${binPath}"`, { stdio: 'inherit' });
736
+ console.log(chalk.green('PATH updated on Windows. Restart your terminal.'));
737
+ } else if (['darwin','linux','freebsd','openbsd','sunos','aix','android'].includes(platform)) {
738
+ const shellRc = platform === 'darwin'
739
+ ? path.join(process.env.HOME, '.zshrc')
740
+ : path.join(process.env.HOME, '.bashrc');
741
+ const exportCmd = `export PATH="$PATH:${binPath}"`;
742
+ execSync(`echo '${exportCmd}' >> ${shellRc}`, { stdio: 'inherit' });
743
+ console.log(chalk.green(`Added to ${shellRc}. Run 'source ${shellRc}' or restart your shell.`));
744
+ } else {
745
+ console.log(chalk.red('Unsupported platform. Manually add to PATH.'));
746
+ }
747
+ } catch (error) {
748
+ console.error(chalk.red('Failed to update PATH:', error.message));
749
+ console.log(chalk.yellow('Please manually add to PATH or run with admin/sudo.'));
750
+ }
751
+ } else {
752
+ console.log(chalk.red(`Unknown option: ${option}. Use 'PATH' or 'build'.`));
753
+ }
754
+ });
755
+
756
+ // ─── novac module <subcommand> ────────────────────────────────────────────────
757
+ const moduleCmd = program.command('module').description('Manage Nova modules');
758
+
759
+ // novac module install [path|glob] [path|glob ...] [--global]
760
+ moduleCmd
761
+ .command('install [modpaths...]')
762
+ .description('Install .novamod bundle(s) or all deps from nova.config.json — supports globs e.g. "dist/*.novamod"')
763
+ .option('-g, --global', 'Install into global nova_modules (~/.novac/nova_modules)')
764
+ .action((modpaths, opts) => {
765
+ const globalDir = path.join(os.homedir(), '.novac', 'nova_modules');
766
+ const novaModulesDir = opts.global ? globalDir : path.join(process.cwd(), 'nova_modules');
767
+ if (opts.global) console.log(chalk.blue(`Installing globally to ${novaModulesDir}`));
768
+ fs.mkdirSync(novaModulesDir, { recursive: true });
769
+
770
+ if (!modpaths || modpaths.length === 0) {
771
+ // ── install all deps from nova.config.json ──
772
+ const configFile = path.join(process.cwd(), 'nova.config.json');
773
+ if (!fs.existsSync(configFile)) {
774
+ console.error(chalk.red('No nova.config.json found. Nothing to install.'));
775
+ process.exit(1);
776
+ }
777
+ const config = JSON.parse(fs.readFileSync(configFile, 'utf8'));
778
+ const deps = { ...(config.dependencies || {}), ...(config.devDependencies || {}) };
779
+ const entries = Object.entries(deps);
780
+ if (entries.length === 0) {
781
+ console.log(chalk.yellow('No dependencies listed in nova.config.json.'));
782
+ return;
783
+ }
784
+ console.log(chalk.blue(`Installing ${entries.length} dependency(ies)...`));
785
+ let ok = 0, fail = 0;
786
+ for (const [name, ref] of entries) {
787
+ const destDir = path.join(novaModulesDir, name);
788
+ if (fs.existsSync(destDir)) {
789
+ console.log(chalk.yellow(` skip ${name} (already installed)`));
790
+ ok++; continue;
791
+ }
792
+ const resolved = path.isAbsolute(ref) ? ref : path.resolve(process.cwd(), ref);
793
+ if (fs.existsSync(resolved) && resolved.endsWith('.novamod')) {
794
+ _installNovamod(resolved, name, novaModulesDir);
795
+ console.log(chalk.green(` ok ${name}`));
796
+ ok++;
797
+ } else {
798
+ const kitDir = path.join(__dirname, '..', 'kits', name);
799
+ if (fs.existsSync(kitDir)) {
800
+ _installKit(kitDir, name, novaModulesDir);
801
+ console.log(chalk.green(` ok ${name} (built-in kit)`));
802
+ ok++;
803
+ } else {
804
+ console.error(chalk.red(` fail ${name}: cannot resolve "${ref}"`));
805
+ fail++;
806
+ }
807
+ }
808
+ }
809
+ console.log(chalk.blue(`Done. ${ok} installed, ${fail} failed.`));
810
+ } else {
811
+ // ── install one or more .novamod files (supports globs) ──
812
+ const resolved = expandFileArgs(modpaths);
813
+ if (resolved.length === 0) {
814
+ console.error(chalk.red('No .novamod files matched:', modpaths.join(', ')));
815
+ process.exit(1);
816
+ }
817
+ let ok = 0, fail = 0;
818
+ for (const modpath of resolved) {
819
+ if (!fs.existsSync(modpath)) {
820
+ console.error(chalk.red(` fail File not found: ${modpath}`));
821
+ fail++; continue;
822
+ }
823
+ if (!modpath.endsWith('.novamod')) {
824
+ console.error(chalk.red(` fail Expected a .novamod file: ${modpath}`));
825
+ fail++; continue;
826
+ }
827
+ const bundle = JSON.parse(fs.readFileSync(modpath, 'utf8'));
828
+ const name = bundle.manifest.name;
829
+ if (!name) {
830
+ console.error(chalk.red(` fail Bundle has no manifest.name: ${modpath}`));
831
+ fail++; continue;
832
+ }
833
+ const destDir = path.join(novaModulesDir, name);
834
+ if (fs.existsSync(destDir)) {
835
+ console.error(chalk.red(` skip "${name}" already installed (remove nova_modules/${name} first)`));
836
+ fail++; continue;
837
+ }
838
+ _installNovamod(modpath, name, novaModulesDir);
839
+ console.log(chalk.green(` ok "${name}" → nova_modules/${name}`));
840
+ console.log(chalk.blue(` Usage: import "${name}"`));
841
+ ok++;
842
+ }
843
+ if (resolved.length > 1) console.log(chalk.blue(`\nDone. ${ok} installed, ${fail} failed/skipped.`));
844
+ }
845
+ });
846
+
847
+ // novac module list [pattern]
848
+ moduleCmd
849
+ .command('list [pattern]')
850
+ .description('List installed modules — optional wildcard filter e.g. "math*"')
851
+ .option('-g, --global', 'List globally installed modules (~/.novac/nova_modules)')
852
+ .action((pattern, opts) => {
853
+ const localDir = path.join(process.cwd(), 'nova_modules');
854
+ const globalDir = path.join(os.homedir(), '.novac', 'nova_modules');
855
+ const novaModulesDir = opts.global ? globalDir : localDir;
856
+ const scope = opts.global ? 'global' : 'local';
857
+
858
+ if (!fs.existsSync(novaModulesDir)) {
859
+ console.log(chalk.yellow(`No ${scope} nova_modules directory found.`));
860
+ return;
861
+ }
862
+ let entries = fs.readdirSync(novaModulesDir, { withFileTypes: true }).filter(e => e.isDirectory());
863
+ if (pattern && isGlobPattern(pattern)) {
864
+ const re = globToRegex(pattern);
865
+ entries = entries.filter(e => re.test(e.name));
866
+ } else if (pattern) {
867
+ entries = entries.filter(e => e.name.includes(pattern));
868
+ }
869
+ if (entries.length === 0) {
870
+ console.log(chalk.yellow(pattern ? `No ${scope} modules matching "${pattern}".` : `No ${scope} modules installed.`));
871
+ return;
872
+ }
873
+ console.log(chalk.blue(`Installed ${scope} modules${pattern ? ` matching "${pattern}"` : ''}:`));
874
+ for (const e of entries) {
875
+ const manifestPath = path.join(novaModulesDir, e.name, 'nova.kit.json');
876
+ const bundleManifest = path.join(novaModulesDir, e.name, 'nova.mod.json');
877
+ let version = '?';
878
+ if (fs.existsSync(manifestPath)) {
879
+ try { version = JSON.parse(fs.readFileSync(manifestPath, 'utf8')).version || '?'; } catch {}
880
+ } else if (fs.existsSync(bundleManifest)) {
881
+ try { version = JSON.parse(fs.readFileSync(bundleManifest, 'utf8')).version || '?'; } catch {}
882
+ }
883
+ console.log(` ${chalk.green(e.name)} ${chalk.gray(version)}`);
884
+ }
885
+ });
886
+
887
+ // novac module remove <name|glob> [name|glob ...]
888
+ moduleCmd
889
+ .command('remove <names...>')
890
+ .description('Remove installed module(s) — supports wildcards e.g. "math*" or "*"')
891
+ .option('-y, --yes', 'Skip confirmation when removing multiple modules')
892
+ .action((names, opts) => {
893
+ const novaModulesDir = path.join(process.cwd(), 'nova_modules');
894
+ const resolved = expandNamesGlob(names, novaModulesDir);
895
+
896
+ if (resolved.length > 1 && !opts.yes) {
897
+ console.log(chalk.yellow(`About to remove ${resolved.length} module(s): ${resolved.join(', ')}`));
898
+ console.log(chalk.yellow('Re-run with --yes to confirm, or remove them one by one.'));
899
+ process.exit(0);
900
+ }
901
+
902
+ let ok = 0, fail = 0;
903
+ for (const name of resolved) {
904
+ const destDir = path.join(novaModulesDir, name);
905
+ if (!fs.existsSync(destDir)) {
906
+ console.error(chalk.red(` fail "${name}" is not installed.`));
907
+ fail++; continue;
908
+ }
909
+ fs.rmSync(destDir, { recursive: true, force: true });
910
+ console.log(chalk.green(` ok "${name}" removed.`));
911
+ ok++;
912
+ }
913
+ if (resolved.length > 1) console.log(chalk.blue(`\nDone. ${ok} removed, ${fail} not found.`));
914
+ if (fail > 0 && ok === 0) process.exit(1);
915
+ });
916
+
917
+ // ── helpers ───────────────────────────────────────────────────────────────────
918
+
919
+ function _installNovamod(resolved, name, novaModulesDir) {
920
+ const bundle = JSON.parse(fs.readFileSync(resolved, 'utf8'));
921
+ const destDir = path.join(novaModulesDir, name);
922
+ fs.mkdirSync(destDir, { recursive: true });
923
+ for (const [relPath, content] of Object.entries(bundle.sources)) {
924
+ const fileDest = path.join(destDir, relPath);
925
+ fs.mkdirSync(path.dirname(fileDest), { recursive: true });
926
+ fs.writeFileSync(fileDest, content, 'utf8');
927
+ }
928
+ fs.copyFileSync(resolved, path.join(destDir, `${name}.novamod`));
929
+ fs.writeFileSync(path.join(destDir, 'nova.mod.json'), JSON.stringify(bundle.manifest, null, 2), 'utf8');
930
+ }
931
+
932
+ function _installKit(kitDir, name, novaModulesDir) {
933
+ const kitdefPath = path.join(kitDir, 'kitdef.js');
934
+ if (!fs.existsSync(kitdefPath)) throw new Error(`Kit ${name} has no kitdef.js`);
935
+ const { kitdef } = require(kitdefPath);
936
+ const destDir = path.join(novaModulesDir, name);
937
+ fs.mkdirSync(destDir, { recursive: true });
938
+ fs.copyFileSync(kitdefPath, path.join(destDir, 'kitdef.js'));
939
+ const members = Object.keys(kitdef);
940
+ const novaLines = [
941
+ `// Auto-generated kit wrapper for ${name}`,
942
+ `let _kit = __js_require("${path.join(destDir, 'kitdef.js')}").kitdef;`,
943
+ ...members.map(m => `export ${m} = _kit.${m};`),
944
+ ].join('\n') + '\n';
945
+ fs.writeFileSync(path.join(destDir, 'index.nova'), novaLines, 'utf8');
946
+ const manifest = { name, version: '1.0.0', main: 'index.nova', type: 'kit' };
947
+ fs.writeFileSync(path.join(destDir, 'nova.kit.json'), JSON.stringify(manifest, null, 2), 'utf8');
948
+ }
949
+
950
+ program.parse();