@riddledc/riddle-proof 0.5.0 → 0.5.2

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 (35) hide show
  1. package/README.md +6 -6
  2. package/dist/chunk-3MHFLQKG.js +853 -0
  3. package/dist/{chunk-LVP22WE4.js → chunk-5GZZZ6JA.js} +5 -1
  4. package/dist/engine-harness.cjs +2505 -22
  5. package/dist/engine-harness.js +1 -1
  6. package/dist/index.cjs +2512 -29
  7. package/dist/index.js +2 -2
  8. package/dist/openclaw.cjs +1 -1
  9. package/dist/openclaw.js +1 -1
  10. package/dist/proof-run-core.cjs +909 -0
  11. package/dist/proof-run-core.d.cts +280 -0
  12. package/dist/proof-run-core.d.ts +280 -0
  13. package/dist/proof-run-core.js +48 -0
  14. package/dist/proof-run-engine.cjs +2499 -0
  15. package/dist/proof-run-engine.d.cts +677 -0
  16. package/dist/proof-run-engine.d.ts +677 -0
  17. package/dist/proof-run-engine.js +1649 -0
  18. package/lib/workspace-core.mjs +391 -0
  19. package/package.json +15 -3
  20. package/runtime/lib/author.py +343 -0
  21. package/runtime/lib/implement.py +63 -0
  22. package/runtime/lib/preflight.py +246 -0
  23. package/runtime/lib/recon.py +1048 -0
  24. package/runtime/lib/riddle_core_call.mjs +151 -0
  25. package/runtime/lib/setup.py +387 -0
  26. package/runtime/lib/ship.py +834 -0
  27. package/runtime/lib/util.py +673 -0
  28. package/runtime/lib/verify.py +1223 -0
  29. package/runtime/pipelines/riddle-proof-author.lobster +28 -0
  30. package/runtime/pipelines/riddle-proof-implement.lobster +26 -0
  31. package/runtime/pipelines/riddle-proof-recon.lobster +79 -0
  32. package/runtime/pipelines/riddle-proof-setup.lobster +141 -0
  33. package/runtime/pipelines/riddle-proof-ship.lobster +36 -0
  34. package/runtime/pipelines/riddle-proof-verify.lobster +74 -0
  35. package/runtime/tests/recon_verify_smoke.py +1198 -0
