@troylar/flightdeck 2.12.2 → 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
@@ -500,6 +500,9 @@ function loadEngine() {
500
500
  read, and named in skipped. */
501
501
  function readAdapter(root, opts) {
502
502
  const NOFOLLOW = fs.constants.O_NOFOLLOW || 0, links = [], skipped = [], max = (opts && opts.maxBytes) || 0;
503
+ /* vanished: a file or folder that was there when its path was walked and gone when it was opened (a sync or another program
504
+ moved it in between). The read gives nothing, as for a file that isn't there, and the path is named. */
505
+ const vanished = [], gone = (rel) => { if (vanished.indexOf(rel) < 0) vanished.push(rel); };
503
506
  function linkErr(rel) { const e = new Refused('refusing to read through a link: ' + rel); e.link = true; if (links.indexOf(rel) < 0) links.push(rel); return e; }
504
507
  function skipErr(rel, bytes, cap, more) {
505
508
  const e = new Refused(rel + ' is ' + (more ? 'more than ' : '') + size(bytes) + ', over the ' + size(cap) + ' this command reads');
@@ -562,7 +565,7 @@ function readAdapter(root, opts) {
562
565
  const f = fileAt(p);
563
566
  if (!f) return null;
564
567
  let fd;
565
- try { fd = fs.openSync(f.full, fs.constants.O_RDONLY | NOFOLLOW); } catch (e) { if (e.code === 'ELOOP') throw linkErr(p); if (e.code === 'ENOENT') return null; throw e; }
568
+ try { fd = fs.openSync(f.full, fs.constants.O_RDONLY | NOFOLLOW); } catch (e) { if (e.code === 'ELOOP') throw linkErr(p); if (e.code === 'ENOENT') { gone(p); return null; } throw e; }
566
569
  try {
567
570
  const st = fs.fstatSync(fd), now = lstatOrNull(path.join(sameWalk(f.a, f.ids, p), f.name));
568
571
  if (!st.isFile() || st.ino !== f.st.ino || st.dev !== f.st.dev || !now || now.isSymbolicLink() || now.ino !== st.ino || now.dev !== st.dev) throw linkErr(p);
@@ -570,13 +573,13 @@ function readAdapter(root, opts) {
570
573
  } catch (e) { fs.closeSync(fd); throw e; }
571
574
  }
572
575
  return {
573
- links, skipped,
576
+ links, skipped, vanished,
574
577
  list: (p) => new Promise((res) => {
575
578
  const a = partsOf(p), dir = dirAt(a);
576
579
  /* No such folder: empty, and marked missing, so a reader that saw it listed can tell gone from empty. */
577
580
  if (!dir) { const none = []; none.missing = true; return res(none); }
578
581
  let entries;
579
- try { entries = fs.readdirSync(dir.cur, { withFileTypes: true }); } catch (e) { if (e.code !== 'ENOENT') throw e; const none = []; none.missing = true; return res(none); }
582
+ try { entries = fs.readdirSync(dir.cur, { withFileTypes: true }); } catch (e) { if (e.code !== 'ENOENT') throw e; gone(p || '.'); const none = []; none.missing = true; return res(none); }
580
583
  sameWalk(a, dir.ids, p || '.');
581
584
  res(entries.map((e) => {
582
585
  const rel = (p ? p + '/' : '') + e.name;
@@ -807,7 +810,8 @@ function folderProblems(PF, d) {
807
810
  }
808
811
  function adapterProblems(adapter) {
809
812
  return (adapter.links || []).map((p) => ({ path: p, kind: 'link', why: 'A link. Flightdeck never reads through links.' }))
810
- .concat((adapter.skipped || []).map((x) => ({ path: x.path, kind: 'skipped', why: 'Too large to read here (' + (x.more ? 'more than ' : '') + size(x.size) + '; the limit is ' + size(x.cap) + ').' })));
813
+ .concat((adapter.skipped || []).map((x) => ({ path: x.path, kind: 'skipped', why: 'Too large to read here (' + (x.more ? 'more than ' : '') + size(x.size) + '; the limit is ' + size(x.cap) + ').' })))
814
+ .concat((adapter.vanished || []).map((p) => ({ path: p, kind: 'vanished', why: 'It was there a moment ago and was gone when it was read. Try again.' })));
811
815
  }
812
816
 
813
817
  /* What a whole-folder command's output depends on, as folders and files: its data, the publications that say whether a
@@ -1514,6 +1518,8 @@ async function draftState(eng, dir, proc, session) {
1514
1518
  try {
1515
1519
  const f = await rfs.read(dpath, { maxBytes: PF.util.DRAFT_BYTES });
1516
1520
  if (f) Object.assign(draft, { exists: true, rev: sha(f.text), text: f.text });
1521
+ /* Gone between seeing it and opening it: there, but unread (named), so nothing takes it as absent. */
1522
+ else if ((ra.vanished || []).indexOf(dpath) >= 0) { draft.exists = true; L.note(dpath, 'vanished', 'It was there a moment ago and was gone when it was read. Try again.'); }
1517
1523
  } catch (e) { draft.exists = true; }
1518
1524
  const copies = [];
1519
1525
  try {
@@ -1566,17 +1572,116 @@ function writeNew(dir, parts, text) {
1566
1572
  if (!fs.readFileSync(target).equals(data)) fail(target + ' didn\'t read back as written');
1567
1573
  return parts.join('/');
1568
1574
  }
1575
+ /* Everything Review & publish checks a draft's text against, read now through one problem ledger over a read-only view of
1576
+ the folder, with the app's own readers: people.json, the team's step types, jira/workflows.json, every process's published
1577
+ versions (which are published, the children linked steps start, the parents that start this one), the team values a
1578
+ linked step names and the screenshots the text shows. Then PF.publish.checkContext and publishCheck, as the app's checks.
1579
+ What couldn't be read (or a newer Flightdeck wrote) is a problem, which makes the answer partial, and that part is left
1580
+ unchecked: it never becomes a blocker, a missing person or an unpublished process. Returns { verdict, problems }. */
1581
+ async function draftInputs(eng, dir, text) {
1582
+ const PF = eng.PF, ra = readAdapter(dir, { maxBytes: READ_MAX }), L = PF.repo.ledger(PF.repo.readOnly(ra, 'Checking a draft'));
1583
+ /* The ledger's problems and the adapter's (links, files too big, files that vanished), each once. */
1584
+ const everything = () => { const out = L.problems(), seen = {}; out.forEach((x) => { seen[x.path + '\u0000' + x.kind] = true; }); adapterProblems(ra).forEach((x) => { if (!seen[x.path + '\u0000' + x.kind]) out.push(x); }); return out; };
1585
+ /* A failure that stops the read at any stage (the team folder can't be listed) still names every problem met before it. */
1586
+ try { return await readInputs(eng, text, ra, L, everything); } catch (e) { if (e && typeof e === 'object') e.problems = everything(); throw e; }
1587
+ }
1588
+ async function readInputs(eng, text, ra, L, everything) {
1589
+ const PF = eng.PF, repo = PF.repo, M = PF.map, rfs = L.fs;
1590
+ const map = M.parse(text), failedAt = (p) => L.problems().some((x) => x.path === p || x.path.indexOf(p + '/') === 0);
1591
+ /* The app's own loader, limited to these parts: what each part's reader gives, or the part named in failed. */
1592
+ const deps = [PF.people.FILE, 'step-types', 'jira/workflows.json', 'publications'];
1593
+ const d = await repo.loadAll(dependsOn(rfs, deps), { readOnly: true, explain: true, deep: true });
1594
+ const failed = d.failed || {};
1595
+ /* First every read, with what each loader refused noted in the ledger; then the adapter's own problems (links, files too
1596
+ big); then PF.publish.unknownOf decides what's unknown from that whole list, and nothing else does. */
1597
+ const PARTS = { people: PF.people.FILE, 'step types': 'step-types', 'Jira workflows': 'jira/workflows.json', publications: 'publications' };
1598
+ Object.keys(PARTS).forEach((k) => { if (k in failed && !failedAt(PARTS[k])) L.note(PARTS[k], /newer/.test(failed[k]) ? 'newer' : 'unreadable', failed[k]); });
1599
+ const people = 'people' in failed ? null : d.people;
1600
+ if (people && people.text != null && people.doc == null) L.note(PF.people.FILE, 'malformed', PF.people.FILE + ' isn\'t valid JSON.');
1601
+ const types = 'step types' in failed ? {} : d.stepTypes.types;
1602
+ if (!('step types' in failed)) {
1603
+ (d.stepTypes.refused || []).forEach((r) => L.note(r.path, 'newer', r.why));
1604
+ }
1605
+ /* What a loader left out over its limit, from its own read (PF.publish.excludedOf): each named, and unknown below. */
1606
+ PF.publish.excludedOf({ stepTypes: 'step types' in failed ? null : d.stepTypes, jira: 'Jira workflows' in failed ? null : d.jira }).forEach((x) => L.note(x.path, x.kind, x.why));
1607
+ const processes = 'publications' in failed ? [] : d.processes.map((p) => {
1608
+ /* A version that read but can't be used is named as READ names one; a failed read the ledger already has. */
1609
+ p.pubs.filter((x) => !x.ok && !x._readFailed).forEach((x) => { const k = PF.appjson.pubProblem(x); L.note('publications/' + p.process + '/' + x.folder + '/', k.kind, k.why); });
1610
+ return { id: p.process, latest: p.index.latest, all: p.index.all, clashes: p.index.clashes, broken: p.pubs.filter((x) => !x.ok) };
1611
+ });
1612
+ /* Team values (2.9), read only for a text that passes one, as publishing reads them. */
1613
+ let teamValues;
1614
+ if (M.steps(map).some((x) => x.step.type === 'run.process' && PF.links.teams(x.step).length)) {
1615
+ teamValues = await repo.readTeamValues(rfs);
1616
+ if (teamValues.state !== 'ok' && teamValues.state !== 'missing' && !failedAt(PF.teamValues.FILE)) L.note(PF.teamValues.FILE, teamValues.state, teamValues.why || PF.teamValues.FILE + ' couldn\'t be read.');
1617
+ }
1618
+ /* Screenshots, read to the last byte as publishing reads them: one that isn't there is a blocker. */
1619
+ const shots = M.shotSources(map), missingShots = [];
1620
+ for (const src of shots) {
1621
+ try { const b = await rfs.blob(src); if (!b) missingShots.push(src); else await b.arrayBuffer(); } catch (e) { if (!failedAt(src)) L.note(src, 'unreadable', src + ' couldn\'t be read: ' + (e && e.message || String(e))); }
1622
+ }
1623
+ /* Only what the text uses counts: a problem with an input it doesn't use (a team-values.json link and no team: value)
1624
+ leaves the answer complete. */
1625
+ const used = PF.publish.usesOf(map), problems = everything().filter((x) => PF.publish.uses(used, x, shots));
1626
+ const ctx = PF.publish.checkContext({ people, types, workflows: d.jira ? d.jira.workflows : null, processes, teamValues, missingShots, unknown: PF.publish.unknownOf(problems, shots) });
1627
+ return { verdict: PF.publish.publishCheck(text, ctx), problems };
1628
+ }
1629
+ /* flightdeck draft check <team folder> <process> [--from <file | ->] --json (2.13): the verdict Review & publish gives the
1630
+ draft in maps/drafts/ (or the text given with --from), from the folder as it is now (draftInputs). Read-only; prints
1631
+ flightdeck-read/2 with SCHEMA2.draftCheck: exit 0, 4 when part of the folder couldn't be read, 5 when there's no draft,
1632
+ 1 for a usage problem or a draft that can't be read. */
1633
+ async function draftCheck(argv) {
1634
+ const VAL = ['--from'], args = argv.filter((a, i) => !a.startsWith('--') && !(i > 0 && VAL.indexOf(argv[i - 1]) >= 0));
1635
+ let eng = null;
1636
+ const C2 = 2, appOf = () => (eng ? eng.version : versionOf(app()) || '');
1637
+ const failOut = (kind, why, problems, code) => { const F = eng ? eng.PF.appjson : null, json = F ? F.failure(appOf(), kind, why, problems || [], C2) : { contract: 'flightdeck-read/2', app: appOf(), error: { kind, why }, problems: [] }; return { out: JSON.stringify(json, null, 2), code: code || EXIT.error }; };
1638
+ const usage = (why) => failOut('usage', why);
1639
+ const fi = argv.indexOf('--from'), from = fi >= 0 ? argv[fi + 1] : null;
1640
+ if (fi >= 0 && (from == null || (from.startsWith('--') && from !== '-'))) return usage('say what goes after --from.');
1641
+ const unknown = argv.filter((a) => a.startsWith('--') && ['--json', '--from'].indexOf(a) < 0);
1642
+ if (unknown.length) return usage('draft check doesn\'t take ' + unknown.join(', ') + '.');
1643
+ if (!argv.includes('--json')) return usage('flightdeck draft prints JSON. Add --json.');
1644
+ const proc = args[3];
1645
+ if (!args[2] || !proc || args[4] || !PROC_ID.test(proc)) return usage('say the team folder and a process ID (lowercase letters, numbers and dashes), for example: flightdeck draft check <team folder> release --json');
1646
+ let dir;
1647
+ try { eng = loadEngine(); dir = folderArg(args[2], 'draft check'); } catch (e) { return failOut('failed', e.message); }
1648
+ const PF = eng.PF, A = PF.appjson;
1649
+ let text, said;
1650
+ if (from != null) {
1651
+ const input = inputText(PF, from, 'the text');
1652
+ if (input.why) return usage(input.why);
1653
+ if (PF.proposals.textProcess(input.text) !== proc) return usage('the text\'s process: line must say ' + proc + '.');
1654
+ text = input.text; said = { kind: 'file', ref: null };
1655
+ } else {
1656
+ const ra = readAdapter(dir, { maxBytes: READ_MAX }), L = PF.repo.ledger(PF.repo.readOnly(ra, 'Checking a draft')), dpath = 'maps/drafts/' + proc + '.md';
1657
+ let f = null;
1658
+ try { f = await L.fs.read(dpath, { maxBytes: PF.util.DRAFT_BYTES }); } catch (e) { return failOut('failed', dpath + ' couldn\'t be read, so nothing was checked. Try again.', L.problems().concat(adapterProblems(ra))); }
1659
+ /* A draft that was there a moment ago and gone when opened is unknown, never "no draft". */
1660
+ if (!f && (ra.vanished || []).indexOf(dpath) >= 0) return failOut('failed', dpath + ' was gone when it was read, so nothing was checked. Try again.', L.problems().concat(adapterProblems(ra)));
1661
+ if (!f) return failOut('not-found', 'There\'s no draft for "' + proc + '" in this folder (' + dpath + '). Pass the text to check with --from.', [], EXIT.notFound);
1662
+ text = f.text; said = { kind: 'draft', ref: dpath };
1663
+ }
1664
+ try {
1665
+ const got = await draftInputs(eng, dir, text);
1666
+ const data = { process: proc, from: { kind: said.kind, ref: said.ref, rev: PF.proposals.sha(text) }, checks: got.verdict.checks, publish: got.verdict.publish };
1667
+ const e = A.envelope(eng.version, new Date().toISOString(), got.problems, data, C2);
1668
+ return { out: JSON.stringify(e, null, 2), code: e.complete ? 0 : EXIT.partial };
1669
+ } catch (e) {
1670
+ return failOut('failed', e instanceof Refused ? e.message : 'The team folder couldn\'t be read: ' + (e && e.message || String(e)), e && e.problems);
1671
+ }
1672
+ }
1569
1673
  async function draftMain(argv) {
1570
1674
  const VAL = ['--session', '--expect', '--from', '--base', '--label', '--by', '--note'], val = {};
1571
1675
  const args = argv.filter((a, i) => !a.startsWith('--') && !(i > 0 && VAL.indexOf(argv[i - 1]) >= 0));
1572
1676
  const sub = args[1], usage = (why) => draftFail(null, 'usage', why);
1677
+ if (sub === 'check') return draftCheck(argv);
1573
1678
  for (const f of VAL) {
1574
1679
  const i = argv.indexOf(f);
1575
1680
  if (i < 0) continue;
1576
1681
  if (argv[i + 1] == null || (argv[i + 1].startsWith('--') && argv[i + 1] !== '-')) return usage('say what goes after ' + f + '.');
1577
1682
  val[f] = argv[i + 1];
1578
1683
  }
1579
- if (['read', 'save', 'propose'].indexOf(sub) < 0) return usage('say read, save or propose, for example: flightdeck draft read <team folder> <process> --json');
1684
+ if (['read', 'save', 'propose'].indexOf(sub) < 0) return usage('say read, save, propose or check, for example: flightdeck draft read <team folder> <process> --json');
1580
1685
  if (!argv.includes('--json')) return usage('flightdeck draft prints JSON. Add --json.');
1581
1686
  const proc = args[3];
1582
1687
  if (!proc || !PROC_ID.test(proc)) return usage('a process ID is lowercase letters, numbers and dashes, for example: flightdeck draft ' + sub + ' <team folder> release --json');
@@ -1585,30 +1690,8 @@ async function draftMain(argv) {
1585
1690
  try { eng = loadEngine(); dir = folderArg(args[2], 'draft ' + sub); } catch (e) { return draftFail(eng, 'failed', e.message); }
1586
1691
  const PF = eng.PF, PP = PF.proposals, U = PF.util, M = PF.map, now = Date.now();
1587
1692
  const session = val['--session'] || 's-' + U.randomId(12);
1588
- /* The same checks the app shows, with each linked step's child read from publications/ (2.8), so what a step passes is
1589
- checked against the child as it is in the folder. */
1590
- const checksOf = async (t) => {
1591
- const map = M.parse(t), procs = {}, names = [];
1592
- M.steps(map).forEach((x) => { const f = x.step.fields || {}; if (x.step.type === 'run.process' && f.process && names.indexOf(f.process) < 0) names.push(f.process); });
1593
- /* The children are read through a problem ledger of their own, so a child that couldn't be read makes the answer partial. */
1594
- const L = PF.repo.ledger(readAdapter(dir, { maxBytes: READ_MAX })), rfs = L.fs;
1595
- for (const n of names) {
1596
- try {
1597
- const p = await PF.repo.readProcess(rfs, n);
1598
- if (p) procs[n] = p;
1599
- /* A version that read but can't be used is named as READ names one; a failed read the ledger already has. */
1600
- (p ? p.broken : []).forEach((x) => { if (!x._readFailed) { const k = PF.appjson.pubProblem(x); L.note('publications/' + n + '/' + x.folder + '/', k.kind, k.why); } });
1601
- } catch (e) { procs[n] = { latest: null, all: [], clashes: [], broken: [], unlisted: 'publications/' + n + '/ couldn\'t be read' }; }
1602
- }
1603
- /* Team values (2.9), through the same ledger: a failed read is noted by the ledger; damaged or newer content here. */
1604
- let tv;
1605
- if (M.steps(map).some((x) => x.step.type === 'run.process' && PF.links.teams(x.step).length)) {
1606
- tv = await PF.repo.readTeamValues(rfs);
1607
- if (tv.state === 'malformed' || tv.state === 'newer') L.note(PF.teamValues.FILE, tv.state, tv.why);
1608
- }
1609
- const c = M.check(map, { people: [], types: {}, workflows: {}, processes: [], children: PF.links.childrenFrom(procs), teamValues: tv });
1610
- return { checks: { blockers: c.blockers, suggestions: c.suggestions }, problems: L.problems() };
1611
- };
1693
+ /* The same checks the app shows, on the folder as it is now (draftInputs), through the same functions. */
1694
+ const checksOf = async (t) => { const got = await draftInputs(eng, dir, t); return { checks: got.verdict.checks, publish: got.verdict.publish, problems: got.problems }; };
1612
1695
  /* Every problem once: the draft's reads, then the children's. */
1613
1696
  const merged = (a, b) => { const seen = {}, out = []; a.concat(b).forEach((p) => { const k = p.path + '\u0000' + p.kind; if (!seen[k]) { seen[k] = true; out.push(p); } }); return out; };
1614
1697
  try {
@@ -1620,7 +1703,9 @@ async function draftMain(argv) {
1620
1703
  const cf = pending ? { rev: pending.rev, kind: 'proposal', ref: pending.id, text: pending.text }
1621
1704
  : s.draft.exists && s.draft.text != null ? { rev: s.draft.rev, kind: 'draft', ref: s.draft.path, text: s.draft.text }
1622
1705
  : latest ? { rev: latest.rev, kind: 'version', ref: latest.folder, text: latest.text } : { rev: PP.sha(''), kind: 'none', ref: null, text: '' };
1623
- const ck = await checksOf(cf.text), problems = merged(s.problems, ck.problems);
1706
+ /* The draft is there but couldn't be read, and nothing else continues it: its text is unknown, so nothing is checked. */
1707
+ const unread = !pending && s.draft.exists && s.draft.text == null;
1708
+ const ck = unread ? { checks: { blockers: [], suggestions: [] }, problems: [] } : await checksOf(cf.text), problems = merged(s.problems, ck.problems);
1624
1709
  const out = draftJson(eng, { process: proc, session, draft: s.draft, openIn: s.presence.openIn, mine: s.mine.map((m) => ({ id: m.id, rev: m.rev, status: m.status, text: m.text })),
1625
1710
  continueFrom: cf, checks: ck.checks, copies: s.copies, readAt: new Date(now).toISOString(), complete: problems.length === 0, problems });
1626
1711
  return { out: JSON.stringify(out, null, 2), code: out.complete ? 0 : EXIT.partial };
@@ -1641,7 +1726,9 @@ async function draftMain(argv) {
1641
1726
  const base = PP.baseFor({ expect: val['--expect'], draft: s.draft, mine: s.mine, versions: s.versions, baseText, afterEarlier: argv.includes('--after-earlier') });
1642
1727
  if (base.missing) return draftFail(eng, 'missing-base', 'The text you started from isn\'t the draft, one of this session\'s proposals or a published version any more. Pass the text you read with --base, or read the draft again.');
1643
1728
  const operation = PP.newId(now, U.randomId(10)), at = new Date(now).toISOString();
1644
- const result = { process: proc, session, operation, outcome: null, why: null, link: base.link, rev: PP.sha(text), file: null, ids: PP.ids(base.text, text), checks: (await checksOf(text)).checks, warnings: [], history: null };
1729
+ const result = { process: proc, session, operation, outcome: null, why: null, link: base.link, rev: PP.sha(text), file: null, ids: PP.ids(base.text, text), checks: null, warnings: [], history: null, complete: true, problems: [] };
1730
+ const ck = await checksOf(text), problems = merged(s.problems, ck.problems);
1731
+ Object.assign(result, { checks: ck.checks, complete: problems.length === 0, problems });
1645
1732
  if (result.ids.removed.length) result.warnings.push('This removes step or phase IDs: ' + result.ids.removed.join(', ') + '. Runs and skills that name them will need a look.');
1646
1733
  let why = sub === 'propose' ? 'asked' : s.presence.open ? 'open' : null;
1647
1734
  const draftNow = s.draft.exists ? s.draft.text : null, expectText = base.kind === 'none' || base.kind === 'version' ? null : base.text;
@@ -1684,10 +1771,646 @@ async function draftMain(argv) {
1684
1771
  Object.assign(result, { outcome: 'proposed', why });
1685
1772
  return { out: JSON.stringify(draftJson(eng, result), null, 2), code: 0 };
1686
1773
  } catch (e) {
1687
- return draftFail(eng, 'failed', e instanceof Refused ? e.message : 'The team folder couldn\'t be changed: ' + (e && e.message || String(e)));
1774
+ return draftFail(eng, 'failed', e instanceof Refused ? e.message : 'The team folder couldn\'t be changed: ' + (e && e.message || String(e)), EXIT.error, e && e.problems);
1688
1775
  }
1689
1776
  }
1690
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
+
1691
2414
  const HELP = `flightdeck: team process checklists, in one HTML file
1692
2415
 
1693
2416
  flightdeck open open the app in this package: the first time, the welcome (start your team, join
@@ -1735,9 +2458,19 @@ const HELP = `flightdeck: team process checklists, in one HTML file
1735
2458
  read; otherwise write a proposal a person applies in Flightdeck
1736
2459
  flightdeck draft propose <same as save>
1737
2460
  always write a proposal
2461
+ flightdeck draft check <team folder> <process> [--from <file|->] --json
2462
+ the verdict Review & publish gives the draft (or the text in the file): what
2463
+ stops publishing and the tips, from the folder as it is now. Only reads
1738
2464
  flightdeck skill register <team folder> --from <file|-> [--json]
1739
2465
  for AI assistants: register a skill against the exact version it was built
1740
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
1741
2474
  flightdeck path print where the app file is
1742
2475
  flightdeck version print this version
1743
2476
 
@@ -1759,6 +2492,12 @@ or version.
1759
2492
  flightdeck draft changes the team folder (contract flightdeck-draft/1). Exit code 0: written or proposed (read: complete);
1760
2493
  4: read, partial; 1: usage, missing-base, too-big or failed; 5: read, no such process.
1761
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
+
1762
2501
  export never changes the team folder and never writes a pack into it. It prints the pack's SHA-256: keep it somewhere
1763
2502
  separate. Exit code 0: a complete pack; 4: a partial pack (made, with what changed or is missing listed); 1: no pack.
1764
2503
 
@@ -1776,6 +2515,7 @@ function main(argv) {
1776
2515
  if (first === 'plan' && !argv.includes('--help')) return requestMain(argv);
1777
2516
  if (first === 'skill' && !argv.includes('--help')) return skillMain(argv);
1778
2517
  if (first === 'report' && !argv.includes('--help')) return reportMain(argv);
2518
+ if (first === 'type' && !argv.includes('--help')) return typeMain(argv);
1779
2519
  const ti = argv.indexOf('--to'), to = ti >= 0 ? argv[ti + 1] : null;
1780
2520
  if (ti >= 0 && (!to || to.startsWith('--'))) fail('say which folder after --to, for example: flightdeck skills --to ~/skills');
1781
2521
  /* Flags that take a value: the value is never read as a command or a folder. */