diff --git a/lib/commands/trust/circleci.js b/lib/commands/trust/circleci.js new file mode 100644 index 0000000000000..896d94e92e7a3 --- /dev/null +++ b/lib/commands/trust/circleci.js @@ -0,0 +1,155 @@ +const Definition = require('@npmcli/config/lib/definitions/definition.js') +const globalDefinitions = require('@npmcli/config/lib/definitions/definitions.js') +const TrustCommand = require('../../trust-cmd.js') + +// UUID validation regex +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +class TrustCircleCI extends TrustCommand { + static description = 'Create a trusted relationship between a package and CircleCI' + static name = 'circleci' + static positionals = 1 // expects at most 1 positional (package name) + static providerName = 'CircleCI' + static providerEntity = 'CircleCI pipeline' + + static usage = [ + '[package] --org-id --pipeline-definition-id --vcs-origin [--context-id ] [-y|--yes]', + ] + + static definitions = { + yes: globalDefinitions.yes, + json: globalDefinitions.json, + 'dry-run': globalDefinitions['dry-run'], + 'org-id': new Definition('org-id', { + default: null, + type: String, + description: 'CircleCI organization UUID', + }), + 'pipeline-definition-id': new Definition('pipeline-definition-id', { + default: null, + type: String, + description: 'CircleCI pipeline definition UUID', + }), + 'vcs-origin': new Definition('vcs-origin', { + default: null, + type: String, + description: "CircleCI repository origin in format 'provider/owner/repo'", + }), + 'context-id': new Definition('context-id', { + default: null, + type: String, + description: 'CircleCI context UUID', + }), + } + + validateUuid (value, fieldName) { + if (!UUID_REGEX.test(value)) { + throw new Error(`${fieldName} must be a valid UUID`) + } + } + + validateVcsOrigin (value) { + // Expected format: provider/owner/repo (e.g., github.com/owner/repo, bitbucket.org/owner/repo) + const parts = value.split('/') + if (parts.length < 3) { + throw new Error("vcs-origin must be in format 'provider/owner/repo'") + } + } + + // Generate a URL from vcs-origin (e.g., github.com/npm/repo -> https://github.com/npm/repo) + getVcsOriginUrl (vcsOrigin) { + if (!vcsOrigin) { + return null + } + // vcs-origin format: github.com/owner/repo or bitbucket.org/owner/repo + return `https://${vcsOrigin}` + } + + static optionsToBody (options) { + const { orgId, pipelineDefinitionId, vcsOrigin, contextId } = options + const trustConfig = { + type: 'circleci', + claims: { + org_id: orgId, + pipeline_definition_id: pipelineDefinitionId, + vcs_origin: vcsOrigin, + }, + } + if (contextId) { + trustConfig.claims.context_id = contextId + } + return trustConfig + } + + static bodyToOptions (body) { + return { + ...(body.id) && { id: body.id }, + ...(body.type) && { type: body.type }, + ...(body.claims?.org_id) && { orgId: body.claims.org_id }, + ...(body.claims?.pipeline_definition_id) && { + pipelineDefinitionId: body.claims.pipeline_definition_id, + }, + ...(body.claims?.vcs_origin) && { vcsOrigin: body.claims.vcs_origin }, + ...(body.claims?.context_id) && { contextId: body.claims.context_id }, + } + } + + // Override flagsToOptions since CircleCI doesn't use file/entity pattern + async flagsToOptions ({ positionalArgs, flags }) { + const content = await this.optionalPkgJson() + const pkgName = positionalArgs[0] || content.name + + if (!pkgName) { + throw new Error('Package name must be specified either as an argument or in package.json file') + } + + const orgId = flags['org-id'] + const pipelineDefinitionId = flags['pipeline-definition-id'] + const vcsOrigin = flags['vcs-origin'] + const contextId = flags['context-id'] + + // Validate required flags + if (!orgId) { + throw new Error('org-id is required') + } + if (!pipelineDefinitionId) { + throw new Error('pipeline-definition-id is required') + } + if (!vcsOrigin) { + throw new Error('vcs-origin is required') + } + + // Validate formats + this.validateUuid(orgId, 'org-id') + this.validateUuid(pipelineDefinitionId, 'pipeline-definition-id') + this.validateVcsOrigin(vcsOrigin) + if (contextId) { + this.validateUuid(contextId, 'context-id') + } + + return { + values: { + package: pkgName, + orgId, + pipelineDefinitionId, + vcsOrigin, + ...(contextId && { contextId }), + }, + fromPackageJson: {}, + warnings: [], + urls: { + package: this.getFrontendUrl({ pkgName }), + vcsOrigin: this.getVcsOriginUrl(vcsOrigin), + }, + } + } + + async exec (positionalArgs, flags) { + await this.createConfigCommand({ + positionalArgs, + flags, + }) + } +} + +module.exports = TrustCircleCI diff --git a/lib/commands/trust/index.js b/lib/commands/trust/index.js index cabcfa7c34cb8..9c3bf070a4ce1 100644 --- a/lib/commands/trust/index.js +++ b/lib/commands/trust/index.js @@ -7,6 +7,7 @@ class Trust extends BaseCommand { static subcommands = { github: require('./github.js'), gitlab: require('./gitlab.js'), + circleci: require('./circleci.js'), list: require('./list.js'), revoke: require('./revoke.js'), } diff --git a/tap-snapshots/test/lib/commands/completion.js.test.cjs b/tap-snapshots/test/lib/commands/completion.js.test.cjs index 50b21f56f3642..9a92e75fc5185 100644 --- a/tap-snapshots/test/lib/commands/completion.js.test.cjs +++ b/tap-snapshots/test/lib/commands/completion.js.test.cjs @@ -137,6 +137,7 @@ Array [ String( github gitlab + circleci list revoke ), diff --git a/tap-snapshots/test/lib/docs.js.test.cjs b/tap-snapshots/test/lib/docs.js.test.cjs index 702a50a07a532..0b19c65cfe02a 100644 --- a/tap-snapshots/test/lib/docs.js.test.cjs +++ b/tap-snapshots/test/lib/docs.js.test.cjs @@ -5710,6 +5710,9 @@ Subcommands: gitlab Create a trusted relationship between a package and GitLab CI/CD + circleci + Create a trusted relationship between a package and CircleCI + list List trusted relationships for a package @@ -5743,6 +5746,15 @@ Note: This command is unaware of workspaces. #### \`json\` #### \`dry-run\` #### Synopsis +#### Flags +#### \`org-id\` +#### \`pipeline-definition-id\` +#### \`vcs-origin\` +#### \`context-id\` +#### \`yes\` +#### \`json\` +#### \`dry-run\` +#### Synopsis #### Configuration #### \`json\` #### Synopsis diff --git a/test/lib/commands/trust/circleci.js b/test/lib/commands/trust/circleci.js new file mode 100644 index 0000000000000..3ce0dfe2dd2d1 --- /dev/null +++ b/test/lib/commands/trust/circleci.js @@ -0,0 +1,343 @@ +const t = require('tap') +const { load: loadMockNpm } = require('../../../fixtures/mock-npm.js') +const MockRegistry = require('@npmcli/mock-registry') +const realProcLog = require('proc-log') + +const packageName = '@npmcli/test-package' + +t.test('circleci with all options provided', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + mocks: { + 'proc-log': { + ...realProcLog, + input: { + ...realProcLog.input, + read: async () => 'y', + }, + }, + }, + }) + + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: 'test-auth-token', + }) + + registry.trustCreate({ packageName }) + + await npm.exec('trust', [ + 'circleci', + packageName, + '--yes', + '--org-id', '550e8400-e29b-41d4-a716-446655440000', + '--pipeline-definition-id', '6ba7b810-9dad-11d1-80b4-00c04fd430c8', + '--vcs-origin', 'github.com/owner/repo', + '--context-id', '123e4567-e89b-12d3-a456-426614174000', + ]) +}) + +t.test('circleci without optional context-id', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + mocks: { + 'proc-log': { + ...realProcLog, + input: { + ...realProcLog.input, + read: async () => 'y', + }, + }, + }, + }) + + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: 'test-auth-token', + }) + + registry.trustCreate({ packageName }) + + await npm.exec('trust', [ + 'circleci', + packageName, + '--yes', + '--org-id', '550e8400-e29b-41d4-a716-446655440000', + '--pipeline-definition-id', '6ba7b810-9dad-11d1-80b4-00c04fd430c8', + '--vcs-origin', 'github.com/owner/repo', + ]) +}) + +t.test('circleci missing required org-id', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + }) + + await t.rejects( + npm.exec('trust', [ + 'circleci', + packageName, + '--yes', + '--pipeline-definition-id', '6ba7b810-9dad-11d1-80b4-00c04fd430c8', + '--vcs-origin', 'github.com/owner/repo', + ]), + { message: /org-id is required/ } + ) +}) + +t.test('circleci missing required pipeline-definition-id', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + }) + + await t.rejects( + npm.exec('trust', [ + 'circleci', + packageName, + '--yes', + '--org-id', '550e8400-e29b-41d4-a716-446655440000', + '--vcs-origin', 'github.com/owner/repo', + ]), + { message: /pipeline-definition-id is required/ } + ) +}) + +t.test('circleci missing required vcs-origin', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + }) + + await t.rejects( + npm.exec('trust', [ + 'circleci', + packageName, + '--yes', + '--org-id', '550e8400-e29b-41d4-a716-446655440000', + '--pipeline-definition-id', '6ba7b810-9dad-11d1-80b4-00c04fd430c8', + ]), + { message: /vcs-origin is required/ } + ) +}) + +t.test('circleci with invalid org-id uuid format', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + }) + + await t.rejects( + npm.exec('trust', [ + 'circleci', + packageName, + '--yes', + '--org-id', 'not-a-uuid', + '--pipeline-definition-id', '6ba7b810-9dad-11d1-80b4-00c04fd430c8', + '--vcs-origin', 'github.com/owner/repo', + ]), + { message: /org-id must be a valid UUID/ } + ) +}) + +t.test('circleci with invalid vcs-origin format', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + }) + + await t.rejects( + npm.exec('trust', [ + 'circleci', + packageName, + '--yes', + '--org-id', '550e8400-e29b-41d4-a716-446655440000', + '--pipeline-definition-id', '6ba7b810-9dad-11d1-80b4-00c04fd430c8', + '--vcs-origin', 'invalid-format', + ]), + { message: /vcs-origin must be in format 'provider\/owner\/repo'/ } + ) +}) + +t.test('circleci missing package name', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + }) + + await t.rejects( + npm.exec('trust', [ + 'circleci', + '--yes', + '--org-id', '550e8400-e29b-41d4-a716-446655440000', + '--pipeline-definition-id', '6ba7b810-9dad-11d1-80b4-00c04fd430c8', + '--vcs-origin', 'github.com/owner/repo', + ]), + { message: /Package name must be specified either as an argument or in package.json file/ } + ) +}) + +t.test('bodyToOptions with all fields', t => { + const TrustCircleCI = require('../../../../lib/commands/trust/circleci.js') + + const body = { + id: 'test-id', + type: 'circleci', + claims: { + org_id: '550e8400-e29b-41d4-a716-446655440000', + pipeline_definition_id: '6ba7b810-9dad-11d1-80b4-00c04fd430c8', + vcs_origin: 'github.com/owner/repo', + context_id: '123e4567-e89b-12d3-a456-426614174000', + }, + } + + const options = TrustCircleCI.bodyToOptions(body) + + t.equal(options.id, 'test-id', 'id should be set') + t.equal(options.type, 'circleci', 'type should be set') + t.equal(options.orgId, '550e8400-e29b-41d4-a716-446655440000', 'orgId should be set') + t.equal(options.pipelineDefinitionId, '6ba7b810-9dad-11d1-80b4-00c04fd430c8', 'pipelineDefinitionId should be set') + t.equal(options.vcsOrigin, 'github.com/owner/repo', 'vcsOrigin should be set') + t.equal(options.contextId, '123e4567-e89b-12d3-a456-426614174000', 'contextId should be set') + t.end() +}) + +t.test('bodyToOptions without optional context_id', t => { + const TrustCircleCI = require('../../../../lib/commands/trust/circleci.js') + + const body = { + id: 'test-id', + type: 'circleci', + claims: { + org_id: '550e8400-e29b-41d4-a716-446655440000', + pipeline_definition_id: '6ba7b810-9dad-11d1-80b4-00c04fd430c8', + vcs_origin: 'github.com/owner/repo', + }, + } + + const options = TrustCircleCI.bodyToOptions(body) + + t.equal(options.contextId, undefined, 'contextId should be undefined') + t.end() +}) + +t.test('optionsToBody with all fields', t => { + const TrustCircleCI = require('../../../../lib/commands/trust/circleci.js') + + const options = { + orgId: '550e8400-e29b-41d4-a716-446655440000', + pipelineDefinitionId: '6ba7b810-9dad-11d1-80b4-00c04fd430c8', + vcsOrigin: 'github.com/owner/repo', + contextId: '123e4567-e89b-12d3-a456-426614174000', + } + + const body = TrustCircleCI.optionsToBody(options) + + t.equal(body.type, 'circleci', 'type should be circleci') + t.equal(body.claims.org_id, '550e8400-e29b-41d4-a716-446655440000', 'org_id should be set') + t.equal(body.claims.pipeline_definition_id, '6ba7b810-9dad-11d1-80b4-00c04fd430c8', 'pipeline_definition_id should be set') + t.equal(body.claims.vcs_origin, 'github.com/owner/repo', 'vcs_origin should be set') + t.equal(body.claims.context_id, '123e4567-e89b-12d3-a456-426614174000', 'context_id should be set') + t.end() +}) + +t.test('optionsToBody without optional contextId', t => { + const TrustCircleCI = require('../../../../lib/commands/trust/circleci.js') + + const options = { + orgId: '550e8400-e29b-41d4-a716-446655440000', + pipelineDefinitionId: '6ba7b810-9dad-11d1-80b4-00c04fd430c8', + vcsOrigin: 'github.com/owner/repo', + } + + const body = TrustCircleCI.optionsToBody(options) + + t.equal(body.claims.context_id, undefined, 'context_id should be undefined') + t.end() +}) + +t.test('getVcsOriginUrl generates correct URL', t => { + const TrustCircleCI = require('../../../../lib/commands/trust/circleci.js') + + t.equal( + TrustCircleCI.prototype.getVcsOriginUrl('github.com/npm/cli'), + 'https://github.com/npm/cli', + 'should generate https URL from vcs-origin' + ) + t.equal( + TrustCircleCI.prototype.getVcsOriginUrl('bitbucket.org/owner/repo'), + 'https://bitbucket.org/owner/repo', + 'should work with bitbucket' + ) + t.equal( + TrustCircleCI.prototype.getVcsOriginUrl(null), + null, + 'should return null for null input' + ) + t.equal( + TrustCircleCI.prototype.getVcsOriginUrl(undefined), + null, + 'should return null for undefined input' + ) + t.end() +})