diff --git a/commands/skills.js b/commands/skills.js index e21e2d0..9460cc7 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, + resolveInstallTarget, + 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,109 @@ 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 target = resolveInstallTarget(cwd); + const installCmd = buildInstallCommand(target.pm, PKG_NAME, { + useWorkspaceFlag: target.useWorkspaceFlag, + pmMajor: target.pmMajor + }); + 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 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}; will install into ${target.installDir}` + ).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}\` in ${target.installDir}?`, 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, target.installDir); + } 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 +197,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 +215,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 +236,8 @@ class SkillsCommand extends Command { } } +SkillsCommand.resolveLocalPkgDir = resolveLocalPkgDir; +SkillsCommand.resolveInstallTarget = resolveInstallTarget; +SkillsCommand.buildInstallCommand = buildInstallCommand; + module.exports = SkillsCommand; diff --git a/src/cli/pkg.js b/src/cli/pkg.js new file mode 100644 index 0000000..67adac9 --- /dev/null +++ b/src/cli/pkg.js @@ -0,0 +1,444 @@ +'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); + 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) { + searching = false; + } else { + 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; + } +} + +/** + * 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; +} + +/** + * 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 {string[]|null} + */ +function readWorkspaceGlobs(dir, pm) { + if (pm === 'pnpm') { + const wsFile = path.join(dir, 'pnpm-workspace.yaml'); + if (!fs.existsSync(wsFile)) { + return null; + } + // 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); + if (!field) { + return null; + } + const globs = Array.isArray(field) ? field : field.packages; + return Array.isArray(globs) ? globs : []; +} + +/** + * Convert a workspace glob into an anchored RegExp. Supports `*` and `**`. + * + * @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. + * + * @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; +} + +/** + * 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. + * + * Within a single directory: a `packageManager` field wins over lockfiles, + * then pnpm, yarn, bun, npm lockfiles. + * + * @param {string} fromDir + * @returns {Array<{ + * dir: string, + * pm: string, + * pmMajor: number|null, + * globs: string[]|null + * }>} + */ +function collectPmCandidates(fromDir) { + const candidates = []; + let dir = path.resolve(fromDir); + const { root } = path.parse(dir); + let searching = true; + + while (searching) { + let pm = null; + let pmVersion = null; + const pkg = readPackageJson(dir); + if (pkg && typeof pkg.packageManager === 'string') { + 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')) + || 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 (pm) { + candidates.push({ + dir, + pm, + pmMajor: detectPmMajor(dir, pm, pmVersion), + globs: readWorkspaceGlobs(dir, pm) + }); + } + + if (dir === root) { + searching = false; + } else { + dir = path.dirname(dir); + } + } + + 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, + * pmMajor: number|null, + * 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, + pmMajor: candidate.pmMajor, + rootDir: candidate.dir, + isWorkspaceRoot: true + }; + } + } + + if (candidates.length) { + const nearest = candidates[0]; + return { + pm: nearest.pm, + pmMajor: nearest.pmMajor, + rootDir: nearest.dir, + isWorkspaceRoot: !!nearest.globs + }; + } + + return { + pm: 'npm', + pmMajor: null, + rootDir: path.resolve(fromDir), + isWorkspaceRoot: false + }; +} + +/** + * 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, + * pmMajor: number|null, + * rootDir: string, + * installDir: string, + * useWorkspaceFlag: boolean + * }} + */ +function resolveInstallTarget(fromDir) { + const nearestPkg = findNearestPackageDir(fromDir); + const pmInfo = detectPackageManager(fromDir, nearestPkg || fromDir); + const installDir = nearestPkg || pmInfo.rootDir; + const useWorkspaceFlag = + pmInfo.isWorkspaceRoot + && path.resolve(installDir) === path.resolve(pmInfo.rootDir); + + return { + pm: pmInfo.pm, + pmMajor: pmInfo.pmMajor, + rootDir: pmInfo.rootDir, + installDir, + useWorkspaceFlag + }; +} + +/** + * 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, + * pmMajor?: number|null + * }} [opts] + * @returns {string} + */ +function buildInstallCommand(pm, pkgName, opts = {}) { + // isWorkspaceRoot kept as a deprecated alias of useWorkspaceFlag + const useWorkspaceFlag = opts.useWorkspaceFlag === true + || opts.isWorkspaceRoot === true; + switch (pm) { + case 'pnpm': + return useWorkspaceFlag + ? `pnpm add ${pkgName} -w` + : `pnpm add ${pkgName}`; + case 'yarn': + return useWorkspaceFlag && opts.pmMajor === 1 + ? `yarn add ${pkgName} -W` + : `yarn add ${pkgName}`; + case 'bun': + return `bun add ${pkgName}`; + case 'npm': + default: + return `npm install ${pkgName}`; + } +} + +module.exports = { + resolveLocalPkgDir, + findNearestPackageDir, + detectPackageManager, + resolveInstallTarget, + buildInstallCommand +}; diff --git a/tests/skills.tests.js b/tests/skills.tests.js new file mode 100644 index 0000000..163ee05 --- /dev/null +++ b/tests/skills.tests.js @@ -0,0 +1,395 @@ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { expect } = require('chai'); +const { + resolveLocalPkgDir, + detectPackageManager, + resolveInstallTarget, + 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); +} + +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', () => { + 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('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); + }); + + 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); + }); + + 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 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'), { + 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()', () => { + it('builds pnpm add -w for workspace roots', () => { + 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', { useWorkspaceFlag: 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'); + }); + + 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()', () => { + 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); + }); + }); +});