@troylar/flightdeck 1.16.1 → 1.18.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
@@ -1,18 +1,23 @@
1
1
  #!/usr/bin/env node
2
2
  /* The flightdeck command: puts the one-file app where a team uses it. No dependencies, nothing runs on install, and it
3
3
  never touches the network. Every command works on plain files: copy the app and the AI assistant contract (AGENTS.md
4
- and the skills) into a team folder, add or update the skills anywhere else, back up a team folder, or open the app. */
4
+ and the skills) into a team folder, add or update the skills anywhere else, back up a team folder, export a run's
5
+ evidence, or open the app. */
5
6
  'use strict';
6
7
  const fs = require('fs');
7
8
  const path = require('path');
8
9
  const crypto = require('crypto');
9
10
  const { spawn } = require('child_process');
11
+ const vm = require('vm');
10
12
 
11
13
  const APP = path.join(__dirname, '..', 'dist', 'flightdeck.html');
12
14
  const NAME = 'flightdeck.html', OLD = 'preflight.html', SKILLS = '.flightdeck/skills', AGENTS = 'AGENTS.md';
13
15
  const BEGIN = '<!-- flightdeck:begin -->', END = '<!-- flightdeck:end -->';
14
- /* --check exit codes, for scripts: nothing to do, updates to install, files that need a person (or an assistant) to merge. */
15
- const EXIT = { none: 0, error: 1, updates: 2, merge: 3 };
16
+ /* --check exit codes, for scripts: nothing to do, updates to install, files that need a person (or an assistant) to merge.
17
+ export: 0 a complete pack, 4 a partial pack (made, with what's unresolved listed), 1 no pack (any partial file removed).
18
+ Reading for other apps (processes, process, runs, run, next, followups): 0 complete, 4 partial (data printed, every
19
+ problem listed), 1 no data, 5 no such process, version or run. */
20
+ const EXIT = { none: 0, error: 1, updates: 2, merge: 3, partial: 4, notFound: 5 };
16
21
 
17
22
  class Refused extends Error {}
18
23
  /* A file that changed between the moment install read it and the moment it would replace it. */
@@ -320,13 +325,11 @@ function exitFor(statuses) {
320
325
  return statuses.some((s) => ['update', 'new', 'missing', 'forced'].includes(s)) ? EXIT.updates : EXIT.none;
321
326
  }
322
327
 
