@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/CHANGELOG.md +37 -0
- package/README.md +20 -0
- package/bin/flightdeck.js +528 -17
- package/dist/flightdeck.html +4920 -469
- package/package.json +1 -1
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,
|
|
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
|
-
|
|
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
|
-
/*
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
506
|
-
|
|
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 };
|