qa-engineer 0.10.0 → 0.12.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/SKILL.md +3 -0
- package/skills/qa-debug/references/failure-handoff.md +44 -0
- 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/README.md +7 -0
- package/skills/qa-run/SKILL.md +12 -7
- package/skills/qa-run/contracts/execution-result.schema.json +77 -0
- package/skills/qa-run/examples/execute-playwright.md +42 -10
- package/skills/qa-run/examples/plan-a-run.md +5 -3
- package/skills/qa-run/references/artifact-collector.md +2 -1
- package/skills/qa-run/references/command-builder.md +18 -1
- package/skills/qa-run/references/execution-strategy.md +2 -0
- package/skills/qa-run/references/failure-handoff.md +44 -0
- package/skills/qa-run/references/playwright-artifacts.md +3 -1
- package/skills/qa-run/references/playwright-execution.md +14 -1
- package/skills/qa-run/scripts/qa-tool.mjs +41 -5
|
@@ -4,18 +4,15 @@
|
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { BACKUP_DIR, LOCKFILE, EXIT } from '../constants.mjs';
|
|
7
|
-
import { resolveSourceRoot
|
|
8
|
-
import { listSkills, skillFiles } from '../core/manifest.mjs';
|
|
9
|
-
import { bundleFilesForSkill } from '../core/bundle.mjs';
|
|
10
|
-
import { hashBytes } from '../core/hash.mjs';
|
|
7
|
+
import { resolveSourceRoot } from '../core/paths.mjs';
|
|
11
8
|
import { detectConflicts } from '../core/conflict.mjs';
|
|
12
|
-
import { Transaction } from '../core/fs-safe.mjs';
|
|
13
|
-
import { buildLock, readLock, serializeLock,
|
|
9
|
+
import { Transaction, readLinkTarget, canLink } from '../core/fs-safe.mjs';
|
|
10
|
+
import { buildLock, readLock, serializeLock, scopeLockPath } from '../core/lockfile.mjs';
|
|
11
|
+
import { resolveScope } from '../core/scope.mjs';
|
|
12
|
+
import { buildPlan, dedupePlan } from '../core/plan.mjs';
|
|
13
|
+
import { resolveScopeTargets, describeUnservedAgents } from '../agents/targets.mjs';
|
|
14
14
|
import { loadConfig } from '../core/config.mjs';
|
|
15
|
-
import { readSkillMeta } from '../core/skill-meta.mjs';
|
|
16
|
-
import { renderWrapper } from '../core/wrappers.mjs';
|
|
17
15
|
import { conflictError, verifyError } from '../core/errors.mjs';
|
|
18
|
-
import { resolveInstallTargets } from '../agents/registry.mjs';
|
|
19
16
|
import { createLogger } from '../core/logger.mjs';
|
|
20
17
|
import { parseCommonFlags } from '../cli/flags.mjs';
|
|
21
18
|
import { validateInstall } from '../core/validate-install.mjs';
|
|
@@ -92,6 +89,19 @@ function reportFrameworkFit(root, logger) {
|
|
|
92
89
|
logger.info(' → unit tests only (Jest, Vitest, Jasmine, pytest)? That is expected');
|
|
93
90
|
}
|
|
94
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Name the hosts a global install could not serve, and what to do about them.
|
|
94
|
+
*
|
|
95
|
+
* Staying quiet here would leave a Cursor user waiting for slash commands that are never
|
|
96
|
+
* going to appear, with an install that reported success.
|
|
97
|
+
*/
|
|
98
|
+
function reportUnserved(unserved, logger) {
|
|
99
|
+
if (!unserved || unserved.length === 0) return;
|
|
100
|
+
logger.info(` → not installed for ${unserved.map((entry) => entry.id).join(', ')}:`);
|
|
101
|
+
for (const entry of unserved) logger.info(` ${entry.id} — ${entry.reason}`);
|
|
102
|
+
logger.info(' → run `qa install --project .` inside a repository to serve those hosts');
|
|
103
|
+
}
|
|
104
|
+
|
|
95
105
|
/**
|
|
96
106
|
* Core install implementation shared by install / onboard / repair / update.
|
|
97
107
|
*
|
|
@@ -108,7 +118,9 @@ function reportFrameworkFit(root, logger) {
|
|
|
108
118
|
*/
|
|
109
119
|
export async function executeInstall({
|
|
110
120
|
projectRoot,
|
|
121
|
+
scope: providedScope = null,
|
|
111
122
|
agentIds = [],
|
|
123
|
+
allAgents = false,
|
|
112
124
|
force = false,
|
|
113
125
|
dryRun = false,
|
|
114
126
|
skipValidate = false,
|
|
@@ -118,85 +130,58 @@ export async function executeInstall({
|
|
|
118
130
|
} = {}) {
|
|
119
131
|
const logger = log ?? createLogger();
|
|
120
132
|
const sourceRoot = resolveSourceRoot();
|
|
121
|
-
|
|
133
|
+
// A caller that names a scope gets it; everything else is a project install at the
|
|
134
|
+
// path it asked for, which is what every pre-0.11 caller means.
|
|
135
|
+
const scope = providedScope ?? resolveScope({ project: projectRoot ?? process.cwd() });
|
|
136
|
+
const root = scope.root;
|
|
122
137
|
const { config } = loadConfig(root);
|
|
123
138
|
const explicit = agentIds.length > 0 ? agentIds : config.agents ?? [];
|
|
124
|
-
const agents =
|
|
125
|
-
const
|
|
139
|
+
const agents = resolveScopeTargets(scope, { explicitIds: explicit, allAgents });
|
|
140
|
+
const unserved = describeUnservedAgents(scope, agents);
|
|
141
|
+
|
|
142
|
+
if (agents.length === 0) {
|
|
143
|
+
throw conflictError(
|
|
144
|
+
`no agent can be served by a ${scope.kind} install here`,
|
|
145
|
+
scope.kind === 'global'
|
|
146
|
+
? 'no supported host has a user-level skills directory on this machine; ' +
|
|
147
|
+
'install per project with: qa install --project .'
|
|
148
|
+
: 'pass --agent <id> to name one explicitly',
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Probe once, before planning. A filesystem that cannot hold a directory link — FAT,
|
|
153
|
+
// some network and container mounts, Windows without junction support — gets copies
|
|
154
|
+
// instead of a failed install.
|
|
155
|
+
const linksSupported = scope.shareEngine ? canLink(scope.root) : false;
|
|
156
|
+
const { skills, files, links } = buildPlan({
|
|
157
|
+
sourceRoot,
|
|
158
|
+
scope,
|
|
159
|
+
targets: agents,
|
|
160
|
+
preferLinks: linksSupported,
|
|
161
|
+
});
|
|
126
162
|
|
|
127
163
|
if (!json) {
|
|
128
164
|
logger.step(`source: ${sourceRoot}`);
|
|
129
|
-
logger.step(`
|
|
165
|
+
logger.step(`scope: ${scope.label}`);
|
|
130
166
|
logger.step(`agents: ${agents.map((a) => a.id).join(', ')}`);
|
|
131
167
|
logger.step(`skills: ${skills.length}`);
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
for (const agent of agents) {
|
|
138
|
-
for (const skill of skills) {
|
|
139
|
-
const files = skillFiles(sourceRoot, skill);
|
|
140
|
-
for (const rel of files) {
|
|
141
|
-
if (rel.startsWith('tests/') || rel.includes('/tests/')) continue;
|
|
142
|
-
const abs = path.join(sourceRoot, 'skills', skill, rel);
|
|
143
|
-
const content = fs.readFileSync(abs);
|
|
144
|
-
const dest = toPosix(path.join(agent.skillsDir, skill, rel));
|
|
145
|
-
planned.push({
|
|
146
|
-
path: dest,
|
|
147
|
-
sha256: hashBytes(content),
|
|
148
|
-
bytes: content.length,
|
|
149
|
-
owner: 'skill',
|
|
150
|
-
skill,
|
|
151
|
-
agent: agent.id,
|
|
152
|
-
content,
|
|
153
|
-
});
|
|
154
|
-
}
|
|
155
|
-
for (const bundled of bundleFilesForSkill(sourceRoot, skill)) {
|
|
156
|
-
const dest = toPosix(path.join(agent.skillsDir, skill, bundled.rel));
|
|
157
|
-
planned.push({
|
|
158
|
-
path: dest,
|
|
159
|
-
sha256: hashBytes(bundled.content),
|
|
160
|
-
bytes: bundled.content.length,
|
|
161
|
-
owner: 'skill',
|
|
162
|
-
skill,
|
|
163
|
-
agent: agent.id,
|
|
164
|
-
content: bundled.content,
|
|
165
|
-
});
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
if (agent.wrapperFormat && agent.wrapperDir) {
|
|
170
|
-
for (const skill of skills) {
|
|
171
|
-
const meta = readSkillMeta(path.join(sourceRoot, 'skills', skill, 'SKILL.md'));
|
|
172
|
-
if (!meta.name) continue;
|
|
173
|
-
const { filename, content } = renderWrapper(agent.wrapperFormat, meta);
|
|
174
|
-
const buf = Buffer.from(content, 'utf8');
|
|
175
|
-
const dest = toPosix(path.join(agent.wrapperDir, filename));
|
|
176
|
-
planned.push({
|
|
177
|
-
path: dest,
|
|
178
|
-
sha256: hashBytes(buf),
|
|
179
|
-
bytes: buf.length,
|
|
180
|
-
owner: 'wrapper',
|
|
181
|
-
skill,
|
|
182
|
-
agent: agent.id,
|
|
183
|
-
content: buf,
|
|
184
|
-
});
|
|
168
|
+
if (scope.shareEngine) {
|
|
169
|
+
logger.step(`shared engine: ${path.join(scope.qaRoot, 'engine')}`);
|
|
170
|
+
if (!linksSupported) {
|
|
171
|
+
logger.warn('this filesystem does not support directory links — copying skills instead');
|
|
172
|
+
logger.info(' → the install works the same; it uses more disk and each agent gets its own copy');
|
|
185
173
|
}
|
|
186
174
|
}
|
|
187
175
|
}
|
|
188
176
|
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
if (prev && prev.sha256 !== entry.sha256) {
|
|
193
|
-
throw conflictError(`conflicting content for ${entry.path}`);
|
|
194
|
-
}
|
|
195
|
-
if (!prev) byPath.set(entry.path, entry);
|
|
177
|
+
const { unique: uniqueFiles, conflicts: contentConflicts } = dedupePlan(files);
|
|
178
|
+
if (contentConflicts.length > 0) {
|
|
179
|
+
throw conflictError(`conflicting content for ${contentConflicts[0]}`);
|
|
196
180
|
}
|
|
197
|
-
const unique =
|
|
181
|
+
const { unique: uniqueLinks } = dedupePlan(links);
|
|
182
|
+
const unique = [...uniqueFiles, ...uniqueLinks];
|
|
198
183
|
|
|
199
|
-
const priorLock = readLock(root);
|
|
184
|
+
const priorLock = readLock(root, scope.lockfile);
|
|
200
185
|
const conflicts = detectConflicts({
|
|
201
186
|
projectRoot: root,
|
|
202
187
|
planned: unique,
|
|
@@ -220,10 +205,15 @@ export async function executeInstall({
|
|
|
220
205
|
reportProgress(INSTALL_STEPS[0].label, 1);
|
|
221
206
|
|
|
222
207
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
223
|
-
|
|
208
|
+
// A scope that owns a directory keeps its backups inside it. Otherwise a global
|
|
209
|
+
// install writes them to `~/.qa/backups`, which is loose in the user's home — the
|
|
210
|
+
// exact thing owning a directory was meant to stop.
|
|
211
|
+
const backupRoot = scope.qaRootRelative ? path.join(scope.qaRootRelative, 'backups') : BACKUP_DIR;
|
|
212
|
+
const backupDir = path.join(root, backupRoot, stamp);
|
|
224
213
|
const tx = new Transaction(root, backupDir, { dryRun });
|
|
225
214
|
for (const entry of unique) {
|
|
226
|
-
tx.
|
|
215
|
+
if (entry.owner === 'link') tx.link(entry.path, entry.linkTarget);
|
|
216
|
+
else tx.write(entry.path, entry.content);
|
|
227
217
|
}
|
|
228
218
|
|
|
229
219
|
// Files the previous install owned that this one does not: remove them.
|
|
@@ -243,8 +233,11 @@ export async function executeInstall({
|
|
|
243
233
|
if (priorLock && !dryRun) {
|
|
244
234
|
const stillOwned = new Set(unique.map((entry) => entry.path));
|
|
245
235
|
for (const previous of priorLock.files ?? []) {
|
|
246
|
-
if (previous.path ===
|
|
247
|
-
|
|
236
|
+
if (previous.path === scope.lockfile || stillOwned.has(previous.path)) continue;
|
|
237
|
+
const absolute = path.join(root, previous.path);
|
|
238
|
+
// A stale link still counts as present even when its target is gone, so lstat
|
|
239
|
+
// rather than exists — otherwise a broken link survives every upgrade.
|
|
240
|
+
if (!fs.existsSync(absolute) && readLinkTarget(absolute) === null) continue;
|
|
248
241
|
orphans.push(previous.path);
|
|
249
242
|
tx.delete(previous.path);
|
|
250
243
|
}
|
|
@@ -261,18 +254,24 @@ export async function executeInstall({
|
|
|
261
254
|
skillsDir: a.skillsDir,
|
|
262
255
|
detected: a.detected,
|
|
263
256
|
})),
|
|
264
|
-
|
|
257
|
+
// Shared entries — the engine, and the canonical skill tree — belong to no single
|
|
258
|
+
// agent, so the optional keys are omitted rather than set to undefined. The schema
|
|
259
|
+
// types them as strings, and a present-but-undefined key fails that as loudly as a
|
|
260
|
+
// wrong one.
|
|
261
|
+
files: unique.map(({ path: p, sha256, bytes, owner, skill, agent, linkTarget }) => ({
|
|
265
262
|
path: p,
|
|
266
263
|
sha256,
|
|
267
264
|
bytes,
|
|
268
265
|
owner,
|
|
269
|
-
skill,
|
|
270
|
-
agent,
|
|
266
|
+
...(skill ? { skill } : {}),
|
|
267
|
+
...(agent ? { agent } : {}),
|
|
268
|
+
...(linkTarget ? { linkTarget } : {}),
|
|
271
269
|
})),
|
|
272
270
|
now: new Date().toISOString(),
|
|
271
|
+
scope,
|
|
273
272
|
});
|
|
274
273
|
if (!dryRun) {
|
|
275
|
-
tx.write(
|
|
274
|
+
tx.write(scope.lockfile, Buffer.from(serializeLock(lock), 'utf8'));
|
|
276
275
|
}
|
|
277
276
|
|
|
278
277
|
const summary = tx.commit();
|
|
@@ -282,7 +281,7 @@ export async function executeInstall({
|
|
|
282
281
|
|
|
283
282
|
let validation = null;
|
|
284
283
|
if (!dryRun && !skipValidate) {
|
|
285
|
-
validation = validateInstall(root);
|
|
284
|
+
validation = validateInstall(root, { scope });
|
|
286
285
|
if (!validation.ok) {
|
|
287
286
|
const failed = validation.checks.filter((c) => c.hard && !c.ok);
|
|
288
287
|
throw verifyError(
|
|
@@ -298,23 +297,37 @@ export async function executeInstall({
|
|
|
298
297
|
if (dryRun) {
|
|
299
298
|
if (!json) logger.ok(`dry run: would write ${summary.written} file(s)`);
|
|
300
299
|
} else if (!json) {
|
|
301
|
-
|
|
300
|
+
const linked = unique.filter((entry) => entry.owner === 'link').length;
|
|
301
|
+
logger.ok(
|
|
302
|
+
`installed ${unique.length - linked} file(s)` +
|
|
303
|
+
(linked > 0 ? ` and ${linked} link(s)` : '') +
|
|
304
|
+
`; lockfile ${scopeLockPath(scope)}`,
|
|
305
|
+
);
|
|
302
306
|
if (orphans.length > 0) {
|
|
303
307
|
logger.ok(`removed ${orphans.length} file(s) the previous version owned and this one does not`);
|
|
304
308
|
}
|
|
305
309
|
for (const step of INSTALL_STEPS) logger.ok(step.label);
|
|
306
310
|
reportInvocation(agents, logger);
|
|
307
|
-
|
|
311
|
+
// A global install has no project to inspect, and reporting "no framework detected"
|
|
312
|
+
// about the user's home directory would be noise pretending to be a diagnosis.
|
|
313
|
+
if (scope.kind !== 'global') reportFrameworkFit(root, logger);
|
|
314
|
+
reportUnserved(unserved, logger);
|
|
308
315
|
}
|
|
309
316
|
|
|
310
317
|
return {
|
|
311
318
|
ok: true,
|
|
312
319
|
dryRun,
|
|
320
|
+
scope: scope.kind,
|
|
321
|
+
root,
|
|
322
|
+
// Kept under its original name so every existing caller and test keeps working.
|
|
313
323
|
projectRoot: root,
|
|
324
|
+
qaRoot: scope.qaRoot,
|
|
314
325
|
agents: agents.map((a) => a.id),
|
|
326
|
+
unservedAgents: unserved,
|
|
315
327
|
skills: skills.length,
|
|
316
|
-
files: unique.length,
|
|
317
|
-
|
|
328
|
+
files: unique.filter((entry) => entry.owner !== 'link').length,
|
|
329
|
+
links: unique.filter((entry) => entry.owner === 'link').length,
|
|
330
|
+
lockfile: dryRun ? null : scope.lockfile,
|
|
318
331
|
removed: orphans,
|
|
319
332
|
validation,
|
|
320
333
|
};
|
|
@@ -324,17 +337,45 @@ export async function runInstall(argv, { log } = {}) {
|
|
|
324
337
|
const opts = parseCommonFlags(argv);
|
|
325
338
|
const logger = log ?? createLogger();
|
|
326
339
|
if (opts.help) {
|
|
327
|
-
logger.result(`Usage: qa install [--agent <id>]... [--
|
|
340
|
+
logger.result(`Usage: qa install [scope] [--agent <id>]... [--all-agents]
|
|
341
|
+
[--force] [--dry-run] [--yes] [--json]
|
|
328
342
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
343
|
+
Scope — choose one; --project is the default:
|
|
344
|
+
|
|
345
|
+
--global, -g Install once for this machine, in ~/.qa-engineer.
|
|
346
|
+
One engine, one copy of the skills, linked into the
|
|
347
|
+
user-level skills directory of every host that has one.
|
|
348
|
+
Every project then works with no per-project install.
|
|
349
|
+
Override the location with QA_ENGINEER_HOME.
|
|
350
|
+
|
|
351
|
+
--workspace, -w Install once at the root of the monorepo containing the
|
|
352
|
+
current directory, shared by every package in it.
|
|
353
|
+
Detects pnpm, npm/yarn workspaces, Nx, Turborepo, Lerna,
|
|
354
|
+
Rush, Go, and Cargo.
|
|
355
|
+
|
|
356
|
+
--project [dir], -C Install into one project (default: this directory).
|
|
357
|
+
Self-contained: the engine travels inside each skill, so
|
|
358
|
+
the repository works on a machine with nothing installed.
|
|
359
|
+
|
|
360
|
+
Other:
|
|
361
|
+
--all-agents Install for every host that has a user-level directory,
|
|
362
|
+
not just the ones detected here.
|
|
363
|
+
--agent <id> Target a specific host; repeatable.
|
|
364
|
+
--force Overwrite files a previous install does not own.
|
|
365
|
+
--dry-run Report what would change and write nothing.`);
|
|
332
366
|
return EXIT.OK;
|
|
333
367
|
}
|
|
334
368
|
|
|
369
|
+
const scope = resolveScope({
|
|
370
|
+
global: opts.global,
|
|
371
|
+
workspace: opts.workspace,
|
|
372
|
+
project: opts.project,
|
|
373
|
+
});
|
|
374
|
+
|
|
335
375
|
const result = await executeInstall({
|
|
336
|
-
|
|
376
|
+
scope,
|
|
337
377
|
agentIds: opts.agents,
|
|
378
|
+
allAgents: opts.allAgents,
|
|
338
379
|
force: opts.force,
|
|
339
380
|
dryRun: opts.dryRun,
|
|
340
381
|
json: opts.json,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// `qa repair` — fix common install problems by reinstalling pack-owned files.
|
|
2
2
|
|
|
3
3
|
import { EXIT, LOCKFILE } from '../constants.mjs';
|
|
4
|
-
import {
|
|
4
|
+
import { resolveOperatingScope } from '../core/scope.mjs';
|
|
5
5
|
import { readLock } from '../core/lockfile.mjs';
|
|
6
6
|
import { parseCommonFlags } from '../cli/flags.mjs';
|
|
7
7
|
import { executeInstall } from './install.mjs';
|
|
@@ -21,9 +21,10 @@ Repair a broken or drifted installation:
|
|
|
21
21
|
return EXIT.OK;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
const
|
|
24
|
+
const scope = resolveOperatingScope(opts);
|
|
25
|
+
const root = scope.root;
|
|
26
|
+
const prior = readLock(root, scope.lockfile);
|
|
27
|
+
const before = validateInstall(root, { scope });
|
|
27
28
|
|
|
28
29
|
if (!opts.json) {
|
|
29
30
|
logger.step(`repairing ${root}`);
|
|
@@ -40,7 +41,10 @@ Repair a broken or drifted installation:
|
|
|
40
41
|
: (prior?.agents ?? []).map((a) => a.id).filter(Boolean);
|
|
41
42
|
|
|
42
43
|
const result = await executeInstall({
|
|
43
|
-
|
|
44
|
+
// The scope the command resolved, not just its root: without it the reinstall
|
|
45
|
+
// reverts a global install to a project-shaped one — 1225 files and a lockfile in
|
|
46
|
+
// the wrong place — while reporting success.
|
|
47
|
+
scope,
|
|
44
48
|
agentIds,
|
|
45
49
|
force: true,
|
|
46
50
|
dryRun: opts.dryRun,
|
|
@@ -48,7 +52,7 @@ Repair a broken or drifted installation:
|
|
|
48
52
|
log: logger,
|
|
49
53
|
});
|
|
50
54
|
|
|
51
|
-
const after = opts.dryRun ? before : validateInstall(root);
|
|
55
|
+
const after = opts.dryRun ? before : validateInstall(root, { scope });
|
|
52
56
|
|
|
53
57
|
if (!opts.json) {
|
|
54
58
|
if (after.ok) logger.ok('repair complete — installation validated');
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// `qa self-test` — verify an installation is operational.
|
|
2
2
|
|
|
3
3
|
import { EXIT } from '../constants.mjs';
|
|
4
|
-
import {
|
|
4
|
+
import { resolveOperatingScope } from '../core/scope.mjs';
|
|
5
5
|
import { parseCommonFlags } from '../cli/flags.mjs';
|
|
6
6
|
import { validateInstall } from '../core/validate-install.mjs';
|
|
7
7
|
import { createLogger } from '../core/logger.mjs';
|
|
@@ -17,8 +17,9 @@ Prints PASS/FAIL per check. Does not invoke live AI agents.`);
|
|
|
17
17
|
return EXIT.OK;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
const
|
|
21
|
-
const
|
|
20
|
+
const scope = resolveOperatingScope(opts);
|
|
21
|
+
const root = scope.root;
|
|
22
|
+
const { ok, checks } = validateInstall(root, { scope });
|
|
22
23
|
|
|
23
24
|
if (!opts.json) {
|
|
24
25
|
logger.step(`self-test — ${root}`);
|
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
import fs from 'node:fs';
|
|
17
17
|
import path from 'node:path';
|
|
18
18
|
import { EXIT, LOCKFILE, BACKUP_DIR } from '../constants.mjs';
|
|
19
|
-
import {
|
|
19
|
+
import { resolveOperatingScope } from '../core/scope.mjs';
|
|
20
20
|
import { readLock, lockPath } from '../core/lockfile.mjs';
|
|
21
|
-
import {
|
|
21
|
+
import { entryDigest } from '../core/integrity.mjs';
|
|
22
22
|
import { Transaction, pruneEmptyDirs } from '../core/fs-safe.mjs';
|
|
23
23
|
import { conflictError, verifyError } from '../core/errors.mjs';
|
|
24
24
|
import { createLogger } from '../core/logger.mjs';
|
|
@@ -40,7 +40,7 @@ function findPycache(dir) {
|
|
|
40
40
|
return found;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
export async function runUninstall(argv, { log } = {}) {
|
|
43
|
+
export async function runUninstall(argv, { log, env = process.env } = {}) {
|
|
44
44
|
const opts = parseCommonFlags(argv);
|
|
45
45
|
const logger = log ?? createLogger();
|
|
46
46
|
if (opts.help) {
|
|
@@ -54,8 +54,11 @@ Only pack-owned files are removed; anything else is left alone.
|
|
|
54
54
|
return EXIT.OK;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
// `env` is injected by the test suite so a global uninstall can be exercised
|
|
58
|
+
// against a temporary home rather than the developer's own.
|
|
59
|
+
const scope = resolveOperatingScope({ ...opts, env });
|
|
60
|
+
const root = scope.root;
|
|
61
|
+
const lock = readLock(root, scope.lockfile);
|
|
59
62
|
if (!lock) {
|
|
60
63
|
if (!opts.json) {
|
|
61
64
|
logger.error(`FAIL no ${LOCKFILE} in ${root}`);
|
|
@@ -73,7 +76,7 @@ Only pack-owned files are removed; anything else is left alone.
|
|
|
73
76
|
missing.push(entry.path);
|
|
74
77
|
continue;
|
|
75
78
|
}
|
|
76
|
-
if (
|
|
79
|
+
if (entryDigest(root, entry) !== entry.sha256) drifted.push(entry.path);
|
|
77
80
|
present.push(entry.path);
|
|
78
81
|
}
|
|
79
82
|
|
|
@@ -97,9 +100,12 @@ Only pack-owned files are removed; anything else is left alone.
|
|
|
97
100
|
}
|
|
98
101
|
|
|
99
102
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
100
|
-
|
|
103
|
+
// Same rule as install: a scope that owns a directory keeps its backups inside it,
|
|
104
|
+
// rather than dropping `.qa/backups` into the user's home.
|
|
105
|
+
const backupRoot = scope.qaRootRelative ? path.join(scope.qaRootRelative, 'backups') : BACKUP_DIR;
|
|
106
|
+
const tx = new Transaction(root, path.join(root, backupRoot, stamp), { dryRun: opts.dryRun });
|
|
101
107
|
for (const rel of present) tx.delete(rel);
|
|
102
|
-
if (fs.existsSync(
|
|
108
|
+
if (fs.existsSync(path.join(root, scope.lockfile))) tx.delete(scope.lockfile);
|
|
103
109
|
const summary = tx.commit();
|
|
104
110
|
|
|
105
111
|
// Byproducts of the pack's own bundled code: Python writes __pycache__ next to
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// `qa update` — refresh an install from the current pack source.
|
|
2
2
|
|
|
3
3
|
import { EXIT } from '../constants.mjs';
|
|
4
|
-
import {
|
|
4
|
+
import { resolveSourceRoot } from '../core/paths.mjs';
|
|
5
|
+
import { resolveOperatingScope } from '../core/scope.mjs';
|
|
5
6
|
import { readLock } from '../core/lockfile.mjs';
|
|
6
7
|
import { parseCommonFlags } from '../cli/flags.mjs';
|
|
7
8
|
import { executeInstall } from './install.mjs';
|
|
@@ -20,9 +21,10 @@ Always re-validates after updating.`);
|
|
|
20
21
|
return EXIT.OK;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
const
|
|
24
|
+
const scope = resolveOperatingScope(opts);
|
|
25
|
+
const root = scope.root;
|
|
24
26
|
const sourceRoot = resolveSourceRoot();
|
|
25
|
-
const lock = readLock(root);
|
|
27
|
+
const lock = readLock(root, scope.lockfile);
|
|
26
28
|
const installedVersion = lock?.pack?.version ?? null;
|
|
27
29
|
|
|
28
30
|
if (!opts.json) {
|
|
@@ -43,7 +45,10 @@ Always re-validates after updating.`);
|
|
|
43
45
|
: (lock?.agents ?? []).map((a) => a.id).filter(Boolean);
|
|
44
46
|
|
|
45
47
|
const result = await executeInstall({
|
|
46
|
-
|
|
48
|
+
// The scope the command resolved, not just its root: without it the reinstall
|
|
49
|
+
// reverts a global install to a project-shaped one — 1225 files and a lockfile in
|
|
50
|
+
// the wrong place — while reporting success.
|
|
51
|
+
scope,
|
|
47
52
|
agentIds,
|
|
48
53
|
force: true,
|
|
49
54
|
dryRun: opts.dryRun,
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { EXIT, LOCKFILE } from '../constants.mjs';
|
|
6
|
-
import {
|
|
6
|
+
import { resolveOperatingScope } from '../core/scope.mjs';
|
|
7
7
|
import { readLock } from '../core/lockfile.mjs';
|
|
8
|
-
import {
|
|
8
|
+
import { entryDigest } from '../core/integrity.mjs';
|
|
9
9
|
import { verifyError } from '../core/errors.mjs';
|
|
10
10
|
import { createLogger } from '../core/logger.mjs';
|
|
11
11
|
import { parseCommonFlags } from '../cli/flags.mjs';
|
|
@@ -21,8 +21,9 @@ On failure: run qa repair`);
|
|
|
21
21
|
return EXIT.OK;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
const
|
|
25
|
-
const
|
|
24
|
+
const scope = resolveOperatingScope(opts);
|
|
25
|
+
const root = scope.root;
|
|
26
|
+
const lock = readLock(root, scope.lockfile);
|
|
26
27
|
if (!lock) {
|
|
27
28
|
if (!opts.json) {
|
|
28
29
|
logger.error(`FAIL no ${LOCKFILE}`);
|
|
@@ -33,14 +34,14 @@ On failure: run qa repair`);
|
|
|
33
34
|
|
|
34
35
|
const problems = [];
|
|
35
36
|
for (const entry of lock.files) {
|
|
36
|
-
const
|
|
37
|
-
if (
|
|
38
|
-
problems.push({ path: entry.path, reason: 'missing' });
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
37
|
+
const actual = entryDigest(root, entry);
|
|
38
|
+
if (actual === null) {
|
|
39
|
+
problems.push({ path: entry.path, reason: entry.owner === 'link' ? 'link missing' : 'missing' });
|
|
40
|
+
} else if (actual !== entry.sha256) {
|
|
41
|
+
problems.push({
|
|
42
|
+
path: entry.path,
|
|
43
|
+
reason: entry.owner === 'link' ? 'link points somewhere else' : 'hash mismatch',
|
|
44
|
+
});
|
|
44
45
|
}
|
|
45
46
|
}
|
|
46
47
|
|
|
@@ -11,6 +11,19 @@ export const LOCKFILE = 'qa-lock.json';
|
|
|
11
11
|
export const QA_DIR = '.qa';
|
|
12
12
|
export const BACKUP_DIR = `${QA_DIR}/backups`;
|
|
13
13
|
|
|
14
|
+
// The directory QA Engineer owns. In a global install it sits in the user's home; in a
|
|
15
|
+
// workspace install it sits at the monorepo root. Same name in both, so a developer who
|
|
16
|
+
// has seen one recognises the other.
|
|
17
|
+
export const QA_HOME_DIR_NAME = '.qa-engineer';
|
|
18
|
+
|
|
19
|
+
// Full override for where the global install lives. Also what makes global installs
|
|
20
|
+
// testable — the suite points it at a temporary directory instead of a real $HOME.
|
|
21
|
+
export const QA_HOME_ENV = 'QA_ENGINEER_HOME';
|
|
22
|
+
|
|
23
|
+
// Where the shared engine lands inside a qaRoot, and where the canonical skills live.
|
|
24
|
+
export const SHARED_ENGINE_DIR = 'engine';
|
|
25
|
+
export const SHARED_SKILLS_STORE = 'skills';
|
|
26
|
+
|
|
14
27
|
// Marker line prepended to every generated invocation wrapper. Wrappers are
|
|
15
28
|
// disposable build artifacts; this line tells humans and `verify` that the file
|
|
16
29
|
// is pack-owned and regenerated, never hand-edited.
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
|
-
import {
|
|
9
|
+
import { entryDigest, entryPresent } from './integrity.mjs';
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* @param {object} args
|
|
@@ -19,10 +19,11 @@ export function detectConflicts({ projectRoot, planned, priorLock }) {
|
|
|
19
19
|
const owned = new Set((priorLock?.files ?? []).map((f) => f.path));
|
|
20
20
|
const conflicts = [];
|
|
21
21
|
for (const entry of planned) {
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
// `entryPresent` rather than `existsSync`: a link whose target is gone still
|
|
23
|
+
// occupies the path, and overwriting it blind would lose where it pointed.
|
|
24
|
+
if (!entryPresent(projectRoot, entry)) continue; // brand-new path, no conflict
|
|
24
25
|
if (owned.has(entry.path)) continue; // pack-owned, safe to update
|
|
25
|
-
if (
|
|
26
|
+
if (entryDigest(projectRoot, entry) === entry.sha256) continue; // already correct, no-op
|
|
26
27
|
conflicts.push({ path: entry.path, reason: 'exists and is not owned by a previous install' });
|
|
27
28
|
}
|
|
28
29
|
return conflicts;
|