Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions lib/commands/trust/circleci.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const Definition = require('@npmcli/config/lib/definitions/definition.js')
const globalDefinitions = require('@npmcli/config/lib/definitions/definitions.js')
const TrustCommand = require('../../trust-cmd.js')
const { trustDefinitions } = 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
Expand Down Expand Up @@ -46,6 +47,8 @@ class TrustCircleCI extends TrustCommand {
type: [null, String, Array],
description: 'CircleCI context UUID to match',
}),
trustDefinitions['allow-publish'],
trustDefinitions['allow-stage-publish'],
// globals are alphabetical
globalDefinitions['dry-run'],
globalDefinitions.json,
Expand Down
3 changes: 3 additions & 0 deletions lib/commands/trust/github.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const Definition = require('@npmcli/config/lib/definitions/definition.js')
const globalDefinitions = require('@npmcli/config/lib/definitions/definitions.js')
const TrustCommand = require('../../trust-cmd.js')
const { trustDefinitions } = require('../../trust-cmd.js')
const path = require('node:path')

class TrustGitHub extends TrustCommand {
Expand Down Expand Up @@ -38,6 +39,8 @@ class TrustGitHub extends TrustCommand {
description: 'CI environment name',
alias: ['env'],
}),
trustDefinitions['allow-publish'],
trustDefinitions['allow-stage-publish'],
// globals are alphabetical
globalDefinitions['dry-run'],
globalDefinitions.json,
Expand Down
3 changes: 3 additions & 0 deletions lib/commands/trust/gitlab.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const Definition = require('@npmcli/config/lib/definitions/definition.js')
const globalDefinitions = require('@npmcli/config/lib/definitions/definitions.js')
const TrustCommand = require('../../trust-cmd.js')
const { trustDefinitions } = require('../../trust-cmd.js')
const path = require('node:path')

class TrustGitLab extends TrustCommand {
Expand Down Expand Up @@ -37,6 +38,8 @@ class TrustGitLab extends TrustCommand {
description: 'CI environment name',
alias: ['env'],
}),
trustDefinitions['allow-publish'],
trustDefinitions['allow-stage-publish'],
// globals are alphabetical
globalDefinitions['dry-run'],
globalDefinitions.json,
Expand Down
70 changes: 66 additions & 4 deletions lib/trust-cmd.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,29 @@ const { read: _read } = require('read')
const { input, output, log, META } = require('proc-log')
const gitinfo = require('hosted-git-info')
const pkgJson = require('@npmcli/package-json')
const Definition = require('@npmcli/config/lib/definitions/definition.js')

const NPM_FRONTEND = 'https://www.npmjs.com'

const PERMISSIONS = {
Comment thread
wraithgar marked this conversation as resolved.
CREATE_PACKAGE: 'createPackage',
CREATE_STAGED_PACKAGE: 'createStagedPackage',
}

const trustDefinitions = {
'allow-publish': new Definition('allow-publish', {
default: false,
type: Boolean,
description: 'Allow npm publish for this trusted publisher configuration',
}),
'allow-stage-publish': new Definition('allow-stage-publish', {
default: false,
type: Boolean,
description: 'Allow npm stage publish for this trusted publisher configuration',
alias: ['allow-staged-publish'],
}),
}

class TrustCommand extends BaseCommand {
// Helper to format template strings with color
// Blue text with reset color for interpolated values
Expand Down Expand Up @@ -45,8 +65,22 @@ class TrustCommand extends BaseCommand {
}))
}

static permissionLabels = {
[PERMISSIONS.CREATE_PACKAGE]: 'publish',
[PERMISSIONS.CREATE_STAGED_PACKAGE]: 'stage publish',
}

static formatPermissions (permissions) {
if (!Array.isArray(permissions) || permissions.length === 0) {
return null
}
return permissions
.map(p => TrustCommand.permissionLabels[p] || p)
.join(', ')
}

