@troylar/flightdeck 2.13.0 → 2.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/flightdeck.js CHANGED
@@ -1775,6 +1775,642 @@ async function draftMain(argv) {
1775
1775
  }
1776
1776
  }
1777
1777
 
1778
+ /* ---------- flightdeck type list | read | save (2.14): step types through a checked write ----------
1779
+ type save compares the exact bytes it read (SHA-256), writes a complete temporary file beside the target, checks the
1780
+ folder allows hard links, compares again (bytes, then the path still names that file, then a final listing for copies),
1781
+ commits with one link (a new type) or one rename (a change), reads it back and records one history line. Like two
1782
+ Flightdeck windows in different browsers, two saves at the same instant can replace one another without either being
1783
+ told. It writes nothing but step-types/<id>.json, one history line and its own temporary files. */
1784
+ const WRITE_CONTRACT = 'flightdeck-write/1';
1785
+ const TYPE_INPUT_MAX = 262144, TYPE_READ_CAP = 1048576;
1786
+ const NO_LINKS = ['EPERM', 'ENOTSUP', 'EOPNOTSUPP', 'ENOSYS', 'EXDEV'];
1787
+ const BIG = { bigint: true };
1788
+ /* A test hook (FD._hooks), when one is set; its promise is awaited only where the caller awaits it. */
1789
+ function hook(name, ...a) { const h = _hooks[name]; return h ? h(...a) : undefined; }
1790
+ const thenable = (p) => !!p && typeof p.then === 'function';
1791
+ const errText = (e) => (e && e.code ? e.code + (e.message && e.message.indexOf(e.code) < 0 ? ': ' + e.message : '') : (e && e.message) || String(e));
1792
+ function lstatBig(p) { try { return fs.lstatSync(p, BIG); } catch (e) { if (e.code === 'ENOENT') return null; throw e; } }
1793
+ const sameNode = (a, b) => a.dev === b.dev && a.ino === b.ino;
1794
+ const sameMeta = (a, b) => sameNode(a, b) && a.size === b.size && a.mtimeNs === b.mtimeNs && a.ctimeNs === b.ctimeNs;
1795
+ const sha256 = (b) => crypto.createHash('sha256').update(b).digest('hex');
1796
+ const relOf = (dir, full) => path.relative(dir, full).split(path.sep).join('/');
1797
+ /* One bounded read through a descriptor: opened without following a link or blocking (a FIFO swapped in can't hang it),
1798
+ checked at once to be the regular file lstat saw (st), read in a loop into at most limit bytes, then stat'ed again (the
1799
+ snapshot). opts.cap refuses a bigger file before reading. Every descriptor is closed; a close error is a failure too. */
1800
+ function readThrough(full, phase, limit, st, opts) {
1801
+ hook('beforeOpen', full, phase);
1802
+ let fd;
1803
+ try { fd = fs.openSync(full, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0) | (fs.constants.O_NONBLOCK || 0)); }
1804
+ catch (e) { return { fail: e.code === 'ENOENT' ? 'gone' : e.code === 'ELOOP' ? 'link' : 'error', why: errText(e), code: e.code || null }; }
1805
+ let res;
1806
+ try {
1807
+ const first = fs.fstatSync(fd, BIG);
1808
+ if (!first.isFile()) res = { fail: 'notfile', why: 'it isn\'t a regular file' };
1809
+ else if (!sameNode(first, st)) res = { fail: 'moved', why: 'it was replaced as it was opened' };
1810
+ else if (opts && opts.cap && first.size > BigInt(opts.cap)) res = { fail: 'oversized', size: Number(first.size) };
1811
+ else {
1812
+ const buf = Buffer.alloc(limit);
1813
+ let total = 0;
1814
+ while (total < limit) { const n = fs.readSync(fd, buf, total, limit - total, total); if (!n) break; total += n; hook('readChunk', full, phase, total); }
1815
+ res = { first, snap: fs.fstatSync(fd, BIG), bytes: buf.subarray(0, total), filled: total >= limit };
1816
+ }
1817
+ } catch (e) { res = { fail: 'error', why: errText(e), code: e.code || null }; }
1818
+ try { fs.closeSync(fd); } catch (e) { if (!res.fail) res = { fail: 'close', why: 'couldn\'t close ' + full + ' (' + errText(e) + ')' }; }
1819
+ return res;
1820
+ }
1821
+ /* A file read whole, up to the read cap, with the path checked again after the read: { state: 'bytes', bytes } or missing,
1822
+ link (a link or non-file at the path), unreadable, oversized or vanished, each with why. retry reads once more when the
1823
+ file was replaced in between. listed: the name was in the folder's listing, so a file gone before its first lstat
1824
+ vanished; it's missing only when nothing said it was there. unreadable carries the error's code. */
1825
+ function readWhole(home, name, phase, retry, listed) {
1826
+ const full = path.join(home, name);
1827
+ for (let n = 0; n < (retry ? 2 : 1); n++) {
1828
+ let st;
1829
+ try { st = lstatBig(full); } catch (e) { return { state: 'unreadable', why: errText(e), code: e.code || null }; }
1830
+ if (!st) return n ? { state: 'vanished', why: 'it was removed while Flightdeck was reading it' } : listed ? { state: 'vanished', why: 'It was listed, then gone before it could be read. Try again.' } : { state: 'missing' };
1831
+ if (st.isSymbolicLink() || !st.isFile()) return { state: 'link', why: st.isSymbolicLink() ? 'it\'s a link; Flightdeck never reads through one' : 'it isn\'t a file', folder: st.isDirectory() };
1832
+ const r = readThrough(full, phase, TYPE_READ_CAP + 1, st, { cap: TYPE_READ_CAP });
1833
+ if (r.fail === 'gone') return { state: 'vanished', why: 'It was there a moment ago and was gone when it was read. Try again.' };
1834
+ if (r.fail === 'link') return { state: 'link', why: 'it\'s a link; Flightdeck never reads through one' };
1835
+ if (r.fail === 'oversized' || (!r.fail && r.filled)) return { state: 'oversized', bytes: r.fail ? r.size : r.bytes.length, why: 'it\'s over the ' + size(TYPE_READ_CAP) + ' Flightdeck reads' };
1836
+ if (r.fail === 'moved') continue;
1837
+ if (r.fail === 'close') return { state: 'unreadable', close: true, why: r.why, code: null };
1838
+ if (r.fail) return { state: 'unreadable', why: r.why, code: r.code || null };
1839
+ if (!sameMeta(r.first, r.snap)) continue;
1840
+ hook('afterSnapshot', full, phase);
1841
+ let now;
1842
+ try { now = lstatBig(full); } catch (e) { return { state: 'unreadable', why: errText(e), code: e.code || null }; }
1843
+ if (!now) return { state: 'vanished', why: 'it was removed while Flightdeck was reading it' };
1844
+ if (sameMeta(now, r.snap)) return { state: 'bytes', bytes: r.bytes };
1845
+ }
1846
+ return { state: 'vanished', why: 'it changed while Flightdeck was reading it, twice. Try again.' };
1847
+ }
1848
+ const IO_KIND = { unreadable: true, link: true, skipped: true, vanished: true };
1849
+ const utf8ok = (b) => { try { new TextDecoder('utf-8', { fatal: true }).decode(b); return true; } catch (e) { return false; } };
1850
+ /* A text file read through the ledger as the app reads it (UTF-8, bad bytes replaced, a byte-order mark dropped), with
1851
+ the replacement named: { text, size } or null. */
1852
+ async function readText(L, p, max) {
1853
+ const b = await L.fs.blob(p, { maxBytes: max });
1854
+ if (!b) return null;
1855
+ const bytes = Buffer.from(await b.arrayBuffer());
1856
+ if (!utf8ok(bytes)) L.note(p, 'malformed', 'isn\'t valid UTF-8 text; Flightdeck reads it with the bad bytes replaced');
1857
+ return { text: new TextDecoder('utf-8').decode(bytes), size: bytes.length };
1858
+ }
1859
+ /* How many lines of one history file PF.repo.loadEditHistory rejects, counted as it counts them (the newest TL.HIST.READ
1860
+ lines looked at; too long, not JSON, or not an entry cleanEntry keeps). */
1861
+ function historyRejects(TL, text, name) {
1862
+ const H = TL.HIST, lines = String(text).split('\n');
1863
+ let n = 0, seen = 0;
1864
+ for (let i = lines.length - 1; i >= 0; i--) {
1865
+ const line = lines[i];
1866
+ if (!line.trim()) continue;
1867
+ if (++seen > H.READ) continue;
1868
+ if (line.length > H.LINE || TL.bytes(line) > H.LINE) { n++; continue; }
1869
+ let ok = false;
1870
+ try { ok = !!TL.cleanEntry(JSON.parse(line), name); } catch (e) { ok = false; }
1871
+ if (!ok) n++;
1872
+ }
1873
+ return n;
1874
+ }
1875
+ /* The type commands' problem ledger: PF.repo.ledger over a read-only view of the team folder (PF.repo.readOnly), as the
1876
+ --json commands read. A step type's exact bytes are read through descriptors (readWhole), and what fails there is noted
1877
+ in it by path; drafts and history are read through its fs, so every listing or read that fails lands in it too.
1878
+ problems(): the ledger's and the adapter's (links, files too big, files that vanished), each once. */
1879
+ function typeLedger(PF, dir, what) {
1880
+ const ra = dir ? readAdapter(dir, { maxBytes: READ_MAX }) : null, L = PF.repo.ledger(ra ? PF.repo.readOnly(ra, what) : {});
1881
+ const problems = () => {
1882
+ const out = L.problems(), seen = {};
1883
+ out.forEach((x) => { seen[x.path + '\u0000' + x.kind] = true; });
1884
+ if (ra) adapterProblems(ra).forEach((x) => { const k = x.path + '\u0000' + x.kind; if (!seen[k]) { seen[k] = true; out.push(x); } });
1885
+ return out;
1886
+ };
1887
+ return { fs: L.fs, note: L.note, problems };
1888
+ }
1889
+ const denied = (code) => code === 'EACCES' || code === 'EPERM';
1890
+ /* Why a save is held because something couldn't be read, with the next step that fits the failure: access for a denial, a
1891
+ person for a file too big to compare, a sync only for a file that changed or vanished as it was read. */
1892
+ function heldWhy(rel, got) {
1893
+ if (denied(got.code)) return 'Access to ' + rel + ' was denied. Ask the folder owner to check your access, then try again.';
1894
+ if (got.state === 'oversized') return rel + ' is over the ' + size(TYPE_READ_CAP) + ' Flightdeck reads, so it\'s too big to check here. Ask a person to look at it in Flightdeck.';
1895
+ if (got.state === 'vanished') return rel + ' changed or vanished while Flightdeck read it, which usually means it\'s syncing. Try again once it has synced.';
1896
+ if (got.state === 'link') return rel + ' isn\'t a file Flightdeck reads: ' + bare(got.why) + '.';
1897
+ return rel + ' couldn\'t be read (' + bare(got.why) + '). Check the folder, then try again.';
1898
+ }
1899
+ const bare = (t) => String(t || '').trim().replace(/[.!?]+$/, '');
1900
+ const UNLISTED = 'Flightdeck couldn\'t list step-types/. Whether this type exists is unknown. Check folder access before trying again.';
1901
+ /* step-types/ as listed now: every entry, and the regular .json files in the order the app's loader reads them. */
1902
+ function typeListing(home) {
1903
+ const entries = fs.readdirSync(home, { withFileTypes: true });
1904
+ const names = entries.map((e) => e.name), files = entries.filter((e) => e.isFile() && /\.json$/i.test(e.name)).map((e) => e.name).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
1905
+ return { entries, names, files };
1906
+ }
1907
+ function loadPosition(files, name) {
1908
+ const all = files.indexOf(name) >= 0 ? files : files.concat([name]).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
1909
+ return all.indexOf(name) + 1;
1910
+ }
1911
+ /* What a step-type file is to the command (state: ok, malformed or newer, on its exact bytes) and to the app (usable: the
1912
+ app's lenient decoding, then checkCustom and name = ID, as repo.loadStepTypes reads it). */
1913
+ function typeFile(PF, bytes, file) {
1914
+ const T = PF.types, bom = bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf;
1915
+ const text = new TextDecoder('utf-8').decode(bytes);
1916
+ let utf8 = true;
1917
+ try { new TextDecoder('utf-8', { fatal: true }).decode(bytes); } catch (e) { utf8 = false; }
1918
+ const crlf = (text.match(/\r\n/g) || []).length, lf = (text.match(/\n/g) || []).length - crlf, cr = (text.match(/\r(?!\n)/g) || []).length;
1919
+ const lineEndings = !crlf && !lf && !cr ? 'none' : crlf && !lf && !cr ? 'crlf' : lf && !crlf && !cr ? 'lf' : 'mixed';
1920
+ const out = { bom, utf8, text, lineEndings, state: 'ok', def: null, appDef: null, errors: [], needs: null, usable: false, valid: false };
1921
+ let o, parsed = true;
1922
+ try { o = JSON.parse(text); } catch (e) { parsed = false; }
1923
+ const object = parsed && !!o && typeof o === 'object' && !Array.isArray(o);
1924
+ /* The app's view. */
1925
+ if (parsed) {
1926
+ let errs;
1927
+ try { errs = T.checkCustom(o); } catch (e) { errs = ['couldn\'t be read as a step type']; }
1928
+ if (!errs.length && file === o.id + '.json') { try { T.customType(o); out.usable = true; out.appDef = o; } catch (e) { out.usable = false; } }
1929
+ }
1930
+ /* The command's view. */
1931
+ if (!parsed) return Object.assign(out, { state: 'malformed', errors: [utf8 ? 'isn\'t valid JSON' : 'isn\'t valid UTF-8 text'] });
1932
+ if (!object) return Object.assign(out, { state: 'malformed', errors: ['must be a JSON object'] });
1933
+ if (o.format != null || o.features != null) {
1934
+ const env = PF.envelope.judge('flightdeck-step-type', o);
1935
+ if (!env.ok && env.kind === 'newer') { let e1; try { e1 = T.checkCustom(o)[0]; } catch (e) { e1 = ''; } return Object.assign(out, { state: 'newer', needs: env.needs || null, errors: [e1 || 'It needs a newer Flightdeck.'] }); }
1936
+ }
1937
+ out.def = o;
1938
+ let errs;
1939
+ try { errs = T.checkCustom(o).slice(); } catch (e) { errs = ['couldn\'t be read as a step type']; }
1940
+ if (!utf8) return Object.assign(out, { state: 'malformed', errors: ['isn\'t valid UTF-8 text'].concat(errs) });
1941
+ if (errs.length) return Object.assign(out, { state: 'malformed', errors: errs });
1942
+ if (file !== o.id + '.json') return Object.assign(out, { state: 'malformed', errors: ['the file name must be ' + o.id + '.json'] });
1943
+ out.valid = true;
1944
+ return out;
1945
+ }
1946
+ const STATE_KIND = { malformed: 'malformed', newer: 'newer', unreadable: 'unreadable', oversized: 'skipped', vanished: 'vanished', link: 'link' };
1947
+ /* One file as type read and type list report it, its problem noted in the ledger. unknown: why step-types/ couldn't be
1948
+ listed (or reached), when it couldn't: the file is still read where it can be, but its position is unknown, and a file
1949
+ not found then is unreadable, never missing. Only absence nothing contradicts gives rev "none". */
1950
+ function typeEntry(PF, L, home, file, files, phase, unknown) {
1951
+ const rel = 'step-types/' + file, listed = !unknown && files.indexOf(file) >= 0, pos = listed ? files.indexOf(file) + 1 : null;
1952
+ const got = home ? readWhole(home, file, phase, true, listed) : { state: 'missing' };
1953
+ const e = { state: got.state, rev: null, bytes: null, bom: null, lineEndings: null, utf8: null, text: null, def: null, errors: [], needs: null, position: pos, selected: pos == null ? null : pos <= PF.save.LIMIT, usable: pos == null ? null : false };
1954
+ if (got.state === 'missing' && unknown) { L.note(rel, 'unreadable', unknown); return Object.assign(e, { state: 'unreadable', errors: [unknown] }); }
1955
+ if (got.state === 'missing') return Object.assign(e, { rev: 'none', position: null, selected: null, usable: null });
1956
+ if (got.state !== 'bytes') {
1957
+ L.note(rel, STATE_KIND[got.state], got.why);
1958
+ return Object.assign(e, { bytes: got.state === 'oversized' ? got.bytes : null, errors: got.why ? [got.why] : [] });
1959
+ }
1960
+ const f = typeFile(PF, got.bytes, file);
1961
+ Object.assign(e, { state: f.state, rev: sha256(got.bytes), bytes: got.bytes.length, bom: f.bom, lineEndings: f.lineEndings, utf8: f.utf8, text: f.text, def: f.state === 'newer' ? null : f.def, errors: f.errors, needs: f.needs });
1962
+ if (pos != null) e.usable = e.selected && f.usable;
1963
+ if (f.state !== 'ok') L.note(rel, STATE_KIND[f.state], f.errors[0] || '');
1964
+ return e;
1965
+ }
1966
+ function typeFail(eng, kind, why) { return { out: JSON.stringify(eng.PF.appjson.failure(eng.version, kind, why, [], 2), null, 2), code: EXIT.error }; }
1967
+ async function typeRead(eng, argv, args) {
1968
+ const PF = eng.PF, AJ = PF.appjson, T = PF.types, sub = args[1];
1969
+ const extra = argv.filter((a) => a.startsWith('--') && a !== '--json');
1970
+ if (extra.length) return typeFail(eng, 'usage', 'type ' + sub + ' doesn\'t take ' + extra[0] + '.');
1971
+ if (!argv.includes('--json')) return typeFail(eng, 'usage', 'flightdeck type ' + sub + ' prints JSON. Add --json.');
1972
+ const id = args[3];
1973
+ if (sub === 'read' && (!id || !T.ID.test(id) || id.length > T.ID_MAX)) return typeFail(eng, 'usage', 'a step type ID is lowercase letters, numbers, dashes and dots, like deploy.check: flightdeck type read <team folder> <id> --json');
1974
+ if (args.length > (sub === 'read' ? 4 : 3)) return typeFail(eng, 'usage', 'type ' + sub + ' takes ' + (sub === 'read' ? 'a team folder and an ID' : 'a team folder') + ', nothing else.');
1975
+ let dir;
1976
+ try { dir = folderArg(args[2], 'type ' + sub); } catch (e) { return typeFail(eng, 'failed', e.message); }
1977
+ const L = typeLedger(PF, dir, 'Reading step types'), readAt = new Date().toISOString();
1978
+ let home = null, unknown = null, listing = { names: [], files: [], entries: [] };
1979
+ try { home = folders(dir, ['step-types'], false); } catch (e) { L.note('step-types', 'link', e.message); unknown = bare(e.message) + '. Whether this type exists is unknown.'; }
1980
+ if (home) { try { listing = typeListing(home); } catch (e) { L.note('step-types', 'unreadable', 'couldn\'t be listed (' + errText(e) + ')'); unknown = UNLISTED; } }
1981
+ const done = (data) => { const env = AJ.envelope(eng.version, readAt, L.problems(), data, 2); return { out: JSON.stringify(env, null, 2), code: env.complete ? 0 : EXIT.partial }; };
1982
+ if (sub === 'read') {
1983
+ const file = id + '.json', e = typeEntry(PF, L, home, file, listing.files, 'read', unknown);
1984
+ return done({ type: Object.assign({ id, path: 'step-types/' + file }, e) });
1985
+ }
1986
+ const S = PF.save, rows = [], other = [];
1987
+ for (const file of listing.files) {
1988
+ const e = typeEntry(PF, L, home, file, listing.files, 'list', null);
1989
+ /* A listed type it's a copy of first, else the name it would be a copy of (the original may be gone). */
1990
+ const base = listing.names.filter((n) => n !== file && /\.json$/i.test(n) && S.copiesOf(n, [file]).length)[0] || S.copyBase(file);
1991
+ rows.push({ file, id: e.def && typeof e.def.id === 'string' ? e.def.id : null, state: e.state, rev: e.rev, kind: 'file', position: e.position, selected: e.selected, usable: e.usable,
1992
+ copyOf: base && /\.json$/i.test(base) && T.ID.test(base.slice(0, -5).toLowerCase()) ? base : null, errors: e.errors });
1993
+ }
1994
+ listing.entries.filter((x) => !x.isFile() && /\.json$/i.test(x.name)).sort((a, b) => (a.name < b.name ? -1 : 1)).forEach((x) => {
1995
+ const link = x.isSymbolicLink();
1996
+ L.note('step-types/' + x.name, link ? 'link' : 'unusable', link ? 'it\'s a link; Flightdeck never reads through one' : 'it isn\'t a file');
1997
+ other.push({ file: x.name, id: null, state: 'link', rev: null, kind: link ? 'link' : 'folder', position: null, selected: null, usable: null, copyOf: null, errors: [] });
1998
+ });
1999
+ return done({ types: rows.concat(other), limit: S.LIMIT, count: listing.files.length });
2000
+ }
2001
+ /* T: the new bytes in an exclusive temporary file beside the target, written in full, flushed, closed and read back. The
2002
+ name never ends in .json, so no loader, copy check or count sees it. */
2003
+ function tempBeside(home, name, data) {
2004
+ const tmp = path.join(home, '.' + name + '.' + process.pid + '.' + crypto.randomBytes(6).toString('hex') + '.tmp');
2005
+ let fd = null, ours = null;
2006
+ try {
2007
+ fd = fs.openSync(tmp, 'wx');
2008
+ ours = fs.fstatSync(fd, BIG);
2009
+ for (let off = 0; off < data.length;) { const n = fs.writeSync(fd, data, off, data.length - off); if (!(n > 0)) throw new Error('the disk accepted no more bytes'); off += n; }
2010
+ fs.fsyncSync(fd);
2011
+ const c = fd; fd = null;
2012
+ try { fs.closeSync(c); } catch (e) { throw new Error('couldn\'t close ' + tmp + ' (' + errText(e) + ')'); }
2013
+ const back = readThrough(tmp, 'T', data.length + 1, ours, null);
2014
+ if (back.fail) throw new Error('the temporary file couldn\'t be read back (' + back.why + ')');
2015
+ if (back.filled || !back.bytes.equals(data)) throw new Error('the temporary file didn\'t read back as written');
2016
+ return { tmp, ours };
2017
+ } catch (e) {
2018
+ if (fd !== null) try { fs.closeSync(fd); } catch (err) { /* the write already failed */ }
2019
+ const x = new Error('couldn\'t write the new file (' + errText(e) + ')');
2020
+ x.tmp = ours ? tmp : null; x.ours = ours;
2021
+ throw x;
2022
+ }
2023
+ }
2024
+ /* Remove a temporary file, only while it's still the one this process made. Returns the error, or null. */
2025
+ function removeOurs(full, ours, atUnlink) {
2026
+ try {
2027
+ const st = lstatBig(full);
2028
+ if (!st || st.isSymbolicLink() || !sameNode(st, ours)) return null;
2029
+ if (atUnlink) hook('atUnlink', full);
2030
+ fs.unlinkSync(full);
2031
+ return null;
2032
+ } catch (e) { return errText(e); }
2033
+ }
2034
+ /* P: link the temporary file to a second name and remove it, before the target is touched. */
2035
+ function probeLinks(tmp) {
2036
+ const probe = tmp.replace(/\.tmp$/, '.probe');
2037
+ try { fs.linkSync(tmp, probe); } catch (e) { return NO_LINKS.indexOf(e.code) >= 0 ? { refused: 'no-links', why: errText(e) } : { failed: true, why: errText(e) }; }
2038
+ try { fs.unlinkSync(probe); } catch (e) { return { failed: true, why: 'the check\'s second name couldn\'t be removed (' + errText(e) + ')', left: probe }; }
2039
+ return null;
2040
+ }
2041
+ /* A new file, created once (the history line): T, P, then one link that never replaces anything. */
2042
+ function createOnce(dir, parts, data) {
2043
+ const res = { commit: 'no', why: null, left: [], leftWhy: [] };
2044
+ let home, t;
2045
+ try { home = folders(dir, parts.slice(0, -1), true); } catch (e) { res.why = e.message; return res; }
2046
+ const target = path.join(home, parts[parts.length - 1]);
2047
+ try { t = tempBeside(home, parts[parts.length - 1], data); } catch (e) { res.why = e.message; if (e.tmp) { const w = removeOurs(e.tmp, e.ours); if (w) { res.left.push(e.tmp); res.leftWhy.push(w); } } return res; }
2048
+ const p = probeLinks(t.tmp);
2049
+ if (p) { res.why = p.why; if (p.left) { res.left.push(p.left); res.leftWhy.push(p.why); } }
2050
+ else {
2051
+ try {
2052
+ if (lstatBig(target)) res.why = 'it\'s already there';
2053
+ else { fs.linkSync(t.tmp, target); res.commit = 'yes'; }
2054
+ } catch (e) { if (e.code === 'EEXIST') res.why = 'it\'s already there'; else { res.commit = 'unknown'; res.why = errText(e); } }
2055
+ }
2056
+ const w = removeOurs(t.tmp, t.ours);
2057
+ if (w) { res.left.push(t.tmp); res.leftWhy.push(w); }
2058
+ return res;
2059
+ }
2060
+ /* B: does the path still name our file, holding our bytes, unchanged while it was read? (design 1.4b) */
2061
+ function observeOther(target) {
2062
+ try {
2063
+ const st = lstatBig(target);
2064
+ if (!st) return { now: null, kind: 'vanished', why: 'it was gone when Flightdeck looked at what replaced it' };
2065
+ if (st.isSymbolicLink() || !st.isFile()) return { now: null, kind: 'link', why: 'a folder or a link is there now' };
2066
+ const r = readThrough(target, 'observe', TYPE_READ_CAP + 1, st, { cap: TYPE_READ_CAP });
2067
+ if (r.fail === 'oversized' || (!r.fail && r.filled)) return { now: null, kind: 'skipped', why: 'what replaced it is over the ' + size(TYPE_READ_CAP) + ' Flightdeck reads' };
2068
+ if (r.fail === 'gone' || r.fail === 'moved') return { now: null, kind: 'vanished', why: 'what replaced it changed again as it was read' };
2069
+ if (r.fail) return { now: null, kind: r.fail === 'link' || r.fail === 'notfile' ? 'link' : 'unreadable', why: r.why };
2070
+ const fin = lstatBig(target);
2071
+ if (!sameMeta(r.first, r.snap) || !fin || !sameMeta(fin, r.snap)) return { now: null, kind: 'vanished', why: 'it changed while Flightdeck was reading it' };
2072
+ return { now: sha256(r.bytes), kind: null, why: null };
2073
+ } catch (e) { return { now: null, kind: 'unreadable', why: errText(e) }; }
2074
+ }
2075
+ function readBack(target, ours, data) {
2076
+ let st;
2077
+ try { st = lstatBig(target); } catch (e) { return { back: 'unreadable', now: null, kind: 'unreadable', why: errText(e) }; }
2078
+ if (!st) return { back: 'missing', now: null, why: null };
2079
+ if (st.isSymbolicLink() || !st.isFile()) return { back: 'unreadable', now: null, kind: 'link', why: 'a folder or a link is there now' };
2080
+ if (!sameNode(st, ours)) return Object.assign({ back: 'other' }, observeOther(target));
2081
+ const r = readThrough(target, 'B', data.length + 1, st, null);
2082
+ if (r.fail === 'gone') return { back: 'missing', now: null, why: null };
2083
+ if (r.fail === 'moved') return Object.assign({ back: 'other' }, observeOther(target));
2084
+ if (r.fail) return { back: 'unreadable', now: null, kind: 'unreadable', why: r.why };
2085
+ hook('afterSnapshot', target, 'B');
2086
+ let fin;
2087
+ try { fin = lstatBig(target); } catch (e) { return { back: 'unreadable', now: null, kind: 'unreadable', why: errText(e) }; }
2088
+ if (!fin) return { back: 'missing', now: null, why: null };
2089
+ if (!sameNode(fin, ours)) return Object.assign({ back: 'other' }, observeOther(target));
2090
+ const len = BigInt(data.length), stable = sameMeta(r.first, r.snap) && sameMeta(fin, r.snap);
2091
+ if (r.filled || !r.bytes.equals(data) || r.snap.size !== len || fin.size !== len) return stable ? Object.assign({ back: 'other' }, observeOther(target)) : { back: 'other', now: null, kind: 'vanished', why: 'it changed while Flightdeck was reading it back' };
2092
+ if (!stable) return { back: 'unstable', now: null, kind: 'vanished', why: 'it changed while Flightdeck was reading it back' };
2093
+ return { back: 'same', now: sha256(data), why: null };
2094
+ }
2095
+ /* --from's bytes, never more than limit + 1 read (design D5): standard input (-), or a regular file opened without
2096
+ blocking (a FIFO or a device is refused, never waited on or read without end). { bytes, over, size } or { usage }. */
2097
+ function boundedInput(file, limit) {
2098
+ let fd = 0, own = false, size = null;
2099
+ try {
2100
+ if (file !== '-') {
2101
+ fd = fs.openSync(expand(file), fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK || 0)); own = true;
2102
+ const st = fs.fstatSync(fd);
2103
+ if (!st.isFile()) return { usage: '--from is a file with the definition, or - for standard input; ' + file + ' isn\'t a regular file.' };
2104
+ if (st.size > limit) return { bytes: null, over: true, size: st.size };
2105
+ }
2106
+ const buf = Buffer.alloc(limit + 1);
2107
+ let total = 0, waits = 0;
2108
+ while (total < buf.length) {
2109
+ let n;
2110
+ try { n = fs.readSync(fd, buf, total, buf.length - total, own ? total : null); } catch (e) {
2111
+ if (e.code === 'EAGAIN' && !own && waits++ < 6000) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10); continue; }
2112
+ if (e.code === 'EOF') break;
2113
+ throw e;
2114
+ }
2115
+ if (!n) break;
2116
+ total += n;
2117
+ }
2118
+ if (total > limit) return { bytes: null, over: true, size };
2119
+ return { bytes: buf.subarray(0, total), over: false, size: total };
2120
+ } catch (e) { return { usage: 'couldn\'t read --from (' + errText(e) + ').' }; }
2121
+ finally { if (own) try { fs.closeSync(fd); } catch (e) { /* only read */ } }
2122
+ }
2123
+ /* The new definition from --from: at most 256 KB of strict UTF-8 (a leading byte-order mark dropped), no key twice in one
2124
+ object, no __proto__, constructor or prototype key, one JSON object, the ID it's saved as, checkCustom, then formatWhy. */
2125
+ function typeInput(PF, file, id) {
2126
+ const T = PF.types, S = PF.save;
2127
+ const bad = (why, errors) => ({ invalid: why, errors: errors || [why] });
2128
+ const got = boundedInput(file, TYPE_INPUT_MAX);
2129
+ if (got.usage) return { usage: got.usage };
2130
+ const buf = got.bytes;
2131
+ if (got.over) return bad('the definition is ' + (got.size != null ? got.size + ' bytes' : 'more than ' + TYPE_INPUT_MAX + ' bytes') + '; a step type is at most ' + TYPE_INPUT_MAX + ' bytes (256 KB) of UTF-8');
2132
+ let text;
2133
+ try { text = new TextDecoder('utf-8', { fatal: true }).decode(buf); } catch (e) { return bad('the definition isn\'t valid UTF-8 text'); }
2134
+ let def;
2135
+ try { def = JSON.parse(text); } catch (e) { return bad('the definition isn\'t valid JSON (' + e.message + ')'); }
2136
+ if (!def || typeof def !== 'object' || Array.isArray(def)) return bad('must be a JSON object');
2137
+ const dup = S.duplicateKeys(text);
2138
+ if (dup !== null) return bad('"' + dup + '" appears twice in one object; JSON keeps only the last, so say it once');
2139
+ if (def.id !== id) return { usage: 'the definition\'s "id" must be ' + id + ', the ID it\'s saved as.' };
2140
+ /* checkCustom first, so a reserved name gets the reader's own words (a shared rule); then the keys it doesn't look at. A
2141
+ value the checks can't even read (a name that's an object with no usable text, say) is invalid too, never a crash. */
2142
+ let data;
2143
+ try {
2144
+ const errs = T.checkCustom(def).slice();
2145
+ if (errs.length) return bad(errs[0], errs);
2146
+ const odd = S.oddKeys(def);
2147
+ if (odd !== null) return bad('"' + odd + '" can\'t be a key in a step type');
2148
+ const fw = T.formatWhy(def);
2149
+ if (fw) return bad(fw);
2150
+ data = Buffer.from(T.fileText(def), 'utf8');
2151
+ } catch (e) { return bad('it couldn\'t be read as a step type: a name, label or option isn\'t plain text or a list where one is expected'); }
2152
+ if (data.length > TYPE_INPUT_MAX) return bad('the saved file would be ' + data.length + ' bytes; a step type is at most ' + TYPE_INPUT_MAX + ' bytes (256 KB)');
2153
+ return { def, data };
2154
+ }
2155
+ /* A flightdeck-write/1 answer with nothing done yet: every field, written false. */
2156
+ function writeShell(app, rel, outcome, error, words, now) {
2157
+ return { contract: WRITE_CONTRACT, app, command: 'type save', path: rel, operation: null, outcome, written: false,
2158
+ rev: { expected: null, before: null, after: null, now: null }, back: null, load: null, existing: null, appLoads: null,
2159
+ checks: { errors: [], warnings: [], omitted: { before: null, after: null } }, askedBy: null, history: null, cleanup: null, error, words, readAt: new Date(now || Date.now()).toISOString(), complete: true, problems: [] };
2160
+ }
2161
+ async function typeSave(eng, argv, args, val) {
2162
+ const PF = eng.PF, S = PF.save, T = PF.types, U = PF.util, TL = PF.timeline, now = Date.now();
2163
+ const id = args[3], name = id + '.json', rel = 'step-types/' + name, idOk = !!id && T.ID.test(id) && id.length <= T.ID_MAX;
2164
+ const out = writeShell(eng.version || '', idOk ? rel : null, '', null, '', now);
2165
+ const left = [], leftWhy = [];
2166
+ let dir = null, L = typeLedger(PF, null);
2167
+ /* Every answer goes through PF.save.settle and PF.save.words. */
2168
+ const finish = (f) => {
2169
+ if (dir && left.length) out.cleanup = { leftover: left.map((p) => relOf(dir, p)), why: leftWhy.join('; ') };
2170
+ const s = S.settle({ refused: f.refused || null, commit: f.commit || 'no', back: f.back || null, history: f.history || null, cleanup: out.cleanup, load: out.load });
2171
+ out.outcome = s.outcome; out.written = s.written; out.back = s.back;
2172
+ if (s.history) out.history = { path: f.historyPath, written: s.history.written, why: s.history.why };
2173
+ const why = f.refused ? f.refused.why || '' : f.why || '';
2174
+ out.words = S.words(s.outcome, { kind: s.kind, path: rel, why, copies: f.refused && f.refused.copies, needs: f.refused && f.refused.needs, load: out.load, history: s.history, cleanup: out.cleanup, back: s.back, dryRun: s.outcome === 'checked' });
2175
+ out.error = s.kind ? { kind: s.kind, why: why || out.words } : null;
2176
+ out.problems = L.problems(); out.complete = out.problems.length === 0;
2177
+ return { out: JSON.stringify(out, null, 2), code: s.exit };
2178
+ };
2179
+ const refuse = (outcome, kind, why, more) => finish({ refused: Object.assign({ outcome, kind, why }, more || {}) });
2180
+ const usage = (why) => refuse('refused', 'usage', why);
2181
+ /* Flags first: nothing is read before every one checks out. */
2182
+ const VAL = ['--expect', '--from', '--asked-by', '--note'], FLAGS = ['--json', '--dry-run'];
2183
+ for (let i = 0; i < argv.length; i++) {
2184
+ if (VAL.indexOf(argv[i]) >= 0) { i++; continue; }
2185
+ if (argv[i].startsWith('--') && FLAGS.indexOf(argv[i]) < 0) return usage('type save doesn\'t take ' + argv[i] + '.');
2186
+ }
2187
+ if (!argv.includes('--json')) return usage('flightdeck type save prints JSON. Add --json.');
2188
+ if (!idOk) return usage('a step type ID is lowercase letters, numbers, dashes and dots, like deploy.check: flightdeck type save <team folder> <id> --expect <rev|none> --from <file> --json');
2189
+ if (args.length > 4) return usage('type save takes a team folder and an ID, nothing else.');
2190
+ for (const f of VAL) if (argv.filter((a) => a === f).length > 1) return usage('say ' + f + ' once.');
2191
+ for (const f of VAL) if (argv.indexOf(f) >= 0 && val[f] == null) return usage('say what goes after ' + f + '.');
2192
+ const expect = val['--expect'];
2193
+ if (expect == null || !(expect === 'none' || /^[0-9a-f]{64}$/.test(expect))) return usage('--expect is the rev type read gave (a SHA-256), or exactly none when type read said missing.');
2194
+ out.rev.expected = expect;
2195
+ if (val['--from'] == null) return usage('say where the definition is: --from <file>, or --from - to read it from standard input.');
2196
+ const by = val['--asked-by'];
2197
+ if (by != null && (by.length < 1 || by.length > 80 || /[\u0000-\u001f\u007f]/.test(by))) return usage('--asked-by is a person ID from people.json, like person-avery: 1 to 80 characters, no control characters.');
2198
+ if (val['--note'] != null && val['--note'].length > 200) return usage('--note is at most 200 characters.');
2199
+ const input = typeInput(PF, val['--from'], id);
2200
+ if (input.usage) return usage(input.usage);
2201
+ if (input.invalid) { out.checks.errors = input.errors; return refuse('refused', 'invalid', input.invalid); }
2202
+ const def = input.def, data = input.data, dryRun = argv.includes('--dry-run');
2203
+ out.rev.after = sha256(data);
2204
+ try { dir = folderArg(args[2], 'type save'); } catch (e) { return refuse('failed', 'failed', e.message); }
2205
+ L = typeLedger(PF, dir, 'Saving a step type');
2206
+ out.operation = PF.proposals.newId(now, U.randomId(10));
2207
+ const historyPath = 'history/step-types/' + id + '/agent-' + out.operation + '.jsonl';
2208
+ try {
2209
+ /* R1: the listing, the target's exact bytes, and who asked. */
2210
+ let home;
2211
+ try { home = folders(dir, ['step-types'], false); } catch (e) { L.note('step-types', 'link', e.message); return refuse('failed', 'failed', e.message); }
2212
+ let listing = { names: [], files: [], entries: [] }, unlisted = null;
2213
+ if (home) {
2214
+ try { listing = typeListing(home); } catch (e) {
2215
+ unlisted = denied(e.code) ? heldWhy('step-types/', { code: e.code }) : 'Flightdeck couldn\'t list step-types/ (' + errText(e) + '), so whether this type exists is unknown. Check folder access before trying again.';
2216
+ L.note('step-types', 'unreadable', 'couldn\'t be listed (' + errText(e) + ')');
2217
+ }
2218
+ }
2219
+ const got = home ? readWhole(home, name, 'R1', false, !unlisted && listing.files.indexOf(name) >= 0) : { state: 'missing' };
2220
+ let seen, file = null, seenBytes = null;
2221
+ if (got.state === 'link') { L.note(rel, 'link', got.why); return refuse('failed', 'failed', rel + ' isn\'t a file Flightdeck can save over: ' + got.why + '.'); }
2222
+ /* A descriptor that won't close before the commit is a failure, never a hold (design 1.4). */
2223
+ if (got.close) { L.note(rel, 'unreadable', got.why); return refuse('failed', 'failed', got.why); }
2224
+ if (got.state === 'missing') { seen = { state: 'missing', rev: null }; out.existing = 'missing'; }
2225
+ else if (got.state === 'bytes') {
2226
+ seenBytes = got.bytes; file = typeFile(PF, got.bytes, name);
2227
+ seen = { state: 'bytes', rev: sha256(got.bytes) }; out.rev.before = seen.rev; out.existing = file.state;
2228
+ /* What's there is named whatever happens next: a malformed file this save repairs, or a newer one it refuses. */
2229
+ if (file.state !== 'ok') L.note(rel, STATE_KIND[file.state], file.errors[0] || '');
2230
+ const pos = listing.files.indexOf(name) >= 0 ? listing.files.indexOf(name) + 1 : null;
2231
+ out.appLoads = pos != null && pos <= S.LIMIT && file.usable;
2232
+ } else {
2233
+ L.note(rel, STATE_KIND[got.state], got.why);
2234
+ seen = { state: 'unreadable', rev: null, why: heldWhy(rel, got) };
2235
+ }
2236
+ const asked = { required: false, held: '', verdict: '' };
2237
+ if (by != null) {
2238
+ /* Listed at the top of the folder first, so a people.json gone as it's read is held, never taken as no people. */
2239
+ let top = null, topWhy = null;
2240
+ try { top = fs.readdirSync(dir); } catch (e) { topWhy = e; }
2241
+ const p = topWhy ? null : readWhole(dir, 'people.json', 'people', false, top.indexOf('people.json') >= 0);
2242
+ if (topWhy) {
2243
+ L.note('people.json', 'unreadable', 'the team folder couldn\'t be listed (' + errText(topWhy) + '), so whether people.json is there is unknown');
2244
+ asked.held = 'Who asked can\'t be checked. ' + (denied(topWhy.code) ? heldWhy('the team folder', { code: topWhy.code }) : 'Flightdeck couldn\'t list the team folder (' + errText(topWhy) + '). Check folder access before trying again.');
2245
+ } else if (p.close) { L.note('people.json', 'unreadable', p.why); return refuse('failed', 'failed', p.why); }
2246
+ else if (p.state === 'missing') asked.verdict = S.asked([], by, 'person');
2247
+ else if (p.state !== 'bytes') { L.note('people.json', STATE_KIND[p.state], p.why); asked.held = 'Who asked can\'t be checked. ' + heldWhy('people.json', p); }
2248
+ else {
2249
+ let doc;
2250
+ try { doc = JSON.parse(new TextDecoder('utf-8').decode(p.bytes)); } catch (e) { doc = undefined; }
2251
+ if (doc === undefined) { L.note('people.json', 'malformed', 'isn\'t valid JSON'); asked.held = 'people.json isn\'t valid JSON, so who asked can\'t be checked.'; }
2252
+ else if (!PF.people.verdict(doc).ok) { L.note('people.json', 'newer', PF.people.verdict(doc).why); asked.held = 'people.json was made with a newer Flightdeck, so who asked can\'t be checked.'; }
2253
+ else {
2254
+ /* What reading it drops or changes is named as the reading commands name it: bad bytes replaced, entries without
2255
+ a text ID and name, names cut to 60. */
2256
+ if (!utf8ok(p.bytes)) L.note('people.json', 'malformed', 'isn\'t valid UTF-8 text; Flightdeck reads it with the bad bytes replaced');
2257
+ const pw = peopleWhy({ hash: '', doc });
2258
+ if (pw) L.note('people.json', 'malformed', pw);
2259
+ const people = PF.people.clean(doc);
2260
+ asked.verdict = S.asked(people, by, 'person');
2261
+ if (!asked.verdict) { const me = people.filter((x) => x.id === by)[0]; out.askedBy = { id: by, name: me.name }; }
2262
+ }
2263
+ }
2264
+ }
2265
+ { const p = hook('afterRead', home ? path.join(home, name) : path.join(dir, 'step-types', name)); if (thenable(p)) await p; }
2266
+ /* Warnings, never refusals; the history line built before any file is made (design 1.6). */
2267
+ const W = out.checks.warnings, oldDef = file && file.state === 'ok' ? file.def : null;
2268
+ /* What the app loads now (a malformed file it still reads leniently included) is what drafts depend on; oldDef, only a
2269
+ valid file, is what history keeps. */
2270
+ const depDef = oldDef || (file && file.usable ? file.appDef : null);
2271
+ if (file && file.state === 'malformed') W.push(rel + ' is malformed (' + (file.errors[0] || 'it isn\'t a step type Flightdeck can use') + '); this save repairs it.');
2272
+ T.unusedKeys(def).forEach((k) => W.push(k + ' isn\'t part of a step type Flightdeck reads; its editor drops it on its next save.'));
2273
+ if (T.markerWhy(def)) W.push(T.markerWhy(def));
2274
+ /* Drafts are read only when the change removes a field or evidence, through the ledger: a listing or a draft that
2275
+ fails is a problem, and the warnings say the check is incomplete. */
2276
+ const gone = T.removed(depDef, def), under = (d) => L.problems().some((x) => x.path === d || x.path.indexOf(d + '/') === 0);
2277
+ if (depDef && (gone.fields.length || gone.evidence.length)) {
2278
+ const drafts = [], DD = 'maps/drafts';
2279
+ let items = [];
2280
+ try { items = await L.fs.list(DD); } catch (e) { items = []; /* named in the ledger */ }
2281
+ for (const i of items) {
2282
+ if (i.kind !== 'file' || !/\.md$/i.test(i.name)) continue;
2283
+ try { const f = await readText(L, DD + '/' + i.name, U.DRAFT_BYTES); if (f) drafts.push({ process: i.name.replace(/\.md$/i, ''), text: f.text }); } catch (e) { /* named in the ledger */ }
2284
+ }
2285
+ T.inUse(depDef, def, drafts).forEach((w) => W.push(w));
2286
+ if (L.problems().some((x) => IO_KIND[x.kind] && (x.path === DD || x.path.indexOf(DD + '/') === 0))) W.push('Couldn\'t check drafts in maps/drafts/. Check them for uses of the removed fields before calling this change complete.');
2287
+ }
2288
+ const omitted = out.checks.omitted, appText = seenBytes ? new TextDecoder('utf-8').decode(seenBytes) : null, from = appText == null ? null : U.hash(appText);
2289
+ if (file && file.state !== 'ok') omitted.before = 'invalid';
2290
+ let seenId = null;
2291
+ if (from) {
2292
+ const HD = 'history/step-types/' + id;
2293
+ /* Every file the reader reads is kept, so a line it rejects (not JSON, too long, a newer kind) is named by its file. */
2294
+ const texts = {}, hfs = Object.assign({}, L.fs, { read: (p2, o) => readText(L, p2, (o && o.maxBytes) || READ_MAX).then((f) => { if (f) texts[p2] = f.text; return f; }) });
2295
+ try {
2296
+ const h = await PF.repo.loadEditHistory(hfs, HD, from); const e = h.items.filter((x) => x.entry && x.entry.to === from)[0]; seenId = e ? e.entry.id : null;
2297
+ if (h.bad) Object.keys(texts).sort().forEach((p2) => { const n = historyRejects(TL, texts[p2], path.posix.basename(p2)); if (n) L.note(p2, 'malformed', n + ' line' + (n === 1 ? '' : 's') + ' Flightdeck couldn\'t read (not a history line, too long, or from a newer Flightdeck)'); });
2298
+ if (h.skipped) L.note(HD, 'skipped', 'over the newest ' + TL.HIST.READ + ' lines Flightdeck reads; ' + h.skipped + ' older line' + (h.skipped === 1 ? ' wasn\'t' : 's weren\'t') + ' read');
2299
+ } catch (e) { seenId = null; /* named in the ledger */ }
2300
+ if (under(HD)) W.push('Couldn\'t read ' + HD + '/, so this save\'s history line doesn\'t say which earlier save it follows.');
2301
+ }
2302
+ const lineOf = (b, a) => TL.histLine({ kind: 'type-edit', id: out.operation, type: id, writer: out.operation, seq: 1, by: by || '', at: new Date(now).toISOString(), from, to: U.hash(T.fileText(def)), seen: seenId,
2303
+ before: b, after: a, source: 'agent', units: [val['--note'] || 'Changed by your AI assistant'] });
2304
+ let line = null, lineWhy = null;
2305
+ try {
2306
+ line = lineOf(oldDef, def);
2307
+ if (line.big) { if (oldDef) omitted.before = 'cap'; omitted.after = 'cap'; }
2308
+ else {
2309
+ const c = TL.cleanEntry(JSON.parse(JSON.stringify(line)), 'agent-' + out.operation + '.jsonl');
2310
+ if (oldDef && (!c || c.before == null)) omitted.before = 'cleaned';
2311
+ if (!c || c.after == null) omitted.after = 'cleaned';
2312
+ }
2313
+ } catch (e) {
2314
+ try { line = lineOf(null, null); if (oldDef) omitted.before = 'fallback'; omitted.after = 'fallback'; } catch (e2) { line = null; lineWhy = 'its history line couldn\'t be built (' + errText(e2) + ')'; omitted.after = 'fallback'; }
2315
+ }
2316
+ S.historyWarnings(omitted).forEach((w) => W.push(w));
2317
+ /* J: one verdict. */
2318
+ const v = S.judge({ mode: S.MODE, expect, seen, listing: unlisted ? { unlisted: true, why: unlisted } : listing.names, name, existing: file ? { verdict: file.state, needs: file.needs } : null, asked: { required: false, held: asked.held, verdict: asked.verdict }, newRev: out.rev.after, dryRun });
2319
+ const loadIf = (files, from2) => { const position = loadPosition(files, name); out.load = { position, limit: S.LIMIT, selected: position <= S.LIMIT, from: from2 }; if (position > S.LIMIT) W.push(from2 === 'C' ? 'Flightdeck won\'t load it: it reads only the first ' + S.LIMIT + ' step-type files by name, and this is number ' + position + '.' : 'If saved with these files present, it would be number ' + position + ', and Flightdeck wouldn\'t load it: it reads only the first ' + S.LIMIT + ' by name.'); };
2320
+ if (!v.go && v.outcome !== 'checked') return finish({ refused: { outcome: v.outcome, kind: v.kind, why: v.why, copies: (v.copies || []).map((c) => 'step-types/' + c), needs: v.needs } });
2321
+ if (!line) return refuse('failed', 'failed', lineWhy);
2322
+ if (v.outcome === 'checked') { loadIf(listing.files, 'R1'); return finish({ refused: { outcome: 'checked', kind: null, why: '' } }); }
2323
+ /* T and P. */
2324
+ let where, t;
2325
+ /* step-types/ is made only for the save: a refusal or failure before the commit removes it again when this call made
2326
+ it and it's still empty, so nothing is left behind (a temporary file it couldn't remove keeps it, and is named). */
2327
+ const made = !lstatOrNull(path.join(dir, 'step-types'));
2328
+ try { where = folders(dir, ['step-types'], true); } catch (e) { L.note('step-types', 'link', e.message); return refuse('failed', 'failed', e.message); }
2329
+ const unmake = () => { if (!made) return; try { if (!fs.readdirSync(where).length) fs.rmdirSync(where); } catch (e) { /* not empty or gone: left as it is */ } };
2330
+ const target = path.join(where, name);
2331
+ try { t = tempBeside(where, name, data); } catch (e) { if (e.tmp) { const w = removeOurs(e.tmp, e.ours); if (w) { left.push(e.tmp); leftWhy.push(w); } } unmake(); return refuse('failed', 'failed', e.message); }
2332
+ const clean = () => { const w = removeOurs(t.tmp, t.ours); if (w) { left.push(t.tmp); leftWhy.push(w); } unmake(); };
2333
+ const stop = (outcome, kind, why, more) => { clean(); return refuse(outcome, kind, why, more); };
2334
+ /* A read in the final compare that fails: named in the ledger, then failed. */
2335
+ const cfail = (at, kind, why) => { L.note(at, kind, why); return stop('failed', 'failed', why); };
2336
+ const pr = probeLinks(t.tmp);
2337
+ if (pr) { if (pr.left) { left.push(pr.left); leftWhy.push(pr.why); } return pr.refused ? stop('refused', 'no-links', pr.why) : stop('failed', 'link-check', pr.why); }
2338
+ /* C: the final compare. */
2339
+ { const p = hook('beforeCompare', target); if (thenable(p)) await p; }
2340
+ let l1;
2341
+ try { l1 = typeListing(where); } catch (e) { return cfail('step-types', 'unreadable', 'step-types/ couldn\'t be listed (' + errText(e) + ')'); }
2342
+ let copies = S.copiesOf(name, l1.names);
2343
+ if (copies.length) return stop('refused', 'copies', '', { copies: copies.map((c) => 'step-types/' + c) });
2344
+ let snap = null;
2345
+ let st0;
2346
+ try { st0 = lstatBig(target); } catch (e) { return cfail(rel, 'unreadable', rel + ' couldn\'t be checked (' + errText(e) + ')'); }
2347
+ if (seenBytes === null) { if (st0) return stop('refused', 'changed', ''); }
2348
+ else {
2349
+ if (!st0) return stop('refused', 'changed', '');
2350
+ if (st0.isSymbolicLink() || !st0.isFile()) return cfail(rel, 'link', rel + ' is a folder or a link now; Flightdeck never saves through one.');
2351
+ const r = readThrough(target, 'C', seenBytes.length + 1, st0, null);
2352
+ if (r.fail === 'gone' || r.fail === 'moved') return stop('refused', 'changed', '');
2353
+ if (r.fail) return cfail(rel, r.fail === 'link' || r.fail === 'notfile' ? 'link' : 'unreadable', r.fail === 'close' ? r.why : rel + ' couldn\'t be compared (' + r.why + ')');
2354
+ if (r.filled || !r.bytes.equals(seenBytes) || !sameMeta(r.first, r.snap)) return stop('refused', 'changed', '');
2355
+ snap = r.snap;
2356
+ hook('afterSnapshot', target, 'C');
2357
+ }
2358
+ let l2;
2359
+ try { l2 = typeListing(where); } catch (e) { return cfail('step-types', 'unreadable', 'step-types/ couldn\'t be listed (' + errText(e) + ')'); }
2360
+ copies = S.copiesOf(name, l2.names);
2361
+ if (copies.length) return stop('refused', 'copies', '', { copies: copies.map((c) => 'step-types/' + c) });
2362
+ { const p = hook('afterFinalListing', target); if (thenable(p)) await p; }
2363
+ let fin;
2364
+ try { fin = lstatBig(target); } catch (e) { return cfail(rel, 'unreadable', rel + ' couldn\'t be checked (' + errText(e) + ')'); }
2365
+ if (seenBytes === null) { if (fin) return stop('refused', 'changed', ''); }
2366
+ else {
2367
+ if (!fin) return stop('refused', 'changed', '');
2368
+ if (fin.isSymbolicLink() || !fin.isFile()) return cfail(rel, 'link', rel + ' is a folder or a link now; Flightdeck never saves through one.');
2369
+ if (!sameMeta(fin, snap)) return stop('refused', 'changed', '');
2370
+ }
2371
+ { const p = hook('afterCompare', target); if (thenable(p)) await p; }
2372
+ /* W: the commit. Only the link or the rename returning says this command wrote. */
2373
+ let commit = 'yes', commitWhy = null;
2374
+ if (seenBytes === null) {
2375
+ try { fs.linkSync(t.tmp, target); } catch (e) { if (e.code === 'EEXIST') return stop('refused', 'changed', ''); commit = 'unknown'; commitWhy = errText(e); }
2376
+ if (commit === 'yes') try { hook('afterLink', target); } catch (e) { /* a test hook; the commit stands */ }
2377
+ } else {
2378
+ try { fs.renameSync(t.tmp, target); } catch (e) { commit = 'unknown'; commitWhy = errText(e); }
2379
+ }
2380
+ if (commit === 'yes') { try { const p = hook('afterCommit', target); if (thenable(p)) await p; } catch (e) { /* a test hook; the commit stands */ } }
2381
+ /* U: the temporary file after a link (a rename moved it; an uncertain rename may have left it). */
2382
+ if (seenBytes === null || commit === 'unknown') { const w = removeOurs(t.tmp, t.ours, commit === 'yes'); if (w) { left.push(t.tmp); leftWhy.push(w); } }
2383
+ /* B: read back, for information only when the commit is unknown. */
2384
+ const b = readBack(target, t.ours, data);
2385
+ /* What the read-back couldn't read, of this save or of what replaced it, is named; the outcome stays what it is. */
2386
+ if (b.kind) L.note(rel, b.kind, b.why || 'it couldn\'t be read back');
2387
+ out.rev.now = b.back === 'same' || b.back === 'other' ? b.now : null;
2388
+ if (commit === 'unknown') return finish({ commit: 'unknown', back: b.back, why: commitWhy });
2389
+ loadIf(l2.files, 'C');
2390
+ /* H: the history line, created once. */
2391
+ const h = createOnce(dir, historyPath.split('/'), Buffer.from(JSON.stringify(line) + '\n', 'utf8'));
2392
+ h.left.forEach((p, i) => { left.push(p); leftWhy.push(h.leftWhy[i]); });
2393
+ return finish({ commit: 'yes', back: b.back, why: b.why || '', history: { commit: h.commit, why: h.why }, historyPath });
2394
+ } catch (e) {
2395
+ return refuse('failed', 'failed', e instanceof Refused ? e.message : 'The team folder couldn\'t be changed: ' + errText(e));
2396
+ }
2397
+ }
2398
+ async function typeMain(argv) {
2399
+ const VAL = ['--expect', '--from', '--asked-by', '--note'], val = {};
2400
+ const args = argv.filter((a, i) => !a.startsWith('--') && !(i > 0 && VAL.indexOf(argv[i - 1]) >= 0));
2401
+ argv.forEach((a, i) => { if (VAL.indexOf(a) >= 0 && i + 1 < argv.length && !(argv[i + 1].startsWith('--') && argv[i + 1] !== '-')) val[a] = argv[i + 1]; });
2402
+ let eng;
2403
+ const sub = args[1];
2404
+ try { eng = loadEngine(); if (_hooks.engine) _hooks.engine(eng); } catch (e) {
2405
+ /* type save answers in its own contract even without an engine: failed, nothing changed. */
2406
+ if (sub === 'save') return { out: JSON.stringify(writeShell('', null, 'failed', { kind: 'failed', why: errText(e) }, 'Nothing was changed. Flightdeck couldn\'t load: ' + bare(errText(e)) + '.'), null, 2), code: EXIT.error };
2407
+ return { out: JSON.stringify({ contract: 'flightdeck-read/2', app: '', error: { kind: 'failed', why: e.message }, problems: [] }, null, 2), code: EXIT.error };
2408
+ }
2409
+ if (sub === 'list' || sub === 'read') return typeRead(eng, argv, args);
2410
+ if (sub === 'save') return typeSave(eng, argv, args, val);
2411
+ return typeFail(eng, 'usage', 'say list, read or save, for example: flightdeck type read <team folder> <id> --json');
2412
+ }
2413
+
1778
2414
  const HELP = `flightdeck: team process checklists, in one HTML file
1779
2415
 
1780
2416
  flightdeck open open the app in this package: the first time, the welcome (start your team, join
@@ -1828,6 +2464,13 @@ const HELP = `flightdeck: team process checklists, in one HTML file
1828
2464
  flightdeck skill register <team folder> --from <file|-> [--json]
