qa-engineer 0.10.0 → 0.11.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/README.md +167 -59
- package/package.json +1 -1
- package/packages/engine/bin/qa-engine.mjs +232 -8
- package/packages/engine/lib/analysis/har.mjs +34 -0
- package/packages/engine/lib/analysis/network.mjs +237 -0
- package/packages/engine/lib/analysis/report-html.mjs +47 -734
- package/packages/engine/lib/artifacts/manager.mjs +453 -0
- package/packages/engine/lib/artifacts/mime.mjs +109 -0
- package/packages/engine/lib/artifacts/zip-write.mjs +143 -0
- package/packages/engine/lib/report/components/charts.mjs +424 -0
- package/packages/engine/lib/report/components/evidence.mjs +207 -0
- package/packages/engine/lib/report/components/findings.mjs +258 -0
- package/packages/engine/lib/report/components/nav.mjs +99 -0
- package/packages/engine/lib/report/components/primitives.mjs +246 -0
- package/packages/engine/lib/report/components/runtime.mjs +246 -0
- package/packages/engine/lib/report/components/timeline.mjs +65 -0
- package/packages/engine/lib/report/core/model.mjs +270 -0
- package/packages/engine/lib/report/core/normalize.mjs +226 -0
- package/packages/engine/lib/report/core/sections.mjs +978 -0
- package/packages/engine/lib/report/export/bundle.mjs +293 -0
- package/packages/engine/lib/report/export/html.mjs +183 -0
- package/packages/engine/lib/report/export/machine.mjs +290 -0
- package/packages/engine/lib/report/export/markdown.mjs +323 -0
- package/packages/engine/lib/report/schemas/qa-report.schema.json +555 -0
- package/packages/engine/lib/report/theme/css.mjs +529 -0
- package/packages/engine/lib/report/theme/tokens.mjs +137 -0
- package/packages/engine/lib/report/version.mjs +78 -0
- package/packages/engine/package.json +2 -2
- package/packages/installer/lib/agents/targets.mjs +90 -0
- package/packages/installer/lib/agents/user-level.mjs +80 -0
- package/packages/installer/lib/cli/flags.mjs +20 -2
- package/packages/installer/lib/commands/doctor.mjs +6 -4
- package/packages/installer/lib/commands/install.mjs +134 -93
- package/packages/installer/lib/commands/repair.mjs +10 -6
- package/packages/installer/lib/commands/self-test.mjs +4 -3
- package/packages/installer/lib/commands/uninstall.mjs +14 -8
- package/packages/installer/lib/commands/update.mjs +9 -4
- package/packages/installer/lib/commands/verify.mjs +13 -12
- package/packages/installer/lib/constants.mjs +13 -0
- package/packages/installer/lib/core/conflict.mjs +5 -4
- package/packages/installer/lib/core/fs-safe.mjs +146 -6
- package/packages/installer/lib/core/integrity.mjs +59 -0
- package/packages/installer/lib/core/lockfile.mjs +19 -3
- package/packages/installer/lib/core/plan.mjs +213 -0
- package/packages/installer/lib/core/qa-home.mjs +145 -0
- package/packages/installer/lib/core/scope.mjs +274 -0
- package/packages/installer/lib/core/validate-install.mjs +37 -12
- package/packages/installer/package.json +1 -1
- package/packages/installer/schemas/qa-lock.schema.json +119 -21
- package/shared/tooling/qa-tool.mjs +41 -5
- package/skills/qa-api/scripts/qa-tool.mjs +41 -5
- package/skills/qa-audit/scripts/qa-tool.mjs +41 -5
- package/skills/qa-debug/scripts/qa-tool.mjs +41 -5
- package/skills/qa-explore/SKILL.md +26 -10
- package/skills/qa-explore/contracts/explore-result.schema.json +517 -11
- package/skills/qa-explore/references/api-replay.md +40 -1
- package/skills/qa-explore/references/report-pipeline.md +265 -95
- package/skills/qa-explore/scripts/qa-tool.mjs +41 -5
- package/skills/qa-fix/scripts/qa-tool.mjs +41 -5
- package/skills/qa-flaky/scripts/qa-tool.mjs +41 -5
- package/skills/qa-init/scripts/qa-tool.mjs +41 -5
- package/skills/qa-report/scripts/qa-tool.mjs +41 -5
- package/skills/qa-run/scripts/qa-tool.mjs +41 -5
|
@@ -109,6 +109,80 @@ function ensureDir(dir, createdDirs) {
|
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
/**
|
|
113
|
+
* The target of a symlink at `abs`, or null when there is no link there.
|
|
114
|
+
*
|
|
115
|
+
* `lstat` rather than `exists`, because a link pointing at something that has been
|
|
116
|
+
* deleted still exists as a link and still has to be replaced rather than written over.
|
|
117
|
+
*/
|
|
118
|
+
export function readLinkTarget(abs) {
|
|
119
|
+
try {
|
|
120
|
+
const stat = fs.lstatSync(abs);
|
|
121
|
+
if (!stat.isSymbolicLink()) return null;
|
|
122
|
+
return fs.readlinkSync(abs);
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Create a directory link, choosing the kind the platform will allow.
|
|
130
|
+
*
|
|
131
|
+
* On Windows a symbolic link needs either administrator rights or Developer Mode, while
|
|
132
|
+
* a *junction* needs neither and behaves the same for reading a directory. Preferring a
|
|
133
|
+
* junction there is the difference between a global install that works for every Windows
|
|
134
|
+
* developer and one that works only for those running an elevated shell.
|
|
135
|
+
*/
|
|
136
|
+
export function createLink(abs, target) {
|
|
137
|
+
const type = process.platform === 'win32' ? 'junction' : 'dir';
|
|
138
|
+
fs.symlinkSync(target, abs, type);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Can this filesystem hold a directory link at all?
|
|
143
|
+
*
|
|
144
|
+
* Asked once, before planning, rather than discovered halfway through a transaction.
|
|
145
|
+
* The answer is no more often than it looks: a FAT- or exFAT-formatted volume supports
|
|
146
|
+
* neither symlinks nor junctions, some container and network mounts refuse them, and a
|
|
147
|
+
* Windows host without Developer Mode refuses symlinks (which is why `createLink` asks
|
|
148
|
+
* for a junction there instead).
|
|
149
|
+
*
|
|
150
|
+
* When the answer is no the installer copies instead. A global install that fails
|
|
151
|
+
* outright on a USB stick would be a worse tool than one that uses more disk.
|
|
152
|
+
*/
|
|
153
|
+
export function canLink(root) {
|
|
154
|
+
const probe = path.join(root, `.qa-linkprobe-${process.pid}`);
|
|
155
|
+
const target = path.join(root, `.qa-linktarget-${process.pid}`);
|
|
156
|
+
try {
|
|
157
|
+
fs.mkdirSync(target, { recursive: true });
|
|
158
|
+
createLink(probe, target);
|
|
159
|
+
const ok = readLinkTarget(probe) !== null;
|
|
160
|
+
fs.unlinkSync(probe);
|
|
161
|
+
fs.rmdirSync(target);
|
|
162
|
+
return ok;
|
|
163
|
+
} catch {
|
|
164
|
+
// Clean up whatever got as far as existing, then report no.
|
|
165
|
+
try {
|
|
166
|
+
if (readLinkTarget(probe) !== null) fs.unlinkSync(probe);
|
|
167
|
+
} catch { /* nothing to undo */ }
|
|
168
|
+
try {
|
|
169
|
+
if (fs.existsSync(target)) fs.rmdirSync(target);
|
|
170
|
+
} catch { /* nothing to undo */ }
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** True when the file already holds exactly these bytes. */
|
|
176
|
+
function sameContent(file, content) {
|
|
177
|
+
try {
|
|
178
|
+
const stat = fs.statSync(file);
|
|
179
|
+
if (!stat.isFile() || stat.size !== content.length) return false;
|
|
180
|
+
return fs.readFileSync(file).equals(content);
|
|
181
|
+
} catch {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
112
186
|
/** Write content to file via a temp file + rename, so readers never see a partial write. */
|
|
113
187
|
function atomicWrite(file, content) {
|
|
114
188
|
const tmp = `${file}.qa-tmp-${process.pid}`;
|
|
@@ -134,6 +208,24 @@ export class Transaction {
|
|
|
134
208
|
this.ops.push({ kind: 'delete', rel: relPath });
|
|
135
209
|
}
|
|
136
210
|
|
|
211
|
+
/**
|
|
212
|
+
* Stage a symbolic link at `relPath` pointing at `targetAbs`.
|
|
213
|
+
*
|
|
214
|
+
* Links are how a global install serves an agent without copying the skills again.
|
|
215
|
+
* They go through the transaction rather than around it so a half-linked install
|
|
216
|
+
* rolls back like a half-written one — and because replacing a link silently would
|
|
217
|
+
* lose where the old one pointed.
|
|
218
|
+
*
|
|
219
|
+
* Only a link may be replaced by a link. A real directory at the target path belongs
|
|
220
|
+
* to someone else, and the conflict detector is what decides whether the user wants it
|
|
221
|
+
* overwritten; reaching here with one is a bug, so it throws rather than deleting a
|
|
222
|
+
* directory nobody agreed to lose.
|
|
223
|
+
*/
|
|
224
|
+
link(relPath, targetAbs) {
|
|
225
|
+
resolveInside(this.root, relPath);
|
|
226
|
+
this.ops.push({ kind: 'link', rel: relPath, target: targetAbs });
|
|
227
|
+
}
|
|
228
|
+
|
|
137
229
|
/** Directories the transaction will need to write into, deduplicated. */
|
|
138
230
|
targetDirs() {
|
|
139
231
|
const dirs = new Set();
|
|
@@ -169,6 +261,8 @@ export class Transaction {
|
|
|
169
261
|
const created = []; // files that did not exist before (remove on rollback)
|
|
170
262
|
const createdDirs = []; // directories we made (remove on rollback if empty)
|
|
171
263
|
const backups = []; // { abs, backup } for files that existed (restore on rollback)
|
|
264
|
+
let unchanged = 0; // already byte-identical: neither written nor backed up
|
|
265
|
+
const replacedLinks = []; // { abs, target } symlinks removed (recreate on rollback)
|
|
172
266
|
let backedUp = 0;
|
|
173
267
|
|
|
174
268
|
const backup = (abs, rel) => {
|
|
@@ -185,21 +279,65 @@ export class Transaction {
|
|
|
185
279
|
// other code, and containment is cheap to reassert.
|
|
186
280
|
const abs = resolveInside(this.root, op.rel);
|
|
187
281
|
if (op.kind === 'write') {
|
|
188
|
-
if (fs.existsSync(abs))
|
|
189
|
-
|
|
282
|
+
if (fs.existsSync(abs)) {
|
|
283
|
+
// Reinstalling identical content is the common case for update and repair.
|
|
284
|
+
// Backing it up copies the whole install into a dated directory on every
|
|
285
|
+
// run — three copies of the engine after two commands — and restoring a
|
|
286
|
+
// byte-identical file on rollback achieves nothing.
|
|
287
|
+
if (sameContent(abs, op.content)) {
|
|
288
|
+
unchanged += 1;
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
backup(abs, op.rel);
|
|
292
|
+
} else {
|
|
293
|
+
created.push(abs);
|
|
294
|
+
}
|
|
190
295
|
ensureDir(path.dirname(abs), createdDirs);
|
|
191
296
|
atomicWrite(abs, op.content);
|
|
192
297
|
} else if (op.kind === 'delete') {
|
|
193
|
-
|
|
298
|
+
// A link is deleted by unlinking it, and "backed up" by remembering where it
|
|
299
|
+
// pointed. Copying it would follow the link and try to copy a directory as a
|
|
300
|
+
// file, which is how uninstall crashed on a global install.
|
|
301
|
+
const linkTarget = readLinkTarget(abs);
|
|
302
|
+
if (linkTarget !== null) {
|
|
303
|
+
replacedLinks.push({ abs, target: linkTarget });
|
|
304
|
+
fs.unlinkSync(abs);
|
|
305
|
+
} else if (fs.existsSync(abs)) {
|
|
194
306
|
backup(abs, op.rel);
|
|
195
307
|
fs.rmSync(abs);
|
|
196
308
|
}
|
|
309
|
+
} else if (op.kind === 'link') {
|
|
310
|
+
const existing = readLinkTarget(abs);
|
|
311
|
+
if (existing !== null) {
|
|
312
|
+
replacedLinks.push({ abs, target: existing });
|
|
313
|
+
fs.unlinkSync(abs);
|
|
314
|
+
} else if (fs.existsSync(abs)) {
|
|
315
|
+
throw environmentError(
|
|
316
|
+
`refusing to replace ${abs} with a link: it is a real file or directory, not a link ` +
|
|
317
|
+
'this installer created',
|
|
318
|
+
);
|
|
319
|
+
} else {
|
|
320
|
+
created.push(abs);
|
|
321
|
+
}
|
|
322
|
+
ensureDir(path.dirname(abs), createdDirs);
|
|
323
|
+
createLink(abs, op.target);
|
|
197
324
|
}
|
|
198
325
|
}
|
|
199
326
|
} catch (error) {
|
|
200
|
-
// Roll back: remove created files, restore backups
|
|
327
|
+
// Roll back: remove created files and links, restore backups and replaced links,
|
|
328
|
+
// drop created dirs.
|
|
201
329
|
for (const abs of created) {
|
|
202
|
-
if (
|
|
330
|
+
if (readLinkTarget(abs) !== null) fs.unlinkSync(abs);
|
|
331
|
+
else if (fs.existsSync(abs)) fs.rmSync(abs, { force: true });
|
|
332
|
+
}
|
|
333
|
+
for (const { abs, target } of replacedLinks) {
|
|
334
|
+
if (!fs.existsSync(abs) && readLinkTarget(abs) === null) {
|
|
335
|
+
try {
|
|
336
|
+
createLink(abs, target);
|
|
337
|
+
} catch {
|
|
338
|
+
/* best effort: the original error is what matters */
|
|
339
|
+
}
|
|
340
|
+
}
|
|
203
341
|
}
|
|
204
342
|
for (const { abs, backup: from } of backups) {
|
|
205
343
|
fs.copyFileSync(from, abs);
|
|
@@ -215,8 +353,10 @@ export class Transaction {
|
|
|
215
353
|
}
|
|
216
354
|
|
|
217
355
|
return {
|
|
218
|
-
written: this.ops.filter((o) => o.kind === 'write').length,
|
|
356
|
+
written: this.ops.filter((o) => o.kind === 'write').length - unchanged,
|
|
357
|
+
unchanged,
|
|
219
358
|
deleted: this.ops.filter((o) => o.kind === 'delete').length,
|
|
359
|
+
linked: this.ops.filter((o) => o.kind === 'link').length,
|
|
220
360
|
backedUp,
|
|
221
361
|
backupDir: backedUp > 0 ? this.backupDir : null,
|
|
222
362
|
dryRun: false,
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// What a lockfile entry should hash to on disk, whatever kind of entry it is.
|
|
2
|
+
//
|
|
3
|
+
// Four commands independently asked "does this file still match its recorded hash?" —
|
|
4
|
+
// validate, verify, uninstall, and the conflict detector — and each did it by calling
|
|
5
|
+
// `hashFile` directly. That was correct while every entry was a file. The moment a
|
|
6
|
+
// global install started recording links, all four tried to read a directory and threw
|
|
7
|
+
// EISDIR, which is a crash rather than a diagnosis.
|
|
8
|
+
//
|
|
9
|
+
// So the question moves here and is asked once. A link's identity is *where it points*,
|
|
10
|
+
// not what is behind it: hashing the target means `verify` catches a link repointed at
|
|
11
|
+
// something else, and does not report drift merely because the canonical skill it points
|
|
12
|
+
// at was legitimately updated.
|
|
13
|
+
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
|
|
17
|
+
import { hashFile, hashString } from './hash.mjs';
|
|
18
|
+
import { readLinkTarget } from './fs-safe.mjs';
|
|
19
|
+
import { toPosix } from './paths.mjs';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The digest of an installed entry, or `null` when it is not there.
|
|
23
|
+
*
|
|
24
|
+
* `null` means missing, which every caller already treats as drift — so a link whose
|
|
25
|
+
* path now holds a real directory reads as drift rather than as a match, which is what
|
|
26
|
+
* it is.
|
|
27
|
+
*/
|
|
28
|
+
export function entryDigest(root, entry) {
|
|
29
|
+
const absolute = path.join(root, entry.path);
|
|
30
|
+
|
|
31
|
+
if (entry.owner === 'link') {
|
|
32
|
+
const target = readLinkTarget(absolute);
|
|
33
|
+
if (target === null) return null;
|
|
34
|
+
// Relative to the scope root, so a lockfile stays valid when the whole tree moves —
|
|
35
|
+
// a home directory that changes name should not invalidate every link in it.
|
|
36
|
+
return hashString(toPosix(path.relative(root, path.resolve(path.dirname(absolute), target))));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!fs.existsSync(absolute)) return null;
|
|
40
|
+
try {
|
|
41
|
+
return hashFile(absolute);
|
|
42
|
+
} catch {
|
|
43
|
+
// A path that exists but cannot be read as a file — a directory where a file was
|
|
44
|
+
// recorded — is drift, not a crash.
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** True when the entry on disk matches what the lockfile recorded. */
|
|
50
|
+
export function entryMatches(root, entry) {
|
|
51
|
+
const digest = entryDigest(root, entry);
|
|
52
|
+
return digest !== null && digest === entry.sha256;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** True when anything at all occupies the entry's path, link or file. */
|
|
56
|
+
export function entryPresent(root, entry) {
|
|
57
|
+
const absolute = path.join(root, entry.path);
|
|
58
|
+
return fs.existsSync(absolute) || readLinkTarget(absolute) !== null;
|
|
59
|
+
}
|
|
@@ -20,8 +20,19 @@ export function lockPath(projectRoot) {
|
|
|
20
20
|
return path.join(projectRoot, LOCKFILE);
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
/**
|
|
24
|
+
* The lockfile for a scope.
|
|
25
|
+
*
|
|
26
|
+
* A global install keeps its lockfile inside `~/.qa-engineer/`, never loose in the home
|
|
27
|
+
* directory — the whole point of owning a directory is that everything the tool wrote is
|
|
28
|
+
* in one place a user can inspect and delete.
|
|
29
|
+
*/
|
|
30
|
+
export function scopeLockPath(scope) {
|
|
31
|
+
return path.join(scope.root, scope.lockfile);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function readLock(projectRoot, relative = LOCKFILE) {
|
|
35
|
+
const file = path.join(projectRoot, relative);
|
|
25
36
|
if (!fs.existsSync(file)) return null;
|
|
26
37
|
let raw;
|
|
27
38
|
try {
|
|
@@ -40,12 +51,17 @@ export function readLock(projectRoot) {
|
|
|
40
51
|
* Assemble a lockfile object. `now` is injected so tests are deterministic;
|
|
41
52
|
* the CLI passes the real time.
|
|
42
53
|
*/
|
|
43
|
-
export function buildLock({ agents, files, now }) {
|
|
54
|
+
export function buildLock({ agents, files, now, scope = null }) {
|
|
44
55
|
const lock = {
|
|
45
56
|
lockfileVersion: LOCKFILE_VERSION,
|
|
46
57
|
pack: { name: PACK_NAME, version: VERSION, specRevision: SPEC_REVISION },
|
|
47
58
|
installer: VERSION,
|
|
48
59
|
generatedAt: now,
|
|
60
|
+
// Recorded so every later command knows what it is operating on without being told
|
|
61
|
+
// again. Omitted for a project install, which is what a lockfile without one means.
|
|
62
|
+
...(scope && scope.kind !== 'project'
|
|
63
|
+
? { scope: { kind: scope.kind, qaRoot: scope.qaRootRelative, sharedEngine: scope.shareEngine } }
|
|
64
|
+
: {}),
|
|
49
65
|
agents: agents.map((a) => ({
|
|
50
66
|
id: a.id,
|
|
51
67
|
name: a.name,
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// Turning a scope into a list of files and links to create.
|
|
2
|
+
//
|
|
3
|
+
// The planner was inline in `install.mjs` and knew exactly one arrangement: copy every
|
|
4
|
+
// skill, with the engine bundled inside it, into every agent directory. That produced
|
|
5
|
+
// eighteen copies of the engine for a single install — nine bundling skills times the
|
|
6
|
+
// two discovery directories the installer writes — and there was nowhere to put a
|
|
7
|
+
// different arrangement without an `if` in the middle of the transaction.
|
|
8
|
+
//
|
|
9
|
+
// So planning is separated from executing. A scope describes *what sharing is possible*;
|
|
10
|
+
// this module turns that into a concrete plan; `install.mjs` commits it. The three modes
|
|
11
|
+
// then differ in data rather than in control flow, which is what makes a fourth mode a
|
|
12
|
+
// table entry instead of a branch.
|
|
13
|
+
//
|
|
14
|
+
// ## The two plans
|
|
15
|
+
//
|
|
16
|
+
// bundled (project) every skill carries its own engine — unchanged, and still the
|
|
17
|
+
// right answer for a repository that must work on a machine where
|
|
18
|
+
// nothing else is installed
|
|
19
|
+
// shared (global, one engine and one canonical skill tree in the scope's qaRoot;
|
|
20
|
+
// workspace) agents reach them by link, or by copy where a link cannot be made
|
|
21
|
+
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
|
|
25
|
+
import { SHARED_ENGINE_DIR, SHARED_SKILLS_STORE } from '../constants.mjs';
|
|
26
|
+
import { toPosix, listFilesRelative } from './paths.mjs';
|
|
27
|
+
import { listSkills, skillFiles, ENGINE_SOURCE } from './manifest.mjs';
|
|
28
|
+
import { bundleFilesForSkill } from './bundle.mjs';
|
|
29
|
+
import { hashBytes, hashString } from './hash.mjs';
|
|
30
|
+
import { readSkillMeta } from './skill-meta.mjs';
|
|
31
|
+
import { renderWrapper } from './wrappers.mjs';
|
|
32
|
+
|
|
33
|
+
/** A skill file that is never installed: tests are development-only. */
|
|
34
|
+
function isDevelopmentFile(rel) {
|
|
35
|
+
return rel.startsWith('tests/') || rel.includes('/tests/');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Files for the shared engine, at `<qaRoot>/engine/`.
|
|
40
|
+
*
|
|
41
|
+
* One copy. The launcher finds it by walking up for `.qa-engineer/engine`, so no skill
|
|
42
|
+
* needs to be told where it went.
|
|
43
|
+
*/
|
|
44
|
+
function planSharedEngine(sourceRoot, qaRootRelative) {
|
|
45
|
+
const engineRoot = path.join(sourceRoot, ENGINE_SOURCE);
|
|
46
|
+
const entries = [];
|
|
47
|
+
for (const rel of listFilesRelative(engineRoot)) {
|
|
48
|
+
if (rel.startsWith('test/') || rel === 'package.json') continue;
|
|
49
|
+
const content = fs.readFileSync(path.join(engineRoot, rel));
|
|
50
|
+
entries.push({
|
|
51
|
+
path: toPosix(path.join(qaRootRelative, SHARED_ENGINE_DIR, rel)),
|
|
52
|
+
sha256: hashBytes(content),
|
|
53
|
+
bytes: content.length,
|
|
54
|
+
owner: 'engine',
|
|
55
|
+
content,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
return entries;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The canonical skill tree at `<qaRoot>/skills/`, with no engine inside it. */
|
|
62
|
+
function planSkillStore(sourceRoot, skills, qaRootRelative) {
|
|
63
|
+
const entries = [];
|
|
64
|
+
for (const skill of skills) {
|
|
65
|
+
for (const rel of skillFiles(sourceRoot, skill)) {
|
|
66
|
+
if (isDevelopmentFile(rel)) continue;
|
|
67
|
+
const content = fs.readFileSync(path.join(sourceRoot, 'skills', skill, rel));
|
|
68
|
+
entries.push({
|
|
69
|
+
path: toPosix(path.join(qaRootRelative, SHARED_SKILLS_STORE, skill, rel)),
|
|
70
|
+
sha256: hashBytes(content),
|
|
71
|
+
bytes: content.length,
|
|
72
|
+
owner: 'skill',
|
|
73
|
+
skill,
|
|
74
|
+
content,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return entries;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Skill files copied into one agent directory, optionally with the engine bundled. */
|
|
82
|
+
function planSkillCopies(sourceRoot, skills, skillsDir, agentId, { bundleEngine }) {
|
|
83
|
+
const entries = [];
|
|
84
|
+
for (const skill of skills) {
|
|
85
|
+
for (const rel of skillFiles(sourceRoot, skill)) {
|
|
86
|
+
if (isDevelopmentFile(rel)) continue;
|
|
87
|
+
const content = fs.readFileSync(path.join(sourceRoot, 'skills', skill, rel));
|
|
88
|
+
entries.push({
|
|
89
|
+
path: toPosix(path.join(skillsDir, skill, rel)),
|
|
90
|
+
sha256: hashBytes(content),
|
|
91
|
+
bytes: content.length,
|
|
92
|
+
owner: 'skill',
|
|
93
|
+
skill,
|
|
94
|
+
agent: agentId,
|
|
95
|
+
content,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
if (!bundleEngine) continue;
|
|
99
|
+
for (const bundled of bundleFilesForSkill(sourceRoot, skill)) {
|
|
100
|
+
entries.push({
|
|
101
|
+
path: toPosix(path.join(skillsDir, skill, bundled.rel)),
|
|
102
|
+
sha256: hashBytes(bundled.content),
|
|
103
|
+
bytes: bundled.content.length,
|
|
104
|
+
owner: 'skill',
|
|
105
|
+
skill,
|
|
106
|
+
agent: agentId,
|
|
107
|
+
content: bundled.content,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return entries;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Slash-command wrappers, for the agents that need one to expose a skill. */
|
|
115
|
+
function planWrappers(sourceRoot, skills, agent) {
|
|
116
|
+
if (!agent.wrapperFormat || !agent.wrapperDir) return [];
|
|
117
|
+
const entries = [];
|
|
118
|
+
for (const skill of skills) {
|
|
119
|
+
const meta = readSkillMeta(path.join(sourceRoot, 'skills', skill, 'SKILL.md'));
|
|
120
|
+
if (!meta.name) continue;
|
|
121
|
+
const { filename, content } = renderWrapper(agent.wrapperFormat, meta);
|
|
122
|
+
const buffer = Buffer.from(content, 'utf8');
|
|
123
|
+
entries.push({
|
|
124
|
+
path: toPosix(path.join(agent.wrapperDir, filename)),
|
|
125
|
+
sha256: hashBytes(buffer),
|
|
126
|
+
bytes: buffer.length,
|
|
127
|
+
owner: 'wrapper',
|
|
128
|
+
skill,
|
|
129
|
+
agent: agent.id,
|
|
130
|
+
content: buffer,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return entries;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* One link per skill, from an agent's directory to the canonical tree.
|
|
138
|
+
*
|
|
139
|
+
* The lockfile needs a hash for every entry, and a link has no content to hash — so the
|
|
140
|
+
* hash is of the target path. That is not a formality: it makes `verify` able to catch a
|
|
141
|
+
* link that still exists but now points somewhere else, which is exactly what happens
|
|
142
|
+
* when a second tool claims the same directory.
|
|
143
|
+
*/
|
|
144
|
+
function planLinks(skills, skillsDir, agentId, qaRoot, root) {
|
|
145
|
+
return skills.map((skill) => {
|
|
146
|
+
const target = path.join(qaRoot, SHARED_SKILLS_STORE, skill);
|
|
147
|
+
const rel = toPosix(path.join(skillsDir, skill));
|
|
148
|
+
return {
|
|
149
|
+
path: rel,
|
|
150
|
+
sha256: hashString(toPosix(path.relative(root, target))),
|
|
151
|
+
bytes: 0,
|
|
152
|
+
owner: 'link',
|
|
153
|
+
skill,
|
|
154
|
+
agent: agentId,
|
|
155
|
+
linkTarget: target,
|
|
156
|
+
};
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Build the full plan for a scope.
|
|
162
|
+
*
|
|
163
|
+
* `targets` are agent definitions already resolved for this scope, each carrying the
|
|
164
|
+
* scope-relative `skillsDir` it wants and whether it can be served by a link.
|
|
165
|
+
*/
|
|
166
|
+
export function buildPlan({ sourceRoot, scope, targets, preferLinks = true }) {
|
|
167
|
+
const skills = listSkills(sourceRoot);
|
|
168
|
+
const files = [];
|
|
169
|
+
const links = [];
|
|
170
|
+
|
|
171
|
+
if (scope.shareEngine) {
|
|
172
|
+
files.push(...planSharedEngine(sourceRoot, scope.qaRootRelative));
|
|
173
|
+
files.push(...planSkillStore(sourceRoot, skills, scope.qaRootRelative));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
for (const agent of targets) {
|
|
177
|
+
if (scope.shareEngine && preferLinks && agent.linkable !== false) {
|
|
178
|
+
links.push(...planLinks(skills, agent.skillsDir, agent.id, scope.qaRoot, scope.root));
|
|
179
|
+
} else {
|
|
180
|
+
files.push(
|
|
181
|
+
...planSkillCopies(sourceRoot, skills, agent.skillsDir, agent.id, {
|
|
182
|
+
// A copied skill needs its own engine only when nothing is shared; with a
|
|
183
|
+
// shared engine present the launcher finds it by walking up.
|
|
184
|
+
bundleEngine: !scope.shareEngine,
|
|
185
|
+
}),
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
files.push(...planWrappers(sourceRoot, skills, agent));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return { skills, files, links };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Collapse duplicate destinations, refusing any that disagree about content.
|
|
196
|
+
*
|
|
197
|
+
* Two agents can name the same directory — Cursor and Codex both read `.agents/skills` —
|
|
198
|
+
* and writing it twice is wasted work. Two *different* contents for one path is a
|
|
199
|
+
* planning bug, and failing loudly beats letting whichever ran last win.
|
|
200
|
+
*/
|
|
201
|
+
export function dedupePlan(entries) {
|
|
202
|
+
const byPath = new Map();
|
|
203
|
+
const conflicts = [];
|
|
204
|
+
for (const entry of entries) {
|
|
205
|
+
const previous = byPath.get(entry.path);
|
|
206
|
+
if (!previous) {
|
|
207
|
+
byPath.set(entry.path, entry);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (previous.sha256 !== entry.sha256) conflicts.push(entry.path);
|
|
211
|
+
}
|
|
212
|
+
return { unique: [...byPath.values()], conflicts };
|
|
213
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// The directory QA Engineer owns on a machine, and the rules for what lives in it.
|
|
2
|
+
//
|
|
3
|
+
// ## Why a single owned directory
|
|
4
|
+
//
|
|
5
|
+
// Before this existed, installing "globally" meant pointing `--project` at `$HOME`,
|
|
6
|
+
// which scattered `qa-lock.json`, `.agents/`, and `.claude/` across the user's home
|
|
7
|
+
// directory and depended on behaviour nothing documented or tested. A tool that writes
|
|
8
|
+
// into `$HOME` directly is a tool that cannot be cleanly uninstalled, because nobody
|
|
9
|
+
// can tell which of those files it put there.
|
|
10
|
+
//
|
|
11
|
+
// So: one directory, everything inside it, and `uninstall` can remove it wholesale.
|
|
12
|
+
// The same shape Docker, the AWS CLI, and Cargo use, for the same reason.
|
|
13
|
+
//
|
|
14
|
+
// ~/.qa-engineer/
|
|
15
|
+
// engine/ one copy of the deterministic engine, shared by every skill
|
|
16
|
+
// skills/ one canonical copy of the skills
|
|
17
|
+
// config/ machine-level configuration
|
|
18
|
+
// sessions/ stored authentication, per application
|
|
19
|
+
// cache/ regenerable — safe to delete at any time
|
|
20
|
+
// logs/ diagnostics
|
|
21
|
+
// qa-lock.json what is installed, with a hash per file
|
|
22
|
+
//
|
|
23
|
+
// ## Why not XDG
|
|
24
|
+
//
|
|
25
|
+
// On Linux the tidier answer is `$XDG_DATA_HOME/qa-engineer` plus `$XDG_STATE_HOME`
|
|
26
|
+
// and `$XDG_CACHE_HOME` — three directories, three variables, three fallbacks, and a
|
|
27
|
+
// different layout on macOS and Windows. The cost is that a user who wants to see what
|
|
28
|
+
// the tool owns has to look in three places on one OS and one place on another, and
|
|
29
|
+
// that `uninstall` has three roots to get right.
|
|
30
|
+
//
|
|
31
|
+
// One dot-directory in `$HOME` is what Docker, AWS, Cargo, and Git do, works
|
|
32
|
+
// identically on all three platforms, and is overridable in full by
|
|
33
|
+
// `QA_ENGINEER_HOME` for anyone who wants it elsewhere. Cache lives inside it and is
|
|
34
|
+
// documented as safe to delete, which is the only thing XDG would really have bought.
|
|
35
|
+
//
|
|
36
|
+
// ## Directories are created on demand
|
|
37
|
+
//
|
|
38
|
+
// Only `engine/`, `skills/`, and the lockfile are written at install time. `sessions/`,
|
|
39
|
+
// `cache/`, and `logs/` are created by whoever first writes to them. An empty directory
|
|
40
|
+
// promising a feature that does not exist yet is a lie the filesystem tells forever.
|
|
41
|
+
|
|
42
|
+
import fs from 'node:fs';
|
|
43
|
+
import os from 'node:os';
|
|
44
|
+
import path from 'node:path';
|
|
45
|
+
|
|
46
|
+
import { QA_HOME_DIR_NAME, QA_HOME_ENV, LOCKFILE } from '../constants.mjs';
|
|
47
|
+
import { environmentError } from './errors.mjs';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Subdirectories of the home, and whether an install creates them.
|
|
51
|
+
*
|
|
52
|
+
* `installed: true` means the installer writes content there and `verify` expects it.
|
|
53
|
+
* Everything else appears the first time something needs it.
|
|
54
|
+
*/
|
|
55
|
+
export const HOME_LAYOUT = Object.freeze({
|
|
56
|
+
engine: { dir: 'engine', installed: true, purpose: 'the deterministic engine, shared by every skill' },
|
|
57
|
+
skills: { dir: 'skills', installed: true, purpose: 'the canonical copy of the skills' },
|
|
58
|
+
adapters: { dir: 'adapters', installed: false, purpose: 'per-agent integration state' },
|
|
59
|
+
config: { dir: 'config', installed: false, purpose: 'machine-level configuration' },
|
|
60
|
+
sessions: { dir: 'sessions', installed: false, purpose: 'stored authentication, one file per application' },
|
|
61
|
+
cache: { dir: 'cache', installed: false, purpose: 'regenerable — safe to delete at any time' },
|
|
62
|
+
logs: { dir: 'logs', installed: false, purpose: 'diagnostics' },
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The absolute path of the QA Engineer home.
|
|
67
|
+
*
|
|
68
|
+
* `QA_ENGINEER_HOME` wins when set, which is what makes the whole thing testable: the
|
|
69
|
+
* test suite points it at a temporary directory rather than touching a real `$HOME`.
|
|
70
|
+
* It is also the escape hatch for anyone whose home is on a network share or who wants
|
|
71
|
+
* the install on another volume.
|
|
72
|
+
*/
|
|
73
|
+
export function qaHome({ env = process.env, homedir = os.homedir } = {}) {
|
|
74
|
+
const override = env[QA_HOME_ENV];
|
|
75
|
+
if (override && override.trim()) {
|
|
76
|
+
const resolved = path.resolve(override.trim());
|
|
77
|
+
if (resolved === path.parse(resolved).root) {
|
|
78
|
+
// A home at `/` would make `uninstall` a catastrophe.
|
|
79
|
+
throw environmentError(`${QA_HOME_ENV} must not be a filesystem root: ${resolved}`);
|
|
80
|
+
}
|
|
81
|
+
return resolved;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// The test suite redirects the user's home wholesale; the owned directory follows it,
|
|
85
|
+
// so a global install can be proven end to end without touching a real home.
|
|
86
|
+
const home = env.QA_ENGINEER_USER_HOME?.trim() || homedir();
|
|
87
|
+
if (!home || home === path.parse(home || '/').root) {
|
|
88
|
+
throw environmentError(
|
|
89
|
+
`could not determine a home directory for the global install; set ${QA_HOME_ENV} to choose one`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
return path.join(home, QA_HOME_DIR_NAME);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Absolute path of one layout directory. */
|
|
96
|
+
export function homePath(kind, options) {
|
|
97
|
+
const entry = HOME_LAYOUT[kind];
|
|
98
|
+
if (!entry) throw new Error(`unknown qa-home directory: ${kind}`);
|
|
99
|
+
return path.join(qaHome(options), entry.dir);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Absolute path of the global lockfile. Inside the home, never loose in `$HOME`. */
|
|
103
|
+
export function homeLockPath(options) {
|
|
104
|
+
return path.join(qaHome(options), LOCKFILE);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Create a layout directory on demand and return it. */
|
|
108
|
+
export function ensureHomeDir(kind, options) {
|
|
109
|
+
const target = homePath(kind, options);
|
|
110
|
+
fs.mkdirSync(target, { recursive: true });
|
|
111
|
+
return target;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** True when a global install is present — the engine is the load-bearing part. */
|
|
115
|
+
export function isGlobalInstalled(options) {
|
|
116
|
+
return fs.existsSync(path.join(homePath('engine', options), 'bin', 'qa-engine.mjs'));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A description of the home for `doctor`: what exists, what does not, and what each
|
|
121
|
+
* directory is for. Reporting an absent optional directory as "not yet created" rather
|
|
122
|
+
* than as missing is the difference between a diagnostic and a false alarm.
|
|
123
|
+
*/
|
|
124
|
+
export function describeHome(options) {
|
|
125
|
+
const root = qaHome(options);
|
|
126
|
+
const present = fs.existsSync(root);
|
|
127
|
+
return {
|
|
128
|
+
root,
|
|
129
|
+
present,
|
|
130
|
+
lockfile: homeLockPath(options),
|
|
131
|
+
engineInstalled: isGlobalInstalled(options),
|
|
132
|
+
directories: Object.entries(HOME_LAYOUT).map(([kind, entry]) => {
|
|
133
|
+
const absolute = path.join(root, entry.dir);
|
|
134
|
+
const exists = fs.existsSync(absolute);
|
|
135
|
+
return {
|
|
136
|
+
kind,
|
|
137
|
+
path: absolute,
|
|
138
|
+
purpose: entry.purpose,
|
|
139
|
+
exists,
|
|
140
|
+
expected: entry.installed,
|
|
141
|
+
state: exists ? 'present' : entry.installed ? 'missing' : 'not yet created',
|
|
142
|
+
};
|
|
143
|
+
}),
|
|
144
|
+
};
|
|
145
|
+
}
|