Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions __tests__/workspace-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
detectWorkspaceLayout,
findWorkspaceRoot,
isNonInteractive,
reconfigureUpstreamForDownstream,
runExperimentalNestBaseRename,
runStandaloneWorkspaceGate,
shouldProceedAsStandalone,
Expand Down Expand Up @@ -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<string, unknown> = templateDefault): void => {
filesystem.write(filesystem.path(tempDir, '.claude', 'upstream.json'), data, { jsonIndent: 2 });
};

const readUpstream = (): Record<string, unknown> =>
filesystem.read(filesystem.path(tempDir, '.claude', 'upstream.json'), 'json') as Record<string, unknown>;

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', () => {
Expand Down
14 changes: 14 additions & 0 deletions src/commands/fullstack/add-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { hoistWorkspacePnpmConfig } from '../../lib/hoist-workspace-pnpm-config'
import {
detectWorkspaceLayout,
findWorkspaceRoot,
reconfigureUpstreamForDownstream,
runExperimentalNestBaseRename,
writeApiConfig,
} from '../../lib/workspace-integration';
Expand Down Expand Up @@ -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.
Expand Down
15 changes: 14 additions & 1 deletion src/commands/fullstack/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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
Expand Down
18 changes: 17 additions & 1 deletion src/commands/server/create.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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) {
Expand Down
69 changes: 69 additions & 0 deletions src/lib/workspace-integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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<string, unknown> = {
...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 <projectDir>` step. Only relevant
* for the `--next` nest-base template (it ships hard-coded `nest-base`
Expand Down
Loading