logOptions (options, pad = true) {
const { values, warnings, fromPackageJson, urls } = { warnings: [], ...options }
const { values, warnings, fromPackageJson, urls, permissions } = { warnings: [], ...options }
if (warnings && warnings.length > 0) {
for (const warningMsg of warnings) {
log.warn('trust', warningMsg)
Expand All @@ -55,8 +89,12 @@ class TrustCommand extends BaseCommand {

const json = this.config.get('json')
if (json) {
const jsonValues = { ...options.values }
if (permissions) {
jsonValues.permissions = permissions
}
// Disable redaction: trust config values (e.g. CircleCI UUIDs) are not secrets
output.standard(JSON.stringify(options.values, null, 2), { [META]: true, redact: false })
output.standard(JSON.stringify(jsonValues, null, 2), { [META]: true, redact: false })
return
}

Expand All @@ -82,6 +120,10 @@ class TrustCommand extends BaseCommand {
lines.push(parts.join(' '))
}
}
const formattedPermissions = TrustCommand.formatPermissions(permissions)
if (formattedPermissions) {
lines.push(`${chalk.reset('permissions')}: ${chalk.green(formattedPermissions)}`)
}
if (pad) {
output.standard()
}
Expand Down Expand Up @@ -165,19 +207,36 @@ class TrustCommand extends BaseCommand {
const { providerName, providerEntity, providerHostname } = this.constructor
const dryRun = this.config.get('dry-run')
const yes = this.config.get('yes') // deep-lore this allows for --no-yes

const allowPublish = flags['allow-publish']
const allowStagePublish = flags['allow-stage-publish'] || flags['allow-staged-publish']
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const allowStagePublish = flags['allow-stage-publish'] || flags['allow-staged-publish']
const allowStagePublish = flags['allow-stage-publish']

I think the aliased key will have already been mapped to the main key by the time we get here


if (!allowPublish && !allowStagePublish) {
throw new Error('Trust Relationships require permission access to run specific commands such as `npm stage` and `npm publish` please provide `--allow-stage-publish` or `--allow-publish` to proceed.')
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message style doesn't match the short declarative pattern used elsewhere in this file

Suggested change
throw new Error('Trust Relationships require permission access to run specific commands such as `npm stage` and `npm publish` please provide `--allow-stage-publish` or `--allow-publish` to proceed.')
throw new Error(''At least one permission flag is required (--allow-publish, --allow-stage-publish)'')

}

const permissions = []
if (allowPublish) {
permissions.push(PERMISSIONS.CREATE_PACKAGE)
}
if (allowStagePublish) {
permissions.push(PERMISSIONS.CREATE_STAGED_PACKAGE)
}

const options = await this.flagsToOptions({ positionalArgs, flags, providerHostname })
this.dialogue`Establishing trust between ${options.values.package} package and ${providerName}`
this.dialogue`Anyone with ${providerEntity} write access can publish to ${options.values.package}`
this.dialogue`Two-factor authentication is required for this operation`
if (!this.registryIsDefault) {
this.warn`Registry ${this.npm.config.get('registry')} may not support trusted publishing`
}
this.logOptions(options)
this.logOptions({ ...options, permissions })
if (dryRun) {
return
}
await this.confirmOperation(yes)
const trustConfig = this.constructor.optionsToBody(options.values)
trustConfig.permissions = permissions
const response = await this.createConfig(options.values.package, [trustConfig])
const body = await response.json()
this.dialogue`Trust configuration created successfully for ${options.values.package} with the following settings:`
Expand Down Expand Up @@ -273,12 +332,15 @@ class TrustCommand extends BaseCommand {
const items = Array.isArray(body) ? body : [body]
for (const config of items) {
const values = this.constructor.bodyToOptions(config)
const permissions = config.permissions
output.standard()
this.logOptions({ values }, false)
this.logOptions({ values, permissions }, false)
}
output.standard()
}
}

module.exports = TrustCommand
module.exports.NPM_FRONTEND = NPM_FRONTEND
module.exports.trustDefinitions = trustDefinitions
module.exports.PERMISSIONS = PERMISSIONS
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we exporting this?

12 changes: 12 additions & 0 deletions tap-snapshots/test/lib/commands/completion.js.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,16 @@ Array [
--repo
--environment
--env
--allow-publish
--allow-stage-publish
--allow-staged-publish
--dry-run
--json
--registry
--yes
--no-allow-publish
--no-allow-stage-publish
--no-allow-staged-publish
--no-dry-run
--no-json
--no-yes
Expand All @@ -123,10 +129,16 @@ Array [
--project
--environment
--env
--allow-publish
--allow-stage-publish
--allow-staged-publish
--dry-run
--json
--registry
--yes
--no-allow-publish
--no-allow-stage-publish
--no-allow-staged-publish
--no-dry-run
--no-json
--no-yes
Expand Down
11 changes: 11 additions & 0 deletions test/lib/commands/trust/circleci.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ t.test('circleci with all options provided', async t => {
'--pipeline-definition-id', '6ba7b810-9dad-11d1-80b4-00c04fd430c8',
'--vcs-origin', 'github.com/owner/repo',
'--context-id', '123e4567-e89b-12d3-a456-426614174000',
'--allow-publish',
])
})

Expand Down Expand Up @@ -85,6 +86,7 @@ t.test('circleci without optional context-id', async t => {
'--project-id', '7c9e6679-7425-40de-944b-e07fc1f90ae7',
'--pipeline-definition-id', '6ba7b810-9dad-11d1-80b4-00c04fd430c8',
'--vcs-origin', 'github.com/owner/repo',
'--allow-publish',
])
})

Expand Down Expand Up @@ -128,6 +130,7 @@ t.test('circleci with multiple context-ids', async t => {
'--vcs-origin', 'github.com/owner/repo',
'--context-id', '123e4567-e89b-12d3-a456-426614174000',
'--context-id', 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
'--allow-publish',
])
})