323
- /* Copy a team folder to <dest>/<name> backup <date time>/. Links are skipped, never followed, and so are temporary files. */
324
- function backup(src, toArg) {
325
- const parent = expand(toArg || path.join(require('os').homedir(), 'Flightdeck backups'));
328
+ /* Whether p is the folder src or inside it, judged by what each folder above p is (device and inode), never by spelling:
329
+ on a file system that ignores letter case, "Team" and "TEAM" are the same folder. Missing parts are skipped. */
330
+ function withinOf(src) {
326
331
  const me = fs.statSync(src);
327
- /* Whether p is the folder or inside it, judged by what each folder above p is (device and inode), never by spelling:
328
- on a file system that ignores letter case, "Team" and "TEAM" are the same folder. Missing parts are skipped. */
329
- const within = (p) => {
332
+ return (p) => {
330
333
  for (let cur = p; ; cur = path.dirname(cur)) {
331
334
  let st = null;
332
335
  try { st = fs.statSync(cur); } catch (e) { /* not there yet */ }
@@ -334,6 +337,12 @@ function backup(src, toArg) {
334
337
  if (path.dirname(cur) === cur) return false;
335
338
  }
336
339
  };
340
+ }
341
+
342
+ /* Copy a team folder to <dest>/<name> backup <date time>/. Links are skipped, never followed, and so are temporary files. */
343
+ function backup(src, toArg) {
344
+ const parent = expand(toArg || path.join(require('os').homedir(), 'Flightdeck backups'));
345
+ const within = withinOf(src);
337
346
  const inside = 'the backup can\'t go inside the folder it backs up. Choose another place with --to.';
338
347
  if (within(parent)) fail(inside);
339
348
  try { fs.mkdirSync(parent, { recursive: true }); } catch (e) { fail('couldn\'t create "' + parent + '": ' + e.message); }
@@ -371,6 +380,477 @@ function backup(src, toArg) {
371
380
  }
372
381
  function size(n) { return n < 1024 ? n + ' bytes' : n < 1048576 ? (n / 1024).toFixed(1) + ' KB' : (n / 1048576).toFixed(1) + ' MB'; }
373
382
 
383
+ /* ---------- The engine: export and reading for other apps run the app's own rules ---------- */
384
+ /* The app's own rules, from this installed package's dist/flightdeck.html and nowhere else (never a team folder's copy):
385
+ its core and storage blocks, run in node:vm without the screens. */
386
+ function loadEngine() {
387
+ const html = app(), block = (id) => {
388
+ const m = new RegExp('<script id="' + id + '">([\\s\\S]*?)</script>').exec(html);
389
+ if (!m) fail('the app file in this install has no ' + id + ' block (' + APP + '). Reinstall the package.');
390
+ return m[1];
391
+ };
392
+ const ctx = vm.createContext({ console, setTimeout, clearTimeout, Blob, TextEncoder, TextDecoder, performance: { now: () => performance.now() } });
393
+ vm.runInContext(block('pf-core'), ctx, { filename: 'flightdeck.html#pf-core' });
394
+ vm.runInContext(block('pf-storage'), ctx, { filename: 'flightdeck.html#pf-storage' });
395
+ return { PF: ctx.PF, version: versionOf(html) };
396
+ }
397
+ /* The team folder for the collector: list, read, stat and blob, read-only, and never through a link. Every folder on the
398
+ way to a path is checked with lstat (a linked folder is refused), a link in a listing is reported as a link and never
399
+ opened, and a file is opened without following a link and must be the very file lstat saw (device and inode), so one
400
+ swapped for a link after it was listed is refused too. links collects what was skipped. opts.maxBytes (or a read's own
401
+ { maxBytes }) caps what read() takes in: a bigger file is sized from its open handle and refused before any byte is
402
+ read, and named in skipped. */
403
+ function readAdapter(root, opts) {
404
+ const NOFOLLOW = fs.constants.O_NOFOLLOW || 0, links = [], skipped = [], max = (opts && opts.maxBytes) || 0;
405
+ 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; }
406
+ function skipErr(rel, bytes, cap, more) {
407
+ const e = new Refused(rel + ' is ' + (more ? 'more than ' : '') + size(bytes) + ', over the ' + size(cap) + ' this command reads');
408
+ e.skipped = true; e.size = bytes;
409
+ if (!skipped.some((x) => x.path === rel)) skipped.push({ path: rel, size: bytes, cap, more: !!more });
410
+ return e;
411
+ }
412
+ /* The whole file from an open descriptor, counting the bytes as they come: the size the handle gave is only a first
413
+ check, so a file that grows while it's read is refused (skipped) the moment it passes the cap. */
414
+ function readAll(fd, rel, cap) {
415
+ const chunks = [], buf = Buffer.alloc(65536);
416
+ let total = 0;
417
+ for (;;) {
418
+ const n = fs.readSync(fd, buf, 0, buf.length, total);
419
+ if (!n) break;
420
+ total += n;
421
+ if (cap && total > cap) throw skipErr(rel, cap, cap, true);
422
+ chunks.push(Buffer.from(buf.subarray(0, n)));
423
+ }
424
+ return Buffer.concat(chunks, total);
425
+ }
426
+ function partsOf(p) {
427
+ const a = p ? String(p).split('/') : [];
428
+ a.forEach((x) => { if (!x || x === '.' || x === '..' || /[\\\u0000]/.test(x)) fail('refusing an unexpected path: ' + p); });
429
+ return a;
430
+ }
431
+ /* The real folder at these parts, or null when it isn't there, with each folder's identity (device and inode) on the way. */
432
+ function dirAt(a) {
433
+ let cur = root;
434
+ /* The team folder itself first: replaced by a link, nothing is read through it. */
435
+ const top = lstatOrNull(root);
436
+ if (!top || top.isSymbolicLink() || !top.isDirectory()) throw linkErr('the team folder');
437
+ const ids = [top.dev + ':' + top.ino];
438
+ for (let i = 0; i < a.length; i++) {
439
+ cur = path.join(cur, a[i]);
440
+ const st = lstatOrNull(cur);
441
+ if (!st) return null;
442
+ if (st.isSymbolicLink()) throw linkErr(a.slice(0, i + 1).join('/'));
443
+ if (!st.isDirectory()) return null;
444
+ ids.push(st.dev + ':' + st.ino);
445
+ }
446
+ return { cur, ids };
447
+ }
448
+ /* Node has no openat, so a folder swapped for a link (and maybe put back) is caught by walking again after the open:
449
+ every folder must still be the same real folder, and the file at the path the one that was opened. */
450
+ function sameWalk(a, ids, rel) {
451
+ const again = dirAt(a);
452
+ if (!again || again.ids.join('/') !== ids.join('/')) throw linkErr(rel);
453
+ return again.cur;
454
+ }
455
+ function fileAt(p) {
456
+ const a = partsOf(p), name = a.pop(), dir = dirAt(a);
457
+ if (!dir) return null;
458
+ const full = path.join(dir.cur, name), st = lstatOrNull(full);
459
+ if (!st) return null;
460
+ if (st.isSymbolicLink()) throw linkErr(p);
461
+ return st.isFile() ? { full, st, a, ids: dir.ids, name } : null;
462
+ }
463
+ function open(p) {
464
+ const f = fileAt(p);
465
+ if (!f) return null;
466
+ let fd;
467
+ 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; }
468
+ try {
469
+ const st = fs.fstatSync(fd), now = lstatOrNull(path.join(sameWalk(f.a, f.ids, p), f.name));
470
+ 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);
471
+ return { fd, st };
472
+ } catch (e) { fs.closeSync(fd); throw e; }
473
+ }
474
+ return {
475
+ links, skipped,
476
+ list: (p) => new Promise((res) => {
477
+ const a = partsOf(p), dir = dirAt(a);
478
+ /* No such folder: empty, and marked missing, so a reader that saw it listed can tell gone from empty. */
479
+ if (!dir) { const none = []; none.missing = true; return res(none); }
480
+ let entries;
481
+ try { entries = fs.readdirSync(dir.cur, { withFileTypes: true }); } catch (e) { if (e.code !== 'ENOENT') throw e; const none = []; none.missing = true; return res(none); }
482
+ sameWalk(a, dir.ids, p || '.');
483
+ res(entries.map((e) => {
484
+ const rel = (p ? p + '/' : '') + e.name;
485
+ if (e.isSymbolicLink()) { if (links.indexOf(rel) < 0) links.push(rel); return { name: e.name, kind: 'link' }; }
486
+ return e.isDirectory() ? { name: e.name, kind: 'dir' } : e.isFile() ? { name: e.name, kind: 'file' } : null;
487
+ }).filter(Boolean));
488
+ }),
489
+ stat: (p) => new Promise((res) => {
490
+ let f = null;
491
+ try { f = fileAt(p); } catch (e) { if (!e.link) throw e; }
492
+ res(f ? { size: f.st.size, mtime: f.st.mtimeMs } : null);
493
+ }),
494
+ read: (p, ro) => new Promise((res) => {
495
+ const o = open(p);
496
+ if (!o) return res(null);
497
+ const cap = (ro && ro.maxBytes) || max;
498
+ if (cap && o.st.size > cap) { fs.closeSync(o.fd); throw skipErr(p, o.st.size, cap); }
499
+ /* Decoded as a browser decodes a file (TextDecoder: UTF-8, a byte-order mark dropped), so every reader sees the same text. */
500
+ try { const data = readAll(o.fd, p, cap); res({ text: new TextDecoder('utf-8').decode(data), size: data.length, mtime: o.st.mtimeMs }); } finally { fs.closeSync(o.fd); }
501
+ }),
502
+ /* A file to read a piece at a time, from the descriptor opened above; close() when done. */
503
+ blob: (p, ro) => new Promise((res) => {
504
+ const o = open(p);
505
+ if (!o) return res(null);
506
+ const cap = (ro && ro.maxBytes) || max;
507
+ if (cap && o.st.size > cap) { fs.closeSync(o.fd); throw skipErr(p, o.st.size, cap); }
508
+ let closed = false;
509
+ const close = () => { if (!closed) { closed = true; fs.closeSync(o.fd); } };
510
+ res({
511
+ size: o.st.size,
512
+ /* The whole file (screenshots are checked this way), counted against the cap as it's read; then closed. */
513
+ arrayBuffer: () => new Promise((ok) => {
514
+ if (closed) throw new Error(p + ' was already closed');
515
+ try { const data = readAll(o.fd, p, cap); ok(data.buffer.slice(data.byteOffset, data.byteOffset + data.length)); } finally { close(); }
516
+ }),
517
+ slice: (a, b) => ({ arrayBuffer: () => new Promise((ok) => {
518
+ const buf = Buffer.alloc(Math.max(0, b - a));
519
+ let got = 0;
520
+ while (got < buf.length) { const n = fs.readSync(o.fd, buf, got, buf.length - got, a + got); if (!n) break; got += n; }
521
+ ok(buf.buffer.slice(buf.byteOffset, buf.byteOffset + got));
522
+ }) }),
523
+ close
524
+ });
525
+ })
526
+ };
527
+ }
528
+ /* Where packs go: the nearest folder of --out that exists, resolved, must be outside the team folder before anything is
529
+ created; then each missing folder is made one at a time, and each must be a real folder (not a link), still outside. */
530
+ function outFolder(want, within) {
531
+ const inTeam = 'the pack can\'t go inside the team folder. Choose another place with --out.';
532
+ const rest = [];
533
+ let cur = want;
534
+ while (!lstatOrNull(cur)) { rest.unshift(path.basename(cur)); const up = path.dirname(cur); if (up === cur) break; cur = up; }
535
+ let at;
536
+ try { at = fs.realpathSync(cur); } catch (e) { fail('"' + cur + '" can\'t be used for the pack: ' + e.message); }
537
+ if (!fs.statSync(at).isDirectory()) fail('"' + cur + '" isn\'t a folder.');
538
+ if (within(at)) fail(inTeam);
539
+ for (const part of rest) {
540
+ if (!part || part === '.' || part === '..') fail('"' + want + '" isn\'t a plain folder path.');
541
+ at = path.join(at, part);
542
+ try { fs.mkdirSync(at); } catch (e) { if (e.code !== 'EEXIST') fail('couldn\'t create "' + at + '": ' + e.message); }
543
+ const st = lstatOrNull(at);
544
+ if (!st || st.isSymbolicLink() || !st.isDirectory()) fail('refusing to write through a link: ' + at);
545
+ if (within(at)) fail(inTeam);
546
+ }
547
+ const real = fs.realpathSync(at);
548
+ if (within(real)) fail(inTeam);
549
+ return real;
550
+ }
551
+ /* The pack goes to <out>/<name>.partial, created exclusively and written as it's made, then gets its final name without
552
+ replacing anything (" (2)" and on when the name is taken). Canceled (Ctrl+C) or failed, the partial file is removed. */
553
+ async function exportPack(opt) {
554
+ const dir = folderArg(opt.folder, 'export');
555
+ if (!opt.run && !opt.process) fail('say which runs: --run <process>/<run id>, or --process <process> --from YYYY-MM-DD --through YYYY-MM-DD');
556
+ if (opt.process && (!opt.from || !opt.through)) fail('give the days with --from YYYY-MM-DD and --through YYYY-MM-DD (both included)');
557
+ const { PF, version } = loadEngine(), E = PF.evidence;
558
+ const tz = opt.tz || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
559
+ if (!E.zoneOk(tz)) fail('"' + tz + '" isn\'t a time zone this computer knows. Use a name like Europe/London or America/New_York.');
560
+ const out = outFolder(expand(opt.out || path.join(require('os').homedir(), 'Flightdeck evidence')), withinOf(dir));
561
+ const scope = opt.run ? { run: opt.run } : { process: opt.process, from: opt.from, through: opt.through, tz };
562
+ let fd = null, partial = null, canceled = false;
563
+ const onSig = () => { canceled = true; };
564
+ process.on('SIGINT', onSig); process.on('SIGTERM', onSig);
565
+ const cleanup = () => {
566
+ if (fd !== null) { try { fs.closeSync(fd); } catch (e) { /* already failing */ } fd = null; }
567
+ if (partial) { try { fs.unlinkSync(partial); } catch (e) { /* gone already */ } partial = null; }
568
+ };
569
+ try {
570
+ const res = await PF.repo.exportEvidence(readAdapter(dir), scope, {
571
+ tz, app: version, madeWith: 'command line', attachments: !opt.noAttachments, keepBytes: false, pool: false,
572
+ cancelled: () => canceled,
573
+ /* Marked for cleanup only once this process created it: a file already there is never touched. */
574
+ begin: (name) => { const p = path.join(out, name + '.partial'); fd = fs.openSync(p, 'wx'); partial = p; },
575
+ sink: (u8) => {
576
+ const b = Buffer.from(u8.buffer, u8.byteOffset, u8.length);
577
+ for (let off = 0; off < b.length;) { const n = fs.writeSync(fd, b, off, b.length - off); if (!(n > 0)) throw new Error('the disk accepted no more bytes'); off += n; }
578
+ }
579
+ });
580
+ fs.fsyncSync(fd); fs.closeSync(fd); fd = null;
581
+ let final = path.join(out, res.name);
582
+ for (let n = 2; ; n++) {
583
+ try { fs.linkSync(partial, final); break; } catch (e) {
584
+ if (e.code === 'EEXIST') { final = path.join(out, res.name.replace(/(\.\w+)$/, ' (' + n + ')$1')); continue; }
585
+ /* A disk without hard links: rename, after checking the name is free. */
586
+ if (fs.existsSync(final)) { final = path.join(out, res.name.replace(/(\.\w+)$/, ' (' + n + ')$1')); continue; }
587
+ fs.renameSync(partial, final); partial = null; break;
588
+ }
589
+ }
590
+ if (partial) { fs.unlinkSync(partial); partial = null; }
591
+ const col = res.col, lines = [];
592
+ lines.push((col.status === 'partial' ? 'Made a PARTIAL evidence pack' : 'Made an evidence pack') + ' of ' + col.runs.length + ' run' + (col.runs.length === 1 ? '' : 's') + ' (' + col.files.length + ' original files, ' + size(res.bytes) + '): ' + final);
593
+ lines.push('SHA-256 of this pack: ' + res.digest);
594
+ lines.push('Keep it somewhere separate. Anyone checking the pack later compares it with the one you kept; the pack can\'t prove by itself that it hasn\'t changed.');
595
+ if (col.status === 'partial') { lines.push('Partial, because:'); col.unresolved.forEach((u) => lines.push(' ' + u)); }
596
+ if (col.exclusions.length) lines.push('Left out: ' + col.exclusions.length + ' file' + (col.exclusions.length === 1 ? '' : 's') + ', each with its reason in manifest.json.');
597
+ lines.push(E.PRIVACY);
598
+ lines.push('Days and times are in ' + tz + '.');
599
+ return { out: lines.join('\n'), code: col.status === 'partial' ? EXIT.partial : 0 };
600
+ } catch (e) {
601
+ cleanup();
602
+ if (e && e.cancelled) fail('export canceled. No pack was made.');
603
+ throw e instanceof Refused ? e : new Refused(e && e.message || String(e));
604
+ } finally {
605
+ cleanup();
606
+ process.removeListener('SIGINT', onSig); process.removeListener('SIGTERM', onSig);
607
+ }
608
+ }
609
+
610
+ /* ---------- Reading for other apps: processes, process, runs, run, next, followups --json (docs/apps.md) ---------- */
611
+ const READS = ['processes', 'process', 'runs', 'run', 'next', 'followups'];
612
+ /* What one read takes in at most: a record past this is named (skipped), never read. */
613
+ const READ_MAX = 16 * 1048576;
614
+ /* Where each part of loadAll lives, for naming a part that couldn't be read. */
615
+ const PART_PATH = { people: 'people.json', drafts: 'maps/drafts/', publications: 'publications/', runs: 'runs/', legacy: 'runs/', skills: 'skills/', proposals: 'proposals/',
616
+ 'step types': 'step-types/', 'Jira workflows': 'jira/workflows.json', 'sign-offs': 'validations/', corrections: 'corrections/', 'follow-ups': 'followups/' };
617
+ const PROC_ID = /^[a-z0-9][a-z0-9-]{0,59}$/, RUN_KEY = /^([a-z0-9][a-z0-9-]{0,59})\/([a-z0-9-]{1,40})$/i;
618
+
619
+ function fileWhy(w) { return 'This file ' + String(w || 'couldn\'t be read') + '.'; }
620
+ /* A run this copy can only list by name: saved by a newer Flightdeck (its format, fields this copy doesn't know, or its own
621
+ frozen checklist and step types using features this copy can't read). A run on a version that's only unusable here
622
+ still reads; its version is named instead. */
623
+ function newerRun(PF, r) {
624
+ const why = r._unrunnable || '';
625
+ if (!why) return false;
626
+ if (r.snapshot === '' || why.indexOf(PF.run.NEWER) === 0) return true;
627
+ return why.indexOf(PF.publish.NEWER) === 0 && !!PF.publish.unrunnable(r.snapshot, r.types);
628
+ }
629
+ function runProblems(PF, r) {
630
+ const out = [], key = r.process + '/' + r.id, dir = 'runs/' + key + '/';
631
+ if (newerRun(PF, r)) out.push({ path: dir + 'run.json', kind: 'newer', why: '"' + r.title + '" (' + key + '): ' + r._unrunnable });
632
+ /* Times sanitize couldn't read, and every other record it dropped, cut or replaced: one problem for the file, naming each. */
633
+ const isNewer = newerRun(PF, r), bt = isNewer ? '' : badTimesWhy(r._badTimes), fixed = isNewer ? [] : (r._repairs || []).map((x) => x.at + ' (' + x.what + ')'), parts = [];
634
+ if (bt) parts.push('Some times in this file couldn\'t be read (' + bt + ').');
635
+ if (fixed.length) parts.push('Records this copy dropped or changed: ' + fixed.slice(0, 20).join(', ') + (fixed.length > 20 ? ', and ' + (fixed.length - 20) + ' more' : '') + '.');
636
+ /* The safety net: anywhere else sanitize's result differs from the file (R.unexplained), not already named above. */
637
+ const named = (r._repairs || []).map((x) => x.at).concat(Object.keys(r._badTimes || {}));
638
+ const other = isNewer ? [] : (r._drift || []).filter((p) => !explained(p, named));
639
+ if (other.length) parts.push('Other places this copy reads differently from the file: ' + other.slice(0, 20).join(', ') + (other.length > 20 ? ', and ' + (other.length - 20) + ' more' : '') + '.');
640
+ if (parts.length) out.push({ path: dir + 'run.json', kind: 'malformed', why: parts.join(' ') + ' The run is shown as the app shows it, without them.' });
641
+ if (r._conflicts && r._conflicts.length) out.push({ path: dir, kind: 'conflict', why: 'Conflict copies or lost saves beside this run: ' + r._conflicts.join(', ') + '. A person resolves them in the app.' });
642
+ return out;
643
+ }
644
+ /* Whether a place where the file and what the app reads differ is already named: the same place, one inside the other, or
645
+ another entry of the same list (dropping one entry shifts the rest). */
646
+ function explained(p, named) {
647
+ const base = (x) => x.replace(/\[\d+\].*$/, '');
648
+ return named.some((e) => p === e || p.startsWith(e + '.') || p.startsWith(e + '[') || e.startsWith(p + '.') || e.startsWith(p + '[') || (/\[/.test(p) && /\[/.test(e) && base(p) === base(e)));
649
+ }
650
+ /* A follow-up decision whose file says something cleanDecision doesn't keep as written (_drift, reading for other apps). */
651
+ function decisionDrift(d) {
652
+ return d && d._drift && d._drift.length ? { path: 'followups/' + d.process + '/' + d._file, kind: 'malformed', why: 'This decision isn\'t read as written: ' + d._drift.slice(0, 20).join(', ') + '.' } : null;
653
+ }
654
+ /* Which times R.sanitize couldn't read (r._badTimes), in words, or ''. */
655
+ function badTimesWhy(b) {
656
+ if (!b) return '';
657
+ const names = { created: 'the start', ended: 'the end', pauses: 'an earlier pause', paused: 'the pause under way', log: 'a log entry' };
658
+ return Object.keys(b).map((k) => (k === 'log' && Array.isArray(b.log) && b.log.length > 1 ? b.log.length + ' log entries' : names[k] || k)).join(', ');
659
+ }
660
+ /* One run, judged the same way by runs, run and next, so a summary never says other than the run itself: its version
661
+ (pub: what readVersion or the version list found; a version that isn't in the folder reads as missing files, as
662
+ readVersion finds it; null when the command doesn't read versions) decides why it's read-only, as the app decides it
663
+ (unusable here, or changed since the run started: R's own reason comes first), then every problem of the run file
664
+ and of that version. Sets r._unrunnable; returns the problems. */
665
+ function judgeRun(PF, r, pub) {
666
+ const A = PF.appjson, out = [], valid = typeof r.folder === 'string' && !!PF.publish.versionOf(r.folder) && /^[a-z0-9][a-z0-9-]{0,59}$/.test(r.process);
667
+ const dir = 'publications/' + r.process + '/' + r.folder + '/';
668
+ if (pub && valid && !pub._readFailed) {
669
+ const why = pub.unusable ? pub.why : pub.ok ? PF.repo.runMismatch(r, pub.info) : '';
670
+ if (why && !r._unrunnable) r._unrunnable = why;
671
+ if (!pub.ok) { const x = A.pubProblem(pub); out.push({ path: dir, kind: x.kind, why: x.why }); }
672
+ /* The version is fine; the run doesn't match it. Named at the run, so the version is never taken as unreadable. */
673
+ else if (why) out.push({ path: 'runs/' + r.process + '/' + r.id + '/run.json', kind: 'unusable', why: 'This run doesn\'t match its version ' + r.folder + ' (its checklist or version number differs from the version\'s record), so the app opens it read-only.' });
674
+ }
675
+ return out.concat(runProblems(PF, r));
676
+ }
677
+ /* A version folder that isn't in the folder, as readVersion would find it: none of its files there. */
678
+ function missingVersion(PF, r) { const v = PF.publish.verify(r.folder, { 'map.md': null, 'types.json': null, 'publication.json': null }); v.folder = r.folder; v.process = r.process; return v; }
679
+ /* What's wrong with people.json as a people list, or '' (no file is fine: names are then null). */
680
+ function peopleWhy(p) {
681
+ if (!p || p.hash == null) return '';
682
+ const doc = p.doc;
683
+ if (doc == null) return 'This file isn\'t complete JSON, so no names could be read.';
684
+ const list = Array.isArray(doc) ? doc : typeof doc === 'object' && Array.isArray(doc.people) ? doc.people : null;
685
+ if (!list) return 'This file isn\'t a people list, so no names could be read.';
686
+ const bad = list.filter((x) => !(x && typeof x === 'object' && typeof x.id === 'string' && typeof x.name === 'string')).length;
687
+ const long = list.filter((x) => x && typeof x.name === 'string' && x.name.length > 60).length;
688
+ if (!bad && long) return 'This file has ' + long + ' ' + (long === 1 ? 'name' : 'names') + ' over 60 characters; they are cut to 60, as the app shows them.';
689
+ return bad ? 'This file has ' + bad + ' ' + (bad === 1 ? 'entry' : 'entries') + ' without a text ID and name; those names are left null.' : '';
690
+ }
691
+ /* What a whole-folder read found beyond failed reads (which the ledger already holds): parts that failed, runs and plans
692
+ that won't parse, versions that can't be used, newer runs and conflict copies, one per file or folder. */
693
+ function folderProblems(PF, d) {
694
+ const A = PF.appjson, out = [];
695
+ Object.keys(d.failed || {}).forEach((part) => out.push({ path: PART_PATH[part] || part, kind: 'unreadable', why: 'This part of the folder couldn\'t be read: ' + d.failed[part] + '.' }));
696
+ (d.unreadable || []).forEach((u) => out.push({ path: u.path, kind: u.kind || 'unreadable', why: fileWhy(u.why) }));
697
+ (d.nofile || []).forEach((n) => out.push({ path: n.path, kind: 'nofile', why: 'The run folder has no run.json.' }));
698
+ (d.malformed || []).forEach((m) => out.push({ path: m.path, kind: m.kind || 'malformed', why: fileWhy(m.why) }));
699
+ (d.fuUnread || []).forEach((u) => out.push({ path: 'followups/' + u.process + '/' + u._file, kind: 'malformed', why: 'This file couldn\'t be read, or isn\'t a follow-up decision this copy can read.' }));
700
+ (d.corrUnread || []).forEach((u) => out.push({ path: u.dir + '/' + u.process + '/' + u._file, kind: 'malformed', why: 'This file couldn\'t be read, or isn\'t a decision this copy can read.' }));
701
+ (d.processes || []).forEach((proc) => (proc.pubs || []).forEach((p) => { if (!p.ok && !p._readFailed) { const x = A.pubProblem(p); out.push({ path: 'publications/' + proc.process + '/' + p.folder + '/', kind: x.kind, why: x.why }); } }));
702
+ (d.fuDecisions || []).forEach((x) => { const p = decisionDrift(x); if (p) out.push(p); });
703
+ const pw = peopleWhy(d.people);
704
+ if (pw) out.push({ path: 'people.json', kind: 'malformed', why: pw });
705
+ return out;
706
+ }
707
+ function adapterProblems(adapter) {
708
+ return (adapter.links || []).map((p) => ({ path: p, kind: 'link', why: 'A link. Flightdeck never reads through links.' }))
709
+ .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) + ').' })));
710
+ }
711
+
712
+ /* What a whole-folder command's output depends on, as folders and files: its data, the publications that say whether a
713
+ run or plan can be used and which version a plan starts on, and people.json for names. With --process, that process's
714
+ part only (the plans are all read, since a plan's process is inside the file). */
715
+ function needs(req) {
716
+ const sub = (top) => top + (req.proc ? '/' + req.proc : '');
717
+ if (req.cmd === 'processes' || req.cmd === 'process') return [req.cmd === 'process' ? 'publications/' + req.proc : 'publications'];
718
+ if (req.cmd === 'runs') return [sub('runs'), 'proposals', sub('publications'), 'people.json'];
719
+ return [sub('followups'), sub('runs'), 'people.json'].concat(req.proc ? [sub('publications'), 'proposals'] : []);
720
+ }
721
+ /* A view of the folder that holds only those paths (and the folders above them, listed to reach them). Anything else
722
+ looks absent: it's never read, so it's never a problem and never part of the output. It sits above the ledger, so
723
+ what it hides never reaches it. */
724
+ /* Whether a path is one of these, inside one, or a folder above one (listed to reach it). */
725
+ function covers(prefixes) { return (p) => { p = p || ''; return p === '' || prefixes.some((x) => p === x || p.startsWith(x + '/') || x.startsWith(p + '/')); }; }
726
+ /* What run and next depend on: the run's folder, its version once it's known, people.json, and that run's own follow-up
727
+ decisions (the files named <run id>.*, not the other runs' in the same folder). */
728
+ function runCovers(proc, id, r) {
729
+ const base = covers(['runs/' + proc + '/' + id, 'people.json'].concat(r && typeof r.folder === 'string' ? ['publications/' + r.process + '/' + r.folder] : []));
730
+ return (p) => base(p) || p === 'followups' || p === 'followups/' + proc || p === 'followups/' + proc + '/' || String(p).startsWith('followups/' + proc + '/' + id + '.');
731
+ }
732
+ function dependsOn(fs, prefixes) {
733
+ const ok = covers(prefixes);
734
+ const v = {};
735
+ if (fs.list) v.list = (p, o) => (ok(p) ? fs.list(p, o) : Promise.resolve([]));
736
+ ['read', 'stat', 'blob'].forEach((m) => { if (fs[m]) v[m] = (p, o) => (ok(p) ? fs[m](p, o) : Promise.resolve(null)); });
737
+ ['write', 'append', 'remove'].forEach((m) => { v[m] = (...a) => fs[m](...a); });
738
+ return v;
739
+ }
740
+
741
+ /* eng: { PF, version } (loadEngine()). adapter: a folder with list, read, stat and blob (and links and skipped, as the
742
+ command line's has); it's only ever read through PF.repo.readOnly, inside a problem ledger (PF.repo.ledger) that names
743
+ every failed list or read, every listed file gone when read, and everything the loaders found malformed. req: { cmd,
744
+ proc, key, version, folder, status, person, contract }. Resolves to { code, json }: an envelope (exit 0 or 4), whose
745
+ problems are the ledger's within the command's scope (complete exactly when there are none), or an error object (exit 1
746
+ or 5) carrying every problem the ledger holds. */
747
+ async function readData(eng, adapter, req) {
748
+ const PF = eng.PF, A = PF.appjson, repo = PF.repo, F = PF.followups, app = eng.version || '';
749
+ const L = repo.ledger(repo.readOnly(adapter, 'Reading for other apps'));
750
+ const take = (list) => list.forEach((p) => L.note(p.path, p.kind, p.why));
751
+ /* What the command depends on (set once the command is known): problems anywhere else, a link met while listing a
752
+ folder above what it reads included, are never its problems. */
753
+ let relevant = () => true;
754
+ const ledger = () => { take(adapterProblems(adapter)); return L.problems().filter((p) => relevant(p.path)); };
755
+ const err = (code, kind, why) => ({ code, json: A.failure(app, kind, why, ledger()) });
756
+ const notFound = (why) => err(EXIT.notFound, 'not-found', why);
757
+ if (req.contract != null && A.VERSIONS.indexOf(+req.contract) < 0) return err(EXIT.error, 'contract', 'This copy of Flightdeck reads contract ' + A.VERSIONS.join(', ') + ' only (' + A.CONTRACT + '). Ask for one of those, or update Flightdeck.');
758
+ const done = (keep, data, readAt) => { const e = A.envelope(app, readAt || new Date().toISOString(), keep(ledger()), data); return { code: e.complete ? 0 : EXIT.partial, json: e }; };
759
+ try {
760
+ if (req.cmd === 'run' || req.cmd === 'next') {
761
+ const m = RUN_KEY.exec(req.key || '');
762
+ relevant = runCovers(m[1], m[2], null);
763
+ const one = await repo.readRunOnly(L.fs, m[1], m[2]);
764
+ if (!one.found) return notFound('There\'s no run ' + req.key + ' in the team folder.');
765
+ const r = one.run, key = m[1] + '/' + m[2];
766
+ if (one.nofile) L.note('runs/' + key + '/run.json', 'nofile', 'The run folder has no run.json.');
767
+ take(one.bad.map((b) => ({ path: b.path, kind: b.kind || 'unreadable', why: fileWhy(b.why) })));
768
+ take(one.fuUnread.map((u) => ({ path: u.path, kind: u.kind || 'malformed', why: fileWhy(u.why) })));
769
+ one.fuDecisions.forEach((x) => { const p = decisionDrift(x); if (p) take([p]); });
770
+ if (one.peopleWhy) L.note('people.json', one.peopleKind || 'unreadable', fileWhy(one.peopleWhy));
771
+ const pw = peopleWhy(one.people);
772
+ if (pw) L.note('people.json', 'malformed', pw);
773
+ if (r) { relevant = runCovers(m[1], m[2], r); take(judgeRun(PF, r, one.pub)); }
774
+ const usable = !!r && !newerRun(PF, r);
775
+ /* run and next read only what they depend on (readRunOnly), so every problem met counts. */
776
+ const keep = (list) => list, nameOf = A.names(one.people);
777
+ if (req.cmd === 'run') return done(keep, usable ? A.run(r, F.list([r], one.fuDecisions), nameOf) : { run: null });
778
+ return done(keep, usable ? A.next(r, nameOf, req.person || null) : { run: key, person: req.person ? nameOf(req.person) : null, next: null, mine: null });
779
+ }
780
+ /* Each command reads only what its output depends on (needs()), so every problem the ledger holds is one that output
781
+ depends on, and all of them are reported: there's no scope to fall behind what's read. */
782
+ relevant = covers(needs(req));
783
+ const d = await repo.loadAll(dependsOn(L.fs, needs(req)), { readOnly: true, explain: true, deep: true });
784
+ take(folderProblems(PF, d));
785
+ /* Every run judged as run judges it, against its own version (runs reads the versions; followups doesn't need them). */
786
+ const pubOf = (r) => { const proc = d.processes.filter((x) => x.process === r.process)[0], p = proc && proc.pubs.filter((x) => x.folder === r.folder)[0]; return p || missingVersion(PF, r); };
787
+ d.runs.forEach((r) => take(judgeRun(PF, r, req.cmd === 'runs' ? pubOf(r) : null)));
788
+ const nameOf = A.names(d.people), runs = d.runs.filter((r) => !newerRun(PF, r)), all = (list) => list;
789
+ const known = (p) => d.processes.some((x) => x.process === p) || d.runs.some((r) => r.process === p) || d.plans.some((x) => x.process === p);
790
+ if (req.proc && !known(req.proc)) return notFound('There\'s no process ' + req.proc + ' in the team folder.');
791
+ if (req.cmd === 'processes') return done(all, A.processes(d.processes), d.readAt);
792
+ if (req.cmd === 'process') {
793
+ const proc = d.processes.filter((x) => x.process === req.proc)[0];
794
+ if (!proc || !proc.pubs.length) return notFound('There\'s no published checklist for ' + req.proc + ' in the team folder.');
795
+ let pub = null;
796
+ const vOf = (p) => (p.info && typeof p.info.version === 'number' ? p.info.version : PF.publish.versionOf(p.folder));
797
+ if (req.folder) pub = proc.pubs.filter((p) => p.folder === req.folder)[0];
798
+ else if (req.version != null) pub = proc.pubs.filter((p) => vOf(p) === +req.version).sort((a, b) => (b.ok ? 1 : 0) - (a.ok ? 1 : 0) || (a.folder < b.folder ? -1 : 1))[0];
799
+ else pub = proc.index.latest || proc.pubs.slice().sort((a, b) => (vOf(b) || 0) - (vOf(a) || 0))[0];
800
+ if (!pub) return notFound('There\'s no version ' + (req.folder || req.version) + ' of ' + req.proc + ' in the team folder.');
801
+ return done(all, A.process(proc, pub), d.readAt);
802
+ }
803
+ if (req.cmd === 'runs') {
804
+ const st = req.status || 'all', mine = (p) => !req.proc || p === req.proc;
805
+ const list = runs.filter((r) => mine(r.process) && (st === 'all' || (st === 'running' && !r.ended) || (st === 'ended' && !!r.ended)));
806
+ const plans = st === 'all' || st === 'planned' ? d.plans.filter((p) => mine(p.process)) : [];
807
+ /* Which version a plan starts on is unknown (null), not missing, when any of its process's versions couldn't be read or
808
+ verified: only problems with the versions themselves count (a run that doesn't match its version is named at the run). */
809
+ const probs = ledger(), unsure = (proc) => probs.some((x) => ['', 'publications', 'publications/'].indexOf(x.path) >= 0 || x.path === 'publications/' + proc || x.path.indexOf('publications/' + proc + '/') === 0);
810
+ return done(all, A.runs(list, plans, nameOf, d.processes, unsure), d.readAt);
811
+ }
812
+ /* followups: every run's follow-ups come from its run.json, so any run that couldn't be read may hide some. */
813
+ const items = F.list(runs.filter((r) => !req.proc || r.process === req.proc), d.fuDecisions).filter((x) => req.status !== 'open' || F.isOpen(x));
814
+ return done(all, A.followUps(items, nameOf), d.readAt);
815
+ } catch (e) {
816
+ return err(EXIT.error, 'failed', 'The team folder couldn\'t be read: ' + (e && e.message || String(e)));
817
+ }
818
+ }
819
+
820
+ /* The command line around readData: flags, the folder, the engine, and JSON out. Usage problems before any reading exit 1. */
821
+ async function readMain(argv) {
822
+ const VAL = ['--process', '--status', '--person', '--version', '--folder', '--contract'], val = {};
823
+ const usage = (why) => ({ out: JSON.stringify(failureJson(null, 'usage', why), null, 2), code: EXIT.error });
824
+ const args = argv.filter((a, i) => !a.startsWith('--') && !(i > 0 && VAL.indexOf(argv[i - 1]) >= 0));
825
+ const cmd = args[0], asJson = argv.includes('--json');
826
+ for (const f of VAL) {
827
+ const i = argv.indexOf(f);
828
+ if (i < 0) continue;
829
+ if (!argv[i + 1] || argv[i + 1].startsWith('--')) { if (asJson) return usage('say what goes after ' + f + '.'); fail('say what goes after ' + f); }
830
+ val[f] = argv[i + 1];
831
+ }
832
+ if (!asJson) fail(cmd + ' prints JSON for other apps. Add --json, for example: flightdeck ' + cmd + ' <team folder> --json');
833
+ const req = { cmd, proc: val['--process'] || null, status: val['--status'] || null, person: val['--person'] || null, version: val['--version'] || null, folder: val['--folder'] || null, contract: val['--contract'] || null };
834
+ if (cmd === 'process') { req.proc = args[2]; if (!req.proc || !PROC_ID.test(req.proc)) return usage('say which process, for example: flightdeck process <team folder> release --json'); }
835
+ if (cmd === 'run' || cmd === 'next') { req.key = args[2]; if (!req.key || !RUN_KEY.test(req.key)) return usage('say which run as <process>/<run id>, for example: flightdeck ' + cmd + ' <team folder> release/k3m9p2q8 --json'); }
836
+ if (req.proc && !PROC_ID.test(req.proc)) return usage('a process ID is lowercase letters, numbers and dashes.');
837
+ if (req.version != null && !/^\d{1,6}$/.test(req.version)) return usage('--version is a version number, like 3.');
838
+ if (req.folder != null && !/^v\d+-[a-z0-9]+$/.test(req.folder)) return usage('--folder is a version folder, like v3-k3m9p2q8.');
839
+ if (req.contract != null && !/^\d{1,4}$/.test(req.contract)) return usage('--contract is a contract number, like 1.');
840
+ const statuses = cmd === 'runs' ? ['running', 'planned', 'ended', 'all'] : cmd === 'followups' ? ['open', 'all'] : null;
841
+ if (req.status != null && (!statuses || statuses.indexOf(req.status) < 0)) return usage(statuses ? '--status is one of: ' + statuses.join(', ') + '.' : '--status works with runs and followups.');
842
+ let eng, dir;
843
+ try { eng = loadEngine(); dir = folderArg(args[1], cmd); }
844
+ catch (e) { return { out: JSON.stringify(failureJson(eng, 'failed', e.message), null, 2), code: EXIT.error }; }
845
+ try {
846
+ const r = await readData(eng, readAdapter(dir, { maxBytes: READ_MAX }), req);
847
+ return { out: JSON.stringify(r.json, null, 2), code: r.code };
848
+ } catch (e) {
849
+ return { out: JSON.stringify(failureJson(eng, 'failed', 'The team folder couldn\'t be read: ' + (e && e.message || String(e))), null, 2), code: EXIT.error };
850
+ }
851
+ }
852
+ function failureJson(eng, kind, why) { return eng ? eng.PF.appjson.failure(eng.version, kind, why, []) : { contract: 'flightdeck-read/1', app: versionOf(app()) || '', error: { kind, why }, problems: [] }; }
853
+
374
854
  const HELP = `flightdeck: team process checklists, in one HTML file
375
855
 
376
856
  flightdeck open open the app (it starts on built-in examples)
@@ -379,6 +859,24 @@ const HELP = `flightdeck: team process checklists, in one HTML file
379
859
  flightdeck skills <team folder> add or update only the skills, in <team folder>/.flightdeck/skills
