diff --git a/.doghouse/workspace.json b/.doghouse/workspace.json new file mode 100644 index 0000000..ea9d5b5 --- /dev/null +++ b/.doghouse/workspace.json @@ -0,0 +1,16 @@ +{ + "version": 1, + "workspace": { + "id": "dotdog", + "name": "dotdog" + }, + "repos": [ + { + "alias": "dotdog", + "role": "cli", + "path": ".." + } + ], + "groups": [], + "edges": [] +} diff --git a/package.json b/package.json index b962ec3..172ce7c 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,16 @@ { - "name": "spec-platform", + "name": "dotdog-workspace", "version": "0.1.0", "private": true, - "description": "Spec genome platform — validate, simulate, generate code. MCP server for AI agents. $0 stack.", + "description": "Private workspace for the dotdog CLI package.", "workspaces": [ "packages/*" ], "scripts": { - "dev": "bun --cwd packages/spec-api dev", "build": "bun run --filter '*' build", "lint": "bun run --filter '*' lint", "test": "bun test", + "pack:check": "node scripts/check-pack.mjs", "validate": "bun packages/spec-cli/src/index.ts validate" }, "dependencies": { diff --git a/packages/dotdog/__tests__/cli.test.ts b/packages/dotdog/__tests__/cli.test.ts index c6bb04c..53feba7 100644 --- a/packages/dotdog/__tests__/cli.test.ts +++ b/packages/dotdog/__tests__/cli.test.ts @@ -170,4 +170,71 @@ describe('CLI', () => { rmSync(dir, { recursive: true, force: true }); } }); + + test('serve reads compiled .doghouse graph artifacts', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dotdog-test-serve-compiled-')); + try { + mkdirSync(join(dir, '.doghouse', 'semantic'), { recursive: true }); + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'compiled-graph-app', version: '1.0.0' }, null, 2)); + writeFileSync(join(dir, 'railway.json'), JSON.stringify({ startCommand: 'bun start' }, null, 2)); + writeFileSync(join(dir, '.doghouse', 'semantic', 'deployment.dog'), [ + '## Deployment', + '', + '### Entity: Deployment', + '', + 'A generic deployment capability.', + '', + '```yaml', + 'entity: Deployment', + 'type: external', + '```', + '', + '### Entity: RailwayService', + '', + 'A generic Railway deployment service.', + '', + '```yaml', + 'entity: RailwayService', + 'type: external', + '```', + '', + '### Relationship: Deployment → RailwayService', + '', + '```yaml', + 'relationship: Deployment → RailwayService', + 'source: Deployment', + 'target: RailwayService', + 'verb: includes', + '```', + ].join('\n')); + + await $`cd ${dir} && ${BUN} ${ROOT}/packages/dotdog/src/cli.ts map . --project compiled-graph-app`.quiet(); + await $`cd ${dir} && ${BUN} ${ROOT}/packages/dotdog/src/cli.ts compile`.quiet(); + expect(existsSync(join(dir, '.doghouse', 'compiled', 'repo.dag'))).toBe(true); + + const proc = Bun.spawn([BUN, join(ROOT, 'packages/dotdog/src/cli.ts'), 'serve'], { + stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', + cwd: dir, + }); + proc.stdin.write(JSON.stringify({jsonrpc:'2.0',id:1,method:'initialize',params:{}})+'\n'); + await new Promise(r => setTimeout(r, 1000)); + proc.stdin.write(JSON.stringify({jsonrpc:'2.0',id:2,method:'tools/call',params:{name:'getEntity',arguments:{name:'Deployment'}}})+'\n'); + proc.stdin.write(JSON.stringify({jsonrpc:'2.0',id:3,method:'tools/call',params:{name:'traverse',arguments:{from:'Deployment',depth:1}}})+'\n'); + proc.stdin.end(); + const out = await new Response(proc.stdout).text(); + proc.kill(); + + const lines = out.split('\n').filter(l => l.trim()); + expect(lines.length).toBeGreaterThanOrEqual(3); + const entity = JSON.parse(JSON.parse(lines[1]).result.content[0].text); + expect(entity.name).toBe('Deployment'); + expect(entity.type).toBe('external'); + expect(entity.edges.some((edge: any) => edge[0] === 'RailwayService' && edge[1] === 'includes')).toBe(true); + const graph = JSON.parse(JSON.parse(lines[2]).result.content[0].text); + expect(graph.nodes.some((node: any) => node.name === 'Deployment')).toBe(true); + expect(graph.nodes.some((node: any) => node.edges.some((edge: string) => edge.startsWith('RailwayService:includes')))).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); }); diff --git a/packages/dotdog/__tests__/commands.test.ts b/packages/dotdog/__tests__/commands.test.ts index 75644f7..ef9ed52 100644 --- a/packages/dotdog/__tests__/commands.test.ts +++ b/packages/dotdog/__tests__/commands.test.ts @@ -130,8 +130,8 @@ describe('untested commands', () => { setupTempProject(dir, 'testproj'); const out = await $`cd ${dir} && ${BUN} ${ROOT}/packages/dotdog/src/cli.ts map . --project repo-world-test`.text(); expect(out).toContain('repo.dag'); - expect(existsSync(join(dir, '.dotdog', 'generated', 'repo-map.dog'))).toBe(true); - expect(existsSync(join(dir, '.dotdog', 'generated', 'repo.dag'))).toBe(true); + expect(existsSync(join(dir, '.doghouse', 'generated', 'repo-map.dog'))).toBe(true); + expect(existsSync(join(dir, '.doghouse', 'generated', 'repo.dag'))).toBe(true); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -142,7 +142,7 @@ describe('untested commands', () => { try { setupTempProject(dir, 'testproj'); await $`cd ${dir} && ${BUN} ${ROOT}/packages/dotdog/src/cli.ts map . --project repo-world-test`.quiet(); - const dag = join(dir, '.dotdog', 'generated', 'repo.dag'); + const dag = join(dir, '.doghouse', 'generated', 'repo.dag'); const out = await $`cd ${dir} && ${BUN} ${ROOT}/packages/dotdog/src/cli.ts query repository --dag ${dag}`.text(); expect(out.toLowerCase()).toContain('repository'); } finally { diff --git a/packages/dotdog/__tests__/regression.test.ts b/packages/dotdog/__tests__/regression.test.ts index 90fd63f..77074eb 100644 --- a/packages/dotdog/__tests__/regression.test.ts +++ b/packages/dotdog/__tests__/regression.test.ts @@ -71,10 +71,10 @@ describe('regression', () => { test('semantic deployment nodes survive map and compile remap', async () => { const dir = mkdtempSync(join(tmpdir(), 'dotdog-test-layers-')); try { - mkdirSync(join(dir, '.dotdog', 'semantic'), { recursive: true }); + mkdirSync(join(dir, '.doghouse', 'semantic'), { recursive: true }); writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'example-web-app', version: '1.0.0' }, null, 2)); writeFileSync(join(dir, 'railway.json'), JSON.stringify({ startCommand: 'npm start' }, null, 2)); - writeFileSync(join(dir, '.dotdog', 'semantic', 'deployment.dog'), [ + writeFileSync(join(dir, '.doghouse', 'semantic', 'deployment.dog'), [ '## Deployment', '', '### Entity: Deployment', @@ -119,7 +119,7 @@ describe('regression', () => { await $`cd ${dir} && ${BUN} ${ROOT}/packages/dotdog/src/cli.ts map`.quiet(); await $`cd ${dir} && ${BUN} ${ROOT}/packages/dotdog/src/cli.ts compile`.quiet(); - const compiled = JSON.parse(readFileSync(join(dir, '.dotdog', 'compiled', 'repo.dag'), 'utf-8')); + const compiled = JSON.parse(readFileSync(join(dir, '.doghouse', 'compiled', 'repo.dag'), 'utf-8')); const labels = compiled.nodes.map((node: any) => node.label); expect(labels).toContain('Deployment'); expect(labels).toContain('RailwayService'); diff --git a/packages/dotdog/__tests__/workspace.test.ts b/packages/dotdog/__tests__/workspace.test.ts new file mode 100644 index 0000000..cd95b78 --- /dev/null +++ b/packages/dotdog/__tests__/workspace.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from 'bun:test'; +import { mkdtempSync, mkdirSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import path from 'path'; +import { buildWorkspaceGraph } from '../src/workspace/graph'; +import { resolveWorkspace } from '../src/workspace/resolver'; +import { validateWorkspaceConfig } from '../src/workspace/validator'; +import { isIgnoredRepoPath, resolveUserPath } from '../src/workspace/paths'; + +function fixtureWorkspace() { + const root = mkdtempSync(path.join(tmpdir(), 'dotdog-workspace-')); + const doghouse = path.join(root, '.doghouse'); + const repos = path.join(root, 'repos'); + mkdirSync(path.join(repos, 'example-web'), { recursive: true }); + mkdirSync(path.join(repos, 'example-api'), { recursive: true }); + mkdirSync(path.join(repos, 'example-worker'), { recursive: true }); + mkdirSync(doghouse, { recursive: true }); + writeFileSync(path.join(doghouse, 'workspace.json'), JSON.stringify({ + version: 1, + workspace: { id: 'example-product', name: 'example-product' }, + repos: [ + { alias: 'example-web', role: 'web', path: '../repos/example-web' }, + { alias: 'example-api', role: 'api', path: '../repos/example-api' }, + { alias: 'example-worker', role: 'worker', path: '../repos/example-worker' }, + ], + groups: [{ name: 'checkout', repos: ['example-web', 'example-api', 'example-worker'] }], + edges: [ + { from: 'example-web', to: 'example-api', type: 'http' }, + { from: 'example-api', to: 'example-worker', type: 'event' }, + ], + }, null, 2)); + return root; +} + +describe('workspace bridge', () => { + test('validates duplicate aliases and unknown edges', () => { + const result = validateWorkspaceConfig({ + version: 1, + workspace: { id: 'example-product' }, + repos: [ + { alias: 'example-api', path: '.' }, + { alias: 'example-api', path: '.' }, + ], + edges: [{ from: 'example-api', to: 'example-worker', type: 'http' }], + }); + expect(result.valid).toBe(false); + expect(result.errors.map((error) => error.code)).toContain('duplicate_repo_alias'); + expect(result.errors.map((error) => error.code)).toContain('unknown_edge_to'); + }); + + test('loads a workspace manifest and builds deterministic graph facts', () => { + const root = fixtureWorkspace(); + const context = resolveWorkspace(root); + expect(context.mode).toBe('workspace'); + expect(context.repos.map((repo) => repo.alias)).toEqual(['example-web', 'example-api', 'example-worker']); + const graph = buildWorkspaceGraph(context); + expect(graph.workspace).toBe('example-product'); + expect(graph.nodes.map((node) => node.id)).toContain('repo:example-api'); + expect(graph.edges.map((edge) => edge.type)).toContain('http'); + }); + + test('blocks parent path traversal and ignores secret paths', () => { + const root = fixtureWorkspace(); + expect(() => resolveUserPath('..', root)).toThrow(); + expect(isIgnoredRepoPath('.env')).toBe(true); + expect(isIgnoredRepoPath('src/index.ts')).toBe(false); + }); +}); diff --git a/packages/dotdog/package.json b/packages/dotdog/package.json index 3933392..0a96dd4 100644 --- a/packages/dotdog/package.json +++ b/packages/dotdog/package.json @@ -20,7 +20,6 @@ "ai", "bun", "cli", - "code-generation", "dag", "graph", "knowledge-graph", @@ -29,7 +28,6 @@ "mcp", "mcp-server", "spec-driven-development", - "spec-genome", "specification", "structured", "typescript", diff --git a/packages/dotdog/src/cli.ts b/packages/dotdog/src/cli.ts index d315978..c890010 100644 --- a/packages/dotdog/src/cli.ts +++ b/packages/dotdog/src/cli.ts @@ -2,16 +2,19 @@ import { Command } from 'commander'; import chalk from 'chalk'; import { existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync, statSync } from 'fs'; -import { join, resolve } from 'path'; +import { join, relative, resolve } from 'path'; import { buildIndex, searchIndex } from './index'; -import { homedir } from 'os'; +import { resolveUserPath } from './workspace/paths'; import { createHash } from 'crypto'; -import { execSync } from 'child_process'; +import { execFileSync } from 'child_process'; import type { DocumentNode, SectionNode, BlockNode, EntityNode, RelationshipNode, ProseNode, TableNode, PropertyDef } from './grammar'; import { parse } from './parser'; import { safeProjectName, writeRepoMap } from './map/repoMapper'; import { formatQueryResult, formatTrace, loadWorldModel, queryWorldModel, traceWorldNode } from './dag/query'; import { compileDotdogLayers } from './dag/layers'; +import { buildWorkspaceGraph } from './workspace/graph'; +import { resolveWorkspace, WORKSPACE_MANIFEST } from './workspace/resolver'; +import { validateWorkspaceConfig } from './workspace/validator'; function normalizeDag(dag: any): any { @@ -32,34 +35,19 @@ function normalizeDag(dag: any): any { } function resolvePath(p: string): string { - if (p.startsWith('~')) p = join(homedir(), p.slice(1)); - const resolved = p.startsWith('/') ? p : join(process.cwd(), p); - // Prevent traversal outside working directory for relative paths. - // Allow descendants (cdw/child), same dir (cwd), and ancestors (parent of cwd). - if (!p.startsWith('/') && !p.startsWith('~')) { - const rel = resolve(process.cwd(), p); - const cwd = process.cwd(); - const isDescendant = rel.startsWith(cwd + '/'); - const isSelf = rel === cwd; - const isAncestor = cwd.startsWith(rel + '/'); - if (!isDescendant && !isSelf && !isAncestor) { - throw new Error(`Path traversal blocked: ${p}`); - } - return rel; - } - return resolved; + return resolveUserPath(p, process.cwd()); } function githubRemote(repoDir = process.cwd()): string | null { try { - const remote = execSync('git remote get-url origin', { cwd: repoDir, encoding: 'utf8' }).trim(); + const remote = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd: repoDir, encoding: 'utf8' }).trim(); const m = remote.match(/github\.com[:/]([^/]+)\/([^/.]+)(?:\.git)?$/); return m ? `${m[1]}/${m[2]}` : null; } catch { return null; } } function ghIssues(repo: string): Array<{ number:number; title:string; state:string; body:string }> { - const raw = execSync(`gh issue list --repo ${repo} --state all --limit 100 --json number,title,state,body`, { encoding: 'utf8' }).trim(); + const raw = execFileSync('gh', ['issue', 'list', '--repo', repo, '--state', 'all', '--limit', '100', '--json', 'number,title,state,body'], { encoding: 'utf8' }).trim(); return JSON.parse(raw || '[]'); } @@ -1701,6 +1689,116 @@ function diffBody(expected: Record, actual: Record', 'workspace id') + .option('--name ', 'workspace display name') + .option('--force', 'overwrite an existing manifest') + .action((opts) => { + const doghouseDir = join(process.cwd(), '.doghouse'); + const manifestPath = join(process.cwd(), WORKSPACE_MANIFEST); + if (existsSync(manifestPath) && !opts.force) { + console.error(chalk.red(`Workspace manifest already exists: ${WORKSPACE_MANIFEST}`)); + process.exitCode = 1; + return; + } + mkdirSync(doghouseDir, { recursive: true }); + const repoAlias = safeProjectName(process.cwd()); + const config = { + version: 1, + workspace: { id: opts.id, name: opts.name || opts.id }, + repos: [{ alias: repoAlias, role: 'unknown', path: '..' }], + groups: [], + edges: [], + }; + writeFileSync(manifestPath, `${JSON.stringify(config, null, 2)}\n`); + console.log(`Workspace initialized: ${WORKSPACE_MANIFEST}`); + }); + +workspaceCmd + .command('add ') + .description('Add a repo to .doghouse/workspace.json') + .requiredOption('--alias ', 'repo alias') + .option('--role ', 'repo role', 'unknown') + .option('--remote ', 'repo remote') + .option('--default-branch ', 'default branch') + .action((repoPath, opts) => { + const manifestPath = join(process.cwd(), WORKSPACE_MANIFEST); + if (!existsSync(manifestPath)) { + console.error(chalk.red(`No ${WORKSPACE_MANIFEST} found. Run: dotdog workspace init --id `)); + process.exitCode = 1; + return; + } + const config = JSON.parse(readFileSync(manifestPath, 'utf-8')); + const resolvedRepoPath = resolve(process.cwd(), repoPath); + const manifestRepoPath = relative(join(process.cwd(), '.doghouse'), resolvedRepoPath) || '.'; + if (!existsSync(resolvedRepoPath) || !statSync(resolvedRepoPath).isDirectory()) { + console.error(chalk.red(`Repo path is not a directory: ${repoPath}`)); + process.exitCode = 1; + return; + } + config.repos = config.repos || []; + config.repos.push({ alias: opts.alias, role: opts.role, path: manifestRepoPath, ...(opts.remote ? { remote: opts.remote } : {}), ...(opts.defaultBranch ? { defaultBranch: opts.defaultBranch } : {}) }); + const validation = validateWorkspaceConfig(config, { manifestDir: join(process.cwd(), '.doghouse'), checkPaths: true }); + if (!validation.valid) { + console.error(chalk.red('Invalid workspace manifest:')); + for (const error of validation.errors) console.error(` - ${error.message}`); + process.exitCode = 1; + return; + } + writeFileSync(manifestPath, `${JSON.stringify(config, null, 2)}\n`); + console.log(`Added repo ${opts.alias}`); + }); + +workspaceCmd + .command('list') + .description('List workspace repos and groups') + .option('--json', 'print JSON') + .action((opts) => { + const context = resolveWorkspace(process.cwd(), { requireManifest: false }); + const data = { workspace: context.config.workspace, mode: context.mode, repos: context.repos.map((repo) => ({ alias: repo.alias, role: repo.role, cwd: repo.cwd })), groups: context.config.groups || [] }; + if (opts.json) { + console.log(JSON.stringify(data, null, 2)); + return; + } + console.log(`${data.workspace.id} (${data.mode})`); + for (const repo of data.repos) console.log(` ${repo.alias}\t${repo.role || 'unknown'}\t${repo.cwd}`); + if (data.groups.length) console.log('\nGroups:'); + for (const group of data.groups) console.log(` ${group.name}\t${group.repos.join(', ')}`); + }); + +workspaceCmd + .command('validate') + .description('Validate .doghouse/workspace.json') + .option('--json', 'print JSON') + .action((opts) => { + try { + const context = resolveWorkspace(process.cwd(), { requireManifest: true }); + const result = validateWorkspaceConfig(context.config, { manifestDir: join(process.cwd(), '.doghouse'), checkPaths: true }); + if (opts.json) console.log(JSON.stringify(result, null, 2)); + else console.log(result.valid ? chalk.green('Workspace valid') : chalk.red('Workspace invalid')); + if (!result.valid) { + for (const error of result.errors) console.error(` - ${error.message}`); + process.exitCode = 1; + } + } catch (error) { + console.error(chalk.red(String(error instanceof Error ? error.message : error))); + process.exitCode = 1; + } + }); + +workspaceCmd + .command('graph') + .description('Print workspace graph JSON') + .option('--json', 'print JSON', true) + .action(() => { + const context = resolveWorkspace(process.cwd(), { requireManifest: false }); + console.log(JSON.stringify(buildWorkspaceGraph(context), null, 2)); + }); + program .command('map [dir]') .description('Map a repository into a machine-readable repo.dag world model') @@ -1708,7 +1806,7 @@ program .option('--json', 'print write result as JSON') .action((dir = '.', opts) => { const projectName = opts.project || safeProjectName(resolve(dir)); - const specDir = resolve(dir, '.dotdog', 'generated'); + const specDir = resolve(dir, '.doghouse', 'generated'); const result = writeRepoMap(resolve(dir), projectName, specDir); if (opts.json) { console.log(JSON.stringify(result, null, 2)); @@ -1721,7 +1819,7 @@ program program .command('query ') .description('Query a repo.dag world model') - .option('--dag ', 'path to repo.dag', '.dotdog/compiled/repo.dag') + .option('--dag ', 'path to repo.dag', '.doghouse/compiled/repo.dag') .option('-l, --limit ', 'max results', '10') .action((term, opts) => { const world = loadWorldModel(resolve(opts.dag)); @@ -1732,7 +1830,7 @@ program program .command('trace ') .description('Trace repo.dag relationships for a node') - .option('--dag ', 'path to repo.dag', '.dotdog/compiled/repo.dag') + .option('--dag ', 'path to repo.dag', '.doghouse/compiled/repo.dag') .option('-d, --depth ', 'trace depth', '2') .action((node, opts) => { const world = loadWorldModel(resolve(opts.dag)); diff --git a/packages/dotdog/src/dag/layers.ts b/packages/dotdog/src/dag/layers.ts index 31f5ab2..75a8c25 100644 --- a/packages/dotdog/src/dag/layers.ts +++ b/packages/dotdog/src/dag/layers.ts @@ -163,10 +163,10 @@ function loadDogLayer(dir: string, originType: 'semantic' | 'overlay', labelToId } export function compileDotdogLayers(root: string, project: string): LayerCompileResult | null { - const dotdogDir = join(root, '.dotdog'); - const generatedFile = join(dotdogDir, 'generated', 'repo.dag'); - const semanticDir = join(dotdogDir, 'semantic'); - const overlayDir = join(dotdogDir, 'overlays'); + const doghouseDir = join(root, '.doghouse'); + const generatedFile = join(doghouseDir, 'generated', 'repo.dag'); + const semanticDir = join(doghouseDir, 'semantic'); + const overlayDir = join(doghouseDir, 'overlays'); if (!existsSync(generatedFile) && !existsSync(semanticDir) && !existsSync(overlayDir)) return null; const base = readGeneratedWorld(generatedFile, project, root); @@ -198,7 +198,7 @@ export function compileDotdogLayers(root: string, project: string): LayerCompile unknowns: [...base.unknowns, ...semantic.unknowns, ...overlay.unknowns], }; - const compiledDir = join(dotdogDir, 'compiled'); + const compiledDir = join(doghouseDir, 'compiled'); mkdirSync(compiledDir, { recursive: true }); const outFile = join(compiledDir, 'repo.dag'); writeFileSync(outFile, serializeWorldModel(compiled)); diff --git a/packages/dotdog/src/infra/providers/aws.ts b/packages/dotdog/src/infra/providers/aws.ts index 8715eec..905a308 100644 --- a/packages/dotdog/src/infra/providers/aws.ts +++ b/packages/dotdog/src/infra/providers/aws.ts @@ -5,27 +5,28 @@ // Auth: AWS_PROFILE env var or ~/.aws/credentials (handled by aws CLI) // Community MCP: aws-s3-mcp (samuraikun/aws-s3-mcp) -import { execSync } from 'child_process'; +import { execFileSync } from 'child_process'; +import { redactSecrets } from '../../workspace/redact'; import type { InfraResource, CheckResult, Provider } from './types'; import { connectStdio, type MCPConnection, type MCPTool } from '../mcp-client'; -function aws(args: string): { ok: boolean; output: string } { +function aws(args: string[]): { ok: boolean; output: string } { try { - const result = execSync(`aws ${args} --no-cli-pager --output json 2>&1`, { + const result = execFileSync('aws', [...args, '--no-cli-pager', '--output', 'json'], { encoding: 'utf-8', timeout: 20000, env: { ...process.env }, }); - return { ok: true, output: result.trim() }; + return { ok: true, output: redactSecrets(result.trim()) }; } catch (e: unknown) { const err = e as { stdout?: string; stderr?: string; message?: string }; const output = String(err.stdout || err.stderr || err.message || ''); - return { ok: false, output }; + return { ok: false, output: redactSecrets(output) }; } } function hasAwsCli(): boolean { - try { execSync('which aws', { encoding: 'utf-8' }); return true; } catch { return false; } + try { execFileSync('aws', ['--version'], { encoding: 'utf-8' }); return true; } catch { return false; } } async function verifyResource(resource: InfraResource): Promise { @@ -38,17 +39,17 @@ async function verifyResource(resource: InfraResource): Promise { return { entity: resource.entity, provider: 'aws', resource: resource.resource, status: 'skip', message: 'aws CLI not installed' }; } - const region = resource.region ? ` --region ${resource.region}` : ''; + const regionArgs = resource.region ? ['--region', resource.region] : []; if (type === 's3') { // aws s3api head-bucket checks existence - const { ok, output } = aws(`s3api head-bucket --bucket ${name}${region}`); + const { ok, output } = aws(['s3api', 'head-bucket', '--bucket', name, ...regionArgs]); if (ok) { // Get approximate object count for detail let detail = ''; try { - const sizeResult = aws(`s3 ls --summarize --human-readable --recursive s3://${name}${region} 2>&1 | tail -3`); - detail = sizeResult.output.replace(/\n/g, ' ').trim().slice(0, 120); + const sizeResult = aws(['s3', 'ls', '--summarize', '--human-readable', '--recursive', `s3://${name}`, ...regionArgs]); + detail = sizeResult.output.split('\n').slice(-3).join(' ').trim().slice(0, 120); } catch {} return { entity: resource.entity, provider: 'aws', resource: resource.resource, status: 'pass', message: 'exists', detail }; } @@ -60,7 +61,7 @@ async function verifyResource(resource: InfraResource): Promise { } if (type === 'lambda') { - const { ok, output } = aws(`lambda get-function --function-name ${name}${region}`); + const { ok, output } = aws(['lambda', 'get-function', '--function-name', name, ...regionArgs]); if (ok) { try { const d = JSON.parse(output); @@ -78,7 +79,7 @@ async function verifyResource(resource: InfraResource): Promise { } if (type === 'rds') { - const { ok, output } = aws(`rds describe-db-instances --db-instance-identifier ${name}${region}`); + const { ok, output } = aws(['rds', 'describe-db-instances', '--db-instance-identifier', name, ...regionArgs]); if (ok) { try { const d = JSON.parse(output); @@ -101,7 +102,7 @@ async function verifyResource(resource: InfraResource): Promise { } if (type === 'dynamodb') { - const { ok, output } = aws(`dynamodb describe-table --table-name ${name}${region}`); + const { ok, output } = aws(['dynamodb', 'describe-table', '--table-name', name, ...regionArgs]); if (ok) { try { const d = JSON.parse(output); diff --git a/packages/dotdog/src/map/repoMapper.ts b/packages/dotdog/src/map/repoMapper.ts index 5569856..aaccd6d 100644 --- a/packages/dotdog/src/map/repoMapper.ts +++ b/packages/dotdog/src/map/repoMapper.ts @@ -1,6 +1,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs'; import { join } from 'path'; import { renderRepositoryDag } from '../dag/repoWorld'; +import { isIgnoredRepoPath } from '../workspace/paths'; export type RepoMapFact = { name: string; @@ -69,6 +70,7 @@ function walkRepoFiles(root: string, maxFiles = 500): string[] { if (REPO_MAP_IGNORES.has(entry.name)) continue; const nextRel = rel ? `${rel}/${entry.name}` : entry.name; + if (isIgnoredRepoPath(nextRel)) continue; const nextPath = join(current, entry.name); if (entry.isDirectory()) walk(nextPath, nextRel); @@ -112,7 +114,7 @@ export function detectRepoMap(root: string): RepoMap { edges.push({ source: name, target: fileNodeName(file), verb: 'configured_by' }); }; - addFact({ name: 'repository', type: 'repo', description: `Mapped repository at ${root}` }); + addFact({ name: 'repository', type: 'repo', description: 'Mapped repository' }); for (const file of files) { if (file === 'railway.json' || file.endsWith('/railway.json')) { @@ -160,8 +162,6 @@ export function detectRepoMap(root: string): RepoMap { } if (/\.(env|env\.example|env\.local|env\.sample)$/.test(file) || file.includes('.env')) { - addFile(file, 'env', 'Environment configuration'); - edges.push({ source: 'repository', target: fileNodeName(file), verb: 'configured_by' }); continue; } @@ -202,7 +202,7 @@ export function renderRepoMapDog(project: string, root: string, map: RepoMap): s const lines: string[] = []; lines.push('# Repo Map'); lines.push(''); - lines.push(`> Generated by dotdog map. Project: ${project}. Source: ${root}.`); + lines.push(`> Generated by dotdog map. Project: ${project}. Source: repository root.`); lines.push(''); lines.push('## Implementation Map'); lines.push(''); diff --git a/packages/dotdog/src/serve.ts b/packages/dotdog/src/serve.ts index d35c021..57919b0 100644 --- a/packages/dotdog/src/serve.ts +++ b/packages/dotdog/src/serve.ts @@ -2,24 +2,15 @@ // Exposes .dag graph to AI agents. v3 format only. import { existsSync, readdirSync, readFileSync } from 'fs'; -import { dirname, join, resolve } from 'path'; -import { homedir } from 'os'; +import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; import * as readline from 'readline'; import { parse } from './parser'; +import { resolveUserPath } from './workspace/paths'; +import { resolveWorkspace } from './workspace/resolver'; function resolvePath(p: string): string { - if (p.startsWith('~')) p = join(homedir(), p.slice(1)); - const resolved = p.startsWith('/') ? p : join(process.cwd(), p); - if (!p.startsWith('/') && !p.startsWith('~')) { - const rel = resolve(process.cwd(), p); - const cwd = process.cwd(); - if (!rel.startsWith(cwd + '/') && rel !== cwd && !cwd.startsWith(rel + '/')) { - throw new Error(`Path traversal blocked: ${p}`); - } - return rel; - } - return resolved; + return resolveUserPath(p, process.cwd()); } // --- v3-only helpers --- @@ -78,12 +69,12 @@ export function serve(dir: string = '.'): void { const dagPaths: Map = new Map(); function loadDags(): string[] { - const compiledDag = join(root, '.dotdog', 'compiled', 'repo.dag'); + const compiledDag = join(root, '.doghouse', 'compiled', 'repo.dag'); if (existsSync(compiledDag)) { const dag = JSON.parse(readFileSync(compiledDag, 'utf-8')); const p = project(dag) || 'repo'; dagCache.set(p, dag); - dagPaths.set(p, join(root, '.dotdog')); + dagPaths.set(p, join(root, '.doghouse')); } const dirs = [join(root,'projects'),join(root,'specs'),root]; @@ -120,6 +111,7 @@ export function serve(dir: string = '.'): void { { name: 'traverse', description: 'BFS from node, return reachable nodes+edges', inputSchema: { type: 'object', properties: { project: { type:'string' }, from: { type:'string' }, depth: { type:'number', default: 2 }, verb: { type:'string' } }, required: ['from'] } }, { name: 'search', description: 'Find entities by name', inputSchema: { type: 'object', properties: { project: { type:'string' }, q: { type:'string' }, type: { type:'string' } }, required: ['q'] } }, { name: 'listProjects', description: 'List loaded projects', inputSchema: { type: 'object', properties: {} } }, + { name: 'workspace.list', description: 'List workspace repos and groups', inputSchema: { type: 'object', properties: {} } }, { name: 'summary', description: 'Project stats: nodes, edges, savings', inputSchema: { type: 'object', properties: { project: { type:'string' } } } }, { name: 'schema', description: 'Entity property schema', inputSchema: { type: 'object', properties: { project: { type:'string' }, entity: { type:'string' } }, required: ['entity'] } }, { name: 'infraVerify', description: 'Verify infra resources', inputSchema: { type: 'object', properties: { provider: { type:'string' }, entity: { type:'string' }, summary: { type:'boolean' } } } }, @@ -136,6 +128,19 @@ export function serve(dir: string = '.'): void { return { jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: JSON.stringify([...dagCache.keys()]) }] } }; } + if (name === 'workspace.list') { + const workspace = resolveWorkspace(root, { requireManifest: false }); + const data = { + workspace: workspace.config.workspace, + mode: workspace.mode, + repos: workspace.repos.map((repo) => ({ alias: repo.alias, role: repo.role, cwd: repo.cwd })), + groups: workspace.config.groups || [], + trustedAsInstruction: false, + contentKind: 'workspace-metadata', + }; + return { jsonrpc: '2.0', id, result: { structuredContent: data, content: [{ type: 'text', text: JSON.stringify(data) }] } }; + } + if (name === 'getEntity') { if (!dag) return { jsonrpc: '2.0', id, error: { code: 404, message: 'Project not found' } }; const node = nodes(dag).find((n: any) => Nm(n).toLowerCase() === (args.name || '').toLowerCase()); diff --git a/packages/dotdog/src/workspace/commandRunner.ts b/packages/dotdog/src/workspace/commandRunner.ts new file mode 100644 index 0000000..c161611 --- /dev/null +++ b/packages/dotdog/src/workspace/commandRunner.ts @@ -0,0 +1,52 @@ +import { spawn } from 'child_process'; +import { assertInsideRoots } from './paths'; +import { redactSecrets } from './redact'; + +export interface CommandResult { + command: string; + args: string[]; + cwd: string; + exitCode: number | null; + stdout: string; + stderr: string; +} + +export function runCommand( + command: string, + args: string[], + options: { cwd: string; repoRoot?: string; timeoutMs?: number; env?: Record }, +): Promise { + const repoRoot = options.repoRoot || options.cwd; + assertInsideRoots(options.cwd, [repoRoot]); + + return new Promise((resolve) => { + const child = spawn(command, args, { + cwd: options.cwd, + env: { ...process.env, ...(options.env || {}) }, + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + const timeout = setTimeout(() => child.kill('SIGTERM'), options.timeoutMs || 30000); + + child.stdout.on('data', (chunk) => { stdout += String(chunk); }); + child.stderr.on('data', (chunk) => { stderr += String(chunk); }); + child.on('close', (exitCode) => { + clearTimeout(timeout); + resolve({ + command, + args, + cwd: options.cwd, + exitCode, + stdout: redactSecrets(stdout), + stderr: redactSecrets(stderr), + }); + }); + child.on('error', (error) => { + clearTimeout(timeout); + resolve({ command, args, cwd: options.cwd, exitCode: 1, stdout: '', stderr: redactSecrets(error.message) }); + }); + }); +} diff --git a/packages/dotdog/src/workspace/graph.ts b/packages/dotdog/src/workspace/graph.ts new file mode 100644 index 0000000..2ce2db3 --- /dev/null +++ b/packages/dotdog/src/workspace/graph.ts @@ -0,0 +1,69 @@ +import type { WorkspaceContext } from './types'; + +export interface WorkspaceGraphNode { + id: string; + kind: 'workspace' | 'repo' | 'group' | 'spec' | 'external'; + label: string; + repoAlias?: string; + path?: string; + metadata?: Record; +} + +export interface WorkspaceGraphEdge { + id: string; + from: string; + to: string; + type: string; + label?: string; + confidence?: 'explicit' | 'compiled' | 'inferred' | 'unknown'; + metadata?: Record; +} + +export interface WorkspaceGraph { + version: 1; + workspace: string; + nodes: WorkspaceGraphNode[]; + edges: WorkspaceGraphEdge[]; +} + +export function buildWorkspaceGraph(context: WorkspaceContext): WorkspaceGraph { + const workspaceId = context.config.workspace.id; + const workspaceNodeId = `workspace:${workspaceId}`; + const nodes: WorkspaceGraphNode[] = [{ id: workspaceNodeId, kind: 'workspace', label: workspaceId, path: context.workspaceRoot }]; + const edges: WorkspaceGraphEdge[] = []; + + for (const repo of [...context.repos].sort((a, b) => a.alias.localeCompare(b.alias))) { + const repoNodeId = `repo:${repo.alias}`; + nodes.push({ id: repoNodeId, kind: 'repo', label: repo.alias, repoAlias: repo.alias, path: repo.cwd, metadata: { role: repo.role || 'unknown' } }); + edges.push({ id: `${workspaceNodeId}:contains:${repoNodeId}`, from: workspaceNodeId, to: repoNodeId, type: 'contains', confidence: 'explicit' }); + } + + for (const group of [...(context.config.groups || [])].sort((a, b) => a.name.localeCompare(b.name))) { + const groupNodeId = `group:${group.name}`; + nodes.push({ id: groupNodeId, kind: 'group', label: group.name }); + edges.push({ id: `${workspaceNodeId}:contains:${groupNodeId}`, from: workspaceNodeId, to: groupNodeId, type: 'contains', confidence: 'explicit' }); + for (const alias of [...group.repos].sort()) { + edges.push({ id: `${groupNodeId}:includes:repo:${alias}`, from: groupNodeId, to: `repo:${alias}`, type: 'includes', confidence: 'explicit' }); + } + } + + for (const edge of [...(context.config.edges || [])].sort((a, b) => `${a.from}:${a.to}:${a.type}`.localeCompare(`${b.from}:${b.to}:${b.type}`))) { + edges.push({ id: `repo:${edge.from}:${edge.type}:repo:${edge.to}`, from: `repo:${edge.from}`, to: `repo:${edge.to}`, type: edge.type, label: edge.label, confidence: 'explicit' }); + } + + return { + version: 1, + workspace: workspaceId, + nodes: nodes.sort((a, b) => graphNodeRank(a).localeCompare(graphNodeRank(b))), + edges: edges.sort((a, b) => `${a.from}:${a.to}:${a.type}`.localeCompare(`${b.from}:${b.to}:${b.type}`)), + }; +} + +function graphNodeRank(node: WorkspaceGraphNode): string { + const order = node.kind === 'workspace' ? '0' : node.kind === 'repo' ? '1' : node.kind === 'group' ? '2' : node.kind === 'spec' ? '3' : '4'; + return `${order}:${node.id}`; +} + +export function repoQualifiedPath(repoAlias: string, filePath: string): string { + return `${repoAlias}:${filePath.replace(/\\/g, '/')}`; +} diff --git a/packages/dotdog/src/workspace/paths.ts b/packages/dotdog/src/workspace/paths.ts new file mode 100644 index 0000000..6f59fb1 --- /dev/null +++ b/packages/dotdog/src/workspace/paths.ts @@ -0,0 +1,78 @@ +import { existsSync, realpathSync } from 'fs'; +import { homedir } from 'os'; +import path from 'path'; + +const SECRET_FILE_PATTERNS = [ + /^\.env(?:\..*)?$/, + /\.pem$/i, + /\.key$/i, + /\.p12$/i, + /\.crt$/i, + /^id_rsa$/, + /^id_ed25519$/, +]; + +const IGNORED_DIRS = new Set([ + '.aws', + '.azure', + '.cache', + '.gcp', + '.git', + '.next', + '.ssh', + '.turbo', + '.vercel', + 'build', + 'coverage', + 'dist', + 'node_modules', +]); + +export function expandHome(inputPath: string): string { + if (inputPath === '~') return homedir(); + if (inputPath.startsWith('~/')) return path.join(homedir(), inputPath.slice(2)); + return inputPath; +} + +export function realPath(inputPath: string): string { + return realpathSync.native(inputPath); +} + +export function isInsideRoot(targetPath: string, rootPath: string): boolean { + const target = realPath(targetPath); + const root = realPath(rootPath); + return target === root || target.startsWith(root + path.sep); +} + +export function assertInsideRoots(targetPath: string, roots: string[]): void { + if (!existsSync(targetPath)) { + throw new Error(`Path does not exist: ${targetPath}`); + } + + if (!roots.some((root) => isInsideRoot(targetPath, root))) { + throw new Error(`Path outside allowed roots: ${targetPath}`); + } +} + +export function resolveUserPath(inputPath: string, rootPath = process.cwd()): string { + const expanded = expandHome(inputPath); + const root = realPath(rootPath); + const resolved = path.resolve(root, expanded); + + if (!existsSync(resolved)) { + const parent = path.dirname(resolved); + assertInsideRoots(parent, [root]); + return resolved; + } + + assertInsideRoots(resolved, [root]); + return realPath(resolved); +} + +export function isIgnoredRepoPath(relativePath: string): boolean { + const normalized = relativePath.replace(/\\/g, '/'); + const parts = normalized.split('/').filter(Boolean); + if (parts.some((part) => IGNORED_DIRS.has(part))) return true; + const basename = parts[parts.length - 1] || normalized; + return SECRET_FILE_PATTERNS.some((pattern) => pattern.test(basename)); +} diff --git a/packages/dotdog/src/workspace/privacy.ts b/packages/dotdog/src/workspace/privacy.ts new file mode 100644 index 0000000..b98120c --- /dev/null +++ b/packages/dotdog/src/workspace/privacy.ts @@ -0,0 +1,22 @@ +const ALLOWED_PUBLIC_EXAMPLES = new Set([ + 'example-org', + 'example-product', + 'example-api', + 'example-web', + 'example-mobile', + 'example-worker', + 'example-ops', + 'checkout', + 'billing', + 'catalog', + 'customer-portal', + 'admin-dashboard', +]); + +export function isAllowedPublicExampleName(name: string): boolean { + return ALLOWED_PUBLIC_EXAMPLES.has(name); +} + +export function allowedPublicExampleNames(): string[] { + return [...ALLOWED_PUBLIC_EXAMPLES].sort(); +} diff --git a/packages/dotdog/src/workspace/redact.ts b/packages/dotdog/src/workspace/redact.ts new file mode 100644 index 0000000..b49a35a --- /dev/null +++ b/packages/dotdog/src/workspace/redact.ts @@ -0,0 +1,15 @@ +const SECRET_PATTERNS = [ + /AKIA[0-9A-Z]{16}/g, + /ghp_[A-Za-z0-9_]{30,}/g, + /github_pat_[A-Za-z0-9_]+/g, + /sk-[A-Za-z0-9_-]+/g, + /xox[baprs]-[A-Za-z0-9-]+/g, + /-----BEGIN (?:RSA |OPENSSH |EC |DSA )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |OPENSSH |EC |DSA )?PRIVATE KEY-----/g, + /\b(TOKEN|SECRET|PASSWORD|API_KEY|PRIVATE_KEY|ACCESS_KEY)=([^\s]+)/gi, +]; + +export function redactSecrets(input: string): string { + let output = input; + for (const pattern of SECRET_PATTERNS) output = output.replace(pattern, (_match, key) => key ? `${key}=[REDACTED]` : '[REDACTED]'); + return output; +} diff --git a/packages/dotdog/src/workspace/registry.ts b/packages/dotdog/src/workspace/registry.ts new file mode 100644 index 0000000..dac9aa2 --- /dev/null +++ b/packages/dotdog/src/workspace/registry.ts @@ -0,0 +1,43 @@ +import type { RepoContext, WorkspaceContext, WorkspaceSelection } from './types'; + +export class RepoRegistry { + private repos: RepoContext[]; + private groups: Map; + + constructor(private context: WorkspaceContext) { + this.repos = [...context.repos]; + this.groups = new Map((context.config.groups || []).map((group) => [group.name, [...group.repos]])); + } + + list(): RepoContext[] { + return [...this.repos]; + } + + get(alias: string): RepoContext | null { + return this.repos.find((repo) => repo.alias === alias) || null; + } + + require(alias: string): RepoContext { + const repo = this.get(alias); + if (!repo) throw new Error(`Unknown repo alias: ${alias}`); + return repo; + } + + byRole(role: string): RepoContext[] { + return this.repos.filter((repo) => repo.role === role); + } + + group(name: string): RepoContext[] { + const aliases = this.groups.get(name); + if (!aliases) throw new Error(`Unknown workspace group: ${name}`); + return aliases.map((alias) => this.require(alias)); + } + + select(selection: WorkspaceSelection): RepoContext[] { + if (selection.type === 'workspace') return this.list(); + if (selection.type === 'repo') return [this.require(selection.repo)]; + if (selection.type === 'group') return this.group(selection.group); + if (selection.type === 'current') return this.context.mode === 'single-repo' ? this.list().slice(0, 1) : this.list(); + return []; + } +} diff --git a/packages/dotdog/src/workspace/resolver.ts b/packages/dotdog/src/workspace/resolver.ts new file mode 100644 index 0000000..1c7d665 --- /dev/null +++ b/packages/dotdog/src/workspace/resolver.ts @@ -0,0 +1,80 @@ +import { existsSync, readFileSync, realpathSync } from 'fs'; +import path from 'path'; +import { RepoRegistry } from './registry'; +import type { RepoContext, WorkspaceConfig, WorkspaceContext } from './types'; +import { validateWorkspaceConfig } from './validator'; + +export interface ResolveWorkspaceOptions { + manifestPath?: string; + requireManifest?: boolean; +} + +export const WORKSPACE_MANIFEST = path.join('.doghouse', 'workspace.json'); + +export function findWorkspaceManifest(startDir: string): string | null { + let dir = path.resolve(startDir); + + while (true) { + const candidate = path.join(dir, WORKSPACE_MANIFEST); + if (existsSync(candidate)) return candidate; + const parent = path.dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +export function resolveWorkspace(startDir = process.cwd(), options: ResolveWorkspaceOptions = {}): WorkspaceContext { + const manifestPath = options.manifestPath ? path.resolve(startDir, options.manifestPath) : findWorkspaceManifest(startDir); + if (!manifestPath) { + if (options.requireManifest) throw new Error(`No ${WORKSPACE_MANIFEST} found from ${startDir}`); + return singleRepoWorkspace(startDir); + } + + const manifestDir = path.dirname(manifestPath); + const config = JSON.parse(readFileSync(manifestPath, 'utf8')) as WorkspaceConfig; + const result = validateWorkspaceConfig(config, { manifestDir, checkPaths: true }); + if (!result.valid) { + throw new Error(`Invalid workspace manifest:\n${result.errors.map((err) => `- ${err.message}`).join('\n')}`); + } + + const repos: RepoContext[] = config.repos.map((repo) => ({ + alias: repo.alias, + role: repo.role, + cwd: realpathSync.native(path.resolve(manifestDir, repo.path)), + remote: repo.remote, + defaultBranch: repo.defaultBranch, + specs: repo.specs, + })); + + const context = { + mode: 'workspace' as const, + manifestPath, + workspaceRoot: path.dirname(manifestDir), + config, + repos, + registry: undefined as unknown as RepoRegistry, + } satisfies WorkspaceContext; + context.registry = new RepoRegistry(context); + return context; +} + +function singleRepoWorkspace(startDir: string): WorkspaceContext { + const cwd = realpathSync.native(path.resolve(startDir)); + const alias = path.basename(cwd) || 'repo'; + const config: WorkspaceConfig = { + version: 1, + workspace: { id: alias, name: alias }, + repos: [{ alias, role: 'unknown', path: cwd }], + }; + const repos: RepoContext[] = [{ alias, role: 'unknown', cwd }]; + const context = { + mode: 'single-repo' as const, + manifestPath: null, + workspaceRoot: cwd, + config, + repos, + registry: undefined as unknown as RepoRegistry, + } satisfies WorkspaceContext; + context.registry = new RepoRegistry(context); + return context; +} diff --git a/packages/dotdog/src/workspace/schema.ts b/packages/dotdog/src/workspace/schema.ts new file mode 100644 index 0000000..b0dfcaf --- /dev/null +++ b/packages/dotdog/src/workspace/schema.ts @@ -0,0 +1,2 @@ +export const WORKSPACE_SCHEMA_VERSION = 1; +export const WORKSPACE_MANIFEST_PATH = '.doghouse/workspace.json'; diff --git a/packages/dotdog/src/workspace/selection.ts b/packages/dotdog/src/workspace/selection.ts new file mode 100644 index 0000000..a6a0d8f --- /dev/null +++ b/packages/dotdog/src/workspace/selection.ts @@ -0,0 +1,24 @@ +import type { WorkspaceSelection } from './types'; + +export function selectionFromOptions(options: { repo?: string; group?: string; workspace?: boolean }): WorkspaceSelection { + const selected = [Boolean(options.repo), Boolean(options.group), Boolean(options.workspace)].filter(Boolean).length; + if (selected > 1) throw new Error('Choose only one of --repo, --group, or --workspace.'); + if (options.repo) return { type: 'repo', repo: options.repo }; + if (options.group) return { type: 'group', group: options.group }; + if (options.workspace) return { type: 'workspace' }; + return { type: 'current' }; +} + +export function assertExplicitMultiRepoSelection(selection: WorkspaceSelection, repoCount: number, commandName: string): void { + if (repoCount <= 1) return; + if (selection.type !== 'current') return; + + throw new Error([ + `Refusing to ${commandName} multiple repos implicitly.`, + '', + 'Use one of:', + ` dotdog ${commandName} --repo `, + ` dotdog ${commandName} --group `, + ` dotdog ${commandName} --workspace`, + ].join('\n')); +} diff --git a/packages/dotdog/src/workspace/types.ts b/packages/dotdog/src/workspace/types.ts new file mode 100644 index 0000000..4cc31de --- /dev/null +++ b/packages/dotdog/src/workspace/types.ts @@ -0,0 +1,88 @@ +export type WorkspaceMode = 'single-repo' | 'workspace'; + +export interface WorkspaceIdentity { + id: string; + name?: string; + description?: string; +} + +export interface RepoSpecConfig { + enabled?: boolean; + path?: string; +} + +export interface RepoConfig { + alias: string; + role?: string; + path: string; + remote?: string; + defaultBranch?: string; + specs?: RepoSpecConfig; +} + +export interface WorkspaceGroup { + name: string; + repos: string[]; +} + +export interface WorkspaceEdge { + from: string; + to: string; + type: string; + label?: string; +} + +export interface WorkspaceConfig { + version: 1; + workspace: WorkspaceIdentity; + repos: RepoConfig[]; + groups?: WorkspaceGroup[]; + edges?: WorkspaceEdge[]; +} + +export interface RepoContext { + alias: string; + role?: string; + cwd: string; + remote?: string; + owner?: string; + defaultBranch?: string; + packageManager?: 'bun' | 'npm' | 'pnpm' | 'yarn' | 'unknown'; + specs?: RepoSpecConfig; +} + +export type WorkspaceSelection = + | { type: 'repo'; repo: string } + | { type: 'group'; group: string } + | { type: 'workspace' } + | { type: 'current' }; + +export interface WorkspaceContext { + mode: WorkspaceMode; + manifestPath: string | null; + workspaceRoot: string; + config: WorkspaceConfig; + repos: RepoContext[]; + registry: RepoRegistryLike; +} + +export interface RepoRegistryLike { + list(): RepoContext[]; + get(alias: string): RepoContext | null; + require(alias: string): RepoContext; + byRole(role: string): RepoContext[]; + group(name: string): RepoContext[]; + select(selection: WorkspaceSelection): RepoContext[]; +} + +export interface ValidationIssue { + code: string; + message: string; + path?: string; +} + +export interface ValidationResult { + valid: boolean; + errors: ValidationIssue[]; + warnings: ValidationIssue[]; +} diff --git a/packages/dotdog/src/workspace/validator.ts b/packages/dotdog/src/workspace/validator.ts new file mode 100644 index 0000000..fab83fa --- /dev/null +++ b/packages/dotdog/src/workspace/validator.ts @@ -0,0 +1,81 @@ +import { existsSync, statSync } from 'fs'; +import path from 'path'; +import type { ValidationIssue, ValidationResult, WorkspaceConfig } from './types'; + +const SAFE_ALIAS = /^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$/; + +function issue(code: string, message: string, fieldPath?: string): ValidationIssue { + return { code, message, path: fieldPath }; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === 'object' && !Array.isArray(value)); +} + +export function validateWorkspaceConfig( + input: unknown, + options: { manifestDir?: string; checkPaths?: boolean; allowDuplicatePaths?: boolean } = {}, +): ValidationResult { + const errors: ValidationIssue[] = []; + const warnings: ValidationIssue[] = []; + + if (!isRecord(input)) { + return { valid: false, errors: [issue('invalid_config', 'Workspace config must be an object.')], warnings }; + } + + const config = input as WorkspaceConfig; + if (config.version !== 1) errors.push(issue('invalid_version', 'Only workspace manifest version 1 is supported.', 'version')); + if (!isRecord(config.workspace)) errors.push(issue('missing_workspace', 'workspace is required.', 'workspace')); + if (!config.workspace?.id || typeof config.workspace.id !== 'string') errors.push(issue('missing_workspace_id', 'workspace.id is required.', 'workspace.id')); + if (!Array.isArray(config.repos) || config.repos.length === 0) errors.push(issue('missing_repos', 'At least one repo is required.', 'repos')); + + const aliases = new Set(); + const paths = new Set(); + + for (const [index, repo] of (Array.isArray(config.repos) ? config.repos : []).entries()) { + const base = `repos[${index}]`; + if (!repo.alias || typeof repo.alias !== 'string' || !SAFE_ALIAS.test(repo.alias)) { + errors.push(issue('invalid_repo_alias', `Invalid repo alias: ${String(repo.alias || '')}`, `${base}.alias`)); + } + if (repo.alias && aliases.has(repo.alias)) errors.push(issue('duplicate_repo_alias', `Duplicate repo alias: ${repo.alias}`, `${base}.alias`)); + if (repo.alias) aliases.add(repo.alias); + + if (!repo.path || typeof repo.path !== 'string') { + errors.push(issue('missing_repo_path', `Repo path is required for ${repo.alias || base}.`, `${base}.path`)); + continue; + } + + if (repo.path.includes('\0')) errors.push(issue('unsafe_path', `Repo path contains a control character: ${repo.path}`, `${base}.path`)); + + if (options.manifestDir) { + const resolved = path.resolve(options.manifestDir, repo.path); + if (!options.allowDuplicatePaths && paths.has(resolved)) errors.push(issue('duplicate_repo_path', `Duplicate repo path: ${repo.path}`, `${base}.path`)); + paths.add(resolved); + + if (options.checkPaths) { + if (!existsSync(resolved)) errors.push(issue('repo_path_not_found', `Repo path not found: ${repo.path}`, `${base}.path`)); + else if (!statSync(resolved).isDirectory()) errors.push(issue('repo_path_not_directory', `Repo path is not a directory: ${repo.path}`, `${base}.path`)); + } + } + } + + const groupNames = new Set(); + for (const [index, group] of (config.groups || []).entries()) { + const base = `groups[${index}]`; + if (!group.name) errors.push(issue('missing_group_name', 'Group name is required.', `${base}.name`)); + if (group.name && groupNames.has(group.name)) errors.push(issue('duplicate_group_name', `Duplicate group name: ${group.name}`, `${base}.name`)); + if (group.name) groupNames.add(group.name); + for (const alias of group.repos || []) { + if (!aliases.has(alias)) errors.push(issue('unknown_group_repo', `Group "${group.name}" references unknown repo "${alias}".`, `${base}.repos`)); + } + } + + for (const [index, edge] of (config.edges || []).entries()) { + const base = `edges[${index}]`; + if (!aliases.has(edge.from)) errors.push(issue('unknown_edge_from', `Edge references unknown source repo "${edge.from}".`, `${base}.from`)); + if (!aliases.has(edge.to)) errors.push(issue('unknown_edge_to', `Edge references unknown target repo "${edge.to}".`, `${base}.to`)); + if (!edge.type) errors.push(issue('missing_edge_type', `Edge "${edge.from}" -> "${edge.to}" is missing type.`, `${base}.type`)); + } + + return { valid: errors.length === 0, errors, warnings }; +} diff --git a/scripts/check-pack.mjs b/scripts/check-pack.mjs new file mode 100644 index 0000000..3c2facf --- /dev/null +++ b/scripts/check-pack.mjs @@ -0,0 +1,28 @@ +import { execFileSync } from 'node:child_process'; + +const raw = execFileSync('npm', ['pack', '--dry-run', '--json'], { + cwd: 'packages/dotdog', + encoding: 'utf8', +}); + +const result = JSON.parse(raw)[0]; +const files = result.files.map((file) => file.path); +const forbidden = [ + /^node_modules\//, + /^__tests__\//, + /^fixtures\//, + /^agent-bench\//, + /^\.env/, + /private/i, + /customer-name/i, + /internal-codename/i, +]; + +const violations = files.filter((file) => forbidden.some((pattern) => pattern.test(file))); +if (violations.length) { + console.error('Forbidden files in dotdog package:'); + for (const file of violations) console.error(` ${file}`); + process.exit(1); +} + +console.log(`Package check passed: ${files.length} files`);