Expand All @@ -152,6 +155,7 @@ t.test('circleci missing required org-id', async t => {
'--project-id', '7c9e6679-7425-40de-944b-e07fc1f90ae7',
'--pipeline-definition-id', '6ba7b810-9dad-11d1-80b4-00c04fd430c8',
'--vcs-origin', 'github.com/owner/repo',
'--allow-publish',
]),
{ message: /org-id is required/ }
)
Expand All @@ -178,6 +182,7 @@ t.test('circleci missing required project-id', async t => {
'--org-id', '550e8400-e29b-41d4-a716-446655440000',
'--pipeline-definition-id', '6ba7b810-9dad-11d1-80b4-00c04fd430c8',
'--vcs-origin', 'github.com/owner/repo',
'--allow-publish',
]),
{ message: /project-id is required/ }
)
Expand All @@ -204,6 +209,7 @@ t.test('circleci missing required pipeline-definition-id', async t => {
'--org-id', '550e8400-e29b-41d4-a716-446655440000',
'--project-id', '7c9e6679-7425-40de-944b-e07fc1f90ae7',
'--vcs-origin', 'github.com/owner/repo',
'--allow-publish',
]),
{ message: /pipeline-definition-id is required/ }
)
Expand All @@ -230,6 +236,7 @@ t.test('circleci missing required vcs-origin', async t => {
'--org-id', '550e8400-e29b-41d4-a716-446655440000',
'--project-id', '7c9e6679-7425-40de-944b-e07fc1f90ae7',
'--pipeline-definition-id', '6ba7b810-9dad-11d1-80b4-00c04fd430c8',
'--allow-publish',
]),
{ message: /vcs-origin is required/ }
)
Expand Down Expand Up @@ -257,6 +264,7 @@ t.test('circleci with invalid org-id uuid format', async t => {
'--project-id', '7c9e6679-7425-40de-944b-e07fc1f90ae7',
'--pipeline-definition-id', '6ba7b810-9dad-11d1-80b4-00c04fd430c8',
'--vcs-origin', 'github.com/owner/repo',
'--allow-publish',
]),
{ message: /org-id must be a valid UUID/ }
)
Expand Down Expand Up @@ -284,6 +292,7 @@ t.test('circleci with invalid vcs-origin format', async t => {
'--project-id', '7c9e6679-7425-40de-944b-e07fc1f90ae7',
'--pipeline-definition-id', '6ba7b810-9dad-11d1-80b4-00c04fd430c8',
'--vcs-origin', 'invalid-format',
'--allow-publish',
]),
{ message: /vcs-origin must be in format 'provider\/owner\/repo'/ }
)
Expand Down Expand Up @@ -311,6 +320,7 @@ t.test('circleci with vcs-origin containing scheme prefix', async t => {
'--project-id', '7c9e6679-7425-40de-944b-e07fc1f90ae7',
'--pipeline-definition-id', '6ba7b810-9dad-11d1-80b4-00c04fd430c8',
'--vcs-origin', 'https://github.com/owner/repo',
'--allow-publish',
]),
{ message: /vcs-origin must not include a scheme/ }
)
Expand All @@ -336,6 +346,7 @@ t.test('circleci missing package name', async t => {
'--project-id', '7c9e6679-7425-40de-944b-e07fc1f90ae7',
'--pipeline-definition-id', '6ba7b810-9dad-11d1-80b4-00c04fd430c8',
'--vcs-origin', 'github.com/owner/repo',
'--allow-publish',
]),
{ message: /Package name must be specified either as an argument or in package.json file/ }
)
Expand Down
8 changes: 4 additions & 4 deletions test/lib/commands/trust/github.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ t.test('github with all options provided', async t => {

registry.trustCreate({ packageName })

await npm.exec('trust', ['github', packageName, '--yes', '--file', 'workflow.yml', '--repository', 'owner/repo', '--environment', 'production'])
await npm.exec('trust', ['github', packageName, '--yes', '--file', 'workflow.yml', '--repository', 'owner/repo', '--environment', 'production', '--allow-publish'])
})

