@panagos/jojoscript 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/jojo.js CHANGED
@@ -1,170 +1,430 @@
1
- #!/usr/bin/env node
2
-
3
- import fs from 'node:fs'
4
- import path from 'node:path'
5
- import { fileURLToPath } from 'node:url'
6
- import { spawn } from 'node:child_process'
7
-
8
- import { compileFile, compileDirectory } from '../src/compiler.js'
9
-
10
- const PACKAGE_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..')
11
-
12
- function packageVersion() {
13
- const pkg = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8'))
14
- return pkg.version
15
- }
16
-
17
- function usage() {
18
- console.log(`JojoScript CLI v${packageVersion()}
19
-
20
- Usage:
21
- jojo build <file.jojo|directory> [--out <directory>] [--watch]
22
- jojo run <file.jojo> [--out <directory>]
23
- jojo --version
24
- jojo --help
25
-
26
- Examples:
27
- jojo build src
28
- jojo build src/main.jojo
29
- jojo build src --out dist
30
- jojo build src --watch
31
- jojo run src/main.jojo
32
- `)
33
- }
34
-
35
- /*
36
- * All CLI-facing failures (bad arguments, missing files, compiler syntax
37
- * errors) are surfaced as a single friendly `jojo: <message>` line instead
38
- * of a raw stack trace.
39
- */
40
- function fail(message) {
41
- throw new Error(message)
42
- }
43
-
44
- function parseFlags(args, startIndex) {
45
- let out = null
46
- let watch = false
47
-
48
- for (let i = startIndex; i < args.length; i++) {
49
- const arg = args[i]
50
-
51
- if (arg === '--out') {
52
- const value = args[++i]
53
- if (!value) fail('--out requires a directory')
54
- out = path.resolve(value)
55
- continue
56
- }
57
-
58
- if (arg === '--watch') {
59
- watch = true
60
- continue
61
- }
62
-
63
- fail(`Unknown option: ${arg}`)
64
- }
65
-
66
- return { out, watch }
67
- }
68
-
69
- function build(input, out) {
70
- const resolvedInput = path.resolve(input)
71
-
72
- if (!fs.existsSync(resolvedInput)) {
73
- fail(`Not found: ${resolvedInput}`)
74
- }
75
-
76
- const isDirectory = fs.statSync(resolvedInput).isDirectory()
77
-
78
- const result = isDirectory
79
- ? compileDirectory(resolvedInput, out)
80
- : [compileFile(resolvedInput, out)]
81
-
82
- for (const item of result) {
83
- console.log(`Compiled ${item.input} -> ${item.output}`)
84
- }
85
-
86
- return result
87
- }
88
-
89
- function watch(input, out) {
90
- const resolvedInput = path.resolve(input)
91
- console.log(`Watching ${resolvedInput} for changes (Ctrl+C to stop)...`)
92
-
93
- const rebuild = () => {
94
- try {
95
- build(input, out)
96
- } catch (error) {
97
- console.error(`jojo: ${error.message}`)
98
- }
99
- }
100
-
101
- rebuild()
102
-
103
- fs.watch(resolvedInput, { recursive: true }, (_, filename) => {
104
- if (filename && !filename.endsWith('.jojo')) return
105
- console.log(`\nChange detected${filename ? `: ${filename}` : ''}, rebuilding...`)
106
- rebuild()
107
- })
108
- }
109
-
110
- function run(input, out) {
111
- const resolvedInput = path.resolve(input)
112
-
113
- if (!fs.existsSync(resolvedInput) || fs.statSync(resolvedInput).isDirectory()) {
114
- fail('`jojo run` requires a single .jojo file, not a directory')
115
- }
116
-
117
- const [{ output }] = build(input, out)
118
-
119
- const child = spawn(process.execPath, [output], { stdio: 'inherit' })
120
- child.on('exit', code => process.exit(code ?? 0))
121
- child.on('error', error => fail(error.message))
122
- }
123
-
124
- function main() {
125
- const args = process.argv.slice(2)
126
-
127
- if (!args.length || args[0] === '--help' || args[0] === '-h') {
128
- usage()
129
- return
130
- }
131
-
132
- if (args[0] === '--version' || args[0] === '-v') {
133
- console.log(packageVersion())
134
- return
135
- }
136
-
137
- const command = args[0]
138
-
139
- if (command !== 'build' && command !== 'run') {
140
- usage()
141
- fail(`Unknown command: ${command}`)
142
- }
143
-
144
- if (!args[1] || args[1].startsWith('-')) {
145
- fail(`\`jojo ${command}\` requires a file or directory argument`)
146
- }
147
-
148
- const input = args[1]
149
- const { out, watch: shouldWatch } = parseFlags(args, 2)
150
-
151
- if (command === 'run') {
152
- if (shouldWatch) fail('--watch is not supported with `jojo run`, use `jojo build --watch` instead')
153
- run(input, out)
154
- return
155
- }
156
-
157
- if (shouldWatch) {
158
- watch(input, out)
159
- return
160
- }
161
-
162
- build(input, out)
163
- }
164
-
165
- try {
166
- main()
167
- } catch (error) {
168
- console.error(`jojo: ${error.message}`)
169
- process.exit(1)
170
- }
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+ import { fileURLToPath, pathToFileURL } from 'node:url'
6
+ import { spawn, spawnSync } from 'node:child_process'
7
+
8
+ import { compile, compileFile, compileDirectory } from '../src/compiler.js'
9
+ import { analyzePipelines, renderGraphText, renderGraphDot } from '../src/pipeline-ir.js'
10
+ import { FileCheckpointStore } from '../src/runtime/checkpoint-store.js'
11
+
12
+ const PACKAGE_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..')
13
+ const RUNTIME_IMPORT = pathToFileURL(path.join(PACKAGE_ROOT, 'src/runtime/index.js')).href
14
+
15
+ function packageVersion() {
16
+ const pkg = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8'))
17
+ return pkg.version
18
+ }
19
+
20
+ function usage() {
21
+ console.log(`JojoScript CLI v${packageVersion()}
22
+
23
+ Usage:
24
+ jojo build <file.jojo|directory> [--out <directory>] [--watch]
25
+ jojo run <file.jojo> [--out <directory>]
26
+ jojo graph <file.jojo> [--format text|dot]
27
+ jojo profile <file.jojo>
28
+ jojo resume <file.jojo> [--out <directory>]
29
+ jojo replay <file.jojo> --from <checkpoint> [--out <directory>]
30
+ jojo test <file.jojo>
31
+ jojo --version
32
+ jojo --help
33
+
34
+ Examples:
35
+ jojo build src
36
+ jojo build src/main.jojo
37
+ jojo build src --out dist
38
+ jojo build src --watch
39
+ jojo run src/main.jojo
40
+ jojo graph orders.jojo
41
+ jojo graph orders.jojo --format dot
42
+ jojo profile orders.jojo
43
+ jojo resume orders.jojo
44
+ jojo replay orders.jojo --from enriched
45
+ jojo test orders.jojo
46
+ `)
47
+ }
48
+
49
+ /*
50
+ * All CLI-facing failures (bad arguments, missing files, compiler syntax
51
+ * errors) are surfaced as a single friendly `jojo: <message>` line instead
52
+ * of a raw stack trace.
53
+ */
54
+ function fail(message) {
55
+ throw new Error(message)
56
+ }
57
+
58
+ function parseFlags(args, startIndex) {
59
+ let out = null
60
+ let watch = false
61
+
62
+ for (let i = startIndex; i < args.length; i++) {
63
+ const arg = args[i]
64
+
65
+ if (arg === '--out') {
66
+ const value = args[++i]
67
+ if (!value) fail('--out requires a directory')
68
+ out = path.resolve(value)
69
+ continue
70
+ }
71
+
72
+ if (arg === '--watch') {
73
+ watch = true
74
+ continue
75
+ }
76
+
77
+ fail(`Unknown option: ${arg}`)
78
+ }
79
+
80
+ return { out, watch }
81
+ }
82
+
83
+ function build(input, out) {
84
+ const resolvedInput = path.resolve(input)
85
+
86
+ if (!fs.existsSync(resolvedInput)) {
87
+ fail(`Not found: ${resolvedInput}`)
88
+ }
89
+
90
+ const isDirectory = fs.statSync(resolvedInput).isDirectory()
91
+
92
+ const result = isDirectory
93
+ ? compileDirectory(resolvedInput, out, { runtimeImport: RUNTIME_IMPORT })
94
+ : [compileFile(resolvedInput, out, { runtimeImport: RUNTIME_IMPORT })]
95
+
96
+ for (const item of result) {
97
+ console.log(`Compiled ${item.input} -> ${item.output}`)
98
+ }
99
+
100
+ return result
101
+ }
102
+
103
+ function watch(input, out) {
104
+ const resolvedInput = path.resolve(input)
105
+ console.log(`Watching ${resolvedInput} for changes (Ctrl+C to stop)...`)
106
+
107
+ const rebuild = () => {
108
+ try {
109
+ build(input, out)
110
+ } catch (error) {
111
+ console.error(`jojo: ${error.message}`)
112
+ }
113
+ }
114
+
115
+ rebuild()
116
+
117
+ fs.watch(resolvedInput, { recursive: true }, (_, filename) => {
118
+ if (filename && !filename.endsWith('.jojo')) return
119
+ console.log(`\nChange detected${filename ? `: ${filename}` : ''}, rebuilding...`)
120
+ rebuild()
121
+ })
122
+ }
123
+
124
+ function run(input, out) {
125
+ const resolvedInput = path.resolve(input)
126
+
127
+ if (!fs.existsSync(resolvedInput) || fs.statSync(resolvedInput).isDirectory()) {
128
+ fail('`jojo run` requires a single .jojo file, not a directory')
129
+ }
130
+
131
+ const [{ output }] = build(input, out)
132
+
133
+ const child = spawn(process.execPath, [output], { stdio: 'inherit' })
134
+ child.on('exit', code => process.exit(code ?? 0))
135
+ child.on('error', error => fail(error.message))
136
+ }
137
+
138
+ function requireSingleFile(command, input) {
139
+ const resolvedInput = path.resolve(input)
140
+
141
+ if (!fs.existsSync(resolvedInput)) fail(`Not found: ${resolvedInput}`)
142
+ if (fs.statSync(resolvedInput).isDirectory()) {
143
+ fail(`\`jojo ${command}\` requires a single .jojo file, not a directory`)
144
+ }
145
+
146
+ return resolvedInput
147
+ }
148
+
149
+ function graph(input, format) {
150
+ const resolvedInput = requireSingleFile('graph', input)
151
+ const source = fs.readFileSync(resolvedInput, 'utf8')
152
+ const pipelines = analyzePipelines(source)
153
+
154
+ if (!pipelines.length) {
155
+ console.log('No pipelines found.')
156
+ return
157
+ }
158
+
159
+ pipelines.forEach((pipeline, index) => {
160
+ if (pipelines.length > 1) console.log(`\nPipeline #${index + 1} (id: ${pipeline.id})`)
161
+ console.log(format === 'dot' ? renderGraphDot(pipeline) : renderGraphText(pipeline))
162
+ })
163
+ }
164
+
165
+ function profile(input) {
166
+ const resolvedInput = requireSingleFile('profile', input)
167
+ const source = fs.readFileSync(resolvedInput, 'utf8')
168
+
169
+ let compiled
170
+ try {
171
+ compiled = compile(source, { profile: true, runtimeImport: RUNTIME_IMPORT })
172
+ } catch (error) {
173
+ fail(error.message)
174
+ }
175
+
176
+ /*
177
+ * Compiled next to the input file (not under the OS temp directory):
178
+ * Node's ESM resolver walks *up* from the compiled file looking for the
179
+ * nearest `package.json` to resolve bare specifiers like the runtime
180
+ * import, and an unrelated/broken `package.json` anywhere above a temp
181
+ * directory would break that resolution.
182
+ */
183
+ const tmpDir = fs.mkdtempSync(path.join(path.dirname(resolvedInput), '.jojo-profile-'))
184
+ const outFile = path.join(tmpDir, path.basename(resolvedInput, path.extname(resolvedInput)) + '.mjs')
185
+ const metricsFile = path.join(tmpDir, 'metrics.json')
186
+
187
+ fs.writeFileSync(outFile, compiled, 'utf8')
188
+
189
+ const result = spawnSync(process.execPath, [outFile], {
190
+ stdio: 'inherit',
191
+ env: { ...process.env, JOJO_PROFILE_OUT: metricsFile }
192
+ })
193
+
194
+ try {
195
+ if (result.error) fail(result.error.message)
196
+
197
+ if (!fs.existsSync(metricsFile)) {
198
+ console.log('No pipeline metrics were recorded.')
199
+ return
200
+ }
201
+
202
+ printProfileTable(JSON.parse(fs.readFileSync(metricsFile, 'utf8')))
203
+ } finally {
204
+ fs.rmSync(tmpDir, { recursive: true, force: true })
205
+ }
206
+
207
+ if (result.status) process.exit(result.status)
208
+ }
209
+
210
+ function printProfileTable(metrics) {
211
+ const rows = metrics.nodes ?? []
212
+
213
+ if (!rows.length) {
214
+ console.log('No pipeline metrics were recorded.')
215
+ return
216
+ }
217
+
218
+ const columns = [22, 13, 15]
219
+ console.log(
220
+ 'Node'.padEnd(columns[0]) +
221
+ 'Items'.padEnd(columns[1]) +
222
+ 'Duration'.padEnd(columns[2]) +
223
+ 'Throughput'
224
+ )
225
+ console.log('-'.repeat(columns.reduce((a, b) => a + b, 0) + 10))
226
+
227
+ for (const row of rows) {
228
+ const duration = `${(row.durationMs / 1000).toFixed(1)}s`
229
+ console.log(
230
+ String(row.type).padEnd(columns[0]) +
231
+ String(row.items).padEnd(columns[1]) +
232
+ duration.padEnd(columns[2]) +
233
+ formatThroughput(row.throughput)
234
+ )
235
+ }
236
+ }
237
+
238
+ function formatThroughput(value) {
239
+ if (value >= 1e6) return `${(value / 1e6).toFixed(1)}M/s`
240
+ if (value >= 1e3) return `${(value / 1e3).toFixed(0)}K/s`
241
+ return `${Math.round(value)}/s`
242
+ }
243
+
244
+ function resume(input, out) {
245
+ const resolvedInput = requireSingleFile('resume', input)
246
+ const source = fs.readFileSync(resolvedInput, 'utf8')
247
+ const pipelines = analyzePipelines(source)
248
+ const checkpointDir = process.env.JOJO_CHECKPOINT_DIR || path.resolve('.jojo-checkpoints')
249
+
250
+ let anyFound = false
251
+ for (const pipeline of pipelines) {
252
+ const store = new FileCheckpointStore(checkpointDir)
253
+ const existing = store.list(pipeline.id)
254
+ if (existing.length) {
255
+ anyFound = true
256
+ console.log(`Resuming pipeline ${pipeline.id}: reusing checkpoint(s) ${existing.join(', ')}`)
257
+ }
258
+ }
259
+
260
+ if (!anyFound) {
261
+ console.log('No existing checkpoints found; running from the start.')
262
+ }
263
+
264
+ run(input, out)
265
+ }
266
+
267
+ function replay(input, out, from) {
268
+ if (!from) fail('`jojo replay` requires --from <checkpoint>')
269
+
270
+ console.log(`Replaying from checkpoint: ${from}`)
271
+ process.env.JOJO_REPLAY_FROM = from
272
+ run(input, out)
273
+ }
274
+
275
+ function parseGraphFlags(args, startIndex) {
276
+ let format = 'text'
277
+
278
+ for (let i = startIndex; i < args.length; i++) {
279
+ const arg = args[i]
280
+
281
+ if (arg === '--format') {
282
+ const value = args[++i]
283
+ if (value !== 'text' && value !== 'dot') fail('--format must be "text" or "dot"')
284
+ format = value
285
+ continue
286
+ }
287
+
288
+ fail(`Unknown option: ${arg}`)
289
+ }
290
+
291
+ return { format }
292
+ }
293
+
294
+ function parseReplayFlags(args, startIndex) {
295
+ let out = null
296
+ let from = null
297
+
298
+ for (let i = startIndex; i < args.length; i++) {
299
+ const arg = args[i]
300
+
301
+ if (arg === '--out') {
302
+ const value = args[++i]
303
+ if (!value) fail('--out requires a directory')
304
+ out = path.resolve(value)
305
+ continue
306
+ }
307
+
308
+ if (arg === '--from') {
309
+ const value = args[++i]
310
+ if (!value) fail('--from requires a checkpoint name')
311
+ from = value
312
+ continue
313
+ }
314
+
315
+ fail(`Unknown option: ${arg}`)
316
+ }
317
+
318
+ return { out, from }
319
+ }
320
+
321
+ function testCmd(input) {
322
+ const resolvedInput = requireSingleFile('test', input)
323
+
324
+ try {
325
+ compileFile(resolvedInput, null, { runtimeImport: RUNTIME_IMPORT })
326
+ } catch (error) {
327
+ fail(error.message)
328
+ }
329
+
330
+ const dir = path.dirname(resolvedInput)
331
+ const base = path.basename(resolvedInput, path.extname(resolvedInput))
332
+ const candidates = [
333
+ path.join(dir, `${base}.test.js`),
334
+ path.join(dir, 'test', `${base}.test.js`)
335
+ ].filter(candidate => fs.existsSync(candidate))
336
+
337
+ if (!candidates.length) {
338
+ console.log(`${resolvedInput} compiled successfully. No test file found (looked for ${base}.test.js).`)
339
+ return
340
+ }
341
+
342
+ const result = spawnSync(process.execPath, ['--test', ...candidates], {
343
+ encoding: 'utf8',
344
+ env: Object.fromEntries(
345
+ Object.entries(process.env).filter(([name]) => name !== 'NODE_TEST_CONTEXT')
346
+ )
347
+ })
348
+ if (result.stdout) process.stdout.write(result.stdout)
349
+ if (result.stderr) process.stdout.write(result.stderr)
350
+ process.exit(result.status ?? 0)
351
+ }
352
+
353
+ const COMMANDS = new Set(['build', 'run', 'graph', 'profile', 'resume', 'replay', 'test'])
354
+
355
+ function main() {
356
+ const args = process.argv.slice(2)
357
+
358
+ if (!args.length || args[0] === '--help' || args[0] === '-h') {
359
+ usage()
360
+ return
361
+ }
362
+
363
+ if (args[0] === '--version' || args[0] === '-v') {
364
+ console.log(packageVersion())
365
+ return
366
+ }
367
+
368
+ const command = args[0]
369
+
370
+ if (!COMMANDS.has(command)) {
371
+ usage()
372
+ fail(`Unknown command: ${command}`)
373
+ }
374
+
375
+ if (!args[1] || args[1].startsWith('-')) {
376
+ fail(`\`jojo ${command}\` requires a file or directory argument`)
377
+ }
378
+
379
+ const input = args[1]
380
+
381
+ if (command === 'graph') {
382
+ const { format } = parseGraphFlags(args, 2)
383
+ graph(input, format)
384
+ return
385
+ }
386
+
387
+ if (command === 'profile') {
388
+ profile(input)
389
+ return
390
+ }
391
+
392
+ if (command === 'test') {
393
+ testCmd(input)
394
+ return
395
+ }
396
+
397
+ if (command === 'replay') {
398
+ const { out, from } = parseReplayFlags(args, 2)
399
+ replay(input, out, from)
400
+ return
401
+ }
402
+
403
+ const { out, watch: shouldWatch } = parseFlags(args, 2)
404
+
405
+ if (command === 'resume') {
406
+ if (shouldWatch) fail('--watch is not supported with `jojo resume`')
407
+ resume(input, out)
408
+ return
409
+ }
410
+
411
+ if (command === 'run') {
412
+ if (shouldWatch) fail('--watch is not supported with `jojo run`, use `jojo build --watch` instead')
413
+ run(input, out)
414
+ return
415
+ }
416
+
417
+ if (shouldWatch) {
418
+ watch(input, out)
419
+ return
420
+ }
421
+
422
+ build(input, out)
423
+ }
424
+
425
+ try {
426
+ main()
427
+ } catch (error) {
428
+ console.error(`jojo: ${error.message}`)
429
+ process.exit(1)
430
+ }