@troylar/flightdeck 1.14.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/flightdeck.js CHANGED
@@ -1,16 +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
- never touches the network. Every command works on plain files: copy the app into a team folder, add or update the
4
- skills for AI assistants (in the team folder or any other folder), or open the app. */
3
+ never touches the network. Every command works on plain files: copy the app and the AI assistant contract (AGENTS.md
4
+ and the skills) into a team folder, add or update the skills anywhere else, back up a team folder, or open the app. */
5
5
  'use strict';
6
6
  const fs = require('fs');
7
7
  const path = require('path');
8
+ const crypto = require('crypto');
8
9
  const { spawn } = require('child_process');
9
10
 
10
11
  const APP = path.join(__dirname, '..', 'dist', 'flightdeck.html');
11
- const NAME = 'flightdeck.html', OLD = 'preflight.html', SKILLS = '.flightdeck/skills';
12
+ const NAME = 'flightdeck.html', OLD = 'preflight.html', SKILLS = '.flightdeck/skills', AGENTS = 'AGENTS.md';
13
+ const BEGIN = '<!-- flightdeck:begin -->', END = '<!-- flightdeck:end -->';
14
+ /* --check exit codes, for scripts: nothing to do, updates to install, files that need a person (or an assistant) to merge. */
15
+ const EXIT = { none: 0, error: 1, updates: 2, merge: 3 };
12
16
 
