@troylar/flightdeck 1.14.0 → 1.16.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 +64 -0
- package/README.md +48 -11
- package/bin/flightdeck.js +448 -81
- package/dist/flightdeck.html +3826 -238
- package/package.json +1 -1
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
|
|
4
|
-
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
function
|
|
38
|
-
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
|
73
|
-
flightdeck install <team folder>
|
|
74
|
-
|
|
75
|
-
flightdeck skills
|
|
76
|
-
flightdeck
|
|
77
|
-
flightdeck
|
|
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
|
-
|
|
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))
|
|
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')) {
|
|
87
|
-
if (cmd === 'version' || argv.includes('--version')) {
|
|
88
|
-
if (cmd === 'path') { app();
|
|
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)
|
|
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 =
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
104
|
-
|
|
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
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
462
|
+
t = { dir: folderArg(args[1], 'skills'), updates: ['.flightdeck', 'updates'], recordAt: ['.flightdeck', 'installed.json'], version: mine, home: 'the team folder' };
|
|
111
463
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
if (
|
|
122
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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', () =>
|
|
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)
|
|
145
|
-
|
|
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 };
|