diff --git a/.changeset/forty-heads-fly.md b/.changeset/forty-heads-fly.md new file mode 100644 index 0000000..b8447e5 --- /dev/null +++ b/.changeset/forty-heads-fly.md @@ -0,0 +1,7 @@ +--- +'@tanstack/intent': patch +--- + +Fix `intent list` suppressing the allow-all risk warning. + +When `intent.skills` is set to allow-all (`"*"`), `intent list --no-notices` and `INTENT_NO_NOTICES=1` no longer hide the warning that all skill sources are permitted. This banner is a security-relevant signal, not a migration tip, so it's now excluded from suppression while other notices remain suppressible as before. diff --git a/eslint.config.js b/eslint.config.js index 8907d2f..0ebda54 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -49,7 +49,7 @@ const config = [ { name: 'intent/static-discovery', files: [ - 'packages/intent/src/scanner.ts', + 'packages/intent/src/discovery/scanner.ts', 'packages/intent/src/lockfile.ts', 'packages/intent/src/manifest.ts', 'packages/intent/src/mcp/**/*.ts', diff --git a/packages/intent/package.json b/packages/intent/package.json index d8bb60e..1582f65 100644 --- a/packages/intent/package.json +++ b/packages/intent/package.json @@ -43,6 +43,7 @@ "test:smoke": "pnpm run build && node dist/cli.mjs --help > /dev/null && node dist/cli.mjs load --help > /dev/null", "test:lib": "vitest run --exclude 'tests/integration/**'", "test:integration": "vitest run tests/integration/", - "test:types": "tsc --noEmit" + "test:types": "tsc --noEmit", + "test:eslint": "eslint ." } } diff --git a/packages/intent/src/commands/exclude.ts b/packages/intent/src/commands/exclude.ts index d525409..503d573 100644 --- a/packages/intent/src/commands/exclude.ts +++ b/packages/intent/src/commands/exclude.ts @@ -126,11 +126,11 @@ function printExcludes(excludes: Array, json?: boolean): void { } } -export async function runExcludeCommand( +export function runExcludeCommand( actionArg: string | undefined, patternArg: string | undefined, options: ExcludeCommandOptions, -): Promise { +): void { const action = normalizeAction(actionArg) const cwd = process.cwd() const pkg = readPackageJson(cwd) diff --git a/packages/intent/src/commands/list.ts b/packages/intent/src/commands/list.ts index 3f32eb7..0fc45c5 100644 --- a/packages/intent/src/commands/list.ts +++ b/packages/intent/src/commands/list.ts @@ -1,3 +1,6 @@ +import { detectIntentAudience } from '../shared/environment.js' +import { formatIntentCommand } from '../shared/command-runner.js' +import { listIntentSkills } from '../core/index.js' import { coreOptionsFromGlobalFlags, noticeOptionsFromGlobalFlags, @@ -5,9 +8,6 @@ import { printNotices, printWarnings, } from './support.js' -import { detectIntentAudience } from '../shared/environment.js' -import { formatIntentCommand } from '../shared/command-runner.js' -import { listIntentSkills } from '../core/index.js' import type { GlobalScanFlags } from './support.js' import type { IntentPackageSummary, diff --git a/packages/intent/src/commands/load.ts b/packages/intent/src/commands/load.ts index 9cf5241..a016041 100644 --- a/packages/intent/src/commands/load.ts +++ b/packages/intent/src/commands/load.ts @@ -32,10 +32,10 @@ function printLoadDebug(loaded: LoadedIntentSkill | ResolvedIntentSkill): void { ]) } -export async function runLoadCommand( +export function runLoadCommand( use: string | undefined, options: LoadCommandOptions, -): Promise { +): void { if (!use) { fail('Missing skill use. Expected: intent load #') } diff --git a/packages/intent/src/core/source-policy.ts b/packages/intent/src/core/source-policy.ts index 5cd24ad..2c1bf6a 100644 --- a/packages/intent/src/core/source-policy.ts +++ b/packages/intent/src/core/source-policy.ts @@ -1,5 +1,6 @@ import { scanForIntents } from '../discovery/scanner.js' import { detectIntentAudience } from '../shared/environment.js' +import { ALLOW_ALL_NOTICE } from '../shared/cli-output.js' import { compileExcludePatterns, getConfigDirs, @@ -22,8 +23,7 @@ import type { IntentHiddenSourceSummary, } from './types.js' -export const ALLOW_ALL_NOTICE = - 'All skill sources allowed (intent.skills: ["*"]) — unvetted skills may be surfaced into agent guidance.' +export { ALLOW_ALL_NOTICE } export const MIGRATION_NOTICE = 'intent.skills is not set — all discovered skill sources are surfaced. A future version will require an explicit intent.skills allowlist; add one to opt in to specific sources.' diff --git a/packages/intent/src/hooks/install.ts b/packages/intent/src/hooks/install.ts index fad29e0..2e8f6a5 100644 --- a/packages/intent/src/hooks/install.ts +++ b/packages/intent/src/hooks/install.ts @@ -548,7 +548,7 @@ function isIntentHook(value: unknown): boolean { } function isIntentGateScriptReference(value: string): boolean { - return /(?:^|[\s"'\/])(?:old-)?intent-(claude|codex|copilot)-gate\.mjs(?:$|[?#\s"'])/i.test( + return /(?:^|[\s"'/])(?:old-)?intent-(claude|codex|copilot)-gate\.mjs(?:$|[?#\s"'])/i.test( value, ) } diff --git a/packages/intent/src/shared/cli-output.ts b/packages/intent/src/shared/cli-output.ts index 58c97b2..f281d88 100644 --- a/packages/intent/src/shared/cli-output.ts +++ b/packages/intent/src/shared/cli-output.ts @@ -1,3 +1,8 @@ +// Lives here (not core/source-policy.ts) so printNotices can enforce +// non-suppressibility by identity without core importing this module. +export const ALLOW_ALL_NOTICE = + 'All skill sources allowed (intent.skills: ["*"]) — unvetted skills may be surfaced into agent guidance.' + export function printWarnings(warnings: Array): void { if (warnings.length === 0) return @@ -27,10 +32,17 @@ export function printNotices( options: NoticeOutputOptions = {}, ): void { if (notices.length === 0) return - if (shouldSuppressNotices(options)) return + + // ALLOW_ALL_NOTICE stays visible even when suppressed: agent hooks read + // warnings/conflicts but never notices, so keeping it here (rather than in + // warnings) also keeps it out of agent-injected context automatically. + const visible = shouldSuppressNotices(options) + ? notices.filter((notice) => notice === ALLOW_ALL_NOTICE) + : notices + if (visible.length === 0) return console.error('Notices:') - for (const notice of notices) { + for (const notice of visible) { console.error(` ℹ ${notice}`) } } diff --git a/packages/intent/src/staleness/check.ts b/packages/intent/src/staleness/check.ts index 9f9bcd7..ec3191b 100644 --- a/packages/intent/src/staleness/check.ts +++ b/packages/intent/src/staleness/check.ts @@ -1,13 +1,13 @@ import { existsSync, readFileSync } from 'node:fs' import { isAbsolute, join, relative, resolve } from 'node:path' import semver from 'semver' -import { readIntentArtifacts } from './artifact-coverage.js' import { findSkillFiles, parseFrontmatter, readScalarField, toPosixPath, } from '../shared/utils.js' +import { readIntentArtifacts } from './artifact-coverage.js' import type { IntentArtifactSet, IntentArtifactSkill, diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index c81b62b..f940d1b 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -1108,6 +1108,38 @@ describe('cli commands', () => { expect(stderr).not.toContain('Notices:') }) + it('does not suppress the allow-all risk banner under --no-notices', async () => { + const root = mkdtempSync( + join(realTmpdir, 'intent-cli-list-allow-all-no-notices-'), + ) + const isolatedGlobalRoot = mkdtempSync( + join(realTmpdir, 'intent-cli-list-allow-all-no-notices-empty-global-'), + ) + tempDirs.push(root, isolatedGlobalRoot) + writeJson(join(root, 'package.json'), { + name: 'consumer', + intent: { skills: ['*'] }, + }) + writeInstalledIntentPackage(root, { + name: '@tanstack/query', + version: '5.0.0', + skillName: 'fetching', + description: 'Query data fetching patterns', + }) + + process.env.INTENT_GLOBAL_NODE_MODULES = isolatedGlobalRoot + process.chdir(root) + + const exitCode = await main(['list', '--no-notices']) + const stdout = logSpy.mock.calls.flat().join('\n') + const stderr = errorSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(stdout).toContain('@tanstack/query') + expect(stderr).toContain('Notices:') + expect(stderr).toContain('All skill sources allowed') + }) + it('suppresses notices when INTENT_NO_NOTICES=1 is set', async () => { const root = mkdtempSync( join(realTmpdir, 'intent-cli-list-env-no-notices-'), @@ -2728,7 +2760,7 @@ describe('cli commands', () => { const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, - json: async () => ({ version: '1.0.0' }), + json: () => Promise.resolve({ version: '1.0.0' }), } as Response) process.chdir(root) @@ -2781,7 +2813,7 @@ describe('cli commands', () => { const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, - json: async () => ({ version: '1.0.0' }), + json: () => Promise.resolve({ version: '1.0.0' }), } as Response) process.chdir(root) @@ -2849,7 +2881,7 @@ describe('cli commands', () => { const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, - json: async () => ({ version: '1.0.0' }), + json: () => Promise.resolve({ version: '1.0.0' }), } as Response) process.chdir(root) @@ -2916,7 +2948,7 @@ describe('cli commands', () => { const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, - json: async () => ({ version: '1.0.0' }), + json: () => Promise.resolve({ version: '1.0.0' }), } as Response) process.chdir(root) @@ -2986,7 +3018,7 @@ describe('cli commands', () => { const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, - json: async () => ({ version: '1.0.0' }), + json: () => Promise.resolve({ version: '1.0.0' }), } as Response) process.chdir(root) @@ -3044,7 +3076,7 @@ describe('cli commands', () => { const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, - json: async () => ({ version: '1.0.0' }), + json: () => Promise.resolve({ version: '1.0.0' }), } as Response) process.chdir(root) @@ -3133,7 +3165,7 @@ describe('cli commands', () => { const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, - json: async () => ({ version: '1.0.0' }), + json: () => Promise.resolve({ version: '1.0.0' }), } as Response) process.env.INTENT_GLOBAL_NODE_MODULES = globalRoot @@ -3177,7 +3209,7 @@ describe('cli commands', () => { const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, - json: async () => ({ version: '1.0.0' }), + json: () => Promise.resolve({ version: '1.0.0' }), } as Response) process.chdir(root) @@ -3223,7 +3255,7 @@ describe('cli commands', () => { const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, - json: async () => ({ version: '1.0.0' }), + json: () => Promise.resolve({ version: '1.0.0' }), } as Response) process.chdir(root) @@ -3267,7 +3299,7 @@ describe('cli commands', () => { const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, - json: async () => ({ version: '1.0.0' }), + json: () => Promise.resolve({ version: '1.0.0' }), } as Response) process.chdir(join(root, 'packages', 'router')) @@ -3300,7 +3332,7 @@ describe('cli commands', () => { const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, - json: async () => ({ version: '1.0.0' }), + json: () => Promise.resolve({ version: '1.0.0' }), } as Response) const elsewhere = mkdtempSync(join(realTmpdir, 'intent-cli-stale-abs-cwd-')) diff --git a/packages/intent/tests/hooks-install.test.ts b/packages/intent/tests/hooks-install.test.ts index 8048aa0..82e8985 100644 --- a/packages/intent/tests/hooks-install.test.ts +++ b/packages/intent/tests/hooks-install.test.ts @@ -379,7 +379,7 @@ describe('hook installer', () => { }) expect(result.status).toBe(0) - const output = JSON.parse(result.stdout) as any + const output = JSON.parse(result.stdout) const context = agent === 'copilot' ? output.additionalContext diff --git a/packages/intent/tests/integration/source-policy-surfaces.test.ts b/packages/intent/tests/integration/source-policy-surfaces.test.ts index 1bedd01..6241736 100644 --- a/packages/intent/tests/integration/source-policy-surfaces.test.ts +++ b/packages/intent/tests/integration/source-policy-surfaces.test.ts @@ -136,7 +136,7 @@ describe('source policy — all four surfaces filter excluded and unlisted', () const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, - json: async () => ({ version: '1.0.0' }), + json: () => Promise.resolve({ version: '1.0.0' }), } as Response) process.chdir(root) diff --git a/packages/intent/tests/stale-command.test.ts b/packages/intent/tests/stale-command.test.ts index 29653ba..451bd4e 100644 --- a/packages/intent/tests/stale-command.test.ts +++ b/packages/intent/tests/stale-command.test.ts @@ -26,26 +26,28 @@ describe('runStaleCommand', () => { }) it('prints review signals in non-json output', async () => { - await runStaleCommand(undefined, {}, async () => ({ - reports: [ - { - library: '@tanstack/router', - currentVersion: '1.0.0', - skillVersion: '1.0.0', - versionDrift: null, - skills: [], - signals: [ - { - type: 'missing-package-coverage', - library: '@tanstack/router', - packageName: '@tanstack/react-start-rsc', - reasons: ['package is not represented in skills or artifacts'], - needsReview: true, - }, - ], - }, - ], - })) + await runStaleCommand(undefined, {}, () => + Promise.resolve({ + reports: [ + { + library: '@tanstack/router', + currentVersion: '1.0.0', + skillVersion: '1.0.0', + versionDrift: null, + skills: [], + signals: [ + { + type: 'missing-package-coverage', + library: '@tanstack/router', + packageName: '@tanstack/react-start-rsc', + reasons: ['package is not represented in skills or artifacts'], + needsReview: true, + }, + ], + }, + ], + }), + ) const output = logSpy.mock.calls.map((call) => String(call[0])).join('\n') expect(output).toContain('@tanstack/router') @@ -55,12 +57,14 @@ describe('runStaleCommand', () => { }) it('prints workflow update advisories in non-json output', async () => { - await runStaleCommand(undefined, {}, async () => ({ - reports: [], - workflowAdvisories: [ - 'Intent workflow update available: run `npx @tanstack/intent@latest setup`.', - ], - })) + await runStaleCommand(undefined, {}, () => + Promise.resolve({ + reports: [], + workflowAdvisories: [ + 'Intent workflow update available: run `npx @tanstack/intent@latest setup`.', + ], + }), + ) const output = logSpy.mock.calls.map((call) => String(call[0])).join('\n') expect(output).toContain('Intent workflow update available') @@ -69,12 +73,14 @@ describe('runStaleCommand', () => { }) it('does not print workflow update advisories in json output', async () => { - await runStaleCommand(undefined, { json: true }, async () => ({ - reports: [], - workflowAdvisories: [ - 'Intent workflow update available: run `npx @tanstack/intent@latest setup`.', - ], - })) + await runStaleCommand(undefined, { json: true }, () => + Promise.resolve({ + reports: [], + workflowAdvisories: [ + 'Intent workflow update available: run `npx @tanstack/intent@latest setup`.', + ], + }), + ) const output = logSpy.mock.calls.map((call) => String(call[0])).join('\n') expect(output).toBe('[]') @@ -94,29 +100,30 @@ describe('runStaleCommand', () => { await runStaleCommand( undefined, { githubReview: true, packageLabel: '@tanstack/router' }, - async () => ({ - reports: [ - { - library: '@tanstack/router', - currentVersion: null, - skillVersion: null, - versionDrift: null, - skills: [], - signals: [ - { - type: 'missing-package-coverage', - library: '@tanstack/react-start-rsc', - packageName: '@tanstack/react-start-rsc', - reasons: ['workspace package is not represented'], - needsReview: true, - }, - ], - }, - ], - workflowAdvisories: [ - 'Intent workflow update available: run `npx @tanstack/intent@latest setup`.', - ], - }), + () => + Promise.resolve({ + reports: [ + { + library: '@tanstack/router', + currentVersion: null, + skillVersion: null, + versionDrift: null, + skills: [], + signals: [ + { + type: 'missing-package-coverage', + library: '@tanstack/react-start-rsc', + packageName: '@tanstack/react-start-rsc', + reasons: ['workspace package is not represented'], + needsReview: true, + }, + ], + }, + ], + workflowAdvisories: [ + 'Intent workflow update available: run `npx @tanstack/intent@latest setup`.', + ], + }), ) } finally { process.chdir(originalCwd) @@ -163,9 +170,7 @@ describe('runStaleCommand', () => { await runStaleCommand( undefined, { githubReview: true, packageLabel: '@tanstack/router' }, - async () => { - throw new Error('boom') - }, + () => Promise.reject(new Error('boom')), ) } finally { process.chdir(originalCwd)