13
- function fail(msg) { process.stderr.write('flightdeck: ' + msg + '\n'); process.exit(1); }
17
+ class Refused extends Error {}
18
+ /* A file that changed between the moment install read it and the moment it would replace it. */
19
+ class Changed extends Error {}
20
+ function fail(msg) { throw new Refused(msg); }
14
21
  function versionOf(html) {
15
22
  const m = /<meta name="(?:flightdeck|preflight)-version" content="(\d+\.\d+\.\d+|dev)">/.exec(html.slice(0, 8192));
16
23
  return m ? m[1] : null;
@@ -25,121 +32,481 @@ function app() {
25
32
  return fs.readFileSync(APP, 'utf8');
26
33
  }
27
34
  function expand(arg) { return path.resolve(arg.replace(/^~(?=$|[\\/])/, require('os').homedir())); }
28
- function folderArg(arg) {
29
- if (!arg) fail('say which team folder, for example: flightdeck install "~/OneDrive/Team/Flightdeck"');
35
+ function folderArg(arg, example) {
36
+ if (!arg) fail('say which team folder, for example: flightdeck ' + (example || 'install') + ' "~/OneDrive/Team/Flightdeck"');
30
37
  const dir = expand(arg);
31
38
  if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) fail('"' + dir + '" isn\'t a folder.');
32
39
  return fs.realpathSync(dir);
33
40
  }
34
- /* Write a file inside the team folder, and only there: no part of the path below the folder may be a link (a symlink or a
35
- Windows junction could point anywhere), and the file is written whole to a temporary sibling first, then moved into
36
- place, so an interrupted write never leaves half a file behind. */
37
- function safeWrite(dir, parts, text) {
38
- let cur = dir;
41
+
42
+ /* The same hashing as PF.contract in the app: no byte-order mark, Unix line endings, and for AGENTS.md only the part
43
+ between the markers (all of it in an AGENTS.md from before the markers). */
44
+ function norm(text) { return String(text).replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n'); }
45
+ function region(text) {
46
+ const t = norm(text), i = t.indexOf(BEGIN), j = i < 0 ? -1 : t.indexOf(END, i);
47
+ return i < 0 || j < 0 ? { text: t, start: 0, end: t.length, marked: false } : { text: t.slice(i, j + END.length), start: i, end: j + END.length, marked: true };
48
+ }
49
+ function managed(key, text) { return key === AGENTS ? region(text).text : norm(text); }
50
+ function hash(text) { return crypto.createHash('sha256').update(text, 'utf8').digest('hex'); }
51
+ function baselineKey(key) { return key === AGENTS ? AGENTS + '#flightdeck' : key; }
52
+
53
+ /* Walk to a path inside a folder without following a link at any level (a symlink or a Windows junction could point
54
+ anywhere). Returns the file's lstat, or null when it isn't there. */
55
+ function inside(dir, parts) {
56
+ let cur = dir, st = null;
39
57
  for (let i = 0; i < parts.length; i++) {
58
+ if (!parts[i] || parts[i] === '.' || parts[i] === '..') fail('refusing an unexpected path: ' + parts.join('/'));
40
59
  cur = path.join(cur, parts[i]);
41
- if (!cur.startsWith(dir + path.sep)) fail('refusing to write outside the team folder: ' + parts.join('/'));
42
- let st = null;
43
- try { st = fs.lstatSync(cur); } catch (e) { if (e.code !== 'ENOENT') throw e; }
44
- if (st && st.isSymbolicLink()) fail('refusing to write through a link: ' + cur);
45
- if (i < parts.length - 1) { if (!st) fs.mkdirSync(cur); else if (!st.isDirectory()) fail(cur + ' isn\'t a folder.'); }
60
+ if (!cur.startsWith(dir + path.sep)) fail('refusing to write outside the folder: ' + parts.join('/'));
61
+ try { st = fs.lstatSync(cur); } catch (e) { if (e.code !== 'ENOENT') throw e; return null; }
62
+ if (st.isSymbolicLink()) fail('refusing to write through a link: ' + cur);
63
+ if (i < parts.length - 1 && !st.isDirectory()) fail(cur + ' isn\'t a folder.');
46
64
  }
47
- const tmp = path.join(path.dirname(cur), '.' + path.basename(cur) + '.' + process.pid + '.' + Date.now() + '.tmp');
48
- try { fs.writeFileSync(tmp, text, { flag: 'wx' }); fs.renameSync(tmp, cur); }
49
- catch (e) { try { fs.unlinkSync(tmp); } catch (e2) { /* nothing to clean up */ } fail('couldn\'t write ' + cur + ': ' + e.message); }
50
- }
51
- /* Whether the app may replace the file at dest: a newer version, or a file that isn't a Flightdeck app, needs --force. */
52
- function mayReplace(dest, mine, force) {
53
- let st = null;
54
- try { st = fs.lstatSync(dest); } catch (e) { return true; }
55
- if (st.isSymbolicLink()) fail('refusing to write through a link: ' + dest);
56
- if (force) return true;
57
- const theirs = versionOf(fs.readFileSync(dest, 'utf8'));
58
- if (!theirs) fail(dest + ' isn\'t a Flightdeck app this version recognizes. Use --force to replace it anyway.');
59
- if (theirs !== 'dev' && mine !== 'dev' && cmp(theirs, mine) > 0) fail(dest + ' is a newer version (' + theirs + ') than this one (' + mine + '). Update the package, or use --force to replace it anyway.');
60
- return true;
61
- }
62
- /* The example folder is built into the app; the skills are taken from there, so they always match this version. */
63
- function skillFiles(html) {
65
+ return st;
66
+ }
67
+ function readInside(dir, parts) {
68
+ const st = inside(dir, parts);
69
+ if (!st) return null;
70
+ if (!st.isFile()) fail(path.join(dir, ...parts) + ' isn\'t a file.');
71
+ return fs.readFileSync(path.join(dir, ...parts), 'utf8');
72
+ }
73
+ function lstatOrNull(p) { try { return fs.lstatSync(p); } catch (e) { if (e.code === 'ENOENT') return null; throw e; } }
74
+ /* Every folder from dir down to dir/parts is a real folder, not a link, and is where its name says. dir itself is a real
75
+ path (folderArg resolves it), so the folder's real path must be exactly the joined name. With create, missing folders
76
+ are made one at a time. */
77
+ function folders(dir, parts, create) {
78
+ let cur = dir;
79
+ for (const p of parts) {
80
+ if (!p || p === '.' || p === '..' || /[\\/]/.test(p)) fail('refusing to write outside the folder: ' + parts.join('/'));
81
+ cur = path.join(cur, p);
82
+ let st = lstatOrNull(cur);
83
+ if (!st && create) { try { fs.mkdirSync(cur); } catch (e) { if (e.code !== 'EEXIST') throw e; } st = lstatOrNull(cur); }
84
+ if (!st) return null;
85
+ if (st.isSymbolicLink()) fail('refusing to write through a link: ' + cur);
86
+ if (!st.isDirectory()) fail(cur + ' isn\'t a folder.');
87
+ }
88
+ if (fs.realpathSync(cur) !== cur) fail('refusing to write through a link: ' + cur);
89
+ return cur;
90
+ }
91
+ /* Write a file inside the folder, and only there, never through a link:
92
+ 1. the text goes to a new temporary file beside the target (created exclusively, so never an existing file or a link),
93
+ written in full, flushed to disk and read back;
94
+ 2. the folders are checked again, and the temporary file must still be the one this process made;
95
+ 3. with expect (the text install read, or null for "not there"), the target must still be exactly that;
96
+ 4. one rename puts the temporary file in place.
97
+ A failure at any step leaves the target as it was and removes the temporary file. The only window is between steps 3
98
+ and 4: a save landing there is replaced, which is why install keeps a checked copy of every file before it replaces it
99
+ (keepCopy). Someone renaming folders inside your own team folder at that instant, on purpose, is out of scope. */
100
+ function safeWrite(dir, parts, text, expect) {
101
+ const name = parts[parts.length - 1], sub = parts.slice(0, -1);
102
+ if (!name || name === '.' || name === '..' || /[\\/]/.test(name)) fail('refusing to write outside the folder: ' + parts.join('/'));
103
+ const home = folders(dir, sub, true), target = path.join(home, name), data = Buffer.from(text, 'utf8');
104
+ const was = lstatOrNull(target);
105
+ if (was && (was.isSymbolicLink() || !was.isFile())) fail('refusing to write through a link: ' + target);
106
+ const tmp = path.join(home, '.' + name + '.' + process.pid + '.' + crypto.randomBytes(6).toString('hex') + '.tmp');
107
+ let fd = null, mine = null, moved = false;
108
+ try {
109
+ fd = fs.openSync(tmp, 'wx');
110
+ mine = fs.fstatSync(fd);
111
+ for (let off = 0; off < data.length;) {
112
+ const n = fs.writeSync(fd, data, off, data.length - off);
113
+ if (!(n > 0)) throw new Error('the disk accepted no more bytes');
114
+ off += n;
115
+ }
116
+ fs.fsyncSync(fd);
117
+ fs.closeSync(fd); fd = null;
118
+ folders(dir, sub, false);
119
+ const now = lstatOrNull(tmp);
120
+ if (!now || now.isSymbolicLink() || now.ino !== mine.ino || now.dev !== mine.dev) fail('refusing to write through a link: ' + home);
121
+ if (!fs.readFileSync(tmp).equals(data)) throw new Error('the written file didn\'t read back the same');
122
+ if (expect !== undefined) {
123
+ const st = lstatOrNull(target);
124
+ if (st && (st.isSymbolicLink() || !st.isFile())) fail('refusing to write through a link: ' + target);
125
+ if ((st ? fs.readFileSync(target, 'utf8') : null) !== expect) throw new Changed(parts.join('/'));
126
+ }
127
+ fs.renameSync(tmp, target);
128
+ moved = true;
129
+ } catch (e) {
130
+ if (e instanceof Refused || e instanceof Changed) throw e;
131
+ fail('couldn\'t write ' + target + ': ' + e.message);
132
+ } finally {
133
+ if (fd !== null) try { fs.closeSync(fd); } catch (e) { /* already failing */ }
134
+ /* Remove the temporary file only if it's still the one this process made, wherever a swapped folder now points. */
135
+ if (!moved && mine) {
136
+ try { const left = lstatOrNull(tmp); if (left && !left.isSymbolicLink() && left.ino === mine.ino && left.dev === mine.dev) fs.unlinkSync(tmp); }
137
+ catch (e) { /* the original error says what went wrong */ }
138
+ }
139
+ }
140
+ }
141
+
142
+ /* The example folder and the baselines are built into the app, so the contract always matches this version. */
143
+ function examples(html) {
64
144
  const m = /var EXAMPLES = (\{[\s\S]*?\});\n/.exec(html);
65
145
  if (!m) fail('couldn\'t find the built-in skills in the app file.');
66
- const all = JSON.parse(m[1].replace(/<\\\//g, '</'));
146
+ return JSON.parse(m[1].replace(/<\\\//g, '</'));
147
+ }
148
+ function skillFiles(html) {
149
+ const all = examples(html);
67
150
  return Object.keys(all).filter((p) => p.startsWith(SKILLS + '/')).map((p) => ({ path: p.slice(SKILLS.length + 1), text: all[p] }));
68
151
  }
152
+ function baselines(html) {
153
+ const m = /var BASELINES = (\{.*\});\n/.exec(html);
154
+ if (!m) fail('couldn\'t find the built-in baselines in the app file.');
155
+ return JSON.parse(m[1]).files || {};
156
+ }
157
+
158
+ /* What the app copy at dest is, compared with this one: missing, current, older (update), or a refusal. */
159
+ function appStatus(dir, file, html, mine, force) {
160
+ const text = readInside(dir, [file]);
161
+ if (text === null) return { status: 'new', text };
162
+ if (text === html) return { status: 'current', text };
163
+ const theirs = versionOf(text);
164
+ if (force) return { status: 'update', text };
165
+ if (!theirs) fail(path.join(dir, file) + ' isn\'t a Flightdeck app this version recognizes. Use --force to replace it anyway.');
166
+ if (theirs !== 'dev' && mine !== 'dev' && cmp(theirs, mine) > 0) fail(path.join(dir, file) + ' is a newer version (' + theirs + ') than this one (' + mine + '). Update the package, or use --force to replace it anyway.');
167
+ return { status: 'update', text };
168
+ }
169
+
170
+ /* Sort each contract file into what install does with it:
171
+ current same as this version's default
172
+ update a default some version shipped (only the built-in baselines prove that): replaced with this version's
173
+ kept the team changed it, and this version's default is the one it was based on: left alone
174
+ merge the team changed it and the default changed too: the new default goes to the updates folder
175
+ new not there yet: written
176
+ missing installed before, since deleted: written again
177
+ The folder's record only ever makes install more careful (kept rather than merge), never lets it replace a file.
178
+ A copy still waiting in .flightdeck/updates/<any version>/ keeps a file at merge until someone deletes the copy.
179
+ With --force, kept and merge files are replaced too, after a copy is saved. */
180
+ function classify(t, files, base, force) {
181
+ return files.map((f) => {
182
+ const parts = f.rel.split('/'), now = readInside(t.dir, parts), want = hash(managed(f.key, f.text));
183
+ const item = { key: f.key, rel: f.rel, parts, text: f.text, now, want, staged: staged(t, f.rel) };
184
+ if (now === null) return Object.assign(item, { status: t.record.files[f.key] ? 'missing' : 'new' });
185
+ const have = hash(managed(f.key, now));
186
+ /* Still waiting on a merge. When this version's default is new to the person (not already staged, not what the file
187
+ already says), it's staged too, so an older pending merge never hides newer instructions. */
188
+ if (item.staged.length && !force) return Object.assign(item, { status: 'merge', waiting: item.staged.map((x) => x.version).join(', '),
189
+ stage: have !== want && !item.staged.some((x) => x.version === t.version || hash(managed(f.key, x.text)) === want) });
190
+ if (have === want) return Object.assign(item, { status: 'current' });
191
+ if ((base[f.baseKey || baselineKey(f.key)] || {})[have]) return Object.assign(item, { status: 'update' });
192
+ if (force) return Object.assign(item, { status: 'forced' });
193
+ if (t.record.files[f.key] === want) return Object.assign(item, { status: 'kept' });
194
+ return Object.assign(item, { status: 'merge' });
195
+ });
196
+ }
197
+ /* The versions under the updates folder, and which of them hold a copy of rel still waiting to be merged. */
198
+ function versionsStaged(t) {
199
+ if (t.versions) return t.versions;
200
+ const home = folders(t.dir, t.updates, false);
201
+ t.versions = home ? fs.readdirSync(home).filter((v) => /^(\d+\.\d+\.\d+|dev)$/.test(v) && folders(t.dir, t.updates.concat(v), false)) : [];
202
+ return t.versions;
203
+ }
204
+ function staged(t, rel) {
205
+ return versionsStaged(t).map((v) => ({ version: v, parts: t.updates.concat(v, rel.split('/')) })).filter((x) => { const text = readInside(t.dir, x.parts); return text !== null && (x.text = text, true); });
206
+ }
207
+ /* The text written for a contract file: AGENTS.md keeps everything outside its markers (the team's notes); a file from
208
+ before the markers is replaced whole. */
209
+ function merged(item) {
210
+ if (item.key !== AGENTS || item.now === null) return item.text;
211
+ const old = region(item.now), fresh = region(item.text);
212
+ return old.marked ? norm(item.now).slice(0, old.start) + fresh.text + norm(item.now).slice(old.end) : item.text;
213
+ }
214
+ /* The folder's record, checked field by field. Anyone can edit it, so a record that isn't exactly what this command writes is
215
+ ignored (and said so), and only entries for files this version installs, with a well-formed hash, are read. */
216
+ function readRecord(t, keys) {
217
+ const text = readInside(t.dir, t.recordAt), none = { files: {} };
218
+ if (text === null) return none;
219
+ let r = null;
220
+ try { r = JSON.parse(text); } catch (e) { /* reported below */ }
221
+ const ok = r && typeof r === 'object' && !Array.isArray(r) && r.format === 'flightdeck-installed/1' && typeof r.version === 'string' && /^(\d+\.\d+\.\d+|dev)$/.test(r.version)
222
+ && typeof r.at === 'string' && (r.by === 'cli' || r.by === 'app') && r.files && typeof r.files === 'object' && !Array.isArray(r.files)
223
+ && Object.keys(r.files).every((k) => typeof r.files[k] === 'string' && /^[0-9a-f]{64}$/.test(r.files[k]));
224
+ if (!ok) return Object.assign(none, { ignored: true });
225
+ /* files: what this command may consult; all: every entry, kept as it is for files this command doesn't handle. */
226
+ const files = {};
227
+ keys.forEach((k) => { if (Object.prototype.hasOwnProperty.call(r.files, k)) files[k] = r.files[k]; });
228
+ return { version: r.version, files, all: Object.assign({}, r.files) };
229
+ }
230
+ function stamp(d) { const p = (n) => String(n).padStart(2, '0'); return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + p(d.getMinutes()); }
231
+ function fineStamp(d) { const p = (n, w) => String(n).padStart(w || 2, '0'); return stamp(d).replace(' ', '-') + p(d.getSeconds()) + '-' + p(d.getMilliseconds(), 3); }
232
+
233
+ function mergeReadme(version, items, t) {
234
+ const here = t.updates.concat(version).join('/');
235
+ return `# Flightdeck ${version}: files to merge
236
+
237
+ Flightdeck ${version} changed these files, and your team changed them too, so the update left your versions in place:
238
+
239
+ ${items.map((i) => '- `' + i.rel + '`' + (i.key === AGENTS ? ' (only the part between the flightdeck markers)' : '')).join('\n')}
240
+
241
+ The new default of each file is in this folder (\`${here}/\`), at the same path it has in ${t.home}.
242
+
243
+ ## Ask your AI assistant to merge them
244
+
245
+ Open your AI assistant in ${t.home}, and give it this:
246
+
247
+ > Flightdeck ${version} put new default versions of some files in \`${here}/\`; its README.md lists them. For each one, compare the new default with our file at the same path. Keep our own changes, and bring in what the new default adds or changes. In AGENTS.md, change only the part between \`<!-- flightdeck:begin -->\` and \`<!-- flightdeck:end -->\`; anything that is ours belongs under Team notes, below the end marker. Show me each change before you save it. When a file is merged, delete its copy here. When only README.md is left, delete this folder.
248
+
249
+ Then run the same flightdeck command with \`--check\`: exit code 0 means nothing is left to merge.
250
+ `;
251
+ }
252
+
253
+ /* install and skills: one routine for the team folder, and for skills --to <dir>. */
254
+ function contract(t, files, opts) {
255
+ const base = baselines(opts.html), now = new Date();
256
+ inside(t.dir, t.updates.concat(t.version, 'replaced'));
257
+ t.record = readRecord(t, files.map((f) => f.key));
258
+ t.changed = [];
259
+ const items = classify(t, files, base, opts.force);
260
+ if (opts.check) return items;
261
+ for (const i of items) {
262
+ if (['current', 'kept'].includes(i.status) || (i.status === 'merge' && i.waiting && !i.stage)) {
263
+ if (opts.force) i.staged.forEach((x) => fileAway(t, x));
264
+ continue;
265
+ }
266
+ try {
267
+ if (i.status === 'merge') { safeWrite(t.dir, t.updates.concat(t.version, i.rel.split('/')), i.text, null); continue; }
268
+ const out = merged(i);
269
+ if (i.now !== null && i.now !== out) keepCopy(t, i.parts, i.now);
270
+ safeWrite(t.dir, i.parts, out, i.now);
271
+ if (opts.force) i.staged.forEach((x) => fileAway(t, x));
272
+ } catch (e) {
273
+ if (!(e instanceof Changed)) throw e;
274
+ i.changed = true; t.changed.push(i.rel);
275
+ }
276
+ }
277
+ const merges = items.filter((i) => i.status === 'merge' && !i.changed);
278
+ if (merges.some((i) => !i.waiting || i.stage)) safeWrite(t.dir, t.updates.concat(t.version, 'README.md'), mergeReadme(t.version, merges.filter((i) => !i.waiting || i.stage || i.staged.some((x) => x.version === t.version)), t));
279
+ const rec = Object.assign({}, t.record.all || {});
280
+ items.forEach((i) => { if (!i.changed) rec[i.key] = i.want; });
281
+ if (!t.record.ignored && t.record.version === t.version && JSON.stringify(rec) === JSON.stringify(t.record.all)) return items;
282
+ safeWrite(t.dir, t.recordAt, JSON.stringify({ format: 'flightdeck-installed/1', version: t.version, at: now.toISOString(), by: 'cli', files: rec }, null, 2) + '\n');
283
+ return items;
284
+ }
285
+ /* Every file install replaces, the app included, is first copied to a folder of its own under
286
+ .flightdeck/updates/<version>/replaced/, made new for this run (never one an earlier run used), and the copy is read back.
287
+ No checked copy, no replacement. */
288
+ function keepCopy(t, parts, text) {
289
+ if (!t.replacedAt) {
290
+ const home = folders(t.dir, t.updates.concat(t.version, 'replaced'), true);
291
+ for (let n = 0; !t.replacedAt; n++) {
292
+ const name = fineStamp(new Date()) + '-' + crypto.randomBytes(3).toString('hex');
293
+ try { fs.mkdirSync(path.join(home, name)); t.replacedAt = t.updates.concat(t.version, 'replaced', name); }
294
+ catch (e) { if (e.code !== 'EEXIST' || n > 20) throw e; }
295
+ }
296
+ }
297
+ safeWrite(t.dir, t.replacedAt.concat(parts), text, null);
298
+ /* Nothing is replaced or removed on the strength of a copy that doesn't read back exactly. */
299
+ if (readInside(t.dir, t.replacedAt.concat(parts)) !== text) fail('the copy of ' + parts.join('/') + ' didn\'t read back the same, so nothing was replaced. Check the disk and try again.');
300
+ }
301
+ /* A staged copy that --force made obsolete: moved beside the files it replaced (staged/<version>/...), never deleted. */
302
+ function fileAway(t, x) {
303
+ keepCopy(t, ['staged', x.version].concat(x.parts.slice(t.updates.length + 1)), x.text);
304
+ const p = path.join(folders(t.dir, x.parts.slice(0, -1), false), x.parts[x.parts.length - 1]), st = lstatOrNull(p);
305
+ if (st && st.isFile() && !st.isSymbolicLink() && fs.readFileSync(p, 'utf8') === x.text) fs.unlinkSync(p);
306
+ }
307
+
308
+ const LABEL = { current: 'up to date', update: 'updated', new: 'added', missing: 'added again', kept: 'kept (your team changed it)', merge: 'needs merge', forced: 'replaced (--force)' };
309
+ const CHECK_LABEL = { update: 'update available', new: 'will be added', missing: 'will be added again', merge: 'needs merge', forced: 'will be replaced (--force)', kept: 'kept (your team changed it)' };
310
+ function report(items, check, out) {
311
+ items.filter((i) => i.status !== 'current').forEach((i) => out.push(' ' + (i.changed ? 'changed during install' : (check ? CHECK_LABEL : LABEL)[i.status]).padEnd(28) + i.rel + (i.waiting ? ' (still waiting in updates/' + i.waiting + ')' : '')));
312
+ }
313
+ /* What install says after the file list: an ignored record, and files left alone because they changed while it ran. */
314
+ function notes(t, out) {
315
+ if (t.record && t.record.ignored) out.push('Ignored ' + t.recordAt.join('/') + ': it isn\'t a record this version of Flightdeck wrote, so it wasn\'t trusted. A new one was written.');
316
+ if (t.changed && t.changed.length) out.push('Left as they are, because they changed while install was running: ' + t.changed.join(', ') + '. Run the command again.');
317
+ }
318
+ function exitFor(statuses) {
319
+ if (statuses.includes('merge')) return EXIT.merge;
320
+ return statuses.some((s) => ['update', 'new', 'missing', 'forced'].includes(s)) ? EXIT.updates : EXIT.none;
321
+ }
322
+
323
+ /* Copy a team folder to <dest>/<name> backup <date time>/. Links are skipped, never followed, and so are temporary files. */
324
+ function backup(src, toArg) {
325
+ const parent = expand(toArg || path.join(require('os').homedir(), 'Flightdeck backups'));
326
+ const me = fs.statSync(src);
327
+ /* Whether p is the folder or inside it, judged by what each folder above p is (device and inode), never by spelling:
328
+ on a file system that ignores letter case, "Team" and "TEAM" are the same folder. Missing parts are skipped. */
329
+ const within = (p) => {
330
+ for (let cur = p; ; cur = path.dirname(cur)) {
331
+ let st = null;
332
+ try { st = fs.statSync(cur); } catch (e) { /* not there yet */ }
333
+ if (st && st.dev === me.dev && st.ino === me.ino) return true;
334
+ if (path.dirname(cur) === cur) return false;
335
+ }
336
+ };
337
+ const inside = 'the backup can\'t go inside the folder it backs up. Choose another place with --to.';
338
+ if (within(parent)) fail(inside);
339
+ try { fs.mkdirSync(parent, { recursive: true }); } catch (e) { fail('couldn\'t create "' + parent + '": ' + e.message); }
340
+ const real = fs.realpathSync(parent);
341
+ if (within(real)) fail(inside);
342
+ let dest = path.join(real, path.basename(src) + ' backup ' + stamp(new Date()));
343
+ for (let n = 2; ; n++) {
344
+ try { fs.mkdirSync(dest); break; } catch (e) { if (e.code !== 'EEXIST') throw e; }
345
+ dest = path.join(real, path.basename(src) + ' backup ' + stamp(new Date()) + ' (' + n + ')');
346
+ }
347
+ if (within(dest)) fail(inside);
348
+ const tally = { files: 0, bytes: 0, links: 0, tmp: 0 };
349
+ const NOFOLLOW = fs.constants.O_NOFOLLOW || 0;
350
+ (function copy(from, to) {
351
+ for (const e of fs.readdirSync(from, { withFileTypes: true })) {
352
+ const a = path.join(from, e.name), b = path.join(to, e.name), st = fs.lstatSync(a);
353
+ if (st.isSymbolicLink()) { tally.links++; continue; }
354
+ if (st.isDirectory()) { fs.mkdirSync(b); copy(a, b); continue; }
355
+ if (!st.isFile()) continue;
356
+ if (/\.tmp$/i.test(e.name)) { tally.tmp++; continue; }
357
+ /* Opened without following a link, and checked to be the file just looked at, so a file swapped for a link meanwhile
358
+ is skipped rather than copied from wherever it points. */
359
+ let fd;
360
+ try { fd = fs.openSync(a, fs.constants.O_RDONLY | NOFOLLOW); } catch (err) { if (err.code === 'ELOOP') { tally.links++; continue; } throw err; }
361
+ try {
362
+ const f = fs.fstatSync(fd);
363
+ if (!f.isFile() || f.ino !== st.ino || f.dev !== st.dev) { tally.links++; continue; }
364
+ const data = fs.readFileSync(fd);
365
+ fs.writeFileSync(b, data, { flag: 'wx' });
366
+ tally.files++; tally.bytes += data.length;
367
+ } finally { fs.closeSync(fd); }
368
+ }
369
+ })(src, dest);
370
+ return Object.assign(tally, { dest });
371
+ }
372
+ function size(n) { return n < 1024 ? n + ' bytes' : n < 1048576 ? (n / 1024).toFixed(1) + ' KB' : (n / 1048576).toFixed(1) + ' MB'; }
69
373
 
70
374
  const HELP = `flightdeck: team process checklists, in one HTML file
71
375
 
72
- flightdeck open open the app (it starts on built-in examples)
73
- flightdeck install <team folder> copy the app into the team folder as ${NAME}
74
- flightdeck skills <team folder> add or update the AI assistant skills in <team folder>/.flightdeck/skills
75
- flightdeck skills --to <folder> add or update them in any other folder (an assistant's skills folder, a repo)
76
- flightdeck path print where the app file is
77
- flightdeck version print this version
376
+ flightdeck open open the app (it starts on built-in examples)
377
+ flightdeck install <team folder> copy the app into the team folder as ${NAME}, and add or update the
378
+ AI assistant contract there: AGENTS.md and .flightdeck/skills
379
+ flightdeck skills <team folder> add or update only the skills, in <team folder>/.flightdeck/skills
380
+ flightdeck skills --to <folder> add or update them in any other folder (an assistant's skills folder, a repo)
381
+ flightdeck backup <team folder> copy the whole folder to ~/Flightdeck backups (or --to <folder>)
382
+ flightdeck path print where the app file is
383
+ flightdeck version print this version
384
+
385
+ --check say what install or skills would do, and change nothing. Exit code 0: nothing to do;
386
+ 2: updates to install; 3: files your team changed that need merging; 1: an error
387
+ --app-only install only ${NAME}, not AGENTS.md or the skills
388
+ --force replace a newer app, an unrecognized file, or contract files your team changed
389
+
390
+ Every file install replaces is first copied to .flightdeck/updates/<version>/replaced/ and read back.
78
391
 
79
- --force replace a newer app, or skill files someone changed`;
392
+ Files your team changed are never replaced without --force. When this version also changed them, the new
393
+ default goes to .flightdeck/updates/<version>/, with a README.md to hand to your AI assistant.`;
80
394
 
81
395
  function main(argv) {
396
+ const out = [];
82
397
  const ti = argv.indexOf('--to'), to = ti >= 0 ? argv[ti + 1] : null;
83
398
  if (ti >= 0 && (!to || to.startsWith('--'))) fail('say which folder after --to, for example: flightdeck skills --to ~/skills');
84
- const args = argv.filter((a, i) => !a.startsWith('--') && !(ti >= 0 && i === ti + 1)), force = argv.includes('--force');
399
+ const args = argv.filter((a, i) => !a.startsWith('--') && !(ti >= 0 && i === ti + 1));
400
+ const force = argv.includes('--force'), check = argv.includes('--check'), appOnly = argv.includes('--app-only');
85
401
  const cmd = args[0];
86
- if (!cmd || cmd === 'help' || argv.includes('--help')) { process.stdout.write(HELP + '\n'); return; }
87
- if (cmd === 'version' || argv.includes('--version')) { process.stdout.write((versionOf(app()) || 'unknown') + '\n'); return; }
88
- if (cmd === 'path') { app(); process.stdout.write(APP + '\n'); return; }
402
+ if (!cmd || cmd === 'help' || argv.includes('--help')) return { out: HELP, code: 0 };
403
+ if (cmd === 'version' || argv.includes('--version')) return { out: versionOf(app()) || 'unknown', code: 0 };
404
+ if (cmd === 'path') { app(); return { out: APP, code: 0 }; }
89
405
  if (cmd === 'install') {
90
- const dir = folderArg(args[1]), html = app(), mine = versionOf(html), dest = path.join(dir, NAME), old = path.join(dir, OLD);
91
- mayReplace(dest, mine, force);
406
+ const dir = folderArg(args[1]), html = app(), mine = versionOf(html);
92
407
  /* Before 1.9.0 the app was preflight.html. If the folder still has it, update it too: people who open that file keep
93
408
  their browser-saved state (it's tied to the file's address), and older copies looking for it see the update. */
94
- const keepOld = fs.existsSync(old);
95
- if (keepOld) mayReplace(old, mine, force);
96
- safeWrite(dir, [NAME], html);
97
- if (keepOld) safeWrite(dir, [OLD], html);
98
- process.stdout.write('Installed Flightdeck ' + mine + ' at ' + dest + (keepOld ? '\nAlso updated ' + old + ' (the same app under its old name)' : '') + '\nOpen it in Edge or Chrome, choose Open team folder, and pick ' + dir + '\n');
99
- return;
409
+ const keepOld = inside(dir, [OLD]) !== null;
410
+ const appS = appStatus(dir, NAME, html, mine, force), oldS = keepOld ? appStatus(dir, OLD, html, mine, force) : { status: 'current' };
411
+ const appSt = appS.status, oldSt = oldS.status;
412
+ const all = examples(html), t = { dir, updates: ['.flightdeck', 'updates'], recordAt: ['.flightdeck', 'installed.json'], version: mine, home: 'the team folder' };
413
+ const files = appOnly ? [] : [AGENTS].concat(Object.keys(all).filter((p) => p.startsWith(SKILLS + '/'))).filter((k) => all[k] != null).map((k) => ({ key: k, rel: k, text: all[k] }));
414
+ if (check) {
415
+ const items = appOnly ? [] : contract(t, files, { html, force, check });
416
+ out.push('Flightdeck ' + mine + ' in ' + dir + ':');
417
+ if (appSt !== 'current') out.push(' ' + (appSt === 'new' ? 'will be added' : 'update available').padEnd(28) + NAME);
418
+ if (oldSt !== 'current') out.push(' ' + 'update available'.padEnd(28) + OLD);
419
+ report(items, true, out);
420
+ notes(t, out);
421
+ const code = exitFor([appSt === 'current' ? 'current' : 'update', oldSt === 'current' ? 'current' : 'update'].concat(items.map((i) => i.status)));
422
+ if (code === EXIT.none) out.push(' Nothing to do.');
423
+ if (code === EXIT.merge) out.push('Files marked "needs merge" go to .flightdeck/updates/' + mine + '/ when you install.');
424
+ return { out: out.join('\n'), code };
425
+ }
426
+ /* Everything is checked before anything is written, so a refusal (a link, say) leaves the folder as it was. */
427
+ if (!appOnly) contract(t, files, { html, force, check: true });
428
+ t.changed = [];
429
+ [[NAME, appS], [OLD, oldS]].forEach(([file, st]) => {
430
+ if (st.status === 'current' || (file === OLD && !keepOld)) return;
431
+ if (st.text !== null) keepCopy(t, [file], st.text);
432
+ try { safeWrite(dir, [file], html, st.text); } catch (e) { if (!(e instanceof Changed)) throw e; t.changed.push(file); }
433
+ });
434
+ const appChanged = t.changed.slice();
435
+ const items = appOnly ? [] : contract(t, files, { html, force });
436
+ t.changed = appChanged.concat(t.changed || []);
437
+ out.push('Installed Flightdeck ' + mine + ' at ' + path.join(dir, NAME) + (keepOld ? '\nAlso updated ' + path.join(dir, OLD) + ' (the same app under its old name)' : ''));
438
+ if (!appOnly) {
439
+ out.push(items.every((i) => i.status === 'current') ? 'AGENTS.md and the skills are up to date.' : 'AGENTS.md and the skills:');
440
+ report(items, false, out);
441
+ if (items.some((i) => i.status === 'merge')) out.push('Your team changed files this version changes too. The new defaults are in ' + path.join(dir, '.flightdeck', 'updates', mine) + '; its README.md has a prompt for your AI assistant to merge them.');
442
+ }
443
+ if (t.replacedAt) out.push('Copies of the files it replaced are in ' + path.join(dir, ...t.replacedAt));
444
+ notes(t, out);
445
+ out.push('Open ' + NAME + ' in Edge or Chrome, choose Open team folder, and pick ' + dir);
446
+ return { out: out.join('\n'), code: t.changed.length ? EXIT.error : 0 };
100
447
  }
101
448
  if (cmd === 'skills') {
102
449
  /* Without --to the skills go in <team folder>/.flightdeck/skills. With --to they go straight into that folder, which
103
- is created if it's missing. Either way nothing is written through a link below it. */
104
- let dir, base;
450
+ is created if it's missing; its record and updates sit beside the skills, in hidden names an assistant won't load. */
451
+ const html = app(), mine = versionOf(html);
452
+ let t;
105
453
  if (to) {
106
- const t = expand(to);
107
- try { fs.mkdirSync(t, { recursive: true }); } catch (e) { fail('couldn\'t create "' + t + '": ' + e.message); }
108
- dir = folderArg(t); base = [];
454
+ const target = expand(to);
455
+ if (check && !fs.existsSync(target)) {
456
+ const n = skillFiles(html).length;
457
+ return { out: n + ' skill files will be added to ' + target, code: EXIT.updates };
458
+ }
459
+ try { fs.mkdirSync(target, { recursive: true }); } catch (e) { fail('couldn\'t create "' + target + '": ' + e.message); }
460
+ t = { dir: folderArg(target, 'skills --to'), updates: ['.flightdeck-updates'], recordAt: ['.flightdeck-installed.json'], version: mine, home: 'the skills folder' };
109
461
  } else {
110
- dir = folderArg(args[1]); base = SKILLS.split('/');
462
+ t = { dir: folderArg(args[1], 'skills'), updates: ['.flightdeck', 'updates'], recordAt: ['.flightdeck', 'installed.json'], version: mine, home: 'the team folder' };
111
463
  }
112
- const files = skillFiles(app()), kept = [];
113
- let written = 0;
114
- for (const f of files) {
115
- const parts = base.concat(f.path.split('/'));
116
- if (parts.some((x) => !x || x === '.' || x === '..')) fail('refusing an unexpected skill path: ' + f.path);
117
- const dest = path.join(dir, ...parts);
118
- let st = null;
119
- try { st = fs.lstatSync(dest); } catch (e) { /* not there yet */ }
120
- if (st && st.isSymbolicLink()) fail('refusing to write through a link: ' + dest);
121
- if (st) {
122
- if (fs.readFileSync(dest, 'utf8') === f.text) continue;
123
- if (!force) { kept.push(f.path); continue; }
124
- }
125
- safeWrite(dir, parts, f.text);
126
- written++;
464
+ /* Records and the updates folder name files relative to the folder they're in; the baselines always use the team folder's names. */
465
+ const files = skillFiles(html).map((f) => ({ key: to ? f.path : SKILLS + '/' + f.path, rel: to ? f.path : SKILLS + '/' + f.path, baseKey: SKILLS + '/' + f.path, text: f.text }));
466
+ const items = contract(t, files, { html, force, check });
467
+ const where = to ? t.dir : path.join(t.dir, ...SKILLS.split('/'));
468
+ if (check) {
469
+ out.push('Skills in ' + where + ':');
470
+ report(items, true, out);
471
+ notes(t, out);
472
+ const code = exitFor(items.map((i) => i.status));
473
+ if (code === EXIT.none) out.push(' Nothing to do.');
474
+ return { out: out.join('\n'), code };
127
475
  }
128
- process.stdout.write(written + ' skill file' + (written === 1 ? '' : 's') + ' written to ' + path.join(dir, ...base) + '\n');
129
- if (kept.length) process.stdout.write('Left as they are (changed in the folder; use --force to replace):\n' + kept.map((p) => ' ' + p).join('\n') + '\n');
130
- return;
476
+ const written = items.filter((i) => ['update', 'new', 'missing', 'forced'].includes(i.status)).length;
477
+ out.push(written + ' skill file' + (written === 1 ? '' : 's') + ' written to ' + where);
478
+ report(items.filter((i) => i.status !== 'update' && i.status !== 'new'), false, out);
479
+ if (t.replacedAt) out.push('Copies of the files it replaced are in ' + path.join(t.dir, ...t.replacedAt));
480
+ if (items.some((i) => i.status === 'merge')) out.push('Some files you changed were also changed in this version. The new defaults are in ' + path.join(t.dir, ...t.updates, mine) + ', with a README.md for your AI assistant. Use --force to replace your versions instead.');
481
+ notes(t, out);
482
+ return { out: out.join('\n'), code: t.changed.length ? EXIT.error : 0 };
483
+ }
484
+ if (cmd === 'backup') {
485
+ const dir = folderArg(args[1], 'backup'), r = backup(dir, to);
486
+ out.push('Backed up ' + r.files + ' file' + (r.files === 1 ? '' : 's') + ' (' + size(r.bytes) + ') to ' + r.dest);
487
+ 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
+ return { out: out.join('\n'), code: 0 };
131
489
  }
132
490
  if (cmd === 'open') {
133
491
  app();
134
492
  /* The path is passed as data to a launcher, never through a shell (cmd would expand %VARIABLES% in it). */
135
493
  const [bin, ...rest] = process.platform === 'darwin' ? ['open', APP] : process.platform === 'win32' ? ['explorer.exe', APP] : ['xdg-open', APP];
136
494
  const child = spawn(bin, rest, { stdio: 'ignore', detached: true });
137
- child.on('error', () => fail('couldn\'t open a browser. Open this file in Edge or Chrome: ' + APP));
495
+ child.on('error', () => { process.stderr.write('flightdeck: couldn\'t open a browser. Open this file in Edge or Chrome: ' + APP + '\n'); process.exitCode = 1; });
138
496
  child.unref();
139
- return;
497
+ return { out: '', code: 0 };
140
498
  }
141
499
  fail('unknown command "' + cmd + '". Try: flightdeck help');
142
500
  }
143
501
 
144
- if (require.main === module) main(process.argv.slice(2));
145
- module.exports = { main, versionOf, cmp, skillFiles };
502
+ if (require.main === module) {
503
+ try {
504
+ const r = main(process.argv.slice(2));
505
+ if (r.out) process.stdout.write(r.out + '\n');
506
+ process.exitCode = r.code;
507
+ } catch (e) {
508
+ process.stderr.write('flightdeck: ' + (e instanceof Refused ? e.message : (e && e.message) || String(e)) + '\n');
509
+ process.exitCode = EXIT.error;
510
+ }
511
+ }
512
+ module.exports = { main, versionOf, cmp, skillFiles, norm, region, managed, merged, hash, baselineKey, examples };