yadflow 3.12.0 → 3.12.1

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 CHANGED
@@ -1,3 +1,11 @@
1
+ ## [3.12.1](https://github.com/abdelrahmannasr/yadflow/compare/v3.12.0...v3.12.1) (2026-07-14)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **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)
7
+ * **cli:** repair and validate persisted IDE targets ([81242ed](https://github.com/abdelrahmannasr/yadflow/commit/81242ed9a075ea067acb2f4497a745ee40e540a6))
8
+
1
9
  # [3.12.0](https://github.com/abdelrahmannasr/yadflow/compare/v3.11.1...v3.12.0) (2026-07-11)
2
10
 
3
11
 
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 IDE_OPENCODE_DIR = '.opencode/commands'; // <skill>.md (flat SKILL.md copy)
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, IDE_FOLDER_TARGETS, IDE_OPENCODE_DIR, MODULE_FILES, wiringFor, HUB_WIRING, PROJECT_FILES,
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
- // Which IDE targets this project wants. Recorded at setup time; falls back to
40
- // whichever IDE base dirs already exist, else .claude.
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
- const rec = readJSON(path.join(root, PROJECT_FILES.version));
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 ideTargets) {
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 ideTargets) {
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 ideTargets) {
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
- // refresh the version stamp (preserve recorded ideTargets) and let it ride the hub's update commit
107
- const rec = readJSON(path.join(root, PROJECT_FILES.version), {});
108
- writeJSON(path.join(root, PROJECT_FILES.version), { ...rec, version: VERSION });
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, IDE_FOLDER_TARGETS, PROJECT_FILES, DESIGN_TOOLS, DESIGN_PRIMARY, TESTING_TOOLS, TESTING_PRIMARY, LEARNING_TOOLS, LEARNING_PRIMARY } from './manifest.mjs';
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
- const ALL_IDES = [...IDE_FOLDER_TARGETS, '.opencode'];
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
- let ideTargets = opts.ideTargets;
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.0",
3
+ "version": "3.12.1",
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",