@troylar/flightdeck 1.16.0 → 1.17.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 +39 -0
- package/README.md +12 -0
- package/bin/flightdeck.js +228 -17
- package/dist/flightdeck.html +4434 -457
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,45 @@
|
|
|
2
2
|
|
|
3
3
|
<!-- Generated by build.py from src/releases.json. Edit that file, not this one. -->
|
|
4
4
|
|
|
5
|
+
## 1.17.0 · Oct 19, 2026
|
|
6
|
+
|
|
7
|
+
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.
|
|
8
|
+
|
|
9
|
+
### New
|
|
10
|
+
|
|
11
|
+
- **Search everything with Ctrl+K** (Search). Press Ctrl+K (Cmd+K on a Mac), or / when single-key shortcuts are on. Find runs, steps, notes, corrections, follow-ups, step types, skills and pages, grouped by kind, with your words highlighted. Accents don't matter. Choose a result to go straight there; anything you were typing is kept. Results update as the folder changes, and a run that couldn't be read is found from its last copy and marked. *Where:* Search in the top bar, on every page
|
|
12
|
+
- **Export a run's evidence** (Runs). Make an evidence pack of one run, or of a process's runs between two days: a zip with the original files, a page to read, and SHA-256 hashes to check them by. It says what it left out and why, and anything that changed while it was made. Save it, and keep its SHA-256 somewhere separate. The people directory is left out; the records may still name people. *Where:* A run's Receipt or its More menu, and Runs › Export evidence…
|
|
13
|
+
- **flightdeck export** (Command line). flightdeck export makes the same evidence pack from the command line, for one run (--run) or for the runs started between two days (--process, --from, --through, in --tz). It never changes the team folder, prints the pack's SHA-256, and exits 0 for a complete pack, 4 for a partial one and 1 when it made none. *Where:* flightdeck export <team folder> --run <process>/<run id>
|
|
14
|
+
- **Filter runs** (Runs). Narrow the Runs page by process, version, outcome and the dates runs started. Each filter shows as a chip you can remove. *Where:* Runs › Filter runs
|
|
15
|
+
- **Says when refreshing is slow on this computer** (Folder health). When the last 3 refreshes each took more than 15 seconds, Folder health says how long refreshing takes here, with what to do about a big folder. *Where:* Settings › Folder health
|
|
16
|
+
|
|
17
|
+
### Improved
|
|
18
|
+
|
|
19
|
+
- **How runs go counts runs, one version at a time, and says what it left out** (Insights). Each number belongs to one version, counts runs, and says how many runs it's out of and which it couldn't count, and why. Unreadable or future times and reopened gates are left out of times. Choose a number to see its runs. Known limit: a time that can't be read is dropped when any copy saves the run, as before, so it's left out only until then. *Where:* A process › How runs go, and the flight plan
|
|
20
|
+
- **Runs load 50 at a time** (Runs). Running and Finished each show 50 runs and say how many there are. Show 50 more adds the next ones and moves you to the first one added. *Where:* Runs
|
|
21
|
+
- **Big team folders open faster, and show runs as they're read** (Team folder). Reading a file now gives its size too, so opening a big folder asks OneDrive about half as many things. Runs appear as they're read, and Home and Runs say "Still reading runs (640 of 2,000)" until it's done. A run not read yet is never shown as gone. Pictures are read when they scroll into view, a few at a time, after the run you're recording. *Where:* Opening a team folder, Home, Runs, and any page with pictures
|
|
22
|
+
- **Refreshing a big team folder asks for much less** (Team folder). Running runs and runs that ended in the last week are checked on every refresh. Older runs are checked a share at a time, each one at least every 10 minutes, so a refresh asks OneDrive about 80% fewer things. A late note on an old run can take up to 10 minutes to show. Refresh in the top bar still reads everything at once. *Where:* Every team folder, while Flightdeck is open
|
|
23
|
+
- **An edit that keeps a file's size and time still shows up** (Team folder). Every finished run is read in full at least once an hour, and every running run you don't have open at least every 5 minutes, whatever its size and time say. Decisions on corrections and follow-ups are read again at least once an hour too. *Where:* Every team folder, while Flightdeck is open
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- **Changing the clock or waking from sleep no longer delays refreshing** (Team folder). Setting the clock back doesn't delay refreshing, and after the computer sleeps, Flightdeck refreshes as soon as you come back and reads whatever went past its time first. While the tab is hidden nothing is read. *Where:* Every team folder, while Flightdeck is open
|
|
28
|
+
|
|
29
|
+
## 1.16.1 · Oct 19, 2026
|
|
30
|
+
|
|
31
|
+
Make the editors' side panels wider or narrower.
|
|
32
|
+
|
|
33
|
+
### Improved
|
|
34
|
+
|
|
35
|
+
- **Resize the Run preview** (Step types). Drag the line between the form and the preview to make the preview wider or narrower. Or focus the line and use the Left and Right arrow keys (Shift moves further), Home and End. Enter or a double-click puts it back. The form always keeps room. Your width is kept in this browser only. *Where:* Step types › a step type, the line left of Run preview
|
|
36
|
+
- **Resize the parts and the inspector** (Draft editor). The Parts list and the inspector each have a line you can drag or move with the arrow keys, the same way as the step-type preview, and each keeps its own width. The Markdown view uses the inspector's width too. When the parts are folded to icons, only the inspector resizes. *Where:* A process page › Edit, the lines beside Parts and the inspector
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
|
|
40
|
+
- **The step-type form has its room again** (Step types). The form takes most of the page and the preview is a narrower column beside it, as intended. *Where:* Step types › a step type
|
|
41
|
+
- **Dialogs fit the window** (Keyboard). Dialogs stay inside the window and scroll when their content is longer. *Where:* Keyboard shortcuts, and History
|
|
42
|
+
- **Opens in a browser that blocks site storage** (Examples). The examples open, and a short dialog explains once that they and your changes to them last only while the tab is open. After that, a Tab only badge at the top of the window stays in view on every screen, dialog and phone step panel. The run's save status and the editors' labels say Saved in this tab only. A team folder still keeps everything. *Where:* Opening Flightdeck
|
|
43
|
+
|
|
5
44
|
## 1.16.0 · Oct 19, 2026
|
|
6
45
|
|
|
7
46
|
Apply a correction to the draft, then see which version has it.
|
package/README.md
CHANGED
|
@@ -393,6 +393,8 @@ The same rules are in the `flightdeck-skill-brief` skill for your AI assistant,
|
|
|
393
393
|
| `flightdeck skills <folder>` | Adds or updates only the skills, in `<folder>/.flightdeck/skills` |
|
|
394
394
|
| `flightdeck skills --to <dir>` | The same, into any other folder you name (absolute or relative), such as your assistant's own skills folder or another repo. `<dir>` is created if it's missing |
|
|
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
|
+
| `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
|
+
| `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) |
|
|
396
398
|
| `flightdeck path` | Prints where the app file is |
|
|
397
399
|
| `flightdeck version` | Prints the version |
|
|
398
400
|
|
|
@@ -401,6 +403,9 @@ The same rules are in the `flightdeck-skill-brief` skill for your AI assistant,
|
|
|
401
403
|
| `--check` | With `install` or `skills`: lists what would change and changes nothing. Exit code 0: nothing to do; 2: updates to install; 3: files your team changed need merging; 1: a problem |
|
|
402
404
|
| `--app-only` | With `install`: copies only `flightdeck.html` |
|
|
403
405
|
| `--force` | Replaces a newer app, an unrecognized file, or files your team changed. Like every install, it first saves a checked copy of each file it replaces in `.flightdeck/updates/<version>/replaced/` |
|
|
406
|
+
| `--no-attachments` | With `export`: lists attachments with their sizes instead of packing them |
|
|
407
|
+
|
|
408
|
+
`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).
|
|
404
409
|
|
|
405
410
|
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).
|
|
406
411
|
|
|
@@ -482,6 +487,13 @@ Flightdeck was called Preflight before 1.9.0. Team folders, runs and published v
|
|
|
482
487
|
|
|
483
488
|
## FAQ
|
|
484
489
|
|
|
490
|
+
<details>
|
|
491
|
+
<summary><b>How do I find an old run, note or ticket?</b></summary>
|
|
492
|
+
|
|
493
|
+
Press Ctrl+K (Cmd+K on a Mac), or choose Search in the top bar. It searches runs, steps, notes, corrections, follow-ups, step types, skills and pages in the team folder, ignores accents, and takes you straight to what you choose. The search stays in memory on your computer; nothing is written to the team folder.
|
|
494
|
+
|
|
495
|
+
</details>
|
|
496
|
+
|
|
485
497
|
<details>
|
|
486
498
|
<summary><b>Does Flightdeck run anything for me?</b></summary>
|
|
487
499
|
|
package/bin/flightdeck.js
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
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
|
+
const EXIT = { none: 0, error: 1, updates: 2, merge: 3, partial: 4 };
|
|
16
19
|
|
|
17
20
|
class Refused extends Error {}
|
|
18
21
|
/* A file that changed between the moment install read it and the moment it would replace it. */
|
|
@@ -320,13 +323,11 @@ function exitFor(statuses) {
|
|
|
320
323
|
return statuses.some((s) => ['update', 'new', 'missing', 'forced'].includes(s)) ? EXIT.updates : EXIT.none;
|
|
321
324
|
}
|
|
322
325
|
|
|
323
|
-
/*
|
|
324
|
-
|
|
325
|
-
|
|
326
|
+
/* Whether p is the folder src or inside it, judged by what each folder above p is (device and inode), never by spelling:
|
|
327
|
+
on a file system that ignores letter case, "Team" and "TEAM" are the same folder. Missing parts are skipped. */
|
|
328
|
+
function withinOf(src) {
|
|
326
329
|
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) => {
|
|
330
|
+
return (p) => {
|
|
330
331
|
for (let cur = p; ; cur = path.dirname(cur)) {
|
|
331
332
|
let st = null;
|
|
332
333
|
try { st = fs.statSync(cur); } catch (e) { /* not there yet */ }
|
|
@@ -334,6 +335,12 @@ function backup(src, toArg) {
|
|
|
334
335
|
if (path.dirname(cur) === cur) return false;
|
|
335
336
|
}
|
|
336
337
|
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/* Copy a team folder to <dest>/<name> backup <date time>/. Links are skipped, never followed, and so are temporary files. */
|
|
341
|
+
function backup(src, toArg) {
|
|
342
|
+
const parent = expand(toArg || path.join(require('os').homedir(), 'Flightdeck backups'));
|
|
343
|
+
const within = withinOf(src);
|
|
337
344
|
const inside = 'the backup can\'t go inside the folder it backs up. Choose another place with --to.';
|
|
338
345
|
if (within(parent)) fail(inside);
|
|
339
346
|
try { fs.mkdirSync(parent, { recursive: true }); } catch (e) { fail('couldn\'t create "' + parent + '": ' + e.message); }
|
|
@@ -371,6 +378,199 @@ function backup(src, toArg) {
|
|
|
371
378
|
}
|
|
372
379
|
function size(n) { return n < 1024 ? n + ' bytes' : n < 1048576 ? (n / 1024).toFixed(1) + ' KB' : (n / 1048576).toFixed(1) + ' MB'; }
|
|
373
380
|
|
|
381
|
+
/* ---------- flightdeck export: an evidence pack (docs/evidence.md) ---------- */
|
|
382
|
+
/* The app's own rules, from this installed package's dist/flightdeck.html and nowhere else (never a team folder's copy):
|
|
383
|
+
its core and storage blocks, run in node:vm without the screens. */
|
|
384
|
+
function engine() {
|
|
385
|
+
const html = app(), block = (id) => {
|
|
386
|
+
const m = new RegExp('<script id="' + id + '">([\\s\\S]*?)</script>').exec(html);
|
|
387
|
+
if (!m) fail('the app file in this install has no ' + id + ' block (' + APP + '). Reinstall the package.');
|
|
388
|
+
return m[1];
|
|
389
|
+
};
|
|
390
|
+
const ctx = vm.createContext({ console, setTimeout, clearTimeout, Blob, TextEncoder, TextDecoder, performance: { now: () => performance.now() } });
|
|
391
|
+
vm.runInContext(block('pf-core'), ctx, { filename: 'flightdeck.html#pf-core' });
|
|
392
|
+
vm.runInContext(block('pf-storage'), ctx, { filename: 'flightdeck.html#pf-storage' });
|
|
393
|
+
return { PF: ctx.PF, version: versionOf(html) };
|
|
394
|
+
}
|
|
395
|
+
/* The team folder for the collector: list, read, stat and blob, read-only, and never through a link. Every folder on the
|
|
396
|
+
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
|
+
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
|
+
function readAdapter(root) {
|
|
400
|
+
const NOFOLLOW = fs.constants.O_NOFOLLOW || 0, links = [];
|
|
401
|
+
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; }
|
|
402
|
+
function partsOf(p) {
|
|
403
|
+
const a = p ? String(p).split('/') : [];
|
|
404
|
+
a.forEach((x) => { if (!x || x === '.' || x === '..' || /[\\\u0000]/.test(x)) fail('refusing an unexpected path: ' + p); });
|
|
405
|
+
return a;
|
|
406
|
+
}
|
|
407
|
+
/* The real folder at these parts, or null when it isn't there, with each folder's identity (device and inode) on the way. */
|
|
408
|
+
function dirAt(a) {
|
|
409
|
+
let cur = root;
|
|
410
|
+
/* The team folder itself first: replaced by a link, nothing is read through it. */
|
|
411
|
+
const top = lstatOrNull(root);
|
|
412
|
+
if (!top || top.isSymbolicLink() || !top.isDirectory()) throw linkErr('the team folder');
|
|
413
|
+
const ids = [top.dev + ':' + top.ino];
|
|
414
|
+
for (let i = 0; i < a.length; i++) {
|
|
415
|
+
cur = path.join(cur, a[i]);
|
|
416
|
+
const st = lstatOrNull(cur);
|
|
417
|
+
if (!st) return null;
|
|
418
|
+
if (st.isSymbolicLink()) throw linkErr(a.slice(0, i + 1).join('/'));
|
|
419
|
+
if (!st.isDirectory()) return null;
|
|
420
|
+
ids.push(st.dev + ':' + st.ino);
|
|
421
|
+
}
|
|
422
|
+
return { cur, ids };
|
|
423
|
+
}
|
|
424
|
+
/* Node has no openat, so a folder swapped for a link (and maybe put back) is caught by walking again after the open:
|
|
425
|
+
every folder must still be the same real folder, and the file at the path the one that was opened. */
|
|
426
|
+
function sameWalk(a, ids, rel) {
|
|
427
|
+
const again = dirAt(a);
|
|
428
|
+
if (!again || again.ids.join('/') !== ids.join('/')) throw linkErr(rel);
|
|
429
|
+
return again.cur;
|
|
430
|
+
}
|
|
431
|
+
function fileAt(p) {
|
|
432
|
+
const a = partsOf(p), name = a.pop(), dir = dirAt(a);
|
|
433
|
+
if (!dir) return null;
|
|
434
|
+
const full = path.join(dir.cur, name), st = lstatOrNull(full);
|
|
435
|
+
if (!st) return null;
|
|
436
|
+
if (st.isSymbolicLink()) throw linkErr(p);
|
|
437
|
+
return st.isFile() ? { full, st, a, ids: dir.ids, name } : null;
|
|
438
|
+
}
|
|
439
|
+
function open(p) {
|
|
440
|
+
const f = fileAt(p);
|
|
441
|
+
if (!f) return null;
|
|
442
|
+
let fd;
|
|
443
|
+
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; }
|
|
444
|
+
try {
|
|
445
|
+
const st = fs.fstatSync(fd), now = lstatOrNull(path.join(sameWalk(f.a, f.ids, p), f.name));
|
|
446
|
+
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);
|
|
447
|
+
return { fd, st };
|
|
448
|
+
} catch (e) { fs.closeSync(fd); throw e; }
|
|
449
|
+
}
|
|
450
|
+
return {
|
|
451
|
+
links,
|
|
452
|
+
list: (p) => new Promise((res) => {
|
|
453
|
+
const a = partsOf(p), dir = dirAt(a);
|
|
454
|
+
if (!dir) return res([]);
|
|
455
|
+
const entries = fs.readdirSync(dir.cur, { withFileTypes: true });
|
|
456
|
+
sameWalk(a, dir.ids, p || '.');
|
|
457
|
+
res(entries.map((e) => {
|
|
458
|
+
const rel = (p ? p + '/' : '') + e.name;
|
|
459
|
+
if (e.isSymbolicLink()) { if (links.indexOf(rel) < 0) links.push(rel); return { name: e.name, kind: 'link' }; }
|
|
460
|
+
return e.isDirectory() ? { name: e.name, kind: 'dir' } : e.isFile() ? { name: e.name, kind: 'file' } : null;
|
|
461
|
+
}).filter(Boolean));
|
|
462
|
+
}),
|
|
463
|
+
stat: (p) => new Promise((res) => {
|
|
464
|
+
let f = null;
|
|
465
|
+
try { f = fileAt(p); } catch (e) { if (!e.link) throw e; }
|
|
466
|
+
res(f ? { size: f.st.size, mtime: f.st.mtimeMs } : null);
|
|
467
|
+
}),
|
|
468
|
+
read: (p) => new Promise((res) => {
|
|
469
|
+
const o = open(p);
|
|
470
|
+
if (!o) return res(null);
|
|
471
|
+
/* 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 = fs.readFileSync(o.fd); res({ text: new TextDecoder('utf-8').decode(data), size: data.length, mtime: o.st.mtimeMs }); } finally { fs.closeSync(o.fd); }
|
|
473
|
+
}),
|
|
474
|
+
/* A file to read a piece at a time, from the descriptor opened above; close() when done. */
|
|
475
|
+
blob: (p) => new Promise((res) => {
|
|
476
|
+
const o = open(p);
|
|
477
|
+
if (!o) return res(null);
|
|
478
|
+
let closed = false;
|
|
479
|
+
res({
|
|
480
|
+
size: o.st.size,
|
|
481
|
+
slice: (a, b) => ({ arrayBuffer: () => new Promise((ok) => {
|
|
482
|
+
const buf = Buffer.alloc(Math.max(0, b - a));
|
|
483
|
+
let got = 0;
|
|
484
|
+
while (got < buf.length) { const n = fs.readSync(o.fd, buf, got, buf.length - got, a + got); if (!n) break; got += n; }
|
|
485
|
+
ok(buf.buffer.slice(buf.byteOffset, buf.byteOffset + got));
|
|
486
|
+
}) }),
|
|
487
|
+
close: () => { if (!closed) { closed = true; fs.closeSync(o.fd); } }
|
|
488
|
+
});
|
|
489
|
+
})
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
/* Where packs go: the nearest folder of --out that exists, resolved, must be outside the team folder before anything is
|
|
493
|
+
created; then each missing folder is made one at a time, and each must be a real folder (not a link), still outside. */
|
|
494
|
+
function outFolder(want, within) {
|
|
495
|
+
const inTeam = 'the pack can\'t go inside the team folder. Choose another place with --out.';
|
|
496
|
+
const rest = [];
|
|
497
|
+
let cur = want;
|
|
498
|
+
while (!lstatOrNull(cur)) { rest.unshift(path.basename(cur)); const up = path.dirname(cur); if (up === cur) break; cur = up; }
|
|
499
|
+
let at;
|
|
500
|
+
try { at = fs.realpathSync(cur); } catch (e) { fail('"' + cur + '" can\'t be used for the pack: ' + e.message); }
|
|
501
|
+
if (!fs.statSync(at).isDirectory()) fail('"' + cur + '" isn\'t a folder.');
|
|
502
|
+
if (within(at)) fail(inTeam);
|
|
503
|
+
for (const part of rest) {
|
|
504
|
+
if (!part || part === '.' || part === '..') fail('"' + want + '" isn\'t a plain folder path.');
|
|
505
|
+
at = path.join(at, part);
|
|
506
|
+
try { fs.mkdirSync(at); } catch (e) { if (e.code !== 'EEXIST') fail('couldn\'t create "' + at + '": ' + e.message); }
|
|
507
|
+
const st = lstatOrNull(at);
|
|
508
|
+
if (!st || st.isSymbolicLink() || !st.isDirectory()) fail('refusing to write through a link: ' + at);
|
|
509
|
+
if (within(at)) fail(inTeam);
|
|
510
|
+
}
|
|
511
|
+
const real = fs.realpathSync(at);
|
|
512
|
+
if (within(real)) fail(inTeam);
|
|
513
|
+
return real;
|
|
514
|
+
}
|
|
515
|
+
/* The pack goes to <out>/<name>.partial, created exclusively and written as it's made, then gets its final name without
|
|
516
|
+
replacing anything (" (2)" and on when the name is taken). Canceled (Ctrl+C) or failed, the partial file is removed. */
|
|
517
|
+
async function exportPack(opt) {
|
|
518
|
+
const dir = folderArg(opt.folder, 'export');
|
|
519
|
+
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
|
+
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 } = engine(), E = PF.evidence;
|
|
522
|
+
const tz = opt.tz || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
|
523
|
+
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
|
+
const out = outFolder(expand(opt.out || path.join(require('os').homedir(), 'Flightdeck evidence')), withinOf(dir));
|
|
525
|
+
const scope = opt.run ? { run: opt.run } : { process: opt.process, from: opt.from, through: opt.through, tz };
|
|
526
|
+
let fd = null, partial = null, canceled = false;
|
|
527
|
+
const onSig = () => { canceled = true; };
|
|
528
|
+
process.on('SIGINT', onSig); process.on('SIGTERM', onSig);
|
|
529
|
+
const cleanup = () => {
|
|
530
|
+
if (fd !== null) { try { fs.closeSync(fd); } catch (e) { /* already failing */ } fd = null; }
|
|
531
|
+
if (partial) { try { fs.unlinkSync(partial); } catch (e) { /* gone already */ } partial = null; }
|
|
532
|
+
};
|
|
533
|
+
try {
|
|
534
|
+
const res = await PF.repo.exportEvidence(readAdapter(dir), scope, {
|
|
535
|
+
tz, app: version, madeWith: 'command line', attachments: !opt.noAttachments, keepBytes: false, pool: false,
|
|
536
|
+
cancelled: () => canceled,
|
|
537
|
+
/* Marked for cleanup only once this process created it: a file already there is never touched. */
|
|
538
|
+
begin: (name) => { const p = path.join(out, name + '.partial'); fd = fs.openSync(p, 'wx'); partial = p; },
|
|
539
|
+
sink: (u8) => {
|
|
540
|
+
const b = Buffer.from(u8.buffer, u8.byteOffset, u8.length);
|
|
541
|
+
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; }
|
|
542
|
+
}
|
|
543
|
+
});
|
|
544
|
+
fs.fsyncSync(fd); fs.closeSync(fd); fd = null;
|
|
545
|
+
let final = path.join(out, res.name);
|
|
546
|
+
for (let n = 2; ; n++) {
|
|
547
|
+
try { fs.linkSync(partial, final); break; } catch (e) {
|
|
548
|
+
if (e.code === 'EEXIST') { final = path.join(out, res.name.replace(/(\.\w+)$/, ' (' + n + ')$1')); continue; }
|
|
549
|
+
/* A disk without hard links: rename, after checking the name is free. */
|
|
550
|
+
if (fs.existsSync(final)) { final = path.join(out, res.name.replace(/(\.\w+)$/, ' (' + n + ')$1')); continue; }
|
|
551
|
+
fs.renameSync(partial, final); partial = null; break;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
if (partial) { fs.unlinkSync(partial); partial = null; }
|
|
555
|
+
const col = res.col, lines = [];
|
|
556
|
+
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);
|
|
557
|
+
lines.push('SHA-256 of this pack: ' + res.digest);
|
|
558
|
+
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.');
|
|
559
|
+
if (col.status === 'partial') { lines.push('Partial, because:'); col.unresolved.forEach((u) => lines.push(' ' + u)); }
|
|
560
|
+
if (col.exclusions.length) lines.push('Left out: ' + col.exclusions.length + ' file' + (col.exclusions.length === 1 ? '' : 's') + ', each with its reason in manifest.json.');
|
|
561
|
+
lines.push(E.PRIVACY);
|
|
562
|
+
lines.push('Days and times are in ' + tz + '.');
|
|
563
|
+
return { out: lines.join('\n'), code: col.status === 'partial' ? EXIT.partial : 0 };
|
|
564
|
+
} catch (e) {
|
|
565
|
+
cleanup();
|
|
566
|
+
if (e && e.cancelled) fail('export canceled. No pack was made.');
|
|
567
|
+
throw e instanceof Refused ? e : new Refused(e && e.message || String(e));
|
|
568
|
+
} finally {
|
|
569
|
+
cleanup();
|
|
570
|
+
process.removeListener('SIGINT', onSig); process.removeListener('SIGTERM', onSig);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
374
574
|
const HELP = `flightdeck: team process checklists, in one HTML file
|
|
375
575
|
|
|
376
576
|
flightdeck open open the app (it starts on built-in examples)
|
|
@@ -379,6 +579,11 @@ const HELP = `flightdeck: team process checklists, in one HTML file
|
|
|
379
579
|
flightdeck skills <team folder> add or update only the skills, in <team folder>/.flightdeck/skills
|
|
380
580
|
flightdeck skills --to <folder> add or update them in any other folder (an assistant's skills folder, a repo)
|
|
381
581
|
flightdeck backup <team folder> copy the whole folder to ~/Flightdeck backups (or --to <folder>)
|
|
582
|
+
flightdeck export <team folder> --run <process>/<run id>
|
|
583
|
+
make an evidence pack of one run in ~/Flightdeck evidence (or --out <folder>)
|
|
584
|
+
flightdeck export <team folder> --process <process> --from YYYY-MM-DD --through YYYY-MM-DD [--tz Area/City]
|
|
585
|
+
make one of the runs started on those days, both included, in --tz
|
|
586
|
+
(this computer's time zone unless you say)
|
|
382
587
|
flightdeck path print where the app file is
|
|
383
588
|
flightdeck version print this version
|
|
384
589
|
|
|
@@ -386,6 +591,10 @@ const HELP = `flightdeck: team process checklists, in one HTML file
|
|
|
386
591
|
2: updates to install; 3: files your team changed that need merging; 1: an error
|
|
387
592
|
--app-only install only ${NAME}, not AGENTS.md or the skills
|
|
388
593
|
--force replace a newer app, an unrecognized file, or contract files your team changed
|
|
594
|
+
--no-attachments export: list attachments with their sizes instead of packing them
|
|
595
|
+
|
|
596
|
+
export never changes the team folder and never writes a pack into it. It prints the pack's SHA-256: keep it somewhere
|
|
597
|
+
separate. Exit code 0: a complete pack; 4: a partial pack (made, with what changed or is missing listed); 1: no pack.
|
|
389
598
|
|
|
390
599
|
Every file install replaces is first copied to .flightdeck/updates/<version>/replaced/ and read back.
|
|
391
600
|
|
|
@@ -396,7 +605,10 @@ function main(argv) {
|
|
|
396
605
|
const out = [];
|
|
397
606
|
const ti = argv.indexOf('--to'), to = ti >= 0 ? argv[ti + 1] : null;
|
|
398
607
|
if (ti >= 0 && (!to || to.startsWith('--'))) fail('say which folder after --to, for example: flightdeck skills --to ~/skills');
|
|
399
|
-
|
|
608
|
+
/* Flags that take a value: the value is never read as a command or a folder. */
|
|
609
|
+
const VALUED = ['--to', '--run', '--process', '--from', '--through', '--tz', '--out'], val = {};
|
|
610
|
+
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]; } });
|
|
611
|
+
const args = argv.filter((a, i) => !a.startsWith('--') && !(i > 0 && VALUED.indexOf(argv[i - 1]) >= 0));
|
|
400
612
|
const force = argv.includes('--force'), check = argv.includes('--check'), appOnly = argv.includes('--app-only');
|
|
401
613
|
const cmd = args[0];
|
|
402
614
|
if (!cmd || cmd === 'help' || argv.includes('--help')) return { out: HELP, code: 0 };
|
|
@@ -487,6 +699,7 @@ function main(argv) {
|
|
|
487
699
|
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
700
|
return { out: out.join('\n'), code: 0 };
|
|
489
701
|
}
|
|
702
|
+
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
703
|
if (cmd === 'open') {
|
|
491
704
|
app();
|
|
492
705
|
/* The path is passed as data to a launcher, never through a shell (cmd would expand %VARIABLES% in it). */
|
|
@@ -500,13 +713,11 @@ function main(argv) {
|
|
|
500
713
|
}
|
|
501
714
|
|
|
502
715
|
if (require.main === module) {
|
|
716
|
+
const done = (r) => { if (r.out) process.stdout.write(r.out + '\n'); process.exitCode = r.code; };
|
|
717
|
+
const failed = (e) => { process.stderr.write('flightdeck: ' + (e instanceof Refused ? e.message : (e && e.message) || String(e)) + '\n'); process.exitCode = EXIT.error; };
|
|
503
718
|
try {
|
|
504
719
|
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
|
-
}
|
|
720
|
+
if (r && typeof r.then === 'function') r.then(done, failed); else done(r);
|
|
721
|
+
} catch (e) { failed(e); }
|
|
511
722
|
}
|
|
512
|
-
module.exports = { main, versionOf, cmp, skillFiles, norm, region, managed, merged, hash, baselineKey, examples };
|
|
723
|
+
module.exports = { main, versionOf, cmp, skillFiles, norm, region, managed, merged, hash, baselineKey, examples, readAdapter, engine };
|