75 lines
4.4 KiB
JavaScript
75 lines
4.4 KiB
JavaScript
#!/usr/bin/env node
|
|
'use strict';
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const crypto = require('node:crypto');
|
|
const readline = require('node:readline/promises');
|
|
const engine = require('./site/engine.js');
|
|
const usage = `Usage (Node 20+):
|
|
node cli.cjs annotate SOURCE.jsonl SIDECAR.json [e001=pinned e002=aside ...]
|
|
node cli.cjs export SOURCE.jsonl SIDECAR.json BUDGET_BYTES NEW_OUTPUT_DIR
|
|
Outputs: continuation.md + omissions.json. Existing files require interactive confirmation.
|
|
Budget includes all Markdown framing; source transcripts are never written.`;
|
|
function identity(file) { return path.resolve(file); }
|
|
async function confirm(files) {
|
|
if (!files.length) return;
|
|
if (!process.stdin.isTTY) throw Error('Output exists; rerun in a terminal to confirm overwrite');
|
|
const rl = readline.createInterface({input:process.stdin,output:process.stdout});
|
|
try { if (await rl.question('Overwrite ' + files.join(', ') + '? Type OVERWRITE: ') !== 'OVERWRITE') throw Error('Cancelled'); }
|
|
finally { rl.close(); }
|
|
}
|
|
function guard(file, protectedFiles) {
|
|
if (protectedFiles.includes(identity(file))) throw Error('Output would overwrite an input');
|
|
if (fs.existsSync(file)) {
|
|
const stat = fs.lstatSync(file);
|
|
if (!stat.isFile() || stat.nlink > 1) throw Error('Output must be a regular file with no hard links');
|
|
if (protectedFiles.some(p => fs.existsSync(p) && fs.realpathSync(p) === fs.realpathSync(file))) throw Error('Output aliases an input');
|
|
}
|
|
}
|
|
async function main() {
|
|
const [command, sourcePath, sidecarPath, ...rest] = process.argv.slice(2);
|
|
if (!['annotate','export'].includes(command) || !sourcePath || !sidecarPath) throw Error(usage);
|
|
const buffer = fs.readFileSync(sourcePath);
|
|
if (buffer.subarray(0,3).equals(Buffer.from([239,187,191]))) throw Error('UTF-8 BOM is unsupported; normalize the export first');
|
|
const source = new TextDecoder('utf-8', {fatal:true}).decode(buffer);
|
|
const exchanges = engine.parse(source);
|
|
const sha256 = crypto.createHash('sha256').update(buffer).digest('hex');
|
|
if (command === 'annotate') {
|
|
guard(sidecarPath, [identity(sourcePath)]);
|
|
const priorities = Object.fromEntries(rest.map(value => {
|
|
const parts = value.split('='); if (parts.length !== 2) throw Error('Use e001=pinned'); return parts;
|
|
}));
|
|
engine.select(exchanges, priorities, Number.MAX_SAFE_INTEGER);
|
|
const existed = fs.existsSync(sidecarPath);
|
|
await confirm(existed?[sidecarPath]:[]);
|
|
fs.writeFileSync(sidecarPath, JSON.stringify({version:1,sourceSha256:sha256,priorities},null,2)+'\n', {flag:existed?'w':'wx',mode:0o600});
|
|
console.log('Sidecar saved; '+exchanges.length+' complete exchanges. Source unchanged.');
|
|
} else {
|
|
if (rest.length !== 2 || !/^\d+$/.test(rest[0])) throw Error(usage);
|
|
const [budget, directory] = rest;
|
|
const sidecar = JSON.parse(fs.readFileSync(sidecarPath,'utf8'));
|
|
if (sidecar.version !== 1 || sidecar.sourceSha256 !== sha256) throw Error('Sidecar does not match this transcript');
|
|
const result = engine.select(exchanges, sidecar.priorities, Number(budget));
|
|
const outputs = ['continuation.md','omissions.json'].map(name => path.join(directory,name));
|
|
for (const file of outputs) guard(file, [identity(sourcePath),identity(sidecarPath)]);
|
|
const existing = new Set(outputs.filter(f => fs.existsSync(f)));
|
|
await confirm([...existing]);
|
|
// Build both artifacts in a private staging directory first. Leave it on publication failure.
|
|
fs.mkdirSync(directory,{recursive:true,mode:0o700});
|
|
const stage = fs.mkdtempSync(path.join(directory,'.context-priority-'));
|
|
fs.writeFileSync(path.join(stage,'continuation.md'),result.packet,{mode:0o600});
|
|
fs.writeFileSync(path.join(stage,'omissions.json'),JSON.stringify({...result.manifest,sourceSha256:sha256},null,2)+'\n',{mode:0o600});
|
|
try {
|
|
for (const file of outputs) {
|
|
guard(file,[identity(sourcePath),identity(sidecarPath)]);
|
|
const staged = path.join(stage,path.basename(file));
|
|
if (existing.has(file)) fs.renameSync(staged,file);
|
|
else { fs.linkSync(staged,file); fs.unlinkSync(staged); }
|
|
}
|
|
fs.rmdirSync(stage);
|
|
} catch (error) { throw Error(error.message+'; recover remaining artifacts from '+stage); }
|
|
console.log('Exported '+result.manifest.packetBytes+'/'+budget+' bytes to '+directory);
|
|
}
|
|
}
|
|
main().catch(error => { console.error(error.message); process.exitCode = 1; });
|