#!/usr/bin/env node import fs from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; export function parseArgs(argv) { const options = { baseUrl: process.env.AIHR_LEGACY_BASE_URL || 'https://wygj-api.localhost', token: process.env.AIHR_LEGACY_TOKEN, clientid: process.env.AIHR_LEGACY_CLIENT_ID, batchSize: 50, afterAttachmentId: 0, maxBatches: 1, manifest: 'tmp/legacy-knowledge-stage-manifest.json', execute: false }; for (let index = 0; index < argv.length; index += 1) { const value = argv[index]; if (value === '--base-url') options.baseUrl = argv[++index]; else if (value === '--token') options.token = argv[++index]; else if (value === '--clientid') options.clientid = argv[++index]; else if (value === '--batch-size') options.batchSize = Number(argv[++index]); else if (value === '--after-attachment-id') options.afterAttachmentId = Number(argv[++index]); else if (value === '--max-batches') options.maxBatches = Number(argv[++index]); else if (value === '--manifest') options.manifest = argv[++index]; else if (value === '--execute') options.execute = true; else if (value === '--dry-run') options.execute = false; else throw new Error(`Unknown option: ${value}`); } if (!Number.isInteger(options.batchSize) || options.batchSize < 20 || options.batchSize > 100) { throw new Error('--batch-size must be an integer between 20 and 100'); } if (!Number.isSafeInteger(options.afterAttachmentId) || options.afterAttachmentId < 0) { throw new Error('--after-attachment-id must be a non-negative integer'); } if (!Number.isInteger(options.maxBatches) || options.maxBatches < 1) { throw new Error('--max-batches must be a positive integer'); } if (options.execute && !options.token) { throw new Error('--token or AIHR_LEGACY_TOKEN is required with --execute'); } return options; } async function readManifest(file) { try { const parsed = JSON.parse(await fs.readFile(file, 'utf8')); if (parsed.schemaVersion !== 1 || !Array.isArray(parsed.batches)) { throw new Error('unsupported manifest schema'); } return parsed; } catch (error) { if (error.code === 'ENOENT') return null; throw new Error(`Cannot read manifest ${file}: ${error.message}`); } } async function writeManifest(file, manifest) { const absolute = path.resolve(file); await fs.mkdir(path.dirname(absolute), { recursive: true }); const temporary = `${absolute}.${process.pid}.tmp`; await fs.writeFile(temporary, `${JSON.stringify(manifest, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 }); await fs.rename(temporary, absolute); } function headers(options) { const result = { accept: 'application/json' }; if (options.token) { result.Authorization = options.token.startsWith('Bearer ') ? options.token : `Bearer ${options.token}`; } if (options.clientid) result.clientid = options.clientid; return result; } async function stageBatch(options, cursor, fetchImpl) { const url = new URL('/api/knowledge/quality/legacy/stage', `${options.baseUrl.replace(/\/$/, '')}/`); url.searchParams.set('afterAttachmentId', String(cursor)); url.searchParams.set('limit', String(options.batchSize)); const response = await fetchImpl(url, { method: 'POST', headers: headers(options) }); const body = await response.json(); if (!response.ok || body.code !== 200 || !body.data) { throw new Error(`legacy stage failed with HTTP ${response.status} code ${body.code ?? ''}`); } return body.data; } async function previewBatch(options, cursor, fetchImpl) { const url = new URL('/api/knowledge/quality/legacy/preview', `${options.baseUrl.replace(/\/$/, '')}/`); url.searchParams.set('afterAttachmentId', String(cursor)); url.searchParams.set('limit', String(options.batchSize)); const response = await fetchImpl(url, { method: 'GET', headers: headers(options) }); const body = await response.json(); if (!response.ok || body.code !== 200 || !body.data) { throw new Error(`legacy preview failed with HTTP ${response.status} code ${body.code ?? ''}`); } return body.data; } export async function run(options, dependencies = {}) { const fetchImpl = dependencies.fetchImpl || globalThis.fetch; const now = dependencies.now || (() => new Date()); const previous = await readManifest(options.manifest); const resumeCursor = previous?.status === 'IN_PROGRESS' || previous?.status === 'FAILED' ? Number(previous.cursor || 0) : options.afterAttachmentId; if (!options.execute) { const preview = options.token ? await previewBatch(options, resumeCursor, fetchImpl) : null; return { mode: 'DRY_RUN', baseUrl: options.baseUrl, batchSize: options.batchSize, maxBatches: options.maxBatches, cursor: resumeCursor, manifest: path.resolve(options.manifest), preview, note: preview ? 'Authenticated preview completed without mutation. Add --execute to stage data into review or quarantine.' : 'Offline validation only. Pass --token for a read-only server preview; add --execute to stage data.' }; } const manifest = previous && previous.status !== 'COMPLETE' ? previous : { schemaVersion: 1, status: 'IN_PROGRESS', cursor: resumeCursor, batches: [] }; manifest.status = 'IN_PROGRESS'; manifest.updatedAt = now().toISOString(); await writeManifest(options.manifest, manifest); let cursor = Number(manifest.cursor || resumeCursor); for (let batchNumber = 0; batchNumber < options.maxBatches; batchNumber += 1) { try { const result = await stageBatch(options, cursor, fetchImpl); const entry = { sequence: manifest.batches.length + 1, startedAfterAttachmentId: cursor, discovered: result.discovered, staged: result.staged, reparsed: result.reparsed, quarantined: result.quarantined, stagedAssetIds: result.stagedAssetIds || [], failedAttachmentIds: result.failedAttachmentIds || [], nextCursor: result.nextCursor, moreAvailable: Boolean(result.moreAvailable), completedAt: now().toISOString() }; manifest.batches.push(entry); if (entry.failedAttachmentIds.length > 0) { manifest.status = 'FAILED'; manifest.lastError = `Batch contains ${entry.failedAttachmentIds.length} failed attachment(s)`; manifest.cursor = cursor; await writeManifest(options.manifest, manifest); throw new Error(`${manifest.lastError}; cursor was not advanced`); } cursor = Number(result.nextCursor || cursor); manifest.cursor = cursor; manifest.status = result.moreAvailable ? 'IN_PROGRESS' : 'COMPLETE'; delete manifest.lastError; await writeManifest(options.manifest, manifest); if (!result.moreAvailable || result.discovered === 0) break; } catch (error) { if (manifest.status !== 'FAILED') { manifest.status = 'FAILED'; manifest.lastError = error.message; manifest.cursor = cursor; manifest.updatedAt = now().toISOString(); await writeManifest(options.manifest, manifest); } throw error; } } return { mode: 'EXECUTE', status: manifest.status, cursor: manifest.cursor, batches: manifest.batches.length, manifest: path.resolve(options.manifest) }; } if (fileURLToPath(import.meta.url) === process.argv[1]) { run(parseArgs(process.argv.slice(2))) .then((result) => console.log(JSON.stringify(result, null, 2))) .catch((error) => { console.error(error.message); process.exitCode = 1; }); }