|
1 | 1 | import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; |
2 | 2 | import { tmpdir } from 'node:os'; |
3 | | -import { join } from 'node:path'; |
| 3 | +import { isAbsolute, join, resolve as resolvePath } from 'node:path'; |
4 | 4 | import { loadSettings } from '@colony/config'; |
5 | 5 | import { |
6 | 6 | type IngestOmxRuntimeSummaryResult, |
@@ -79,6 +79,14 @@ interface BridgeLifecycleOptions { |
79 | 79 | dryRun?: boolean; |
80 | 80 | } |
81 | 81 |
|
| 82 | +interface BridgeReplayOptions { |
| 83 | + json?: boolean; |
| 84 | + ide?: string; |
| 85 | + cwd?: string; |
| 86 | + apply?: boolean; |
| 87 | + rewriteRoot?: string[]; |
| 88 | +} |
| 89 | + |
82 | 90 | interface BridgeRuntimeSummaryOptions { |
83 | 91 | json?: boolean; |
84 | 92 | repoRoot?: string; |
@@ -212,6 +220,67 @@ export function registerBridgeCommand(program: Command, deps: BridgeCommandDeps |
212 | 220 | } |
213 | 221 | }); |
214 | 222 |
|
| 223 | + bridge |
| 224 | + .command('replay <file>') |
| 225 | + .description( |
| 226 | + 'Replay a saved colony-omx-lifecycle-v1 envelope from disk (dry-run by default; pass --apply to write to the live store)', |
| 227 | + ) |
| 228 | + .option('--json', 'emit the routing result as JSON') |
| 229 | + .option('--ide <name>', 'IDE/agent hint used when the envelope omits one') |
| 230 | + .option('--cwd <path>', 'cwd hint used when the envelope uses relative paths') |
| 231 | + .option( |
| 232 | + '--apply', |
| 233 | + 'apply the envelope against the live SQLite store; default is dry-run against an ephemeral DB', |
| 234 | + ) |
| 235 | + .option( |
| 236 | + '--rewrite-root <pair>', |
| 237 | + 'rewrite absolute paths in the envelope as <from>=<to> (repeatable; useful when replaying a capture from another machine)', |
| 238 | + (value: string, previous: string[] | undefined) => (previous ?? []).concat([value]), |
| 239 | + ) |
| 240 | + .action(async (file: string, opts: BridgeReplayOptions) => { |
| 241 | + const inputPath = resolvePath(process.cwd(), file); |
| 242 | + const raw = (deps.readReplayFile ?? defaultReadReplayFile)(inputPath); |
| 243 | + let payload = raw.trim() ? safeJson(raw) : {}; |
| 244 | + |
| 245 | + const rewriteRules = parseRewriteRootPairs(opts.rewriteRoot); |
| 246 | + if (rewriteRules.length > 0) { |
| 247 | + payload = rewriteEnvelopePaths(payload, rewriteRules); |
| 248 | + } |
| 249 | + |
| 250 | + const applied = opts.apply === true; |
| 251 | + if (applied && !opts.json) { |
| 252 | + process.stderr.write(`${kleur.yellow('applying to live store')}\n`); |
| 253 | + } |
| 254 | + |
| 255 | + const runLifecycle = |
| 256 | + deps.runOmxLifecycleEnvelope ?? (await import('@colony/hooks')).runOmxLifecycleEnvelope; |
| 257 | + const dryRun = applied ? null : (deps.createDryRunStore ?? defaultCreateDryRunStore)(); |
| 258 | + try { |
| 259 | + const result = await runLifecycle(payload, { |
| 260 | + defaultCwd: opts.cwd?.trim() || process.cwd(), |
| 261 | + ...(opts.ide?.trim() ? { ide: opts.ide.trim() } : {}), |
| 262 | + ...(dryRun ? { store: dryRun.store } : {}), |
| 263 | + }); |
| 264 | + |
| 265 | + const augmented = { ...result, replay: true, applied, input_path: inputPath }; |
| 266 | + |
| 267 | + if (opts.json) { |
| 268 | + process.stdout.write(`${JSON.stringify(augmented, null, 2)}\n`); |
| 269 | + } else if (result.ok) { |
| 270 | + const duplicate = result.duplicate === true ? ' duplicate=true' : ''; |
| 271 | + process.stdout.write( |
| 272 | + `${kleur.green('ok')} event=${result.event_type ?? '-'} route=${result.route ?? '-'}${duplicate} replay=true applied=${applied}\n`, |
| 273 | + ); |
| 274 | + } else { |
| 275 | + process.stderr.write(`${kleur.red('error')} ${result.error ?? 'lifecycle failed'}\n`); |
| 276 | + } |
| 277 | + |
| 278 | + if (!result.ok) process.exitCode = 1; |
| 279 | + } finally { |
| 280 | + dryRun?.cleanup(); |
| 281 | + } |
| 282 | + }); |
| 283 | + |
215 | 284 | bridge |
216 | 285 | .command('runtime-summary') |
217 | 286 | .description('Receive a compact OMX runtime summary from stdin') |
@@ -291,3 +360,61 @@ function defaultCreateDryRunStore(): { store: MemoryStore; cleanup: () => void } |
291 | 360 | }, |
292 | 361 | }; |
293 | 362 | } |
| 363 | + |
| 364 | +interface RewriteRule { |
| 365 | + from: string; |
| 366 | + to: string; |
| 367 | +} |
| 368 | + |
| 369 | +function parseRewriteRootPairs(values: string[] | undefined): RewriteRule[] { |
| 370 | + if (!values || values.length === 0) return []; |
| 371 | + const rules: RewriteRule[] = []; |
| 372 | + for (const value of values) { |
| 373 | + const idx = value.indexOf('='); |
| 374 | + if (idx <= 0 || idx === value.length - 1) { |
| 375 | + process.stderr.write( |
| 376 | + `${kleur.yellow('warn')} ignoring malformed --rewrite-root pair: ${value}\n`, |
| 377 | + ); |
| 378 | + continue; |
| 379 | + } |
| 380 | + const from = value.slice(0, idx); |
| 381 | + const to = value.slice(idx + 1); |
| 382 | + if (!isAbsolute(from)) { |
| 383 | + process.stderr.write( |
| 384 | + `${kleur.yellow('warn')} --rewrite-root <from> must be absolute: ${from}\n`, |
| 385 | + ); |
| 386 | + continue; |
| 387 | + } |
| 388 | + rules.push({ from, to }); |
| 389 | + } |
| 390 | + return rules; |
| 391 | +} |
| 392 | + |
| 393 | +function rewriteEnvelopePaths(value: unknown, rules: RewriteRule[]): Record<string, unknown> { |
| 394 | + const rewritten = rewriteValue(value, rules); |
| 395 | + return rewritten && typeof rewritten === 'object' && !Array.isArray(rewritten) |
| 396 | + ? (rewritten as Record<string, unknown>) |
| 397 | + : {}; |
| 398 | +} |
| 399 | + |
| 400 | +function rewriteValue(value: unknown, rules: RewriteRule[]): unknown { |
| 401 | + if (typeof value === 'string') return rewriteString(value, rules); |
| 402 | + if (Array.isArray(value)) return value.map((entry) => rewriteValue(entry, rules)); |
| 403 | + if (value && typeof value === 'object') { |
| 404 | + const out: Record<string, unknown> = {}; |
| 405 | + for (const [key, entry] of Object.entries(value as Record<string, unknown>)) { |
| 406 | + out[key] = rewriteValue(entry, rules); |
| 407 | + } |
| 408 | + return out; |
| 409 | + } |
| 410 | + return value; |
| 411 | +} |
| 412 | + |
| 413 | +function rewriteString(value: string, rules: RewriteRule[]): string { |
| 414 | + for (const rule of rules) { |
| 415 | + if (value === rule.from) return rule.to; |
| 416 | + const prefix = rule.from.endsWith('/') ? rule.from : `${rule.from}/`; |
| 417 | + if (value.startsWith(prefix)) return `${rule.to}${value.slice(rule.from.length)}`; |
| 418 | + } |
| 419 | + return value; |
| 420 | +} |
0 commit comments