@mmerterden/multi-agent-pipeline 17.5.1 → 17.6.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 +149 -0
- package/README.md +16 -0
- package/README.tr.md +16 -0
- package/docs/features.md +24 -0
- package/docs/token-budget-history.md +1 -1
- package/install/templates/claude-hooks.json +13 -1
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/feedback/SKILL.md +7 -1
- package/pipeline/commands/multi-agent/graph/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/issue/SKILL.md +13 -1
- package/pipeline/commands/multi-agent/jira/SKILL.md +13 -1
- package/pipeline/commands/multi-agent/resume/SKILL.md +16 -1
- package/pipeline/commands/multi-agent/setup/SKILL.md +14 -16
- package/pipeline/commands/multi-agent/update/SKILL.md +13 -56
- package/pipeline/multi-agent-refs/features/code-graph.md +20 -0
- package/pipeline/multi-agent-refs/features/doctor.md +23 -0
- package/pipeline/multi-agent-refs/features/maturity-followup.md +166 -0
- package/pipeline/multi-agent-refs/features/package-manager.md +80 -0
- package/pipeline/multi-agent-refs/features/usage-reporting.md +79 -0
- package/pipeline/multi-agent-refs/features/verify-by-test.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +5 -2
- package/pipeline/multi-agent-refs/phases/phase-3-dev.md +8 -2
- package/pipeline/multi-agent-refs/phases/phase-4-review.md +1 -1
- package/pipeline/multi-agent-refs/picker-contract.md +1 -1
- package/pipeline/preferences-template.json +1 -1
- package/pipeline/schemas/agent-state.schema.json +122 -11
- package/pipeline/schemas/prefs.schema.json +35 -0
- package/pipeline/schemas/token-budget.json +2 -2
- package/pipeline/scripts/doctor.mjs +65 -0
- package/pipeline/scripts/feedback-send.mjs +1 -1
- package/pipeline/scripts/graph-report.mjs +155 -1
- package/pipeline/scripts/maturity-followup.mjs +294 -0
- package/pipeline/scripts/package-manager.mjs +310 -0
- package/pipeline/scripts/usage-register.mjs +271 -0
- package/pipeline/scripts/usage-report.mjs +2 -2
- package/pipeline/skills/.skill-manifest.json +5 -5
- package/pipeline/skills/shared/core/multi-agent-issue/SKILL.md +14 -0
- package/pipeline/skills/shared/core/multi-agent-jira/SKILL.md +14 -0
- package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +13 -0
- package/pipeline/skills/shared/core/multi-agent-update/SKILL.md +6 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* maturity-followup.mjs - what to do about an issue that is not ready yet.
|
|
4
|
+
*
|
|
5
|
+
* The maturity check has always produced a machine-readable gap list
|
|
6
|
+
* (`blockers[]`, `warnings[]`, stable codes) and then thrown most of it away: a
|
|
7
|
+
* blocker halted the run and an autopilot queue moved on to the next item,
|
|
8
|
+
* leaving the issue exactly as immature as it found it. Nobody was told, so
|
|
9
|
+
* nothing changed, so the next scan halted on the same issue for the same
|
|
10
|
+
* reason.
|
|
11
|
+
*
|
|
12
|
+
* This module decides the follow-up. It is PURE - no network, no git, no issue
|
|
13
|
+
* API, no clock unless one is handed to it - because the decision is the part
|
|
14
|
+
* worth testing and the I/O around it is the part that differs per host.
|
|
15
|
+
*
|
|
16
|
+
* THE RULE THAT SHAPES EVERYTHING: an edit is a reason to look again, never
|
|
17
|
+
* proof that the gap closed. A reply reading "will do later" moves the
|
|
18
|
+
* artifact's timestamp and fixes nothing. So a changed artifact re-runs the
|
|
19
|
+
* maturity check against the new content and the CHECK decides - this module
|
|
20
|
+
* never infers maturity from the fact that something moved.
|
|
21
|
+
*
|
|
22
|
+
* IT NEVER RE-LABELS A GAP. The fetcher already renders the codes into
|
|
23
|
+
* `maturity.summary` in the user's language; re-deriving that wording here
|
|
24
|
+
* would give the project two copies of one table and only one of them would be
|
|
25
|
+
* maintained.
|
|
26
|
+
*
|
|
27
|
+
* @module pipeline/scripts/maturity-followup
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The first line of any comment this pipeline writes about maturity.
|
|
32
|
+
*
|
|
33
|
+
* It is a plain sentence rather than an HTML comment on purpose: Jira, GitHub
|
|
34
|
+
* and Confluence render markdown differently and at least one of them shows the
|
|
35
|
+
* raw `<!-- -->`, but all three show a first line. It carries no square brackets
|
|
36
|
+
* for the same reason: `[text]` is a LINK in Jira wiki markup, so a bracketed
|
|
37
|
+
* marker renders as a broken link to a page nobody created. It has to be recognisable to
|
|
38
|
+
* BOTH a human skimming the thread and the next scan looking for its own prior
|
|
39
|
+
* comment, because posting the same question twice is how an unattended queue
|
|
40
|
+
* turns an issue into a wall of identical bot comments.
|
|
41
|
+
*/
|
|
42
|
+
export const MARKER = "multi-agent: Bu madde geliştirmeye hazır değil / not ready to develop yet";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The machine-readable half of the comment.
|
|
46
|
+
*
|
|
47
|
+
* WHY IT IS IN THE BODY AND NOT ONLY IN STATE. `state.maturityFollowup` lives in
|
|
48
|
+
* one run's agent-state.json, and the next scan of the same item is a DIFFERENT
|
|
49
|
+
* run with a fresh state file. Deriving "have we already asked" from state alone
|
|
50
|
+
* means every scan is a first ask, which is precisely the wall of identical bot
|
|
51
|
+
* comments this feature exists to avoid. The item itself is the only store both
|
|
52
|
+
* runs can see, so the question we asked is recorded where we asked it.
|
|
53
|
+
*
|
|
54
|
+
* The prose above it is the fetcher's localized summary, which is written for a
|
|
55
|
+
* human and is not stable enough to compare; these are the codes.
|
|
56
|
+
*
|
|
57
|
+
* No square brackets, for the Jira reason above.
|
|
58
|
+
*/
|
|
59
|
+
export const GAP_LINE = "multi-agent gaps:";
|
|
60
|
+
|
|
61
|
+
/** The codes carried by one of our comments, or null when it carries none. */
|
|
62
|
+
export function parseGaps(text) {
|
|
63
|
+
if (typeof text !== "string") return null;
|
|
64
|
+
const line = text.split("\n").find((l) => l.trim().startsWith(GAP_LINE));
|
|
65
|
+
if (!line) return null;
|
|
66
|
+
const rest = line
|
|
67
|
+
.trim()
|
|
68
|
+
.slice(GAP_LINE.length)
|
|
69
|
+
.replace(/^[:\s]+/, "");
|
|
70
|
+
const codes = rest
|
|
71
|
+
.split(",")
|
|
72
|
+
.map((c) => c.trim())
|
|
73
|
+
.filter(isCode);
|
|
74
|
+
return codes.length ? [...new Set(codes)].sort() : [];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The prior ask, read from the item's own comments.
|
|
79
|
+
*
|
|
80
|
+
* Newest ours wins: a gap set that changed produces a second comment, and the
|
|
81
|
+
* newest one is the question actually outstanding. A comment of ours that
|
|
82
|
+
* carries no gap line (written before this version, or edited by hand) counts as
|
|
83
|
+
* "we asked, about something we can no longer name" - gaps `[]`, which never
|
|
84
|
+
* equals a live gap set, so the next scan asks again with the codes attached
|
|
85
|
+
* rather than staying silent forever on an unreadable record.
|
|
86
|
+
*
|
|
87
|
+
* @param {Array<{body?: string, text?: string, createdAt?: string, created?: string, updatedAt?: string}>} comments
|
|
88
|
+
* @returns {{gaps: string[], askedAt: string|null}|null}
|
|
89
|
+
*/
|
|
90
|
+
export function priorFromComments(comments) {
|
|
91
|
+
if (!Array.isArray(comments)) return null;
|
|
92
|
+
let best = null;
|
|
93
|
+
for (const c of comments) {
|
|
94
|
+
const body = typeof c === "string" ? c : c?.body || c?.text || "";
|
|
95
|
+
if (!isOurComment(body)) continue;
|
|
96
|
+
const at = typeof c === "string" ? null : c?.createdAt || c?.created || c?.updatedAt || null;
|
|
97
|
+
const ms = at ? Date.parse(at) : NaN;
|
|
98
|
+
const rank = Number.isNaN(ms) ? -1 : ms;
|
|
99
|
+
if (!best || rank >= best.rank) best = { gaps: parseGaps(body) || [], askedAt: at, rank };
|
|
100
|
+
}
|
|
101
|
+
return best ? { gaps: best.gaps, askedAt: best.askedAt } : null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Gap codes that stop a run outright, in the order the fetcher emits them. */
|
|
105
|
+
export function gapsOf(maturity) {
|
|
106
|
+
if (!maturity || typeof maturity !== "object") return { blockers: [], warnings: [], all: [] };
|
|
107
|
+
const blockers = Array.isArray(maturity.blockers) ? maturity.blockers.filter(isCode) : [];
|
|
108
|
+
const warnings = Array.isArray(maturity.warnings) ? maturity.warnings.filter(isCode) : [];
|
|
109
|
+
return { blockers, warnings, all: [...blockers, ...warnings] };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function isCode(c) {
|
|
113
|
+
return typeof c === "string" && c.trim().length > 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Two gap sets are the same question when they hold the same codes. */
|
|
117
|
+
export function sameGaps(a, b) {
|
|
118
|
+
const A = [...new Set((a || []).filter(isCode))].sort();
|
|
119
|
+
const B = [...new Set((b || []).filter(isCode))].sort();
|
|
120
|
+
return A.length === B.length && A.every((v, i) => v === B[i]);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function toMs(v) {
|
|
124
|
+
if (typeof v === "number" && Number.isFinite(v)) return v;
|
|
125
|
+
if (typeof v !== "string" || !v.trim()) return null;
|
|
126
|
+
const t = Date.parse(v);
|
|
127
|
+
return Number.isNaN(t) ? null : t;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Has the artifact moved since we asked?
|
|
132
|
+
*
|
|
133
|
+
* `null` for "cannot tell" is deliberate and is NOT folded into `false`. An
|
|
134
|
+
* unparseable or absent timestamp means the host did not tell us, and treating
|
|
135
|
+
* that as "nothing changed" parks a run forever on a tracker whose API omits
|
|
136
|
+
* the field. Unknown re-checks; it does not wait.
|
|
137
|
+
*
|
|
138
|
+
* @returns {boolean|null}
|
|
139
|
+
*/
|
|
140
|
+
export function movedSince(artifactUpdatedAt, askedAt) {
|
|
141
|
+
const a = toMs(artifactUpdatedAt);
|
|
142
|
+
const b = toMs(askedAt);
|
|
143
|
+
if (a === null || b === null) return null;
|
|
144
|
+
return a > b;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Decide the follow-up.
|
|
149
|
+
*
|
|
150
|
+
* @param {object} input
|
|
151
|
+
* @param {object} input.maturity the FRESH maturity object, re-scored this pass
|
|
152
|
+
* @param {"interactive"|"autopilot"} input.mode
|
|
153
|
+
* @param {object|null} [input.prior] `state.maturityFollowup` from a previous pass
|
|
154
|
+
* @param {object} [input.artifact] `{kind, key, url, updatedAt}`
|
|
155
|
+
* @param {object} [input.prefs] `prefs.global.maturityFollowup`
|
|
156
|
+
* @returns {{action: string, reason: string, gaps?: string[], [k: string]: any}}
|
|
157
|
+
*/
|
|
158
|
+
export function decide({ maturity, mode, prior = null, artifact = {}, prefs = {} } = {}) {
|
|
159
|
+
const { blockers, warnings, all } = gapsOf(maturity);
|
|
160
|
+
const askInteractively = prefs.askInteractively !== false;
|
|
161
|
+
const commentsOn = prefs.autopilotCommentsOnIssue === true;
|
|
162
|
+
const commentOnWarnings = prefs.commentOnWarnings === true;
|
|
163
|
+
|
|
164
|
+
// Free-text input scores null: there is no issue to be immature.
|
|
165
|
+
if (maturity && maturity.score === null) {
|
|
166
|
+
return { action: "proceed", reason: "no issue to score" };
|
|
167
|
+
}
|
|
168
|
+
if (all.length === 0) {
|
|
169
|
+
return { action: "proceed", reason: "mature" };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Warnings alone have always auto-continued under autopilot, and a release
|
|
173
|
+
// that silently converted every warning into a halt would stall queues
|
|
174
|
+
// overnight on issues that were running fine yesterday. Raising them is
|
|
175
|
+
// opt-in, and the gaps are recorded either way so the next pass can compare.
|
|
176
|
+
const actionable = blockers.length > 0 || (commentOnWarnings && warnings.length > 0);
|
|
177
|
+
|
|
178
|
+
if (mode !== "autopilot") {
|
|
179
|
+
if (!actionable) return { action: "proceed", reason: "warnings only", gaps: all };
|
|
180
|
+
if (!askInteractively) return { action: "halt", reason: "blocker", gaps: all };
|
|
181
|
+
return { action: "ask", reason: "blocker", gaps: all, blockers, warnings };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (!actionable) {
|
|
185
|
+
return { action: "proceed", reason: "warnings only", gaps: all, record: true };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (!commentsOn) {
|
|
189
|
+
// Today's behaviour, unchanged: halt and say why. The comment is the new
|
|
190
|
+
// part and it is an outward-facing write, so it does not happen by default.
|
|
191
|
+
return { action: "halt", reason: "blocker", gaps: all };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const priorGaps = prior && Array.isArray(prior.gaps) ? prior.gaps : null;
|
|
195
|
+
if (!priorGaps) {
|
|
196
|
+
return { action: "comment", reason: "first ask", gaps: all, blockers, warnings };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (!sameGaps(priorGaps, all)) {
|
|
200
|
+
// The issue was edited and a DIFFERENT gap set survives. That is new
|
|
201
|
+
// information for the reader, so it earns a comment; the same set does not.
|
|
202
|
+
return { action: "comment", reason: "gaps changed", gaps: all, blockers, warnings, priorGaps };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const moved = movedSince(artifact.updatedAt, prior.askedAt);
|
|
206
|
+
if (moved === false) {
|
|
207
|
+
return { action: "wait", reason: "asked already, artifact unchanged", gaps: all };
|
|
208
|
+
}
|
|
209
|
+
// moved === true or null: the check has already re-run against fresh content
|
|
210
|
+
// to produce the `maturity` handed in here, and the same gaps survived. Say
|
|
211
|
+
// nothing again; a queue that re-comments on every scan is noise.
|
|
212
|
+
return {
|
|
213
|
+
action: "wait",
|
|
214
|
+
reason:
|
|
215
|
+
moved === null
|
|
216
|
+
? "asked already, artifact age unknown"
|
|
217
|
+
: "asked already, gaps survived the edit",
|
|
218
|
+
gaps: all,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The comment body.
|
|
224
|
+
*
|
|
225
|
+
* `summary` is the fetcher's own localized rendering of these same codes and is
|
|
226
|
+
* used verbatim. `Ref:` is the only issue reference form this pipeline writes -
|
|
227
|
+
* a closing keyword would let the host's automation act on a comment that is
|
|
228
|
+
* asking a question.
|
|
229
|
+
*
|
|
230
|
+
* @param {object} input
|
|
231
|
+
* @param {string} input.summary `maturity.summary`, already localized
|
|
232
|
+
* @param {string} [input.taskRef] what to point back at, e.g. `PROJ-1234`
|
|
233
|
+
* @param {string[]} [input.gaps] the codes, recorded on the item so the NEXT run
|
|
234
|
+
* can tell what was already asked without our state file
|
|
235
|
+
* @param {"tr"|"en"} [input.lang]
|
|
236
|
+
* @returns {string}
|
|
237
|
+
*/
|
|
238
|
+
export function commentBody({ summary, taskRef = "", lang = "tr", gaps = [] } = {}) {
|
|
239
|
+
const body = String(summary || "").trim();
|
|
240
|
+
const codes = [...new Set((gaps || []).filter(isCode))].sort();
|
|
241
|
+
const tr = [
|
|
242
|
+
MARKER,
|
|
243
|
+
"",
|
|
244
|
+
"Otomatik geliştirme sırası bu maddeye geldi ve şu eksiklerle durdu:",
|
|
245
|
+
"",
|
|
246
|
+
body,
|
|
247
|
+
"",
|
|
248
|
+
"Maddeyi düzenlediğinde bir sonraki tarama eksikleri yeniden kontrol eder ve",
|
|
249
|
+
"kapandıysa geliştirme kendiliğinden başlar. Bu yorum yalnızca sorar: maddenin",
|
|
250
|
+
"durumu, atanan kişisi ya da etiketleri değiştirilmedi.",
|
|
251
|
+
];
|
|
252
|
+
const en = [
|
|
253
|
+
MARKER,
|
|
254
|
+
"",
|
|
255
|
+
"The automated queue reached this item and stopped on:",
|
|
256
|
+
"",
|
|
257
|
+
body,
|
|
258
|
+
"",
|
|
259
|
+
"Edit the item and the next scan re-checks these; development starts on its own",
|
|
260
|
+
"once they are closed. This comment only asks - the item's status, assignee and",
|
|
261
|
+
"labels were not touched.",
|
|
262
|
+
];
|
|
263
|
+
const lines = lang === "en" ? en : tr;
|
|
264
|
+
if (taskRef) lines.push("", `Ref: ${taskRef}`);
|
|
265
|
+
// Last line, always present even when empty: its ABSENCE is how a reader tells
|
|
266
|
+
// a comment written before this version from one that asked about nothing.
|
|
267
|
+
lines.push("", `${GAP_LINE} ${codes.join(",")}`.trimEnd());
|
|
268
|
+
return lines.join("\n");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Is this comment one of ours?
|
|
273
|
+
*
|
|
274
|
+
* Used before posting, against the artifact's existing comments. Matching on
|
|
275
|
+
* the marker rather than on authorship is what makes it work when the token
|
|
276
|
+
* that posts is a shared service account.
|
|
277
|
+
*/
|
|
278
|
+
export function isOurComment(text) {
|
|
279
|
+
return typeof text === "string" && text.includes(MARKER);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** The record written to `state.maturityFollowup` after a comment. */
|
|
283
|
+
export function recordFor({ gaps, artifact = {}, at, commentUrl = null }) {
|
|
284
|
+
return {
|
|
285
|
+
gaps: [...new Set((gaps || []).filter(isCode))].sort(),
|
|
286
|
+
askedAt: typeof at === "string" ? at : new Date(at || Date.now()).toISOString(),
|
|
287
|
+
target: {
|
|
288
|
+
kind: artifact.kind || "unknown",
|
|
289
|
+
key: artifact.key || null,
|
|
290
|
+
url: artifact.url || null,
|
|
291
|
+
},
|
|
292
|
+
commentUrl,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* package-manager.mjs - which package manager does THIS repo use.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS
|
|
6
|
+
*
|
|
7
|
+
* Phase 3 and Phase 5 shelled `npm test` and `npm run build` for the node-shaped
|
|
8
|
+
* stacks, hardcoded. A repo on pnpm, yarn or bun then gets one of two outcomes,
|
|
9
|
+
* both bad: the command fails outright, or npm quietly installs against a lock
|
|
10
|
+
* file it does not own and the run continues on a tree the repo's own tooling
|
|
11
|
+
* would not have produced. Either way it happens in Phase 3, after a worktree
|
|
12
|
+
* and a branch already exist - the same failure shape ADR-0012 rejected for
|
|
13
|
+
* platforms ("it fails partway through, having already created a worktree and a
|
|
14
|
+
* branch").
|
|
15
|
+
*
|
|
16
|
+
* The iOS and Android paths are untouched; this is only about the stacks where
|
|
17
|
+
* a node package manager is the build tool.
|
|
18
|
+
*
|
|
19
|
+
* NODE CORE ONLY (ADR-0004). No corepack call, no spawn, no network: the answer
|
|
20
|
+
* is derivable from files that are already on disk, and a resolver that shells
|
|
21
|
+
* out would need a working install of the very tool it is trying to identify.
|
|
22
|
+
*
|
|
23
|
+
* RESOLUTION ORDER, strongest evidence first:
|
|
24
|
+
* 1. $MA_PACKAGE_MANAGER an explicit override always wins
|
|
25
|
+
* 2. package.json "packageManager" the repo's own declaration (corepack's field)
|
|
26
|
+
* 3. a lock file what the repo actually committed
|
|
27
|
+
* 4. npm the default, reported AS a default
|
|
28
|
+
*
|
|
29
|
+
* The order is "what the repo said" before "what the repo left behind", because
|
|
30
|
+
* a stale lock file outlives a migration and the declaration does not.
|
|
31
|
+
*
|
|
32
|
+
* Usage:
|
|
33
|
+
* node package-manager.mjs detect --dir <d> -> JSON
|
|
34
|
+
* node package-manager.mjs test --dir <d> [--pattern <p>] -> the command line
|
|
35
|
+
* node package-manager.mjs run --dir <d> --script build -> the command line
|
|
36
|
+
* node package-manager.mjs install --dir <d> -> the command line
|
|
37
|
+
*
|
|
38
|
+
* Exit: 0 ok · 2 usage · 3 the requested script is not declared (run/test)
|
|
39
|
+
*
|
|
40
|
+
* @module pipeline/scripts/package-manager
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
44
|
+
import { dirname, join, resolve as resolvePath } from "node:path";
|
|
45
|
+
import { pathToFileURL } from "node:url";
|
|
46
|
+
|
|
47
|
+
/** Lock file -> package manager. Ordered only for a stable tie-break message. */
|
|
48
|
+
export const LOCKFILES = [
|
|
49
|
+
["bun.lockb", "bun"],
|
|
50
|
+
["bun.lock", "bun"],
|
|
51
|
+
["pnpm-lock.yaml", "pnpm"],
|
|
52
|
+
["yarn.lock", "yarn"],
|
|
53
|
+
["package-lock.json", "npm"],
|
|
54
|
+
["npm-shrinkwrap.json", "npm"],
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
export const KNOWN = ["npm", "pnpm", "yarn", "bun"];
|
|
58
|
+
|
|
59
|
+
function readJson(file) {
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* `"pnpm@9.1.0"` -> `pnpm`. A field naming something we do not know about is
|
|
69
|
+
* returned as-is by name so the caller can say WHICH unknown manager it saw;
|
|
70
|
+
* garbage (empty, a version with no name) resolves to null and falls through.
|
|
71
|
+
*/
|
|
72
|
+
export function parseManagerField(value) {
|
|
73
|
+
if (typeof value !== "string") return null;
|
|
74
|
+
const name = value.trim().split("@")[0].trim().toLowerCase();
|
|
75
|
+
return /^[a-z][a-z0-9-]*$/.test(name) ? name : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Walk up from `dir` looking for the evidence. A monorepo keeps its lock file at
|
|
80
|
+
* the root while the task edits a package three levels down, so stopping at the
|
|
81
|
+
* starting directory would resolve to the default for most real repos.
|
|
82
|
+
*
|
|
83
|
+
* The walk stops AFTER the directory that holds `.git` - that is the repo, and
|
|
84
|
+
* anything above it belongs to somebody else (a home directory with a stray
|
|
85
|
+
* yarn.lock has misrouted builds before).
|
|
86
|
+
*/
|
|
87
|
+
export function* ancestors(dir) {
|
|
88
|
+
let cur = resolvePath(dir);
|
|
89
|
+
for (;;) {
|
|
90
|
+
yield cur;
|
|
91
|
+
if (existsSync(join(cur, ".git"))) return;
|
|
92
|
+
const up = dirname(cur);
|
|
93
|
+
if (up === cur) return;
|
|
94
|
+
cur = up;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function locksIn(dir) {
|
|
99
|
+
const found = [];
|
|
100
|
+
for (const [file, pm] of LOCKFILES) {
|
|
101
|
+
const full = join(dir, file);
|
|
102
|
+
if (existsSync(full)) {
|
|
103
|
+
let mtime = 0;
|
|
104
|
+
try {
|
|
105
|
+
mtime = statSync(full).mtimeMs;
|
|
106
|
+
} catch {
|
|
107
|
+
/* unreadable is the same as absent for a tie-break */
|
|
108
|
+
}
|
|
109
|
+
found.push({ file, pm, mtime });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return found;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Resolve the package manager for a directory.
|
|
117
|
+
*
|
|
118
|
+
* @param {string} dir
|
|
119
|
+
* @param {object} [opts]
|
|
120
|
+
* @param {Record<string,string|undefined>} [opts.env]
|
|
121
|
+
* @returns {{pm: string, source: string, evidence: string, root: string|null,
|
|
122
|
+
* known: boolean, ambiguous?: string[]}}
|
|
123
|
+
*/
|
|
124
|
+
export function detect(dir = process.cwd(), { env = process.env } = {}) {
|
|
125
|
+
const raw = (env.MA_PACKAGE_MANAGER || "").trim().toLowerCase();
|
|
126
|
+
// The output of this module is pasted into a shell line, so the name is held to
|
|
127
|
+
// the same shape a manager's binary actually has. Anything else is dropped and
|
|
128
|
+
// the resolution continues from the repo - an override is not worth executing
|
|
129
|
+
// whatever a stray environment variable happens to contain.
|
|
130
|
+
const override = /^[a-z][a-z0-9-]*$/.test(raw) ? raw : "";
|
|
131
|
+
if (override) {
|
|
132
|
+
return {
|
|
133
|
+
pm: override,
|
|
134
|
+
source: "env",
|
|
135
|
+
evidence: "MA_PACKAGE_MANAGER",
|
|
136
|
+
root: null,
|
|
137
|
+
known: KNOWN.includes(override),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
if (raw) {
|
|
141
|
+
process.emitWarning(
|
|
142
|
+
`MA_PACKAGE_MANAGER=${JSON.stringify(raw)} is not a package manager name - ignored`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
for (const cur of ancestors(dir)) {
|
|
147
|
+
const pkgPath = join(cur, "package.json");
|
|
148
|
+
if (existsSync(pkgPath)) {
|
|
149
|
+
const declared = parseManagerField(readJson(pkgPath)?.packageManager);
|
|
150
|
+
if (declared) {
|
|
151
|
+
return {
|
|
152
|
+
pm: declared,
|
|
153
|
+
source: "packageManager-field",
|
|
154
|
+
evidence: `${pkgPath}#packageManager`,
|
|
155
|
+
root: cur,
|
|
156
|
+
known: KNOWN.includes(declared),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const locks = locksIn(cur);
|
|
162
|
+
if (locks.length === 1) {
|
|
163
|
+
return {
|
|
164
|
+
pm: locks[0].pm,
|
|
165
|
+
source: "lockfile",
|
|
166
|
+
evidence: join(cur, locks[0].file),
|
|
167
|
+
root: cur,
|
|
168
|
+
known: true,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
if (locks.length > 1) {
|
|
172
|
+
// Two lock files almost always means a migration that left one behind.
|
|
173
|
+
// Newest wins, and every candidate is reported: a resolver that silently
|
|
174
|
+
// picks one of two committed lock files is how a repo ends up building
|
|
175
|
+
// with the manager it migrated AWAY from.
|
|
176
|
+
const sorted = [...locks].sort((a, b) => b.mtime - a.mtime);
|
|
177
|
+
return {
|
|
178
|
+
pm: sorted[0].pm,
|
|
179
|
+
source: "lockfile-newest",
|
|
180
|
+
evidence: join(cur, sorted[0].file),
|
|
181
|
+
root: cur,
|
|
182
|
+
known: true,
|
|
183
|
+
ambiguous: sorted.map((l) => l.file),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
pm: "npm",
|
|
190
|
+
source: "default",
|
|
191
|
+
evidence: "no lock file, no declaration",
|
|
192
|
+
root: null,
|
|
193
|
+
known: true,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Does the resolved root declare this script? */
|
|
198
|
+
export function hasScript(dir, script) {
|
|
199
|
+
for (const cur of ancestors(dir)) {
|
|
200
|
+
const pkgPath = join(cur, "package.json");
|
|
201
|
+
if (!existsSync(pkgPath)) continue;
|
|
202
|
+
const scripts = readJson(pkgPath)?.scripts;
|
|
203
|
+
if (scripts && typeof scripts === "object") return Object.hasOwn(scripts, script);
|
|
204
|
+
}
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* `npm run build` for every manager, deliberately.
|
|
210
|
+
*
|
|
211
|
+
* `pnpm build` and `yarn build` also work, but only until a script shares a name
|
|
212
|
+
* with a builtin (`pnpm test`, `yarn add`, `bun install`): then the builtin wins
|
|
213
|
+
* and the repo's own script never runs. The explicit `run` form has no such
|
|
214
|
+
* collision on any of the four.
|
|
215
|
+
*/
|
|
216
|
+
export function runCommand(pm, script, args = []) {
|
|
217
|
+
const extra = args.length ? ` ${args.join(" ")}` : "";
|
|
218
|
+
return `${pm} run ${script}${extra}`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The test command, with pass-through arguments.
|
|
223
|
+
*
|
|
224
|
+
* npm is the odd one: it needs `--` to stop eating the arguments itself. The
|
|
225
|
+
* other three forward them to the script as written, and an extra `--` becomes a
|
|
226
|
+
* literal argument the test runner then has to ignore.
|
|
227
|
+
*/
|
|
228
|
+
export function testCommand(pm, args = []) {
|
|
229
|
+
if (!args.length) return pm === "bun" ? "bun run test" : `${pm} run test`;
|
|
230
|
+
const sep = pm === "npm" ? " -- " : " ";
|
|
231
|
+
return `${pm === "bun" ? "bun run test" : `${pm} run test`}${sep}${args.join(" ")}`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** The install command. `--frozen-lockfile` is NOT added: that is a CI decision. */
|
|
235
|
+
export function installCommand(pm) {
|
|
236
|
+
return pm === "yarn" ? "yarn install" : `${pm} install`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
function usage(msg) {
|
|
242
|
+
if (msg) process.stderr.write(`package-manager: ${msg}\n`);
|
|
243
|
+
process.stderr.write(
|
|
244
|
+
"usage: package-manager.mjs detect|test|run|install --dir <d> [--script <s>] [--pattern <p>]\n",
|
|
245
|
+
);
|
|
246
|
+
process.exit(2);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function main(argv) {
|
|
250
|
+
const mode = argv[0];
|
|
251
|
+
if (!mode || mode.startsWith("-")) usage("a mode is required");
|
|
252
|
+
const opts = {};
|
|
253
|
+
for (let i = 1; i < argv.length; i += 1) {
|
|
254
|
+
const a = argv[i];
|
|
255
|
+
if (a === "--dir") opts.dir = argv[++i];
|
|
256
|
+
else if (a === "--script") opts.script = argv[++i];
|
|
257
|
+
else if (a === "--pattern") opts.pattern = argv[++i];
|
|
258
|
+
else if (a === "-h" || a === "--help") usage();
|
|
259
|
+
else usage(`unexpected argument ${a}`);
|
|
260
|
+
}
|
|
261
|
+
const dir = opts.dir || process.cwd();
|
|
262
|
+
const d = detect(dir);
|
|
263
|
+
|
|
264
|
+
if (mode === "detect") {
|
|
265
|
+
process.stdout.write(`${JSON.stringify(d)}\n`);
|
|
266
|
+
return 0;
|
|
267
|
+
}
|
|
268
|
+
if (mode === "install") {
|
|
269
|
+
process.stdout.write(`${installCommand(d.pm)}\n`);
|
|
270
|
+
return 0;
|
|
271
|
+
}
|
|
272
|
+
if (mode === "test") {
|
|
273
|
+
if (!hasScript(dir, "test")) {
|
|
274
|
+
process.stderr.write("package-manager: no test script declared in package.json\n");
|
|
275
|
+
return 3;
|
|
276
|
+
}
|
|
277
|
+
const args = opts.pattern ? [opts.pattern] : [];
|
|
278
|
+
process.stdout.write(`${testCommand(d.pm, args)}\n`);
|
|
279
|
+
return 0;
|
|
280
|
+
}
|
|
281
|
+
if (mode === "run") {
|
|
282
|
+
if (!opts.script) usage("run needs --script");
|
|
283
|
+
if (!hasScript(dir, opts.script)) {
|
|
284
|
+
process.stderr.write(`package-manager: no ${opts.script} script declared in package.json\n`);
|
|
285
|
+
return 3;
|
|
286
|
+
}
|
|
287
|
+
process.stdout.write(`${runCommand(d.pm, opts.script)}\n`);
|
|
288
|
+
return 0;
|
|
289
|
+
}
|
|
290
|
+
usage(`unknown mode ${mode}`);
|
|
291
|
+
return 2;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Not `file://${process.argv[1]}`: a path with a space or a non-ASCII character
|
|
295
|
+
// percent-encodes in import.meta.url, and on macOS /tmp is a symlink to
|
|
296
|
+
// /private/tmp so argv[1] and the resolved module URL disagree. Either one
|
|
297
|
+
// silently turns this file into a library that prints nothing and exits 0.
|
|
298
|
+
function isEntrypoint() {
|
|
299
|
+
const arg = process.argv[1];
|
|
300
|
+
if (!arg) return false;
|
|
301
|
+
try {
|
|
302
|
+
return import.meta.url === pathToFileURL(realpathSync(arg)).href;
|
|
303
|
+
} catch {
|
|
304
|
+
return import.meta.url === pathToFileURL(arg).href;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (isEntrypoint()) {
|
|
309
|
+
process.exit(main(process.argv.slice(2)));
|
|
310
|
+
}
|