t.test('github with invalid repository format', async t => {
Expand All @@ -61,7 +61,7 @@ t.test('github with invalid repository format', async t => {
})

await t.rejects(
npm.exec('trust', ['github', packageName, '--yes', '--file', 'workflow.yml', '--repository', 'invalid']),
npm.exec('trust', ['github', packageName, '--yes', '--file', 'workflow.yml', '--repository', 'invalid', '--allow-publish']),
{ message: /must be specified in the format owner\/repository/ }
)
})
Expand Down Expand Up @@ -89,7 +89,7 @@ t.test('github with file as path', async t => {
})

await t.rejects(
npm.exec('trust', ['github', packageName, '--yes', '--file', '.github/workflows/ci.yml', '--repository', 'owner/repo']),
npm.exec('trust', ['github', packageName, '--yes', '--file', '.github/workflows/ci.yml', '--repository', 'owner/repo', '--allow-publish']),
{ message: /must be just a file not a path/ }
)
})
Expand Down Expand Up @@ -124,7 +124,7 @@ t.test('github without environment', async t => {

registry.trustCreate({ packageName })

await npm.exec('trust', ['github', packageName, '--yes', '--file', 'workflow.yml', '--repository', 'owner/repo'])
await npm.exec('trust', ['github', packageName, '--yes', '--file', 'workflow.yml', '--repository', 'owner/repo', '--allow-publish'])
})

t.test('bodyToOptions with all fields', t => {
Expand Down
8 changes: 4 additions & 4 deletions test/lib/commands/trust/gitlab.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ t.test('gitlab with all options provided', async t => {

registry.trustCreate({ packageName })

await npm.exec('trust', ['gitlab', packageName, '--yes', '--file', '.gitlab-ci.yml', '--project', 'group/subgroup/repo', '--environment', 'production'])
await npm.exec('trust', ['gitlab', packageName, '--yes', '--file', '.gitlab-ci.yml', '--project', 'group/subgroup/repo', '--environment', 'production', '--allow-publish'])
})

t.test('gitlab with invalid project format', async t => {
Expand All @@ -61,7 +61,7 @@ t.test('gitlab with invalid project format', async t => {
})

await t.rejects(
npm.exec('trust', ['gitlab', packageName, '--yes', '--file', '.gitlab-ci.yml', '--project', 'invalid']),
npm.exec('trust', ['gitlab', packageName, '--yes', '--file', '.gitlab-ci.yml', '--project', 'invalid', '--allow-publish']),
{ message: /must be specified in the format/ }
)
})
Expand Down Expand Up @@ -89,7 +89,7 @@ t.test('gitlab with file as path', async t => {
})

await t.rejects(
npm.exec('trust', ['gitlab', packageName, '--yes', '--file', '.gitlab/ci.yml', '--project', 'group/repo']),
npm.exec('trust', ['gitlab', packageName, '--yes', '--file', '.gitlab/ci.yml', '--project', 'group/repo', '--allow-publish']),
{ message: /must be just a file not a path/ }
)
})
Expand Down Expand Up @@ -124,7 +124,7 @@ t.test('gitlab without environment', async t => {

registry.trustCreate({ packageName })

await npm.exec('trust', ['gitlab', packageName, '--yes', '--file', '.gitlab-ci.yml', '--project', 'group/repo'])
await npm.exec('trust', ['gitlab', packageName, '--yes', '--file', '.gitlab-ci.yml', '--project', 'group/repo', '--allow-publish'])
})

t.test('bodyToOptions with all fields', t => {
Expand Down
Loading
Loading