From 847deba9b53041588a90d4801a641a37e9f34b69 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 23 Mar 2026 14:04:41 +0200 Subject: [PATCH 1/2] feat(nx-plugin): add git initialization to workspace generator with fallback for missing git --- docs/frontmcp/deployment/local-dev-server.mdx | 3 + .../getting-started/cli-reference.mdx | 7 +- docs/frontmcp/getting-started/quickstart.mdx | 1 + .../nx-plugin/generators/workspace.mdx | 2 +- .../sdk-reference/decorators/agent.mdx | 19 +++- .../frontmcp/sdk-reference/decorators/job.mdx | 15 +++- .../sdk-reference/decorators/prompt.mdx | 4 +- .../sdk-reference/decorators/resource.mdx | 6 +- .../sdk-reference/decorators/tool.mdx | 21 ++++- .../exec/__tests__/daemon-client.spec.ts | 14 ++- .../exec/__tests__/generate-cli-entry.spec.ts | 64 ++++++++++++++ .../build/exec/cli-runtime/daemon-client.ts | 1 + .../exec/cli-runtime/generate-cli-entry.ts | 45 ++++++++-- .../scaffold/__tests__/create.spec.ts | 87 +++++++++++++++++++ libs/cli/src/commands/scaffold/create.ts | 30 +++++++ .../files/node/docker-compose.yml__tmpl__ | 19 ++-- .../generators/workspace/workspace.spec.ts | 43 +++++++++ .../src/generators/workspace/workspace.ts | 19 ++++ 18 files changed, 375 insertions(+), 25 deletions(-) diff --git a/docs/frontmcp/deployment/local-dev-server.mdx b/docs/frontmcp/deployment/local-dev-server.mdx index aa5e9bb4b..fb36c0f24 100644 --- a/docs/frontmcp/deployment/local-dev-server.mdx +++ b/docs/frontmcp/deployment/local-dev-server.mdx @@ -86,6 +86,9 @@ npm run docker:down # Rebuild the Docker image npm run docker:build + +# Rebuild only the app service (selective rebuild — skips Redis) +docker compose -f ci/docker-compose.yml up --build app ``` --- diff --git a/docs/frontmcp/getting-started/cli-reference.mdx b/docs/frontmcp/getting-started/cli-reference.mdx index a83fbc5ed..9f57135ba 100644 --- a/docs/frontmcp/getting-started/cli-reference.mdx +++ b/docs/frontmcp/getting-started/cli-reference.mdx @@ -23,7 +23,7 @@ Commands for building, testing, and debugging your FrontMCP server. | `build --exec --cli` | Build CLI executable with subcommands per tool | | `test` | Run E2E tests with auto-injected Jest configuration | | `init` | Create or fix a tsconfig.json suitable for FrontMCP | -| `doctor` | Check Node/npm versions and tsconfig requirements | +| `doctor` | Check Node/npm versions and tsconfig requirements. Use `--fix` to auto-repair (installs missing deps, creates app directories) | | `inspector` | Launch MCP Inspector (`npx @modelcontextprotocol/inspector`) | | `create [name]` | Scaffold a new FrontMCP project (interactive if name omitted) | | `socket ` | Start Unix socket daemon for local MCP server | @@ -142,6 +142,11 @@ See [ESM Packages](/frontmcp/servers/esm-packages) for full documentation on ESM | `--no-cicd` | Disable GitHub Actions CI/CD | | `--nx` | Scaffold an Nx monorepo workspace | + +The `create` command automatically initializes a git repository and creates an initial commit after scaffolding. +If `git` is not installed, this step is silently skipped. + + ### Socket Options | Option | Description | diff --git a/docs/frontmcp/getting-started/quickstart.mdx b/docs/frontmcp/getting-started/quickstart.mdx index e19411de7..5eb91caa1 100644 --- a/docs/frontmcp/getting-started/quickstart.mdx +++ b/docs/frontmcp/getting-started/quickstart.mdx @@ -62,6 +62,7 @@ The CLI creates a complete project structure with: - ✅ Hot-reload enabled - ✅ Deployment configuration for your target platform - ✅ GitHub Actions CI/CD (optional) +- ✅ Git repository initialized with initial commit Your server is now running at `http://localhost:3000`! diff --git a/docs/frontmcp/nx-plugin/generators/workspace.mdx b/docs/frontmcp/nx-plugin/generators/workspace.mdx index 2640a0986..424115c23 100644 --- a/docs/frontmcp/nx-plugin/generators/workspace.mdx +++ b/docs/frontmcp/nx-plugin/generators/workspace.mdx @@ -26,7 +26,7 @@ npx frontmcp create my-platform --nx | `name` | `string` | — | **Required.** The name of the workspace | | `packageManager` | `npm` \| `yarn` \| `pnpm` | `npm` | Package manager to use | | `skipInstall` | `boolean` | `false` | Skip package installation | -| `skipGit` | `boolean` | `false` | Skip git initialization | +| `skipGit` | `boolean` | `false` | Skip git initialization. When `false`, runs `git init` and creates an initial commit. | | `createSampleApp` | `boolean` | `true` | Create a sample demo application | ## Generated Files diff --git a/docs/frontmcp/sdk-reference/decorators/agent.mdx b/docs/frontmcp/sdk-reference/decorators/agent.mdx index be73a25f9..1afb6a482 100644 --- a/docs/frontmcp/sdk-reference/decorators/agent.mdx +++ b/docs/frontmcp/sdk-reference/decorators/agent.mdx @@ -38,11 +38,24 @@ export default class ResearchAgent extends AgentContext { ## Signature ```typescript -function Agent( - providedMetadata: AgentMetadata -): ClassDecorator +function Agent( + opts: AgentMetadataOptions +): TypedClassDecorator ``` +## Type Safety + +The `@Agent` decorator validates at compile time that: + +- The decorated class extends `AgentContext` +- When `inputSchema` is provided, the `execute()` parameter matches the schema type +- When `outputSchema` is provided, the `execute()` return type is compatible +- Invalid metadata options (e.g., typos in `concurrency`) produce specific compile-time errors + + +Agents can use the default `execute()` from `AgentContext` (which runs the LLM agent loop) without overriding it. The type checker allows this pattern. + + ## Configuration Options ### Required Properties diff --git a/docs/frontmcp/sdk-reference/decorators/job.mdx b/docs/frontmcp/sdk-reference/decorators/job.mdx index 224916a13..1dbf347cb 100644 --- a/docs/frontmcp/sdk-reference/decorators/job.mdx +++ b/docs/frontmcp/sdk-reference/decorators/job.mdx @@ -35,11 +35,20 @@ class SendEmailJob extends JobContext { ## Signature ```typescript -function Job( - opts: JobMetadata -): ClassDecorator +function Job( + opts: JobMetadata & { outputSchema: O } +): TypedClassDecorator ``` +## Type Safety + +The `@Job` decorator enforces at compile time: + +- The decorated class must extend `JobContext` +- The `execute()` parameter must exactly match the `inputSchema` type +- The `execute()` return type must match the `outputSchema` type +- Both `inputSchema` and `outputSchema` are required for Jobs + ## Configuration Options ### Required Properties diff --git a/docs/frontmcp/sdk-reference/decorators/prompt.mdx b/docs/frontmcp/sdk-reference/decorators/prompt.mdx index 2edd1a5c6..40fd3d903 100644 --- a/docs/frontmcp/sdk-reference/decorators/prompt.mdx +++ b/docs/frontmcp/sdk-reference/decorators/prompt.mdx @@ -40,9 +40,11 @@ class ResearchPrompt extends PromptContext { ## Signature ```typescript -function Prompt(providedMetadata: PromptMetadata): ClassDecorator +function Prompt(opts: PromptMetadata): TypedClassDecorator ``` +The `@Prompt` decorator validates at compile time that the decorated class extends `PromptContext`. Using it on a plain class produces a descriptive compile error. + ## Configuration Options ### Required Properties diff --git a/docs/frontmcp/sdk-reference/decorators/resource.mdx b/docs/frontmcp/sdk-reference/decorators/resource.mdx index b4fc5f9c1..b2202b715 100644 --- a/docs/frontmcp/sdk-reference/decorators/resource.mdx +++ b/docs/frontmcp/sdk-reference/decorators/resource.mdx @@ -31,9 +31,13 @@ class AppConfigResource extends ResourceContext { ## Signature ```typescript -function Resource(providedMetadata: ResourceMetadata): ClassDecorator +function Resource(opts: ResourceMetadata): TypedClassDecorator ``` +The `@Resource` decorator validates at compile time that the decorated class extends `ResourceContext`. Using it on a plain class produces a descriptive compile error. + +The same applies to `@ResourceTemplate`. + ## Configuration Options ### Required Properties diff --git a/docs/frontmcp/sdk-reference/decorators/tool.mdx b/docs/frontmcp/sdk-reference/decorators/tool.mdx index 85ee650e9..f09cfb6e7 100644 --- a/docs/frontmcp/sdk-reference/decorators/tool.mdx +++ b/docs/frontmcp/sdk-reference/decorators/tool.mdx @@ -29,9 +29,26 @@ class GetWeatherTool extends ToolContext { ## Signature ```typescript -function Tool( +function Tool( opts: ToolMetadataOptions -): ClassDecorator +): TypedClassDecorator +``` + +## Type Safety + +The `@Tool` decorator provides compile-time type checking: + +- **Input validation**: The `execute()` parameter type must exactly match the `inputSchema`. Mismatches produce a descriptive error at compile time. +- **Output validation**: When `outputSchema` is provided, the `execute()` return type must be assignable to the inferred output type. +- **Context check**: The decorated class must extend `ToolContext`. Using `@Tool` on a plain class produces a compile error. +- **Invalid options**: Typos in decorator options (e.g., `concurrency: { maxConcurrensst: 5 }`) are caught at compile time with specific error messages, without losing autocomplete on other fields. + +```typescript +// Compile error: parameter type { query: number } doesn't match inputSchema { query: string } +@Tool({ name: 'search', inputSchema: { query: z.string() } }) +class BadTool extends ToolContext { + async execute(input: { query: number }) { ... } // TS error here +} ``` ## Configuration Options diff --git a/libs/cli/src/commands/build/exec/__tests__/daemon-client.spec.ts b/libs/cli/src/commands/build/exec/__tests__/daemon-client.spec.ts index 789ed038a..c9ae65685 100644 --- a/libs/cli/src/commands/build/exec/__tests__/daemon-client.spec.ts +++ b/libs/cli/src/commands/build/exec/__tests__/daemon-client.spec.ts @@ -150,10 +150,22 @@ describe('generateDaemonClientSource', () => { }); }); + it('should include _isDaemon flag on client object', () => { + expect(source).toContain('_isDaemon: true'); + }); + + it('should warn about notification limitations in daemon mode', () => { + expect(source).toContain('Notifications are not supported in daemon mode'); + }); + + it('should warn about resource subscription limitations in daemon mode', () => { + expect(source).toContain('Resource subscriptions are not supported in daemon mode'); + }); + it('should be parseable as JavaScript', () => { // Verify the generated source is valid JS by creating a function from it expect(() => { - + new Function(source); }).not.toThrow(); }); diff --git a/libs/cli/src/commands/build/exec/__tests__/generate-cli-entry.spec.ts b/libs/cli/src/commands/build/exec/__tests__/generate-cli-entry.spec.ts index bd54e81c7..f307e59b9 100644 --- a/libs/cli/src/commands/build/exec/__tests__/generate-cli-entry.spec.ts +++ b/libs/cli/src/commands/build/exec/__tests__/generate-cli-entry.spec.ts @@ -1111,6 +1111,70 @@ describe('extractTemplateParams', () => { }); }); +describe('daemon config extraction', () => { + it('should use Reflect.getMetadata for class config resolution in daemon script', () => { + const source = generateCliEntry(makeOptions()); + expect(source).toContain('Reflect.getMetadata("__frontmcp:config"'); + }); + + it('should require reflect-metadata in daemon script', () => { + const source = generateCliEntry(makeOptions()); + expect(source).toContain('require("reflect-metadata")'); + }); + + it('should fall back to raw module if not a function', () => { + const source = generateCliEntry(makeOptions()); + // The daemon script checks typeof raw === "function" before using Reflect + expect(source).toContain('typeof raw === "function"'); + }); +}); + +describe('doctor --fix app directory', () => { + it('should create app directory when --fix is used', () => { + const source = generateCliEntry(makeOptions({ + nativeDeps: {}, + })); + expect(source).toContain('fs.mkdirSync(appDir, { recursive: true })'); + expect(source).toContain('[fixed] Created'); + }); + + it('should only create directory when opts.fix is true', () => { + const source = generateCliEntry(makeOptions()); + expect(source).toContain('if (opts.fix)'); + expect(source).toContain('App directory not found'); + }); +}); + +describe('subscribe commands', () => { + it('should include setInterval for event loop keep-alive', () => { + const source = generateCliEntry(makeOptions()); + expect(source).toContain('setInterval(function() {}, 2147483647)'); + }); + + it('should define getSubscribeClient function', () => { + const source = generateCliEntry(makeOptions()); + expect(source).toContain('async function getSubscribeClient()'); + }); + + it('should use getSubscribeClient instead of getClient for subscribe commands', () => { + const source = generateCliEntry(makeOptions()); + // Subscribe resource and notification should use getSubscribeClient + expect(source).toContain('await getSubscribeClient()'); + }); + + it('should detect daemon mode via _isDaemon flag', () => { + const source = generateCliEntry(makeOptions()); + expect(source).toContain('client._isDaemon'); + }); + + it('should reconnect via in-process when daemon is detected', () => { + const source = generateCliEntry(makeOptions()); + // When daemon detected, should clear cached client and use connect() + expect(source).toContain('_client = null'); + expect(source).toContain("connect(configOrClass, { mode: 'cli' })"); + }); +}); + describe('RESERVED_COMMANDS', () => { it('should contain all expected reserved names', () => { expect(RESERVED_COMMANDS.has('resource')).toBe(true); diff --git a/libs/cli/src/commands/build/exec/cli-runtime/daemon-client.ts b/libs/cli/src/commands/build/exec/cli-runtime/daemon-client.ts index 986f1acd8..835c45ccf 100644 --- a/libs/cli/src/commands/build/exec/cli-runtime/daemon-client.ts +++ b/libs/cli/src/commands/build/exec/cli-runtime/daemon-client.ts @@ -91,6 +91,7 @@ function createDaemonClient(socketPath) { } return { + _isDaemon: true, ping: function() { return call('ping'); }, diff --git a/libs/cli/src/commands/build/exec/cli-runtime/generate-cli-entry.ts b/libs/cli/src/commands/build/exec/cli-runtime/generate-cli-entry.ts index 567f19c72..65497dcfd 100644 --- a/libs/cli/src/commands/build/exec/cli-runtime/generate-cli-entry.ts +++ b/libs/cli/src/commands/build/exec/cli-runtime/generate-cli-entry.ts @@ -833,14 +833,33 @@ workflowCmd } function generateSubscribeCommands(): string { - return `var subscribeCmd = program.command('subscribe').description('Subscribe to updates'); + return ` +// Subscribe commands need push support (onNotification/onResourceUpdated). +// Daemon HTTP cannot push, so we force in-process when daemon was used. +async function getSubscribeClient() { + var client = await getClient(); + // If connected via daemon, the onNotification/onResourceUpdated are no-ops. + // Reconnect via in-process for push support. + if (client._isDaemon) { + _client = null; // clear cached daemon client + var mod = require(SERVER_BUNDLE); + var configOrClass = mod.default || mod; + var sdk = require('@frontmcp/sdk'); + var connect = sdk.connect || sdk.direct.connect; + _client = await connect(configOrClass, { mode: 'cli' }); + return _client; + } + return client; +} + +var subscribeCmd = program.command('subscribe').description('Subscribe to updates'); subscribeCmd .command('resource ') .description('Stream resource updates (Ctrl+C to stop)') .action(async function(uri) { try { - var client = await getClient(); + var client = await getSubscribeClient(); await client.subscribeResource(uri); var mode = program.opts().output || 'text'; console.log('Subscribed to resource: ' + uri); @@ -853,7 +872,9 @@ subscribeCmd try { await client.unsubscribeResource(uri); } catch (_) { /* ok */ } process.exit(0); }); - // Keep process alive + // Keep process alive — setInterval creates an active event loop handle + // so Node.js won't exit even with InMemoryTransport (no persistent I/O) + setInterval(function() {}, 2147483647); await new Promise(function() {}); } catch (err) { console.error('Error:', err.message || err); @@ -866,7 +887,7 @@ subscribeCmd .description('Stream notifications (Ctrl+C to stop)') .action(async function(name) { try { - var client = await getClient(); + var client = await getSubscribeClient(); var mode = program.opts().output || 'text'; console.log('Listening for notification: ' + name); console.log('Waiting for events... (Ctrl+C to stop)\\n'); @@ -879,7 +900,9 @@ subscribeCmd console.log('\\nStopping...'); process.exit(0); }); - // Keep process alive + // Keep process alive — setInterval creates an active event loop handle + // so Node.js won't exit even with InMemoryTransport (no persistent I/O) + setInterval(function() {}, 2147483647); await new Promise(function() {}); } catch (err) { console.error('Error:', err.message || err); @@ -1097,6 +1120,10 @@ ${checks.join(',\n')} } else { console.log(' [!!] App directory not found: ' + appDir); ok = false; + if (opts.fix) { + fs.mkdirSync(appDir, { recursive: true }); + console.log(' [fixed] Created ' + appDir); + } } if (ok) console.log('\\nAll checks passed.'); @@ -1261,10 +1288,14 @@ daemonCmd // Start the daemon using runUnixSocket via a small wrapper script // Always use absolute path for the server bundle (SCRIPT_DIR resolves to __dirname at runtime) var serverBundlePath = pathMod.join(SCRIPT_DIR, ${JSON.stringify(serverBundleFilename)}); - var daemonScript = 'var mod = require(' + JSON.stringify(serverBundlePath) + ');' + + var daemonScript = 'require("reflect-metadata");' + + 'var mod = require(' + JSON.stringify(serverBundlePath) + ');' + 'var sdk = require("@frontmcp/sdk");' + 'var FrontMcpInstance = sdk.FrontMcpInstance || sdk.default.FrontMcpInstance;' + - 'var config = mod.default || mod;' + + 'var raw = mod.default || mod;' + + // If the export is a @FrontMcp-decorated class, extract config via Reflect metadata + 'var config = (typeof raw === "function" && typeof Reflect !== "undefined" && Reflect.getMetadata) ' + + ' ? (Reflect.getMetadata("__frontmcp:config", raw) || raw) : raw;' + 'FrontMcpInstance.runUnixSocket(Object.assign({}, config, { socketPath: ' + JSON.stringify(socketPath) + ' }))' + '.then(function() { console.log("Daemon listening on " + ' + JSON.stringify(socketPath) + '); })' + '.catch(function(e) { console.error("Daemon failed:", e); process.exit(1); });'; diff --git a/libs/cli/src/commands/scaffold/__tests__/create.spec.ts b/libs/cli/src/commands/scaffold/__tests__/create.spec.ts index 04de50fb1..acfedaa21 100644 --- a/libs/cli/src/commands/scaffold/__tests__/create.spec.ts +++ b/libs/cli/src/commands/scaffold/__tests__/create.spec.ts @@ -13,6 +13,13 @@ jest.mock('module', () => { return { ...actual, createRequire: jest.fn() }; }); +// Mock child_process for git init tests +const mockExecSync = jest.fn(); +jest.mock('child_process', () => ({ + ...jest.requireActual('child_process'), + execSync: mockExecSync, +})); + import { runCreate } from '../create'; import { runCmd, mkdtemp, mkdir, rm, readFileSync, writeFile, fileExists } from '@frontmcp/utils'; import { createRequire } from 'module'; @@ -421,6 +428,86 @@ describe('runCreate', () => { }); }); + describe('git initialization', () => { + beforeEach(() => { + jest.spyOn(process, 'cwd').mockReturnValue(tempDir); + mockExecSync.mockReset(); + mockExecSync.mockReturnValue(Buffer.from('')); + }); + + it('should initialize git repository after scaffolding', async () => { + await runCreate('git-init-app', { yes: true }); + + expect(mockExecSync).toHaveBeenCalledWith('git init', expect.objectContaining({ stdio: 'ignore' })); + expect(mockExecSync).toHaveBeenCalledWith('git add -A', expect.objectContaining({ stdio: 'ignore' })); + expect(mockExecSync).toHaveBeenCalledWith( + 'git commit -m "Initial commit"', + expect.objectContaining({ stdio: 'ignore' }), + ); + }); + + it('should log success message after git init', async () => { + await runCreate('git-msg-app', { yes: true }); + + expect(consoleLogs.some((log) => log.includes('Initialized git repository'))).toBe(true); + }); + + it('should silently skip git init when git is not available', async () => { + mockExecSync.mockImplementation(() => { + throw new Error('git: command not found'); + }); + + // Should not throw — git init failure is silently ignored + await runCreate('no-git-app', { yes: true }); + + // Should NOT have the success message + expect(consoleLogs.some((log) => log.includes('Initialized git repository'))).toBe(false); + }); + }); + + describe('docker-compose selective rebuild instructions', () => { + beforeEach(() => { + jest.spyOn(process, 'cwd').mockReturnValue(tempDir); + }); + + it('should include selective rebuild comment in docker-compose with Redis', async () => { + await runCreate('rebuild-redis-app', { yes: true, target: 'node', redis: 'docker' }); + + const content = readFileSync(path.join(tempDir, 'rebuild-redis-app', 'ci', 'docker-compose.yml'), 'utf8'); + expect(content).toContain('Selective rebuild'); + expect(content).toContain('docker compose'); + expect(content).toContain('--build app'); + }); + + it('should include selective rebuild comment in docker-compose without Redis', async () => { + await runCreate('rebuild-no-redis-app', { yes: true, target: 'node', redis: 'none' }); + + const content = readFileSync(path.join(tempDir, 'rebuild-no-redis-app', 'ci', 'docker-compose.yml'), 'utf8'); + expect(content).toContain('Selective rebuild'); + expect(content).toContain('--build app'); + }); + }); + + describe('E2E test template', () => { + beforeEach(() => { + jest.spyOn(process, 'cwd').mockReturnValue(tempDir); + }); + + it('should include frontmcp test comment in E2E test template', async () => { + await runCreate('e2e-template-app', { yes: true }); + + const content = readFileSync(path.join(tempDir, 'e2e-template-app', 'e2e', 'server.e2e.spec.ts'), 'utf8'); + expect(content).toContain('frontmcp test'); + }); + + it('should include resource listing test in E2E template', async () => { + await runCreate('e2e-resources-app', { yes: true }); + + const content = readFileSync(path.join(tempDir, 'e2e-resources-app', 'e2e', 'server.e2e.spec.ts'), 'utf8'); + expect(content).toContain('resources.list()'); + }); + }); + describe('package manager support', () => { beforeEach(() => { jest.spyOn(process, 'cwd').mockReturnValue(tempDir); diff --git a/libs/cli/src/commands/scaffold/create.ts b/libs/cli/src/commands/scaffold/create.ts index 4450325cf..95c9aba9b 100644 --- a/libs/cli/src/commands/scaffold/create.ts +++ b/libs/cli/src/commands/scaffold/create.ts @@ -180,6 +180,13 @@ export default class AddTool extends ToolContext { const TEMPLATE_E2E_TEST_TS = ` import { test, expect } from '@frontmcp/testing'; +/** + * E2E tests for the MCP server. + * + * Run with: + * frontmcp test # recommended + * npm run test:e2e # alternative + */ test.describe('Server E2E', () => { test.use({ server: './src/main.ts', @@ -200,6 +207,11 @@ test.describe('Server E2E', () => { const result = await mcp.tools.call('add', { a: 2, b: 3 }); expect(result).toBeSuccessful(); }); + + test('should list resources', async ({ mcp }) => { + const resources = await mcp.resources.list(); + expect(resources).toBeDefined(); + }); }); `; @@ -522,6 +534,10 @@ services: volumes: redis-data: + +# Selective rebuild: +# docker compose -f ci/docker-compose.yml up --build app # rebuild only the app +# docker compose -f ci/docker-compose.yml up --build # rebuild everything `; } @@ -537,6 +553,9 @@ services: environment: - NODE_ENV=\${NODE_ENV:-development} - PORT=\${PORT:-3000} + +# Selective rebuild: +# docker compose -f ci/docker-compose.yml up --build app # rebuild only the app `; } @@ -1569,6 +1588,17 @@ async function scaffoldProject(options: CreateOptions): Promise { // Dynamic README await scaffoldFileIfMissing(targetDir, path.join(targetDir, 'README.md'), generateReadme(options)); + // Initialize git repository + try { + const { execSync } = await import('child_process'); + execSync('git init', { cwd: targetDir, stdio: 'ignore' }); + execSync('git add -A', { cwd: targetDir, stdio: 'ignore' }); + execSync('git commit -m "Initial commit"', { cwd: targetDir, stdio: 'ignore' }); + console.log(`${c('green', '✓')} Initialized git repository`); + } catch { + // git may not be installed — silently skip + } + // Print next steps printNextSteps(folder, deploymentTarget, redisSetup, enableGitHubActions, packageManager); } diff --git a/libs/nx-plugin/src/generators/server/files/node/docker-compose.yml__tmpl__ b/libs/nx-plugin/src/generators/server/files/node/docker-compose.yml__tmpl__ index d92a4570e..4423dbcc0 100644 --- a/libs/nx-plugin/src/generators/server/files/node/docker-compose.yml__tmpl__ +++ b/libs/nx-plugin/src/generators/server/files/node/docker-compose.yml__tmpl__ @@ -1,14 +1,13 @@ -version: '3.8' - services: app: build: context: ../.. dockerfile: <%= projectRoot %>/Dockerfile ports: - - '3000:3000' + - '${PORT:-3000}:3000' environment: - - NODE_ENV=production + - NODE_ENV=${NODE_ENV:-production} + - PORT=${PORT:-3000} <% if (redis === 'docker') { %> - REDIS_HOST=redis - REDIS_PORT=6379 @@ -20,9 +19,19 @@ services: image: redis:7-alpine ports: - '6379:6379' + volumes: + - redis-data:/data + command: redis-server --appendonly yes healthcheck: test: ['CMD', 'redis-cli', 'ping'] - interval: 10s + interval: 3s timeout: 5s retries: 3 + +volumes: + redis-data: <% } %> + +# Selective rebuild: +# docker compose up --build app # rebuild only the app +# docker compose up --build # rebuild everything diff --git a/libs/nx-plugin/src/generators/workspace/workspace.spec.ts b/libs/nx-plugin/src/generators/workspace/workspace.spec.ts index 7dc228533..40d49d870 100644 --- a/libs/nx-plugin/src/generators/workspace/workspace.spec.ts +++ b/libs/nx-plugin/src/generators/workspace/workspace.spec.ts @@ -155,4 +155,47 @@ describe('workspace generator', () => { const mod = await import('./workspace'); expect(mod.default).toBe(workspaceGenerator); }); + + describe('git initialization', () => { + let execSyncMock: jest.SpyInstance; + + beforeEach(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const cp = require('child_process'); + execSyncMock = jest.spyOn(cp, 'execSync').mockImplementation(() => Buffer.from('')); + }); + + afterEach(() => { + execSyncMock.mockRestore(); + }); + + it('should initialize git repository by default', async () => { + const callback = await workspaceGenerator(tree, { name: 'git-project', skipInstall: true }); + callback(); + + expect(execSyncMock).toHaveBeenCalledWith('git init', expect.objectContaining({ stdio: 'ignore' })); + expect(execSyncMock).toHaveBeenCalledWith('git add -A', expect.objectContaining({ stdio: 'ignore' })); + expect(execSyncMock).toHaveBeenCalledWith( + 'git commit -m "Initial commit"', + expect.objectContaining({ stdio: 'ignore' }), + ); + }); + + it('should skip git init when skipGit is true', async () => { + const callback = await workspaceGenerator(tree, { name: 'no-git-project', skipInstall: true, skipGit: true }); + callback(); + + expect(execSyncMock).not.toHaveBeenCalledWith('git init', expect.anything()); + }); + + it('should silently skip git init when git is not available', async () => { + execSyncMock.mockImplementation(() => { + throw new Error('git: command not found'); + }); + + const callback = await workspaceGenerator(tree, { name: 'no-git-binary', skipInstall: true }); + // Should not throw + expect(() => callback()).not.toThrow(); + }); + }); }); diff --git a/libs/nx-plugin/src/generators/workspace/workspace.ts b/libs/nx-plugin/src/generators/workspace/workspace.ts index 1e3624349..057338db6 100644 --- a/libs/nx-plugin/src/generators/workspace/workspace.ts +++ b/libs/nx-plugin/src/generators/workspace/workspace.ts @@ -1,4 +1,5 @@ import { type Tree, formatFiles, generateFiles, installPackagesTask, type GeneratorCallback } from '@nx/devkit'; +import { execSync } from 'child_process'; import { join } from 'path'; import type { WorkspaceGeneratorSchema } from './schema.js'; import { normalizeOptions } from './lib/index.js'; @@ -40,6 +41,11 @@ async function workspaceGeneratorInternal(tree: Tree, schema: WorkspaceGenerator await formatFiles(tree); if (options.skipInstall) { + if (!options.skipGit) { + return () => { + initGitRepository(options.workspaceRoot); + }; + } return () => { /* noop */ }; @@ -47,7 +53,20 @@ async function workspaceGeneratorInternal(tree: Tree, schema: WorkspaceGenerator return () => { installPackagesTask(tree); + if (!options.skipGit) { + initGitRepository(options.workspaceRoot); + } }; } +function initGitRepository(workspaceRoot: string): void { + try { + execSync('git init', { cwd: workspaceRoot, stdio: 'ignore' }); + execSync('git add -A', { cwd: workspaceRoot, stdio: 'ignore' }); + execSync('git commit -m "Initial commit"', { cwd: workspaceRoot, stdio: 'ignore' }); + } catch { + // git may not be installed — silently skip + } +} + export default workspaceGenerator; From ca3df2a7d14677e076f01fe219e68ce9c628e881 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 23 Mar 2026 21:19:43 +0200 Subject: [PATCH 2/2] feat(nx-plugin): add git initialization to workspace generator with fallback for missing git --- docs/frontmcp/sdk-reference/decorators/prompt.mdx | 2 ++ docs/frontmcp/sdk-reference/decorators/resource.mdx | 2 ++ docs/frontmcp/sdk-reference/decorators/tool.mdx | 4 +++- libs/cli/src/commands/scaffold/create.ts | 9 ++++----- 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/frontmcp/sdk-reference/decorators/prompt.mdx b/docs/frontmcp/sdk-reference/decorators/prompt.mdx index 40fd3d903..50908b80c 100644 --- a/docs/frontmcp/sdk-reference/decorators/prompt.mdx +++ b/docs/frontmcp/sdk-reference/decorators/prompt.mdx @@ -43,6 +43,8 @@ class ResearchPrompt extends PromptContext { function Prompt(opts: PromptMetadata): TypedClassDecorator ``` +## Type Safety + The `@Prompt` decorator validates at compile time that the decorated class extends `PromptContext`. Using it on a plain class produces a descriptive compile error. ## Configuration Options diff --git a/docs/frontmcp/sdk-reference/decorators/resource.mdx b/docs/frontmcp/sdk-reference/decorators/resource.mdx index b2202b715..787d12842 100644 --- a/docs/frontmcp/sdk-reference/decorators/resource.mdx +++ b/docs/frontmcp/sdk-reference/decorators/resource.mdx @@ -34,6 +34,8 @@ class AppConfigResource extends ResourceContext { function Resource(opts: ResourceMetadata): TypedClassDecorator ``` +## Type Safety + The `@Resource` decorator validates at compile time that the decorated class extends `ResourceContext`. Using it on a plain class produces a descriptive compile error. The same applies to `@ResourceTemplate`. diff --git a/docs/frontmcp/sdk-reference/decorators/tool.mdx b/docs/frontmcp/sdk-reference/decorators/tool.mdx index f09cfb6e7..2ebe5755c 100644 --- a/docs/frontmcp/sdk-reference/decorators/tool.mdx +++ b/docs/frontmcp/sdk-reference/decorators/tool.mdx @@ -47,7 +47,9 @@ The `@Tool` decorator provides compile-time type checking: // Compile error: parameter type { query: number } doesn't match inputSchema { query: string } @Tool({ name: 'search', inputSchema: { query: z.string() } }) class BadTool extends ToolContext { - async execute(input: { query: number }) { ... } // TS error here + async execute(input: { query: number }) { // TS error on this class + return { result: input.query }; + } } ``` diff --git a/libs/cli/src/commands/scaffold/create.ts b/libs/cli/src/commands/scaffold/create.ts index 95c9aba9b..534b9e977 100644 --- a/libs/cli/src/commands/scaffold/create.ts +++ b/libs/cli/src/commands/scaffold/create.ts @@ -1,8 +1,7 @@ import * as path from 'path'; import { createRequire } from 'module'; -import { promises as fsp } from 'fs'; import { c } from '../../core/colors'; -import { ensureDir, fileExists, isDirEmpty, writeJSON, readJSON, runCmd, stat } from '@frontmcp/utils'; +import { ensureDir, fileExists, isDirEmpty, writeFile, writeJSON, readJSON, runCmd, stat } from '@frontmcp/utils'; import { runInit } from '../../core/tsconfig'; import { getSelfVersion } from '../../core/version'; import { clack } from '../../shared/prompts'; @@ -130,7 +129,7 @@ async function scaffoldFileIfMissing(baseDir: string, p: string, content: string return; } await ensureDir(path.dirname(p)); - await fsp.writeFile(p, content.replace(/^\n/, ''), 'utf8'); + await writeFile(p, content.replace(/^\n/, '')); console.log(c('green', `✓ created ${path.relative(baseDir, p)}`)); } @@ -1520,8 +1519,8 @@ async function scaffoldProject(options: CreateOptions): Promise { // Validate directory try { - const stat = await fsp.stat(targetDir); - if (!stat.isDirectory()) { + const targetStat = await stat(targetDir); + if (!targetStat.isDirectory()) { console.error( c('red', `Refusing to scaffold into non-directory path: ${path.relative(process.cwd(), targetDir)}`), );