diff --git a/.changeset/spicy-laws-burn.md b/.changeset/spicy-laws-burn.md new file mode 100644 index 0000000..05335ed --- /dev/null +++ b/.changeset/spicy-laws-burn.md @@ -0,0 +1,5 @@ +--- +'@tanstack/intent': patch +--- + +- Added Deno monorepo setup coverage so setup-github-actions writes workflows at the workspace root and preserves monorepo-aware path substitutions. diff --git a/packages/intent/src/workspace-patterns.ts b/packages/intent/src/workspace-patterns.ts index 8afa392..4c3d7d6 100644 --- a/packages/intent/src/workspace-patterns.ts +++ b/packages/intent/src/workspace-patterns.ts @@ -27,6 +27,101 @@ function hasPackageJson(dir: string): boolean { return existsSync(join(dir, 'package.json')) } +function stripJsonCommentsAndTrailingCommas(source: string): string { + let result = '' + let inString = false + let escaped = false + + for (let index = 0; index < source.length; index += 1) { + const char = source[index]! + const next = source[index + 1] + + if (inString) { + result += char + if (escaped) { + escaped = false + } else if (char === '\\') { + escaped = true + } else if (char === '"') { + inString = false + } + continue + } + + if (char === '"') { + inString = true + result += char + continue + } + + if (char === '/' && next === '/') { + while (index < source.length && source[index] !== '\n') { + index += 1 + } + if (index < source.length) { + result += source[index]! + } + continue + } + + if (char === '/' && next === '*') { + const commentStart = index + index += 2 + while ( + index < source.length && + !(source[index] === '*' && source[index + 1] === '/') + ) { + index += 1 + } + if (index >= source.length) { + throw new SyntaxError( + `Unterminated block comment starting at position ${commentStart}`, + ) + } + index += 1 + continue + } + + if (char === ',') { + let lookahead = index + 1 + while (lookahead < source.length) { + const la = source[lookahead]! + if (/\s/.test(la)) { + lookahead += 1 + } else if (la === '/' && source[lookahead + 1] === '/') { + lookahead += 2 + while (lookahead < source.length && source[lookahead] !== '\n') { + lookahead += 1 + } + } else if (la === '/' && source[lookahead + 1] === '*') { + lookahead += 2 + while ( + lookahead < source.length && + !(source[lookahead] === '*' && source[lookahead + 1] === '/') + ) { + lookahead += 1 + } + lookahead += 2 + } else { + break + } + } + if (source[lookahead] === '}' || source[lookahead] === ']') { + continue + } + } + + result += char + } + + return result +} + +function readJsonFile(path: string, jsonc = false): unknown { + const source = readFileSync(path, 'utf8') + return JSON.parse(jsonc ? stripJsonCommentsAndTrailingCommas(source) : source) +} + export function readWorkspacePatterns(root: string): Array | null { const pnpmWs = join(root, 'pnpm-workspace.yaml') if (existsSync(pnpmWs)) { @@ -40,8 +135,9 @@ export function readWorkspacePatterns(root: string): Array | null { return patterns } } catch (err: unknown) { + const verb = err instanceof SyntaxError ? 'parse' : 'read' console.error( - `Warning: failed to parse ${pnpmWs}: ${err instanceof Error ? err.message : err}`, + `Warning: failed to ${verb} ${pnpmWs}: ${err instanceof Error ? err.message : err}`, ) } } @@ -49,16 +145,41 @@ export function readWorkspacePatterns(root: string): Array | null { const pkgPath = join(root, 'package.json') if (existsSync(pkgPath)) { try { - const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) + const pkg = readJsonFile(pkgPath) as Record + const workspaces = pkg.workspaces as Record | undefined const patterns = - parseWorkspacePatterns(pkg.workspaces) ?? - parseWorkspacePatterns(pkg.workspaces?.packages) + parseWorkspacePatterns(workspaces) ?? + parseWorkspacePatterns(workspaces?.packages) + if (patterns) { + return patterns + } + } catch (err: unknown) { + const verb = err instanceof SyntaxError ? 'parse' : 'read' + console.error( + `Warning: failed to ${verb} ${pkgPath}: ${err instanceof Error ? err.message : err}`, + ) + } + } + + for (const denoConfigName of ['deno.json', 'deno.jsonc']) { + const denoConfigPath = join(root, denoConfigName) + if (!existsSync(denoConfigPath)) { + continue + } + + try { + const denoConfig = readJsonFile(denoConfigPath, true) as Record< + string, + unknown + > + const patterns = parseWorkspacePatterns(denoConfig.workspace) if (patterns) { return patterns } } catch (err: unknown) { + const verb = err instanceof SyntaxError ? 'parse' : 'read' console.error( - `Warning: failed to parse ${pkgPath}: ${err instanceof Error ? err.message : err}`, + `Warning: failed to ${verb} ${denoConfigPath}: ${err instanceof Error ? err.message : err}`, ) } } diff --git a/packages/intent/tests/project-context.test.ts b/packages/intent/tests/project-context.test.ts index c16b64e..0416d91 100644 --- a/packages/intent/tests/project-context.test.ts +++ b/packages/intent/tests/project-context.test.ts @@ -136,4 +136,31 @@ describe('resolveProjectContext', () => { expect(context.isMonorepo).toBe(true) expect(context.packageRoot).toBe(packageRoot) }) + + it('detects Deno workspaces from a workspace package cwd', () => { + const root = createRoot() + writeJson(join(root, 'package.json'), { name: 'repo-root', private: true }) + writeFileSync( + join(root, 'deno.jsonc'), + `{ + "workspace": [ + "packages/*", + ], + } + `, + ) + const packageRoot = createWorkspacePackage(root, 'router') + + const context = resolveProjectContext({ cwd: packageRoot }) + + expect(context).toEqual({ + cwd: packageRoot, + workspaceRoot: root, + packageRoot, + isMonorepo: true, + workspacePatterns: ['packages/*'], + targetPackageJsonPath: join(packageRoot, 'package.json'), + targetSkillsDir: join(packageRoot, 'skills'), + }) + }) }) diff --git a/packages/intent/tests/setup.test.ts b/packages/intent/tests/setup.test.ts index 6911a8c..181cff6 100644 --- a/packages/intent/tests/setup.test.ts +++ b/packages/intent/tests/setup.test.ts @@ -344,6 +344,72 @@ describe('runSetupGithubActions', () => { rmSync(monoRoot, { recursive: true, force: true }) }) + + it('writes workflows to the Deno workspace root from a workspace package', () => { + const monoRoot = createMonorepo({ + usePackageJsonWorkspaces: true, + packages: [ + { name: 'router', hasSkills: true }, + { name: 'start', hasSkills: true }, + ], + }) + + writeFileSync( + join(monoRoot, 'package.json'), + JSON.stringify({ name: 'root', private: true }, null, 2), + ) + writeFileSync( + join(monoRoot, 'deno.jsonc'), + `{ + // Deno workspace config should be used for monorepo resolution. + "workspace": [ + "packages/*", + ], + } + `, + ) + writeFileSync( + join(monoRoot, 'packages', 'router', 'package.json'), + JSON.stringify( + { + name: '@tanstack/react-router', + intent: { repo: 'TanStack/router', docs: 'docs/' }, + }, + null, + 2, + ), + ) + mkdirSync(join(monoRoot, 'packages', 'router', 'src'), { recursive: true }) + mkdirSync(join(monoRoot, 'packages', 'router', 'docs'), { recursive: true }) + mkdirSync(join(monoRoot, 'packages', 'start', 'src'), { recursive: true }) + + const result = runSetupGithubActions( + join(monoRoot, 'packages', 'router'), + metaDir, + ) + + expect(result.workflows).toEqual( + expect.arrayContaining([ + join(monoRoot, '.github', 'workflows', 'notify-intent.yml'), + join(monoRoot, '.github', 'workflows', 'check-skills.yml'), + ]), + ) + expect( + existsSync(join(monoRoot, 'packages', 'router', '.github', 'workflows')), + ).toBe(false) + + const notifyContent = readFileSync( + join(monoRoot, '.github', 'workflows', 'notify-intent.yml'), + 'utf8', + ) + expect(notifyContent).toContain('package: @tanstack/router') + expect(notifyContent).toContain('repo: TanStack/router') + expect(notifyContent).toContain("- 'packages/router/docs/**'") + expect(notifyContent).toContain("- 'packages/router/src/**'") + expect(notifyContent).toContain("- 'packages/start/src/**'") + + rmSync(monoRoot, { recursive: true, force: true }) + }) }) // --------------------------------------------------------------------------- diff --git a/packages/intent/tests/workspace-patterns.test.ts b/packages/intent/tests/workspace-patterns.test.ts index e48f646..15f3fdd 100644 --- a/packages/intent/tests/workspace-patterns.test.ts +++ b/packages/intent/tests/workspace-patterns.test.ts @@ -7,7 +7,7 @@ import { } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { findPackagesWithSkills, findWorkspaceRoot, @@ -78,6 +78,70 @@ describe('readWorkspacePatterns', () => { 'packages/*', ]) }) + + it('reads workspace patterns from deno.json', () => { + const root = createRoot() + + writeFileSync( + join(root, 'deno.json'), + JSON.stringify({ + workspace: ['', './apps/*/', 'packages\\*', 'apps/*'], + }), + ) + + expect(readWorkspacePatterns(root)).toEqual(['apps/*', 'packages/*']) + }) + + it('reads workspace patterns from deno.jsonc', () => { + const root = createRoot() + + writeFileSync( + join(root, 'deno.jsonc'), + `{ + // Deno supports JSONC config files. + "workspace": [ + "./packages/*/", + "apps/*", + ], + } + `, + ) + + expect(readWorkspacePatterns(root)).toEqual(['apps/*', 'packages/*']) + }) + + it('prefers package.json workspaces over Deno workspace config', () => { + const root = createRoot() + + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ workspaces: ['packages/*'] }), + ) + writeFileSync( + join(root, 'deno.json'), + JSON.stringify({ workspace: ['apps/*'] }), + ) + + expect(readWorkspacePatterns(root)).toEqual(['packages/*']) + }) + + it('warns and returns null for invalid Deno config', () => { + const root = createRoot() + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + + writeFileSync(join(root, 'deno.jsonc'), '{ invalid jsonc') + + expect(readWorkspacePatterns(root)).toBeNull() + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining( + `Warning: failed to parse ${join(root, 'deno.jsonc')}`, + ), + ) + + consoleErrorSpy.mockRestore() + }) }) describe('resolveWorkspacePackages', () => {