1829
2465
  for AI assistants: register a skill against the exact version it was built
1830
2466
  from (Flightdeck 2.0). The only writer of skill-registrations/
2467
+ flightdeck type list <team folder> --json
2468
+ for AI assistants: the team's step types, in the order Flightdeck reads them
2469
+ flightdeck type read <team folder> <id> --json
2470
+ one step type: its state, its exact text and rev (none when it isn't there)
2471
+ flightdeck type save <team folder> <id> --expect <rev|none> --from <file|-> [--asked-by <person id>]
2472
+ [--note "<text>"] [--dry-run] --json
2473
+ add or change a step type, only when it's exactly what you read
1831
2474
  flightdeck path print where the app file is
1832
2475
  flightdeck version print this version
1833
2476
 
@@ -1849,6 +2492,12 @@ or version.
1849
2492
  flightdeck draft changes the team folder (contract flightdeck-draft/1). Exit code 0: written or proposed (read: complete);
1850
2493
  4: read, partial; 1: usage, missing-base, too-big or failed; 5: read, no such process.
1851
2494
 
2495
+ flightdeck type save changes the team folder (contract flightdeck-write/1): it writes step-types/<id>.json and one
2496
+ history line, never anything else. It compares the exact bytes type read gave and reads its save back. Like two Flightdeck
2497
+ windows in different browsers, two saves at the same instant can replace one another without either being told. Exit
2498
+ code 0: done, same or checked; 4: held, partial, replaced, unverified or unsure (read the type again); 1: refused or
2499
+ failed (nothing was changed).
2500
+
1852
2501
  export never changes the team folder and never writes a pack into it. It prints the pack's SHA-256: keep it somewhere
1853
2502
  separate. Exit code 0: a complete pack; 4: a partial pack (made, with what changed or is missing listed); 1: no pack.
1854
2503
 
@@ -1866,6 +2515,7 @@ function main(argv) {
1866
2515
  if (first === 'plan' && !argv.includes('--help')) return requestMain(argv);
1867
2516
  if (first === 'skill' && !argv.includes('--help')) return skillMain(argv);
1868
2517
  if (first === 'report' && !argv.includes('--help')) return reportMain(argv);
2518
+ if (first === 'type' && !argv.includes('--help')) return typeMain(argv);
1869
2519
  const ti = argv.indexOf('--to'), to = ti >= 0 ? argv[ti + 1] : null;
1870
2520
  if (ti >= 0 && (!to || to.startsWith('--'))) fail('say which folder after --to, for example: flightdeck skills --to ~/skills');
1871
2521
  /* Flags that take a value: the value is never read as a command or a folder. */