380
860
  flightdeck skills --to <folder> add or update them in any other folder (an assistant's skills folder, a repo)
381
861
  flightdeck backup <team folder> copy the whole folder to ~/Flightdeck backups (or --to <folder>)
862
+ flightdeck export <team folder> --run <process>/<run id>
863
+ make an evidence pack of one run in ~/Flightdeck evidence (or --out <folder>)
864
+ flightdeck export <team folder> --process <process> --from YYYY-MM-DD --through YYYY-MM-DD [--tz Area/City]
865
+ make one of the runs started on those days, both included, in --tz
866
+ (this computer's time zone unless you say)
867
+ flightdeck processes <team folder> --json
868
+ for other apps: every process and its versions, as JSON
869
+ flightdeck process <team folder> <process> [--version N | --folder v3-...] --json
870
+ one version's phases, steps, gates and evidence (the latest usable one
871
+ unless you say)
872
+ flightdeck runs <team folder> [--process <id>] [--status running|planned|ended|all] --json
873
+ runs and planned runs
874
+ flightdeck run <team folder> <process>/<run id> --json
875
+ one run: each step's state, who and when, its evidence, gates, follow-ups
876
+ flightdeck next <team folder> <process>/<run id> [--person <id>] --json
877
+ what's waiting in a run and on whom (with --person, that person's next step)
878
+ flightdeck followups <team folder> [--process <id>] [--status open|all] --json
879
+ every follow-up and what became of it
382
880
  flightdeck path print where the app file is