@@ -0,0 +1,673 @@
1
+ """Shared helpers for Riddle Proof pipeline."""
2
+
3
+ import json, subprocess as sp, os, shlex, time
4
+ from urllib.parse import urljoin
5
+ from urllib.request import urlopen
6
+
7
+ STATE_FILE = os.environ.get('RIDDLE_PROOF_STATE_FILE', '/tmp/riddle-proof-state.json')
8
+ ARGS_FILE = os.environ.get('RIDDLE_PROOF_ARGS_FILE', '/tmp/riddle-proof-args.json')
9
+ RIDDLE_DIRECT_TOOLS = {
10
+ 'riddle_preview',
11
+ 'riddle_preview_delete',
12
+ 'riddle_server_preview',
13
+ 'riddle_build_preview',
14
+ 'riddle_script',
15
+ 'riddle_run',
16
+ }
17
+ CAPTURE_ARTIFACT_JSON_LIMIT = 256 * 1024
18
+ _JSON_ARTIFACT_CACHE = {}
19
+ CAPTURE_DIAGNOSTIC_VERSION = 'riddle-proof.capture-diagnostic.v1'
20
+ DEBUG_STRING_LIMIT = 2000
21
+ SENSITIVE_KEY_FRAGMENTS = (
22
+ 'authorization',
23
+ 'apikey',
24
+ 'api_key',
25
+ 'cookie',
26
+ 'header',
27
+ 'localstorage',
28
+ 'password',
29
+ 'secret',
30
+ 'token',
31
+ )
32
+
33
+
34
+ def load_state():
35
+ with open(STATE_FILE) as f:
36
+ return json.load(f)
37
+
38
+
39
+ def save_state(s):
40
+ with open(STATE_FILE, 'w') as f:
41
+ json.dump(s, f, indent=2)
42
+
43
+
44
+ def compact_debug_value(value, limit=DEBUG_STRING_LIMIT):
45
+ if isinstance(value, str) and len(value) > limit:
46
+ return value[:limit] + '... [truncated]'
47
+ return value
48
+
49
+
50
+ def redact_for_diagnostics(value):
51
+ if isinstance(value, dict):
52
+ redacted = {}
53
+ for key, child in value.items():
54
+ normalized = ''.join(ch for ch in str(key).lower() if ch.isalnum() or ch == '_')
55
+ if any(fragment.replace('_', '') in normalized.replace('_', '') for fragment in SENSITIVE_KEY_FRAGMENTS):
56
+ redacted[key] = '[redacted]'
57
+ else:
58
+ redacted[key] = redact_for_diagnostics(child)
59
+ return redacted
60
+ if isinstance(value, list):
61
+ return [redact_for_diagnostics(child) for child in value[:50]]
62
+ return compact_debug_value(value)
63
+
64
+
65
+ def capture_diagnostic(label, tool, args, payload):
66
+ payload = payload if isinstance(payload, dict) else {'raw': payload}
67
+ return {
68
+ 'version': CAPTURE_DIAGNOSTIC_VERSION,
69
+ 'label': label,
70
+ 'tool': tool,
71
+ 'captured_at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
72
+ 'ok': payload.get('ok'),
73
+ 'timeout': bool(payload.get('timeout')),
74
+ 'error': compact_debug_value(str(payload.get('error') or payload.get('stderr') or '')),
75
+ 'args': redact_for_diagnostics({} if args is None else args),
76
+ 'artifact_summary': summarize_capture_artifacts(payload),
77
+ }
78
+
79
+
80
+ def append_capture_diagnostic(state, label, tool, args, payload):
81
+ diagnostics = list(state.get('capture_diagnostics') or [])
82
+ diagnostics.append(capture_diagnostic(label, tool, args, payload))
83
+ state['capture_diagnostics'] = diagnostics[-20:]
84
+ return state['capture_diagnostics'][-1]
85
+
86
+
87
+ def truthy(value):
88
+ return str(value or '').strip().lower() in ('1', 'true', 'yes', 'y', 'on')
89
+
90
+
91
+ def has_auth_context(state):
92
+ return bool(
93
+ truthy(state.get('use_auth'))
94
+ or state.get('auth_localStorage')
95
+ or state.get('auth_cookies')
96
+ or state.get('auth_headers')
97
+ )
98
+
99
+
100
+ def apply_auth_context(state, args):
101
+ if state.get('auth_localStorage'):
102
+ args['localStorage'] = state['auth_localStorage']
103
+ if state.get('auth_cookies'):
104
+ args['cookies'] = state['auth_cookies']
105
+ if state.get('auth_headers'):
106
+ args['headers'] = state['auth_headers']
107
+ return args
108
+
109
+
110
+ def direct_riddle_enabled():
111
+ return os.environ.get('RIDDLE_PROOF_DIRECT_RIDDLE', '1').lower() not in ('0', 'false', 'no')
112
+
113
+
114
+ def nested_riddle_fallback_enabled():
115
+ return os.environ.get('RIDDLE_PROOF_ALLOW_NESTED_RIDDLE', '').lower() in ('1', 'true', 'yes')
116
+
117
+
118
+ def nested_non_riddle_enabled():
119
+ return os.environ.get('RIDDLE_PROOF_ALLOW_NESTED_NON_RIDDLE', '').lower() in ('1', 'true', 'yes')
120
+
121
+
122
+ def invoke_riddle_core(tool, args, timeout=180):
123
+ """Call Riddle's shared core package directly, without nested OpenClaw tool invocation."""
124
+ script = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'riddle_core_call.mjs')
125
+ try:
126
+ r = sp.run(
127
+ ['node', script, tool, json.dumps(args)],
128
+ capture_output=True, text=True, timeout=timeout
129
+ )
130
+ except sp.TimeoutExpired as e:
131
+ print('direct_riddle(' + tool + ') TIMED OUT after ' + str(timeout) + 's')
132
+ if e.stdout:
133
+ print(' stdout: ' + e.stdout[:500])
134
+ if e.stderr:
135
+ print(' stderr: ' + e.stderr[:500])
136
+ return {
137
+ 'ok': False,
138
+ 'timeout': True,
139
+ 'error': f'direct_riddle({tool}) timed out after {timeout}s',
140
+ 'stdout': (e.stdout or '')[:500],
141
+ 'stderr': (e.stderr or '')[:500],
142
+ }
143
+
144
+ if r.returncode != 0:
145
+ print('direct_riddle(' + tool + ') FAILED rc=' + str(r.returncode))
146
+ print(' stdout: ' + r.stdout[:500])
147
+ print(' stderr: ' + r.stderr[:500])
148
+
149
+ try:
150
+ return json.loads(r.stdout)
151
+ except:
152
+ print('direct_riddle(' + tool + ') JSON parse failed')
153
+ print(' stdout: ' + r.stdout[:500])
154
+ print(' stderr: ' + r.stderr[:500])
155
+ return {'ok': False, 'error': r.stdout[:300], 'stderr': r.stderr[:300]}
156
+
157
+
158
+ def invoke(tool, args, timeout=180):
159
+ """Call an OpenClaw tool via openclaw.invoke CLI."""
160
+ if tool in RIDDLE_DIRECT_TOOLS and direct_riddle_enabled():
161
+ result = invoke_riddle_core(tool, args, timeout=timeout)
162
+ if result.get('ok') or not nested_riddle_fallback_enabled():
163
+ return result
164
+ print('direct_riddle(' + tool + ') failed; falling back to openclaw.invoke because RIDDLE_PROOF_ALLOW_NESTED_RIDDLE is set.')
165
+
166
+ if tool not in RIDDLE_DIRECT_TOOLS and not nested_non_riddle_enabled():
167
+ return {
168
+ 'ok': False,
169
+ 'error': (
170
+ 'Nested OpenClaw tool invocation is disabled for ' + tool +
171
+ '. Set RIDDLE_PROOF_ALLOW_NESTED_NON_RIDDLE=1 only if this workflow intentionally needs another plugin.'
172
+ ),
173
+ }
174
+
175
+ try:
176
+ r = sp.run(
177
+ ['openclaw.invoke', '--tool', tool, '--args-json', json.dumps(args)],
178
+ capture_output=True, text=True, timeout=timeout
179
+ )
180
+ except sp.TimeoutExpired as e:
181
+ print('invoke(' + tool + ') TIMED OUT after ' + str(timeout) + 's')
182
+ if e.stdout:
183
+ print(' stdout: ' + e.stdout[:500])
184
+ if e.stderr:
185
+ print(' stderr: ' + e.stderr[:500])
186
+ return {
187
+ 'ok': False,
188
+ 'timeout': True,
189
+ 'error': f'invoke({tool}) timed out after {timeout}s',
190
+ 'stdout': (e.stdout or '')[:500],
191
+ 'stderr': (e.stderr or '')[:500],
192
+ }
193
+ if r.returncode != 0:
194
+ print('invoke(' + tool + ') FAILED rc=' + str(r.returncode))
195
+ print(' stdout: ' + r.stdout[:500])
196
+ print(' stderr: ' + r.stderr[:500])
197
+ try:
198
+ outer = json.loads(r.stdout)
199
+ if 'result' in outer and 'content' in outer['result']:
200
+ for c in outer['result']['content']:
201
+ if c.get('type') == 'text':
202
+ try:
203
+ return json.loads(c['text'])
204
+ except:
205
+ return {'ok': True, 'raw': c['text']}
206
+ return outer
207
+ except:
208
+ print('invoke(' + tool + ') JSON parse failed')
209
+ print(' stdout: ' + r.stdout[:500])
210
+ print(' stderr: ' + r.stderr[:500])
211
+ return {'ok': False, 'error': r.stdout[:300], 'stderr': r.stderr[:300]}
212
+
213
+
214
+ def invoke_retry(tool, args, retries=3, timeout=180):
215
+ """Call an OpenClaw tool with automatic retries on failure."""
216
+ last_result = None
217
+ for attempt in range(1, retries + 1):
218
+ result = invoke(tool, args, timeout=timeout)
219
+ last_result = result
220
+ # Check for success indicators
221
+ if result.get('ok') or result.get('outputs') or result.get('screenshots'):
222
+ return result
223
+ print(f'invoke_retry({tool}) attempt {attempt}/{retries} failed: {str(result.get("error", "no output"))[:200]}')
224
+ if attempt < retries:
225
+ import time
226
+ time.sleep(5)
227
+ print(f'invoke_retry({tool}) all {retries} attempts failed')
228
+ return last_result or {'ok': False, 'error': 'all retries exhausted'}
229
+
230
+
231
+ def capture_output_item(payload, name):
232
+ if not isinstance(payload, dict):
233
+ return None
234
+ for item in payload.get('outputs') or []:
235
+ if isinstance(item, dict) and item.get('name') == name and item.get('url'):
236
+ return item
237
+ return None
238
+
239
+
240
+ def fetch_json_artifact(url, max_bytes=CAPTURE_ARTIFACT_JSON_LIMIT):
241
+ if not str(url or '').startswith(('http://', 'https://')):
242
+ return None, 'unsupported artifact url'
243
+ if url in _JSON_ARTIFACT_CACHE:
244
+ return _JSON_ARTIFACT_CACHE[url]
245
+ try:
246
+ with urlopen(url, timeout=15) as response:
247
+ data = response.read(max_bytes + 1)
248
+ except Exception as e:
249
+ result = (None, type(e).__name__ + ': ' + str(e))
250
+ _JSON_ARTIFACT_CACHE[url] = result
251
+ return result
252
+ if len(data) > max_bytes:
253
+ result = (None, 'artifact exceeds ' + str(max_bytes) + ' bytes')
254
+ _JSON_ARTIFACT_CACHE[url] = result
255
+ return result
256
+ try:
257
+ result = (json.loads(data.decode('utf-8')), '')
258
+ except Exception as e:
259
+ result = (None, 'json parse failed: ' + str(e))
260
+ _JSON_ARTIFACT_CACHE[url] = result
261
+ return result
262
+
263
+
264
+ def enrich_capture_payload(payload):
265
+ """Attach JSON artifacts that Riddle previews return as URLs instead of inline data."""
266
+ if not isinstance(payload, dict):
267
+ return payload
268
+ enriched = dict(payload)
269
+ artifact_json = dict(enriched.get('_artifact_json') or {})
270
+ artifact_errors = dict(enriched.get('_artifact_errors') or {})
271
+
272
+ for name in ('console.json', 'proof.json'):
273
+ if name in artifact_json or name in artifact_errors:
274
+ continue
275
+ item = capture_output_item(enriched, name)
276
+ if not item:
277
+ continue
278
+ data, error = fetch_json_artifact(item.get('url', ''))
279
+ if error:
280
+ artifact_errors[name] = error
281
+ elif data is not None:
282
+ artifact_json[name] = data
283
+
284
+ if artifact_json:
285
+ enriched['_artifact_json'] = artifact_json
286
+ if artifact_errors:
287
+ enriched['_artifact_errors'] = artifact_errors
288
+
289
+ console_json = artifact_json.get('console.json')
290
+ if console_json is not None and not enriched.get('console'):
291
+ enriched['console'] = console_json
292
+
293
+ proof_json = artifact_json.get('proof.json')
294
+ if isinstance(proof_json, dict):
295
+ enriched['_proof_json'] = proof_json
296
+ if not enriched.get('result'):
297
+ for key in ('result', 'script_result', 'return_value', 'value'):
298
+ result = proof_json.get(key)
299
+ if isinstance(result, dict):
300
+ enriched['result'] = result
301
+ break
302
+
303
+ return enriched
304
+
305
+
306
+ def summarize_capture_artifacts(payload):
307
+ if not isinstance(payload, dict):
308
+ return {}
309
+ enriched = enrich_capture_payload(payload)
310
+ proof_json = enriched.get('_proof_json') or {}
311
+ console_json = (enriched.get('_artifact_json') or {}).get('console.json')
312
+ result = enriched.get('result') if isinstance(enriched.get('result'), dict) else {}
313
+ return {
314
+ 'outputs': [
315
+ {'name': item.get('name', ''), 'url': item.get('url', '')}
316
+ for item in (enriched.get('outputs') or [])
317
+ if isinstance(item, dict)
318
+ ][:20],
319
+ 'screenshots': [
320
+ {'name': item.get('name', ''), 'url': item.get('url', '')}
321
+ for item in (enriched.get('screenshots') or [])
322
+ if isinstance(item, dict)
323
+ ][:10],
324
+ 'artifacts': [
325
+ summarize_capture_artifact_item(item)
326
+ for item in (enriched.get('artifacts') or [])
327
+ if isinstance(item, dict)
328
+ ][:20],
329
+ 'result_keys': sorted(result.keys()),
330
+ 'artifact_json': sorted((enriched.get('_artifact_json') or {}).keys()),
331
+ 'artifact_errors': dict(enriched.get('_artifact_errors') or {}),
332
+ 'proof_script_error': bool(isinstance(proof_json, dict) and proof_json.get('script_error')),
333
+ 'console_summary': console_json.get('summary', {}) if isinstance(console_json, dict) else {},
334
+ }
335
+
336
+
337
+ def summarize_capture_artifact_item(item):
338
+ summary = {
339
+ 'name': item.get('name', ''),
340
+ 'kind': item.get('kind'),
341
+ 'role': item.get('role'),
342
+ 'url': item.get('url'),
343
+ 'path': item.get('path'),
344
+ 'content_type': item.get('content_type'),
345
+ 'size_bytes': item.get('size_bytes'),
346
+ 'source': 'artifacts',
347
+ }
348
+ metadata = item.get('metadata')
349
+ if isinstance(metadata, dict):
350
+ summary['metadata_keys'] = sorted(metadata.keys())
351
+ return {key: value for key, value in summary.items() if value not in (None, '')}
352
+
353
+
354
+ def git(cmd, cwd):
355
+ """Run a shell command in a repo directory."""
356
+ return sp.run(cmd, shell=True, cwd=cwd, capture_output=True, text=True)
357
+
358
+
359
+ def load_package_json(project_dir):
360
+ package_json = os.path.join(project_dir, 'package.json')
361
+ if not os.path.exists(package_json):
362
+ return {}
363
+ try:
364
+ with open(package_json) as f:
365
+ return json.load(f)
366
+ except:
367
+ return {}
368
+
369
+
370
+ def truthy(value):
371
+ return str(value or '').strip().lower() in ('1', 'true', 'yes', 'y', 'on')
372
+
373
+
374
+ def should_use_static_preview(project_dir, state):
375
+ if not truthy(state.get('allow_static_preview_fallback')):
376
+ return ''
377
+
378
+ build_dir = detect_static_build_dir(project_dir, state)
379
+ if not os.path.exists(os.path.join(build_dir, 'index.html')):
380
+ return ''
381
+ if os.path.exists(os.path.join(project_dir, '.next', 'standalone', 'server.js')):
382
+ return ''
383
+
384
+ pkg = load_package_json(project_dir)
385
+ scripts = pkg.get('scripts') or {}
386
+ start_cmd = ' '.join(str(scripts.get(k, '')).lower() for k in ('start', 'preview', 'dev'))
387
+ if 'vite' in start_cmd:
388
+ return 'package.json scripts indicate a Vite static app'
389
+ if 'react-scripts' in start_cmd:
390
+ return 'package.json scripts indicate a static SPA preview'
391
+ if os.path.exists(os.path.join(project_dir, 'server.js')):
392
+ return ''
393
+ server_command = str(state.get('server_command') or '').lower()
394
+ if 'vite' in server_command:
395
+ return 'configured server command points at Vite rather than a standalone server'
396
+ return ''
397
+
398
+
399
+ def capture_script_saves_screenshot(capture_script):
400
+ return 'saveScreenshot' in (capture_script or '')
401
+
402
+
403
+ def join_url_path(base_url, target_path=''):
404
+ base = (base_url or '').strip()
405
+ path = (target_path or '').strip()
406
+ if not path or path == '/':
407
+ return base
408
+ if not base:
409
+ return path
410
+ return urljoin(base.rstrip('/') + '/', path.lstrip('/'))
411
+
412
+
413
+ def build_capture_script(url, capture_script, label, wait_for_selector=''):
414
+ pieces = [
415
+ 'await page.goto(' + json.dumps(url) + ');',
416
+ ]
417
+ selector = (wait_for_selector or '').strip()
418
+ if selector:
419
+ pieces.append('await page.waitForSelector(' + json.dumps(selector) + ');')
420
+ pieces.append('await page.waitForTimeout(1500);')
421
+ if (capture_script or '').strip():
422
+ pieces.append((capture_script or '').strip().rstrip(';') + ';')
423
+ if not capture_script_saves_screenshot(capture_script):
424
+ pieces.append('await saveScreenshot(' + json.dumps(label) + ');')
425
+ return ' '.join(pieces)
426
+
427
+
428
+ def capture_static_preview(state, project_dir, label, capture_script, timeout=300, target_path=''):
429
+ build_dir = detect_static_build_dir(project_dir, state)
430
+ if not build_dir:
431
+ return {
432
+ 'ok': False,
433
+ 'preview_id': '',
434
+ 'preview_url': '',
435
+ 'url': '',
436
+ 'raw': {'ok': False, 'error': 'No static build output found. Tried configured build_output, dist, build, out.'},
437
+ }
438
+
439
+ preview = invoke_retry('riddle_preview', {'directory': build_dir, 'label': label}, retries=3, timeout=timeout)
440
+ if not preview.get('ok'):
441
+ return {
442
+ 'ok': False,
443
+ 'preview_id': preview.get('id', ''),
444
+ 'preview_url': preview.get('preview_url') or preview.get('previewUrl') or '',
445
+ 'url': '',
446
+ 'raw': preview,
447
+ }
448
+ preview_url = preview.get('preview_url') or preview.get('previewUrl') or ''
449
+ preview_id = preview.get('id', '')
450
+ capture_url = join_url_path(preview_url, target_path or state.get('server_path', ''))
451
+
452
+ script = build_capture_script(capture_url, capture_script, label, state.get('wait_for_selector', ''))
453
+ args = {'script': script, 'timeout_sec': 60}
454
+ apply_auth_context(state, args)
455
+ shot = invoke_retry('riddle_script', args, retries=3, timeout=max(timeout, 120))
456
+ screenshots = shot.get('screenshots') or []
457
+ url = screenshots[0].get('url', '') if screenshots else ''
458
+ return {
459
+ 'ok': bool(url),
460
+ 'preview_id': preview_id,
461
+ 'preview_url': preview_url,
462
+ 'capture_url': capture_url,
463
+ 'url': url,
464
+ 'raw': {
465
+ 'preview': preview,
466
+ 'capture': shot,
467
+ },
468
+ }
469
+
470
+
471
+ def shell_quote(value):
472
+ return shlex.quote(str(value))
473
+
474
+
475
+ def detect_static_build_dir(project_dir, state):
476
+ candidates = []
477
+ configured = (state.get('build_output') or '').strip()
478
+ if configured:
479
+ candidates.append(configured)
480
+ candidates += ['dist', 'build', 'out']
481
+
482
+ seen = set()
483
+ for candidate in candidates:
484
+ if not candidate or candidate in seen:
485
+ continue
486
+ seen.add(candidate)
487
+ build_dir = candidate if os.path.isabs(candidate) else os.path.join(project_dir, candidate)
488
+ if os.path.exists(os.path.join(build_dir, 'index.html')):
489
+ return build_dir
490
+ return ''
491
+
492
+
493
+ def is_vite_project(project_dir):
494
+ pkg = load_package_json(project_dir)
495
+ scripts = pkg.get('scripts') or {}
496
+ script_text = ' '.join(str(v).lower() for v in scripts.values())
497
+ if 'vite' in script_text:
498
+ return True
499
+ for name in ('vite.config.js', 'vite.config.ts', 'vite.config.mjs', 'vite.config.cjs'):
500
+ if os.path.exists(os.path.join(project_dir, name)):
501
+ return True
502
+ return False
503
+
504
+
505
+ def write_static_spa_server(build_dir):
506
+ server_path = os.path.join(build_dir, 'riddle-proof-server.js')
507
+ server_code = r"""const http = require('http');
508
+ const fs = require('fs');
509
+ const path = require('path');
510
+
511
+ const root = process.cwd();
512
+ const port = Number(process.env.PORT || 3000);
513
+ const host = process.env.HOSTNAME || '0.0.0.0';
514
+ const types = {
515
+ '.html': 'text/html; charset=utf-8',
516
+ '.js': 'text/javascript; charset=utf-8',
517
+ '.mjs': 'text/javascript; charset=utf-8',
518
+ '.css': 'text/css; charset=utf-8',
519
+ '.json': 'application/json; charset=utf-8',
520
+ '.svg': 'image/svg+xml',
521
+ '.png': 'image/png',
522
+ '.jpg': 'image/jpeg',
523
+ '.jpeg': 'image/jpeg',
524
+ '.gif': 'image/gif',
525
+ '.webp': 'image/webp',
526
+ '.ico': 'image/x-icon',
527
+ '.wav': 'audio/wav',
528
+ '.mp3': 'audio/mpeg',
529
+ '.ogg': 'audio/ogg',
530
+ };
531
+
532
+ function sendFile(res, filePath) {
533
+ res.setHeader('Content-Type', types[path.extname(filePath).toLowerCase()] || 'application/octet-stream');
534
+ fs.createReadStream(filePath)
535
+ .on('error', () => {
536
+ res.statusCode = 500;
537
+ res.end('Failed to read file');
538
+ })
539
+ .pipe(res);
540
+ }
541
+
542
+ function safePathFromParts(parts) {
543
+ const filePath = path.join(root, ...parts);
544
+ return filePath.startsWith(root) ? filePath : '';
545
+ }
546
+
547
+ function resolveStaticPath(pathname) {
548
+ const parts = pathname.split('/').filter(Boolean);
549
+ const candidates = [safePathFromParts(parts)];
550
+ for (let i = 1; i < parts.length; i += 1) {
551
+ candidates.push(safePathFromParts(parts.slice(i)));
552
+ }
553
+
554
+ for (const candidate of candidates) {
555
+ if (!candidate) continue;
556
+ try {
557
+ const stat = fs.statSync(candidate);
558
+ if (stat.isDirectory()) {
559
+ const indexPath = path.join(candidate, 'index.html');
560
+ if (fs.existsSync(indexPath)) return indexPath;
561
+ }
562
+ if (stat.isFile()) return candidate;
563
+ } catch (_) {
564
+ // Try the next stripped base-path candidate before falling back to the SPA shell.
565
+ }
566
+ }
567
+ return '';
568
+ }
569
+
570
+ http.createServer((req, res) => {
571
+ const parsed = new URL(req.url || '/', 'http://127.0.0.1');
572
+ const pathname = decodeURIComponent(parsed.pathname || '/');
573
+ const filePath = resolveStaticPath(pathname);
574
+ if (filePath && !filePath.startsWith(root)) {
575
+ res.statusCode = 403;
576
+ res.end('Forbidden');
577
+ return;
578
+ }
579
+
580
+ if (filePath) {
581
+ sendFile(res, filePath);
582
+ return;
583
+ }
584
+ sendFile(res, path.join(root, 'index.html'));
585
+ }).listen(port, host, () => {
586
+ console.log(`riddle-proof static server listening on http://${host}:${port}`);
587
+ });
588
+ """
589
+ with open(server_path, 'w') as f:
590
+ f.write(server_code)
591
+ return server_path
592
+
593
+
594
+ def find_next_standalone_dir(project_dir):
595
+ standalone = os.path.join(project_dir, '.next', 'standalone')
596
+ if not os.path.exists(standalone):
597
+ return ''
598
+
599
+ if os.path.exists(os.path.join(standalone, 'server.js')):
600
+ return standalone
601
+
602
+ for d in os.listdir(standalone):
603
+ candidate = os.path.join(standalone, d)
604
+ if os.path.exists(os.path.join(candidate, 'server.js')):
605
+ return candidate
606
+ return ''
607
+
608
+
609
+ def prepare_server_preview(project_dir, state):
610
+ """Return (directory, command, exclude) for riddle_server_preview after build."""
611
+ standalone = find_next_standalone_dir(project_dir)
612
+ if standalone:
613
+ sp.run(
614
+ 'cp -r ' + shell_quote(os.path.join(project_dir, '.next', 'static')) + ' ' + shell_quote(os.path.join(standalone, '.next', 'static')),
615
+ shell=True,
616
+ capture_output=True,
617
+ )
618
+ sp.run(
619
+ 'cp -r ' + shell_quote(os.path.join(project_dir, 'public')) + ' ' + shell_quote(os.path.join(standalone, 'public')) + ' 2>/dev/null',
620
+ shell=True,
621
+ capture_output=True,
622
+ )
623
+ return standalone, 'node server.js', ['.git', '*.log']
624
+
625
+ if is_vite_project(project_dir):
626
+ build_dir = detect_static_build_dir(project_dir, state)
627
+ if build_dir:
628
+ write_static_spa_server(build_dir)
629
+ return build_dir, 'node riddle-proof-server.js', ['.git', '*.log', 'node_modules']
630
+
631
+ return project_dir, state['server_command'], ['.git', '*.log', 'node_modules']
632
+
633
+
634
+ def prepare_standalone(project_dir):
635
+ """Prepare Next.js standalone dir. Returns the correct build dir path.
636
+
637
+ Next.js standalone output may nest under a subdir matching the project
638
+ folder name (e.g. .next/standalone/my-project/server.js). This finds
639
+ the right dir and copies static assets + public into it.
640
+ """
641
+ standalone = project_dir + '/.next/standalone'
642
+ if not os.path.exists(standalone):
643
+ return project_dir
644
+
645
+ # Find the dir containing server.js
646
+ if not os.path.exists(standalone + '/server.js'):
647
+ for d in os.listdir(standalone):
648
+ candidate = standalone + '/' + d + '/server.js'
649
+ if os.path.exists(candidate):
650
+ standalone = standalone + '/' + d
651
+ break
652
+
653
+ if not os.path.exists(standalone + '/server.js'):
654
+ return project_dir
655
+
656
+ # Copy static assets
657
+ sp.run('cp -r ' + project_dir + '/.next/static ' + standalone + '/.next/static',
658
+ shell=True, capture_output=True)
659
+ sp.run('cp -r ' + project_dir + '/public ' + standalone + '/public 2>/dev/null',
660
+ shell=True, capture_output=True)
661
+
662
+ # Standalone requires 'node server.js', not 'npm start' / 'next start'
663
+ # Update state if loaded
664
+ try:
665
+ if os.path.exists(STATE_FILE):
666
+ s = json.load(open(STATE_FILE))
667
+ if s.get('server_command') in ('npm start', 'next start'):
668
+ s['server_command'] = 'node server.js'
669
+ json.dump(s, open(STATE_FILE, 'w'), indent=2)
670
+ except:
671
+ pass
672
+
673
+ return standalone