diff --git a/docs/commands/init.md b/docs/commands/init.md index 5d8c06cc92b..7abf8c7b4dc 100644 --- a/docs/commands/init.md +++ b/docs/commands/init.md @@ -22,6 +22,7 @@ netlify init - `force` (*boolean*) - Reinitialize CI hooks if the linked project is already configured to use CI - `git-remote-name` (*string*) - Name of Git remote to use. e.g. "origin" - `manual` (*boolean*) - Manually configure a git remote for CI +- `reset-context` (*boolean*) - Replace locally edited Netlify skills with the latest release, and migrate or delete edited copies under renamed or deprecated names - `debug` (*boolean*) - Print debugging information - `auth` (*string*) - Netlify auth token - can be used to run this command without logging in - `skip-agent-setup` (*boolean*) - Skip installing Netlify skills for AI coding agents into the project diff --git a/src/commands/init/index.ts b/src/commands/init/index.ts index b7e446cd590..39d14acfc31 100644 --- a/src/commands/init/index.ts +++ b/src/commands/init/index.ts @@ -12,6 +12,10 @@ export const createInitCommand = (program: BaseCommand) => .option('-m, --manual', 'Manually configure a git remote for CI') .option('--git-remote-name ', 'Name of Git remote to use. e.g. "origin"') .option('--skip-agent-setup', 'Skip installing Netlify skills for AI coding agents into the project') + .option( + '--reset-context', + 'Replace locally edited Netlify skills with the latest release, and migrate or delete edited copies under renamed or deprecated names', + ) .addHelpText('after', () => { const docsUrl = 'https://docs.netlify.com/cli/get-started/' return ` @@ -20,5 +24,8 @@ For more information about getting started with Netlify CLI, see ${terminalLink( }) .action(async (options: OptionValues, command: BaseCommand) => { const { init } = await import('./init.js') - await init(options, command, { setupAgentSkills: !options.skipAgentSetup }) + await init(options, command, { + setupAgentSkills: !options.skipAgentSetup, + resetContext: Boolean(options.resetContext), + }) }) diff --git a/src/commands/init/init.ts b/src/commands/init/init.ts index a8dfc4fbf7f..f6130c62cde 100644 --- a/src/commands/init/init.ts +++ b/src/commands/init/init.ts @@ -212,15 +212,17 @@ type InitExtraOptions = { customizeExitMessage?: InitExitMessageCustomizer | undefined exitAfterConfiguringRepo?: boolean | undefined setupAgentSkills?: boolean | undefined + resetContext?: boolean | undefined } -const installAgentSkills = async (command: BaseCommand): Promise => { +const installAgentSkills = async (command: BaseCommand, reset: boolean): Promise => { log() - const result = await setupAgentSkills({ workingDir: command.netlify.repositoryRoot }) + const result = await setupAgentSkills({ workingDir: command.netlify.repositoryRoot, reset }) await track('sites_agentSkillsSetup', { installed: result.installed, directories: result.directories, skillsVersion: result.skillsVersion, + resetContext: reset, ...result.summary, }) } @@ -232,9 +234,15 @@ export const init = async ( customizeExitMessage, exitAfterConfiguringRepo = false, setupAgentSkills: shouldSetupAgentSkills = false, + resetContext = false, }: InitExtraOptions = {}, ): Promise => { - command.setAnalyticsPayload({ manual: options.manual, force: options.force, skipAgentSetup: options.skipAgentSetup }) + command.setAnalyticsPayload({ + manual: options.manual, + force: options.force, + skipAgentSetup: options.skipAgentSetup, + resetContext: options.resetContext, + }) const { repositoryRoot, state } = command.netlify const { siteInfo: existingSiteInfo } = command.netlify @@ -246,7 +254,7 @@ export const init = async ( await ensureNetlifyIgnore(repositoryRoot) if (shouldSetupAgentSkills) { - await installAgentSkills(command) + await installAgentSkills(command, resetContext) } const repoUrl = getRepoUrl(existingSiteInfo) diff --git a/src/utils/init/agent-skills.ts b/src/utils/init/agent-skills.ts index e27bc7aff70..4c28a1a72af 100644 --- a/src/utils/init/agent-skills.ts +++ b/src/utils/init/agent-skills.ts @@ -17,7 +17,10 @@ const AGENT_DIRECTORY_BY_DRIVING_AGENT: Partial> = { const SUPPORTED_SCHEMA_VERSION = 1 const SKILL_NAME = /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/ const STAGING_PREFIX = '.netlify-skill-' +const STAGING_LEFTOVER = /^\.netlify-skill-(.+)-[A-Za-z0-9]{6}$/ +const RETIRED_LEFTOVER = /^(.+)\.old-\d+-[0-9a-f]{12}$/ const FETCH_TIMEOUT_MS = 10_000 +const LEFTOVER_MIN_AGE_MS = 10 * 60_000 const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']) export interface SkillHistoryEntry { @@ -49,8 +52,8 @@ export type SkillRecord = | { name: string; status: 'current'; version: string | null } | { name: string; status: 'stale'; version: string | null; have: string | null } | { name: string; status: 'modified'; version: string | null } - | { name: string; status: 'renamed'; currentName: string } - | { name: string; status: 'deprecated'; replacedBy: string | null } + | { name: string; status: 'renamed'; currentName: string; modified: boolean } + | { name: string; status: 'deprecated'; replacedBy: string | null; modified: boolean } | { name: string; status: 'unknown' } export interface SkillsClassification { @@ -58,7 +61,16 @@ export interface SkillsClassification { missing: string[] } -export type SkillAction = 'current' | 'added' | 'updated' | 'kept' | 'ignored' | 'failed' +export type SkillAction = + | 'current' + | 'added' + | 'updated' + | 'reset' + | 'renamed' + | 'removed' + | 'kept' + | 'ignored' + | 'failed' export interface SkillActionRecord { name: string @@ -163,6 +175,9 @@ const indexManifest = (manifest: SkillsManifest): ManifestIndex => { return { exact, prior } } +const skillUnderName = ({ exact, prior }: ManifestIndex, name: string): ManifestSkill | undefined => + exact.get(name) ?? prior.get(name) + export const fetchSkillsManifest = async (host: string): Promise => { const url = urlFor(host, 'manifest.json') const bytes = await fetchBytes(url) @@ -191,6 +206,8 @@ const historyOf = (skill: ManifestSkill): SkillHistoryEntry[] => { const lastMatching = (skill: ManifestSkill, treeHash: string | null): SkillHistoryEntry | undefined => treeHash ? historyOf(skill).findLast((entry) => entry.tree_hash === treeHash) : undefined +const executableOf = (skill: ManifestSkill): Set => new Set(skill.executable ?? []) + const listRegularFiles = async (dir: string): Promise => { const files: string[] = [] const walk = async (current: string, prefix: string) => { @@ -261,9 +278,41 @@ const isDirectoryOrLinkToOne = async (dir: string): Promise => { } } +const isOlderThan = async (file: string, ageMs: number): Promise => { + try { + return Date.now() - (await fs.lstat(file)).mtimeMs >= ageMs + } catch { + return false + } +} + +const isSameEntry = async (a: string, b: string): Promise => { + try { + const [statA, statB] = await Promise.all([fs.lstat(a), fs.lstat(b)]) + return statA.ino !== 0 && statA.ino === statB.ino && statA.dev === statB.dev + } catch { + return false + } +} + const sortByName = (entries: T[]): T[] => [...entries].sort((a, b) => a.name.localeCompare(b.name, 'en')) +const hashIfPossible = async (dir: string, skill: ManifestSkill): Promise => { + try { + return await hashSkillTree(dir, executableOf(skill)) + } catch { + return null + } +} + +const isUneditedRelease = async (dir: string, skill: ManifestSkill): Promise => { + if (!(await isDirectory(dir))) { + return false + } + return lastMatching(skill, await hashIfPossible(dir, skill)) !== undefined +} + export const classifySkillsDirectory = async ( root: string, manifest: SkillsManifest, @@ -278,46 +327,50 @@ export const classifySkillsDirectory = async ( } for (const entry of entries) { + if (entry.name.startsWith(STAGING_PREFIX)) continue const known = exact.get(entry.name) const renamed = prior.get(entry.name) - if (!entry.isDirectory() && !known && !renamed) continue + const target = known?.status === 'active' ? known : renamed?.status === 'active' ? renamed : null + const retired = known?.status === 'deprecated' ? known : renamed?.status === 'deprecated' ? renamed : null + if (!entry.isDirectory() && !target && !retired) continue const dir = path.join(root, entry.name) const hasSkillMd = entry.isDirectory() && (await isFile(path.join(dir, 'SKILL.md'))) - if (!hasSkillMd && !known && !renamed) continue + if (!hasSkillMd && known?.status !== 'active') continue if (entry.isDirectory() && !hasSkillMd && known?.status === 'active' && (await fs.readdir(dir)).length === 0) { continue } - if (known?.status === 'deprecated' || renamed?.status === 'deprecated') { - const retired = known?.status === 'deprecated' ? known : renamed - records.push({ name: entry.name, status: 'deprecated', replacedBy: retired?.deprecated?.replaced_by ?? null }) - continue - } - if (!known && renamed) { - records.push({ name: entry.name, status: 'renamed', currentName: renamed.name }) + const subject = target ?? retired + const treeHash = hasSkillMd && subject ? await hashIfPossible(dir, subject) : null + + if (retired) { + records.push({ + name: entry.name, + status: 'deprecated', + replacedBy: retired.deprecated?.replaced_by ?? null, + modified: lastMatching(retired, treeHash) === undefined, + }) continue } - if (!known) { + + if (!target) { records.push({ name: entry.name, status: 'unknown' }) continue } - presentActive.add(known.name) - let treeHash: string | null = null - if (hasSkillMd) { - try { - treeHash = await hashSkillTree(dir, new Set(known.executable ?? [])) - } catch { - treeHash = null - } + const match = lastMatching(target, treeHash) + if (target === renamed) { + records.push({ name: entry.name, status: 'renamed', currentName: target.name, modified: match === undefined }) + continue } - const match = lastMatching(known, treeHash) - if (treeHash === known.tree_hash) { - records.push({ name: entry.name, status: 'current', version: known.version }) + + presentActive.add(target.name) + if (treeHash === target.tree_hash) { + records.push({ name: entry.name, status: 'current', version: target.version }) } else if (match) { - records.push({ name: entry.name, status: 'stale', version: known.version, have: match.version }) + records.push({ name: entry.name, status: 'stale', version: target.version, have: match.version }) } else { - records.push({ name: entry.name, status: 'modified', version: known.version }) + records.push({ name: entry.name, status: 'modified', version: target.version }) } } @@ -330,20 +383,24 @@ export const classifySkillsDirectory = async ( } const replaceDirectory = async (staged: string, target: string): Promise => { - const exists = await isDirectory(target) + let existing = await fs.lstat(target).catch(() => null) + if (existing && !existing.isDirectory()) { + await fs.rm(target, { force: true }) + existing = null + } const retired = `${target}.old-${process.pid.toString()}-${randomBytes(6).toString('hex')}` - if (exists) { + if (existing) { await fs.rename(target, retired) } try { await fs.rename(staged, target) } catch (error) { - if (exists) { + if (existing) { await fs.rename(retired, target) } throw error } - if (exists) { + if (existing) { await fs.rm(retired, { recursive: true, force: true }).catch(() => undefined) } } @@ -355,12 +412,7 @@ const isReplaceableCopy = async (target: string, skill: ManifestSkill): Promise< if ((await fs.readdir(target)).length === 0) { return true } - try { - const treeHash = await hashSkillTree(target, new Set(skill.executable ?? [])) - return historyOf(skill).some((entry) => entry.tree_hash === treeHash) - } catch { - return false - } + return isUneditedRelease(target, skill) } const downloadSkill = async (host: string, skill: ManifestSkill): Promise<[string, Uint8Array][]> => { @@ -383,23 +435,32 @@ const downloadSkill = async (host: string, skill: ManifestSkill): Promise<[strin return downloaded } -export const installSkill = async (host: string, dest: string, skill: ManifestSkill): Promise => { +export const installSkill = async ( + host: string, + dest: string, + skill: ManifestSkill, + { force = false }: { force?: boolean } = {}, +): Promise => { const downloaded = await downloadSkill(host, skill) const target = path.join(dest, skill.name) - if (!(await isReplaceableCopy(target, skill))) { + if (!force && !(await isReplaceableCopy(target, skill))) { throw new SkillConflictError(`${target} already exists and is not an unedited Netlify skill; left in place`) } await fs.mkdir(dest, { recursive: true }) const staged = await fs.mkdtemp(path.join(dest, `${STAGING_PREFIX}${skill.name}-`)) try { - const executable = new Set(skill.executable ?? []) + const executable = executableOf(skill) for (const [file, bytes] of downloaded) { const output = path.join(staged, ...file.split('/')) await fs.mkdir(path.dirname(output), { recursive: true }) await fs.writeFile(output, bytes, { mode: executable.has(file) ? 0o755 : 0o644 }) } - if (!(await isReplaceableCopy(target, skill))) { + const stagedHash = await hashSkillTree(staged, executable) + if (stagedHash !== skill.tree_hash) { + throw new SkillsError(`staged tree hash ${stagedHash} does not match the manifest`) + } + if (!force && !(await isReplaceableCopy(target, skill))) { throw new SkillConflictError(`${target} changed while downloading; left in place`) } await replaceDirectory(staged, target) @@ -411,40 +472,103 @@ export const installSkill = async (host: string, dest: string, skill: ManifestSk return downloaded.length } +const removeInstallLeftovers = async (root: string, index: ManifestIndex): Promise => { + if (!(await isDirectoryOrLinkToOne(root))) { + return [] + } + const removed: string[] = [] + for (const entry of sortByName(await fs.readdir(root, { withFileTypes: true }))) { + if (!entry.isDirectory()) continue + const leftover = path.join(root, entry.name) + const staging = STAGING_LEFTOVER.exec(entry.name) + if (staging) { + if (skillUnderName(index, staging[1]) && (await isOlderThan(leftover, LEFTOVER_MIN_AGE_MS))) { + await fs.rm(leftover, { recursive: true, force: true }) + removed.push(entry.name) + } + continue + } + const retired = RETIRED_LEFTOVER.exec(entry.name) + const skill = retired ? skillUnderName(index, retired[1]) : undefined + if (!retired || !skill) continue + const skillPresent = (await exists(path.join(root, retired[1]))) || (await exists(path.join(root, skill.name))) + if ( + skillPresent && + (await isOlderThan(leftover, LEFTOVER_MIN_AGE_MS)) && + (await isUneditedRelease(leftover, skill)) + ) { + await fs.rm(leftover, { recursive: true, force: true }) + removed.push(entry.name) + } + } + return removed +} + export const syncSkills = async ({ host, directory, manifest, + reset = false, }: { host: string directory: string manifest: SkillsManifest + reset?: boolean }): Promise => { - const { exact } = indexManifest(manifest) - const before = await classifySkillsDirectory(directory, manifest) + const index = indexManifest(manifest) const actions: SkillActionRecord[] = [] const act = (name: string, action: SkillAction, detail?: string) => { actions.push(detail ? { name, action, detail } : { name, action }) } const skillByName = (name: string): ManifestSkill => { - const skill = exact.get(name) + const skill = skillUnderName(index, name) if (!skill) { throw new SkillsError(`manifest: unknown skill ${name}`) } return skill } + const stillUnedited = async (name: string, skill: ManifestSkill): Promise => + reset || isUneditedRelease(path.join(directory, name), skill) + const removeUnlessSame = async (name: string, keep: string): Promise => { + if (await isSameEntry(path.join(directory, name), path.join(directory, keep))) return + await fs.rm(path.join(directory, name), { recursive: true, force: true }) + } + const occupantOf = async (name: string): Promise => { + if (!(await isDirectoryOrLinkToOne(directory))) return undefined + for (const entry of await fs.readdir(directory)) { + if (entry === name) continue + if (await isSameEntry(path.join(directory, entry), path.join(directory, name))) return entry + } + return undefined + } - const install = async (name: string, onInstalled: (skill: ManifestSkill) => void) => { - const skill = skillByName(name) + for (const leftover of await removeInstallLeftovers(directory, index)) { + act(leftover, 'removed', 'leftover from an interrupted install') + } + const before = await classifySkillsDirectory(directory, manifest) + + const installed = new Set() + const settled = new Set() + const install = async (skill: ManifestSkill, options: { force?: boolean } = {}): Promise => { + if (settled.has(skill.name)) return false + const occupant = await occupantOf(skill.name) + if (occupant) { + settled.add(skill.name) + act(skill.name, 'kept', `${occupant} already uses this name; left in place`) + return false + } try { - await installSkill(host, directory, skill) - onInstalled(skill) + await installSkill(host, directory, skill, options) + installed.add(skill.name) + return true } catch (error) { + settled.add(skill.name) if (error instanceof SkillConflictError) { - act(name, 'kept', error.message) + act(skill.name, 'kept', error.message) } else { - act(name, 'failed', errorMessage(error)) + act(skill.name, 'failed', errorMessage(error)) } + return false } } @@ -453,20 +577,75 @@ export const syncSkills = async ({ case 'current': act(record.name, 'current', record.version ?? undefined) break - case 'stale': - await install(record.name, (skill) => { + case 'stale': { + const skill = skillByName(record.name) + if (await install(skill)) { act(record.name, 'updated', `${record.have ?? 'unknown'} -> ${skill.version ?? 'latest'}`) - }) + } break - case 'modified': - act(record.name, 'kept', 'edited locally') + } + case 'modified': { + if (!reset) { + act(record.name, 'kept', 'edited locally') + break + } + const target = path.join(directory, record.name) + if ((await isDirectory(target)) && !(await isFile(path.join(target, 'SKILL.md')))) { + act(record.name, 'kept', 'has no SKILL.md, so it is not a Netlify skill; left in place') + break + } + const skill = skillByName(record.name) + if (await install(skill, { force: true })) { + act(record.name, 'reset', `edited copy replaced with ${skill.version ?? 'latest'}`) + } break - case 'renamed': - act(record.name, 'kept', `now called ${record.currentName}; this copy can be removed`) + } + case 'renamed': { + const skill = skillByName(record.currentName) + const current = before.skills.find((other) => other.name === record.currentName) + const currentPresent = current !== undefined || installed.has(record.currentName) + if (!reset && record.modified) { + act(record.name, 'kept', `edited locally; now called ${record.currentName}`) + break + } + if (!reset && current?.status === 'modified') { + act(record.name, 'kept', `${record.currentName} is already installed and edited locally`) + break + } + const priorDir = path.join(directory, record.name) + if (!currentPresent && (await isSameEntry(priorDir, path.join(directory, record.currentName)))) { + await fs.rename(priorDir, path.join(directory, record.currentName)) + installed.add(record.currentName) + act(record.name, 'renamed', `-> ${record.currentName}`) + break + } + if (!currentPresent && !(await install(skill))) { + act(record.name, 'kept', `now called ${record.currentName}, which could not be installed`) + break + } + if (!(await stillUnedited(record.name, skill))) { + act(record.name, 'kept', `edited locally; now called ${record.currentName}`) + break + } + await removeUnlessSame(record.name, record.currentName) + if (currentPresent) { + act(record.name, 'removed', `superseded by ${record.currentName}`) + } else { + act(record.name, 'renamed', `-> ${record.currentName}`) + } break - case 'deprecated': - act(record.name, 'kept', `deprecated${record.replacedBy ? `; use ${record.replacedBy}` : ''}`) + } + case 'deprecated': { + const retired = skillByName(record.name) + const replacement = record.replacedBy ? `; use ${record.replacedBy}` : '' + if ((record.modified && !reset) || !(await stillUnedited(record.name, retired))) { + act(record.name, 'kept', `deprecated${replacement}, but edited locally`) + break + } + await fs.rm(path.join(directory, record.name), { recursive: true, force: true }) + act(record.name, 'removed', `deprecated${replacement}`) break + } case 'unknown': act(record.name, 'ignored', 'not a Netlify skill') break @@ -474,9 +653,11 @@ export const syncSkills = async ({ } for (const name of before.missing) { - await install(name, (skill) => { + if (installed.has(name)) continue + const skill = skillByName(name) + if (await install(skill)) { act(name, 'added', skill.version ?? undefined) - }) + } } return { directory, actions } @@ -501,7 +682,17 @@ export const resolveSkillsDirectories = async ( } const summarize = (actions: SkillActionRecord[]): Record => { - const summary: Record = { current: 0, added: 0, updated: 0, kept: 0, ignored: 0, failed: 0 } + const summary: Record = { + current: 0, + added: 0, + updated: 0, + reset: 0, + renamed: 0, + removed: 0, + kept: 0, + ignored: 0, + failed: 0, + } for (const { action } of actions) { summary[action] += 1 } @@ -511,16 +702,34 @@ const summarize = (actions: SkillActionRecord[]): Record => const describeSync = ({ directory, actions }: SkillsSyncResult): string => { const summary = summarize(actions) const location = chalk.underline(directory) + const changed = summary.added + summary.updated + summary.reset + summary.renamed + summary.removed const parts = [ summary.added > 0 ? `${summary.added.toString()} added` : '', summary.updated > 0 ? `${summary.updated.toString()} updated` : '', + summary.reset > 0 ? `${summary.reset.toString()} reset` : '', + summary.renamed > 0 ? `${summary.renamed.toString()} renamed` : '', + summary.removed > 0 ? `${summary.removed.toString()} removed` : '', summary.kept > 0 ? `${summary.kept.toString()} kept` : '', summary.failed > 0 ? `${summary.failed.toString()} failed` : '', ].filter(Boolean) - if (summary.added + summary.updated + summary.failed === 0) { + if (changed + summary.failed === 0) { return `Netlify skills in ${location} are up to date${parts.length > 0 ? ` (${parts.join(', ')})` : ''}.` } - return `Installed Netlify skills in ${location} (${parts.join(', ')}).` + const verb = changed === summary.added ? 'Installed' : 'Synced' + return `${verb} Netlify skills in ${location} (${parts.join(', ')}).` +} + +const logSync = (directory: string, result: SkillsSyncResult, reset: boolean): void => { + log(describeSync({ directory, actions: result.actions })) + const listed = result.actions.filter(({ action }) => action === 'kept' || action === 'failed') + for (const { name, action, detail } of listed) { + log(` ${chalk.dim(name)}: ${detail ?? action}`) + } + if (!reset && listed.some(({ action, detail }) => action === 'kept' && detail?.includes('edited locally'))) { + log( + ` Run ${chalk.cyanBright.bold(`${netlifyCommand()} init --reset-context`)} to replace edited Netlify skills with the latest release.`, + ) + } } export interface AgentSkillsSetupSummary { @@ -534,9 +743,11 @@ export interface AgentSkillsSetupSummary { export const setupAgentSkills = async ({ workingDir, env = process.env, + reset = false, }: { workingDir: string env?: NodeJS.ProcessEnv + reset?: boolean }): Promise => { let directories: string[] = [] try { @@ -545,18 +756,13 @@ export const setupAgentSkills = async ({ const manifest = await fetchSkillsManifest(host) const actions: SkillActionRecord[] = [] for (const directory of directories) { - const result = await syncSkills({ host, directory: path.resolve(workingDir, directory), manifest }) + const result = await syncSkills({ host, directory: path.resolve(workingDir, directory), manifest, reset }) actions.push(...result.actions) - log(describeSync({ directory, actions: result.actions })) - for (const { name, action, detail } of result.actions) { - if (action === 'kept' || action === 'failed') { - log(` ${chalk.dim(name)}: ${detail ?? action}`) - } - } + logSync(directory, result, reset) } const summary = summarize(actions) return { - installed: summary.current + summary.added + summary.updated + summary.kept > 0, + installed: summary.current + summary.added + summary.updated + summary.reset + summary.renamed + summary.kept > 0, directories, skillsVersion: manifest.version, summary, diff --git a/tests/integration/commands/init/init.test.ts b/tests/integration/commands/init/init.test.ts index 7e7c8a01e66..3b731b53509 100644 --- a/tests/integration/commands/init/init.test.ts +++ b/tests/integration/commands/init/init.test.ts @@ -20,36 +20,59 @@ const SKILL_CONTENT = '# netlify-functions\n' const sha256 = (content: string) => `sha256:${createHash('sha256').update(content).digest('hex')}` -const skillsManifest = () => { - const fileHash = sha256(SKILL_CONTENT) - const treeHash = `sha256:${createHash('sha256') - .update(`SKILL.md\u0000100644\u0000${fileHash.replace(/^sha256:/, '')}\n`) - .digest('hex')}` - return { - schema_version: 1, - version: '1.0.0', - skills: [ - { - name: 'netlify-functions', - status: 'active', - version: '1.0.0', - prior_names: [], - description: 'Netlify Functions', - tree_hash: treeHash, - files: { 'SKILL.md': fileHash }, - executable: [], - history: [{ version: '1.0.0', tree_hash: treeHash }], - }, - ], +type SkillFiles = Record + +const treeHashOf = (files: SkillFiles) => { + const hash = createHash('sha256') + for (const file of Object.keys(files).sort()) { + hash.update(`${file}\u0000100644\u0000${sha256(files[file]).replace(/^sha256:/, '')}\n`) } + return `sha256:${hash.digest('hex')}` +} + +interface HostedSkill { + files: SkillFiles + status?: 'active' | 'deprecated' + previous?: Record } -const withSkillsHost = async (handler: (host: { url: string; requests: string[] }) => Promise) => { +const DEFAULT_HOSTED_SKILLS: Record = { + 'netlify-functions': { files: { 'SKILL.md': SKILL_CONTENT } }, +} + +const skillsManifest = (skills: Record) => ({ + schema_version: 1, + version: '1.0.0', + skills: Object.entries(skills).map(([name, { files, status = 'active', previous = {} }]) => { + const current = status === 'active' ? treeHashOf(files) : null + return { + name, + status, + version: current ? '1.0.0' : null, + prior_names: [], + description: name, + tree_hash: current, + files: Object.fromEntries(Object.entries(files).map(([file, content]) => [file, sha256(content)])), + executable: [], + history: [ + ...Object.entries(previous).map(([version, oldFiles]) => ({ version, tree_hash: treeHashOf(oldFiles) })), + ...(current ? [{ version: '1.0.0', tree_hash: current }] : []), + ], + } + }), +}) + +const withSkillsHost = async ( + handler: (host: { url: string; requests: string[] }) => Promise, + skills: Record = DEFAULT_HOSTED_SKILLS, +) => { const requests: string[] = [] - const responses = new Map([ - ['/manifest.json', JSON.stringify(skillsManifest())], - ['/skills/netlify-functions/SKILL.md', SKILL_CONTENT], - ]) + const responses = new Map([['/manifest.json', JSON.stringify(skillsManifest(skills))]]) + for (const [name, { files }] of Object.entries(skills)) { + for (const [file, content] of Object.entries(files)) { + responses.set(`/skills/${name}/${file}`, content) + } + } const server = createServer((req, res) => { const url = req.url ?? '' requests.push(url) @@ -760,53 +783,56 @@ describe.concurrent('commands/init', () => { }) }) - test('netlify init installs Netlify skills for AI agents by default and is idempotent', async (t) => { - const siteInfo = { - admin_url: 'https://app.netlify.com/projects/site-name/overview', - ssl_url: 'https://site-name.netlify.app/', - id: 'site_id', - name: 'site-name', - build_settings: { repo_url: 'https://github.com/owner/repo' }, + const linkedSiteInfo = { + admin_url: 'https://app.netlify.com/projects/site-name/overview', + ssl_url: 'https://site-name.netlify.app/', + id: 'site_id', + name: 'site-name', + build_settings: { repo_url: 'https://github.com/owner/repo' }, + } + const linkedSiteRoutes = [ + { path: 'accounts', response: [{ slug: 'test-account' }] }, + { path: 'sites/site_id/service-instances', response: [] }, + { path: 'sites/site_id', response: linkedSiteInfo }, + { path: 'sites', response: [linkedSiteInfo] }, + { path: 'deploy_keys', method: 'POST' as const, response: { public_key: 'public_key' } }, + { path: 'sites/site_id', method: 'PATCH' as const, response: { deploy_hook: 'deploy_hook' } }, + ] + const manualQuestions = () => [ + { question: 'Your build command (hugo build/yarn run build/etc)', answer: answerWithValue('npm run build') }, + { question: 'Directory to deploy (blank for current dir)', answer: answerWithValue('dist') }, + { question: 'No netlify.toml detected', answer: CONFIRM }, + { question: 'Give this Netlify SSH public key access to your repository', answer: CONFIRM }, + { question: 'The SSH URL of the remote git repo', answer: CONFIRM }, + { question: 'Configure the following webhook for your repository', answer: CONFIRM }, + ] + const initOnLinkedSite = + ({ apiUrl, cwd, skillsHost }: { apiUrl: string; cwd: string; skillsHost: string }) => + async (...flags: string[]) => { + const env = { + NETLIFY_API_URL: apiUrl, + NETLIFY_SITE_ID: 'site_id', + NETLIFY_AUTH_TOKEN: 'fake-token', + NETLIFY_SKILLS_HOST: skillsHost, + } + const childProcess = execa(cliPath, ['init', '--manual', ...flags], { cwd, env }) + if (process.env.DEBUG_TESTS) { + childProcess.stdout?.on('data', (data: Buffer) => { + process.stderr.write(data) + }) + } + handleQuestions(childProcess, manualQuestions()) + return await childProcess } - const routes = [ - { path: 'accounts', response: [{ slug: 'test-account' }] }, - { path: 'sites/site_id/service-instances', response: [] }, - { path: 'sites/site_id', response: siteInfo }, - { path: 'sites', response: [siteInfo] }, - { path: 'deploy_keys', method: 'POST' as const, response: { public_key: 'public_key' } }, - { path: 'sites/site_id', method: 'PATCH' as const, response: { deploy_hook: 'deploy_hook' } }, - ] - const manualQuestions = () => [ - { question: 'Your build command (hugo build/yarn run build/etc)', answer: answerWithValue('npm run build') }, - { question: 'Directory to deploy (blank for current dir)', answer: answerWithValue('dist') }, - { question: 'No netlify.toml detected', answer: CONFIRM }, - { question: 'Give this Netlify SSH public key access to your repository', answer: CONFIRM }, - { question: 'The SSH URL of the remote git repo', answer: CONFIRM }, - { question: 'Configure the following webhook for your repository', answer: CONFIRM }, - ] + test('netlify init installs Netlify skills for AI agents by default and is idempotent', async (t) => { await withSiteBuilder(t, async (builder) => { await builder.withGit().ensureDirectoryExists(path.join(builder.directory, '.agents')).build() - await withMockApi(routes, async ({ apiUrl }) => { + await withMockApi(linkedSiteRoutes, async ({ apiUrl }) => { await withSkillsHost(async (skillsHost) => { - const env = { - NETLIFY_API_URL: apiUrl, - NETLIFY_SITE_ID: 'site_id', - NETLIFY_AUTH_TOKEN: 'fake-token', - NETLIFY_SKILLS_HOST: skillsHost.url, - } const skillPath = path.join(builder.directory, '.agents', 'skills', 'netlify-functions', 'SKILL.md') - const runInit = async (...flags: string[]) => { - const childProcess = execa(cliPath, ['init', '--manual', ...flags], { cwd: builder.directory, env }) - if (process.env.DEBUG_TESTS) { - childProcess.stdout?.on('data', (data: Buffer) => { - process.stderr.write(data) - }) - } - handleQuestions(childProcess, manualQuestions()) - return await childProcess - } + const runInit = initOnLinkedSite({ apiUrl, cwd: builder.directory, skillsHost: skillsHost.url }) const first = await runInit() t.expect(first.stdout).toContain('Installed Netlify skills') @@ -825,4 +851,47 @@ describe.concurrent('commands/init', () => { }) }) }) + + test('netlify init syncs installed skills: updates stale copies, removes deprecated ones, keeps edits until --reset-context', async (t) => { + await withSiteBuilder(t, async (builder) => { + const skillsDir = path.join('.agents', 'skills') + await builder + .withGit() + .withContentFiles([ + { path: path.join(skillsDir, 'netlify-functions', 'SKILL.md'), content: '# functions (old)\n' }, + { path: path.join(skillsDir, 'netlify-blobs', 'SKILL.md'), content: '# blobs plus my notes\n' }, + { path: path.join(skillsDir, 'netlify-db', 'SKILL.md'), content: '# db\n' }, + ]) + .build() + const skillFile = (name: string) => readFile(path.join(builder.directory, skillsDir, name, 'SKILL.md'), 'utf8') + + await withMockApi(linkedSiteRoutes, async ({ apiUrl }) => { + await withSkillsHost( + async (skillsHost) => { + const runInit = initOnLinkedSite({ apiUrl, cwd: builder.directory, skillsHost: skillsHost.url }) + + const first = await runInit() + t.expect(first.stdout).toMatch(/Synced Netlify skills in .*\(1 updated, 1 removed, 1 kept\)\./) + t.expect(first.stdout).toContain('netlify-blobs: edited locally') + t.expect(first.stdout).toContain('--reset-context') + await t.expect(skillFile('netlify-functions')).resolves.toBe('# functions\n') + await t.expect(skillFile('netlify-blobs')).resolves.toBe('# blobs plus my notes\n') + await t.expect(skillFile('netlify-db')).rejects.toThrow(/ENOENT/) + + const second = await runInit('--reset-context') + t.expect(second.stdout).toMatch(/Synced Netlify skills in .*\(1 reset\)\./) + await t.expect(skillFile('netlify-blobs')).resolves.toBe('# blobs\n') + }, + { + 'netlify-functions': { + files: { 'SKILL.md': '# functions\n' }, + previous: { '0.9.0': { 'SKILL.md': '# functions (old)\n' } }, + }, + 'netlify-blobs': { files: { 'SKILL.md': '# blobs\n' } }, + 'netlify-db': { files: {}, status: 'deprecated', previous: { '0.9.0': { 'SKILL.md': '# db\n' } } }, + }, + ) + }) + }) + }) }) diff --git a/tests/unit/utils/init/agent-skills.test.ts b/tests/unit/utils/init/agent-skills.test.ts index 4c606c45527..8e9edc42ed2 100644 --- a/tests/unit/utils/init/agent-skills.test.ts +++ b/tests/unit/utils/init/agent-skills.test.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto' -import { chmod, lstat, mkdir, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises' +import { chmod, lstat, mkdir, mkdtemp, readFile, readdir, rm, stat, symlink, utimes, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -19,6 +19,7 @@ import { setupAgentSkills, syncSkills, } from '../../../../src/utils/init/agent-skills.js' +import { log } from '../../../../src/utils/command-helpers.js' vi.mock('../../../../src/utils/command-helpers.js', async (importOriginal) => ({ ...(await importOriginal()), @@ -130,12 +131,15 @@ const listDirectories = async (root: string) => .map((entry) => entry.name) .sort() +const loggedLines = () => vi.mocked(log).mock.calls.map(([line]) => String(line)) + describe('agent skills', () => { let projectDir: string let skillsDir: string beforeEach(async () => { vi.stubGlobal('fetch', vi.fn()) + vi.mocked(log).mockClear() projectDir = await mkdtemp(join(tmpdir(), 'agent-skills-')) skillsDir = join(projectDir, '.agents', 'skills') }) @@ -200,6 +204,12 @@ describe('agent skills', () => { let manifest: SkillsManifest let responses: Map + const manifestSkill = (name: string): ManifestSkill => { + const skill = manifest.skills.find((candidate) => candidate.name === name) + if (!skill) throw new Error(`${name} missing from manifest`) + return skill + } + beforeEach(() => { manifest = buildManifest(skills, [deprecatedSkill('netlify-legacy', RETIRED_FILES, 'netlify-deploy')]) responses = hostedResponses(manifest, skills) @@ -269,15 +279,76 @@ describe('agent skills', () => { await expect(readFile(join(skillsDir, 'netlify-deploy', 'SKILL.md'), 'utf8')).resolves.toBe('# my own notes\n') }) - test('installs the new name beside a copy under a prior name and leaves the old copy alone', async () => { + test('--reset-context replaces a locally edited copy with the latest release', async () => { + await writeSkill(skillsDir, DEPLOY.name, { ...DEPLOY.files, 'SKILL.md': '# my own notes\n', 'notes.md': 'x\n' }) + + const { actions } = await syncSkills({ host: HOST, directory: skillsDir, manifest, reset: true }) + + expect(actions).toContainEqual({ + name: 'netlify-deploy', + action: 'reset', + detail: 'edited copy replaced with 2.0.0', + }) + await expect(readFile(join(skillsDir, 'netlify-deploy', 'SKILL.md'), 'utf8')).resolves.toBe('# deploy\n') + expect((await readdir(join(skillsDir, 'netlify-deploy'))).sort()).toEqual(['SKILL.md', 'scripts']) + }) + + test('--reset-context replaces a symlink standing in for a skill without touching its target', async () => { + const elsewhere = join(projectDir, 'elsewhere') + await writeSkill(elsewhere, 'functions-source', FUNCTIONS_V1) + await mkdir(skillsDir, { recursive: true }) + await symlink(join(elsewhere, 'functions-source'), join(skillsDir, 'netlify-functions')) + + const { actions } = await syncSkills({ host: HOST, directory: skillsDir, manifest, reset: true }) + + expect(actions).toContainEqual({ + name: 'netlify-functions', + action: 'reset', + detail: 'edited copy replaced with 2.0.0', + }) + expect((await lstat(join(skillsDir, 'netlify-functions'))).isDirectory()).toBe(true) + await expect(readFile(join(skillsDir, 'netlify-functions', 'SKILL.md'), 'utf8')).resolves.toBe('# functions v2\n') + await expect(readFile(join(elsewhere, 'functions-source', 'SKILL.md'), 'utf8')).resolves.toBe('# functions v1\n') + }) + + test('migrates an unedited copy under a prior name to the current name', async () => { await writeSkill(skillsDir, 'netlify-cli-and-deploy', DEPLOY.files, DEPLOY.executable) const { actions } = await syncSkills({ host: HOST, directory: skillsDir, manifest }) + expect(actions).toEqual([ + { name: 'netlify-cli-and-deploy', action: 'renamed', detail: '-> netlify-deploy' }, + { name: 'netlify-functions', action: 'added', detail: '2.0.0' }, + ]) + await expect(listDirectories(skillsDir)).resolves.toEqual(['netlify-deploy', 'netlify-functions']) + await expect(readFile(join(skillsDir, 'netlify-deploy', 'SKILL.md'), 'utf8')).resolves.toBe('# deploy\n') + }) + + test('removes an unedited prior-name copy when the current name is already installed', async () => { + await writeSkill(skillsDir, 'netlify-cli-and-deploy', DEPLOY.files, DEPLOY.executable) + await writeSkill(skillsDir, DEPLOY.name, DEPLOY.files, DEPLOY.executable) + await writeSkill(skillsDir, FUNCTIONS.name, FUNCTIONS.files) + + const { actions } = await syncSkills({ host: HOST, directory: skillsDir, manifest }) + + expect(actions).toEqual([ + { name: 'netlify-cli-and-deploy', action: 'removed', detail: 'superseded by netlify-deploy' }, + { name: 'netlify-deploy', action: 'current', detail: '2.0.0' }, + { name: 'netlify-functions', action: 'current', detail: '2.0.0' }, + ]) + await expect(listDirectories(skillsDir)).resolves.toEqual(['netlify-deploy', 'netlify-functions']) + expect(requestedPaths()).toEqual([]) + }) + + test('keeps an edited prior-name copy and still installs the current name beside it', async () => { + await writeSkill(skillsDir, 'netlify-cli-and-deploy', { 'SKILL.md': '# my deploy notes\n' }) + + const { actions } = await syncSkills({ host: HOST, directory: skillsDir, manifest }) + expect(actions).toContainEqual({ name: 'netlify-cli-and-deploy', action: 'kept', - detail: 'now called netlify-deploy; this copy can be removed', + detail: 'edited locally; now called netlify-deploy', }) expect(actions).toContainEqual({ name: 'netlify-deploy', action: 'added', detail: '2.0.0' }) await expect(listDirectories(skillsDir)).resolves.toEqual([ @@ -287,26 +358,42 @@ describe('agent skills', () => { ]) }) - test('keeps reporting a renamed copy on the second run without extra downloads', async () => { + test('keeps a prior-name copy when the current name is installed and edited', async () => { await writeSkill(skillsDir, 'netlify-cli-and-deploy', DEPLOY.files, DEPLOY.executable) - await syncSkills({ host: HOST, directory: skillsDir, manifest }) - vi.mocked(fetch).mockClear() + await writeSkill(skillsDir, DEPLOY.name, { 'SKILL.md': '# my deploy notes\n' }) const { actions } = await syncSkills({ host: HOST, directory: skillsDir, manifest }) - expect(actions).toEqual([ - { - name: 'netlify-cli-and-deploy', - action: 'kept', - detail: 'now called netlify-deploy; this copy can be removed', - }, - { name: 'netlify-deploy', action: 'current', detail: '2.0.0' }, - { name: 'netlify-functions', action: 'current', detail: '2.0.0' }, - ]) - expect(requestedPaths()).toEqual([]) + expect(actions).toContainEqual({ + name: 'netlify-cli-and-deploy', + action: 'kept', + detail: 'netlify-deploy is already installed and edited locally', + }) + expect(actions).toContainEqual({ name: 'netlify-deploy', action: 'kept', detail: 'edited locally' }) + await expect(readFile(join(skillsDir, 'netlify-cli-and-deploy', 'SKILL.md'), 'utf8')).resolves.toBe('# deploy\n') + }) + + test('--reset-context migrates an edited prior-name copy over an edited current one', async () => { + await writeSkill(skillsDir, 'netlify-cli-and-deploy', { 'SKILL.md': '# my deploy notes\n' }) + await writeSkill(skillsDir, DEPLOY.name, { 'SKILL.md': '# other notes\n' }) + + const { actions } = await syncSkills({ host: HOST, directory: skillsDir, manifest, reset: true }) + + expect(actions).toContainEqual({ + name: 'netlify-cli-and-deploy', + action: 'removed', + detail: 'superseded by netlify-deploy', + }) + expect(actions).toContainEqual({ + name: 'netlify-deploy', + action: 'reset', + detail: 'edited copy replaced with 2.0.0', + }) + await expect(listDirectories(skillsDir)).resolves.toEqual(['netlify-deploy', 'netlify-functions']) + await expect(readFile(join(skillsDir, 'netlify-deploy', 'SKILL.md'), 'utf8')).resolves.toBe('# deploy\n') }) - test('reports a deprecated skill and leaves it and unknown directories alone', async () => { + test('deletes an unedited deprecated skill and leaves unknown directories alone', async () => { await writeSkill(skillsDir, 'netlify-legacy', RETIRED_FILES) await writeSkill(skillsDir, 'my-team-skill', { 'SKILL.md': '# ours\n' }) @@ -315,17 +402,199 @@ describe('agent skills', () => { expect(actions).toContainEqual({ name: 'my-team-skill', action: 'ignored', detail: 'not a Netlify skill' }) expect(actions).toContainEqual({ name: 'netlify-legacy', - action: 'kept', + action: 'removed', detail: 'deprecated; use netlify-deploy', }) await expect(listDirectories(skillsDir)).resolves.toEqual([ 'my-team-skill', 'netlify-deploy', 'netlify-functions', - 'netlify-legacy', ]) }) + test('keeps an edited deprecated skill until --reset-context', async () => { + await writeSkill(skillsDir, 'netlify-legacy', { 'SKILL.md': '# retired, with my notes\n' }) + + const first = await syncSkills({ host: HOST, directory: skillsDir, manifest }) + expect(first.actions).toContainEqual({ + name: 'netlify-legacy', + action: 'kept', + detail: 'deprecated; use netlify-deploy, but edited locally', + }) + await expect(listDirectories(skillsDir)).resolves.toContain('netlify-legacy') + + const second = await syncSkills({ host: HOST, directory: skillsDir, manifest, reset: true }) + expect(second.actions).toContainEqual({ + name: 'netlify-legacy', + action: 'removed', + detail: 'deprecated; use netlify-deploy', + }) + await expect(listDirectories(skillsDir)).resolves.toEqual(['netlify-deploy', 'netlify-functions']) + }) + + test('never touches a copy of a skill living under an unrelated name', async () => { + await writeSkill(skillsDir, 'deploy-copy', DEPLOY.files, DEPLOY.executable) + + const { actions } = await syncSkills({ host: HOST, directory: skillsDir, manifest }) + + expect(actions).toContainEqual({ name: 'deploy-copy', action: 'ignored', detail: 'not a Netlify skill' }) + await expect(listDirectories(skillsDir)).resolves.toEqual(['deploy-copy', 'netlify-deploy', 'netlify-functions']) + }) + + test('cleans up staging and backup directories left by an interrupted install', async () => { + await writeSkill(skillsDir, FUNCTIONS.name, FUNCTIONS.files) + await writeSkill(skillsDir, '.netlify-skill-netlify-deploy-Ab12Cd', { 'SKILL.md': '# half written\n' }) + await writeSkill(skillsDir, 'netlify-functions.old-123-0123456789ab', FUNCTIONS_V1) + await writeSkill(skillsDir, 'netlify-deploy.old-123-0123456789ab', DEPLOY.files, DEPLOY.executable) + await writeSkill(skillsDir, '.netlify-skill-someone-else-Ab12Cd', { 'SKILL.md': '# not ours\n' }) + const anHourAgo = new Date(Date.now() - 60 * 60_000) + for (const name of await readdir(skillsDir)) { + await utimes(join(skillsDir, name), anHourAgo, anHourAgo) + } + + const { actions } = await syncSkills({ host: HOST, directory: skillsDir, manifest }) + + const leftover = { action: 'removed', detail: 'leftover from an interrupted install' } + expect(actions.slice(0, 2)).toEqual([ + { name: '.netlify-skill-netlify-deploy-Ab12Cd', ...leftover }, + { name: 'netlify-functions.old-123-0123456789ab', ...leftover }, + ]) + expect(actions).toContainEqual({ name: 'netlify-deploy', action: 'added', detail: '2.0.0' }) + expect((await readdir(skillsDir)).sort()).toEqual([ + '.netlify-skill-someone-else-Ab12Cd', + 'netlify-deploy', + 'netlify-deploy.old-123-0123456789ab', + 'netlify-functions', + ]) + + const second = await syncSkills({ host: HOST, directory: skillsDir, manifest }) + + expect(second.actions).toContainEqual({ name: 'netlify-deploy.old-123-0123456789ab', ...leftover }) + expect((await readdir(skillsDir)).sort()).toEqual([ + '.netlify-skill-someone-else-Ab12Cd', + 'netlify-deploy', + 'netlify-functions', + ]) + }) + + test('keeps a backup while its skill is missing, so a failed download leaves a copy behind', async () => { + await writeSkill(skillsDir, 'netlify-functions.old-123-0123456789ab', FUNCTIONS.files) + const anHourAgo = new Date(Date.now() - 60 * 60_000) + await utimes(join(skillsDir, 'netlify-functions.old-123-0123456789ab'), anHourAgo, anHourAgo) + responses.delete('/skills/netlify-functions/SKILL.md') + + const { actions } = await syncSkills({ host: HOST, directory: skillsDir, manifest }) + + expect(actions.find(({ name }) => name === 'netlify-functions')?.action).toBe('failed') + await expect(listDirectories(skillsDir)).resolves.toEqual([ + 'netlify-deploy', + 'netlify-functions.old-123-0123456789ab', + ]) + }) + + test('--reset-context leaves a directory at a skill name alone when it has no SKILL.md', async () => { + await writeSkill(skillsDir, FUNCTIONS.name, { 'notes.md': '# my notes, not a skill\n' }) + + const { actions } = await syncSkills({ host: HOST, directory: skillsDir, manifest, reset: true }) + + expect(actions).toContainEqual({ + name: 'netlify-functions', + action: 'kept', + detail: 'has no SKILL.md, so it is not a Netlify skill; left in place', + }) + await expect(readFile(join(skillsDir, 'netlify-functions', 'notes.md'), 'utf8')).resolves.toBe( + '# my notes, not a skill\n', + ) + await expect(readdir(join(skillsDir, 'netlify-functions'))).resolves.toEqual(['notes.md']) + }) + + test('renames a prior-name copy in place when the names differ only by case', async () => { + const caseOnly = buildManifest([FUNCTIONS, { ...DEPLOY, priorNames: ['Netlify-Deploy'] }]) + await writeSkill(skillsDir, 'Netlify-Deploy', DEPLOY.files, DEPLOY.executable) + await writeSkill(skillsDir, FUNCTIONS.name, FUNCTIONS.files) + + const { actions } = await syncSkills({ host: HOST, directory: skillsDir, manifest: caseOnly }) + + expect(actions).toContainEqual({ name: 'Netlify-Deploy', action: 'renamed', detail: '-> netlify-deploy' }) + await expect(listDirectories(skillsDir)).resolves.toEqual(['netlify-deploy', 'netlify-functions']) + + const second = await syncSkills({ host: HOST, directory: skillsDir, manifest: caseOnly }) + expect(second.actions.map(({ action }) => action)).toEqual(['current', 'current']) + }) + + test('leaves a fresh staging directory alone, since another run may still be writing it', async () => { + await writeSkill(skillsDir, FUNCTIONS.name, FUNCTIONS.files) + await writeSkill(skillsDir, DEPLOY.name, DEPLOY.files, DEPLOY.executable) + await writeSkill(skillsDir, '.netlify-skill-netlify-deploy-Ab12Cd', { 'SKILL.md': '# half written\n' }) + + const { actions } = await syncSkills({ host: HOST, directory: skillsDir, manifest }) + + expect(actions.map(({ action }) => action)).toEqual(['current', 'current']) + await expect(readdir(skillsDir)).resolves.toContain('.netlify-skill-netlify-deploy-Ab12Cd') + }) + + test('refuses to install when the assembled tree does not match the manifest tree_hash', async () => { + const broken = structuredClone(manifest) + const functions = broken.skills.find(({ name }) => name === 'netlify-functions') + if (functions) functions.tree_hash = 'sha256:not-what-the-files-hash-to' + + await expect(installSkill(HOST, skillsDir, functions ?? manifestSkill('netlify-functions'))).rejects.toThrow( + /staged tree hash .* does not match/, + ) + await expect(listDirectories(skillsDir)).resolves.toEqual([]) + }) + + test('--reset-context never migrates over a case twin that has no SKILL.md', async () => { + await writeSkill(skillsDir, 'Netlify-Deploy', { 'notes.md': '# keep me\n' }) + await writeSkill(skillsDir, 'netlify-cli-and-deploy', { 'SKILL.md': '# my deploy notes\n' }) + const caseInsensitive = await lstat(join(skillsDir, 'netlify-deploy')).then( + () => true, + () => false, + ) + + const { actions } = await syncSkills({ host: HOST, directory: skillsDir, manifest, reset: true }) + + await expect(readFile(join(skillsDir, 'Netlify-Deploy', 'notes.md'), 'utf8')).resolves.toBe('# keep me\n') + if (caseInsensitive) { + expect(actions).toContainEqual({ + name: 'netlify-deploy', + action: 'kept', + detail: 'Netlify-Deploy already uses this name; left in place', + }) + expect(actions).toContainEqual({ + name: 'netlify-cli-and-deploy', + action: 'kept', + detail: 'now called netlify-deploy, which could not be installed', + }) + await expect(listDirectories(skillsDir)).resolves.toEqual([ + 'Netlify-Deploy', + 'netlify-cli-and-deploy', + 'netlify-functions', + ]) + } else { + expect(actions).toContainEqual({ + name: 'netlify-cli-and-deploy', + action: 'renamed', + detail: '-> netlify-deploy', + }) + await expect(listDirectories(skillsDir)).resolves.toEqual([ + 'Netlify-Deploy', + 'netlify-deploy', + 'netlify-functions', + ]) + } + }) + + test('keeps a backup directory the user has edited', async () => { + await writeSkill(skillsDir, FUNCTIONS.name, FUNCTIONS.files) + await writeSkill(skillsDir, 'netlify-functions.old-123-0123456789ab', { 'SKILL.md': '# my recovery\n' }) + + const { actions } = await syncSkills({ host: HOST, directory: skillsDir, manifest }) + + expect(actions.map(({ action }) => action)).not.toContain('removed') + await expect(listDirectories(skillsDir)).resolves.toContain('netlify-functions.old-123-0123456789ab') + }) + test('reinstalls over an empty directory carrying a skill name', async () => { await mkdir(join(skillsDir, FUNCTIONS.name), { recursive: true }) @@ -381,10 +650,8 @@ describe('agent skills', () => { test('refuses to replace a directory that is not an unedited Netlify skill, even when asked directly', async () => { await writeSkill(skillsDir, FUNCTIONS.name, { 'SKILL.md': '# mine\n', 'notes.md': '# keep me\n' }) - const functions = manifest.skills.find(({ name }) => name === 'netlify-functions') - if (!functions) throw new Error('netlify-functions missing from manifest') - await expect(installSkill(HOST, skillsDir, functions)).rejects.toThrow(/already exists/) + await expect(installSkill(HOST, skillsDir, manifestSkill('netlify-functions'))).rejects.toThrow(/already exists/) await expect(readFile(join(skillsDir, 'netlify-functions', 'notes.md'), 'utf8')).resolves.toBe('# keep me\n') await expect(listDirectories(skillsDir)).resolves.toEqual(['netlify-functions']) }) @@ -396,8 +663,114 @@ describe('agent skills', () => { await expect(readFile(join(skillsDir, 'Netlify-Functions', 'notes.md'), 'utf8')).resolves.toBe('# keep me\n') expect(actions).toContainEqual({ name: 'netlify-deploy', action: 'added', detail: '2.0.0' }) - const functions = actions.find(({ name }) => name === 'netlify-functions') - expect(functions?.action === 'added' || functions?.detail?.includes('already exists')).toBe(true) + const functions = actions.filter(({ name }) => name === 'netlify-functions') + expect(functions).toHaveLength(1) + const caseInsensitive = (await listDirectories(skillsDir)).length === 2 + expect(functions[0]).toEqual( + caseInsensitive + ? { + name: 'netlify-functions', + action: 'kept', + detail: 'Netlify-Functions already uses this name; left in place', + } + : { name: 'netlify-functions', action: 'added', detail: '2.0.0' }, + ) + }) + + test('never renames away a user directory that differs only by case but holds release content', async () => { + await writeSkill(skillsDir, 'Netlify-Deploy', DEPLOY.files, DEPLOY.executable) + + const { actions } = await syncSkills({ host: HOST, directory: skillsDir, manifest }) + + expect(actions).toContainEqual({ name: 'Netlify-Deploy', action: 'ignored', detail: 'not a Netlify skill' }) + const directories = await listDirectories(skillsDir) + expect(directories).toContain('Netlify-Deploy') + if (!directories.includes('netlify-deploy')) { + expect(actions).toContainEqual({ + name: 'netlify-deploy', + action: 'kept', + detail: 'Netlify-Deploy already uses this name; left in place', + }) + } + }) + + test('migrates two prior names of one skill in a single run and is quiet afterwards', async () => { + const twice = buildManifest([FUNCTIONS, { ...DEPLOY, priorNames: ['netlify-cli-and-deploy', 'deploy-skill'] }]) + await writeSkill(skillsDir, 'netlify-cli-and-deploy', DEPLOY.files, DEPLOY.executable) + await writeSkill(skillsDir, 'deploy-skill', DEPLOY.files, DEPLOY.executable) + await writeSkill(skillsDir, FUNCTIONS.name, FUNCTIONS.files) + + const first = await syncSkills({ host: HOST, directory: skillsDir, manifest: twice }) + expect(first.actions).toEqual([ + { name: 'deploy-skill', action: 'renamed', detail: '-> netlify-deploy' }, + { name: 'netlify-cli-and-deploy', action: 'removed', detail: 'superseded by netlify-deploy' }, + { name: 'netlify-functions', action: 'current', detail: '2.0.0' }, + ]) + await expect(listDirectories(skillsDir)).resolves.toEqual(['netlify-deploy', 'netlify-functions']) + + const second = await syncSkills({ host: HOST, directory: skillsDir, manifest: twice }) + expect(second.actions.map(({ action }) => action)).toEqual(['current', 'current']) + }) + + test('deletes an unedited deprecated skill found under one of its prior names', async () => { + const legacy = { ...deprecatedSkill('netlify-legacy', RETIRED_FILES), prior_names: ['netlify-old'] } + const withLegacy = buildManifest(skills, [legacy]) + await writeSkill(skillsDir, 'netlify-old', RETIRED_FILES) + + const { actions } = await syncSkills({ host: HOST, directory: skillsDir, manifest: withLegacy }) + + expect(actions).toContainEqual({ name: 'netlify-old', action: 'removed', detail: 'deprecated' }) + await expect(listDirectories(skillsDir)).resolves.toEqual(['netlify-deploy', 'netlify-functions']) + }) + + test('leaves a directory without SKILL.md under a prior or deprecated name alone, even on --reset-context', async () => { + await writeSkill(skillsDir, 'netlify-cli-and-deploy', { 'notes.md': '# not a skill\n' }) + await writeSkill(skillsDir, 'netlify-legacy', { 'notes.md': '# not a skill either\n' }) + + const { actions } = await syncSkills({ host: HOST, directory: skillsDir, manifest, reset: true }) + + expect(actions.map(({ name }) => name)).not.toContain('netlify-cli-and-deploy') + expect(actions).toContainEqual({ name: 'netlify-deploy', action: 'added', detail: '2.0.0' }) + await expect(listDirectories(skillsDir)).resolves.toEqual([ + 'netlify-cli-and-deploy', + 'netlify-deploy', + 'netlify-functions', + 'netlify-legacy', + ]) + }) + + test('removes an unedited backup left under a prior name once the current name is installed', async () => { + await writeSkill(skillsDir, 'netlify-cli-and-deploy.old-123-0123456789ab', DEPLOY.files, DEPLOY.executable) + const anHourAgo = new Date(Date.now() - 60 * 60_000) + await utimes(join(skillsDir, 'netlify-cli-and-deploy.old-123-0123456789ab'), anHourAgo, anHourAgo) + + const first = await syncSkills({ host: HOST, directory: skillsDir, manifest }) + expect(first.actions).toContainEqual({ name: 'netlify-deploy', action: 'added', detail: '2.0.0' }) + await expect(listDirectories(skillsDir)).resolves.toContain('netlify-cli-and-deploy.old-123-0123456789ab') + + const second = await syncSkills({ host: HOST, directory: skillsDir, manifest }) + expect(second.actions).toContainEqual({ + name: 'netlify-cli-and-deploy.old-123-0123456789ab', + action: 'removed', + detail: 'leftover from an interrupted install', + }) + await expect(listDirectories(skillsDir)).resolves.toEqual(['netlify-deploy', 'netlify-functions']) + }) + + test('does not point at --reset-context when the only kept copies are not edited Netlify skills', async () => { + await writeSkill(skillsDir, 'Netlify-Functions', { 'SKILL.md': '# mine\n', 'notes.md': '# keep me\n' }) + + await setupAgentSkills({ workingDir: projectDir, env: { NETLIFY_SKILLS_HOST: HOST } }) + + expect(loggedLines().join('\n')).not.toContain('--reset-context') + }) + + test('--reset-context never forces over a directory that is not classified as a Netlify skill', async () => { + await writeSkill(skillsDir, 'Netlify-Functions', { 'SKILL.md': '# mine\n', 'notes.md': '# keep me\n' }) + + await syncSkills({ host: HOST, directory: skillsDir, manifest, reset: true }) + + await expect(readFile(join(skillsDir, 'Netlify-Functions', 'notes.md'), 'utf8')).resolves.toBe('# keep me\n') }) test('records a skill whose download fails and keeps installing the others', async () => { @@ -452,13 +825,50 @@ describe('agent skills', () => { test('rejects a file whose bytes do not match the manifest and leaves no partial install', async () => { responses.set('/skills/netlify-functions/SKILL.md', '# tampered\n') - const functions = manifest.skills.find(({ name }) => name === 'netlify-functions') - if (!functions) throw new Error('netlify-functions missing from manifest') - await expect(installSkill(HOST, skillsDir, functions)).rejects.toThrow(/hash mismatch/) + await expect(installSkill(HOST, skillsDir, manifestSkill('netlify-functions'))).rejects.toThrow(/hash mismatch/) await expect(stat(skillsDir)).rejects.toThrow(/ENOENT/) }) + test('a failed download records that skill and the rest of the sync still happens', async () => { + await writeSkill(skillsDir, 'netlify-legacy', RETIRED_FILES) + responses.delete('/skills/netlify-functions/SKILL.md') + + const { actions } = await syncSkills({ host: HOST, directory: skillsDir, manifest }) + + expect(actions.map(({ name, action }) => `${name}:${action}`)).toEqual([ + 'netlify-legacy:removed', + 'netlify-deploy:added', + 'netlify-functions:failed', + ]) + await expect(listDirectories(skillsDir)).resolves.toEqual(['netlify-deploy']) + }) + + test('re-hashes a deprecated copy right before deleting it, so an edit made mid-run is kept', async () => { + await writeSkill(skillsDir, FUNCTIONS.name, FUNCTIONS_V1) + await writeSkill(skillsDir, 'netlify-legacy', RETIRED_FILES) + const fetchHosted = vi.mocked(fetch).getMockImplementation() + if (!fetchHosted) throw new Error('fetch is not stubbed') + vi.mocked(fetch).mockImplementation(async (input, init) => { + if (requestPath(input) === '/skills/netlify-functions/SKILL.md') { + await writeFile(join(skillsDir, 'netlify-legacy', 'SKILL.md'), '# retired, edited during the sync\n') + } + return fetchHosted(input, init) + }) + + const { actions } = await syncSkills({ host: HOST, directory: skillsDir, manifest }) + + expect(actions).toContainEqual({ name: 'netlify-functions', action: 'updated', detail: '1.0.0 -> 2.0.0' }) + expect(actions).toContainEqual({ + name: 'netlify-legacy', + action: 'kept', + detail: 'deprecated; use netlify-deploy, but edited locally', + }) + await expect(readFile(join(skillsDir, 'netlify-legacy', 'SKILL.md'), 'utf8')).resolves.toBe( + '# retired, edited during the sync\n', + ) + }) + test('setupAgentSkills installs into the detected directories and reports a summary', async () => { await mkdir(join(projectDir, '.claude')) const result = await setupAgentSkills({ workingDir: projectDir, env: { NETLIFY_SKILLS_HOST: HOST } }) @@ -471,6 +881,36 @@ describe('agent skills', () => { 'netlify-deploy', 'netlify-functions', ]) + expect(loggedLines()).toEqual([expect.stringContaining('Installed Netlify skills')]) + }) + + test('setupAgentSkills summarizes a sync and points edited copies at --reset-context', async () => { + await writeSkill(skillsDir, FUNCTIONS.name, FUNCTIONS_V1) + await writeSkill(skillsDir, DEPLOY.name, { 'SKILL.md': '# my own notes\n' }) + await writeSkill(skillsDir, 'netlify-legacy', RETIRED_FILES) + + const result = await setupAgentSkills({ workingDir: projectDir, env: { NETLIFY_SKILLS_HOST: HOST } }) + + expect(result.summary).toMatchObject({ updated: 1, removed: 1, kept: 1 }) + const lines = loggedLines() + expect(lines[0]).toMatch(/Synced Netlify skills in .*\(1 updated, 1 removed, 1 kept\)\./) + expect(lines[1]).toContain('netlify-deploy') + expect(lines[1]).toContain('edited locally') + expect(lines[2]).toContain('--reset-context') + }) + + test('setupAgentSkills reports a failed skill next to what landed', async () => { + await writeSkill(skillsDir, 'netlify-legacy', RETIRED_FILES) + responses.delete('/skills/netlify-functions/SKILL.md') + + const result = await setupAgentSkills({ workingDir: projectDir, env: { NETLIFY_SKILLS_HOST: HOST } }) + + expect(result.installed).toBe(true) + expect(result.summary).toMatchObject({ added: 1, removed: 1, failed: 1 }) + const lines = loggedLines() + expect(lines[0]).toMatch(/Synced Netlify skills in .*\(1 added, 1 removed, 1 failed\)\./) + expect(lines[1]).toMatch(/netlify-functions.*SKILL\.md: .*HTTP 404/) + expect(lines.join('\n')).not.toContain('--reset-context') }) })