Add archive layer: zip, .sfgz share files, locks, snapshots

- Hand-rolled ZIP writer/reader (node:zlib only) with CRC verification,
  UTF-8 names, store/deflate, path-traversal validation; verified
  interoperable with system unzip
- .sfgz guide archives: export, copy-import (fresh ids, remapped
  substeps), linked-import with explicit write-back save
- Advisory .lock-sfgz sidecar locks with stale detection and force-steal
- Snapshot backups with pruning and undoable restore
- 7 more workflow tests (19 total)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Iisyourdad
2026-06-10 16:38:30 -05:00
parent 2a602d7477
commit 3c51cf6f81
5 changed files with 717 additions and 0 deletions
+173
View File
@@ -0,0 +1,173 @@
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const { zipSync, unzipSync } = require('./zip');
const { newId, nowIso, atomicWriteFileSync, writeJsonSync, deepClone } = require('./util');
const { normalizeGuide, normalizeStep, validateGuide, validateStep, SCHEMA_VERSION } = require('./schema');
const { acquireLock, releaseLock } = require('./locks');
const ARCHIVE_FORMAT = 'stepforge-guide-archive';
const APP_VERSION = require('../package.json').version;
/**
* Single-file share archive (.sfgz). Zip layout:
* manifest.json
* guide.json
* steps/<stepId>/step.json
* steps/<stepId>/<image files>
*/
function buildArchiveEntries(store, guideId) {
const guide = store.getGuide(guideId);
const steps = store.listSteps(guideId);
const entries = [];
const manifest = {
format: ARCHIVE_FORMAT,
formatVersion: 1,
schemaVersion: SCHEMA_VERSION,
appVersion: APP_VERSION,
guideId: guide.guideId,
title: guide.title,
exportedAt: nowIso(),
stepCount: guide.stepsOrder.length,
};
entries.push({ name: 'manifest.json', data: JSON.stringify(manifest, null, 2) });
const portableGuide = deepClone(guide);
portableGuide.linkedSource = null; // links are a property of the library, not the file
entries.push({ name: 'guide.json', data: JSON.stringify(portableGuide, null, 2) });
for (const stepId of guide.stepsOrder) {
const step = steps.get(stepId);
if (!step) continue;
entries.push({ name: `steps/${stepId}/step.json`, data: JSON.stringify(step, null, 2) });
const dir = store.stepDir(guideId, stepId);
for (const file of fs.readdirSync(dir)) {
if (file === 'step.json') continue;
entries.push({ name: `steps/${stepId}/${file}`, data: fs.readFileSync(path.join(dir, file)), store: /\.(png|jpg|jpeg|gif|webp)$/i.test(file) });
}
}
return entries;
}
/** Export a guide to a .sfgz file. Returns the manifest written. */
function exportGuideArchive(store, guideId, destFile) {
const entries = buildArchiveEntries(store, guideId);
atomicWriteFileSync(destFile, zipSync(entries));
return JSON.parse(entries[0].data);
}
function readArchive(file) {
const entries = unzipSync(fs.readFileSync(file));
const byName = new Map(entries.map((e) => [e.name, e.data]));
if (!byName.has('manifest.json') || !byName.has('guide.json')) {
throw new Error('not a StepForge guide archive (missing manifest)');
}
const manifest = JSON.parse(byName.get('manifest.json').toString('utf8'));
if (manifest.format !== ARCHIVE_FORMAT) throw new Error(`unsupported archive format: ${manifest.format}`);
if (manifest.schemaVersion > SCHEMA_VERSION) {
throw new Error(`archive uses newer schema (${manifest.schemaVersion}) than this app supports`);
}
const guide = normalizeGuide(JSON.parse(byName.get('guide.json').toString('utf8')));
validateGuide(guide);
return { manifest, guide, entries };
}
/**
* Import a .sfgz into the library.
* mode 'copy' — fresh ids, fully independent local guide.
* mode 'linked' — keeps archive identity; edits autosave locally and write
* back to the file only on explicit save (saveLinkedGuide).
*/
function importGuideArchive(store, file, { mode = 'copy' } = {}) {
const { guide, entries } = readArchive(file);
const idMap = new Map();
const stepFiles = new Map(); // newStepId -> [{name, data}]
const stepJsons = new Map();
for (const { name, data } of entries) {
const m = /^steps\/([^/]+)\/(.+)$/.exec(name);
if (!m) continue;
const [, oldStepId, rest] = m;
if (!idMap.has(oldStepId)) {
idMap.set(oldStepId, mode === 'copy' ? newId('step') : oldStepId);
}
const stepId = idMap.get(oldStepId);
if (rest === 'step.json') stepJsons.set(stepId, { oldStepId, raw: JSON.parse(data.toString('utf8')) });
else {
if (!stepFiles.has(stepId)) stepFiles.set(stepId, []);
if (!/^[A-Za-z0-9._-]+$/.test(rest)) continue; // only flat, safe file names
stepFiles.get(stepId).push({ name: rest, data });
}
}
const newGuide = deepClone(guide);
if (mode === 'copy') {
newGuide.guideId = newId('guide');
newGuide.linkedSource = null;
} else {
if (store.guideExists(newGuide.guideId)) {
throw new Error('this shared guide is already in the library');
}
newGuide.linkedSource = {
path: path.resolve(file),
openedAt: nowIso(),
lastSavedAt: null,
};
}
newGuide.stepsOrder = guide.stepsOrder.map((id) => idMap.get(id)).filter(Boolean);
return finalizeImport(store, newGuide, idMap, stepJsons, stepFiles);
}
function finalizeImport(store, newGuide, idMap, stepJsons, stepFiles) {
validateGuide(newGuide);
writeJsonSync(path.join(store.guideDir(newGuide.guideId), 'guide.json'), newGuide);
for (const [stepId, { raw }] of stepJsons) {
const step = normalizeStep({ ...raw, stepId });
step.parentStepId = raw.parentStepId ? idMap.get(raw.parentStepId) || null : null;
validateStep(step);
const dir = store.stepDir(newGuide.guideId, stepId);
writeJsonSync(path.join(dir, 'step.json'), step);
for (const { name, data } of stepFiles.get(stepId) || []) {
atomicWriteFileSync(path.join(dir, name), data);
}
}
return store.getGuide(newGuide.guideId);
}
/**
* Write a linked guide back to its shared archive (explicit Ctrl+S save).
* Takes the advisory lock for the duration of the write.
*/
function saveLinkedGuide(store, guideId, { force = false } = {}) {
const guide = store.getGuide(guideId);
if (!guide.linkedSource || !guide.linkedSource.path) {
throw new Error('guide is not linked to a shared archive');
}
const target = guide.linkedSource.path;
const result = acquireLock(target, { force });
if (!result.acquired) {
return { saved: false, conflict: result.conflict };
}
try {
exportGuideArchive(store, guideId, target);
guide.linkedSource.lastSavedAt = nowIso();
store.saveGuide(guide, { touch: false });
return { saved: true, path: target };
} finally {
releaseLock(target);
}
}
module.exports = {
ARCHIVE_FORMAT,
exportGuideArchive,
readArchive,
importGuideArchive,
saveLinkedGuide,
};