383
881
  flightdeck version print this version
384
882
 
@@ -386,6 +884,14 @@ const HELP = `flightdeck: team process checklists, in one HTML file
386
884
  2: updates to install; 3: files your team changed that need merging; 1: an error
387
885
  --app-only install only ${NAME}, not AGENTS.md or the skills
388
886
  --force replace a newer app, an unrecognized file, or contract files your team changed
887
+ --no-attachments export: list attachments with their sizes instead of packing them
888
+
889
+ The JSON commands only read: they never change a file in the team folder. Exit code 0: complete; 4: partial (the data
890
+ is printed, and "problems" names every file or folder that couldn't be read); 1: no data; 5: no such process, version or
891
+ run. People-directory email fields are omitted. See docs/apps.md in the Flightdeck repository.
892
+
893
+ export never changes the team folder and never writes a pack into it. It prints the pack's SHA-256: keep it somewhere
894
+ separate. Exit code 0: a complete pack; 4: a partial pack (made, with what changed or is missing listed); 1: no pack.
389
895
 
390
896
  Every file install replaces is first copied to .flightdeck/updates/<version>/replaced/ and read back.
391
897
 
@@ -394,9 +900,15 @@ default goes to .flightdeck/updates/<version>/, with a README.md to hand to your
394
900
 
395
901
  function main(argv) {
396
902
  const out = [];
903
+ /* Reading for other apps has flags of its own (--version takes a number there), so it's parsed apart. */
904
+ const first = argv.filter((a) => !a.startsWith('--'))[0];
905
+ if (READS.indexOf(first) >= 0 && !argv.includes('--help')) return readMain(argv);
397
906
  const ti = argv.indexOf('--to'), to = ti >= 0 ? argv[ti + 1] : null;
398
907
  if (ti >= 0 && (!to || to.startsWith('--'))) fail('say which folder after --to, for example: flightdeck skills --to ~/skills');
399
- const args = argv.filter((a, i) => !a.startsWith('--') && !(ti >= 0 && i === ti + 1));
908
+ /* Flags that take a value: the value is never read as a command or a folder. */
909
+ const VALUED = ['--to', '--run', '--process', '--from', '--through', '--tz', '--out'], val = {};
910
+ VALUED.forEach((f) => { const i = argv.indexOf(f); if (i >= 0) { if (!argv[i + 1] || argv[i + 1].startsWith('--')) fail('say what goes after ' + f); val[f] = argv[i + 1]; } });
911
+ const args = argv.filter((a, i) => !a.startsWith('--') && !(i > 0 && VALUED.indexOf(argv[i - 1]) >= 0));
400
912
  const force = argv.includes('--force'), check = argv.includes('--check'), appOnly = argv.includes('--app-only');
401
913
  const cmd = args[0];
402
914
  if (!cmd || cmd === 'help' || argv.includes('--help')) return { out: HELP, code: 0 };
@@ -487,6 +999,7 @@ function main(argv) {
487
999
  if (r.links || r.tmp) out.push('Skipped ' + [r.links ? r.links + ' link' + (r.links === 1 ? '' : 's') : '', r.tmp ? r.tmp + ' temporary file' + (r.tmp === 1 ? '' : 's') : ''].filter(Boolean).join(' and ') + '.');
488
1000
  return { out: out.join('\n'), code: 0 };
489
1001
  }
1002
+ if (cmd === 'export') return exportPack({ folder: args[1], run: val['--run'], process: val['--process'], from: val['--from'], through: val['--through'], tz: val['--tz'], out: val['--out'], noAttachments: argv.includes('--no-attachments') });
490
1003
  if (cmd === 'open') {
491
1004
  app();
492
1005
  /* The path is passed as data to a launcher, never through a shell (cmd would expand %VARIABLES% in it). */
@@ -500,13 +1013,11 @@ function main(argv) {
500
1013
  }
501
1014
 
502
1015
  if (require.main === module) {
1016
+ const done = (r) => { if (r.out) process.stdout.write(r.out + '\n'); process.exitCode = r.code; };
1017
+ const failed = (e) => { process.stderr.write('flightdeck: ' + (e instanceof Refused ? e.message : (e && e.message) || String(e)) + '\n'); process.exitCode = EXIT.error; };
503
1018
  try {
504
1019
  const r = main(process.argv.slice(2));
505
- if (r.out) process.stdout.write(r.out + '\n');
506
- process.exitCode = r.code;
507
- } catch (e) {
508
- process.stderr.write('flightdeck: ' + (e instanceof Refused ? e.message : (e && e.message) || String(e)) + '\n');
509
- process.exitCode = EXIT.error;
510
- }
1020
+ if (r && typeof r.then === 'function') r.then(done, failed); else done(r);
1021
+ } catch (e) { failed(e); }
511
1022
  }
512
- module.exports = { main, versionOf, cmp, skillFiles, norm, region, managed, merged, hash, baselineKey, examples };
1023
+ module.exports = { main, versionOf, cmp, skillFiles, norm, region, managed, merged, hash, baselineKey, examples, readAdapter, loadEngine, readData, EXIT };