From 96c4c8b15c932e1da3f46aa2b1d638b019f368c6 Mon Sep 17 00:00:00 2001 From: axiosleo Date: Wed, 29 Jul 2026 10:45:15 +0800 Subject: [PATCH 1/6] feat: enhance SkillsCommand with local package resolution and dependency installation options, including new add-dep flag for automatic dependency addition --- commands/skills.js | 153 +++++++++++++++++++++++++--------------- src/cli/pkg.js | 157 ++++++++++++++++++++++++++++++++++++++++++ tests/skills.tests.js | 145 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 399 insertions(+), 56 deletions(-) create mode 100644 src/cli/pkg.js create mode 100644 tests/skills.tests.js diff --git a/commands/skills.js b/commands/skills.js index e21e2d0..02e0c09 100644 --- a/commands/skills.js +++ b/commands/skills.js @@ -11,6 +11,11 @@ const { _remove } = require('@axiosleo/cli-tool/src/helper/fs'); const { _exec } = require('@axiosleo/cli-tool/src/helper/cmd'); +const { + resolveLocalPkgDir, + detectPackageManager, + buildInstallCommand +} = require('../src/cli/pkg'); const PKG_NAME = '@axiosleo/koapp'; const TARGET_DIRS = { @@ -35,6 +40,7 @@ class SkillsCommand extends Command { this.addOption('install', 'i', 'Target AI tool: cursor | claude', 'required'); this.addOption('scope', 's', 'Install scope: project (default) | user', 'optional', 'project'); this.addOption('force', 'f', 'Overwrite existing skills without prompting', 'optional', false); + this.addOption('add-dep', 'a', `Also add ${PKG_NAME} to the project if missing`, 'optional', false); } resolveDestDir(target, scope) { @@ -46,75 +52,108 @@ class SkillsCommand extends Command { return path.join(base, sub); } - async resolveSourceDir() { - const runnerPkgDir = path.resolve(__dirname, '..'); - const localPkgDir = path.join(process.cwd(), 'node_modules', ...PKG_NAME.split('/')); + useRunnerAssets(state, reminder) { + state.sourceDir = path.join(state.runnerPkgDir, 'assets/skills'); + state.usingRunner = true; + if (reminder) { + state.updateReminder = reminder; + printer.warning('[skills] ' + reminder).println(); + } + return state; + } + + async resolveFromLocal(state, localPkgDir) { + state.localPkgDir = localPkgDir; + state.localVer = readPkgVersion(localPkgDir); + const localSkills = path.join(localPkgDir, 'assets/skills'); + if (await _exists(localSkills) && await _is_dir(localSkills)) { + state.sourceDir = localSkills; + if (state.localVer && state.runnerVer && state.localVer !== state.runnerVer) { + printer.warning( + `[skills] running ${PKG_NAME}@${state.runnerVer}, local install is ${state.localVer}` + ).println(); + } else if (state.localVer) { + printer.info(`[skills] installing from local ${PKG_NAME}@${state.localVer}`).println(); + } + return state; + } + return this.useRunnerAssets( + state, + `Local ${PKG_NAME}${state.localVer ? '@' + state.localVer : ''} does not ship skills assets. ` + + `Installed from runner ${PKG_NAME}${state.runnerVer ? '@' + state.runnerVer : ''} instead. ` + + 'Please update your local dependency: npm install ' + PKG_NAME + '@latest' + ); + } + async resolveSourceDir(addDep = false) { + const runnerPkgDir = path.resolve(__dirname, '..'); + const cwd = process.cwd(); const runnerVer = readPkgVersion(runnerPkgDir); + const pmInfo = detectPackageManager(cwd); + const installCmd = buildInstallCommand(pmInfo.pm, PKG_NAME, { + isWorkspaceRoot: pmInfo.isWorkspaceRoot + }); + const state = { runnerPkgDir, runnerVer, - localPkgDir, + localPkgDir: null, localVer: null, sourceDir: null, updateReminder: null, usingRunner: false }; - const localExists = await _exists(localPkgDir); - if (localExists) { - state.localVer = readPkgVersion(localPkgDir); - const localSkills = path.join(localPkgDir, 'assets/skills'); - if (await _exists(localSkills) && await _is_dir(localSkills)) { - state.sourceDir = localSkills; - if (state.localVer && state.runnerVer && state.localVer !== state.runnerVer) { - printer.warning( - `[skills] running ${PKG_NAME}@${state.runnerVer}, local install is ${state.localVer}` - ).println(); - } else if (state.localVer) { - printer.info(`[skills] installing from local ${PKG_NAME}@${state.localVer}`).println(); - } - return state; - } - // local install exists but lacks skills assets - state.sourceDir = path.join(runnerPkgDir, 'assets/skills'); - state.usingRunner = true; - state.updateReminder = - `Local ${PKG_NAME}${state.localVer ? '@' + state.localVer : ''} does not ship skills assets. ` + - `Installed from runner ${PKG_NAME}${state.runnerVer ? '@' + state.runnerVer : ''} instead. ` + - 'Please update your local dependency: npm install ' + PKG_NAME + '@latest'; - printer.warning('[skills] ' + state.updateReminder).println(); - return state; + const localPkgDir = resolveLocalPkgDir(PKG_NAME, cwd); + if (localPkgDir) { + return this.resolveFromLocal(state, localPkgDir); + } + + // Warn when cwd is inside a workspace but has no package.json of its own + if (pmInfo.rootDir !== path.resolve(cwd) && !(await _exists(path.join(cwd, 'package.json')))) { + printer.warning( + `[skills] no package.json in ${cwd}; detected project root at ${pmInfo.rootDir}` + ).println(); + } + + printer.info(`[skills] ${PKG_NAME} is not installed under ${cwd}`).println(); + + if (!addDep) { + printer.info( + `[skills] using runner assets. To add the dependency later: ${installCmd}` + ).println(); + return this.useRunnerAssets(state); } - printer.warning(`[skills] ${PKG_NAME} is not installed in ${process.cwd()}`).println(); const shouldInstall = await this.confirm( - `Install ${PKG_NAME} now? (required for consistent skill content)`, + `Install ${PKG_NAME} now via \`${installCmd}\`?`, true ); if (!shouldInstall) { - printer.info(`[skills] aborted. Run \`npm install ${PKG_NAME}\` and retry.`).println(); - return null; + printer.info( + `[skills] skipped install. Using runner assets. Hint: ${installCmd}` + ).println(); + return this.useRunnerAssets(state); } - await _exec(`npm install ${PKG_NAME}`, process.cwd()); - - if (await _exists(localPkgDir)) { - state.localVer = readPkgVersion(localPkgDir); - const localSkills = path.join(localPkgDir, 'assets/skills'); - if (await _exists(localSkills) && await _is_dir(localSkills)) { - state.sourceDir = localSkills; - return state; - } - state.sourceDir = path.join(runnerPkgDir, 'assets/skills'); - state.usingRunner = true; - state.updateReminder = - `Freshly installed ${PKG_NAME}${state.localVer ? '@' + state.localVer : ''} lacks skills assets. ` + - 'Using runner assets. Please upgrade: npm install ' + PKG_NAME + '@latest'; - printer.warning('[skills] ' + state.updateReminder).println(); - return state; + + try { + await _exec(installCmd, pmInfo.rootDir); + } catch (err) { + const reason = err && err.message ? err.message : String(err); + printer.error(`[skills] install failed: ${reason}`).println(); + printer.info('[skills] falling back to runner assets.').println(); + return this.useRunnerAssets(state); } - printer.error(`[skills] ${PKG_NAME} install appears to have failed.`).println(); - return null; + + const freshPkgDir = resolveLocalPkgDir(PKG_NAME, cwd); + if (freshPkgDir) { + return this.resolveFromLocal(state, freshPkgDir); + } + + printer.warning( + `[skills] ${PKG_NAME} install completed but package was not found; using runner assets.` + ).println(); + return this.useRunnerAssets(state); } async copySkill(src, dst, force) { @@ -157,13 +196,14 @@ class SkillsCommand extends Command { } /** - * @param {*} args - * @param {*} options + * @param {*} args + * @param {*} options */ async exec(args, options) { const target = options.install; const scope = options.scope === 'user' ? 'user' : 'project'; const force = options.force === true || options.force === 'true'; + const addDep = options['add-dep'] === true || options['add-dep'] === 'true'; if (!target || !TARGET_DIRS[target]) { printer.error(`[skills] --install must be one of: ${Object.keys(TARGET_DIRS).join(', ')}`).println(); @@ -174,10 +214,7 @@ class SkillsCommand extends Command { printer.info(`[skills] target : ${target} (${scope} scope)`).println(); printer.info(`[skills] destDir: ${destDir}`).println(); - const state = await this.resolveSourceDir(); - if (!state) { - return; - } + const state = await this.resolveSourceDir(addDep); printer.info(`[skills] source : ${state.sourceDir}`).println(); if (!await _exists(state.sourceDir)) { @@ -198,4 +235,8 @@ class SkillsCommand extends Command { } } +SkillsCommand.resolveLocalPkgDir = resolveLocalPkgDir; +SkillsCommand.detectPackageManager = detectPackageManager; +SkillsCommand.buildInstallCommand = buildInstallCommand; + module.exports = SkillsCommand; diff --git a/src/cli/pkg.js b/src/cli/pkg.js new file mode 100644 index 0000000..25f889a --- /dev/null +++ b/src/cli/pkg.js @@ -0,0 +1,157 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +/** + * Resolve an installed package directory reachable from `fromDir`. + * Uses Node's own resolution (walks up to workspace roots), then falls + * back to a manual upward walk of `node_modules/`. + * + * @param {string} pkgName + * @param {string} fromDir + * @returns {string|null} + */ +function resolveLocalPkgDir(pkgName, fromDir) { + try { + const pkgJson = require.resolve(pkgName + '/package.json', { paths: [fromDir] }); + return path.dirname(pkgJson); + } catch (_err) { // eslint-disable-line no-unused-vars + // MODULE_NOT_FOUND — try a manual upward walk + } + + let dir = path.resolve(fromDir); + const { root } = path.parse(dir); + while (true) { + const candidate = path.join(dir, 'node_modules', ...pkgName.split('/')); + if (fs.existsSync(path.join(candidate, 'package.json'))) { + return candidate; + } + if (dir === root) { + break; + } + dir = path.dirname(dir); + } + return null; +} + +/** + * Read package.json from a directory, returning null on any failure. + * @param {string} dir + * @returns {object|null} + */ +function readPackageJson(dir) { + try { + const raw = fs.readFileSync(path.join(dir, 'package.json'), 'utf8'); + return JSON.parse(raw); + } catch (_err) { // eslint-disable-line no-unused-vars + return null; + } +} + +/** + * @param {string} dir + * @param {string} pm + * @returns {boolean} + */ +function isWorkspaceRoot(dir, pm) { + if (pm === 'pnpm') { + return fs.existsSync(path.join(dir, 'pnpm-workspace.yaml')); + } + if (pm === 'yarn' || pm === 'npm' || pm === 'bun') { + const pkg = readPackageJson(dir); + return !!(pkg && (pkg.workspaces || pkg.workspace)); + } + return false; +} + +/** + * Detect the package manager and project root for `fromDir`. + * + * Precedence while walking upward: + * 1. `packageManager` field in package.json (name@version prefix) + * 2. pnpm-lock.yaml / pnpm-workspace.yaml + * 3. yarn.lock + * 4. bun.lockb / bun.lock + * 5. package-lock.json + * + * Defaults to `{ pm: 'npm', rootDir: fromDir, isWorkspaceRoot: false }`. + * + * @param {string} fromDir + * @returns {{ pm: string, rootDir: string, isWorkspaceRoot: boolean }} + */ +function detectPackageManager(fromDir) { + let dir = path.resolve(fromDir); + const { root } = path.parse(dir); + + while (true) { + const pkg = readPackageJson(dir); + if (pkg && typeof pkg.packageManager === 'string') { + const pm = pkg.packageManager.split('@')[0].trim(); + if (pm) { + return { + pm, + rootDir: dir, + isWorkspaceRoot: isWorkspaceRoot(dir, pm) + }; + } + } + + if (fs.existsSync(path.join(dir, 'pnpm-lock.yaml')) + || fs.existsSync(path.join(dir, 'pnpm-workspace.yaml'))) { + return { pm: 'pnpm', rootDir: dir, isWorkspaceRoot: isWorkspaceRoot(dir, 'pnpm') }; + } + if (fs.existsSync(path.join(dir, 'yarn.lock'))) { + return { pm: 'yarn', rootDir: dir, isWorkspaceRoot: isWorkspaceRoot(dir, 'yarn') }; + } + if (fs.existsSync(path.join(dir, 'bun.lockb')) + || fs.existsSync(path.join(dir, 'bun.lock'))) { + return { pm: 'bun', rootDir: dir, isWorkspaceRoot: isWorkspaceRoot(dir, 'bun') }; + } + if (fs.existsSync(path.join(dir, 'package-lock.json'))) { + return { pm: 'npm', rootDir: dir, isWorkspaceRoot: isWorkspaceRoot(dir, 'npm') }; + } + + if (dir === root) { + break; + } + dir = path.dirname(dir); + } + + return { + pm: 'npm', + rootDir: path.resolve(fromDir), + isWorkspaceRoot: false + }; +} + +/** + * Build the shell command used to add a dependency. + * + * @param {string} pm + * @param {string} pkgName + * @param {{ isWorkspaceRoot?: boolean }} [opts] + * @returns {string} + */ +function buildInstallCommand(pm, pkgName, opts = {}) { + const { isWorkspaceRoot: workspaceRoot = false } = opts; + switch (pm) { + case 'pnpm': + return workspaceRoot + ? `pnpm add ${pkgName} -w` + : `pnpm add ${pkgName}`; + case 'yarn': + return `yarn add ${pkgName}`; + case 'bun': + return `bun add ${pkgName}`; + case 'npm': + default: + return `npm install ${pkgName}`; + } +} + +module.exports = { + resolveLocalPkgDir, + detectPackageManager, + buildInstallCommand +}; diff --git a/tests/skills.tests.js b/tests/skills.tests.js new file mode 100644 index 0000000..dd24c59 --- /dev/null +++ b/tests/skills.tests.js @@ -0,0 +1,145 @@ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { expect } = require('chai'); +const { + resolveLocalPkgDir, + detectPackageManager, + buildInstallCommand +} = require('../src/cli/pkg'); + +function mkdtemp(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function writeJson(filePath, data) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); +} + +function touch(filePath, content = '') { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content); +} + +describe('cli/pkg', () => { + describe('detectPackageManager()', () => { + it('detects pnpm via packageManager field at workspace root', () => { + const root = mkdtemp('koapp-skills-pnpm-'); + writeJson(path.join(root, 'package.json'), { + name: 'root', + packageManager: 'pnpm@11.10.0' + }); + touch(path.join(root, 'pnpm-workspace.yaml'), 'packages:\n - "apps/*"\n'); + + const info = detectPackageManager(root); + expect(info.pm).to.equal('pnpm'); + expect(info.rootDir).to.equal(root); + expect(info.isWorkspaceRoot).to.equal(true); + }); + + it('detects pnpm workspace root from a member directory (the failing case)', () => { + const root = mkdtemp('koapp-skills-member-'); + writeJson(path.join(root, 'package.json'), { + name: 'root', + packageManager: 'pnpm@11.10.0' + }); + touch(path.join(root, 'pnpm-lock.yaml'), 'lockfileVersion: 9.0\n'); + touch(path.join(root, 'pnpm-workspace.yaml'), 'packages:\n - "apps/*"\n'); + const member = path.join(root, 'apps'); + fs.mkdirSync(member); + + const info = detectPackageManager(member); + expect(info.pm).to.equal('pnpm'); + expect(info.rootDir).to.equal(root); + expect(info.isWorkspaceRoot).to.equal(true); + }); + + it('detects yarn via yarn.lock', () => { + const root = mkdtemp('koapp-skills-yarn-'); + writeJson(path.join(root, 'package.json'), { name: 'yarn-app' }); + touch(path.join(root, 'yarn.lock'), '# yarn lockfile v1\n'); + + const info = detectPackageManager(root); + expect(info.pm).to.equal('yarn'); + expect(info.rootDir).to.equal(root); + }); + + it('prefers packageManager field over lockfiles', () => { + const root = mkdtemp('koapp-skills-pref-'); + writeJson(path.join(root, 'package.json'), { + name: 'pref', + packageManager: 'bun@1.0.0' + }); + touch(path.join(root, 'package-lock.json'), '{}'); + + const info = detectPackageManager(root); + expect(info.pm).to.equal('bun'); + expect(info.rootDir).to.equal(root); + }); + + it('defaults to npm when no markers are present', () => { + const root = mkdtemp('koapp-skills-npm-'); + writeJson(path.join(root, 'package.json'), { name: 'bare' }); + + const info = detectPackageManager(root); + expect(info.pm).to.equal('npm'); + expect(info.rootDir).to.equal(root); + expect(info.isWorkspaceRoot).to.equal(false); + }); + }); + + describe('buildInstallCommand()', () => { + it('builds pnpm add -w for workspace roots', () => { + expect(buildInstallCommand('pnpm', '@axiosleo/koapp', { isWorkspaceRoot: true })) + .to.equal('pnpm add @axiosleo/koapp -w'); + }); + + it('builds pnpm add without -w for non-workspace', () => { + expect(buildInstallCommand('pnpm', '@axiosleo/koapp', { isWorkspaceRoot: false })) + .to.equal('pnpm add @axiosleo/koapp'); + }); + + it('builds yarn / bun / npm commands', () => { + expect(buildInstallCommand('yarn', '@axiosleo/koapp')).to.equal('yarn add @axiosleo/koapp'); + expect(buildInstallCommand('bun', '@axiosleo/koapp')).to.equal('bun add @axiosleo/koapp'); + expect(buildInstallCommand('npm', '@axiosleo/koapp')).to.equal('npm install @axiosleo/koapp'); + }); + }); + + describe('resolveLocalPkgDir()', () => { + it('resolves a package via require.resolve from a nested cwd', () => { + // Use this repo itself: require.resolve finds @axiosleo/cli-tool from any nested dir + const nested = path.join(__dirname, 'fixtures-skills-nested'); + fs.mkdirSync(nested, { recursive: true }); + try { + const found = resolveLocalPkgDir('@axiosleo/cli-tool', nested); + expect(found).to.be.a('string'); + expect(fs.existsSync(path.join(found, 'package.json'))).to.equal(true); + } finally { + fs.rmSync(nested, { recursive: true, force: true }); + } + }); + + it('resolves a package hoisted at an ancestor node_modules from a nested cwd', () => { + const root = mkdtemp('koapp-skills-walk-'); + const pkgDir = path.join(root, 'node_modules', '@scope', 'pkg'); + writeJson(path.join(pkgDir, 'package.json'), { name: '@scope/pkg', version: '1.0.0' }); + const nested = path.join(root, 'apps', 'svc'); + fs.mkdirSync(nested, { recursive: true }); + + // Unique scoped name so ambient installs cannot interfere. + // realpathSync normalizes macOS /var -> /private/var from require.resolve. + const found = resolveLocalPkgDir('@scope/pkg', nested); + expect(fs.realpathSync(found)).to.equal(fs.realpathSync(pkgDir)); + }); + + it('returns null when the package is not installed', () => { + const root = mkdtemp('koapp-skills-miss-'); + const found = resolveLocalPkgDir('@axiosleo/definitely-not-installed-xyz', root); + expect(found).to.equal(null); + }); + }); +}); From 6eefe9a5c46fe66e1d0718f960214ea06f179979 Mon Sep 17 00:00:00 2001 From: axiosleo Date: Wed, 29 Jul 2026 10:52:45 +0800 Subject: [PATCH 2/6] refactor: improve local package directory resolution logic by replacing infinite loop with a controlled search mechanism --- src/cli/pkg.js | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/cli/pkg.js b/src/cli/pkg.js index 25f889a..12d7c28 100644 --- a/src/cli/pkg.js +++ b/src/cli/pkg.js @@ -22,15 +22,17 @@ function resolveLocalPkgDir(pkgName, fromDir) { let dir = path.resolve(fromDir); const { root } = path.parse(dir); - while (true) { + let searching = true; + while (searching) { const candidate = path.join(dir, 'node_modules', ...pkgName.split('/')); if (fs.existsSync(path.join(candidate, 'package.json'))) { return candidate; } if (dir === root) { - break; + searching = false; + } else { + dir = path.dirname(dir); } - dir = path.dirname(dir); } return null; } @@ -83,8 +85,9 @@ function isWorkspaceRoot(dir, pm) { function detectPackageManager(fromDir) { let dir = path.resolve(fromDir); const { root } = path.parse(dir); + let searching = true; - while (true) { + while (searching) { const pkg = readPackageJson(dir); if (pkg && typeof pkg.packageManager === 'string') { const pm = pkg.packageManager.split('@')[0].trim(); @@ -113,9 +116,10 @@ function detectPackageManager(fromDir) { } if (dir === root) { - break; + searching = false; + } else { + dir = path.dirname(dir); } - dir = path.dirname(dir); } return { From 7c8b15d5548ca47ff3283b2c35d356687be1533a Mon Sep 17 00:00:00 2001 From: axiosleo Date: Wed, 29 Jul 2026 11:18:09 +0800 Subject: [PATCH 3/6] refactor: replace detectPackageManager with resolveInstallTarget in SkillsCommand for improved dependency installation logic and add nearest package.json resolution --- commands/skills.js | 20 ++++++------- src/cli/pkg.js | 63 +++++++++++++++++++++++++++++++++++++++-- tests/skills.tests.js | 66 +++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 134 insertions(+), 15 deletions(-) diff --git a/commands/skills.js b/commands/skills.js index 02e0c09..08889e6 100644 --- a/commands/skills.js +++ b/commands/skills.js @@ -13,7 +13,7 @@ const { const { _exec } = require('@axiosleo/cli-tool/src/helper/cmd'); const { resolveLocalPkgDir, - detectPackageManager, + resolveInstallTarget, buildInstallCommand } = require('../src/cli/pkg'); @@ -89,9 +89,9 @@ class SkillsCommand extends Command { const runnerPkgDir = path.resolve(__dirname, '..'); const cwd = process.cwd(); const runnerVer = readPkgVersion(runnerPkgDir); - const pmInfo = detectPackageManager(cwd); - const installCmd = buildInstallCommand(pmInfo.pm, PKG_NAME, { - isWorkspaceRoot: pmInfo.isWorkspaceRoot + const target = resolveInstallTarget(cwd); + const installCmd = buildInstallCommand(target.pm, PKG_NAME, { + useWorkspaceFlag: target.useWorkspaceFlag }); const state = { @@ -109,10 +109,10 @@ class SkillsCommand extends Command { return this.resolveFromLocal(state, localPkgDir); } - // Warn when cwd is inside a workspace but has no package.json of its own - if (pmInfo.rootDir !== path.resolve(cwd) && !(await _exists(path.join(cwd, 'package.json')))) { + // Warn when install will land outside cwd (nearest package.json / workspace root) + if (path.resolve(target.installDir) !== path.resolve(cwd)) { printer.warning( - `[skills] no package.json in ${cwd}; detected project root at ${pmInfo.rootDir}` + `[skills] no package.json in ${cwd}; will install into ${target.installDir}` ).println(); } @@ -126,7 +126,7 @@ class SkillsCommand extends Command { } const shouldInstall = await this.confirm( - `Install ${PKG_NAME} now via \`${installCmd}\`?`, + `Install ${PKG_NAME} now via \`${installCmd}\` in ${target.installDir}?`, true ); if (!shouldInstall) { @@ -137,7 +137,7 @@ class SkillsCommand extends Command { } try { - await _exec(installCmd, pmInfo.rootDir); + await _exec(installCmd, target.installDir); } catch (err) { const reason = err && err.message ? err.message : String(err); printer.error(`[skills] install failed: ${reason}`).println(); @@ -236,7 +236,7 @@ class SkillsCommand extends Command { } SkillsCommand.resolveLocalPkgDir = resolveLocalPkgDir; -SkillsCommand.detectPackageManager = detectPackageManager; +SkillsCommand.resolveInstallTarget = resolveInstallTarget; SkillsCommand.buildInstallCommand = buildInstallCommand; module.exports = SkillsCommand; diff --git a/src/cli/pkg.js b/src/cli/pkg.js index 12d7c28..8ac2d29 100644 --- a/src/cli/pkg.js +++ b/src/cli/pkg.js @@ -51,6 +51,30 @@ function readPackageJson(dir) { } } +/** + * Walk up from `fromDir` and return the nearest directory that contains + * a package.json, or null if none is found. + * + * @param {string} fromDir + * @returns {string|null} + */ +function findNearestPackageDir(fromDir) { + let dir = path.resolve(fromDir); + const { root } = path.parse(dir); + let searching = true; + while (searching) { + if (fs.existsSync(path.join(dir, 'package.json'))) { + return dir; + } + if (dir === root) { + searching = false; + } else { + dir = path.dirname(dir); + } + } + return null; +} + /** * @param {string} dir * @param {string} pm @@ -129,19 +153,50 @@ function detectPackageManager(fromDir) { }; } +/** + * Decide where and how to add a dependency when running from `fromDir`. + * Prefers the nearest package.json (workspace member) over the monorepo root, + * so `pnpm add -w` is only used when installing into the workspace root itself. + * + * @param {string} fromDir + * @returns {{ + * pm: string, + * rootDir: string, + * installDir: string, + * useWorkspaceFlag: boolean + * }} + */ +function resolveInstallTarget(fromDir) { + const pmInfo = detectPackageManager(fromDir); + const nearestPkg = findNearestPackageDir(fromDir); + const installDir = nearestPkg || pmInfo.rootDir; + const useWorkspaceFlag = + pmInfo.isWorkspaceRoot + && path.resolve(installDir) === path.resolve(pmInfo.rootDir); + + return { + pm: pmInfo.pm, + rootDir: pmInfo.rootDir, + installDir, + useWorkspaceFlag + }; +} + /** * Build the shell command used to add a dependency. * * @param {string} pm * @param {string} pkgName - * @param {{ isWorkspaceRoot?: boolean }} [opts] + * @param {{ useWorkspaceFlag?: boolean, isWorkspaceRoot?: boolean }} [opts] * @returns {string} */ function buildInstallCommand(pm, pkgName, opts = {}) { - const { isWorkspaceRoot: workspaceRoot = false } = opts; + // isWorkspaceRoot kept as a deprecated alias of useWorkspaceFlag + const useWorkspaceFlag = opts.useWorkspaceFlag === true + || opts.isWorkspaceRoot === true; switch (pm) { case 'pnpm': - return workspaceRoot + return useWorkspaceFlag ? `pnpm add ${pkgName} -w` : `pnpm add ${pkgName}`; case 'yarn': @@ -156,6 +211,8 @@ function buildInstallCommand(pm, pkgName, opts = {}) { module.exports = { resolveLocalPkgDir, + findNearestPackageDir, detectPackageManager, + resolveInstallTarget, buildInstallCommand }; diff --git a/tests/skills.tests.js b/tests/skills.tests.js index dd24c59..5fda028 100644 --- a/tests/skills.tests.js +++ b/tests/skills.tests.js @@ -7,6 +7,7 @@ const { expect } = require('chai'); const { resolveLocalPkgDir, detectPackageManager, + resolveInstallTarget, buildInstallCommand } = require('../src/cli/pkg'); @@ -24,6 +25,17 @@ function touch(filePath, content = '') { fs.writeFileSync(filePath, content); } +function makePnpmWorkspace() { + const root = mkdtemp('koapp-skills-ws-'); + writeJson(path.join(root, 'package.json'), { + name: 'root', + packageManager: 'pnpm@11.10.0' + }); + touch(path.join(root, 'pnpm-lock.yaml'), 'lockfileVersion: 9.0\n'); + touch(path.join(root, 'pnpm-workspace.yaml'), 'packages:\n - "packages/*"\n'); + return root; +} + describe('cli/pkg', () => { describe('detectPackageManager()', () => { it('detects pnpm via packageManager field at workspace root', () => { @@ -91,14 +103,64 @@ describe('cli/pkg', () => { }); }); + describe('resolveInstallTarget()', () => { + it('installs into a workspace member package without -w', () => { + const root = makePnpmWorkspace(); + const member = path.join(root, 'packages', 'api'); + writeJson(path.join(member, 'package.json'), { name: 'api', version: '0.0.0' }); + + const target = resolveInstallTarget(member); + expect(target.pm).to.equal('pnpm'); + expect(target.rootDir).to.equal(root); + expect(target.installDir).to.equal(member); + expect(target.useWorkspaceFlag).to.equal(false); + expect(buildInstallCommand(target.pm, '@axiosleo/koapp', { + useWorkspaceFlag: target.useWorkspaceFlag + })).to.equal('pnpm add @axiosleo/koapp'); + }); + + it('uses -w when cwd is the workspace root', () => { + const root = makePnpmWorkspace(); + + const target = resolveInstallTarget(root); + expect(target.installDir).to.equal(root); + expect(target.useWorkspaceFlag).to.equal(true); + expect(buildInstallCommand(target.pm, '@axiosleo/koapp', { + useWorkspaceFlag: target.useWorkspaceFlag + })).to.equal('pnpm add @axiosleo/koapp -w'); + }); + + it('falls back to workspace root when cwd has no package.json', () => { + const root = makePnpmWorkspace(); + const nested = path.join(root, 'apps'); + fs.mkdirSync(nested); + + const target = resolveInstallTarget(nested); + expect(target.installDir).to.equal(root); + expect(target.useWorkspaceFlag).to.equal(true); + }); + + it('resolves nearest package.json above a nested cwd', () => { + const root = makePnpmWorkspace(); + const member = path.join(root, 'packages', 'api'); + writeJson(path.join(member, 'package.json'), { name: 'api', version: '0.0.0' }); + const nested = path.join(member, 'src'); + fs.mkdirSync(nested, { recursive: true }); + + const target = resolveInstallTarget(nested); + expect(target.installDir).to.equal(member); + expect(target.useWorkspaceFlag).to.equal(false); + }); + }); + describe('buildInstallCommand()', () => { it('builds pnpm add -w for workspace roots', () => { - expect(buildInstallCommand('pnpm', '@axiosleo/koapp', { isWorkspaceRoot: true })) + expect(buildInstallCommand('pnpm', '@axiosleo/koapp', { useWorkspaceFlag: true })) .to.equal('pnpm add @axiosleo/koapp -w'); }); it('builds pnpm add without -w for non-workspace', () => { - expect(buildInstallCommand('pnpm', '@axiosleo/koapp', { isWorkspaceRoot: false })) + expect(buildInstallCommand('pnpm', '@axiosleo/koapp', { useWorkspaceFlag: false })) .to.equal('pnpm add @axiosleo/koapp'); }); From 2ae305bb1dfbe2dd4bbdda235656019aaed472b5 Mon Sep 17 00:00:00 2001 From: axiosleo Date: Wed, 29 Jul 2026 13:52:35 +0800 Subject: [PATCH 4/6] feat: implement parsing of pnpm workspace globs and enhance package manager detection logic to support nested projects and workspaces --- src/cli/pkg.js | 221 ++++++++++++++++++++++++++++++++++-------- tests/skills.tests.js | 43 ++++++++ 2 files changed, 225 insertions(+), 39 deletions(-) diff --git a/src/cli/pkg.js b/src/cli/pkg.js index 8ac2d29..e9be0de 100644 --- a/src/cli/pkg.js +++ b/src/cli/pkg.js @@ -76,67 +76,173 @@ function findNearestPackageDir(fromDir) { } /** + * Parse the `packages:` list out of a pnpm-workspace.yaml without a YAML + * dependency. Supports both block sequences and an inline array, and stops + * at the next top-level key. + * + * @param {string} filePath + * @returns {string[]} + */ +function parsePnpmWorkspaceGlobs(filePath) { + let raw; + try { + raw = fs.readFileSync(filePath, 'utf8'); + } catch (_err) { // eslint-disable-line no-unused-vars + return []; + } + const unquote = (s) => s.trim().replace(/^['"]|['"]$/g, '').trim(); + const globs = []; + const lines = raw.split(/\r?\n/); + let inPackages = false; + for (const line of lines) { + if (!inPackages) { + const inline = line.match(/^packages:\s*\[(.*)\]\s*$/); + if (inline) { + return inline[1].split(',').map(unquote).filter((s) => s); + } + if (/^packages:\s*$/.test(line)) { + inPackages = true; + } + continue; + } + if (!line.trim() || line.trim().startsWith('#')) { + continue; + } + const item = line.match(/^\s+-\s*(.+?)\s*$/); + if (!item) { + break; // next top-level key + } + const value = unquote(item[1]); + if (value) { + globs.push(value); + } + } + return globs; +} + +/** + * Read the workspace globs declared by `dir`, or null when `dir` is not a + * workspace root for `pm`. + * * @param {string} dir * @param {string} pm - * @returns {boolean} + * @returns {string[]|null} */ -function isWorkspaceRoot(dir, pm) { +function readWorkspaceGlobs(dir, pm) { if (pm === 'pnpm') { - return fs.existsSync(path.join(dir, 'pnpm-workspace.yaml')); + const wsFile = path.join(dir, 'pnpm-workspace.yaml'); + if (!fs.existsSync(wsFile)) { + return null; + } + const globs = parsePnpmWorkspaceGlobs(wsFile); + // An unparseable workspace file still governs its subtree + return globs.length ? globs : ['**']; } - if (pm === 'yarn' || pm === 'npm' || pm === 'bun') { - const pkg = readPackageJson(dir); - return !!(pkg && (pkg.workspaces || pkg.workspace)); + const pkg = readPackageJson(dir); + const field = pkg && (pkg.workspaces || pkg.workspace); + if (!field) { + return null; } - return false; + const globs = Array.isArray(field) ? field : field.packages; + return Array.isArray(globs) && globs.length ? globs : ['**']; } /** - * Detect the package manager and project root for `fromDir`. + * Convert a workspace glob into an anchored RegExp. Supports `*` and `**`. * - * Precedence while walking upward: - * 1. `packageManager` field in package.json (name@version prefix) - * 2. pnpm-lock.yaml / pnpm-workspace.yaml - * 3. yarn.lock - * 4. bun.lockb / bun.lock - * 5. package-lock.json + * @param {string} glob + * @returns {RegExp} + */ +function globToRegExp(glob) { + let source = ''; + for (let i = 0; i < glob.length; i++) { + const char = glob[i]; + if (char === '*') { + if (glob[i + 1] === '*') { + source += '.*'; + i++; + } else { + source += '[^/]*'; + } + } else if ('\\^$.|?+()[]{}'.includes(char)) { + source += '\\' + char; + } else { + source += char; + } + } + return new RegExp('^' + source + '$'); +} + +/** + * Is `targetDir` governed by the workspace rooted at `rootDir`? + * The root itself always counts. * - * Defaults to `{ pm: 'npm', rootDir: fromDir, isWorkspaceRoot: false }`. + * @param {string} rootDir + * @param {string[]} globs + * @param {string} targetDir + * @returns {boolean} + */ +function isWorkspaceMember(rootDir, globs, targetDir) { + const from = path.resolve(rootDir); + const to = path.resolve(targetDir); + if (from === to) { + return true; + } + const rel = path.relative(from, to).split(path.sep).join('/'); + if (!rel || rel.startsWith('..')) { + return false; + } + let matched = false; + for (const glob of globs) { + const negated = glob.startsWith('!'); + const pattern = negated ? glob.slice(1) : glob; + if (globToRegExp(pattern).test(rel)) { + if (negated) { + return false; + } + matched = true; + } + } + return matched; +} + +/** + * Collect every package-manager marker between `fromDir` and the filesystem + * root, ordered nearest first. + * + * Within a single directory: a `packageManager` field wins over lockfiles, + * then pnpm, yarn, bun, npm lockfiles. * * @param {string} fromDir - * @returns {{ pm: string, rootDir: string, isWorkspaceRoot: boolean }} + * @returns {Array<{ dir: string, pm: string, globs: string[]|null }>} */ -function detectPackageManager(fromDir) { +function collectPmCandidates(fromDir) { + const candidates = []; let dir = path.resolve(fromDir); const { root } = path.parse(dir); let searching = true; while (searching) { + let pm = null; const pkg = readPackageJson(dir); if (pkg && typeof pkg.packageManager === 'string') { - const pm = pkg.packageManager.split('@')[0].trim(); - if (pm) { - return { - pm, - rootDir: dir, - isWorkspaceRoot: isWorkspaceRoot(dir, pm) - }; - } - } - - if (fs.existsSync(path.join(dir, 'pnpm-lock.yaml')) - || fs.existsSync(path.join(dir, 'pnpm-workspace.yaml'))) { - return { pm: 'pnpm', rootDir: dir, isWorkspaceRoot: isWorkspaceRoot(dir, 'pnpm') }; - } - if (fs.existsSync(path.join(dir, 'yarn.lock'))) { - return { pm: 'yarn', rootDir: dir, isWorkspaceRoot: isWorkspaceRoot(dir, 'yarn') }; + pm = pkg.packageManager.split('@')[0].trim() || null; } - if (fs.existsSync(path.join(dir, 'bun.lockb')) - || fs.existsSync(path.join(dir, 'bun.lock'))) { - return { pm: 'bun', rootDir: dir, isWorkspaceRoot: isWorkspaceRoot(dir, 'bun') }; + if (!pm) { + if (fs.existsSync(path.join(dir, 'pnpm-lock.yaml')) + || fs.existsSync(path.join(dir, 'pnpm-workspace.yaml'))) { + pm = 'pnpm'; + } else if (fs.existsSync(path.join(dir, 'yarn.lock'))) { + pm = 'yarn'; + } else if (fs.existsSync(path.join(dir, 'bun.lockb')) + || fs.existsSync(path.join(dir, 'bun.lock'))) { + pm = 'bun'; + } else if (fs.existsSync(path.join(dir, 'package-lock.json'))) { + pm = 'npm'; + } } - if (fs.existsSync(path.join(dir, 'package-lock.json'))) { - return { pm: 'npm', rootDir: dir, isWorkspaceRoot: isWorkspaceRoot(dir, 'npm') }; + if (pm) { + candidates.push({ dir, pm, globs: readWorkspaceGlobs(dir, pm) }); } if (dir === root) { @@ -146,6 +252,43 @@ function detectPackageManager(fromDir) { } } + return candidates; +} + +/** + * Detect the package manager and project root governing `targetDir`. + * + * Selection, over every marker between `fromDir` and the filesystem root: + * 1. The outermost workspace root that lists `targetDir` as a member wins, + * so a stray nested lockfile cannot override the workspace's own manager. + * 2. Otherwise the nearest marker wins, which keeps independent nested + * projects on their own package manager. + * + * Defaults to `{ pm: 'npm', rootDir: fromDir, isWorkspaceRoot: false }`. + * + * @param {string} fromDir + * @param {string} [targetDir] directory the dependency would be added to + * @returns {{ pm: string, rootDir: string, isWorkspaceRoot: boolean }} + */ +function detectPackageManager(fromDir, targetDir = fromDir) { + const candidates = collectPmCandidates(fromDir); + + for (let i = candidates.length - 1; i >= 0; i--) { + const candidate = candidates[i]; + if (candidate.globs && isWorkspaceMember(candidate.dir, candidate.globs, targetDir)) { + return { pm: candidate.pm, rootDir: candidate.dir, isWorkspaceRoot: true }; + } + } + + if (candidates.length) { + const nearest = candidates[0]; + return { + pm: nearest.pm, + rootDir: nearest.dir, + isWorkspaceRoot: !!nearest.globs + }; + } + return { pm: 'npm', rootDir: path.resolve(fromDir), @@ -167,8 +310,8 @@ function detectPackageManager(fromDir) { * }} */ function resolveInstallTarget(fromDir) { - const pmInfo = detectPackageManager(fromDir); const nearestPkg = findNearestPackageDir(fromDir); + const pmInfo = detectPackageManager(fromDir, nearestPkg || fromDir); const installDir = nearestPkg || pmInfo.rootDir; const useWorkspaceFlag = pmInfo.isWorkspaceRoot diff --git a/tests/skills.tests.js b/tests/skills.tests.js index 5fda028..e1567a4 100644 --- a/tests/skills.tests.js +++ b/tests/skills.tests.js @@ -151,6 +151,49 @@ describe('cli/pkg', () => { expect(target.installDir).to.equal(member); expect(target.useWorkspaceFlag).to.equal(false); }); + + it('ignores a stray package-lock.json inside a pnpm workspace member', () => { + const root = makePnpmWorkspace(); + const member = path.join(root, 'packages', 'api'); + writeJson(path.join(member, 'package.json'), { name: 'api', version: '0.0.0' }); + touch(path.join(member, 'package-lock.json'), '{}'); + + const target = resolveInstallTarget(member); + expect(target.pm).to.equal('pnpm'); + expect(target.rootDir).to.equal(root); + expect(target.installDir).to.equal(member); + expect(target.useWorkspaceFlag).to.equal(false); + }); + + it('keeps npm for an independent nested project outside the workspace globs', () => { + const root = makePnpmWorkspace(); + const standalone = path.join(root, 'examples', 'demo'); + writeJson(path.join(standalone, 'package.json'), { name: 'demo', version: '0.0.0' }); + touch(path.join(standalone, 'package-lock.json'), '{}'); + + const target = resolveInstallTarget(standalone); + expect(target.pm).to.equal('npm'); + expect(target.installDir).to.equal(standalone); + expect(target.useWorkspaceFlag).to.equal(false); + }); + + it('honors yarn workspaces declared in package.json', () => { + const root = mkdtemp('koapp-skills-yarnws-'); + writeJson(path.join(root, 'package.json'), { + name: 'root', + private: true, + workspaces: ['packages/*'] + }); + touch(path.join(root, 'yarn.lock'), '# yarn lockfile v1\n'); + const member = path.join(root, 'packages', 'web'); + writeJson(path.join(member, 'package.json'), { name: 'web' }); + touch(path.join(member, 'package-lock.json'), '{}'); + + const target = resolveInstallTarget(member); + expect(target.pm).to.equal('yarn'); + expect(target.rootDir).to.equal(root); + expect(target.installDir).to.equal(member); + }); }); describe('buildInstallCommand()', () => { From cb658b4095592e54fb5f2c0a9df51ff1ed366c25 Mon Sep 17 00:00:00 2001 From: axiosleo Date: Wed, 29 Jul 2026 13:58:38 +0800 Subject: [PATCH 5/6] feat: enhance package manager detection and installation command logic for Yarn, including major version handling and workspace flag adjustments --- commands/skills.js | 3 +- src/cli/pkg.js | 96 +++++++++++++++++++++++++++++++++++++++---- tests/skills.tests.js | 94 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 185 insertions(+), 8 deletions(-) diff --git a/commands/skills.js b/commands/skills.js index 08889e6..9460cc7 100644 --- a/commands/skills.js +++ b/commands/skills.js @@ -91,7 +91,8 @@ class SkillsCommand extends Command { const runnerVer = readPkgVersion(runnerPkgDir); const target = resolveInstallTarget(cwd); const installCmd = buildInstallCommand(target.pm, PKG_NAME, { - useWorkspaceFlag: target.useWorkspaceFlag + useWorkspaceFlag: target.useWorkspaceFlag, + pmMajor: target.pmMajor }); const state = { diff --git a/src/cli/pkg.js b/src/cli/pkg.js index e9be0de..4ba9d17 100644 --- a/src/cli/pkg.js +++ b/src/cli/pkg.js @@ -206,6 +206,48 @@ function isWorkspaceMember(rootDir, globs, targetDir) { return matched; } +/** + * Determine the major version of the package manager at `dir`. + * Falls back to lockfile / rc-file shape when no `packageManager` field + * pins a version, and returns null when it cannot be determined. + * + * @param {string} dir + * @param {string} pm + * @param {string|null} pmVersion version from the `packageManager` field + * @returns {number|null} + */ +function detectPmMajor(dir, pm, pmVersion) { + if (pmVersion) { + const major = parseInt(pmVersion.split('.')[0], 10); + if (!Number.isNaN(major)) { + return major; + } + } + if (pm !== 'yarn') { + return null; + } + // Yarn Berry always ships a .yarnrc.yml + if (fs.existsSync(path.join(dir, '.yarnrc.yml'))) { + return 2; + } + const lockFile = path.join(dir, 'yarn.lock'); + if (fs.existsSync(lockFile)) { + let head = ''; + try { + head = fs.readFileSync(lockFile, 'utf8').slice(0, 1024); + } catch (_err) { // eslint-disable-line no-unused-vars + return null; + } + if (head.includes('yarn lockfile v1')) { + return 1; + } + if (head.includes('__metadata')) { + return 2; + } + } + return null; +} + /** * Collect every package-manager marker between `fromDir` and the filesystem * root, ordered nearest first. @@ -214,7 +256,12 @@ function isWorkspaceMember(rootDir, globs, targetDir) { * then pnpm, yarn, bun, npm lockfiles. * * @param {string} fromDir - * @returns {Array<{ dir: string, pm: string, globs: string[]|null }>} + * @returns {Array<{ + * dir: string, + * pm: string, + * pmMajor: number|null, + * globs: string[]|null + * }>} */ function collectPmCandidates(fromDir) { const candidates = []; @@ -224,9 +271,16 @@ function collectPmCandidates(fromDir) { while (searching) { let pm = null; + let pmVersion = null; const pkg = readPackageJson(dir); if (pkg && typeof pkg.packageManager === 'string') { - pm = pkg.packageManager.split('@')[0].trim() || null; + const at = pkg.packageManager.indexOf('@'); + if (at > 0) { + pm = pkg.packageManager.slice(0, at).trim() || null; + pmVersion = pkg.packageManager.slice(at + 1).trim() || null; + } else { + pm = pkg.packageManager.trim() || null; + } } if (!pm) { if (fs.existsSync(path.join(dir, 'pnpm-lock.yaml')) @@ -242,7 +296,12 @@ function collectPmCandidates(fromDir) { } } if (pm) { - candidates.push({ dir, pm, globs: readWorkspaceGlobs(dir, pm) }); + candidates.push({ + dir, + pm, + pmMajor: detectPmMajor(dir, pm, pmVersion), + globs: readWorkspaceGlobs(dir, pm) + }); } if (dir === root) { @@ -268,7 +327,12 @@ function collectPmCandidates(fromDir) { * * @param {string} fromDir * @param {string} [targetDir] directory the dependency would be added to - * @returns {{ pm: string, rootDir: string, isWorkspaceRoot: boolean }} + * @returns {{ + * pm: string, + * pmMajor: number|null, + * rootDir: string, + * isWorkspaceRoot: boolean + * }} */ function detectPackageManager(fromDir, targetDir = fromDir) { const candidates = collectPmCandidates(fromDir); @@ -276,7 +340,12 @@ function detectPackageManager(fromDir, targetDir = fromDir) { for (let i = candidates.length - 1; i >= 0; i--) { const candidate = candidates[i]; if (candidate.globs && isWorkspaceMember(candidate.dir, candidate.globs, targetDir)) { - return { pm: candidate.pm, rootDir: candidate.dir, isWorkspaceRoot: true }; + return { + pm: candidate.pm, + pmMajor: candidate.pmMajor, + rootDir: candidate.dir, + isWorkspaceRoot: true + }; } } @@ -284,6 +353,7 @@ function detectPackageManager(fromDir, targetDir = fromDir) { const nearest = candidates[0]; return { pm: nearest.pm, + pmMajor: nearest.pmMajor, rootDir: nearest.dir, isWorkspaceRoot: !!nearest.globs }; @@ -291,6 +361,7 @@ function detectPackageManager(fromDir, targetDir = fromDir) { return { pm: 'npm', + pmMajor: null, rootDir: path.resolve(fromDir), isWorkspaceRoot: false }; @@ -304,6 +375,7 @@ function detectPackageManager(fromDir, targetDir = fromDir) { * @param {string} fromDir * @returns {{ * pm: string, + * pmMajor: number|null, * rootDir: string, * installDir: string, * useWorkspaceFlag: boolean @@ -319,6 +391,7 @@ function resolveInstallTarget(fromDir) { return { pm: pmInfo.pm, + pmMajor: pmInfo.pmMajor, rootDir: pmInfo.rootDir, installDir, useWorkspaceFlag @@ -328,9 +401,16 @@ function resolveInstallTarget(fromDir) { /** * Build the shell command used to add a dependency. * + * Only pnpm and Yarn Classic need an explicit workspace-root flag. Yarn Berry + * rejects `-W` as an unknown option, and npm / bun add at the root as-is. + * * @param {string} pm * @param {string} pkgName - * @param {{ useWorkspaceFlag?: boolean, isWorkspaceRoot?: boolean }} [opts] + * @param {{ + * useWorkspaceFlag?: boolean, + * isWorkspaceRoot?: boolean, + * pmMajor?: number|null + * }} [opts] * @returns {string} */ function buildInstallCommand(pm, pkgName, opts = {}) { @@ -343,7 +423,9 @@ function buildInstallCommand(pm, pkgName, opts = {}) { ? `pnpm add ${pkgName} -w` : `pnpm add ${pkgName}`; case 'yarn': - return `yarn add ${pkgName}`; + return useWorkspaceFlag && opts.pmMajor === 1 + ? `yarn add ${pkgName} -W` + : `yarn add ${pkgName}`; case 'bun': return `bun add ${pkgName}`; case 'npm': diff --git a/tests/skills.tests.js b/tests/skills.tests.js index e1567a4..2412136 100644 --- a/tests/skills.tests.js +++ b/tests/skills.tests.js @@ -194,6 +194,85 @@ describe('cli/pkg', () => { expect(target.rootDir).to.equal(root); expect(target.installDir).to.equal(member); }); + + it('adds -W at a Yarn Classic workspace root', () => { + const root = mkdtemp('koapp-skills-yarn1-'); + writeJson(path.join(root, 'package.json'), { + name: 'root', + private: true, + workspaces: ['packages/*'] + }); + touch(path.join(root, 'yarn.lock'), '# yarn lockfile v1\n'); + + const target = resolveInstallTarget(root); + expect(target.pm).to.equal('yarn'); + expect(target.pmMajor).to.equal(1); + expect(target.useWorkspaceFlag).to.equal(true); + expect(buildInstallCommand(target.pm, '@axiosleo/koapp', target)) + .to.equal('yarn add @axiosleo/koapp -W'); + }); + + it('omits -W at a Yarn Berry workspace root', () => { + const root = mkdtemp('koapp-skills-yarn3-'); + writeJson(path.join(root, 'package.json'), { + name: 'root', + private: true, + packageManager: 'yarn@4.1.0', + workspaces: ['packages/*'] + }); + touch(path.join(root, '.yarnrc.yml'), 'nodeLinker: node-modules\n'); + touch(path.join(root, 'yarn.lock'), '__metadata:\n version: 8\n'); + + const target = resolveInstallTarget(root); + expect(target.pmMajor).to.equal(4); + expect(target.useWorkspaceFlag).to.equal(true); + expect(buildInstallCommand(target.pm, '@axiosleo/koapp', target)) + .to.equal('yarn add @axiosleo/koapp'); + }); + + it('detects Yarn Berry from .yarnrc.yml without a packageManager field', () => { + const root = mkdtemp('koapp-skills-yarnrc-'); + writeJson(path.join(root, 'package.json'), { + name: 'root', + private: true, + workspaces: ['packages/*'] + }); + touch(path.join(root, '.yarnrc.yml'), 'nodeLinker: node-modules\n'); + touch(path.join(root, 'yarn.lock'), '__metadata:\n version: 8\n'); + + const target = resolveInstallTarget(root); + expect(target.pmMajor).to.equal(2); + expect(buildInstallCommand(target.pm, '@axiosleo/koapp', target)) + .to.equal('yarn add @axiosleo/koapp'); + }); + + it('does not add a workspace flag for npm or bun roots', () => { + const npmRoot = mkdtemp('koapp-skills-npmws-'); + writeJson(path.join(npmRoot, 'package.json'), { + name: 'root', + private: true, + workspaces: ['packages/*'] + }); + touch(path.join(npmRoot, 'package-lock.json'), '{}'); + + const npmTarget = resolveInstallTarget(npmRoot); + expect(npmTarget.useWorkspaceFlag).to.equal(true); + expect(buildInstallCommand(npmTarget.pm, '@axiosleo/koapp', npmTarget)) + .to.equal('npm install @axiosleo/koapp'); + + const bunRoot = mkdtemp('koapp-skills-bunws-'); + writeJson(path.join(bunRoot, 'package.json'), { + name: 'root', + private: true, + workspaces: ['packages/*'] + }); + touch(path.join(bunRoot, 'bun.lock'), '{}'); + + const bunTarget = resolveInstallTarget(bunRoot); + expect(bunTarget.useWorkspaceFlag).to.equal(true); + expect(buildInstallCommand(bunTarget.pm, '@axiosleo/koapp', bunTarget)) + .to.equal('bun add @axiosleo/koapp'); + }); }); describe('buildInstallCommand()', () => { @@ -212,6 +291,21 @@ describe('cli/pkg', () => { expect(buildInstallCommand('bun', '@axiosleo/koapp')).to.equal('bun add @axiosleo/koapp'); expect(buildInstallCommand('npm', '@axiosleo/koapp')).to.equal('npm install @axiosleo/koapp'); }); + + it('only adds yarn -W for Yarn Classic at a workspace root', () => { + expect(buildInstallCommand('yarn', '@axiosleo/koapp', { + useWorkspaceFlag: true, pmMajor: 1 + })).to.equal('yarn add @axiosleo/koapp -W'); + expect(buildInstallCommand('yarn', '@axiosleo/koapp', { + useWorkspaceFlag: true, pmMajor: 4 + })).to.equal('yarn add @axiosleo/koapp'); + expect(buildInstallCommand('yarn', '@axiosleo/koapp', { + useWorkspaceFlag: false, pmMajor: 1 + })).to.equal('yarn add @axiosleo/koapp'); + expect(buildInstallCommand('yarn', '@axiosleo/koapp', { + useWorkspaceFlag: true, pmMajor: null + })).to.equal('yarn add @axiosleo/koapp'); + }); }); describe('resolveLocalPkgDir()', () => { From 185f04b3e8426d7ccadde93958a0aaf8cc29ddd6 Mon Sep 17 00:00:00 2001 From: axiosleo Date: Wed, 29 Jul 2026 14:04:08 +0800 Subject: [PATCH 6/6] fix: update workspace glob parsing logic to handle empty packages list and improve nested project detection in pnpm-workspace.yaml --- src/cli/pkg.js | 9 ++++---- tests/skills.tests.js | 51 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/src/cli/pkg.js b/src/cli/pkg.js index 4ba9d17..67adac9 100644 --- a/src/cli/pkg.js +++ b/src/cli/pkg.js @@ -134,9 +134,10 @@ function readWorkspaceGlobs(dir, pm) { if (!fs.existsSync(wsFile)) { return null; } - const globs = parsePnpmWorkspaceGlobs(wsFile); - // An unparseable workspace file still governs its subtree - return globs.length ? globs : ['**']; + // A missing or empty `packages:` list means the root is the only package + // (pnpm 10+ uses this file for plain config too). Membership then only + // matches the root itself, never the whole subtree. + return parsePnpmWorkspaceGlobs(wsFile); } const pkg = readPackageJson(dir); const field = pkg && (pkg.workspaces || pkg.workspace); @@ -144,7 +145,7 @@ function readWorkspaceGlobs(dir, pm) { return null; } const globs = Array.isArray(field) ? field : field.packages; - return Array.isArray(globs) && globs.length ? globs : ['**']; + return Array.isArray(globs) ? globs : []; } /** diff --git a/tests/skills.tests.js b/tests/skills.tests.js index 2412136..163ee05 100644 --- a/tests/skills.tests.js +++ b/tests/skills.tests.js @@ -246,6 +246,57 @@ describe('cli/pkg', () => { .to.equal('yarn add @axiosleo/koapp'); }); + it('does not hijack a nested project under a config-only pnpm-workspace.yaml', () => { + // pnpm 10+ allows pnpm-workspace.yaml with config keys and no packages list + const root = mkdtemp('koapp-skills-cfgonly-'); + writeJson(path.join(root, 'package.json'), { name: 'root' }); + touch(path.join(root, 'pnpm-workspace.yaml'), 'onlyBuiltDependencies:\n - esbuild\n'); + const standalone = path.join(root, 'tools', 'site'); + writeJson(path.join(standalone, 'package.json'), { name: 'site' }); + touch(path.join(standalone, 'package-lock.json'), '{}'); + + const target = resolveInstallTarget(standalone); + expect(target.pm).to.equal('npm'); + expect(target.installDir).to.equal(standalone); + }); + + it('treats an empty packages list as having no members', () => { + const root = mkdtemp('koapp-skills-emptypkgs-'); + writeJson(path.join(root, 'package.json'), { name: 'root' }); + touch(path.join(root, 'pnpm-workspace.yaml'), 'packages: []\n'); + const standalone = path.join(root, 'demo'); + writeJson(path.join(standalone, 'package.json'), { name: 'demo' }); + touch(path.join(standalone, 'package-lock.json'), '{}'); + + const target = resolveInstallTarget(standalone); + expect(target.pm).to.equal('npm'); + expect(target.installDir).to.equal(standalone); + }); + + it('treats empty workspaces: [] in package.json as having no members', () => { + const root = mkdtemp('koapp-skills-emptyws-'); + writeJson(path.join(root, 'package.json'), { name: 'root', workspaces: [] }); + touch(path.join(root, 'yarn.lock'), '# yarn lockfile v1\n'); + const standalone = path.join(root, 'demo'); + writeJson(path.join(standalone, 'package.json'), { name: 'demo' }); + touch(path.join(standalone, 'package-lock.json'), '{}'); + + const target = resolveInstallTarget(standalone); + expect(target.pm).to.equal('npm'); + expect(target.installDir).to.equal(standalone); + }); + + it('still uses pnpm at a config-only pnpm-workspace.yaml root itself', () => { + const root = mkdtemp('koapp-skills-cfgroot-'); + writeJson(path.join(root, 'package.json'), { name: 'root' }); + touch(path.join(root, 'pnpm-workspace.yaml'), 'onlyBuiltDependencies:\n - esbuild\n'); + + const target = resolveInstallTarget(root); + expect(target.pm).to.equal('pnpm'); + expect(target.installDir).to.equal(root); + expect(target.useWorkspaceFlag).to.equal(true); + }); + it('does not add a workspace flag for npm or bun roots', () => { const npmRoot = mkdtemp('koapp-skills-npmws-'); writeJson(path.join(npmRoot, 'package.json'), {