import { readFile } from 'node:fs/promises'; import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const WRITE_COMMANDS = new Set(['patch-property', 'patch-unit', 'patch-media', 'import-media', 'deactivate-property', 'deactivate-unit']); const DEACTIVATE_COMMANDS = new Set(['deactivate-property', 'deactivate-unit']); export function baseUrl(value) { if (!value) throw new Error('Set OUTRESERVE_SUPPLY_BASE_URL'); const url = new URL(value); const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname); if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) { throw new Error('Supply credentials require HTTPS outside local loopback'); } if (url.pathname.replace(/\/$/, '') !== '/api/v1/supply') { throw new Error('Base URL must end in /api/v1/supply'); } if (url.username || url.password || url.search || url.hash) throw new Error('Base URL cannot contain credentials or a query'); return url.href.replace(/\/$/, ''); } function segment(value, label) { if (!value || value.includes('/')) throw new Error(`Provide a ${label}`); return encodeURIComponent(value); } async function bodyFile(path, root, { statusAllowed = false, confirmStatus = false } = {}) { if (!path) throw new Error(`Provide a JSON file containing {"${root}": {...}}`); const raw = await readFile(path, 'utf8'); const parsed = JSON.parse(raw); if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || Object.keys(parsed).length !== 1 || !parsed[root] || typeof parsed[root] !== 'object' || Array.isArray(parsed[root])) { throw new Error(`JSON file must contain only a ${root} object`); } if ('status' in parsed[root]) { const allowedStatus = root === 'media' ? 'deleted' : 'inactive'; if (!statusAllowed || parsed[root].status !== allowedStatus) { throw new Error(`${root} status may only be set to ${allowedStatus} in a patch`); } if (!confirmStatus) throw new Error('Set OUTRESERVE_CONFIRM_DEACTIVATE=yes for a status patch'); } return raw; } export async function supplyRequest(base, key, path, { method = 'GET', body, idempotencyKey } = {}, transport = fetch) { const response = await transport(`${base}${path}`, { method, headers: { Authorization: `Bearer ${key}`, ...(body ? { 'Content-Type': 'application/json' } : {}), ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}), }, ...(body ? { body } : {}), }); const raw = await response.text(); let payload; try { payload = raw ? JSON.parse(raw) : null; } catch { payload = null; } if (!response.ok) { const problem = payload && typeof payload === 'object' ? payload : {}; const evidence = { status: response.status, code: problem.code || 'unexpected_response', type: problem.type, request_id: problem.request_id || response.headers.get('x-request-id'), retryable: problem.retryable === true, retry_after: response.headers.get('retry-after'), errors: problem.errors, context: problem.context, }; throw new Error(`Supply API problem: ${JSON.stringify(evidence)}`); } if (!payload || typeof payload !== 'object') throw new Error(`Supply API returned non-JSON HTTP ${response.status}`); return payload; } export async function run(command, args, env = process.env, transport = fetch) { if (!env.OUTRESERVE_SUPPLY_API_KEY) throw new Error('Set OUTRESERVE_SUPPLY_API_KEY'); const base = baseUrl(env.OUTRESERVE_SUPPLY_BASE_URL); if (WRITE_COMMANDS.has(command) && env.OUTRESERVE_CONFIRM_WRITE !== 'yes') { throw new Error('Set OUTRESERVE_CONFIRM_WRITE=yes before changing Supply content'); } if (DEACTIVATE_COMMANDS.has(command) && env.OUTRESERVE_CONFIRM_DEACTIVATE !== 'yes') { throw new Error('Set OUTRESERVE_CONFIRM_DEACTIVATE=yes before deactivating a record'); } const request = (path, options) => supplyRequest(base, env.OUTRESERVE_SUPPLY_API_KEY, path, options, transport); switch (command) { case 'patch-property': return request(`/properties/${segment(args[0], 'property UUID')}`, { method: 'PATCH', body: await bodyFile(args[1], 'property', { statusAllowed: true, confirmStatus: env.OUTRESERVE_CONFIRM_DEACTIVATE === 'yes' }) }); case 'patch-unit': return request(`/properties/${segment(args[0], 'property UUID')}/units/${segment(args[1], 'unit UUID')}`, { method: 'PATCH', body: await bodyFile(args[2], 'unit', { statusAllowed: true, confirmStatus: env.OUTRESERVE_CONFIRM_DEACTIVATE === 'yes' }) }); case 'patch-media': return request(`/properties/${segment(args[0], 'property UUID')}/media/${segment(args[1], 'media UUID')}`, { method: 'PATCH', body: await bodyFile(args[2], 'media', { statusAllowed: true, confirmStatus: env.OUTRESERVE_CONFIRM_DEACTIVATE === 'yes' }) }); case 'import-media': { const idempotencyKey = env.OUTRESERVE_MEDIA_IDEMPOTENCY_KEY; if (!idempotencyKey || idempotencyKey.length < 8 || idempotencyKey.length > 128) { throw new Error('Set a persisted 8-128 character OUTRESERVE_MEDIA_IDEMPOTENCY_KEY'); } return request(`/properties/${segment(args[0], 'property UUID')}/media`, { method: 'POST', body: await bodyFile(args[1], 'media'), idempotencyKey }); } case 'media-status': { const media = await request(`/properties/${segment(args[0], 'property UUID')}/media/${segment(args[1], 'media UUID')}`); if (!args[2]) return { media }; const operation = await request(`/operations/${segment(args[2], 'operation UUID')}`); return { media, operation }; } case 'deactivate-property': return request(`/properties/${segment(args[0], 'property UUID')}`, { method: 'PATCH', body: JSON.stringify({ property: { status: 'inactive' } }) }); case 'deactivate-unit': return request(`/properties/${segment(args[0], 'property UUID')}/units/${segment(args[1], 'unit UUID')}`, { method: 'PATCH', body: JSON.stringify({ unit: { status: 'inactive' } }) }); case 'request-audit': return request(`/requests/${segment(args[0], 'request UUID')}`); default: throw new Error('Use patch-property, patch-unit, patch-media, import-media, media-status, deactivate-property, deactivate-unit, or request-audit'); } } if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { run(process.argv[2], process.argv.slice(3)) .then((result) => console.log(JSON.stringify(result, null, 2))) .catch((error) => { console.error(error.message); process.exitCode = 1; }); }