yadflow 3.12.0 → 3.12.2
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 +15 -0
- package/cli/manifest.mjs +4 -2
- package/cli/plan.mjs +156 -10
- package/cli/reconcile.mjs +51 -4
- package/cli/setup.mjs +23 -9
- package/package.json +1 -1
- package/skills/yad-checks/references/check-gates.md +7 -0
- package/skills/yad-checks/templates/checks/verified-commits.sh +24 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
## [3.12.2](https://github.com/abdelrahmannasr/yadflow/compare/v3.12.1...v3.12.2) (2026-07-14)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* **checks:** waive verified-commits signature for content-free merge commits ([1e73837](https://github.com/abdelrahmannasr/yadflow/commit/1e738372f821e93020069b565d40315cd7be2591)), closes [#138](https://github.com/abdelrahmannasr/yadflow/issues/138)
|
|
7
|
+
|
|
8
|
+
## [3.12.1](https://github.com/abdelrahmannasr/yadflow/compare/v3.12.0...v3.12.1) (2026-07-14)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **cli:** reject unsafe detected IDE targets and opencode write destinations ([792a40b](https://github.com/abdelrahmannasr/yadflow/commit/792a40b399b92f8db9d560e314110c432b98d93e)), closes [#134](https://github.com/abdelrahmannasr/yadflow/issues/134)
|
|
14
|
+
* **cli:** repair and validate persisted IDE targets ([81242ed](https://github.com/abdelrahmannasr/yadflow/commit/81242ed9a075ea067acb2f4497a745ee40e540a6))
|
|
15
|
+
|
|
1
16
|
# [3.12.0](https://github.com/abdelrahmannasr/yadflow/compare/v3.11.1...v3.12.0) (2026-07-11)
|
|
2
17
|
|
|
3
18
|
|
package/cli/manifest.mjs
CHANGED
|
@@ -116,8 +116,10 @@ export const LEGACY_HUB_FILES = {
|
|
|
116
116
|
};
|
|
117
117
|
|
|
118
118
|
// IDE install targets (relative to the target project root).
|
|
119
|
-
export const IDE_FOLDER_TARGETS = ['.claude', '.agents', '.zencoder']; // <ide>/skills/<skill>/ (folder copy)
|
|
120
|
-
export const
|
|
119
|
+
export const IDE_FOLDER_TARGETS = Object.freeze(['.claude', '.agents', '.zencoder']); // <ide>/skills/<skill>/ (folder copy)
|
|
120
|
+
export const IDE_OPENCODE_TARGET = '.opencode';
|
|
121
|
+
export const IDE_TARGETS = Object.freeze([...IDE_FOLDER_TARGETS, IDE_OPENCODE_TARGET]);
|
|
122
|
+
export const IDE_OPENCODE_DIR = `${IDE_OPENCODE_TARGET}/commands`; // <skill>.md (flat SKILL.md copy)
|
|
121
123
|
|
|
122
124
|
// Module registration files copied from skills/sdlc/ into _bmad/sdlc/.
|
|
123
125
|
export const MODULE_FILES = ['config.yaml', 'module-help.csv'];
|
package/cli/plan.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
asset, exists, copyDir, copyFile, dirMatches, sameContent, readJSON,
|
|
8
8
|
} from './lib.mjs';
|
|
9
9
|
import {
|
|
10
|
-
SKILLS,
|
|
10
|
+
SKILLS, IDE_TARGETS, IDE_OPENCODE_DIR, MODULE_FILES, wiringFor, HUB_WIRING, PROJECT_FILES,
|
|
11
11
|
LEGACY_SKILLS, REMOVED_SKILLS, LEGACY_MARKER, LEGACY_REPO_FILES, LEGACY_HUB_FILES,
|
|
12
12
|
} from './manifest.mjs';
|
|
13
13
|
|
|
@@ -36,13 +36,149 @@ const dirAction = (scope, item, src, dest, { root } = {}) => ({
|
|
|
36
36
|
apply: () => copyDir(src, dest),
|
|
37
37
|
});
|
|
38
38
|
|
|
39
|
-
//
|
|
40
|
-
//
|
|
39
|
+
// Persisted state gets one deliberately narrow compatibility repair. Explicit setup/planner input
|
|
40
|
+
// does not: a caller typo is an error, while the known v3.11.1 `.cluade` stamp is safely migrated.
|
|
41
|
+
const PERSISTED_IDE_ALIASES = new Map([['.cluade', '.claude']]);
|
|
42
|
+
const IDE_TARGET_ERROR_CODE = 'YAD_IDE_TARGET';
|
|
43
|
+
const sameTargets = (a, b) => Array.isArray(a) && a.length === b.length && a.every((v, i) => v === b[i]);
|
|
44
|
+
const displayTarget = (value) => {
|
|
45
|
+
if (value === undefined) return 'undefined';
|
|
46
|
+
try { return JSON.stringify(value) ?? String(value); } catch { return String(value); }
|
|
47
|
+
};
|
|
48
|
+
const ideTargetError = (message) => Object.assign(new Error(message), { code: IDE_TARGET_ERROR_CODE });
|
|
49
|
+
const lstatIfPresent = (full) => {
|
|
50
|
+
try {
|
|
51
|
+
return fs.lstatSync(full);
|
|
52
|
+
} catch (e) {
|
|
53
|
+
if (e?.code === 'ENOENT') return null;
|
|
54
|
+
throw e;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
const ideContainers = (ide) => ide === '.opencode'
|
|
58
|
+
? [ide, IDE_OPENCODE_DIR]
|
|
59
|
+
: [ide, path.join(ide, 'skills')];
|
|
60
|
+
|
|
61
|
+
function assertSafeIdeContainers(root, ide) {
|
|
62
|
+
for (const relPath of ideContainers(ide)) {
|
|
63
|
+
const stat = lstatIfPresent(path.join(root, relPath));
|
|
64
|
+
if (!stat) continue;
|
|
65
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
66
|
+
const kind = stat.isSymbolicLink() ? 'a symbolic link' : 'not a directory';
|
|
67
|
+
throw ideTargetError(`unsafe IDE target '${ide}': ${relPath} is ${kind}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function assertSafeOpenCodeWriteDestinations(root, skills) {
|
|
73
|
+
for (const skill of new Set(skills)) {
|
|
74
|
+
const relPath = path.join(IDE_OPENCODE_DIR, `${skill}.md`);
|
|
75
|
+
const stat = lstatIfPresent(path.join(root, relPath));
|
|
76
|
+
if (!stat) continue;
|
|
77
|
+
let kind = null;
|
|
78
|
+
if (stat.isSymbolicLink()) kind = 'a symbolic link';
|
|
79
|
+
else if (!stat.isFile()) kind = 'not a regular file';
|
|
80
|
+
else if (stat.nlink > 1) kind = 'linked to multiple paths';
|
|
81
|
+
if (kind) throw ideTargetError(`unsafe IDE target '.opencode': ${relPath} is ${kind}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Pure target inspection. Valid entries are trimmed and deduplicated in input order; unsupported
|
|
86
|
+
// entries are returned to the caller for reporting rather than ever becoming filesystem paths.
|
|
87
|
+
export function normalizeIdeTargets(input, { repairAliases = false } = {}) {
|
|
88
|
+
const shapeValid = Array.isArray(input);
|
|
89
|
+
const invalid = shapeValid ? [] : [input];
|
|
90
|
+
const repaired = [];
|
|
91
|
+
const targets = [];
|
|
92
|
+
const seen = new Set();
|
|
93
|
+
for (const raw of shapeValid ? input : []) {
|
|
94
|
+
if (typeof raw !== 'string') { invalid.push(raw); continue; }
|
|
95
|
+
const trimmed = raw.trim();
|
|
96
|
+
const target = repairAliases ? (PERSISTED_IDE_ALIASES.get(trimmed) || trimmed) : trimmed;
|
|
97
|
+
if (!IDE_TARGETS.includes(target)) { invalid.push(raw); continue; }
|
|
98
|
+
if (target !== trimmed) repaired.push({ from: trimmed, to: target });
|
|
99
|
+
if (!seen.has(target)) { seen.add(target); targets.push(target); }
|
|
100
|
+
}
|
|
101
|
+
return { targets, invalid, repaired, shapeValid };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Strict boundary for every explicit action-builder/setup input. Returning only canonical values
|
|
105
|
+
// makes path construction below safe by construction.
|
|
106
|
+
export function canonicalIdeTargets(input) {
|
|
107
|
+
const state = normalizeIdeTargets(input);
|
|
108
|
+
if (!state.shapeValid) {
|
|
109
|
+
throw ideTargetError(`IDE targets must be a non-empty array (supported: ${IDE_TARGETS.join(', ')})`);
|
|
110
|
+
}
|
|
111
|
+
if (state.invalid.length) {
|
|
112
|
+
throw ideTargetError(`unsupported IDE target(s): ${state.invalid.map(displayTarget).join(', ')} (supported: ${IDE_TARGETS.join(', ')})`);
|
|
113
|
+
}
|
|
114
|
+
if (!state.targets.length) {
|
|
115
|
+
throw ideTargetError(`at least one IDE target is required (supported: ${IDE_TARGETS.join(', ')})`);
|
|
116
|
+
}
|
|
117
|
+
return state.targets;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// A canonical relative name is necessary but not sufficient: an existing IDE root (or its install
|
|
121
|
+
// container) could be a file or symlink that redirects writes/removals outside the project. Validate
|
|
122
|
+
// every target before constructing ANY actions, so a bad later target cannot cause a partial install.
|
|
123
|
+
export function safeIdeTargetsFor(root, input) {
|
|
124
|
+
const targets = canonicalIdeTargets(input);
|
|
125
|
+
for (const ide of targets) assertSafeIdeContainers(root, ide);
|
|
126
|
+
return targets;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Fallback discovery must not promote a supported-looking file/symlink into an install target.
|
|
130
|
+
// Keep unsafe entries for diagnostics, while returning only real IDE directories with safe install
|
|
131
|
+
// containers. Unexpected filesystem errors remain fatal instead of being mistaken for bad input.
|
|
132
|
+
export function detectedIdeTargetStateFor(root) {
|
|
133
|
+
const targets = [];
|
|
134
|
+
const unsafe = [];
|
|
135
|
+
for (const ide of IDE_TARGETS) {
|
|
136
|
+
if (!lstatIfPresent(path.join(root, ide))) continue;
|
|
137
|
+
try {
|
|
138
|
+
assertSafeIdeContainers(root, ide);
|
|
139
|
+
targets.push(ide);
|
|
140
|
+
} catch (e) {
|
|
141
|
+
if (e?.code !== IDE_TARGET_ERROR_CODE) throw e;
|
|
142
|
+
unsafe.push({ target: ide, message: e.message });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return { targets, unsafe };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Which IDE targets this project wants. Persisted values are recovery-oriented: repair the one known
|
|
149
|
+
// alias, filter everything else, then fall back to supported IDE dirs already present (or .claude).
|
|
150
|
+
// The full state lets reconcile report drift without mutating during a read-only check.
|
|
151
|
+
export function ideTargetStateFor(root) {
|
|
152
|
+
const stampPath = path.join(root, PROJECT_FILES.version);
|
|
153
|
+
const hasStamp = exists(stampPath);
|
|
154
|
+
const rec = readJSON(stampPath);
|
|
155
|
+
const recordIsObject = !!rec && typeof rec === 'object' && !Array.isArray(rec);
|
|
156
|
+
const hasField = recordIsObject && Object.hasOwn(rec, 'ideTargets');
|
|
157
|
+
const raw = hasField ? rec.ideTargets : undefined;
|
|
158
|
+
const normalized = normalizeIdeTargets(raw, { repairAliases: true });
|
|
159
|
+
let targets = normalized.targets;
|
|
160
|
+
let usedFallback = false;
|
|
161
|
+
let unsafeDetected = [];
|
|
162
|
+
if (!targets.length) {
|
|
163
|
+
const detected = detectedIdeTargetStateFor(root);
|
|
164
|
+
targets = detected.targets.length ? detected.targets : ['.claude'];
|
|
165
|
+
unsafeDetected = detected.unsafe;
|
|
166
|
+
usedFallback = true;
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
...normalized,
|
|
170
|
+
targets,
|
|
171
|
+
hasStamp,
|
|
172
|
+
recordIsObject,
|
|
173
|
+
hasField,
|
|
174
|
+
usedFallback,
|
|
175
|
+
unsafeDetected,
|
|
176
|
+
needsRepair: hasStamp && !sameTargets(raw, targets),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
41
180
|
export function ideTargetsFor(root) {
|
|
42
|
-
|
|
43
|
-
if (rec?.ideTargets?.length) return rec.ideTargets;
|
|
44
|
-
const present = [...IDE_FOLDER_TARGETS, '.opencode'].filter((d) => exists(path.join(root, d)));
|
|
45
|
-
return present.length ? present : ['.claude'];
|
|
181
|
+
return ideTargetStateFor(root).targets;
|
|
46
182
|
}
|
|
47
183
|
|
|
48
184
|
// A brand-new first-party skill is `missing` on every existing install. Relabel that to status `'new'`
|
|
@@ -53,8 +189,10 @@ const asNewSkill = (a) => (a.status === 'missing' ? { ...a, status: 'new' } : a)
|
|
|
53
189
|
|
|
54
190
|
// Module = skills installed into each IDE target + the _bmad/sdlc registration.
|
|
55
191
|
export function moduleActions(root, ideTargets = ideTargetsFor(root)) {
|
|
192
|
+
const targets = safeIdeTargetsFor(root, ideTargets);
|
|
193
|
+
if (targets.includes('.opencode')) assertSafeOpenCodeWriteDestinations(root, SKILLS);
|
|
56
194
|
const actions = [];
|
|
57
|
-
for (const ide of
|
|
195
|
+
for (const ide of targets) {
|
|
58
196
|
if (ide === '.opencode') {
|
|
59
197
|
for (const s of SKILLS) {
|
|
60
198
|
actions.push(asNewSkill(fileAction(
|
|
@@ -92,8 +230,15 @@ export function moduleActions(root, ideTargets = ideTargetsFor(root)) {
|
|
|
92
230
|
// one, so a single update completes the rename even when the new copy would otherwise be
|
|
93
231
|
// skipped as missing-scope.
|
|
94
232
|
export function legacyModuleActions(root, ideTargets = ideTargetsFor(root)) {
|
|
233
|
+
const targets = safeIdeTargetsFor(root, ideTargets);
|
|
234
|
+
if (targets.includes('.opencode')) {
|
|
235
|
+
const writes = Object.entries(LEGACY_SKILLS)
|
|
236
|
+
.filter(([, old]) => exists(path.join(root, IDE_OPENCODE_DIR, `${old}.md`)))
|
|
237
|
+
.map(([skill]) => skill);
|
|
238
|
+
assertSafeOpenCodeWriteDestinations(root, writes);
|
|
239
|
+
}
|
|
95
240
|
const actions = [];
|
|
96
|
-
for (const ide of
|
|
241
|
+
for (const ide of targets) {
|
|
97
242
|
for (const [skill, old] of Object.entries(LEGACY_SKILLS)) {
|
|
98
243
|
if (ide === '.opencode') {
|
|
99
244
|
const oldDest = path.join(root, IDE_OPENCODE_DIR, `${old}.md`);
|
|
@@ -137,8 +282,9 @@ export function legacyModuleActions(root, ideTargets = ideTargetsFor(root)) {
|
|
|
137
282
|
// clean tree yields nothing and the purge is idempotent. apply() just deletes the install (no
|
|
138
283
|
// replacement — that is what makes this a removal, not a rename).
|
|
139
284
|
export function removedModuleActions(root, ideTargets = ideTargetsFor(root)) {
|
|
285
|
+
const targets = safeIdeTargetsFor(root, ideTargets);
|
|
140
286
|
const actions = [];
|
|
141
|
-
for (const ide of
|
|
287
|
+
for (const ide of targets) {
|
|
142
288
|
for (const skill of REMOVED_SKILLS) {
|
|
143
289
|
if (ide === '.opencode') {
|
|
144
290
|
const dest = path.join(root, IDE_OPENCODE_DIR, `${skill}.md`);
|
package/cli/reconcile.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import { VERSION, PROJECT_FILES } from './manifest.mjs';
|
|
|
14
14
|
import {
|
|
15
15
|
moduleActions, repoActions, hubActions, authorsActions,
|
|
16
16
|
legacyModuleActions, removedModuleActions, legacyRepoActions, legacyHubActions,
|
|
17
|
+
ideTargetStateFor,
|
|
17
18
|
} from './plan.mjs';
|
|
18
19
|
import { gitHead, packRepo } from './setup.mjs';
|
|
19
20
|
import { groupByRoot, commitUpdates } from './update-commit.mjs';
|
|
@@ -31,14 +32,36 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
|
|
|
31
32
|
const registry = readJSON(path.join(root, PROJECT_FILES.reposRegistry), { repos: [] });
|
|
32
33
|
if (!exists(path.join(root, PROJECT_FILES.reposRegistry))) gaps.push('no repos registered (.sdlc/repos.json absent)');
|
|
33
34
|
|
|
35
|
+
// Resolve untrusted persisted IDE targets once, before any filesystem action is constructed.
|
|
36
|
+
// The returned list contains canonical allowlisted roots only.
|
|
37
|
+
const ideState = ideTargetStateFor(root);
|
|
38
|
+
const ideTargets = ideState.targets;
|
|
39
|
+
const stampPath = path.join(root, PROJECT_FILES.version);
|
|
40
|
+
const writeCanonicalStamp = () => {
|
|
41
|
+
const current = readJSON(stampPath, {});
|
|
42
|
+
const record = current && typeof current === 'object' && !Array.isArray(current) ? current : {};
|
|
43
|
+
writeJSON(stampPath, { ...record, version: VERSION, ideTargets });
|
|
44
|
+
};
|
|
45
|
+
|
|
34
46
|
// --- deterministic file actions (module + hub CI + author allowlists + every registered repo),
|
|
35
47
|
// plus pre-2.0 sdlc-* -> yad-* migrations ('legacy': old name installed; rename in place)
|
|
36
48
|
// and purge of skills removed in a later release ('removed': delete the lingering install) ---
|
|
37
49
|
const actions = [
|
|
38
|
-
...moduleActions(root), ...legacyModuleActions(root), ...removedModuleActions(root),
|
|
50
|
+
...moduleActions(root, ideTargets), ...legacyModuleActions(root, ideTargets), ...removedModuleActions(root, ideTargets),
|
|
39
51
|
...hubActions(root), ...legacyHubActions(root),
|
|
40
52
|
...authorsActions(root, registry.repos),
|
|
41
53
|
];
|
|
54
|
+
if (ideState.needsRepair) {
|
|
55
|
+
actions.push({
|
|
56
|
+
scope: 'hub',
|
|
57
|
+
item: `${PROJECT_FILES.version} ideTargets`,
|
|
58
|
+
status: 'outdated',
|
|
59
|
+
root,
|
|
60
|
+
paths: [PROJECT_FILES.version],
|
|
61
|
+
// The canonical stamp is written once, after every filesystem action succeeds below.
|
|
62
|
+
apply: () => undefined,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
42
65
|
for (const repo of registry.repos) actions.push(...repoActions(root, repo), ...legacyRepoActions(root, repo));
|
|
43
66
|
|
|
44
67
|
// --- stale code-context (HEAD moved since last pack) ---
|
|
@@ -77,6 +100,30 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
|
|
|
77
100
|
for (const i of notOk) log(` ${MARK[i.status]} ${i.item}`);
|
|
78
101
|
}
|
|
79
102
|
for (const g of gaps) warn(g);
|
|
103
|
+
const shownInvalid = ideState.invalid.map((v) => {
|
|
104
|
+
try { return JSON.stringify(v) ?? String(v); } catch { return String(v); }
|
|
105
|
+
});
|
|
106
|
+
if (ideState.hasStamp && !ideState.recordIsObject) {
|
|
107
|
+
warn(`${PROJECT_FILES.version}: version stamp is unreadable or not a JSON object; using safe targets: ${ideTargets.join(', ')}`);
|
|
108
|
+
} else if (ideState.hasStamp && !ideState.hasField) {
|
|
109
|
+
warn(`${PROJECT_FILES.version}: ideTargets is missing; using safe targets: ${ideTargets.join(', ')}`);
|
|
110
|
+
} else if (ideState.hasStamp && !ideState.shapeValid) {
|
|
111
|
+
warn(`${PROJECT_FILES.version}: ideTargets is not an array; ignored persisted value: ${shownInvalid.join(', ')}; using safe targets: ${ideTargets.join(', ')}`);
|
|
112
|
+
} else if (ideState.hasStamp && ideState.usedFallback && !ideState.invalid.length) {
|
|
113
|
+
warn(`${PROJECT_FILES.version}: ideTargets is empty; using safe targets: ${ideTargets.join(', ')}`);
|
|
114
|
+
}
|
|
115
|
+
if (ideState.invalid.length && ideState.shapeValid) {
|
|
116
|
+
warn(`${PROJECT_FILES.version}: ignored unsupported persisted IDE target(s): ${shownInvalid.join(', ')}`);
|
|
117
|
+
}
|
|
118
|
+
if (ideState.repaired.length) {
|
|
119
|
+
warn(`${PROJECT_FILES.version}: persisted .cluade target will be repaired to .claude`);
|
|
120
|
+
}
|
|
121
|
+
for (const unsafe of ideState.unsafeDetected) {
|
|
122
|
+
warn(`${PROJECT_FILES.version}: ignored unsafe detected IDE path '${unsafe.target}'; ${unsafe.message}; using safe targets: ${ideTargets.join(', ')}`);
|
|
123
|
+
}
|
|
124
|
+
if (exists(path.join(root, '.cluade'))) {
|
|
125
|
+
warn('existing .cluade path was left untouched; review its contents and remove it manually');
|
|
126
|
+
}
|
|
80
127
|
|
|
81
128
|
const fixable = actions.filter((a) =>
|
|
82
129
|
a.status !== 'ok' && (scope === 'all' ? true : a.status !== 'missing'),
|
|
@@ -103,9 +150,9 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
|
|
|
103
150
|
if (force) {
|
|
104
151
|
for (const a of actions.filter((a) => a.status === 'ok')) { a.apply(); appliedActions.push(a); }
|
|
105
152
|
}
|
|
106
|
-
//
|
|
107
|
-
|
|
108
|
-
|
|
153
|
+
// Refresh the version stamp and persist only the canonical targets used to build actions. This also
|
|
154
|
+
// completes legacy/corrupt target migration even when no skill content itself needed an update.
|
|
155
|
+
writeCanonicalStamp();
|
|
109
156
|
appliedActions.push({ scope: 'hub', item: PROJECT_FILES.version, status: 'stamp', root, paths: [PROJECT_FILES.version] });
|
|
110
157
|
applied ? ok(`reconciled ${applied} item(s)`) : info('nothing to fix');
|
|
111
158
|
if (gaps.length) hand('one-time setup still missing — run `yad setup`.');
|
package/cli/setup.mjs
CHANGED
|
@@ -6,10 +6,11 @@ import {
|
|
|
6
6
|
c, log, step, guide, ok, info, warn, hand, fail, ask, askYesNo, run, has,
|
|
7
7
|
exists, readJSON, readJSONStrict, writeJSON,
|
|
8
8
|
} from './lib.mjs';
|
|
9
|
-
import { VERSION,
|
|
9
|
+
import { VERSION, IDE_TARGETS, PROJECT_FILES, DESIGN_TOOLS, DESIGN_PRIMARY, TESTING_TOOLS, TESTING_PRIMARY, LEARNING_TOOLS, LEARNING_PRIMARY } from './manifest.mjs';
|
|
10
10
|
import {
|
|
11
11
|
moduleActions, repoActions, hubActions, authorsActions,
|
|
12
12
|
legacyModuleActions, removedModuleActions, legacyRepoActions, legacyHubActions,
|
|
13
|
+
safeIdeTargetsFor, detectedIdeTargetStateFor,
|
|
13
14
|
} from './plan.mjs';
|
|
14
15
|
import { validateLogin, rolesForScope } from './platform.mjs';
|
|
15
16
|
|
|
@@ -34,7 +35,26 @@ export function parseRolesSpec(s) {
|
|
|
34
35
|
return out;
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
|
|
38
|
+
// Programmatic setup is strict; interactive setup keeps asking until it receives at least one valid
|
|
39
|
+
// canonical target. Both paths return the same trimmed, ordered, deduplicated representation.
|
|
40
|
+
export async function selectIdeTargets(root, provided, asker = ask) {
|
|
41
|
+
if (provided !== undefined) return safeIdeTargetsFor(root, provided);
|
|
42
|
+
const detected = detectedIdeTargetStateFor(root);
|
|
43
|
+
const present = detected.targets;
|
|
44
|
+
for (const unsafe of detected.unsafe) warn(`${unsafe.message}; excluded from IDE defaults`);
|
|
45
|
+
const def = (present.length ? present : ['.claude']).join(',');
|
|
46
|
+
for (;;) {
|
|
47
|
+
const answer = await asker(`IDE targets to install ${c.dim('(comma-separated: ' + IDE_TARGETS.join(', ') + ')')}`, def);
|
|
48
|
+
if (answer === undefined || answer === null) throw new Error('IDE target selection ended before a valid choice was provided');
|
|
49
|
+
const values = String(answer ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
50
|
+
try {
|
|
51
|
+
return safeIdeTargetsFor(root, values);
|
|
52
|
+
} catch (e) {
|
|
53
|
+
if (process.env.SDLC_NONINTERACTIVE || e?.code !== 'YAD_IDE_TARGET') throw e;
|
|
54
|
+
warn(e.message);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
38
58
|
|
|
39
59
|
export function detectPlatform(remoteUrl = '') {
|
|
40
60
|
if (/gitlab/i.test(remoteUrl)) return 'gitlab';
|
|
@@ -416,13 +436,7 @@ export async function runSetup(root, opts = {}) {
|
|
|
416
436
|
'Copies the yad-* skills into your AI tool(s) so they appear in Claude Code / agents / opencode.',
|
|
417
437
|
'Enter the IDE folders to install into, comma-separated; default = whatever is already present.',
|
|
418
438
|
]);
|
|
419
|
-
|
|
420
|
-
if (!ideTargets) {
|
|
421
|
-
const present = ALL_IDES.filter((d) => exists(path.join(root, d)));
|
|
422
|
-
const def = (present.length ? present : ['.claude']).join(',');
|
|
423
|
-
const answer = await ask(`IDE targets to install ${c.dim('(comma-separated: ' + ALL_IDES.join(', ') + ')')}`, def);
|
|
424
|
-
ideTargets = answer.split(',').map((s) => s.trim()).filter(Boolean);
|
|
425
|
-
}
|
|
439
|
+
const ideTargets = await selectIdeTargets(root, opts.ideTargets);
|
|
426
440
|
applyActions(moduleActions(root, ideTargets), { force: true });
|
|
427
441
|
// Migrate any pre-2.0 install in place: remove the old sdlc-* skill copies in the project's
|
|
428
442
|
// IDE targets and install their yad-* renames. Without this, setup only ADDED yad-* and left
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yadflow",
|
|
3
|
-
"version": "3.12.
|
|
3
|
+
"version": "3.12.2",
|
|
4
4
|
"description": "Yadflow — the gated, team, multi-repo SDLC: author → review → build with a PR-driven review gate and a zero-dependency `yad` CLI (setup, gate, commit, open-pr, ship, repo, thread, reconcile). A BMAD module + 38 yad-* skills.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "AbdelRahman Nasr",
|
|
@@ -93,6 +93,13 @@ repo. For each commit in `<base>..HEAD`, two independent checks:
|
|
|
93
93
|
(2+ parents) — a merge's author is whoever pressed merge (often a platform noreply), not a roster
|
|
94
94
|
human, and its content already passed the PR gate suite. This waiver matters for the push-on-default
|
|
95
95
|
`yad-update-guard` (§9), which — unlike this PR-triggered gate — sees merge commits.
|
|
96
|
+
A merge commit is **additionally signature-waived when it introduces no content of its own** (its
|
|
97
|
+
combined diff — `git diff-tree --cc` — is empty, i.e. no conflict-resolution or evil-merge hunks):
|
|
98
|
+
every change it carries already lives in an individually author+signature-checked parent, so there
|
|
99
|
+
is nothing to protect. This unblocks **self-hosted GitLab**, which does not sign UI-created merge
|
|
100
|
+
commits (the signature API returns 404) — without it every routine merge would red the branch. A
|
|
101
|
+
merge that *does* introduce content of its own still requires a verified signature (fail-closed),
|
|
102
|
+
so an evil merge pushed direct-to-default cannot smuggle in unverified changes.
|
|
96
103
|
|
|
97
104
|
Degradation is explicit, never silent: a missing allowlist SKIPs the author check with a warning
|
|
98
105
|
(configure roster emails, re-wire); no GitHub/GitLab remote SKIPs the signature check (the badge is a
|
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
# commits set the platform itself as the committer (e.g. noreply@github.com), and their integrity is
|
|
12
12
|
# covered by the signature check (the platform signs them).
|
|
13
13
|
#
|
|
14
|
+
# Merge-commit signature exemption: a merge that introduces NO content of its own (its combined diff
|
|
15
|
+
# is empty — no conflict-resolution / evil-merge hunks) is signature-waived, because every change it
|
|
16
|
+
# carries already lives in its individually author+signature-checked parents. This unblocks self-hosted
|
|
17
|
+
# GitLab, which does not sign UI-created merge commits (the signature API returns 404). A merge that
|
|
18
|
+
# DOES introduce content of its own still requires a verified signature (fail-closed).
|
|
19
|
+
#
|
|
14
20
|
# Degradation is explicit, never silent:
|
|
15
21
|
# - no allowlist file -> author check SKIPPED with a warning (configure emails, re-wire)
|
|
16
22
|
# - no GitHub/GitLab remote -> signature check SKIPPED with a warning (no platform, no badge)
|
|
@@ -88,6 +94,19 @@ signature_verified() {
|
|
|
88
94
|
esac
|
|
89
95
|
}
|
|
90
96
|
|
|
97
|
+
# 0 when a merge commit introduced NO content of its own. The combined diff (--cc) lists only hunks
|
|
98
|
+
# that differ from ALL parents, so empty output means every change lives in an individually-checked
|
|
99
|
+
# parent — there is no conflict-resolution or evil-merge content unique to the merge commit, hence
|
|
100
|
+
# nothing an unverified author could smuggle in past the per-parent author+signature checks.
|
|
101
|
+
merge_introduces_no_content() {
|
|
102
|
+
# `local` on its own line: `local out=$(...)` would mask the substitution's exit status (local
|
|
103
|
+
# always returns 0). A git error (e.g. a parent tree missing in a shallow clone) must fail closed —
|
|
104
|
+
# an empty stdout from a *failed* command is not evidence the merge is content-free.
|
|
105
|
+
local out
|
|
106
|
+
out="$(git diff-tree --cc --no-commit-id -r "$1")" || return 1
|
|
107
|
+
[ -z "$out" ]
|
|
108
|
+
}
|
|
109
|
+
|
|
91
110
|
rc=0
|
|
92
111
|
while IFS= read -r sha; do
|
|
93
112
|
[ -z "$sha" ] && continue
|
|
@@ -126,6 +145,11 @@ while IFS= read -r sha; do
|
|
|
126
145
|
if [ -n "$platform" ]; then
|
|
127
146
|
if signature_verified "$sha"; then
|
|
128
147
|
echo "PASS [verified-commits]: ${short} signature verified by ${platform}"
|
|
148
|
+
elif [ "$is_merge" = 1 ] && merge_introduces_no_content "$sha"; then
|
|
149
|
+
# Content-free merge: nothing to protect. Self-hosted GitLab does not sign UI merge commits, so
|
|
150
|
+
# requiring a signature here would block every routine merge. An evil merge (content of its own)
|
|
151
|
+
# falls through to FAIL below.
|
|
152
|
+
echo "WARN [verified-commits]: ${short} unsigned merge commit — introduces no content of its own (covered by verified parents); signature waived. (Self-hosted GitLab does not sign UI merge commits; on a platform that signs its merges an unsigned content-free merge is unusual but still harmless.)"
|
|
129
153
|
else
|
|
130
154
|
echo "FAIL [verified-commits]: ${short} signature missing/unverified — sign commits (GPG/SSH key registered on ${platform}), or the signature API was unreachable (GitLab: set GITLAB_TOKEN/SDLC_API_TOKEN with read_api)."
|
|
131
155
|
rc=1
|