diff --git a/.changeset/studio-connect.md b/.changeset/studio-connect.md new file mode 100644 index 0000000..5873629 --- /dev/null +++ b/.changeset/studio-connect.md @@ -0,0 +1,5 @@ +--- +"contentrain": minor +--- + +Add `studio connect` command that links a local repository to a Contentrain Studio project in one interactive flow — workspace selection, GitHub App installation, repo detection, `.contentrain/` scanning, and project creation. Also fixes the validate integration test timeout by batching 80 sequential git-branch spawns into a single `git update-ref --stdin` call. diff --git a/README.md b/README.md index 880e1b6..79dee37 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,8 @@ npx contentrain validate # check content health npx contentrain generate # generate typed SDK client npx contentrain status # project overview npx contentrain doctor # setup health check +npx contentrain studio login # authenticate with Studio +npx contentrain studio connect # connect repo to Studio project ``` ## Documentation diff --git a/docs/getting-started.md b/docs/getting-started.md index bacecea..ea82c7c 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -177,6 +177,15 @@ When the local CLI and MCP flow are not enough, [Contentrain Studio](/studio) ad - media management - CDN delivery for non-web platforms +Connect your local project to Studio with two commands: + +```bash +contentrain studio login +contentrain studio connect +``` + +The `connect` command detects your git remote, verifies GitHub App installation, scans for `.contentrain/` configuration, and creates the project — all in one interactive flow. See [CLI Studio Integration](/packages/cli#connecting-a-repository) for details. + ## The Content Pipeline Every operation follows the same governance pipeline: diff --git a/docs/packages/cli.md b/docs/packages/cli.md index c8d3993..d112e04 100644 --- a/docs/packages/cli.md +++ b/docs/packages/cli.md @@ -55,6 +55,7 @@ Requirements: | `contentrain generate` | Generate `.contentrain/client/` and `#contentrain` package imports | | `contentrain diff` | Review and merge or reject pending `contentrain/*` branches | | `contentrain serve` | Start the local review UI or the MCP stdio server | +| `contentrain studio connect` | Connect a repository to a Studio project | | `contentrain studio login` | Authenticate with Contentrain Studio | | `contentrain studio logout` | Log out from Studio | | `contentrain studio whoami` | Show current authentication status | @@ -311,6 +312,30 @@ contentrain studio logout Credentials are stored in `~/.contentrain/credentials.json` with `0o600` permissions — never inside the project directory. For CI/CD, set the `CONTENTRAIN_STUDIO_TOKEN` environment variable to skip interactive login. +### Connecting a Repository + +```bash +# Interactive flow: workspace → GitHub App → repo → scan → create project +contentrain studio connect + +# Skip workspace selection +contentrain studio connect --workspace ws-123 +``` + +The `connect` command links your local repository to a Studio project in one interactive flow: + +1. **Workspace** — select an existing workspace (auto-selects if only one) +2. **GitHub App** — checks if the Contentrain GitHub App is installed; if not, opens the browser for installation +3. **Repository** — detects the current git remote and matches it against accessible repos +4. **Scan** — checks the repository for `.contentrain/` configuration, reports found models and locales +5. **Create** — prompts for a project name and creates the project in Studio + +After a successful connection, workspace and project IDs are saved as defaults so subsequent `studio` commands skip interactive selection. + +::: tip Run `contentrain init` First +The connect flow works best when `.contentrain/` is already initialized and pushed to the repository. The scan step confirms your setup, but you can also connect first and initialize later. +::: + ### CDN Setup & Delivery ```bash diff --git a/docs/studio.md b/docs/studio.md index 811ccbb..d8e2f3f 100644 --- a/docs/studio.md +++ b/docs/studio.md @@ -241,6 +241,17 @@ So the split is: - **open-source Contentrain** = local, Git-native content governance core - **Contentrain Studio** = team operations and delivery layer for the same content contract +## Connecting from the CLI + +The fastest way to create a Studio project from an existing repository is the CLI `connect` command: + +```bash +contentrain studio login +contentrain studio connect +``` + +This single interactive flow handles workspace selection, GitHub App installation, repository detection, `.contentrain/` scanning, and project creation. See the [CLI Studio Integration](/packages/cli#connecting-a-repository) section for the full step-by-step. + ## Go Deeper - [Ecosystem Map](/ecosystem) diff --git a/packages/cli/README.md b/packages/cli/README.md index cbda86a..ef6767b 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -58,6 +58,7 @@ Requirements: | `contentrain generate` | Generate `.contentrain/client/` and `#contentrain` package imports | | `contentrain diff` | Review and merge or reject pending `contentrain/*` branches | | `contentrain serve` | Start the local review UI or the MCP stdio server | +| `contentrain studio connect` | Connect a repository to a Studio project | | `contentrain studio login` | Authenticate with Contentrain Studio | | `contentrain studio logout` | Log out from Studio | | `contentrain studio whoami` | Show current authentication status | @@ -172,11 +173,18 @@ to understand: The `studio` command group connects the CLI to [Contentrain Studio](https://studio.contentrain.io) for enterprise workflows. -Authenticate: +Authenticate and connect: ```bash contentrain studio login contentrain studio whoami +contentrain studio connect +``` + +The `connect` command links your local repository to a Studio project. It detects the git remote, verifies GitHub App installation, scans for `.contentrain/` configuration, and creates the project — all in one interactive flow. + +```bash +contentrain studio connect --workspace ws-123 ``` Set up CDN for content delivery: diff --git a/packages/cli/src/studio/client.ts b/packages/cli/src/studio/client.ts index 5e81e08..92134a9 100644 --- a/packages/cli/src/studio/client.ts +++ b/packages/cli/src/studio/client.ts @@ -27,6 +27,11 @@ import { type ActivityEntry, type UsageMetrics, type PaginatedResponse, + type GitHubInstallation, + type GitHubRepo, + type GitHubSetupUrl, + type ScanResult, + type CreateProjectPayload, } from './types.js' // ── Client ──────────────────────────────────────────────────────────────── @@ -163,6 +168,37 @@ export class StudioApiClient { return this.request('GET', `/api/workspaces/${workspaceId}/projects`) } + // ── GitHub / Connect ─────────────────────────────────────────────────── + + async createProject(workspaceId: string, payload: CreateProjectPayload): Promise { + return this.request('POST', `/api/workspaces/${workspaceId}/projects`, { + body: payload, + }) + } + + async listGitHubInstallations(): Promise { + return this.request('GET', '/api/github/installations') + } + + async getGitHubSetupUrl(): Promise { + return this.request('GET', '/api/github/setup') + } + + async listGitHubRepos(installationId: number): Promise { + return this.request('GET', '/api/github/repos', { + query: { installationId: String(installationId) }, + }) + } + + async scanRepository(installationId: number, repoFullName: string): Promise { + return this.request('GET', '/api/github/scan', { + query: { + installationId: String(installationId), + repo: repoFullName, + }, + }) + } + // ── Branches ────────────────────────────────────────────────────────── async listBranches(wid: string, pid: string): Promise { diff --git a/packages/cli/src/studio/commands/connect.ts b/packages/cli/src/studio/commands/connect.ts new file mode 100644 index 0000000..84272ce --- /dev/null +++ b/packages/cli/src/studio/commands/connect.ts @@ -0,0 +1,338 @@ +import { defineCommand } from 'citty' +import { intro, outro, log, spinner, select, text, confirm, isCancel } from '@clack/prompts' +import { simpleGit } from 'simple-git' +import { pc } from '../../utils/ui.js' +import { openBrowser } from '../../utils/browser.js' +import { resolveStudioClient } from '../client.js' +import { startOAuthServer } from '../auth/oauth-server.js' +import { saveDefaults } from '../auth/credential-store.js' +import type { GitHubInstallation, GitHubRepo } from '../types.js' + +export default defineCommand({ + meta: { + name: 'connect', + description: 'Connect this repository to a Contentrain Studio project', + }, + args: { + workspace: { type: 'string', description: 'Workspace ID', required: false }, + json: { type: 'boolean', description: 'JSON output', required: false }, + }, + async run({ args }) { + if (!args.json) { + intro(pc.bold('contentrain studio connect')) + } + + try { + // ── Stage 1: Authentication ───────────────────────────────────────── + const client = await resolveStudioClient() + + // ── Stage 2: Workspace ────────────────────────────────────────────── + const s1 = args.json ? null : spinner() + s1?.start('Loading workspaces...') + + const workspaces = await client.listWorkspaces() + s1?.stop(`${workspaces.length} workspace(s) found`) + + if (workspaces.length === 0) { + log.warning('No workspaces found. Create one at studio.contentrain.io first.') + outro('') + return + } + + let workspaceId = args.workspace + if (!workspaceId) { + if (workspaces.length === 1) { + workspaceId = workspaces[0]!.id + if (!args.json) { + log.info(`Using workspace: ${pc.cyan(workspaces[0]!.name)}`) + } + } else { + const choice = await select({ + message: 'Select workspace', + options: workspaces.map(w => ({ + value: w.id, + label: w.name, + hint: w.plan, + })), + }) + if (isCancel(choice)) { + outro(pc.dim('Cancelled')) + return + } + workspaceId = choice as string + } + } + + const workspace = workspaces.find(w => w.id === workspaceId)! + + // ── Stage 3: GitHub App ───────────────────────────────────────────── + const s2 = args.json ? null : spinner() + s2?.start('Checking GitHub App installation...') + + let installations = await client.listGitHubInstallations() + s2?.stop( + installations.length > 0 + ? `${installations.length} GitHub installation(s) found` + : 'No GitHub App installed', + ) + + if (installations.length === 0) { + const shouldInstall = await confirm({ + message: 'The Contentrain GitHub App is required. Install it now?', + }) + if (isCancel(shouldInstall) || !shouldInstall) { + outro(pc.dim('Cancelled')) + return + } + + const setupData = await client.getGitHubSetupUrl() + const oauth = await startOAuthServer() + const installUrl = `${setupData.url}&redirect_uri=${encodeURIComponent(oauth.callbackUrl)}&state=${oauth.state}` + + if (!args.json) { + log.info(`If the browser doesn't open, visit:\n ${pc.cyan(installUrl)}`) + } + await openBrowser(installUrl) + + const s2b = args.json ? null : spinner() + s2b?.start('Waiting for GitHub App installation...') + + try { + const result = await oauth.waitForCallback() + if (result.state !== oauth.state) { + s2b?.stop('Failed') + log.error('State mismatch. Please try again.') + process.exitCode = 1 + outro('') + return + } + s2b?.stop('GitHub App installed') + } catch (error) { + s2b?.stop('Failed') + oauth.close() + log.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + outro('') + return + } + + // Re-fetch installations after setup + installations = await client.listGitHubInstallations() + if (installations.length === 0) { + log.error('GitHub App installation not detected. Please try again.') + process.exitCode = 1 + outro('') + return + } + } + + // Select installation + let installation: GitHubInstallation + + if (installations.length === 1) { + installation = installations[0]! + if (!args.json) { + log.info(`Using GitHub account: ${pc.cyan(installation.accountLogin)}`) + } + } else { + const choice = await select({ + message: 'Select GitHub account', + options: installations.map(inst => ({ + value: String(inst.id), + label: inst.accountLogin, + hint: inst.accountType, + })), + }) + if (isCancel(choice)) { + outro(pc.dim('Cancelled')) + return + } + installation = installations.find(i => String(i.id) === choice)! + } + + // ── Stage 4: Repo Detection ──────────────────────────────────────── + const s3 = args.json ? null : spinner() + s3?.start('Detecting git remote...') + + let detectedRepoFullName: string | null = null + + try { + const git = simpleGit(process.cwd()) + const remotes = await git.getRemotes(true) + const origin = remotes.find(r => r.name === 'origin') + + if (origin?.refs?.fetch) { + detectedRepoFullName = parseGitHubRepoFromUrl(origin.refs.fetch) + } + } catch { + // Not a git repo or no remotes — user can pick manually + } + + const repos = await client.listGitHubRepos(installation.id) + s3?.stop( + detectedRepoFullName + ? `Detected: ${pc.cyan(detectedRepoFullName)}` + : `${repos.length} accessible repo(s)`, + ) + + let selectedRepo: GitHubRepo | null = null + + if (detectedRepoFullName) { + const match = repos.find(r => r.fullName === detectedRepoFullName) + if (match) { + const useDetected = await confirm({ + message: `Connect to ${pc.cyan(match.fullName)}?`, + }) + if (isCancel(useDetected)) { + outro(pc.dim('Cancelled')) + return + } + selectedRepo = useDetected ? match : await pickRepo(repos) + } else { + if (!args.json) { + log.warning(`Detected remote "${detectedRepoFullName}" is not accessible to the GitHub App.`) + } + selectedRepo = await pickRepo(repos) + } + } else { + if (repos.length === 0) { + log.error('No repositories accessible. Grant the GitHub App access to your repos.') + process.exitCode = 1 + outro('') + return + } + selectedRepo = await pickRepo(repos) + } + + if (!selectedRepo) { + outro(pc.dim('Cancelled')) + return + } + + // ── Stage 5: Scan ────────────────────────────────────────────────── + const s4 = args.json ? null : spinner() + s4?.start('Scanning repository...') + + const scan = await client.scanRepository(installation.id, selectedRepo.fullName) + s4?.stop(scan.hasContentrain ? 'Contentrain configuration found' : 'No Contentrain configuration found') + + if (!scan.hasContentrain) { + if (!args.json) { + log.warning('No .contentrain/ directory found in the repository.') + log.info(`Run ${pc.cyan('contentrain init')} in your project first, then push to ${selectedRepo.defaultBranch}.`) + } + + const proceed = await confirm({ + message: 'Continue anyway? (You can initialize later)', + }) + if (isCancel(proceed) || !proceed) { + outro(pc.dim('Cancelled')) + return + } + } else if (!args.json) { + log.info(`Found ${scan.models.length} model(s), ${scan.locales.length} locale(s)`) + } + + // ── Stage 6: Create Project ──────────────────────────────────────── + const repoName = selectedRepo.fullName.split('/')[1] ?? selectedRepo.fullName + + const projectName = await text({ + message: 'Project name', + initialValue: repoName, + validate: (v) => { + if (!v.trim()) return 'Name is required' + return undefined + }, + }) + if (isCancel(projectName)) { + outro(pc.dim('Cancelled')) + return + } + + const s5 = args.json ? null : spinner() + s5?.start('Creating project...') + + const project = await client.createProject(workspaceId, { + name: projectName as string, + installationId: installation.id, + repositoryFullName: selectedRepo.fullName, + defaultBranch: selectedRepo.defaultBranch, + }) + + s5?.stop('Project created') + + // Save defaults + await saveDefaults(workspaceId, project.id) + + // ── Output ───────────────────────────────────────────────────────── + if (args.json) { + const output = { + workspace: { id: workspace.id, name: workspace.name }, + project: { id: project.id, name: project.name }, + repository: selectedRepo.fullName, + scan, + } + process.stdout.write(JSON.stringify(output, null, 2)) + return + } + + log.success(pc.bold('Project connected!')) + log.message('') + log.message(` Workspace: ${pc.cyan(workspace.name)}`) + log.message(` Project: ${pc.cyan(project.name)}`) + log.message(` Repository: ${pc.cyan(selectedRepo.fullName)}`) + log.message(` Branch: ${selectedRepo.defaultBranch}`) + log.message('') + log.info('Next steps:') + log.message(` ${pc.cyan('contentrain studio status')} — view project overview`) + log.message(` ${pc.cyan('contentrain studio cdn-init')} — set up content delivery`) + log.message(` ${pc.cyan('contentrain studio webhooks')} — configure webhooks`) + } catch (error) { + log.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + } + + if (!args.json) { + outro('') + } + }, +}) + +// ── Helpers ─────────────────────────────────────────────────────────────── + +/** + * Parse owner/repo from a GitHub remote URL. + * Handles both SSH (git@github.com:owner/repo.git) and HTTPS formats. + */ +export function parseGitHubRepoFromUrl(url: string): string | null { + // SSH format: git@github.com:owner/repo.git + const sshMatch = url.match(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/) + if (sshMatch) return sshMatch[1]! + + // HTTPS format: https://github.com/owner/repo.git + try { + const parsed = new URL(url) + if (parsed.hostname === 'github.com') { + return parsed.pathname.replace(/^\//, '').replace(/\.git$/, '') + } + } catch { + // Not a valid URL + } + + return null +} + +async function pickRepo(repos: GitHubRepo[]): Promise { + if (repos.length === 0) return null + + const choice = await select({ + message: 'Select repository', + options: repos.map(r => ({ + value: r.fullName, + label: r.fullName, + hint: r.private ? 'private' : 'public', + })), + }) + if (isCancel(choice)) return null + return repos.find(r => r.fullName === choice) ?? null +} diff --git a/packages/cli/src/studio/commands/login.ts b/packages/cli/src/studio/commands/login.ts index c1d9538..fc3d2e0 100644 --- a/packages/cli/src/studio/commands/login.ts +++ b/packages/cli/src/studio/commands/login.ts @@ -1,6 +1,7 @@ import { defineCommand } from 'citty' import { intro, outro, log, spinner, select, confirm, isCancel } from '@clack/prompts' import { pc } from '../../utils/ui.js' +import { openBrowser } from '../../utils/browser.js' import { loadCredentials, saveCredentials, checkPermissions } from '../auth/credential-store.js' import { startOAuthServer } from '../auth/oauth-server.js' import { StudioApiClient } from '../client.js' @@ -170,21 +171,3 @@ export default defineCommand({ }, }) -// ── Helpers ─────────────────────────────────────────────────────────────── - -async function openBrowser(url: string): Promise { - const { exec } = await import('node:child_process') - - const command = process.platform === 'darwin' - ? `open "${url}"` - : process.platform === 'win32' - ? `start "" "${url}"` - : `xdg-open "${url}"` - - return new Promise((resolve) => { - exec(command, () => { - // Best-effort — resolve regardless of result - resolve() - }) - }) -} diff --git a/packages/cli/src/studio/index.ts b/packages/cli/src/studio/index.ts index 77bff42..7315c3a 100644 --- a/packages/cli/src/studio/index.ts +++ b/packages/cli/src/studio/index.ts @@ -9,6 +9,7 @@ export default defineCommand({ login: () => import('./commands/login.js').then(m => m.default), logout: () => import('./commands/logout.js').then(m => m.default), whoami: () => import('./commands/whoami.js').then(m => m.default), + connect: () => import('./commands/connect.js').then(m => m.default), status: () => import('./commands/status.js').then(m => m.default), activity: () => import('./commands/activity.js').then(m => m.default), usage: () => import('./commands/usage.js').then(m => m.default), diff --git a/packages/cli/src/studio/types.ts b/packages/cli/src/studio/types.ts index 9fc64bb..913dd0b 100644 --- a/packages/cli/src/studio/types.ts +++ b/packages/cli/src/studio/types.ts @@ -53,6 +53,42 @@ export interface Project { memberCount: number } +// ── GitHub Integration ─────────────────────────────────────────────────── + +export interface GitHubInstallation { + id: number + accountLogin: string + accountType: 'User' | 'Organization' + avatarUrl: string | null + appSlug: string +} + +export interface GitHubRepo { + id: number + fullName: string + private: boolean + defaultBranch: string + htmlUrl: string +} + +export interface ScanResult { + hasContentrain: boolean + models: string[] + locales: string[] + configPath: string | null +} + +export interface CreateProjectPayload { + name: string + installationId: number + repositoryFullName: string + defaultBranch: string +} + +export interface GitHubSetupUrl { + url: string +} + // ── Branches ────────────────────────────────────────────────────────────── export interface Branch { diff --git a/packages/cli/src/utils/browser.ts b/packages/cli/src/utils/browser.ts new file mode 100644 index 0000000..537b193 --- /dev/null +++ b/packages/cli/src/utils/browser.ts @@ -0,0 +1,19 @@ +/** + * Open a URL in the user's default browser (best-effort, platform-aware). + */ +export async function openBrowser(url: string): Promise { + const { exec } = await import('node:child_process') + + const command = process.platform === 'darwin' + ? `open "${url}"` + : process.platform === 'win32' + ? `start "" "${url}"` + : `xdg-open "${url}"` + + return new Promise((resolve) => { + exec(command, () => { + // Best-effort — resolve regardless of result + resolve() + }) + }) +} diff --git a/packages/cli/tests/integration/validate.integration.test.ts b/packages/cli/tests/integration/validate.integration.test.ts index 9a5097b..a3eee22 100644 --- a/packages/cli/tests/integration/validate.integration.test.ts +++ b/packages/cli/tests/integration/validate.integration.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { join } from 'node:path' import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { spawnSync } from 'node:child_process' import { tmpdir } from 'node:os' import { simpleGit } from 'simple-git' import { writeJson, pathExists } from '@contentrain/mcp/util/fs' @@ -82,9 +83,11 @@ async function createActiveContentrainBranches(dir: string, count: number): Prom const sourceHead = (await git.revparse(['HEAD'])).trim() await git.checkout(baseBranch) - for (let i = 0; i < count; i++) { - await git.raw(['branch', `cr/review/test-${i}`, sourceHead]) - } + // Batch-create all branches in a single git process via update-ref --stdin + const input = Array.from({ length: count }, (_, i) => + `create refs/heads/cr/review/test-${i} ${sourceHead}`, + ).join('\n') + '\n' + spawnSync('git', ['update-ref', '--stdin', '--no-deref'], { input, cwd: dir }) await git.deleteLocalBranch('contentrain-source', true) } diff --git a/packages/cli/tests/studio/client.test.ts b/packages/cli/tests/studio/client.test.ts index 65afc3f..3bb0865 100644 --- a/packages/cli/tests/studio/client.test.ts +++ b/packages/cli/tests/studio/client.test.ts @@ -213,6 +213,159 @@ describe('StudioApiClient', () => { ) }) + it('createProject sends POST with payload', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + id: 'proj-new', + name: 'My Project', + slug: 'my-project', + stack: 'nuxt', + repositoryUrl: 'https://github.com/owner/repo', + memberCount: 1, + }), + }) + + const { StudioApiClient } = await import('../../src/studio/client.js') + const client = new StudioApiClient({ + studioUrl: 'https://studio.test.io', + accessToken: 'token', + refreshToken: 'refresh', + expiresAt: '2040-01-01T00:00:00Z', + }) + + const project = await client.createProject('ws-1', { + name: 'My Project', + installationId: 12345, + repositoryFullName: 'owner/repo', + defaultBranch: 'main', + }) + + expect(project.id).toBe('proj-new') + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('/api/workspaces/ws-1/projects'), + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + name: 'My Project', + installationId: 12345, + repositoryFullName: 'owner/repo', + defaultBranch: 'main', + }), + }), + ) + }) + + it('listGitHubInstallations fetches installations', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ([ + { id: 1, accountLogin: 'myorg', accountType: 'Organization', avatarUrl: null, appSlug: 'contentrain' }, + ]), + }) + + const { StudioApiClient } = await import('../../src/studio/client.js') + const client = new StudioApiClient({ + studioUrl: 'https://studio.test.io', + accessToken: 'token', + refreshToken: 'refresh', + expiresAt: '2040-01-01T00:00:00Z', + }) + + const installations = await client.listGitHubInstallations() + + expect(installations).toHaveLength(1) + expect(installations[0]!.accountLogin).toBe('myorg') + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('/api/github/installations'), + expect.anything(), + ) + }) + + it('getGitHubSetupUrl fetches setup URL', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ url: 'https://github.com/apps/contentrain/installations/new' }), + }) + + const { StudioApiClient } = await import('../../src/studio/client.js') + const client = new StudioApiClient({ + studioUrl: 'https://studio.test.io', + accessToken: 'token', + refreshToken: 'refresh', + expiresAt: '2040-01-01T00:00:00Z', + }) + + const result = await client.getGitHubSetupUrl() + + expect(result.url).toContain('github.com') + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('/api/github/setup'), + expect.anything(), + ) + }) + + it('listGitHubRepos passes installationId as query param', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ([ + { id: 100, fullName: 'owner/repo', private: false, defaultBranch: 'main', htmlUrl: 'https://github.com/owner/repo' }, + ]), + }) + + const { StudioApiClient } = await import('../../src/studio/client.js') + const client = new StudioApiClient({ + studioUrl: 'https://studio.test.io', + accessToken: 'token', + refreshToken: 'refresh', + expiresAt: '2040-01-01T00:00:00Z', + }) + + const repos = await client.listGitHubRepos(12345) + + expect(repos).toHaveLength(1) + expect(repos[0]!.fullName).toBe('owner/repo') + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('installationId=12345'), + expect.anything(), + ) + }) + + it('scanRepository passes installationId and repo as query params', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + hasContentrain: true, + models: ['blog-posts', 'authors'], + locales: ['en', 'tr'], + configPath: '.contentrain/config.json', + }), + }) + + const { StudioApiClient } = await import('../../src/studio/client.js') + const client = new StudioApiClient({ + studioUrl: 'https://studio.test.io', + accessToken: 'token', + refreshToken: 'refresh', + expiresAt: '2040-01-01T00:00:00Z', + }) + + const scan = await client.scanRepository(12345, 'owner/repo') + + expect(scan.hasContentrain).toBe(true) + expect(scan.models).toEqual(['blog-posts', 'authors']) + expect(scan.locales).toEqual(['en', 'tr']) + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('installationId=12345'), + expect.anything(), + ) + }) + it('resolveStudioClient throws when not logged in', async () => { vi.resetModules() vi.doMock('../../src/studio/auth/credential-store.js', () => ({ diff --git a/packages/cli/tests/studio/commands/connect.test.ts b/packages/cli/tests/studio/commands/connect.test.ts new file mode 100644 index 0000000..060db0f --- /dev/null +++ b/packages/cli/tests/studio/commands/connect.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' + +vi.mock('../../../src/studio/client.js', () => ({ + resolveStudioClient: vi.fn().mockResolvedValue({ + listWorkspaces: vi.fn().mockResolvedValue([ + { id: 'ws-1', name: 'Acme Corp', slug: 'acme', plan: 'pro', role: 'owner' }, + ]), + listGitHubInstallations: vi.fn().mockResolvedValue([ + { id: 1, accountLogin: 'acme', accountType: 'Organization', avatarUrl: null, appSlug: 'contentrain' }, + ]), + listGitHubRepos: vi.fn().mockResolvedValue([ + { id: 100, fullName: 'acme/website', private: false, defaultBranch: 'main', htmlUrl: 'https://github.com/acme/website' }, + ]), + scanRepository: vi.fn().mockResolvedValue({ + hasContentrain: true, + models: ['blog-posts'], + locales: ['en'], + configPath: '.contentrain/config.json', + }), + createProject: vi.fn().mockResolvedValue({ + id: 'proj-new', + name: 'website', + slug: 'website', + stack: 'nuxt', + repositoryUrl: 'https://github.com/acme/website', + memberCount: 1, + }), + }), +})) + +vi.mock('../../../src/studio/auth/credential-store.js', () => ({ + saveDefaults: vi.fn().mockResolvedValue(undefined), + loadCredentials: vi.fn().mockResolvedValue({ + studioUrl: 'https://studio.test.io', + accessToken: 'test-token', + refreshToken: 'test-refresh', + expiresAt: '2040-01-01T00:00:00Z', + }), +})) + +vi.mock('../../../src/studio/auth/oauth-server.js', () => ({ + startOAuthServer: vi.fn(), +})) + +vi.mock('../../../src/utils/browser.js', () => ({ + openBrowser: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock('simple-git', () => ({ + simpleGit: vi.fn().mockReturnValue({ + getRemotes: vi.fn().mockResolvedValue([ + { name: 'origin', refs: { fetch: 'https://github.com/acme/website.git', push: 'https://github.com/acme/website.git' } }, + ]), + }), +})) + +vi.mock('@clack/prompts', () => ({ + intro: vi.fn(), + outro: vi.fn(), + log: { message: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }, + spinner: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })), + select: vi.fn().mockResolvedValue('ws-1'), + confirm: vi.fn().mockResolvedValue(true), + isCancel: vi.fn().mockReturnValue(false), + text: vi.fn().mockResolvedValue('website'), +})) + +describe('studio connect command', () => { + afterEach(() => { + vi.clearAllMocks() + }) + + it('module loads and has correct metadata', async () => { + const mod = await import('../../../src/studio/commands/connect.js') + expect(mod.default).toBeDefined() + expect(mod.default.meta?.name).toBe('connect') + }) + + it('supports --workspace and --json args', async () => { + const mod = await import('../../../src/studio/commands/connect.js') + expect(mod.default.args?.workspace?.type).toBe('string') + expect(mod.default.args?.json?.type).toBe('boolean') + }) + + it('outputs valid JSON in json mode', async () => { + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + + const mod = await import('../../../src/studio/commands/connect.js') + await mod.default.run?.({ args: { json: true } } as Parameters>[0]) + + expect(writeSpy).toHaveBeenCalled() + const output = JSON.parse(String(writeSpy.mock.calls.at(-1)?.[0] ?? '{}')) as Record + + expect(output['workspace']).toBeDefined() + expect(output['project']).toBeDefined() + expect(output['repository']).toBe('acme/website') + + writeSpy.mockRestore() + }) +}) + +describe('parseGitHubRepoFromUrl', () => { + it('parses HTTPS URLs with .git suffix', async () => { + const { parseGitHubRepoFromUrl } = await import('../../../src/studio/commands/connect.js') + expect(parseGitHubRepoFromUrl('https://github.com/owner/repo.git')).toBe('owner/repo') + }) + + it('parses HTTPS URLs without .git suffix', async () => { + const { parseGitHubRepoFromUrl } = await import('../../../src/studio/commands/connect.js') + expect(parseGitHubRepoFromUrl('https://github.com/owner/repo')).toBe('owner/repo') + }) + + it('parses SSH URLs', async () => { + const { parseGitHubRepoFromUrl } = await import('../../../src/studio/commands/connect.js') + expect(parseGitHubRepoFromUrl('git@github.com:owner/repo.git')).toBe('owner/repo') + }) + + it('parses SSH URLs without .git suffix', async () => { + const { parseGitHubRepoFromUrl } = await import('../../../src/studio/commands/connect.js') + expect(parseGitHubRepoFromUrl('git@github.com:owner/repo')).toBe('owner/repo') + }) + + it('returns null for non-GitHub URLs', async () => { + const { parseGitHubRepoFromUrl } = await import('../../../src/studio/commands/connect.js') + expect(parseGitHubRepoFromUrl('https://gitlab.com/owner/repo')).toBeNull() + }) + + it('returns null for invalid URLs', async () => { + const { parseGitHubRepoFromUrl } = await import('../../../src/studio/commands/connect.js') + expect(parseGitHubRepoFromUrl('not-a-url')).toBeNull() + }) +})