@troylar/flightdeck 1.17.0 → 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/CHANGELOG.md +13 -0
- package/README.md +8 -0
- package/bin/flightdeck.js +316 -16
- package/dist/flightdeck.html +769 -62
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
<!-- Generated by build.py from src/releases.json. Edit that file, not this one. -->
|
|
4
4
|
|
|
5
|
+
## 1.18.0 · Oct 19, 2026
|
|
6
|
+
|
|
7
|
+
Other apps can read checklists, runs, what's waiting and follow-ups as JSON.
|
|
8
|
+
|
|
9
|
+
### New
|
|
10
|
+
|
|
11
|
+
- **Read checklists and runs as JSON** (Command line). Scripts and other apps can read the team folder as a teammate would: flightdeck processes, process, runs, run, next and followups, with --json. They show checklists, runs, what's waiting and on whom, and follow-ups. They only read and never change the folder. Anything they couldn't read is named file by file, with exit code 4. People-directory email fields are omitted. *Where:* flightdeck run <team folder> <process>/<run id> --json, and docs/apps.md
|
|
12
|
+
- **Copy a run's key** (Runs). Copy run key copies the key other apps use to read a run, like backup-check/k3m9p2q8. *Where:* A run's More menu
|
|
13
|
+
|
|
14
|
+
### Improved
|
|
15
|
+
|
|
16
|
+
- **See each phase's steps in the flight plan** (Flight plan). Each phase lists its steps: the type, the text, must-dos, who does it, the skill that covers it, lanes side by side, and the gate last. Choose a step to open it in the checklist. Show all steps or Hide all steps opens or closes every phase. Phases start open when the checklist has 40 steps or fewer. Your choice is kept for each process in this browser. *Where:* A process page, Flight plan
|
|
17
|
+
|
|
5
18
|
## 1.17.0 · Oct 19, 2026
|
|
6
19
|
|
|
7
20
|
Search everything with Ctrl+K, and big team folders open and refresh faster, show 50 runs at a time, and warn when refreshing is slow.
|
package/README.md
CHANGED
|
@@ -395,6 +395,12 @@ The same rules are in the `flightdeck-skill-brief` skill for your AI assistant,
|
|
|
395
395
|
| `flightdeck backup <folder>` | Copies the whole team folder to `~/Flightdeck backups/<folder> backup <date> <time>/`, or under `--to <dir>`. Skips links and temporary files, and refuses a destination inside the folder |
|
|
396
396
|
| `flightdeck export <folder> --run <process>/<id>` | Makes an evidence pack of one run in `~/Flightdeck evidence/` (or `--out <dir>`, never inside the team folder): a zip of the original files, a readable `evidence.html`, `manifest.json`, `SHA256SUMS` and a `README.txt`. Prints the pack's SHA-256 to keep somewhere separate. Reads only; never changes the team folder |
|
|
397
397
|
| `flightdeck export <folder> --process <id> --from YYYY-MM-DD --through YYYY-MM-DD` | The same for every run of a process started on those days, both included, in `--tz Area/City` (this computer's time zone unless you say) |
|
|
398
|
+
| `flightdeck processes <folder> --json` | For other apps: every process and its versions, as JSON |
|
|
399
|
+
| `flightdeck process <folder> <process> --json` | One version of a checklist (the latest usable one, or `--version N`, or `--folder v3-…`): phases, steps, gates and the evidence each step can record |
|
|
400
|
+
| `flightdeck runs <folder> --json` | Runs and planned runs, with `--process <id>` and `--status running\|planned\|ended\|all` |
|
|
401
|
+
| `flightdeck run <folder> <process>/<run id> --json` | One run in full: each step's state, who and when, what was recorded, gates and follow-ups. In the app, a run's **More** menu has **Copy run key** |
|
|
402
|
+
| `flightdeck next <folder> <process>/<run id> --json` | What's waiting in a run and on whom; `--person <id>` adds that person's own next step |
|
|
403
|
+
| `flightdeck followups <folder> --json` | Every follow-up and what became of it (`--status open` for the open ones) |
|
|
398
404
|
| `flightdeck path` | Prints where the app file is |
|
|
399
405
|
| `flightdeck version` | Prints the version |
|
|
400
406
|
|
|
@@ -407,6 +413,8 @@ The same rules are in the `flightdeck-skill-brief` skill for your AI assistant,
|
|
|
407
413
|
|
|
408
414
|
`export` exits with 0 for a complete pack, 4 for a partial one (made, and it lists at the top what changed or was missing while it read), and 1 when it made no pack. It writes `<name>.zip.partial` as it goes and renames it only when it's done; Ctrl+C or a failure removes it. It uses only the code in its own installed `flightdeck.html`, never a team folder's copy, and never reads through a link. The people directory and its email fields are left out; original records and attachments may contain personal information. A pack isn't signed: checking its hashes shows whether its files still match, and comparing its SHA-256 with the one you kept shows whether the pack is the one you made. See [Evidence packs](docs/evidence.md).
|
|
409
415
|
|
|
416
|
+
The JSON commands let other apps read a team folder the way a teammate would: a script beside the monthly backup check, a reminder bot for an access review, an onboarding tracker, a deploy script. They only read, never through a link, and never change a file in the team folder. Every result says which contract it follows (`flightdeck-read/1`) and names, file by file, anything it couldn't read. Exit code 0: complete; 4: partial (the data is still printed, with each problem listed); 1: no data; 5: no such process, version or run. People-directory email fields are omitted. See [Using Flightdeck from other apps](docs/apps.md).
|
|
417
|
+
|
|
410
418
|
Install and skills sort every `AGENTS.md` and skill file the same way. A file that's still a Flightdeck default (this version's or an earlier one) is updated. A file your team changed is left alone. If this version changed that file's default too, the new default goes to `.flightdeck/updates/<version>/`, with a `README.md` holding a prompt for your AI assistant to merge it. In `AGENTS.md` only the part between the `flightdeck:begin` and `flightdeck:end` markers is Flightdeck's; Team notes, below it, are never touched. `.flightdeck/installed.json` records which defaults the folder is based on; it can make install more careful, never less. Every file install replaces is first copied to `.flightdeck/updates/<version>/replaced/`. A file that changes while install runs is left alone and install exits with 1. Nothing is written through a link or outside the folder (see [Updating Flightdeck](docs/updating.md) for the limits).
|
|
411
419
|
|
|
412
420
|
<br>
|
package/bin/flightdeck.js
CHANGED
|
@@ -14,8 +14,10 @@ const APP = path.join(__dirname, '..', 'dist', 'flightdeck.html');
|
|
|
14
14
|
const NAME = 'flightdeck.html', OLD = 'preflight.html', SKILLS = '.flightdeck/skills', AGENTS = 'AGENTS.md';
|
|
15
15
|
const BEGIN = '<!-- flightdeck:begin -->', END = '<!-- flightdeck:end -->';
|
|
16
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
|
-
|
|
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 };
|
|
19
21
|
|
|
20
22
|
class Refused extends Error {}
|
|
21
23
|
/* A file that changed between the moment install read it and the moment it would replace it. */
|
|
@@ -378,10 +380,10 @@ function backup(src, toArg) {
|
|
|
378
380
|
}
|
|
379
381
|
function size(n) { return n < 1024 ? n + ' bytes' : n < 1048576 ? (n / 1024).toFixed(1) + ' KB' : (n / 1048576).toFixed(1) + ' MB'; }
|
|
380
382
|
|
|
381
|
-
/* ----------
|
|
383
|
+
/* ---------- The engine: export and reading for other apps run the app's own rules ---------- */
|
|
382
384
|
/* The app's own rules, from this installed package's dist/flightdeck.html and nowhere else (never a team folder's copy):
|
|
383
385
|
its core and storage blocks, run in node:vm without the screens. */
|
|
384
|
-
function
|
|
386
|
+
function loadEngine() {
|
|
385
387
|
const html = app(), block = (id) => {
|
|
386
388
|
const m = new RegExp('<script id="' + id + '">([\\s\\S]*?)</script>').exec(html);
|
|
387
389
|
if (!m) fail('the app file in this install has no ' + id + ' block (' + APP + '). Reinstall the package.');
|
|
@@ -395,10 +397,32 @@ function engine() {
|
|
|
395
397
|
/* The team folder for the collector: list, read, stat and blob, read-only, and never through a link. Every folder on the
|
|
396
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
|
|
397
399
|
opened, and a file is opened without following a link and must be the very file lstat saw (device and inode), so one
|
|
398
|
-
swapped for a link after it was listed is refused too. links collects what was skipped.
|
|
399
|
-
|
|
400
|
-
|
|
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;
|
|
401
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
|
+
}
|
|
402
426
|
function partsOf(p) {
|
|
403
427
|
const a = p ? String(p).split('/') : [];
|
|
404
428
|
a.forEach((x) => { if (!x || x === '.' || x === '..' || /[\\\u0000]/.test(x)) fail('refusing an unexpected path: ' + p); });
|
|
@@ -448,11 +472,13 @@ function readAdapter(root) {
|
|
|
448
472
|
} catch (e) { fs.closeSync(fd); throw e; }
|
|
449
473
|
}
|
|
450
474
|
return {
|
|
451
|
-
links,
|
|
475
|
+
links, skipped,
|
|
452
476
|
list: (p) => new Promise((res) => {
|
|
453
477
|
const a = partsOf(p), dir = dirAt(a);
|
|
454
|
-
|
|
455
|
-
const
|
|
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); }
|
|
456
482
|
sameWalk(a, dir.ids, p || '.');
|
|
457
483
|
res(entries.map((e) => {
|
|
458
484
|
const rel = (p ? p + '/' : '') + e.name;
|
|
@@ -465,26 +491,36 @@ function readAdapter(root) {
|
|
|
465
491
|
try { f = fileAt(p); } catch (e) { if (!e.link) throw e; }
|
|
466
492
|
res(f ? { size: f.st.size, mtime: f.st.mtimeMs } : null);
|
|
467
493
|
}),
|
|
468
|
-
read: (p) => new Promise((res) => {
|
|
494
|
+
read: (p, ro) => new Promise((res) => {
|
|
469
495
|
const o = open(p);
|
|
470
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); }
|
|
471
499
|
/* Decoded as a browser decodes a file (TextDecoder: UTF-8, a byte-order mark dropped), so every reader sees the same text. */
|
|
472
|
-
try { const data =
|
|
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); }
|
|
473
501
|
}),
|
|
474
502
|
/* A file to read a piece at a time, from the descriptor opened above; close() when done. */
|
|
475
|
-
blob: (p) => new Promise((res) => {
|
|
503
|
+
blob: (p, ro) => new Promise((res) => {
|
|
476
504
|
const o = open(p);
|
|
477
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); }
|
|
478
508
|
let closed = false;
|
|
509
|
+
const close = () => { if (!closed) { closed = true; fs.closeSync(o.fd); } };
|
|
479
510
|
res({
|
|
480
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
|
+
}),
|
|
481
517
|
slice: (a, b) => ({ arrayBuffer: () => new Promise((ok) => {
|
|
482
518
|
const buf = Buffer.alloc(Math.max(0, b - a));
|
|
483
519
|
let got = 0;
|
|
484
520
|
while (got < buf.length) { const n = fs.readSync(o.fd, buf, got, buf.length - got, a + got); if (!n) break; got += n; }
|
|
485
521
|
ok(buf.buffer.slice(buf.byteOffset, buf.byteOffset + got));
|
|
486
522
|
}) }),
|
|
487
|
-
close
|
|
523
|
+
close
|
|
488
524
|
});
|
|
489
525
|
})
|
|
490
526
|
};
|
|
@@ -518,7 +554,7 @@ async function exportPack(opt) {
|
|
|
518
554
|
const dir = folderArg(opt.folder, 'export');
|
|
519
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');
|
|
520
556
|
if (opt.process && (!opt.from || !opt.through)) fail('give the days with --from YYYY-MM-DD and --through YYYY-MM-DD (both included)');
|
|
521
|
-
const { PF, version } =
|
|
557
|
+
const { PF, version } = loadEngine(), E = PF.evidence;
|
|
522
558
|
const tz = opt.tz || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
|
523
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.');
|
|
524
560
|
const out = outFolder(expand(opt.out || path.join(require('os').homedir(), 'Flightdeck evidence')), withinOf(dir));
|
|
@@ -571,6 +607,250 @@ async function exportPack(opt) {
|
|
|
571
607
|
}
|
|
572
608
|
}
|
|
573
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
|
+
|
|
574
854
|
const HELP = `flightdeck: team process checklists, in one HTML file
|
|
575
855
|
|
|
576
856
|
flightdeck open open the app (it starts on built-in examples)
|
|
@@ -584,6 +864,19 @@ const HELP = `flightdeck: team process checklists, in one HTML file
|
|
|
584
864
|
flightdeck export <team folder> --process <process> --from YYYY-MM-DD --through YYYY-MM-DD [--tz Area/City]
|
|
585
865
|
make one of the runs started on those days, both included, in --tz
|
|
586
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
|
|
587
880
|
flightdeck path print where the app file is
|
|
588
881
|
flightdeck version print this version
|
|
589
882
|
|
|
@@ -593,6 +886,10 @@ const HELP = `flightdeck: team process checklists, in one HTML file
|
|
|
593
886
|
--force replace a newer app, an unrecognized file, or contract files your team changed
|
|
594
887
|
--no-attachments export: list attachments with their sizes instead of packing them
|
|
595
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
|
+
|
|
596
893
|
export never changes the team folder and never writes a pack into it. It prints the pack's SHA-256: keep it somewhere
|
|
597
894
|
separate. Exit code 0: a complete pack; 4: a partial pack (made, with what changed or is missing listed); 1: no pack.
|
|
598
895
|
|
|
@@ -603,6 +900,9 @@ default goes to .flightdeck/updates/<version>/, with a README.md to hand to your
|
|
|
603
900
|
|
|
604
901
|
function main(argv) {
|
|
605
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);
|
|
606
906
|
const ti = argv.indexOf('--to'), to = ti >= 0 ? argv[ti + 1] : null;
|
|
607
907
|
if (ti >= 0 && (!to || to.startsWith('--'))) fail('say which folder after --to, for example: flightdeck skills --to ~/skills');
|
|
608
908
|
/* Flags that take a value: the value is never read as a command or a folder. */
|
|
@@ -720,4 +1020,4 @@ if (require.main === module) {
|
|
|
720
1020
|
if (r && typeof r.then === 'function') r.then(done, failed); else done(r);
|
|
721
1021
|
} catch (e) { failed(e); }
|
|
722
1022
|
}
|
|
723
|
-
module.exports = { main, versionOf, cmp, skillFiles, norm, region, managed, merged, hash, baselineKey, examples, readAdapter,
|
|
1023
|
+
module.exports = { main, versionOf, cmp, skillFiles, norm, region, managed, merged, hash, baselineKey, examples, readAdapter, loadEngine, readData, EXIT };
|