-
Notifications
You must be signed in to change notification settings - Fork 236
feat: add credential manager configuration to the oclif init hook #3786
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
1e45a58
a7bbd93
5873062
8f043ac
bc57a01
d5ac519
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| import {Hook} from '@oclif/core/hooks' | ||
|
|
||
| import Git from '../../lib/git/git.js' | ||
|
|
||
| const hook: Hook.Init = async function () { | ||
| // Skip for --version / --help / completion-style invocations to avoid a needless git subprocess. | ||
| // Version-style invocations include `--version` plus any `oclif.additionalVersionFlags` | ||
| // (e.g. `-v`, `version`) declared in package.json — mirror version.ts, which reads the | ||
| // same config. We hard-include `-v`/`version` as a defensive fallback so this skip can | ||
| // never silently regress if config is somehow unavailable at runtime. | ||
| // Help-style invocations are skipped for the same reason: they're fast informational | ||
| // commands that shouldn't touch git. They include `--help` plus any | ||
| // `oclif.additionalHelpFlags` (e.g. `-h`) declared in package.json, and we hard-include | ||
| // `--help`/`-h`/`help` as the same kind of defensive fallback. | ||
| // Completion invocations are matched by the `autocomplete` prefix, since shell completion | ||
| // actually runs `autocomplete:options` (not the bare `autocomplete` command). | ||
| // NOTE: temporary hook until v12. | ||
| const arg = process.argv[2] | ||
| const additionalVersionFlags = this.config?.pjson?.oclif?.additionalVersionFlags ?? [] | ||
| const additionalHelpFlags = this.config?.pjson?.oclif?.additionalHelpFlags ?? [] | ||
| const skip = new Set([ | ||
| '--help', | ||
| '--version', | ||
| '-h', | ||
| '-v', | ||
| 'help', | ||
| 'version', | ||
| ...additionalHelpFlags, | ||
| ...additionalVersionFlags, | ||
| ]) | ||
| if (!arg || skip.has(arg) || arg.startsWith('autocomplete')) return | ||
|
|
||
| const git = new Git() | ||
| try { | ||
| await git.configureCredentialHelper() | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we comment out / temporarily remove the
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @k80bowman What do you think? I don't think so but maybe?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. My thinking is that we keep it because they're the permanent path once the temporary hook is removed at v12. The idempotency check already makes the duplicate call a no-op. (According to Claude)
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. With the check, that makes sense to me |
||
| } catch { | ||
| // ignore — best-effort; never block a command on git config | ||
| } | ||
| } | ||
|
|
||
| export default hook | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,213 @@ | ||
| import {expect} from 'chai' | ||
| import {readFileSync} from 'node:fs' | ||
| import {join} from 'node:path' | ||
| import {fileURLToPath} from 'node:url' | ||
| import * as sinon from 'sinon' | ||
|
|
||
| import configureGitCredentialManagerHook from '../../../src/hooks/init/configure-git-credential-manager.js' | ||
| import Git from '../../../src/lib/git/git.js' | ||
|
|
||
| const callHook = async (id: string) => { | ||
| // The hook reads `this.config.pjson.oclif.additionalVersionFlags` and | ||
| // `.additionalHelpFlags`, so the bound `this` context must carry a real config to | ||
| // exercise the production code path (this.config IS set when oclif invokes the hook). | ||
| // The `--sentinel-help` flag below appears ONLY in additionalHelpFlags (not the | ||
| // hardcoded set), so a test using it proves the dynamic merge spread actually works. | ||
| const config = {pjson: {oclif: {additionalHelpFlags: ['-h', '--sentinel-help'], additionalVersionFlags: ['-v', 'version']}}} | ||
| const context = {config} | ||
| const options = { | ||
| argv: [], | ||
| config: config as any, | ||
| context: {} as any, | ||
| id, | ||
| } | ||
|
|
||
|
jdodson marked this conversation as resolved.
|
||
| await configureGitCredentialManagerHook.call(context as any, options) | ||
| } | ||
|
|
||
| describe('configure-git-credential-manager hook', function () { | ||
| let originalArgv: string[] | ||
| let configureStub: sinon.SinonStub | ||
|
|
||
| beforeEach(function () { | ||
| // Save original argv since the hook reads process.argv[2] | ||
| originalArgv = process.argv | ||
|
|
||
| // Stub on the prototype so the real git subprocess never runs | ||
| configureStub = sinon.stub(Git.prototype, 'configureCredentialHelper').resolves() | ||
| }) | ||
|
|
||
| afterEach(function () { | ||
| process.argv = originalArgv | ||
| sinon.restore() | ||
| }) | ||
|
|
||
| describe('when a normal command is invoked', function () { | ||
| beforeEach(function () { | ||
| process.argv = ['node', 'heroku', 'apps'] | ||
| }) | ||
|
|
||
| it('configures the git credential helper exactly once', async function () { | ||
| await callHook('apps') | ||
|
|
||
| expect(configureStub.calledOnce).to.be.true | ||
| }) | ||
| }) | ||
|
|
||
| describe('when configuring the credential helper fails', function () { | ||
| beforeEach(function () { | ||
| process.argv = ['node', 'heroku', 'apps'] | ||
| configureStub.rejects(new Error('Git not found')) | ||
| }) | ||
|
|
||
| it('does not throw', async function () { | ||
| try { | ||
| await callHook('apps') | ||
| } catch (error: any) { | ||
| expect.fail(`hook should not throw, but threw: ${error.message}`) | ||
| } | ||
|
|
||
| expect(configureStub.calledOnce).to.be.true | ||
| }) | ||
| }) | ||
|
|
||
| describe('when command is --version', function () { | ||
| beforeEach(function () { | ||
| process.argv = ['node', 'heroku', '--version'] | ||
| }) | ||
|
|
||
| it('does not configure the git credential helper', async function () { | ||
| await callHook('--version') | ||
|
|
||
| expect(configureStub.called).to.be.false | ||
| }) | ||
| }) | ||
|
|
||
| describe('when command is the version command', function () { | ||
| beforeEach(function () { | ||
| process.argv = ['node', 'heroku', 'version'] | ||
| }) | ||
|
|
||
| it('does not configure the git credential helper', async function () { | ||
| await callHook('version') | ||
|
|
||
| expect(configureStub.called).to.be.false | ||
| }) | ||
| }) | ||
|
|
||
| describe('when command is -v', function () { | ||
| beforeEach(function () { | ||
| process.argv = ['node', 'heroku', '-v'] | ||
| }) | ||
|
|
||
| it('does not configure the git credential helper', async function () { | ||
| await callHook('-v') | ||
|
|
||
| expect(configureStub.called).to.be.false | ||
| }) | ||
| }) | ||
|
|
||
| describe('when command is --help', function () { | ||
| beforeEach(function () { | ||
| process.argv = ['node', 'heroku', '--help'] | ||
| }) | ||
|
|
||
| it('does not configure the git credential helper', async function () { | ||
| await callHook('--help') | ||
|
|
||
| expect(configureStub.called).to.be.false | ||
| }) | ||
| }) | ||
|
|
||
| describe('when command is the help command', function () { | ||
| beforeEach(function () { | ||
| process.argv = ['node', 'heroku', 'help'] | ||
| }) | ||
|
|
||
| it('does not configure the git credential helper', async function () { | ||
| await callHook('help') | ||
|
|
||
| expect(configureStub.called).to.be.false | ||
| }) | ||
| }) | ||
|
|
||
| describe('when command is -h', function () { | ||
| beforeEach(function () { | ||
| process.argv = ['node', 'heroku', '-h'] | ||
| }) | ||
|
|
||
| it('does not configure the git credential helper', async function () { | ||
| await callHook('-h') | ||
|
|
||
| expect(configureStub.called).to.be.false | ||
| }) | ||
| }) | ||
|
|
||
| describe('when command is a flag present only in additionalHelpFlags', function () { | ||
| beforeEach(function () { | ||
| process.argv = ['node', 'heroku', '--sentinel-help'] | ||
| }) | ||
|
|
||
| it('does not configure the git credential helper', async function () { | ||
| await callHook('--sentinel-help') | ||
|
|
||
| expect(configureStub.called).to.be.false | ||
| }) | ||
| }) | ||
|
|
||
| describe('when command is autocomplete', function () { | ||
| beforeEach(function () { | ||
| process.argv = ['node', 'heroku', 'autocomplete'] | ||
| }) | ||
|
|
||
| it('does not configure the git credential helper', async function () { | ||
| await callHook('autocomplete') | ||
|
|
||
| expect(configureStub.called).to.be.false | ||
| }) | ||
| }) | ||
|
|
||
| describe('when command is autocomplete:options (the real completion path)', function () { | ||
| beforeEach(function () { | ||
| process.argv = ['node', 'heroku', 'autocomplete:options', 'apps'] | ||
| }) | ||
|
|
||
| it('does not configure the git credential helper', async function () { | ||
| await callHook('autocomplete:options') | ||
|
|
||
| expect(configureStub.called).to.be.false | ||
| }) | ||
| }) | ||
|
|
||
| describe('when no command argument is provided', function () { | ||
| beforeEach(function () { | ||
| process.argv = ['node', 'heroku'] | ||
| }) | ||
|
|
||
| it('does not configure the git credential helper', async function () { | ||
| await callHook('') | ||
|
|
||
| expect(configureStub.called).to.be.false | ||
| }) | ||
| }) | ||
|
|
||
| // Registration regression guard: the unit tests above call the hook function | ||
| // directly, so they pass even if the hook is removed from package.json. This | ||
| // pins the oclif `init` hook list so an accidental deletion or path typo — | ||
| // including a botched removal when this temporary hook is taken out at v12 — | ||
| // fails a test instead of silently disabling the feature. | ||
| describe('oclif init hook registration', function () { | ||
| it('registers the expected init hooks in package.json', function () { | ||
| const root = fileURLToPath(new URL('.', import.meta.url)) | ||
| const pjsonPath = join(root, '..', '..', '..', 'package.json') | ||
| const pjson = JSON.parse(readFileSync(pjsonPath, 'utf8')) | ||
|
|
||
| expect(pjson.oclif.hooks.init).to.deep.equal([ | ||
| './dist/hooks/init/version', | ||
| './dist/hooks/init/terms-of-service', | ||
| './dist/hooks/init/setup-otel-telemetry', | ||
| './dist/hooks/init/configure-git-credential-manager', | ||
| ]) | ||
| }) | ||
| }) | ||
| }) | ||
Uh oh!
There was an error while loading. Please reload this page.