diff --git a/__tests__/workspace-integration.test.ts b/__tests__/workspace-integration.test.ts index 989fa29..73d8149 100644 --- a/__tests__/workspace-integration.test.ts +++ b/__tests__/workspace-integration.test.ts @@ -16,6 +16,7 @@ import { detectWorkspaceLayout, findWorkspaceRoot, isNonInteractive, + reconfigureUpstreamForDownstream, runExperimentalNestBaseRename, runStandaloneWorkspaceGate, shouldProceedAsStandalone, @@ -178,6 +179,89 @@ describe('runExperimentalNestBaseRename', () => { }); }); +describe('reconfigureUpstreamForDownstream', () => { + // The nest-base template ships `.claude/upstream.json` with + // `isTemplate: true`, which makes the `/upstream-pr` slash command + // refuse to open upstream PRs. After forking the template into a + // downstream project, that file must be flipped to the downstream + // shape. These tests exercise the helper that does this. + + let tempDir: string; + + // Mirrors the template default that nest-base ships. + const templateDefault = { + $schema: './upstream.schema.json', + isTemplate: true, + notes: [ + 'This repo IS the nest-base template.', + 'After forking, set isTemplate: false and fill upstream.', + ], + syncedPaths: ['src/core/'], + upstream: null, + }; + + beforeEach(() => { + tempDir = filesystem.path('__tests__', `temp-upstream-${Date.now()}-${Math.random().toString(36).slice(2)}`); + filesystem.dir(tempDir); + }); + + afterEach(() => { + filesystem.remove(tempDir); + }); + + const seedTemplate = (data: Record = templateDefault): void => { + filesystem.write(filesystem.path(tempDir, '.claude', 'upstream.json'), data, { jsonIndent: 2 }); + }; + + const readUpstream = (): Record => + filesystem.read(filesystem.path(tempDir, '.claude', 'upstream.json'), 'json') as Record; + + test('flips isTemplate true → false and fills upstream with defaults', () => { + seedTemplate(); + const result = reconfigureUpstreamForDownstream({ apiDir: tempDir, filesystem }); + expect(result.updated).toBe(true); + const cfg = readUpstream(); + expect(cfg.isTemplate).toBe(false); + expect(cfg.upstream).toEqual({ branch: 'main', repo: 'lenneTech/nest-base' }); + }); + + test('preserves syncedPaths and $schema exactly', () => { + seedTemplate(); + reconfigureUpstreamForDownstream({ apiDir: tempDir, filesystem }); + const cfg = readUpstream(); + expect(cfg.syncedPaths).toEqual(['src/core/']); + expect(cfg.$schema).toBe('./upstream.schema.json'); + }); + + test('honours custom upstreamRepo / upstreamBranch args', () => { + seedTemplate(); + reconfigureUpstreamForDownstream({ + apiDir: tempDir, + filesystem, + upstreamBranch: 'develop', + upstreamRepo: 'acme/forked-base', + }); + const cfg = readUpstream(); + expect(cfg.upstream).toEqual({ branch: 'develop', repo: 'acme/forked-base' }); + }); + + test('returns { updated: false } and does not throw when the file is missing', () => { + // No .claude/upstream.json seeded. + const result = reconfigureUpstreamForDownstream({ apiDir: tempDir, filesystem }); + expect(result.updated).toBe(false); + expect(filesystem.exists(filesystem.path(tempDir, '.claude', 'upstream.json'))).toBe(false); + }); + + test('is idempotent: running twice yields identical output', () => { + seedTemplate(); + reconfigureUpstreamForDownstream({ apiDir: tempDir, filesystem }); + const first = readUpstream(); + reconfigureUpstreamForDownstream({ apiDir: tempDir, filesystem }); + const second = readUpstream(); + expect(second).toEqual(first); + }); +}); + describe('shouldProceedAsStandalone', () => { // Interactive: caller already asked the user; we just relay the answer. test('interactive yes → proceed', () => { diff --git a/src/commands/fullstack/add-api.ts b/src/commands/fullstack/add-api.ts index c37c910..d635761 100644 --- a/src/commands/fullstack/add-api.ts +++ b/src/commands/fullstack/add-api.ts @@ -5,6 +5,7 @@ import { hoistWorkspacePnpmConfig } from '../../lib/hoist-workspace-pnpm-config' import { detectWorkspaceLayout, findWorkspaceRoot, + reconfigureUpstreamForDownstream, runExperimentalNestBaseRename, writeApiConfig, } from '../../lib/workspace-integration'; @@ -325,6 +326,19 @@ const NewCommand: GluegunCommand = { } else { renameSpinner.succeed(`Renamed nest-base → ${projectDir} in projects/api`); } + + // Flip .claude/upstream.json from the template-self default to the + // downstream shape so `/upstream-pr` can contribute core fixes back + // to nest-base. Independent of the rename above — run it even if + // the rename failed. Non-fatal when the file is absent. + const upstreamResult = reconfigureUpstreamForDownstream({ + apiDir: apiDest, + filesystem, + upstreamBranch: apiBranch, + }); + if (upstreamResult.updated) { + info('Configured .claude/upstream.json for downstream contributions'); + } } // Persist apiMode + frameworkMode for downstream generators. diff --git a/src/commands/fullstack/init.ts b/src/commands/fullstack/init.ts index af0b332..6ff06de 100644 --- a/src/commands/fullstack/init.ts +++ b/src/commands/fullstack/init.ts @@ -5,7 +5,7 @@ import { caddyAvailable } from '../../lib/caddy'; import { runMigrate } from '../../lib/dev-migrate-helper'; import { resolveLayout } from '../../lib/dev-project'; import { hoistWorkspacePnpmConfig } from '../../lib/hoist-workspace-pnpm-config'; -import { detectWorkspaceLayout } from '../../lib/workspace-integration'; +import { detectWorkspaceLayout, reconfigureUpstreamForDownstream } from '../../lib/workspace-integration'; import addApiCommand from './add-api'; import addAppCommand from './add-app'; @@ -611,6 +611,19 @@ const NewCommand: GluegunCommand = { `Auto-rename failed (${(err as Error).message}). Run \`bun run rename ${projectDir}\` manually inside projects/api.`, ); } + + // Flip .claude/upstream.json from the template-self default to the + // downstream shape so `/upstream-pr` can contribute core fixes back + // to nest-base. Independent of the rename above — run it even if the + // rename failed. Non-fatal when the file is absent. + const upstreamResult = reconfigureUpstreamForDownstream({ + apiDir: apiDest, + filesystem, + upstreamBranch: apiBranch, + }); + if (upstreamResult.updated) { + info('Configured .claude/upstream.json for downstream contributions'); + } } // Create lt.config.json for API diff --git a/src/commands/server/create.ts b/src/commands/server/create.ts index 71d6163..b5f3061 100644 --- a/src/commands/server/create.ts +++ b/src/commands/server/create.ts @@ -1,7 +1,7 @@ import { GluegunCommand } from 'gluegun'; import { ExtendedGluegunToolbox } from '../../interfaces/extended-gluegun-toolbox'; -import { runStandaloneWorkspaceGate } from '../../lib/workspace-integration'; +import { reconfigureUpstreamForDownstream, runStandaloneWorkspaceGate } from '../../lib/workspace-integration'; /** * Create a new server @@ -398,6 +398,22 @@ const NewCommand: GluegunCommand = { return `created server symlink ${name}`; } + // For the experimental nest-base template, flip .claude/upstream.json + // from the template-self default to the downstream shape so + // `/upstream-pr` can contribute core fixes back to nest-base. The + // standalone clone lands directly at `projectDir`. Non-fatal when the + // file is absent (older templates). + if (experimental) { + const upstreamResult = reconfigureUpstreamForDownstream({ + apiDir: projectDir, + filesystem, + upstreamBranch: branch, + }); + if (upstreamResult.updated) { + info('Configured .claude/upstream.json for downstream contributions'); + } + } + // Git initialization (after npm install which is done in setupServer). // When cwd is not inside a repo, `git rev-parse` exits 128 — treat as false. if (git) { diff --git a/src/lib/workspace-integration.ts b/src/lib/workspace-integration.ts index 4f6f9bc..3b494c1 100644 --- a/src/lib/workspace-integration.ts +++ b/src/lib/workspace-integration.ts @@ -197,6 +197,75 @@ export function isNonInteractive(noConfirmFlag: boolean): boolean { return Boolean(process.stdin && process.stdin.isTTY === false); } +/** + * Reconfigure the cloned nest-base template's `.claude/upstream.json` + * into its downstream shape. + * + * WHY: nest-base ships `.claude/upstream.json` with `isTemplate: true` + * and `upstream: null` — the template-self default. The `/upstream-pr` + * slash command and the `contributing-upstream` skill key off + * `isTemplate` and refuse to open upstream PRs when it is still `true` + * ("this repo IS the template"). The template's own notes document a + * manual post-fork step (flip `isTemplate` to false, fill `upstream`) + * that humans and agents both forget. The `bun run rename` step only + * rewrites four files and never touches this one, so without this + * helper every scaffolded project keeps `isTemplate: true` and can + * never contribute core fixes back to nest-base. + * + * Behaviour: + * - Missing file (or unparseable JSON) → `{ updated: false }`, no + * throw. Older templates may not ship the file at all; that is not + * an error, just nothing to do. + * - Otherwise sets `isTemplate = false` and + * `upstream = { repo, branch }`, preserving `$schema` and + * `syncedPaths` exactly as the template defined them. + * + * Idempotent: running twice yields identical output. + */ +export function reconfigureUpstreamForDownstream(options: { + apiDir: string; + filesystem: GluegunFilesystem; + /** Upstream branch. Default: 'main'. */ + upstreamBranch?: string; + /** Upstream repo slug. Default: 'lenneTech/nest-base'. */ + upstreamRepo?: string; +}): { updated: boolean } { + const { apiDir, filesystem, upstreamBranch, upstreamRepo } = options; + const upstreamPath = filesystem.path(apiDir, '.claude', 'upstream.json'); + + // Non-fatal when absent — older templates may not ship the file. + if (filesystem.exists(upstreamPath) !== 'file') { + return { updated: false }; + } + + const current = filesystem.read(upstreamPath, 'json') as null | Record; + if (!current) { + // Unparseable / empty JSON — leave it untouched rather than risk + // clobbering hand-edited content with a guessed shape. + return { updated: false }; + } + + const next: Record = { + ...current, + // The whole point of the fix: this is a fork, not the template. + isTemplate: false, + // Replace the template-self notes (no longer applicable) with a + // short downstream-appropriate marker. Kept as a fixed array so the + // operation is idempotent. + notes: [ + 'Downstream project forked from the nest-base template.', + 'Core fixes flow back upstream via the /upstream-pr command.', + ], + upstream: { + branch: upstreamBranch ?? 'main', + repo: upstreamRepo ?? 'lenneTech/nest-base', + }, + }; + + filesystem.write(upstreamPath, next, { jsonIndent: 2 }); + return { updated: true }; +} + /** * Run the experimental `bun run rename ` step. Only relevant * for the `--next` nest-base template (it ships hard-coded `nest-base`