diff --git a/.changeset/rule-on-every-publish.md b/.changeset/rule-on-every-publish.md new file mode 100644 index 0000000..8503b38 --- /dev/null +++ b/.changeset/rule-on-every-publish.md @@ -0,0 +1,12 @@ +--- +'@memnox/core': patch +'@memnox/proxy': patch +'@memnox/interceptors': patch +'memnox': patch +--- + +Publishing is ruled on whichever client ran it. `npm publish` resolved to `npm.publish`, and `pnpm publish`, `yarn npm publish` and `bun publish` were ordinary shell lines, so a rule naming `npm.publish` covered the one spelling most projects do not use. All four now reach the same action, along with `unpublish` and `dist-tag`, and the reason still names the client that ran it. Only the publishing verbs route that way: `pnpm add` is its own install and stays one. + +`cargo`, `helm` and `pulumi` have tables of their own. Destructive: `cargo yank`, `helm uninstall`, `helm delete`, `pulumi destroy`. Write: `cargo publish` and `cargo install`, `helm install`, `helm upgrade` and `helm rollback`, `pulumi up` and `pulumi config set`. Read: `cargo search`, `helm list`, `helm status`, `helm get`, `pulumi preview` and `pulumi stack ls`. + +`bun`, `uv`, `pipx`, `gem` and `brew` are recognised as installing code that then runs on the machine, which only npm, pnpm, yarn and pip were. diff --git a/packages/core/src/intercept/binary-class.ts b/packages/core/src/intercept/binary-class.ts index 1af4a17..8b6a1ad 100644 --- a/packages/core/src/intercept/binary-class.ts +++ b/packages/core/src/intercept/binary-class.ts @@ -227,8 +227,13 @@ const PACKAGE_VALUE_FLAGS: Readonly>> = { /** Managers whose bare invocation installs from the lockfile rather than printing help. */ const BARE_INSTALLS = new Set(['yarn', 'pnpm']); +/** `uv pip install x` puts the install one word further along than every other manager. */ +const PIP_FRONTED = new Set(['uv']); + function classifyPackageManager(binary: string, args: readonly string[]): BinaryVerdict { - const [verb, target] = positionalArgs(args, PACKAGE_VALUE_FLAGS[binary] ?? new Set()); + const words = positionalArgs(args, PACKAGE_VALUE_FLAGS[binary] ?? new Set()); + const [verb, target] = + PIP_FRONTED.has(binary) && words[0] === 'pip' ? words.slice(1) : words; const installing = verb === undefined ? BARE_INSTALLS.has(binary) : PACKAGE_INSTALL_VERBS.includes(verb); return { @@ -436,6 +441,11 @@ const CLASSIFIERS: Readonly> = { yarn: classifyPackageManager, pip: classifyPackageManager, pip3: classifyPackageManager, + bun: classifyPackageManager, + uv: classifyPackageManager, + pipx: classifyPackageManager, + gem: classifyPackageManager, + brew: classifyPackageManager, }; export function interceptedBinaries(): readonly string[] { diff --git a/packages/core/src/intercept/npm-workalike.ts b/packages/core/src/intercept/npm-workalike.ts new file mode 100644 index 0000000..56962cd --- /dev/null +++ b/packages/core/src/intercept/npm-workalike.ts @@ -0,0 +1,26 @@ +/** + * pnpm, yarn and bun push to the registry npm pushes to, so a rule naming `npm.publish` + * has to cover the publish whichever of them ran it. Only the publishing verbs: an + * install is each client's own and belongs with the package-manager classifier. + */ + +/** The publishing verbs all four clients spell the way npm does. */ +const PUBLISH_VERBS: readonly string[] = ['publish', 'unpublish', 'dist-tag']; + +const WORKALIKES: readonly string[] = ['pnpm', 'yarn', 'bun']; + +interface NpmPublish { + /** The command as npm's own table reads it. */ + words: readonly string[]; + /** The client that actually ran it, so a row names what a person typed. */ + because: string; +} + +/** Null when this is not one of npm's workalikes publishing. */ +export function npmPublishIn(binary: string, args: readonly string[]): NpmPublish | null { + if (!WORKALIKES.includes(binary)) return null; + // `yarn npm publish` is yarn's own spelling of the same command. + const words = binary === 'yarn' && args[0] === 'npm' ? args.slice(1) : args; + if (!PUBLISH_VERBS.includes(words[0] ?? '')) return null; + return { words, because: `${binary} ${words.join(' ')}`.trim() }; +} diff --git a/packages/core/src/intercept/resolve.ts b/packages/core/src/intercept/resolve.ts index 84b9ccd..7820a65 100644 --- a/packages/core/src/intercept/resolve.ts +++ b/packages/core/src/intercept/resolve.ts @@ -2,6 +2,7 @@ * One command line, one action name, for every surface with an opinion about a command, * because two resolvers would mean a rule written from one screen failing at another. */ +import { npmPublishIn } from './npm-workalike'; import { classifyBinary, classifyReader, COMMAND_CLASS } from './binary-class'; import { classifyWriter } from './writers'; import { loosens, MEMNOX_ACTION_PREFIX } from '../gate/self-protection'; @@ -75,6 +76,7 @@ export function resolveAction( return ( resolveMemnox(binary, args) ?? resolveSqlStatement(binary, args, env, options.stdin) ?? + resolveNpmWorkalike(binary, args, env) ?? resolveFromVerbTable(binary, args, env) ?? resolveReader(binary, args, env) ?? resolveWriter(binary, args, env) ?? @@ -132,6 +134,18 @@ function resolveSqlStatement( }; } +/** npm's workalikes publish to the same registry, so one rule covers all four. */ +function resolveNpmWorkalike( + binary: string, + args: readonly string[], + env: NodeJS.ProcessEnv, +): ResolvedAction | null { + const publishing = npmPublishIn(binary, args); + if (publishing === null) return null; + const ruled = resolveFromVerbTable('npm', publishing.words, env); + return ruled === null ? null : { ...ruled, because: publishing.because }; +} + function resolveFromVerbTable( binary: string, args: readonly string[], diff --git a/packages/core/src/verbs/tables.ts b/packages/core/src/verbs/tables.ts index d945c6c..60486cd 100644 --- a/packages/core/src/verbs/tables.ts +++ b/packages/core/src/verbs/tables.ts @@ -27,6 +27,9 @@ const ORDER = [ 'mysql', 'mongosh', 'npm', + 'cargo', + 'helm', + 'pulumi', 'stripe', 'git', 'playwright', diff --git a/packages/core/src/verbs/tables/code.ts b/packages/core/src/verbs/tables/code.ts index 0aa86f7..19711dd 100644 --- a/packages/core/src/verbs/tables/code.ts +++ b/packages/core/src/verbs/tables/code.ts @@ -252,6 +252,27 @@ export const CODE_TABLES: readonly VerbTable[] = [ { match: 'help **', class: READ }, ], }, + { + name: 'cargo', + credential: ['~/.cargo/credentials.toml', 'CARGO_REGISTRY_TOKEN'], + headline: 'can publish crates', + verbs: [ + { match: 'yank **', class: GONE, note: 'nobody can depend on that version again' }, + { + match: 'publish **', + class: WRITE, + tags: [PROD], + note: 'everyone can install it', + }, + { match: 'owner **', class: WRITE }, + { + match: 'install **', + class: WRITE, + note: 'builds and runs code from the registry', + }, + { match: 'search **', class: READ }, + ], + }, { name: 'npm', credential: ['~/.npmrc', 'NPM_TOKEN'], diff --git a/packages/core/src/verbs/tables/deploy.ts b/packages/core/src/verbs/tables/deploy.ts index a8783c2..5de1d3b 100644 --- a/packages/core/src/verbs/tables/deploy.ts +++ b/packages/core/src/verbs/tables/deploy.ts @@ -9,6 +9,35 @@ const PROD = VERB_TAG.PRODUCTION; const SECRETS = VERB_TAG.SECRETS; export const DEPLOY_TABLES: readonly VerbTable[] = [ + { + name: 'helm', + credential: ['~/.kube/config', 'KUBECONFIG'], + headline: 'can change what runs in a cluster', + globalFlags: ['--kube-context', '-n', '--namespace', '--kubeconfig'], + verbs: [ + { match: 'uninstall **', class: GONE, note: 'the release and its resources go' }, + { match: 'delete **', class: GONE }, + { match: 'rollback **', class: WRITE }, + { match: 'upgrade **', class: WRITE, tags: [PROD] }, + { match: 'install **', class: WRITE, tags: [PROD] }, + { match: 'list **', class: READ }, + { match: 'status **', class: READ }, + { match: 'get **', class: READ }, + ], + }, + { + name: 'pulumi', + credential: ['~/.pulumi/credentials.json', 'PULUMI_ACCESS_TOKEN'], + headline: 'can change infrastructure', + globalFlags: ['-s', '--stack', '--cwd'], + verbs: [ + { match: 'destroy **', class: GONE, note: 'every resource in the stack goes' }, + { match: 'up **', class: WRITE, tags: [PROD] }, + { match: 'config set **', class: WRITE, tags: [SECRETS] }, + { match: 'preview **', class: READ }, + { match: 'stack ls **', class: READ }, + ], + }, { name: 'vercel', credential: ['~/.vercel/auth.json', 'VERCEL_TOKEN'], diff --git a/packages/core/test/resolve.test.ts b/packages/core/test/resolve.test.ts index dcb0ce2..de4db92 100644 --- a/packages/core/test/resolve.test.ts +++ b/packages/core/test/resolve.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from 'vitest'; +import { TOOL_CLASS } from '../src/discovery/classify'; +import { COMMAND_CLASS } from '../src/intercept/binary-class'; import { resolveAction, resolveShellLine } from '../src/intercept/resolve'; import { takesLease } from '../src/coordination/writes'; @@ -163,3 +165,64 @@ describe('a line whose only dollar is quoted code', () => { expect(actions.filter((each) => each.class === 'write')).toEqual([]); }); }); + +/* Somebody who writes a rule on `npm.publish` reasonably believes publishing is covered. + Every spelling but npm's own reached the registry as an ordinary shell line. */ +describe('publishing, however it was run', () => { + const ruled = (words: string[]) => { + const [binary, ...rest] = words; + return resolveAction(binary ?? '', rest); + }; + + it.each([[['pnpm', 'publish']], [['yarn', 'npm', 'publish']], [['bun', 'publish']]])( + '%j resolves to the action npm publish does', + (words) => { + expect(ruled(words as string[]).action).toBe(ruled(['npm', 'publish']).action); + }, + ); + + it('names the client that ran it, even though the action is npm.publish', () => { + expect(ruled(['pnpm', 'publish']).because).toContain('pnpm'); + }); + + it.each([ + [['pnpm', 'unpublish', 'pkg'], 'npm.unpublish', TOOL_CLASS.DESTRUCTIVE], + [ + ['pnpm', 'dist-tag', 'add', 'pkg@1', 'latest'], + 'npm.dist-tag-add', + TOOL_CLASS.WRITE, + ], + [['cargo', 'yank', '--version', '1.0.0'], 'cargo.yank', TOOL_CLASS.DESTRUCTIVE], + [['cargo', 'publish'], 'cargo.publish', TOOL_CLASS.WRITE], + [['cargo', 'install', 'ripgrep'], 'cargo.install', TOOL_CLASS.WRITE], + [['cargo', 'search', 'serde'], 'cargo.search', TOOL_CLASS.READ], + [['helm', 'uninstall', 'api'], 'helm.uninstall', TOOL_CLASS.DESTRUCTIVE], + [['helm', 'upgrade', 'api', './chart'], 'helm.upgrade', TOOL_CLASS.WRITE], + [['helm', 'list'], 'helm.list', TOOL_CLASS.READ], + [['pulumi', 'destroy'], 'pulumi.destroy', TOOL_CLASS.DESTRUCTIVE], + [['pulumi', 'up'], 'pulumi.up', TOOL_CLASS.WRITE], + [['pulumi', 'preview'], 'pulumi.preview', TOOL_CLASS.READ], + ])('%j is %s', (words, action, expected) => { + const resolved = ruled(words as string[]); + expect(resolved.action).toBe(action); + expect(resolved.class).toBe(expected); + }); + + it.each([ + [['bun', 'install']], + [['uv', 'pip', 'install', 'ruff']], + [['pipx', 'install', 'black']], + [['gem', 'install', 'rails']], + [['brew', 'install', 'jq']], + ])('%j installs code that then runs here', (words) => { + expect(ruled(words as string[]).class).toBe(COMMAND_CLASS.PACKAGE_INSTALL); + }); + + /* Only the publishing verbs route onto npm's table: an install is its own thing. */ + it.each([[['pnpm', 'add', 'x']], [['pnpm', 'install']], [['yarn', 'add', 'x']]])( + '%j is still a package install, not an npm table verb', + (words) => { + expect(ruled(words as string[]).class).toBe(COMMAND_CLASS.PACKAGE_INSTALL); + }, + ); +});