From 159a705aa275122a4d7b1621c764f0e523026306 Mon Sep 17 00:00:00 2001 From: Spiros Martzoukos Date: Tue, 29 Sep 2026 18:56:19 +0300 Subject: [PATCH 1/2] feat(cli): derive the repo URL from the git remote Projects created by `checkly init` or `import plan` often end up without a repoUrl, which blocks Export to code from syncing bound resources. - getGitInformation now falls back to GitHub Actions' built-in env vars and the `origin` remote, with credentials stripped and SSH remotes converted to https. The derived URL only goes into repoInfo.repoUrl, never project.repoUrl. - Repositories without commits still send their URL; commitId is omitted. - `checkly init` writes repoUrl into the generated config, and the agent onboarding instructions tell the agent to set it from the remote. - `import plan` falls back to the git remote when creating a new project. Co-Authored-By: Claude Opus 5.5 --- packages/cli/src/ai-context/context.ts | 1 + .../checkly-config-template.ts | 2 + .../src/ai-context/references/initialize.md | 8 ++ packages/cli/src/commands/import/plan.ts | 10 +- .../onboarding/__tests__/boilerplate.spec.ts | 35 ++++- .../cli/src/helpers/onboarding/boilerplate.ts | 8 ++ .../cli/src/services/__tests__/util.spec.ts | 97 ++++++++++++++ packages/cli/src/services/util.ts | 122 +++++++++++++++--- 8 files changed, 263 insertions(+), 20 deletions(-) diff --git a/packages/cli/src/ai-context/context.ts b/packages/cli/src/ai-context/context.ts index 705502e87..cddb2ccf8 100644 --- a/packages/cli/src/ai-context/context.ts +++ b/packages/cli/src/ai-context/context.ts @@ -146,6 +146,7 @@ import { Frequency } from 'checkly/constructs' export default defineConfig({ projectName: "Production Monitoring Suite", logicalId: "prod-monitoring-2025", + // Must be this repo's own remote (\`git remote get-url origin\` as https). Never copy this example value. repoUrl: "https://github.com/acme/monitoring", checks: { activated: true, diff --git a/packages/cli/src/ai-context/onboarding-boilerplate/checkly-config-template.ts b/packages/cli/src/ai-context/onboarding-boilerplate/checkly-config-template.ts index eae1c4d26..646804d1e 100644 --- a/packages/cli/src/ai-context/onboarding-boilerplate/checkly-config-template.ts +++ b/packages/cli/src/ai-context/onboarding-boilerplate/checkly-config-template.ts @@ -10,6 +10,8 @@ const config = defineConfig({ * See https://www.checklyhq.com/docs/cli/constructs/ to learn more about logical IDs. */ logicalId: '{{logicalId}}', + /* The URL of this project's git repository, used to link checks to their source code */ + repoUrl: '{{repoUrl}}', /* Sets default values for Checks */ checks: { /* A default for how often your Check should run in minutes */ diff --git a/packages/cli/src/ai-context/references/initialize.md b/packages/cli/src/ai-context/references/initialize.md index d384d6471..3c0df24d7 100644 --- a/packages/cli/src/ai-context/references/initialize.md +++ b/packages/cli/src/ai-context/references/initialize.md @@ -91,6 +91,14 @@ Use the output to create a `checkly.config.ts` (or `checkly.config.js` if the us Adjust the `checkMatch` property according to previous selection. Use only locations verified as available in the previous step. +Set `repoUrl` to this repository's own remote: + +1. Run `git remote get-url origin`. +2. Convert it to an `https://` URL (`git@github.com:owner/repo.git` → `https://github.com/owner/repo`). Drop any credentials and the trailing `.git`. +3. Set the result as `repoUrl`. If there is no remote, leave `repoUrl` out. + +Never copy the `repoUrl` from the example config. + Present the generated configuration to the user and ask if it looks correct. Allow the user to make changes. Congratulate the user on completing the config. Now it's time to test the configuration and turn everything into monitoring! diff --git a/packages/cli/src/commands/import/plan.ts b/packages/cli/src/commands/import/plan.ts index cd811d611..0d9bc43e8 100644 --- a/packages/cli/src/commands/import/plan.ts +++ b/packages/cli/src/commands/import/plan.ts @@ -13,7 +13,7 @@ import { LOGICAL_ID_PATTERN } from '../../constants.js' import * as api from '../../rest/api.js' import { AuthCommand } from '../authCommand.js' import commonMessages from '../../messages/common-messages.js' -import { splitConfigFilePath } from '../../services/util.js' +import { getRepoUrlFromGit, splitConfigFilePath } from '../../services/util.js' import { ChecklyConfig, ConfigNotFoundError, loadChecklyConfig } from '../../services/checkly-config-loader.js' import { ImportPlan, ProjectNotFoundError, ImportPlanFilter, ImportPlanOptions, ResourceSync, ImportPlanFriend, FriendResourceSync, NoImportableResourcesFoundError } from '../../rest/projects.js' import { cased, Comment, docComment, Program } from '../../sourcegen/index.js' @@ -245,7 +245,7 @@ future deployments include the imported resources.` const loaded = await this.#loadConfig(configDirectory, configFilenames) const checklyConfig = loaded?.config ?? await this.#interactiveCreateConfig(configDirectory) - await this.#initializeProject(checklyConfig) + await this.#initializeProject(checklyConfig, configDirectory) const constructExports = await this.#findExportedResources( configDirectory, @@ -1310,12 +1310,14 @@ ${chalk.cyan('For safety, resources are not deletable until the plan has been co } } - async #initializeProject (config: ChecklyConfig): Promise { + async #initializeProject (config: ChecklyConfig, configDirectory: string): Promise { const { logicalId, projectName, - repoUrl, } = config + // The project is new, so a URL derived from the git remote can't override + // a declared one. + const repoUrl = config.repoUrl ?? getRepoUrlFromGit(configDirectory) this.style.actionStart('Checking project status') diff --git a/packages/cli/src/helpers/onboarding/__tests__/boilerplate.spec.ts b/packages/cli/src/helpers/onboarding/__tests__/boilerplate.spec.ts index 320d4f390..bc8c6412e 100644 --- a/packages/cli/src/helpers/onboarding/__tests__/boilerplate.spec.ts +++ b/packages/cli/src/helpers/onboarding/__tests__/boilerplate.spec.ts @@ -22,6 +22,10 @@ vi.mock('../../../services/check-parser/package-files/package-manager', () => ({ }), })) +vi.mock('../../../services/util', () => ({ + getRepoUrlFromGit: vi.fn(), +})) + vi.mock('../prompts-helpers', () => ({ makeOnCancel: vi.fn(() => vi.fn()), successMessage: vi.fn((msg: string) => `OK ${msg}`), @@ -32,6 +36,7 @@ import { execSync } from 'child_process' import prompts from 'prompts' import { join } from 'path' import { detectPackageManager } from '../../../services/check-parser/package-files/package-manager.js' +import { getRepoUrlFromGit } from '../../../services/util.js' import { createConfig, copyChecks, @@ -44,9 +49,10 @@ const mockWriteFileSync = vi.mocked(writeFileSync) const mockCpSync = vi.mocked(cpSync) const mockExecSync = vi.mocked(execSync) const mockPrompts = vi.mocked(prompts) +const mockGetRepoUrlFromGit = vi.mocked(getRepoUrlFromGit) const projectDir = '/test/project' -const configTemplate = `projectName: '{{projectName}}', logicalId: '{{logicalId}}'` +const configTemplate = `projectName: '{{projectName}}', logicalId: '{{logicalId}}', repoUrl: '{{repoUrl}}',` const packageJson = JSON.stringify({ name: 'my-cool-app', devDependencies: {} }) describe('boilerplate', () => { @@ -95,6 +101,33 @@ describe('boilerplate', () => { expect(content).not.toContain('{{logicalId}}') }) + it('writes repoUrl from the git remote of the project directory', () => { + mockGetRepoUrlFromGit.mockReturnValue('https://github.com/acme/app') + + createConfig(projectDir, log) + + expect(mockGetRepoUrlFromGit).toHaveBeenCalledWith(projectDir) + const writeCall = mockWriteFileSync.mock.calls.find( + ([path]) => path.toString().endsWith('checkly.config.ts'), + ) + const content = writeCall![1] as string + expect(content).toContain('repoUrl: \'https://github.com/acme/app\',') + expect(content).not.toContain('{{repoUrl}}') + }) + + it('writes a commented repoUrl placeholder when there is no git remote', () => { + mockGetRepoUrlFromGit.mockReturnValue(undefined) + + createConfig(projectDir, log) + + const writeCall = mockWriteFileSync.mock.calls.find( + ([path]) => path.toString().endsWith('checkly.config.ts'), + ) + const content = writeCall![1] as string + expect(content).toContain('// repoUrl: \'https://github.com//\',') + expect(content).not.toContain('{{repoUrl}}') + }) + it('sanitizes logicalId by replacing non-alphanumeric chars with hyphens', () => { mockReadFileSync.mockImplementation(path => { const p = path.toString() diff --git a/packages/cli/src/helpers/onboarding/boilerplate.ts b/packages/cli/src/helpers/onboarding/boilerplate.ts index 67ae8cf5d..6b48875ee 100644 --- a/packages/cli/src/helpers/onboarding/boilerplate.ts +++ b/packages/cli/src/helpers/onboarding/boilerplate.ts @@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url' import { detectPackageManager } from '../../services/check-parser/package-files/package-manager.js' import { makeOnCancel, successMessage } from './prompts-helpers.js' +import { getRepoUrlFromGit } from '../../services/util.js' const __dirname = dirname(fileURLToPath(import.meta.url)) @@ -90,9 +91,16 @@ export function createConfig ( } const projectName = getProjectName(projectDir) const logicalId = sanitizeLogicalId(projectName) + const repoUrl = getRepoUrlFromGit(projectDir) + // The template keeps the slot as valid TypeScript; without a remote the + // whole property becomes a commented placeholder. + const repoUrlProperty = repoUrl + ? `repoUrl: '${repoUrl}'` + : `// repoUrl: 'https://github.com//'` const content = template .replaceAll('{{projectName}}', projectName) .replaceAll('{{logicalId}}', logicalId) + .replaceAll(`repoUrl: '{{repoUrl}}'`, repoUrlProperty) try { writeFileSync(configPath, content) } catch { diff --git a/packages/cli/src/services/__tests__/util.spec.ts b/packages/cli/src/services/__tests__/util.spec.ts index dceae72bc..924d1dded 100644 --- a/packages/cli/src/services/__tests__/util.spec.ts +++ b/packages/cli/src/services/__tests__/util.spec.ts @@ -1,18 +1,25 @@ import fs from 'node:fs' import os from 'node:os' import path from 'node:path' +import { execFileSync } from 'node:child_process' import { afterEach, beforeEach, describe, it, expect } from 'vitest' import { getGitInformation, getGitRepoRoot, + getRepoUrlFromGit, + normalizeGitRemoteUrl, pathToPosix, isFileSync, } from '../util.js' const ENV_KEYS = [ 'CHECKLY_REPO_SHA', + 'CHECKLY_TEST_REPO_SHA', 'CHECKLY_REPO_URL', + 'CHECKLY_TEST_REPO_URL', + 'GITHUB_SERVER_URL', + 'GITHUB_REPOSITORY', 'CHECKLY_REPO_BRANCH', 'CHECKLY_GITHUB_REPORT', 'CHECKLY_GITHUB_SOURCE', @@ -197,4 +204,94 @@ describe('util', () => { })) }) }) + + describe('normalizeGitRemoteUrl()', () => { + it.each([ + ['git@github.com:acme/app.git', 'https://github.com/acme/app'], + ['ssh://git@github.com/acme/app.git', 'https://github.com/acme/app'], + ['ssh://git@gitlab.example.com:2222/group/sub/app.git', 'https://gitlab.example.com/group/sub/app'], + ['https://gitlab-ci-token:s3cr3t@gitlab.com/acme/app.git', 'https://gitlab.com/acme/app'], + ['https://x-access-token@github.com/acme/app/', 'https://github.com/acme/app'], + ['https://github.com/acme/app', 'https://github.com/acme/app'], + ['http://git.internal:8080/acme/app.git\n', 'http://git.internal:8080/acme/app'], + ])('normalizes %s', (remote, expected) => { + expect(normalizeGitRemoteUrl(remote)).toBe(expected) + }) + + it.each([ + [''], + ['/srv/git/app.git'], + ['../app'], + ['file:///srv/git/app.git'], + ['C:\\repos\\app'], + ])('returns undefined for %s', remote => { + expect(normalizeGitRemoteUrl(remote)).toBeUndefined() + }) + }) + + describe('git remote detection', () => { + let tmpDir: string + + const git = (...args: string[]) => execFileSync('git', args, { cwd: tmpDir, stdio: 'ignore' }) + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'checkly-git-remote-')) + git('init', '-q') + }) + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + it('reads and cleans the origin remote', () => { + git('remote', 'add', 'origin', 'https://ci-token:abc@github.com/acme/app.git') + expect(getRepoUrlFromGit(tmpDir)).toBe('https://github.com/acme/app') + }) + + it('returns undefined without an origin remote', () => { + expect(getRepoUrlFromGit(tmpDir)).toBeUndefined() + }) + + it('returns undefined outside a git repository', () => { + const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'checkly-no-git-')) + try { + expect(getRepoUrlFromGit(outside)).toBeUndefined() + } finally { + fs.rmSync(outside, { recursive: true, force: true }) + } + }) + + it('sends the repo URL without commitId for a repository with no commits', () => { + git('remote', 'add', 'origin', 'git@github.com:acme/app.git') + const info = getGitInformation(undefined, tmpDir) + expect(info).toEqual(expect.objectContaining({ repoUrl: 'https://github.com/acme/app' })) + expect(info).not.toHaveProperty('commitId') + }) + + it('returns null for a repository with no commits and no remote', () => { + expect(getGitInformation(undefined, tmpDir)).toBeNull() + }) + + it('resolves the repo URL in fallback order', () => { + git('remote', 'add', 'origin', 'git@github.com:acme/from-git.git') + const resolve = (configRepoUrl?: string) => getGitInformation(configRepoUrl, tmpDir)?.repoUrl + + expect(resolve()).toBe('https://github.com/acme/from-git') + + process.env.GITHUB_SERVER_URL = 'https://github.com' + process.env.GITHUB_REPOSITORY = 'acme/from-actions' + expect(resolve()).toBe('https://github.com/acme/from-actions') + + process.env.CHECKLY_GITHUB_REPOSITORY = 'acme/from-checkly-github' + expect(resolve()).toBe('https://github.com/acme/from-checkly-github') + + expect(resolve('https://github.com/acme/from-config')).toBe('https://github.com/acme/from-config') + + process.env.CHECKLY_TEST_REPO_URL = 'https://github.com/acme/from-test-env' + expect(resolve('https://github.com/acme/from-config')).toBe('https://github.com/acme/from-test-env') + + process.env.CHECKLY_REPO_URL = 'https://github.com/acme/from-env' + expect(resolve('https://github.com/acme/from-config')).toBe('https://github.com/acme/from-env') + }) + }) }) diff --git a/packages/cli/src/services/util.ts b/packages/cli/src/services/util.ts index 98147ab79..3b27fc9cc 100644 --- a/packages/cli/src/services/util.ts +++ b/packages/cli/src/services/util.ts @@ -1,6 +1,7 @@ import * as path from 'path' import * as fs from 'fs/promises' import * as fsSync from 'fs' +import { execFileSync } from 'child_process' import gitRepoInfo from 'git-repo-info' import { parse } from 'dotenv' @@ -10,7 +11,8 @@ import JSON5 from 'json5' import { existsSync } from 'fs' export interface GitInformation { - commitId: string + /** Absent when the repository has no commits yet but a repo URL is known. */ + commitId?: string repoUrl?: string | null branchName?: string | null commitOwner?: string | null @@ -51,6 +53,86 @@ function getGitHubRepositoryUrl (): string | undefined { return `${serverUrl.replace(/\/$/, '')}/${repository}` } +/** The repository URL from GitHub Actions' built-in env vars. */ +function getGitHubActionsRepositoryUrl (): string | undefined { + const serverUrl = process.env.GITHUB_SERVER_URL + const repository = process.env.GITHUB_REPOSITORY + if (!serverUrl || !repository) { + return undefined + } + return `${serverUrl.replace(/\/$/, '')}/${repository}` +} + +/** + * Turns a git remote URL into a credential-free web URL, e.g. + * `git@github.com:acme/app.git` -> `https://github.com/acme/app`. + * Returns `undefined` for remotes that have no web URL (local paths, file://). + */ +export function normalizeGitRemoteUrl (remote: string): string | undefined { + const trimmed = remote.trim() + if (!trimmed) { + return undefined + } + + let host: string + let repoPath: string + let protocol = 'https:' + + // scp-like syntax: [user@]host:path. A single-letter host is a Windows drive. + const scpLike = trimmed.match(/^(?:[^@/\s]+@)?([^:/\s]{2,}):(?!\/\/)(.+)$/) + if (scpLike) { + host = scpLike[1] + repoPath = scpLike[2] + } else { + let url: URL + try { + url = new URL(trimmed) + } catch { + return undefined + } + if (url.protocol === 'https:' || url.protocol === 'http:') { + // Keep the port: it is part of the web address. Credentials are dropped. + protocol = url.protocol + host = url.host + } else if (url.protocol === 'ssh:' || url.protocol === 'git+ssh:' || url.protocol === 'git:') { + // SSH and git-daemon ports don't carry over to the web URL. + host = url.hostname + } else { + return undefined + } + repoPath = url.pathname + } + + repoPath = repoPath + .replace(/^\/+/, '') + .replace(/\/+$/, '') + .replace(/\.git$/, '') + .replace(/\/+$/, '') + if (!host || !repoPath) { + return undefined + } + return `${protocol}//${host}/${repoPath}` +} + +/** + * The web URL of the `origin` remote of the git repository at `cwd`, with + * credentials stripped. `undefined` when there is no repository, no origin, + * or git is not installed. + */ +export function getRepoUrlFromGit (cwd: string = process.cwd()): string | undefined { + let remote: string + try { + remote = execFileSync('git', ['config', '--get', 'remote.origin.url'], { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }) + } catch { + return undefined + } + return normalizeGitRemoteUrl(remote) +} + function isGitHubReportingEnabled (): boolean { const value = (process.env.CHECKLY_GITHUB_REPORT ?? '').trim().toLowerCase() return value === 'true' || value === '1' @@ -122,27 +204,37 @@ export function isFileSync (path: string): boolean { } /** * @param repoUrl default repoURL the user can set in their project config. + * @param cwd directory to read git information from. */ -export function getGitInformation (repoUrl?: string): GitInformation | null { - const repositoryInfo = gitRepoInfo() - - if ( - !process.env.CHECKLY_REPO_SHA - && !process.env.CHECKLY_TEST_REPO_SHA - && !process.env.CHECKLY_GITHUB_SHA - && !repositoryInfo.sha - ) { +export function getGitInformation (repoUrl?: string, cwd: string = process.cwd()): GitInformation | null { + const repositoryInfo = gitRepoInfo(cwd) + + const commitId = process.env.CHECKLY_REPO_SHA + ?? process.env.CHECKLY_TEST_REPO_SHA + ?? process.env.CHECKLY_GITHUB_SHA + ?? repositoryInfo.sha + ?? undefined + + // Declared values first; the git remote is only a last resort. Callers must + // not copy a derived URL into project.repoUrl: the backend only lets it fill + // a project that has no URL yet. + const resolvedRepoUrl = process.env.CHECKLY_REPO_URL + ?? process.env.CHECKLY_TEST_REPO_URL + ?? repoUrl + ?? getGitHubRepositoryUrl() + ?? getGitHubActionsRepositoryUrl() + ?? getRepoUrlFromGit(cwd) + + // A repository without commits still has a URL worth sending. + if (!commitId && !resolvedRepoUrl) { return null } // safe way to remove the email address const committer = (repositoryInfo.committer?.match(/([^<]+)/) || [])[1]?.trim() const gitInformation: GitInformation = { - commitId: process.env.CHECKLY_REPO_SHA - ?? process.env.CHECKLY_TEST_REPO_SHA - ?? process.env.CHECKLY_GITHUB_SHA - ?? repositoryInfo.sha, - repoUrl: process.env.CHECKLY_REPO_URL ?? process.env.CHECKLY_TEST_REPO_URL ?? repoUrl ?? getGitHubRepositoryUrl(), + ...(commitId ? { commitId } : {}), + repoUrl: resolvedRepoUrl, branchName: process.env.CHECKLY_REPO_BRANCH ?? process.env.CHECKLY_TEST_REPO_BRANCH ?? repositoryInfo.branch, commitOwner: process.env.CHECKLY_REPO_COMMIT_OWNER ?? process.env.CHECKLY_TEST_REPO_COMMIT_OWNER ?? committer, commitMessage: process.env.CHECKLY_REPO_COMMIT_MESSAGE From 6e573161f8f234dfc295eb6222b430c801e31f84 Mon Sep 17 00:00:00 2001 From: Spiros Martzoukos Date: Wed, 30 Sep 2026 10:33:14 +0300 Subject: [PATCH 2/2] fix(cli): escape the derived repo URL, reject SSH aliases, honour config repoUrl in trigger - `checkly init` quotes the git-derived URL as a string literal and uses a function replacer, so quotes or `$'` in a remote can't break or inject code into checkly.config.ts. - SSH remotes whose host has no dot (SSH config aliases like `github-work`) or whose web path layout differs (Azure DevOps) no longer produce a URL. - `checkly trigger` passes the config repoUrl, so a declared URL wins over the git remote there too. Co-Authored-By: Claude Opus 5.5 --- packages/cli/src/commands/trigger.ts | 2 +- .../onboarding/__tests__/boilerplate.spec.ts | 12 +++++++++++ .../cli/src/helpers/onboarding/boilerplate.ts | 8 +++++-- .../cli/src/services/__tests__/util.spec.ts | 4 ++++ packages/cli/src/services/util.ts | 21 ++++++++++++++++++- 5 files changed, 43 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/trigger.ts b/packages/cli/src/commands/trigger.ts index c3c00dac5..8b5738ebd 100644 --- a/packages/cli/src/commands/trigger.ts +++ b/packages/cli/src/commands/trigger.ts @@ -164,7 +164,7 @@ export default class Trigger extends AuthCommand { const reporters = createReporters(reporterTypes, location, verbose) const testRetryStrategy = this.prepareTestRetryStrategy(retries, checklyConfig?.cli?.retries) - const repoInfo = getGitInformation() + const repoInfo = getGitInformation(checklyConfig?.repoUrl) const ciInfo = getCiInformation() const runner = new TriggerRunner( diff --git a/packages/cli/src/helpers/onboarding/__tests__/boilerplate.spec.ts b/packages/cli/src/helpers/onboarding/__tests__/boilerplate.spec.ts index bc8c6412e..b0b722f6e 100644 --- a/packages/cli/src/helpers/onboarding/__tests__/boilerplate.spec.ts +++ b/packages/cli/src/helpers/onboarding/__tests__/boilerplate.spec.ts @@ -115,6 +115,18 @@ describe('boilerplate', () => { expect(content).not.toContain('{{repoUrl}}') }) + it('writes a repoUrl with quotes and replacement patterns as an escaped string literal', () => { + mockGetRepoUrlFromGit.mockReturnValue('https://h.example/o/r\');x(\'$\'') + + createConfig(projectDir, log) + + const writeCall = mockWriteFileSync.mock.calls.find( + ([path]) => path.toString().endsWith('checkly.config.ts'), + ) + const content = writeCall![1] as string + expect(content).toContain('repoUrl: "https://h.example/o/r\');x(\'$\'",') + }) + it('writes a commented repoUrl placeholder when there is no git remote', () => { mockGetRepoUrlFromGit.mockReturnValue(undefined) diff --git a/packages/cli/src/helpers/onboarding/boilerplate.ts b/packages/cli/src/helpers/onboarding/boilerplate.ts index 6b48875ee..2e1db14f3 100644 --- a/packages/cli/src/helpers/onboarding/boilerplate.ts +++ b/packages/cli/src/helpers/onboarding/boilerplate.ts @@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url' import { detectPackageManager } from '../../services/check-parser/package-files/package-manager.js' import { makeOnCancel, successMessage } from './prompts-helpers.js' import { getRepoUrlFromGit } from '../../services/util.js' +import { quote } from '../../sourcegen/string.js' const __dirname = dirname(fileURLToPath(import.meta.url)) @@ -94,13 +95,16 @@ export function createConfig ( const repoUrl = getRepoUrlFromGit(projectDir) // The template keeps the slot as valid TypeScript; without a remote the // whole property becomes a commented placeholder. + // The URL comes from local git config, so it is quoted as a string literal + // rather than pasted into the generated source. const repoUrlProperty = repoUrl - ? `repoUrl: '${repoUrl}'` + ? `repoUrl: ${quote(repoUrl)}` : `// repoUrl: 'https://github.com//'` const content = template .replaceAll('{{projectName}}', projectName) .replaceAll('{{logicalId}}', logicalId) - .replaceAll(`repoUrl: '{{repoUrl}}'`, repoUrlProperty) + // A function replacer keeps `$'`-style patterns in the URL literal. + .replaceAll(`repoUrl: '{{repoUrl}}'`, () => repoUrlProperty) try { writeFileSync(configPath, content) } catch { diff --git a/packages/cli/src/services/__tests__/util.spec.ts b/packages/cli/src/services/__tests__/util.spec.ts index 924d1dded..919080d6a 100644 --- a/packages/cli/src/services/__tests__/util.spec.ts +++ b/packages/cli/src/services/__tests__/util.spec.ts @@ -224,6 +224,10 @@ describe('util', () => { ['../app'], ['file:///srv/git/app.git'], ['C:\\repos\\app'], + ['git@github-work:acme/app.git'], + ['ssh://git@gitalias/acme/app.git'], + ['git@ssh.dev.azure.com:v3/acme/project/app'], + ['acme@vs-ssh.visualstudio.com:v3/acme/project/app'], ])('returns undefined for %s', remote => { expect(normalizeGitRemoteUrl(remote)).toBeUndefined() }) diff --git a/packages/cli/src/services/util.ts b/packages/cli/src/services/util.ts index 3b27fc9cc..7330ada75 100644 --- a/packages/cli/src/services/util.ts +++ b/packages/cli/src/services/util.ts @@ -66,7 +66,8 @@ function getGitHubActionsRepositoryUrl (): string | undefined { /** * Turns a git remote URL into a credential-free web URL, e.g. * `git@github.com:acme/app.git` -> `https://github.com/acme/app`. - * Returns `undefined` for remotes that have no web URL (local paths, file://). + * Returns `undefined` for remotes that have no web URL (local paths, file://, + * SSH config aliases, SSH hosts with a different web path layout). */ export function normalizeGitRemoteUrl (remote: string): string | undefined { const trimmed = remote.trim() @@ -77,6 +78,7 @@ export function normalizeGitRemoteUrl (remote: string): string | undefined { let host: string let repoPath: string let protocol = 'https:' + let isSshRemote = true // scp-like syntax: [user@]host:path. A single-letter host is a Windows drive. const scpLike = trimmed.match(/^(?:[^@/\s]+@)?([^:/\s]{2,}):(?!\/\/)(.+)$/) @@ -94,6 +96,7 @@ export function normalizeGitRemoteUrl (remote: string): string | undefined { // Keep the port: it is part of the web address. Credentials are dropped. protocol = url.protocol host = url.host + isSshRemote = false } else if (url.protocol === 'ssh:' || url.protocol === 'git+ssh:' || url.protocol === 'git:') { // SSH and git-daemon ports don't carry over to the web URL. host = url.hostname @@ -111,9 +114,25 @@ export function normalizeGitRemoteUrl (remote: string): string | undefined { if (!host || !repoPath) { return undefined } + if (isSshRemote && !isWebHostForSshRemote(host)) { + return undefined + } return `${protocol}//${host}/${repoPath}` } +// SSH hosts whose repository paths don't map onto a web URL of the same shape. +const NON_WEB_SSH_HOSTS = new Set(['ssh.dev.azure.com', 'vs-ssh.visualstudio.com']) + +/** + * Whether the host of an SSH remote (converted to https) is also a web host. + * A host without a dot is an SSH config alias (e.g. `github-work`), not a + * resolvable web address. + */ +function isWebHostForSshRemote (host: string): boolean { + const hostname = host.toLowerCase() + return hostname.includes('.') && !NON_WEB_SSH_HOSTS.has(hostname) +} + /** * The web URL of the `origin` remote of the git repository at `cwd`, with * credentials stripped. `undefined` when there is no repository, no origin,