From efa91348b5ae0f24d7894d6dbf38c763ef5d5e1b Mon Sep 17 00:00:00 2001 From: Jason Morais Date: Thu, 4 Jun 2026 11:15:08 -0400 Subject: [PATCH 1/3] initial commit for verify --- readme.md | 1 + 1 file changed, 1 insertion(+) diff --git a/readme.md b/readme.md index a997ba1ed..9de01cabd 100644 --- a/readme.md +++ b/readme.md @@ -6,6 +6,7 @@ Domain-driven architecture for Azure Functions with GraphQL/REST, MongoDB (Mongo + [Getting Started](https://developers.cellixjs.org/docs/intro): Our Docusaurus website will help you get started with running and contributing to CellixJS From dfdfb1520831cf56410ac3011452fc244e8f945e Mon Sep 17 00:00:00 2001 From: Jason Morais Date: Thu, 4 Jun 2026 11:44:41 -0400 Subject: [PATCH 2/3] initial script extraction - wip --- apps/api/package.json | 9 +- apps/api/scripts/sync-local-settings.mjs | 63 ----- apps/api/start-azurite.mjs | 47 ---- apps/api/start-dev.mjs | 47 ---- apps/api/turbo.json | 2 +- apps/docs/package.json | 5 +- apps/docs/start-dev.mjs | 10 - apps/docs/turbo.json | 4 +- apps/server-mongodb-memory-mock/package.json | 6 +- .../start-mongo.mjs | 12 - apps/server-oauth2-mock/package.json | 5 +- apps/server-oauth2-mock/start-dev.mjs | 20 -- apps/server-oauth2-mock/turbo.json | 2 +- apps/ui-community/package.json | 5 +- apps/ui-community/start-dev.mjs | 24 -- apps/ui-community/turbo.json | 4 +- apps/ui-staff/package.json | 5 +- apps/ui-staff/start-dev.mjs | 22 -- biome.json | 2 +- knip.json | 36 ++- packages/cellix/local-dev/.gitignore | 2 + packages/cellix/local-dev/README.md | 89 +++++++ .../cellix/local-dev/bin/cellix-local-dev.mjs | 22 ++ packages/cellix/local-dev/manifest.md | 60 +++++ packages/cellix/local-dev/package.json | 42 +++ .../local-dev/src/api-local-settings.ts | 85 ++++++ packages/cellix/local-dev/src/bin.ts | 7 + packages/cellix/local-dev/src/cli.ts | 121 +++++++++ packages/cellix/local-dev/src/dev-process.ts | 24 ++ packages/cellix/local-dev/src/hostnames.ts | 91 +++++++ packages/cellix/local-dev/src/index.test.ts | 137 ++++++++++ packages/cellix/local-dev/src/index.ts | 31 +++ packages/cellix/local-dev/src/runners.ts | 243 ++++++++++++++++++ packages/cellix/local-dev/src/vite.ts | 31 +++ packages/cellix/local-dev/src/workspace.ts | 26 ++ .../cellix/local-dev/src/worktree-ports.ts | 107 ++++++++ packages/cellix/local-dev/tsconfig.json | 9 + .../cellix/local-dev/tsconfig.vitest.json | 9 + packages/cellix/local-dev/vitest.config.ts | 18 ++ .../src/servers/process-test-server.test.ts | 31 ++- .../src/servers/process-test-server.ts | 8 +- .../e2e-tests/src/test-server-factories.ts | 11 +- .../ocom-verification/e2e-tests/turbo.json | 6 +- pnpm-lock.yaml | 63 ++++- scripts/local-dev/dev-process-exit.mjs | 25 -- scripts/local-dev/portless-hostnames.mjs | 97 ------- scripts/local-dev/vite-dev-args.mjs | 26 -- scripts/local-dev/worktree-ports.mjs | 130 ---------- sonar-project.properties | 2 +- 49 files changed, 1290 insertions(+), 593 deletions(-) delete mode 100644 apps/api/scripts/sync-local-settings.mjs delete mode 100644 apps/api/start-azurite.mjs delete mode 100644 apps/api/start-dev.mjs delete mode 100644 apps/docs/start-dev.mjs delete mode 100644 apps/server-mongodb-memory-mock/start-mongo.mjs delete mode 100644 apps/server-oauth2-mock/start-dev.mjs delete mode 100644 apps/ui-community/start-dev.mjs delete mode 100644 apps/ui-staff/start-dev.mjs create mode 100644 packages/cellix/local-dev/.gitignore create mode 100644 packages/cellix/local-dev/README.md create mode 100755 packages/cellix/local-dev/bin/cellix-local-dev.mjs create mode 100644 packages/cellix/local-dev/manifest.md create mode 100644 packages/cellix/local-dev/package.json create mode 100644 packages/cellix/local-dev/src/api-local-settings.ts create mode 100644 packages/cellix/local-dev/src/bin.ts create mode 100644 packages/cellix/local-dev/src/cli.ts create mode 100644 packages/cellix/local-dev/src/dev-process.ts create mode 100644 packages/cellix/local-dev/src/hostnames.ts create mode 100644 packages/cellix/local-dev/src/index.test.ts create mode 100644 packages/cellix/local-dev/src/index.ts create mode 100644 packages/cellix/local-dev/src/runners.ts create mode 100644 packages/cellix/local-dev/src/vite.ts create mode 100644 packages/cellix/local-dev/src/workspace.ts create mode 100644 packages/cellix/local-dev/src/worktree-ports.ts create mode 100644 packages/cellix/local-dev/tsconfig.json create mode 100644 packages/cellix/local-dev/tsconfig.vitest.json create mode 100644 packages/cellix/local-dev/vitest.config.ts delete mode 100644 scripts/local-dev/dev-process-exit.mjs delete mode 100644 scripts/local-dev/portless-hostnames.mjs delete mode 100644 scripts/local-dev/vite-dev-args.mjs delete mode 100644 scripts/local-dev/worktree-ports.mjs diff --git a/apps/api/package.json b/apps/api/package.json index f4d026525..330501df0 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -9,9 +9,9 @@ "prebuild": "pnpm run lint", "build": "tsgo --build && rolldown -c rolldown.config.ts", "predev": "pnpm run prepare:deploy && pnpm run sync-local-settings", - "dev": "pnpm exec portless data-access.ownercommunity.localhost --force node start-dev.mjs", + "dev": "pnpm exec portless data-access.ownercommunity.localhost --force cellix-local-dev azure-functions", "predev:worktree": "pnpm run prepare:deploy && pnpm run sync-local-settings", - "dev:worktree": "pnpm exec portless data-access.ownercommunity.${WORKTREE_NAME}.localhost --force node start-dev.mjs", + "dev:worktree": "pnpm exec portless data-access.ownercommunity.${WORKTREE_NAME}.localhost --force cellix-local-dev azure-functions", "prepare:deploy": "cellix-prepare-func-deploy", "watch": "tsgo --watch", "test": "vitest run --silent --reporter=dot", @@ -23,8 +23,8 @@ "clean": "rimraf dist deploy", "prestart": "pnpm run prepare:deploy && pnpm run sync-local-settings", "start": "func start --typescript --script-root deploy/", - "sync-local-settings": "node scripts/sync-local-settings.mjs", - "azurite": "node start-azurite.mjs" + "sync-local-settings": "cellix-local-dev sync-api-local-settings", + "azurite": "cellix-local-dev azurite" }, "dependencies": { "@azure/functions": "catalog:", @@ -48,6 +48,7 @@ "@cellix/config-rolldown": "workspace:*", "@cellix/config-typescript": "workspace:*", "@cellix/config-vitest": "workspace:*", + "@cellix/local-dev": "workspace:*", "@vitest/coverage-istanbul": "catalog:", "azurite": "^3.35.0", "rimraf": "catalog:", diff --git a/apps/api/scripts/sync-local-settings.mjs b/apps/api/scripts/sync-local-settings.mjs deleted file mode 100644 index d9b3a7acc..000000000 --- a/apps/api/scripts/sync-local-settings.mjs +++ /dev/null @@ -1,63 +0,0 @@ -import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { buildPortlessUrl, getHostnames } from '../../../scripts/local-dev/portless-hostnames.mjs'; -import { getAzuriteConnectionString } from '../../../scripts/local-dev/worktree-ports.mjs'; - -const apiDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const mode = process.argv[2] ?? (isE2E() ? 'e2e' : undefined); -const localSettingsPath = path.join(apiDir, 'local.settings.json'); -const e2eLocalSettingsPath = path.join(apiDir, 'local-settings.e2e.json'); -const targetPath = path.join(apiDir, 'deploy', 'local.settings.json'); - -mkdirSync(path.dirname(targetPath), { recursive: true }); - -if (!mode) { - if (existsSync(localSettingsPath)) { - copyFileSync(localSettingsPath, targetPath); - } - process.exit(0); -} - -if (mode !== 'e2e') { - throw new Error('[sync-local-settings] Invalid mode: expected one of e2e'); -} - -if (!existsSync(e2eLocalSettingsPath)) { - throw new Error(`[sync-local-settings] Missing local settings for mode "e2e": ${e2eLocalSettingsPath}`); -} - -const settings = JSON.parse(readFileSync(e2eLocalSettingsPath, 'utf-8')); -applyE2EOverrides(settings); -writeFileSync(targetPath, `${JSON.stringify(settings, null, '\t')}\n`); - -function applyE2EOverrides(settings) { - const values = { ...(settings.Values ?? {}) }; - - // Worktree-scoped overrides: when WORKTREE_NAME is set the proxy hostnames - // and Azurite ports are scoped to that worktree, so we rewrite the URLs and - // connection strings here. Without WORKTREE_NAME the committed JSON values - // already match the default hostnames, so we leave them alone. - if (process.env.WORKTREE_NAME) { - const hostnames = getHostnames(); - values.ACCOUNT_PORTAL_OIDC_ISSUER = buildPortlessUrl(hostnames.mockAuth, '/community'); - values.ACCOUNT_PORTAL_OIDC_ENDPOINT = buildPortlessUrl(hostnames.mockAuth, '/community/.well-known/jwks.json'); - values.STAFF_PORTAL_OIDC_ISSUER = buildPortlessUrl(hostnames.mockAuth, '/staff'); - values.STAFF_PORTAL_OIDC_ENDPOINT = buildPortlessUrl(hostnames.mockAuth, '/staff/.well-known/jwks.json'); - const azurite = getAzuriteConnectionString(values); - values.AZURE_STORAGE_CONNECTION_STRING = azurite; - values.AzureWebJobsStorage = azurite; - } - - // Runtime-only injection: the e2e harness spawns MongoMemoryServer on a - // random port and passes the connection string through process.env. - if (process.env.COSMOSDB_CONNECTION_STRING) { - values.COSMOSDB_CONNECTION_STRING = process.env.COSMOSDB_CONNECTION_STRING; - } - - settings.Values = values; -} - -function isE2E() { - return ['1', 'true', 'yes'].includes((process.env.E2E ?? '').toLowerCase()); -} diff --git a/apps/api/start-azurite.mjs b/apps/api/start-azurite.mjs deleted file mode 100644 index 8cb110b31..000000000 --- a/apps/api/start-azurite.mjs +++ /dev/null @@ -1,47 +0,0 @@ -import { spawn } from 'node:child_process'; -import { isGracefulInterruptExit } from '../../scripts/local-dev/dev-process-exit.mjs'; -import { getAzuritePorts } from '../../scripts/local-dev/worktree-ports.mjs'; - -const ports = getAzuritePorts(); -const worktreeName = process.env.WORKTREE_NAME ?? ''; -const storageSuffix = worktreeName ? `-${worktreeName}` : ''; - -const blobDir = `../../__blobstorage__${storageSuffix}`; -const queueDir = `../../__queuestorage__${storageSuffix}`; -const tableDir = `../../__tablestorage__${storageSuffix}`; - -const procSpecs = [ - ['azurite-blob', ['--silent', '--blobPort', String(ports.blob), '--location', blobDir]], - ['azurite-queue', ['--silent', '--queuePort', String(ports.queue), '--location', queueDir]], - ['azurite-table', ['--silent', '--tablePort', String(ports.table), '--location', tableDir]], -]; -const procs = procSpecs.map(([command, args]) => { - const proc = spawn(command, args, { stdio: 'inherit' }); - proc.on('error', (error) => { - console.error(`[azurite] failed to start ${command}: ${error.message}`); - for (const p of procs) p.kill(); - process.exit(1); - }); - return proc; -}); - -console.log(`[azurite] started (blob=${ports.blob}, queue=${ports.queue}, table=${ports.table})`); - -let exited = 0; -for (const proc of procs) { - proc.on('exit', (code, signal) => { - if (isGracefulInterruptExit(signal, code)) { - if (++exited === procs.length) process.exit(0); - return; - } - console.error(`[azurite] process exited unexpectedly: code=${code} signal=${signal}`); - for (const p of procs) p.kill(); - process.exit(code ?? 1); - }); -} -process.on('SIGINT', () => { - for (const p of procs) p.kill('SIGINT'); -}); -process.on('SIGTERM', () => { - for (const p of procs) p.kill('SIGTERM'); -}); diff --git a/apps/api/start-dev.mjs b/apps/api/start-dev.mjs deleted file mode 100644 index 17cb96ad8..000000000 --- a/apps/api/start-dev.mjs +++ /dev/null @@ -1,47 +0,0 @@ -import { spawn } from 'node:child_process'; -import os from 'node:os'; -import path from 'node:path'; -import { forwardChildExit } from '../../scripts/local-dev/dev-process-exit.mjs'; -import { buildPortlessUrl, getHostnames } from '../../scripts/local-dev/portless-hostnames.mjs'; -import { getAzuriteConnectionString, getMongoConnectionString } from '../../scripts/local-dev/worktree-ports.mjs'; - -const envPort = process.env.PORT; - -if (!envPort) { - console.error('PORT environment variable is not set. Start this command through portless.'); - process.exit(1); -} - -const portlessCaPath = process.env.PORTLESS_CA_PATH ?? path.join(os.homedir(), '.portless', 'ca.pem'); - -const childEnv = { - ...process.env, - NODE_EXTRA_CA_CERTS: portlessCaPath, - NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ''} --use-system-ca`.trim(), -}; - -// Only inject worktree-scoped overrides when running in worktree mode. -// When WORKTREE_NAME is absent, local.settings.json remains the source of truth. -// Use `??=` so callers can override any individual value via process.env. -if (process.env.WORKTREE_NAME) { - const hostnames = getHostnames(); - childEnv.ACCOUNT_PORTAL_OIDC_ISSUER ??= buildPortlessUrl(hostnames.mockAuth, '/community'); - childEnv.ACCOUNT_PORTAL_OIDC_ENDPOINT ??= buildPortlessUrl(hostnames.mockAuth, '/community/.well-known/jwks.json'); - childEnv.STAFF_PORTAL_OIDC_ISSUER ??= buildPortlessUrl(hostnames.mockAuth, '/staff'); - childEnv.STAFF_PORTAL_OIDC_ENDPOINT ??= buildPortlessUrl(hostnames.mockAuth, '/staff/.well-known/jwks.json'); - childEnv.COSMOSDB_CONNECTION_STRING ??= getMongoConnectionString(); - childEnv.AZURE_STORAGE_CONNECTION_STRING ??= getAzuriteConnectionString(); - childEnv.AzureWebJobsStorage ??= getAzuriteConnectionString(); - // Disable the Node.js inspector — port 5858 is already used by the primary worktree. - childEnv.languageWorkers__node__arguments ??= ''; -} - -// `--cors '*'` matches Host.CORS in local.settings.json but does not depend on -// that file existing — local.settings.json is gitignored, so CI has no CORS -// allowance otherwise and the UI's cross-origin GraphQL requests are blocked. -const child = spawn('func', ['start', '--typescript', '--script-root', 'deploy/', '--port', envPort, '--cors', '*'], { - stdio: 'inherit', - env: childEnv, -}); - -forwardChildExit(child); diff --git a/apps/api/turbo.json b/apps/api/turbo.json index 75ff6794b..378439790 100644 --- a/apps/api/turbo.json +++ b/apps/api/turbo.json @@ -4,7 +4,7 @@ "build": { "cache": true, "dependsOn": ["^build", "//#gen"], - "inputs": ["$TURBO_EXTENDS$", "rolldown.config.ts", "host.json", "$TURBO_ROOT$/scripts/local-dev/**"], + "inputs": ["$TURBO_EXTENDS$", "rolldown.config.ts", "host.json"], "outputs": ["$TURBO_EXTENDS$", "deploy/**"] }, "dev": { diff --git a/apps/docs/package.json b/apps/docs/package.json index 0d63617a3..a8a526048 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -4,8 +4,8 @@ "private": true, "scripts": { "docusaurus": "docusaurus", - "dev": "pnpm exec portless docs.ownercommunity.localhost --force node start-dev.mjs", - "dev:worktree": "pnpm exec portless docs.ownercommunity.${WORKTREE_NAME}.localhost --force node start-dev.mjs", + "dev": "pnpm exec portless docs.ownercommunity.localhost --force cellix-local-dev docusaurus", + "dev:worktree": "pnpm exec portless docs.ownercommunity.${WORKTREE_NAME}.localhost --force cellix-local-dev docusaurus", "start": "docusaurus start --port 3001", "build": "docusaurus build", "swizzle": "docusaurus swizzle", @@ -32,6 +32,7 @@ "devDependencies": { "@cellix/config-typescript": "workspace:*", "@cellix/config-vitest": "workspace:*", + "@cellix/local-dev": "workspace:*", "@docusaurus/module-type-aliases": "3.9.2", "@docusaurus/tsconfig": "3.9.2", "@docusaurus/types": "3.9.2", diff --git a/apps/docs/start-dev.mjs b/apps/docs/start-dev.mjs deleted file mode 100644 index 8180032bb..000000000 --- a/apps/docs/start-dev.mjs +++ /dev/null @@ -1,10 +0,0 @@ -import { spawn } from 'node:child_process'; -import { forwardChildExit } from '../../scripts/local-dev/dev-process-exit.mjs'; - -const port = process.env.PORT ?? '3001'; - -const child = spawn('docusaurus', ['start', '--port', port, '--host', '127.0.0.1', '--no-open'], { - stdio: 'inherit', -}); - -forwardChildExit(child); diff --git a/apps/docs/turbo.json b/apps/docs/turbo.json index 304f1bb57..62eb2f5e0 100644 --- a/apps/docs/turbo.json +++ b/apps/docs/turbo.json @@ -5,13 +5,13 @@ "dependsOn": [], "persistent": true, "interruptible": false, - "inputs": [".env", "package.json", "start-dev.mjs", "docusaurus.config.ts", "sidebars.ts", "tsconfig.json"] + "inputs": [".env", "package.json", "docusaurus.config.ts", "sidebars.ts", "tsconfig.json"] }, "dev:worktree": { "dependsOn": [], "persistent": true, "interruptible": false, - "inputs": [".env", "package.json", "start-dev.mjs", "docusaurus.config.ts", "sidebars.ts", "tsconfig.json"] + "inputs": [".env", "package.json", "docusaurus.config.ts", "sidebars.ts", "tsconfig.json"] }, "test": { "inputs": ["$TURBO_EXTENDS$", "!docs/**", "!blog/**", "!static/**"] diff --git a/apps/server-mongodb-memory-mock/package.json b/apps/server-mongodb-memory-mock/package.json index ed4a774d3..96109e079 100644 --- a/apps/server-mongodb-memory-mock/package.json +++ b/apps/server-mongodb-memory-mock/package.json @@ -11,17 +11,17 @@ "format": "biome format --write", "format:check": "biome format .", "start": "node dist/index.js", - "dev": "tsx src/index.ts", - "dev:worktree": "node start-mongo.mjs" + "dev": "cellix-local-dev tsx --profile mongo-memory-mock", + "dev:worktree": "cellix-local-dev tsx --profile mongo-memory-mock" }, "dependencies": { "@cellix/server-mongodb-memory-mock-seedwork": "workspace:*", "dotenv": "^16.4.5" }, "devDependencies": { + "@cellix/local-dev": "workspace:*", "@cellix/config-typescript": "workspace:*", "rimraf": "catalog:", - "tsx": "catalog:", "typescript": "catalog:" } } diff --git a/apps/server-mongodb-memory-mock/start-mongo.mjs b/apps/server-mongodb-memory-mock/start-mongo.mjs deleted file mode 100644 index cead8110f..000000000 --- a/apps/server-mongodb-memory-mock/start-mongo.mjs +++ /dev/null @@ -1,12 +0,0 @@ -import { spawn } from 'node:child_process'; -import { forwardChildExit } from '../../scripts/local-dev/dev-process-exit.mjs'; -import { getMongoPort } from '../../scripts/local-dev/worktree-ports.mjs'; - -const MONGO_PORT = getMongoPort(); - -const child = spawn('tsx', ['src/index.ts'], { - stdio: 'inherit', - env: { ...process.env, PORT: String(MONGO_PORT) }, -}); - -forwardChildExit(child); diff --git a/apps/server-oauth2-mock/package.json b/apps/server-oauth2-mock/package.json index 7201d84b9..6c0c8ae07 100644 --- a/apps/server-oauth2-mock/package.json +++ b/apps/server-oauth2-mock/package.json @@ -11,8 +11,8 @@ "format": "biome format --write", "format:check": "biome format .", "start": "node dist/index.js", - "dev": "pnpm exec portless mock-auth.ownercommunity.localhost --force tsx src/index.ts", - "dev:worktree": "pnpm exec portless mock-auth.ownercommunity.${WORKTREE_NAME}.localhost --force node start-dev.mjs", + "dev": "pnpm exec portless mock-auth.ownercommunity.localhost --force cellix-local-dev tsx --profile oauth2-mock", + "dev:worktree": "pnpm exec portless mock-auth.ownercommunity.${WORKTREE_NAME}.localhost --force cellix-local-dev tsx --profile oauth2-mock", "test": "vitest run", "test:coverage": "vitest run --coverage", "test:watch": "vitest" @@ -22,6 +22,7 @@ "dotenv": "^16.4.5" }, "devDependencies": { + "@cellix/local-dev": "workspace:*", "@cellix/config-vitest": "workspace:*", "@cellix/config-typescript": "workspace:*", "@vitest/coverage-istanbul": "catalog:", diff --git a/apps/server-oauth2-mock/start-dev.mjs b/apps/server-oauth2-mock/start-dev.mjs deleted file mode 100644 index 3f9d0f985..000000000 --- a/apps/server-oauth2-mock/start-dev.mjs +++ /dev/null @@ -1,20 +0,0 @@ -import { spawn } from 'node:child_process'; -import { forwardChildExit } from '../../scripts/local-dev/dev-process-exit.mjs'; -import { buildPortlessUrl, getHostnames } from '../../scripts/local-dev/portless-hostnames.mjs'; - -const childEnv = { ...process.env }; - -if (process.env.WORKTREE_NAME) { - const hostnames = getHostnames(); - childEnv.BASE_URL = buildPortlessUrl(hostnames.mockAuth); - // Override redirect URIs so portal-discovery picks up worktree-scoped URLs. - childEnv.VITE_APP_UI_COMMUNITY_B2C_REDIRECT_URI = buildPortlessUrl(hostnames.uiCommunity, '/auth-redirect'); - childEnv.VITE_APP_UI_STAFF_AAD_REDIRECT_URI = buildPortlessUrl(hostnames.uiStaff, '/auth-redirect'); -} - -const child = spawn('tsx', ['src/index.ts'], { - stdio: 'inherit', - env: childEnv, -}); - -forwardChildExit(child); diff --git a/apps/server-oauth2-mock/turbo.json b/apps/server-oauth2-mock/turbo.json index 2596ed01a..83fe0b4ba 100644 --- a/apps/server-oauth2-mock/turbo.json +++ b/apps/server-oauth2-mock/turbo.json @@ -14,7 +14,7 @@ "dependsOn": ["build"], "interruptible": true, "persistent": true, - "inputs": ["$TURBO_DEFAULT$", "../**/mock-oidc*.json", "start-dev.mjs"] + "inputs": ["$TURBO_DEFAULT$", "../**/mock-oidc*.json"] } } } diff --git a/apps/ui-community/package.json b/apps/ui-community/package.json index f0cc73767..a4391587e 100644 --- a/apps/ui-community/package.json +++ b/apps/ui-community/package.json @@ -9,8 +9,8 @@ "format:check": "biome format .", "prebuild": "pnpm run lint", "build": "tsgo --build && vite build", - "dev": "pnpm exec portless ownercommunity.localhost --force node start-dev.mjs", - "dev:worktree": "pnpm exec portless ownercommunity.${WORKTREE_NAME}.localhost --force node start-dev.mjs", + "dev": "pnpm exec portless ownercommunity.localhost --force cellix-local-dev vite --profile ui-community", + "dev:worktree": "pnpm exec portless ownercommunity.${WORKTREE_NAME}.localhost --force cellix-local-dev vite --profile ui-community", "start": "vite", "preview": "vite preview", "test": "vitest run --silent --reporter=dot", @@ -39,6 +39,7 @@ "devDependencies": { "@cellix/config-typescript": "workspace:*", "@cellix/config-vitest": "workspace:*", + "@cellix/local-dev": "workspace:*", "@chromatic-com/storybook": "^4.1.1", "@storybook/addon-a11y": "^9.1.3", "@storybook/addon-docs": "^9.1.3", diff --git a/apps/ui-community/start-dev.mjs b/apps/ui-community/start-dev.mjs deleted file mode 100644 index 233be3229..000000000 --- a/apps/ui-community/start-dev.mjs +++ /dev/null @@ -1,24 +0,0 @@ -import { spawn } from 'node:child_process'; -import { forwardChildExit } from '../../scripts/local-dev/dev-process-exit.mjs'; -import { buildPortlessUrl, getHostnames } from '../../scripts/local-dev/portless-hostnames.mjs'; -import { buildViteArgs } from '../../scripts/local-dev/vite-dev-args.mjs'; - -const childEnv = { ...process.env }; - -// Worktree-scoped overrides; plain `dev` leaves .env as the source of truth. -if (process.env.WORKTREE_NAME) { - const hostnames = getHostnames(); - childEnv.VITE_APP_UI_COMMUNITY_B2C_AUTHORITY = buildPortlessUrl(hostnames.mockAuth, '/community'); - childEnv.VITE_APP_UI_COMMUNITY_B2C_REDIRECT_URI = buildPortlessUrl(hostnames.uiCommunity, '/auth-redirect'); - childEnv.VITE_COMMON_API_ENDPOINT = buildPortlessUrl(hostnames.api, '/api/graphql'); - childEnv.VITE_APP_UI_COMMUNITY_BASE_URL = buildPortlessUrl(hostnames.uiCommunity); -} - -const viteArgs = buildViteArgs({ host: process.env.HOST, port: process.env.PORT }); - -const child = spawn('vite', viteArgs, { - stdio: 'inherit', - env: childEnv, -}); - -forwardChildExit(child); diff --git a/apps/ui-community/turbo.json b/apps/ui-community/turbo.json index d05ef2c09..bcb802dd1 100644 --- a/apps/ui-community/turbo.json +++ b/apps/ui-community/turbo.json @@ -5,13 +5,13 @@ "dependsOn": ["^build"], "persistent": true, "interruptible": false, - "inputs": [".env", ".env.*", "package.json", "start-dev.mjs", "vite.config.ts", "tsconfig.json", "tsconfig.app.json", "tsconfig.node.json"] + "inputs": [".env", ".env.*", "package.json", "vite.config.ts", "tsconfig.json", "tsconfig.app.json", "tsconfig.node.json"] }, "dev:worktree": { "dependsOn": ["^build"], "persistent": true, "interruptible": false, - "inputs": [".env", ".env.*", "package.json", "start-dev.mjs", "vite.config.ts", "tsconfig.json", "tsconfig.app.json", "tsconfig.node.json"] + "inputs": [".env", ".env.*", "package.json", "vite.config.ts", "tsconfig.json", "tsconfig.app.json", "tsconfig.node.json"] } } } diff --git a/apps/ui-staff/package.json b/apps/ui-staff/package.json index f5c74fc0b..70158d368 100644 --- a/apps/ui-staff/package.json +++ b/apps/ui-staff/package.json @@ -9,8 +9,8 @@ "format:check": "biome format .", "prebuild": "pnpm run lint", "build": "tsgo --build && vite build", - "dev": "pnpm exec portless staff.ownercommunity.localhost --force node start-dev.mjs", - "dev:worktree": "pnpm exec portless staff.ownercommunity.${WORKTREE_NAME}.localhost --force node start-dev.mjs", + "dev": "pnpm exec portless staff.ownercommunity.localhost --force cellix-local-dev vite --profile ui-staff", + "dev:worktree": "pnpm exec portless staff.ownercommunity.${WORKTREE_NAME}.localhost --force cellix-local-dev vite --profile ui-staff", "start": "vite", "preview": "vite preview", "test": "vitest run --silent --reporter=dot", @@ -40,6 +40,7 @@ "devDependencies": { "@cellix/config-typescript": "workspace:*", "@cellix/config-vitest": "workspace:*", + "@cellix/local-dev": "workspace:*", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^6.0.1", diff --git a/apps/ui-staff/start-dev.mjs b/apps/ui-staff/start-dev.mjs deleted file mode 100644 index c18f42dcf..000000000 --- a/apps/ui-staff/start-dev.mjs +++ /dev/null @@ -1,22 +0,0 @@ -import { spawn } from 'node:child_process'; -import { forwardChildExit } from '../../scripts/local-dev/dev-process-exit.mjs'; -import { buildPortlessUrl, getHostnames } from '../../scripts/local-dev/portless-hostnames.mjs'; -import { buildViteArgs } from '../../scripts/local-dev/vite-dev-args.mjs'; - -const childEnv = { ...process.env }; - -if (process.env.WORKTREE_NAME) { - const hostnames = getHostnames(); - childEnv.VITE_APP_UI_STAFF_AAD_AUTHORITY = buildPortlessUrl(hostnames.mockAuth, '/staff'); - childEnv.VITE_APP_UI_STAFF_AAD_REDIRECT_URI = buildPortlessUrl(hostnames.uiStaff, '/auth-redirect'); - childEnv.VITE_COMMON_API_ENDPOINT = buildPortlessUrl(hostnames.api, '/api/graphql'); -} - -const viteArgs = buildViteArgs({ host: process.env.HOST, port: process.env.PORT }); - -const child = spawn('vite', viteArgs, { - stdio: 'inherit', - env: childEnv, -}); - -forwardChildExit(child); diff --git a/biome.json b/biome.json index e14913384..5a0fc8b34 100644 --- a/biome.json +++ b/biome.json @@ -166,7 +166,7 @@ }, { "includes": ["**/src/**/*.ts"], "javascript": { "globals": [] } }, { - "includes": ["build-pipeline/scripts/**/*.{c|m}js", "scripts/local-dev/**/*.mjs"], + "includes": ["build-pipeline/scripts/**/*.{c|m}js", "packages/cellix/local-dev/bin/**/*.mjs"], "linter": { "rules": { "style": { "noCommonJs": "off" } } } }, { diff --git a/knip.json b/knip.json index e3de3d8c4..fa91a2bc3 100644 --- a/knip.json +++ b/knip.json @@ -2,23 +2,26 @@ "$schema": "https://unpkg.com/knip@5/schema.json", "workspaces": { "apps/api": { - "entry": ["src/index.ts", "start-*.mjs"], - "project": ["src/**/*.ts", "*.mjs"], + "entry": ["src/index.ts"], + "project": ["src/**/*.ts"], "ignoreDependencies": ["azurite"] }, "apps/ui-community": { "entry": ["src/main.tsx"], "project": ["src/**/*.{ts,tsx}"], - "ignore": ["**/apollo-client-links.tsx"] + "ignore": ["**/apollo-client-links.tsx"], + "ignoreDependencies": ["@cellix/local-dev"] }, "apps/ui-staff": { "entry": ["src/main.tsx"], "project": ["src/**/*.{ts,tsx}"], - "ignore": [] + "ignore": [], + "ignoreDependencies": ["@cellix/local-dev"] }, "apps/docs": { "entry": ["src/**/*.{ts,tsx,js,jsx}"], - "project": ["src/**/*.{ts,tsx,js,jsx}"] + "project": ["src/**/*.{ts,tsx,js,jsx}"], + "ignoreDependencies": ["@cellix/local-dev"] }, "packages/cellix/*": { "project": ["src/**/*.ts"], @@ -90,25 +93,16 @@ "apps/server-oauth2-mock": { "entry": ["src/index.ts"], "project": ["src/**/*.ts"], - "ignoreDependencies": ["tsx"] + "ignoreDependencies": ["@cellix/local-dev", "tsx"] + }, + "apps/server-mongodb-memory-mock": { + "entry": ["src/index.ts"], + "project": ["src/**/*.ts"], + "ignoreDependencies": [] } }, "ignoreWorkspaces": ["packages/cellix/config-typescript"], - "ignore": [ - "build-pipeline/scripts/**", - "scripts/local-dev/**", - "**/fixtures/**", - "**/*.test.ts", - "**/*.spec.ts", - "**/*.stories.tsx", - "**/dist/**", - "**/coverage/**", - "**/__tests__/**", - "**/tests/**", - ".agents/**", - ".github/**", - "portless.config.cjs" - ], + "ignore": ["build-pipeline/scripts/**", "**/fixtures/**", "**/*.test.ts", "**/*.spec.ts", "**/*.stories.tsx", "**/dist/**", "**/coverage/**", "**/__tests__/**", "**/tests/**", ".agents/**", ".github/**", "portless.config.cjs"], "ignoreIssues": { "codegen.yml": ["unlisted"] }, diff --git a/packages/cellix/local-dev/.gitignore b/packages/cellix/local-dev/.gitignore new file mode 100644 index 000000000..c925c21d5 --- /dev/null +++ b/packages/cellix/local-dev/.gitignore @@ -0,0 +1,2 @@ +/dist +/node_modules diff --git a/packages/cellix/local-dev/README.md b/packages/cellix/local-dev/README.md new file mode 100644 index 000000000..84dd87026 --- /dev/null +++ b/packages/cellix/local-dev/README.md @@ -0,0 +1,89 @@ +# @cellix/local-dev + +Shared local-development runtime for Cellix application packages. + +This package replaces duplicated `start-dev.*`, `start-mongo.*`, `start-azurite.*`, and root `scripts/local-dev/*` orchestration with one reusable package plus a small CLI. + +## What this package provides + +- Worktree-aware portless hostname derivation +- Worktree-aware MongoDB and Azurite port derivation +- API local-settings sync for normal and `e2e` modes +- Shared dev runners for: + - Vite apps + - Docusaurus docs + - Azure Functions local startup + - TSX-backed mock servers + - Azurite + +## Install + +In this monorepo, app packages consume the workspace package directly: + +```json +{ + "devDependencies": { + "@cellix/local-dev": "workspace:*" + } +} +``` + +## CLI usage + +```bash +cellix-local-dev vite --profile ui-community +cellix-local-dev vite --profile ui-staff +cellix-local-dev docusaurus +cellix-local-dev azure-functions +cellix-local-dev tsx --profile oauth2-mock +cellix-local-dev tsx --profile mongo-memory-mock +cellix-local-dev azurite +cellix-local-dev sync-api-local-settings +cellix-local-dev sync-api-local-settings e2e +``` + +## Example app scripts + +```json +{ + "scripts": { + "dev": "pnpm exec portless ownercommunity.localhost --force cellix-local-dev vite --profile ui-community", + "dev:worktree": "pnpm exec portless ownercommunity.${WORKTREE_NAME}.localhost --force cellix-local-dev vite --profile ui-community" + } +} +``` + +```json +{ + "scripts": { + "sync-local-settings": "cellix-local-dev sync-api-local-settings", + "dev": "pnpm exec portless data-access.ownercommunity.localhost --force cellix-local-dev azure-functions", + "azurite": "cellix-local-dev azurite" + } +} +``` + +## Public API + +The package also exports the local-dev primitives for tests or scripted composition: + +- `resolveWorkspaceRoot` +- `resolvePortlessHostnames` +- `buildPortlessUrl` +- `buildViteArgs` +- `getWorktreePortOffset` +- `getMongoPort` +- `getAzuritePorts` +- `getAzuriteConnectionString` +- `getMongoConnectionString` +- `syncApiLocalSettings` +- `runViteDev` +- `runDocusaurusDev` +- `runAzureFunctionsDev` +- `runTsxDev` +- `runAzuriteDev` + +## Notes + +- The implementation is in TypeScript, but the package exposes a normal Node bin so consuming app scripts do not need to boot shared `.ts` files through `tsx`. +- The package derives the workspace root from the caller's current working directory, so app packages do not need to hardcode repo-relative paths. diff --git a/packages/cellix/local-dev/bin/cellix-local-dev.mjs b/packages/cellix/local-dev/bin/cellix-local-dev.mjs new file mode 100755 index 000000000..ffe9cc635 --- /dev/null +++ b/packages/cellix/local-dev/bin/cellix-local-dev.mjs @@ -0,0 +1,22 @@ +#!/usr/bin/env node + +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const child = spawn(process.execPath, ['--experimental-strip-types', fileURLToPath(new URL('../src/bin.ts', import.meta.url)), ...process.argv.slice(2)], { + stdio: 'inherit', +}); + +child.on('error', (error) => { + console.error(`[local-dev] failed to start CLI: ${error.message}`); + process.exit(1); +}); + +child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + + process.exit(code ?? 1); +}); diff --git a/packages/cellix/local-dev/manifest.md b/packages/cellix/local-dev/manifest.md new file mode 100644 index 000000000..b6883617f --- /dev/null +++ b/packages/cellix/local-dev/manifest.md @@ -0,0 +1,60 @@ +# manifest.md - @cellix/local-dev + +## Purpose + +Provide a single local-development runtime for Cellix app packages so app-level `dev`, `dev:worktree`, `azurite`, and local-settings scripts can stay configuration-shaped while the real orchestration lives in one reusable package. + +## Scope + +This package owns worktree-aware hostname and port derivation, portless URL building, API local-settings rewriting, child-process lifecycle handling, and the shared dev-runner orchestration for Vite, Docusaurus, Azure Functions, TSX-based mock servers, and Azurite. + +## Non-goals + +- Production runtime behavior +- Generic process management beyond Cellix local-development use cases +- Replacing app build/start scripts that are not part of the local-development workflow + +## Public API shape + +- `resolveWorkspaceRoot(options?)` +- `resolvePortlessHostnames(options?)`, `buildPortlessUrl(hostname, path?)`, `PORTLESS_PORT` +- `isE2E(env?)`, `buildViteArgs(options?)` +- `isGracefulInterruptExit(signal, code)`, `forwardChildExit(child)` +- `getWorktreePortOffset(worktreeName?)`, `getMongoPort(worktreeName?)`, `getAzuritePorts(worktreeName?)` +- `getAzuriteConnectionString(options?)`, `getMongoConnectionString(options?)` +- `syncApiLocalSettings(options?)` +- `runViteDev(profile, options?)`, `runDocusaurusDev(options?)`, `runAzureFunctionsDev(options?)`, `runTsxDev(profile, options?)`, `runAzuriteDev(options?)` + +## Core concepts + +- App packages should express local dev behavior as small parameter choices such as profile names or entry paths, not as duplicated process wiring. +- Worktree isolation is deterministic and should keep hostnames, MongoDB ports, and Azurite ports aligned across all participating apps. +- The package is allowed to know Cellix app profiles such as `ui-community`, `ui-staff`, `oauth2-mock`, and `mongo-memory-mock`, because those profiles are the stable consumer contract that replaces ad hoc app wrapper scripts. + +## Package boundaries + +- Keep CLI argument parsing, process spawning helpers, file-system helpers, and app profile definitions internal unless consumers outside this package need them directly. +- Do not leak app-relative path assumptions into consumers; the package must derive workspace paths from the caller's current working directory. +- Avoid widening the public surface with one-off helpers that only exist to support a single internal branch. + +## Dependencies / relationships + +- Downstream consumers in this monorepo: `@apps/api`, `@apps/docs`, `@apps/ui-community`, `@apps/ui-staff`, `@apps/server-oauth2-mock`, `@apps/server-mongodb-memory-mock` +- Consumed from package scripts through the `cellix-local-dev` bin and from tests through the TypeScript API + +## Testing strategy + +- Prefer public-entrypoint tests for hostname derivation, worktree port derivation, Vite arg building, connection-string patching, and API local-settings rewriting. +- Avoid tests that reach into internal CLI parsing or helper modules when a public function already proves the observable behavior. + +## Documentation obligations + +- Keep `README.md` focused on how app packages consume the package. +- Keep TSDoc aligned on public exports that define package behavior. +- Update this manifest when app profiles, public exports, or scope boundaries change. + +## Release-readiness standards + +- App packages should need only profile/entry configuration in `package.json` after consuming this package. +- Package build and package tests must pass, plus affected app builds/tests as justified by the migration. +- Any new app profile should be added deliberately and documented as part of the contract. diff --git a/packages/cellix/local-dev/package.json b/packages/cellix/local-dev/package.json new file mode 100644 index 000000000..10c3d2454 --- /dev/null +++ b/packages/cellix/local-dev/package.json @@ -0,0 +1,42 @@ +{ + "name": "@cellix/local-dev", + "version": "1.0.0", + "description": "Shared local-development runner orchestration for Cellix applications", + "type": "module", + "files": [ + "dist", + "bin", + "src" + ], + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "bin": { + "cellix-local-dev": "bin/cellix-local-dev.mjs" + }, + "scripts": { + "prebuild": "pnpm run lint", + "build": "tsgo --build", + "clean": "rimraf dist", + "lint": "biome lint", + "format": "biome format --write", + "format:check": "biome format .", + "test": "vitest run --silent --reporter=dot", + "test:coverage": "vitest run --coverage --silent --reporter=dot", + "test:watch": "vitest" + }, + "devDependencies": { + "@cellix/config-typescript": "workspace:*", + "@cellix/config-vitest": "workspace:*", + "@types/node": "catalog:", + "@vitest/coverage-istanbul": "catalog:", + "rimraf": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/cellix/local-dev/src/api-local-settings.ts b/packages/cellix/local-dev/src/api-local-settings.ts new file mode 100644 index 000000000..f91a2a82b --- /dev/null +++ b/packages/cellix/local-dev/src/api-local-settings.ts @@ -0,0 +1,85 @@ +import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { buildPortlessUrl, resolvePortlessHostnames } from './hostnames.ts'; +import { getAzuriteConnectionString } from './worktree-ports.ts'; + +interface ApiLocalSettingsDocument { + Values?: Record; +} + +interface SyncApiLocalSettingsOptions { + appDir?: string; + workspaceRoot?: string; + env?: NodeJS.ProcessEnv; + mode?: 'e2e'; +} + +/** + * Applies the shared Cellix e2e and worktree overrides to an API + * `local.settings.json` document. + */ +export function applyApiLocalSettingsOverrides(settings: ApiLocalSettingsDocument, options: Pick = {}): ApiLocalSettingsDocument { + const env = options.env ?? process.env; + const values = { ...(settings.Values ?? {}) }; + + if (env['WORKTREE_NAME']) { + const hostnames = resolvePortlessHostnames({ + ...(options.workspaceRoot ? { startDir: options.workspaceRoot } : {}), + env, + }); + values['ACCOUNT_PORTAL_OIDC_ISSUER'] = buildPortlessUrl(hostnames.mockAuth, '/community'); + values['ACCOUNT_PORTAL_OIDC_ENDPOINT'] = buildPortlessUrl(hostnames.mockAuth, '/community/.well-known/jwks.json'); + values['STAFF_PORTAL_OIDC_ISSUER'] = buildPortlessUrl(hostnames.mockAuth, '/staff'); + values['STAFF_PORTAL_OIDC_ENDPOINT'] = buildPortlessUrl(hostnames.mockAuth, '/staff/.well-known/jwks.json'); + const azurite = getAzuriteConnectionString({ + ...(options.workspaceRoot ? { startDir: options.workspaceRoot } : {}), + env, + values, + }); + values['AZURE_STORAGE_CONNECTION_STRING'] = azurite; + values['AzureWebJobsStorage'] = azurite; + } + + if (env['COSMOSDB_CONNECTION_STRING']) { + values['COSMOSDB_CONNECTION_STRING'] = env['COSMOSDB_CONNECTION_STRING']; + } + + settings.Values = values; + return settings; +} + +/** + * Syncs `apps/api/deploy/local.settings.json` from the local or e2e source file. + */ +export function syncApiLocalSettings(options: SyncApiLocalSettingsOptions = {}): void { + const env = options.env ?? process.env; + const appDir = path.resolve(options.appDir ?? process.cwd()); + const mode = options.mode ?? (['1', 'true', 'yes'].includes((env['E2E'] ?? '').toLowerCase()) ? 'e2e' : undefined); + const localSettingsPath = path.join(appDir, 'local.settings.json'); + const e2eLocalSettingsPath = path.join(appDir, 'local-settings.e2e.json'); + const targetPath = path.join(appDir, 'deploy', 'local.settings.json'); + + mkdirSync(path.dirname(targetPath), { recursive: true }); + + if (!mode) { + if (existsSync(localSettingsPath)) { + copyFileSync(localSettingsPath, targetPath); + } + return; + } + + if (mode !== 'e2e') { + throw new Error(`[local-dev] Invalid mode: expected one of e2e, received "${mode}"`); + } + + if (!existsSync(e2eLocalSettingsPath)) { + throw new Error(`[local-dev] Missing local settings for mode "e2e": ${e2eLocalSettingsPath}`); + } + + const settings = JSON.parse(readFileSync(e2eLocalSettingsPath, 'utf8')) as ApiLocalSettingsDocument; + applyApiLocalSettingsOverrides(settings, { + workspaceRoot: options.workspaceRoot ?? appDir, + env, + }); + writeFileSync(targetPath, `${JSON.stringify(settings, null, '\t')}\n`); +} diff --git a/packages/cellix/local-dev/src/bin.ts b/packages/cellix/local-dev/src/bin.ts new file mode 100644 index 000000000..e6bbb3f23 --- /dev/null +++ b/packages/cellix/local-dev/src/bin.ts @@ -0,0 +1,7 @@ +import { runCli } from './cli.ts'; + +const exitCode = runCli(process.argv.slice(2)); + +if (typeof exitCode === 'number' && exitCode !== 0) { + process.exit(exitCode); +} diff --git a/packages/cellix/local-dev/src/cli.ts b/packages/cellix/local-dev/src/cli.ts new file mode 100644 index 000000000..1d9e1da4c --- /dev/null +++ b/packages/cellix/local-dev/src/cli.ts @@ -0,0 +1,121 @@ +import { syncApiLocalSettings } from './api-local-settings.ts'; +import { runAzureFunctionsDev, runAzuriteDev, runDocusaurusDev, runTsxDev, runViteDev, type TsxDevProfile, type ViteDevProfile } from './runners.ts'; + +type CliCommand = 'vite' | 'docusaurus' | 'azure-functions' | 'tsx' | 'azurite' | 'sync-api-local-settings'; + +type FlagMap = Record; + +function parseFlags(args: string[]): { flags: FlagMap; positionals: string[] } { + const flags: FlagMap = {}; + const positionals: string[] = []; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (!arg) { + continue; + } + if (arg === '--help' || arg === '-h') { + flags['help'] = 'true'; + continue; + } + + if (arg.startsWith('--')) { + const key = arg.slice(2); + const value = args[index + 1]; + if (!value || value.startsWith('--')) { + throw new Error(`[local-dev] Missing value for --${key}`); + } + flags[key] = value; + index += 1; + continue; + } + + positionals.push(arg); + } + + return { flags, positionals }; +} + +function usage(): string { + return [ + 'Usage:', + ' cellix-local-dev vite --profile ', + ' cellix-local-dev docusaurus', + ' cellix-local-dev azure-functions', + ' cellix-local-dev tsx --profile [--entry src/index.ts]', + ' cellix-local-dev azurite', + ' cellix-local-dev sync-api-local-settings [e2e]', + ].join('\n'); +} + +function asViteProfile(value: string | undefined): ViteDevProfile { + if (value === 'ui-community' || value === 'ui-staff') { + return value; + } + throw new Error(`[local-dev] Invalid vite profile "${value ?? ''}"`); +} + +function asTsxProfile(value: string | undefined): TsxDevProfile { + if (value === 'oauth2-mock' || value === 'mongo-memory-mock') { + return value; + } + throw new Error(`[local-dev] Invalid tsx profile "${value ?? ''}"`); +} + +/** + * Runs the `cellix-local-dev` CLI. + */ +export function runCli(argv = process.argv.slice(2)): number { + const [command, ...rest] = argv as [CliCommand | undefined, ...string[]]; + + if (!command) { + console.error(usage()); + return 1; + } + + try { + const { flags, positionals } = parseFlags(rest); + if (flags['help'] === 'true') { + console.log(usage()); + return 0; + } + + switch (command) { + case 'vite': + runViteDev(asViteProfile(flags['profile'])); + return 0; + case 'docusaurus': + runDocusaurusDev(); + return 0; + case 'azure-functions': + runAzureFunctionsDev(); + return 0; + case 'tsx': + runTsxDev(asTsxProfile(flags['profile']), { + ...(flags['entry'] ? { entry: flags['entry'] } : {}), + }); + return 0; + case 'azurite': + runAzuriteDev(); + return 0; + case 'sync-api-local-settings': + if (positionals[0] && positionals[0] !== 'e2e') { + throw new Error(`[local-dev] Invalid sync-api-local-settings mode "${positionals[0]}"`); + } + syncApiLocalSettings( + positionals[0] === 'e2e' + ? { + mode: 'e2e', + } + : {}, + ); + return 0; + default: + console.error(usage()); + return 1; + } + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + return 1; + } +} diff --git a/packages/cellix/local-dev/src/dev-process.ts b/packages/cellix/local-dev/src/dev-process.ts new file mode 100644 index 000000000..ec9394fca --- /dev/null +++ b/packages/cellix/local-dev/src/dev-process.ts @@ -0,0 +1,24 @@ +import type { ChildProcess } from 'node:child_process'; + +/** + * Returns true for interrupt-style exits that should be treated as graceful + * shutdowns when Turbo or the terminal stops a persistent dev process. + */ +export function isGracefulInterruptExit(signal: NodeJS.Signals | null | undefined, code: number | null | undefined): boolean { + return signal === 'SIGINT' || signal === 'SIGTERM' || signal === 'SIGQUIT' || code === 130 || code === 143; +} + +/** + * Forwards a spawned child process exit code back to the parent process while + * treating common interrupt exits as a successful shutdown. + */ +export function forwardChildExit(child: ChildProcess): void { + child.on('exit', (code, signal) => { + if (isGracefulInterruptExit(signal, code)) { + process.exitCode = 0; + return; + } + + process.exitCode = code ?? 1; + }); +} diff --git a/packages/cellix/local-dev/src/hostnames.ts b/packages/cellix/local-dev/src/hostnames.ts new file mode 100644 index 000000000..1afa4c8ae --- /dev/null +++ b/packages/cellix/local-dev/src/hostnames.ts @@ -0,0 +1,91 @@ +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { type ResolveWorkspaceRootOptions, resolveWorkspaceRoot } from './workspace.ts'; + +export interface PortlessHostnames { + uiCommunity: string; + uiStaff: string; + api: string; + mockAuth: string; + docs: string; +} + +export type PortlessHostnameKey = keyof PortlessHostnames; + +interface ResolvePortlessHostnamesOptions extends ResolveWorkspaceRootOptions { + env?: NodeJS.ProcessEnv; +} + +type DotEnvValues = Record; + +export const PORTLESS_PORT = 1355; + +function readDotEnv(filePath: string): DotEnvValues { + if (!existsSync(filePath)) return {}; + + const result: DotEnvValues = {}; + for (const line of readFileSync(filePath, 'utf8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + + const eqIdx = trimmed.indexOf('='); + if (eqIdx === -1) continue; + result[trimmed.slice(0, eqIdx)] = trimmed.slice(eqIdx + 1); + } + + return result; +} + +function hostnameFrom(url: string): string | null { + try { + return new URL(url).hostname; + } catch { + return null; + } +} + +function hostnameFor(key: string, values: DotEnvValues, env: NodeJS.ProcessEnv): string | null { + return hostnameFrom(env[key] ?? values[key] ?? ''); +} + +function applyWorktreeSuffix(hostname: string, worktreeName: string | undefined): string { + if (!worktreeName) return hostname; + return hostname.replace('.localhost', `.${worktreeName}.localhost`); +} + +/** + * Resolves the local hostnames shared by the main Cellix dev applications. + */ +export function resolvePortlessHostnames(options: ResolvePortlessHostnamesOptions = {}): PortlessHostnames { + const env = options.env ?? process.env; + const workspaceRoot = resolveWorkspaceRoot(options); + const uiEnv = readDotEnv(path.join(workspaceRoot, 'apps', 'ui-community', '.env')); + const staffEnv = readDotEnv(path.join(workspaceRoot, 'apps', 'ui-staff', '.env')); + + const uiCommunity = hostnameFor('VITE_APP_UI_COMMUNITY_BASE_URL', uiEnv, env); + const api = hostnameFor('VITE_COMMON_API_ENDPOINT', uiEnv, env); + const mockAuth = hostnameFor('VITE_APP_UI_COMMUNITY_B2C_AUTHORITY', uiEnv, env); + const uiStaff = hostnameFor('VITE_APP_UI_STAFF_AAD_REDIRECT_URI', staffEnv, env); + + if (!uiCommunity || !api || !mockAuth || !uiStaff) { + throw new Error('[local-dev] Could not derive all portless hostnames. Ensure apps/ui-community/.env and apps/ui-staff/.env are present.'); + } + + const worktreeName = env['WORKTREE_NAME']; + const docs = `docs.${uiCommunity}`; + + return { + uiCommunity: applyWorktreeSuffix(uiCommunity, worktreeName), + uiStaff: applyWorktreeSuffix(uiStaff, worktreeName), + api: applyWorktreeSuffix(api, worktreeName), + mockAuth: applyWorktreeSuffix(mockAuth, worktreeName), + docs: applyWorktreeSuffix(docs, worktreeName), + }; +} + +/** + * Builds a portless-proxied HTTPS URL for a hostname and optional path. + */ +export function buildPortlessUrl(hostname: string, relativePath = ''): string { + return `https://${hostname}:${PORTLESS_PORT}${relativePath}`; +} diff --git a/packages/cellix/local-dev/src/index.test.ts b/packages/cellix/local-dev/src/index.test.ts new file mode 100644 index 000000000..d0bfe34c1 --- /dev/null +++ b/packages/cellix/local-dev/src/index.test.ts @@ -0,0 +1,137 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { + applyApiLocalSettingsOverrides, + buildPortlessUrl, + buildViteArgs, + getAzuriteConnectionString, + getAzuritePorts, + getMongoConnectionString, + getMongoPort, + getWorktreePortOffset, + PORTLESS_PORT, + resolvePortlessHostnames, + resolveWorkspaceRoot, +} from '@cellix/local-dev'; +import { describe, expect, it } from 'vitest'; + +function createWorkspaceFixture(): string { + const root = mkdtempSync(path.join(tmpdir(), 'cellix-local-dev-')); + writeFileSync(path.join(root, 'pnpm-workspace.yaml'), 'packages:\n - "apps/*"\n'); + mkdirSync(path.join(root, 'apps', 'ui-community'), { recursive: true }); + mkdirSync(path.join(root, 'apps', 'ui-staff'), { recursive: true }); + mkdirSync(path.join(root, 'apps', 'api'), { recursive: true }); + + writeFileSync( + path.join(root, 'apps', 'ui-community', '.env'), + [ + 'VITE_APP_UI_COMMUNITY_BASE_URL=https://ownercommunity.localhost:1355', + 'VITE_COMMON_API_ENDPOINT=https://data-access.ownercommunity.localhost:1355/api/graphql', + 'VITE_APP_UI_COMMUNITY_B2C_AUTHORITY=https://mock-auth.ownercommunity.localhost:1355/community', + ].join('\n'), + ); + writeFileSync(path.join(root, 'apps', 'ui-staff', '.env'), 'VITE_APP_UI_STAFF_AAD_REDIRECT_URI=https://staff.ownercommunity.localhost:1355/auth-redirect\n'); + writeFileSync( + path.join(root, 'apps', 'api', 'local.settings.json'), + JSON.stringify( + { + Values: { + COSMOSDB_CONNECTION_STRING: 'mongodb://127.0.0.1:50000/test?replicaSet=rs0', + STORAGE_ACCOUNT_NAME: 'devstoreaccount1', + STORAGE_ACCOUNT_KEY: 'key', + }, + }, + null, + 2, + ), + ); + + return root; +} + +describe('@cellix/local-dev', () => { + it('resolves the workspace root from a nested directory', () => { + const workspaceRoot = createWorkspaceFixture(); + const nestedDir = path.join(workspaceRoot, 'apps', 'ui-community'); + + expect(resolveWorkspaceRoot({ startDir: nestedDir })).toBe(workspaceRoot); + }); + + it('derives shared hostnames and applies the worktree suffix', () => { + const workspaceRoot = createWorkspaceFixture(); + + expect( + resolvePortlessHostnames({ + startDir: path.join(workspaceRoot, 'apps', 'ui-community'), + env: { WORKTREE_NAME: 'feature-123' }, + }), + ).toEqual({ + uiCommunity: 'ownercommunity.feature-123.localhost', + uiStaff: 'staff.ownercommunity.feature-123.localhost', + api: 'data-access.ownercommunity.feature-123.localhost', + mockAuth: 'mock-auth.ownercommunity.feature-123.localhost', + docs: 'docs.ownercommunity.feature-123.localhost', + }); + expect(buildPortlessUrl('ownercommunity.localhost')).toBe(`https://ownercommunity.localhost:${PORTLESS_PORT}`); + }); + + it('builds shared Vite args including e2e mode', () => { + expect( + buildViteArgs({ + host: '0.0.0.0', + port: '4444', + env: { E2E: 'true' }, + }), + ).toEqual(['--host', '0.0.0.0', '--port', '4444', '--mode', 'e2e']); + }); + + it('derives deterministic worktree ports and connection strings', () => { + const workspaceRoot = createWorkspaceFixture(); + const env = { WORKTREE_NAME: 'feature-123' }; + + expect(getWorktreePortOffset('feature-123')).toBeGreaterThanOrEqual(100); + expect(getMongoPort('feature-123')).toBe(50000 + getWorktreePortOffset('feature-123')); + expect(getAzuritePorts('feature-123')).toEqual({ + blob: 10000 + getWorktreePortOffset('feature-123'), + queue: 10001 + getWorktreePortOffset('feature-123'), + table: 10002 + getWorktreePortOffset('feature-123'), + }); + expect( + getMongoConnectionString({ + startDir: path.join(workspaceRoot, 'apps', 'api'), + env, + }), + ).toContain(`:${getMongoPort('feature-123')}/test?replicaSet=rs0`); + expect( + getAzuriteConnectionString({ + startDir: path.join(workspaceRoot, 'apps', 'api'), + env, + }), + ).toContain(`BlobEndpoint=http://127.0.0.1:${getAzuritePorts('feature-123').blob}/devstoreaccount1`); + }); + + it('applies worktree and runtime overrides to API local settings', () => { + const workspaceRoot = createWorkspaceFixture(); + const settings = { + Values: { + STORAGE_ACCOUNT_NAME: 'devstoreaccount1', + STORAGE_ACCOUNT_KEY: 'key', + AZURE_STORAGE_CONNECTION_STRING: 'UseDevelopmentStorage=true', + }, + }; + + const updated = applyApiLocalSettingsOverrides(settings, { + workspaceRoot, + env: { + WORKTREE_NAME: 'feature-123', + COSMOSDB_CONNECTION_STRING: 'mongodb://127.0.0.1:61234/override?replicaSet=rs0', + }, + }); + + expect(updated.Values?.['ACCOUNT_PORTAL_OIDC_ISSUER']).toBe('https://mock-auth.ownercommunity.feature-123.localhost:1355/community'); + expect(updated.Values?.['STAFF_PORTAL_OIDC_ENDPOINT']).toBe('https://mock-auth.ownercommunity.feature-123.localhost:1355/staff/.well-known/jwks.json'); + expect(updated.Values?.['COSMOSDB_CONNECTION_STRING']).toBe('mongodb://127.0.0.1:61234/override?replicaSet=rs0'); + expect(updated.Values?.['AZURE_STORAGE_CONNECTION_STRING']).not.toBe('UseDevelopmentStorage=true'); + }); +}); diff --git a/packages/cellix/local-dev/src/index.ts b/packages/cellix/local-dev/src/index.ts new file mode 100644 index 000000000..2cdaacbbf --- /dev/null +++ b/packages/cellix/local-dev/src/index.ts @@ -0,0 +1,31 @@ +export { applyApiLocalSettingsOverrides, syncApiLocalSettings } from './api-local-settings.ts'; +export { runCli } from './cli.ts'; +export { forwardChildExit, isGracefulInterruptExit } from './dev-process.ts'; +export { + buildPortlessUrl, + PORTLESS_PORT, + type PortlessHostnameKey, + type PortlessHostnames, + resolvePortlessHostnames, +} from './hostnames.ts'; +export { + type RunnerOptions, + runAzureFunctionsDev, + runAzuriteDev, + runDocusaurusDev, + runTsxDev, + runViteDev, + type TsxDevProfile, + type ViteDevProfile, +} from './runners.ts'; +export { type BuildViteArgsOptions, buildViteArgs, isE2E } from './vite.ts'; +export { type ResolveWorkspaceRootOptions, resolveWorkspaceRoot } from './workspace.ts'; +export { + type AzuritePorts, + type ConnectionStringOptions, + getAzuriteConnectionString, + getAzuritePorts, + getMongoConnectionString, + getMongoPort, + getWorktreePortOffset, +} from './worktree-ports.ts'; diff --git a/packages/cellix/local-dev/src/runners.ts b/packages/cellix/local-dev/src/runners.ts new file mode 100644 index 000000000..aecd430ac --- /dev/null +++ b/packages/cellix/local-dev/src/runners.ts @@ -0,0 +1,243 @@ +import { type ChildProcess, spawn } from 'node:child_process'; +import os from 'node:os'; +import path from 'node:path'; +import { forwardChildExit, isGracefulInterruptExit } from './dev-process.ts'; +import { buildPortlessUrl, type PortlessHostnameKey, resolvePortlessHostnames } from './hostnames.ts'; +import { buildViteArgs } from './vite.ts'; +import { type ResolveWorkspaceRootOptions, resolveWorkspaceRoot } from './workspace.ts'; +import { getAzuriteConnectionString, getAzuritePorts, getMongoConnectionString, getMongoPort } from './worktree-ports.ts'; + +export type ViteDevProfile = 'ui-community' | 'ui-staff'; +export type TsxDevProfile = 'oauth2-mock' | 'mongo-memory-mock'; + +export interface RunnerOptions extends ResolveWorkspaceRootOptions { + env?: NodeJS.ProcessEnv; +} + +interface TsxRunnerOptions extends RunnerOptions { + entry?: string; +} + +type OverrideSpec = Record; + +function spawnInherited(command: string, args: string[], options: { env?: NodeJS.ProcessEnv } = {}): ChildProcess { + return spawn(command, args, { + stdio: 'inherit', + env: options.env, + }); +} + +function applyPortlessEnvOverrides(childEnv: NodeJS.ProcessEnv, overrideSpec: OverrideSpec, options: RunnerOptions, settings: { preserveExisting?: boolean } = {}): void { + const env = options.env ?? process.env; + if (!env['WORKTREE_NAME']) return; + + const hostnames = resolvePortlessHostnames(options); + for (const [key, target] of Object.entries(overrideSpec)) { + if (settings.preserveExisting && childEnv[key]) { + continue; + } + + childEnv[key] = buildPortlessUrl(hostnames[target.hostname], target.path ?? ''); + } +} + +/** + * Starts a Vite dev process using one of the shared Cellix UI profiles. + */ +export function runViteDev(profile: ViteDevProfile, options: RunnerOptions = {}): ChildProcess { + const env = options.env ?? process.env; + const childEnv = { ...env }; + + if (profile === 'ui-community') { + applyPortlessEnvOverrides( + childEnv, + { + VITE_APP_UI_COMMUNITY_B2C_AUTHORITY: { + hostname: 'mockAuth', + path: '/community', + }, + VITE_APP_UI_COMMUNITY_B2C_REDIRECT_URI: { + hostname: 'uiCommunity', + path: '/auth-redirect', + }, + VITE_COMMON_API_ENDPOINT: { hostname: 'api', path: '/api/graphql' }, + VITE_APP_UI_COMMUNITY_BASE_URL: { hostname: 'uiCommunity' }, + }, + options, + ); + } else { + applyPortlessEnvOverrides( + childEnv, + { + VITE_APP_UI_STAFF_AAD_AUTHORITY: { hostname: 'mockAuth', path: '/staff' }, + VITE_APP_UI_STAFF_AAD_REDIRECT_URI: { + hostname: 'uiStaff', + path: '/auth-redirect', + }, + VITE_COMMON_API_ENDPOINT: { hostname: 'api', path: '/api/graphql' }, + }, + options, + ); + } + + const child = spawnInherited( + 'vite', + buildViteArgs({ + ...(env['HOST'] ? { host: env['HOST'] } : {}), + ...(env['PORT'] ? { port: env['PORT'] } : {}), + env, + }), + { env: childEnv }, + ); + forwardChildExit(child); + return child; +} + +/** + * Starts the Docusaurus dev server with the shared local-dev defaults. + */ +export function runDocusaurusDev(options: RunnerOptions = {}): ChildProcess { + const env = options.env ?? process.env; + const child = spawnInherited('docusaurus', ['start', '--port', env['PORT'] ?? '3001', '--host', '127.0.0.1', '--no-open']); + forwardChildExit(child); + return child; +} + +/** + * Starts the API Azure Functions process with shared CA, CORS, and worktree overrides. + */ +export function runAzureFunctionsDev(options: RunnerOptions = {}): ChildProcess { + const env = options.env ?? process.env; + const envPort = env['PORT']; + + if (!envPort) { + throw new Error('[local-dev] PORT environment variable is not set. Start this command through portless.'); + } + + const childEnv: NodeJS.ProcessEnv = { + ...env, + NODE_EXTRA_CA_CERTS: env['PORTLESS_CA_PATH'] ?? path.join(os.homedir(), '.portless', 'ca.pem'), + NODE_OPTIONS: `${env['NODE_OPTIONS'] ?? ''} --use-system-ca`.trim(), + }; + + if (env['WORKTREE_NAME']) { + applyPortlessEnvOverrides( + childEnv, + { + ACCOUNT_PORTAL_OIDC_ISSUER: { hostname: 'mockAuth', path: '/community' }, + ACCOUNT_PORTAL_OIDC_ENDPOINT: { + hostname: 'mockAuth', + path: '/community/.well-known/jwks.json', + }, + STAFF_PORTAL_OIDC_ISSUER: { hostname: 'mockAuth', path: '/staff' }, + STAFF_PORTAL_OIDC_ENDPOINT: { + hostname: 'mockAuth', + path: '/staff/.well-known/jwks.json', + }, + }, + options, + { preserveExisting: true }, + ); + childEnv['COSMOSDB_CONNECTION_STRING'] ??= getMongoConnectionString(options); + childEnv['AZURE_STORAGE_CONNECTION_STRING'] ??= getAzuriteConnectionString(options); + childEnv['AzureWebJobsStorage'] ??= getAzuriteConnectionString(options); + childEnv['languageWorkers__node__arguments'] ??= ''; + } + + const child = spawnInherited('func', ['start', '--typescript', '--script-root', 'deploy/', '--port', envPort, '--cors', '*'], { env: childEnv }); + forwardChildExit(child); + return child; +} + +/** + * Starts a TSX-backed mock service using one of the shared Cellix profiles. + */ +export function runTsxDev(profile: TsxDevProfile, options: TsxRunnerOptions = {}): ChildProcess { + const env = options.env ?? process.env; + const childEnv: NodeJS.ProcessEnv = { ...env }; + + if (profile === 'oauth2-mock') { + applyPortlessEnvOverrides( + childEnv, + { + BASE_URL: { hostname: 'mockAuth' }, + VITE_APP_UI_COMMUNITY_B2C_REDIRECT_URI: { + hostname: 'uiCommunity', + path: '/auth-redirect', + }, + VITE_APP_UI_STAFF_AAD_REDIRECT_URI: { + hostname: 'uiStaff', + path: '/auth-redirect', + }, + }, + options, + ); + } else if (profile === 'mongo-memory-mock') { + childEnv['PORT'] = String(getMongoPort(env['WORKTREE_NAME'])); + } + + const child = spawnInherited('tsx', [options.entry ?? 'src/index.ts'], { env: childEnv }); + forwardChildExit(child); + return child; +} + +/** + * Starts the three Azurite worker processes with worktree-scoped ports and storage paths. + */ +export function runAzuriteDev(options: RunnerOptions = {}): ChildProcess[] { + const env = options.env ?? process.env; + const workspaceRoot = resolveWorkspaceRoot(options); + const ports = getAzuritePorts(env['WORKTREE_NAME']); + const storageSuffix = env['WORKTREE_NAME'] ? `-${env['WORKTREE_NAME']}` : ''; + + const procSpecs: Array<[string, string[]]> = [ + ['azurite-blob', ['--silent', '--blobPort', String(ports.blob), '--location', path.join(workspaceRoot, `__blobstorage__${storageSuffix}`)]], + ['azurite-queue', ['--silent', '--queuePort', String(ports.queue), '--location', path.join(workspaceRoot, `__queuestorage__${storageSuffix}`)]], + ['azurite-table', ['--silent', '--tablePort', String(ports.table), '--location', path.join(workspaceRoot, `__tablestorage__${storageSuffix}`)]], + ]; + + const procs = procSpecs.map(([command, args]) => { + const proc = spawnInherited(command, args); + proc.on('error', (error) => { + console.error(`[azurite] failed to start ${command}: ${error.message}`); + for (const runningProc of procs) { + runningProc.kill(); + } + process.exit(1); + }); + return proc; + }); + + console.log(`[azurite] started (blob=${ports.blob}, queue=${ports.queue}, table=${ports.table})`); + + let exited = 0; + for (const proc of procs) { + proc.on('exit', (code, signal) => { + if (isGracefulInterruptExit(signal, code)) { + if (++exited === procs.length) { + process.exit(0); + } + return; + } + + console.error(`[azurite] process exited unexpectedly: code=${code} signal=${signal}`); + for (const runningProc of procs) { + runningProc.kill(); + } + process.exit(code ?? 1); + }); + } + + process.on('SIGINT', () => { + for (const proc of procs) { + proc.kill('SIGINT'); + } + }); + process.on('SIGTERM', () => { + for (const proc of procs) { + proc.kill('SIGTERM'); + } + }); + + return procs; +} diff --git a/packages/cellix/local-dev/src/vite.ts b/packages/cellix/local-dev/src/vite.ts new file mode 100644 index 000000000..7944136f1 --- /dev/null +++ b/packages/cellix/local-dev/src/vite.ts @@ -0,0 +1,31 @@ +export interface BuildViteArgsOptions { + host?: string; + port?: string; + env?: NodeJS.ProcessEnv; +} + +/** + * Returns true when the current process is running in an e2e-oriented mode. + */ +export function isE2E(env: NodeJS.ProcessEnv = process.env): boolean { + return ['1', 'true', 'yes'].includes((env['E2E'] ?? '').toLowerCase()); +} + +/** + * Builds the shared argument list for Vite dev startup across Cellix apps. + */ +export function buildViteArgs(options: BuildViteArgsOptions = {}): string[] { + const { host = '127.0.0.1', port, env = process.env } = options; + const args = ['--host', host]; + + if (port) { + args.push('--port', port); + } + + const viteMode = env['E2E_VITE_MODE'] ?? (isE2E(env) || env['TF_BUILD'] ? 'e2e' : undefined); + if (viteMode) { + args.push('--mode', viteMode); + } + + return args; +} diff --git a/packages/cellix/local-dev/src/workspace.ts b/packages/cellix/local-dev/src/workspace.ts new file mode 100644 index 000000000..19db913ff --- /dev/null +++ b/packages/cellix/local-dev/src/workspace.ts @@ -0,0 +1,26 @@ +import { existsSync } from 'node:fs'; +import path from 'node:path'; + +export interface ResolveWorkspaceRootOptions { + startDir?: string; +} + +/** + * Finds the Cellix workspace root by walking upward until `pnpm-workspace.yaml` + * is present. + */ +export function resolveWorkspaceRoot(options: ResolveWorkspaceRootOptions = {}): string { + let currentDir = path.resolve(options.startDir ?? process.cwd()); + + for (;;) { + if (existsSync(path.join(currentDir, 'pnpm-workspace.yaml'))) { + return currentDir; + } + + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) { + throw new Error(`[local-dev] Could not find pnpm-workspace.yaml above ${options.startDir ?? process.cwd()}`); + } + currentDir = parentDir; + } +} diff --git a/packages/cellix/local-dev/src/worktree-ports.ts b/packages/cellix/local-dev/src/worktree-ports.ts new file mode 100644 index 000000000..d9b4ff806 --- /dev/null +++ b/packages/cellix/local-dev/src/worktree-ports.ts @@ -0,0 +1,107 @@ +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { type ResolveWorkspaceRootOptions, resolveWorkspaceRoot } from './workspace.ts'; + +type SettingsValues = Record; + +export interface AzuritePorts { + blob: number; + queue: number; + table: number; +} + +export interface ConnectionStringOptions extends ResolveWorkspaceRootOptions { + env?: NodeJS.ProcessEnv; + values?: SettingsValues; +} + +function readApiLocalSettingsValues(workspaceRoot: string): SettingsValues { + const candidatePaths = [path.join(workspaceRoot, 'apps', 'api', 'deploy', 'local.settings.json'), path.join(workspaceRoot, 'apps', 'api', 'local.settings.json')]; + + for (const settingsPath of candidatePaths) { + if (!existsSync(settingsPath)) continue; + const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) as { + Values?: SettingsValues; + }; + return settings.Values ?? {}; + } + + return {}; +} + +function getSetting(name: string, options: ConnectionStringOptions, workspaceRoot: string): string | undefined { + return options.env?.[name] ?? options.values?.[name] ?? readApiLocalSettingsValues(workspaceRoot)[name]; +} + +/** + * Returns a deterministic worktree port offset in increments of 100. + */ +export function getWorktreePortOffset(worktreeName = process.env['WORKTREE_NAME']): number { + if (!worktreeName) return 0; + + let hash = 0; + for (const char of worktreeName) { + hash = ((hash << 5) - hash + char.charCodeAt(0)) | 0; + } + + return ((Math.abs(hash) % 49) + 1) * 100; +} + +/** + * Returns the MongoDB port for the current worktree. + */ +export function getMongoPort(worktreeName = process.env['WORKTREE_NAME']): number { + return 50000 + getWorktreePortOffset(worktreeName); +} + +/** + * Returns the Azurite ports for the current worktree. + */ +export function getAzuritePorts(worktreeName = process.env['WORKTREE_NAME']): AzuritePorts { + const offset = getWorktreePortOffset(worktreeName); + + return { + blob: 10000 + offset, + queue: 10001 + offset, + table: 10002 + offset, + }; +} + +/** + * Returns the Azurite connection string for the current worktree. + */ +export function getAzuriteConnectionString(options: ConnectionStringOptions = {}): string { + const workspaceRoot = resolveWorkspaceRoot(options); + const ports = getAzuritePorts(options.env?.['WORKTREE_NAME'] ?? process.env['WORKTREE_NAME']); + if (ports.blob === 10000) return 'UseDevelopmentStorage=true'; + + const accountName = getSetting('STORAGE_ACCOUNT_NAME', options, workspaceRoot); + const accountKey = getSetting('STORAGE_ACCOUNT_KEY', options, workspaceRoot); + if (!accountName || !accountKey) { + throw new Error('[local-dev] STORAGE_ACCOUNT_NAME and STORAGE_ACCOUNT_KEY must be set to build a worktree Azurite connection string'); + } + + return [ + 'DefaultEndpointsProtocol=http', + `AccountName=${accountName}`, + `AccountKey=${accountKey}`, + `BlobEndpoint=http://127.0.0.1:${ports.blob}/${accountName}`, + `QueueEndpoint=http://127.0.0.1:${ports.queue}/${accountName}`, + `TableEndpoint=http://127.0.0.1:${ports.table}/${accountName}`, + ].join(';'); +} + +/** + * Reads the API Mongo connection string and patches in the worktree-specific port. + */ +export function getMongoConnectionString(options: ConnectionStringOptions = {}): string { + const workspaceRoot = resolveWorkspaceRoot(options); + const base = getSetting('COSMOSDB_CONNECTION_STRING', options, workspaceRoot); + if (!base) { + throw new Error('[local-dev] COSMOSDB_CONNECTION_STRING must be set'); + } + + const url = new URL(base); + url.port = String(getMongoPort(options.env?.['WORKTREE_NAME'] ?? process.env['WORKTREE_NAME'])); + return url.toString(); +} diff --git a/packages/cellix/local-dev/tsconfig.json b/packages/cellix/local-dev/tsconfig.json new file mode 100644 index 000000000..3b79c6fd1 --- /dev/null +++ b/packages/cellix/local-dev/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@cellix/config-typescript/node", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/cellix/local-dev/tsconfig.vitest.json b/packages/cellix/local-dev/tsconfig.vitest.json new file mode 100644 index 000000000..0d3bc7fca --- /dev/null +++ b/packages/cellix/local-dev/tsconfig.vitest.json @@ -0,0 +1,9 @@ +{ + "extends": ["./tsconfig.json", "@cellix/config-typescript/vitest"], + "compilerOptions": { + "paths": { + "@cellix/local-dev": ["./src/index.ts"] + } + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/cellix/local-dev/vitest.config.ts b/packages/cellix/local-dev/vitest.config.ts new file mode 100644 index 000000000..2c2d02b84 --- /dev/null +++ b/packages/cellix/local-dev/vitest.config.ts @@ -0,0 +1,18 @@ +import { join } from 'node:path'; +import { getDirnameFromImportMetaUrl, nodeConfig } from '@cellix/config-vitest'; +import { mergeConfig } from 'vitest/config'; + +const dirname = getDirnameFromImportMetaUrl(import.meta.url); + +export default mergeConfig(nodeConfig, { + test: { + typecheck: { + tsconfig: './tsconfig.vitest.json', + }, + }, + resolve: { + alias: { + '@cellix/local-dev': join(dirname, 'src/index.ts'), + }, + }, +}); diff --git a/packages/cellix/serenity-framework/src/servers/process-test-server.test.ts b/packages/cellix/serenity-framework/src/servers/process-test-server.test.ts index 51f05e970..1bb126c2f 100644 --- a/packages/cellix/serenity-framework/src/servers/process-test-server.test.ts +++ b/packages/cellix/serenity-framework/src/servers/process-test-server.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { ProcessTestServer } from './index.ts'; async function waitUntil(predicate: () => boolean, timeoutMs = 2_000): Promise { @@ -12,6 +12,10 @@ async function waitUntil(predicate: () => boolean, timeoutMs = 2_000): Promise { + afterEach(() => { + vi.restoreAllMocks(); + }); + it('starts a process and trusts the ready marker when probing is disabled', async () => { const server = new ProcessTestServer({ serverName: 'marker-only server', @@ -50,4 +54,29 @@ describe('ProcessTestServer', () => { expect(server.isRunning()).toBe(false); }); + + it('reuses an already-running server when a replacement exits with EADDRINUSE', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('ok', { status: 200 })), + ); + + const server = new ProcessTestServer({ + serverName: 'reusable-existing server', + executable: process.execPath, + spawnArgs: ['-e', "console.error('Error: listen EADDRINUSE: address already in use 127.0.0.1:4965'); process.exit(1)"], + cwd: process.cwd(), + readyMarker: 'READY', + getUrl: () => 'http://127.0.0.1:4965', + isReusableExit: (stderrOutput) => stderrOutput.includes('EADDRINUSE'), + shutdownTimeoutMs: 500, + }); + + try { + await server.start(); + expect(server.isRunning()).toBe(false); + } finally { + await server.stop(); + } + }); }); diff --git a/packages/cellix/serenity-framework/src/servers/process-test-server.ts b/packages/cellix/serenity-framework/src/servers/process-test-server.ts index 6491e0732..da2f6ddc6 100644 --- a/packages/cellix/serenity-framework/src/servers/process-test-server.ts +++ b/packages/cellix/serenity-framework/src/servers/process-test-server.ts @@ -223,7 +223,13 @@ export class ProcessTestServer implements TestServer { clearTimeout(timeout); if (this.options.isReusableExit?.(stderrOutput)) { - resolve(); + this.waitForProbeReady(startupDeadline, startupTimeout) + .then(() => { + resolve(); + }) + .catch((error: unknown) => { + reject(error); + }); return; } diff --git a/packages/ocom-verification/e2e-tests/src/test-server-factories.ts b/packages/ocom-verification/e2e-tests/src/test-server-factories.ts index 83d72cb67..be75b270e 100644 --- a/packages/ocom-verification/e2e-tests/src/test-server-factories.ts +++ b/packages/ocom-verification/e2e-tests/src/test-server-factories.ts @@ -1,4 +1,3 @@ -import { join } from 'node:path'; import { ProcessTestServer } from '@cellix/serenity-framework/servers'; import { getAzuritePorts } from '@ocom-verification/verification-shared/environment'; import { appPaths } from './shared/environment/app-paths.ts'; @@ -47,19 +46,14 @@ export function createTestApiServer(getMongoConnectionString: () => string): Pro export function createTestAzuriteServer(): ProcessTestServer { return new ProcessTestServer({ cwd: appPaths.apiDir, - executable: 'node', - extraEnv: () => { - const binDir = join(appPaths.apiDir, 'node_modules', '.bin'); - const { PATH: pathValue = '' } = process.env; - return { PATH: `${binDir}:${pathValue}` }; - }, + executable: 'pnpm', getUrl: () => `http://127.0.0.1:${getAzuritePorts().blob}`, isAlreadyRunning: async () => false, isReusableExit: (stderrOutput) => stderrOutput.includes('EADDRINUSE'), probe: false, readyMarker: '[azurite] started', serverName: 'TestAzuriteServer', - spawnArgs: ['start-azurite.mjs'], + spawnArgs: ['run', 'azurite'], }); } @@ -68,6 +62,7 @@ export function createTestOAuth2Server(): ProcessTestServer { cwd: appPaths.oauth2MockDir, executable: 'pnpm', getUrl: () => mockOidcIssuer, + isReusableExit: (stderrOutput) => stderrOutput.includes('EADDRINUSE'), probe: { url: mockOidcEndpoint, }, diff --git a/packages/ocom-verification/e2e-tests/turbo.json b/packages/ocom-verification/e2e-tests/turbo.json index 5a7a5f821..de20c2818 100644 --- a/packages/ocom-verification/e2e-tests/turbo.json +++ b/packages/ocom-verification/e2e-tests/turbo.json @@ -7,10 +7,9 @@ "src/**/*.ts", "config/**", "$TURBO_ROOT$/apps/api/local-settings.e2e.json", - "$TURBO_ROOT$/apps/api/scripts/sync-local-settings.mjs", "$TURBO_ROOT$/apps/ui-community/.env.e2e", "$TURBO_ROOT$/apps/ui-staff/.env.e2e", - "$TURBO_ROOT$/scripts/local-dev/**", + "$TURBO_ROOT$/packages/cellix/local-dev/src/**", "cucumber.js", "package.json" ], @@ -27,10 +26,9 @@ "src/**/*.ts", "config/**", "$TURBO_ROOT$/apps/api/local-settings.e2e.json", - "$TURBO_ROOT$/apps/api/scripts/sync-local-settings.mjs", "$TURBO_ROOT$/apps/ui-community/.env.e2e", "$TURBO_ROOT$/apps/ui-staff/.env.e2e", - "$TURBO_ROOT$/scripts/local-dev/**", + "$TURBO_ROOT$/packages/cellix/local-dev/src/**", "cucumber.js", "package.json" ], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e935c90aa..d8fd2dd2e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -299,12 +299,15 @@ importers: '@cellix/config-vitest': specifier: workspace:* version: link:../../packages/cellix/config-vitest + '@cellix/local-dev': + specifier: workspace:* + version: link:../../packages/cellix/local-dev '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.6(vitest@4.1.6) azurite: specifier: ^3.35.0 - version: 3.35.0(@azure/core-client@1.10.1)(@types/node@22.19.15) + version: 3.35.0(@azure/core-client@1.10.1)(@types/node@24.10.1) rimraf: specifier: 'catalog:' version: 6.0.1 @@ -316,7 +319,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(jsdom@26.1.0)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(jsdom@26.1.0)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) apps/docs: dependencies: @@ -351,6 +354,9 @@ importers: '@cellix/config-vitest': specifier: workspace:* version: link:../../packages/cellix/config-vitest + '@cellix/local-dev': + specifier: workspace:* + version: link:../../packages/cellix/local-dev '@docusaurus/module-type-aliases': specifier: 3.9.2 version: 3.9.2(esbuild@0.27.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -400,12 +406,12 @@ importers: '@cellix/config-typescript': specifier: workspace:* version: link:../../packages/cellix/config-typescript + '@cellix/local-dev': + specifier: workspace:* + version: link:../../packages/cellix/local-dev rimraf: specifier: 'catalog:' version: 6.0.1 - tsx: - specifier: 'catalog:' - version: 4.21.0 typescript: specifier: 'catalog:' version: 6.0.3 @@ -425,6 +431,9 @@ importers: '@cellix/config-vitest': specifier: workspace:* version: link:../../packages/cellix/config-vitest + '@cellix/local-dev': + specifier: workspace:* + version: link:../../packages/cellix/local-dev '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.6(vitest@4.1.6) @@ -495,6 +504,9 @@ importers: '@cellix/config-vitest': specifier: workspace:* version: link:../../packages/cellix/config-vitest + '@cellix/local-dev': + specifier: workspace:* + version: link:../../packages/cellix/local-dev '@chromatic-com/storybook': specifier: ^4.1.1 version: 4.1.3(storybook@9.1.20(@testing-library/dom@10.4.1)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))) @@ -625,6 +637,9 @@ importers: '@cellix/config-vitest': specifier: workspace:* version: link:../../packages/cellix/config-vitest + '@cellix/local-dev': + specifier: workspace:* + version: link:../../packages/cellix/local-dev '@types/react': specifier: ^19.1.8 version: 19.2.7 @@ -859,6 +874,30 @@ importers: specifier: 'catalog:' version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(jsdom@26.1.0)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + packages/cellix/local-dev: + devDependencies: + '@cellix/config-typescript': + specifier: workspace:* + version: link:../config-typescript + '@cellix/config-vitest': + specifier: workspace:* + version: link:../config-vitest + '@types/node': + specifier: 'catalog:' + version: 22.19.15 + '@vitest/coverage-istanbul': + specifier: 'catalog:' + version: 4.1.6(vitest@4.1.6) + rimraf: + specifier: 'catalog:' + version: 6.0.1 + typescript: + specifier: 'catalog:' + version: 6.0.3 + vitest: + specifier: 'catalog:' + version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(jsdom@26.1.0)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + packages/cellix/mongoose-seedwork: dependencies: '@cellix/domain-seedwork': @@ -19760,7 +19799,7 @@ snapshots: transitivePeerDependencies: - debug - azurite@3.35.0(@azure/core-client@1.10.1)(@types/node@22.19.15): + azurite@3.35.0(@azure/core-client@1.10.1)(@types/node@24.10.1): dependencies: '@azure/ms-rest-js': 1.11.2 applicationinsights: 2.9.8 @@ -19774,9 +19813,9 @@ snapshots: lokijs: 1.5.12 morgan: 1.10.1 multistream: 2.1.1 - mysql2: 3.22.3(@types/node@22.19.15) + mysql2: 3.22.3(@types/node@24.10.1) rimraf: 3.0.2 - sequelize: 6.37.8(mysql2@3.22.3(@types/node@22.19.15))(tedious@16.7.1(@azure/core-client@1.10.1)) + sequelize: 6.37.8(mysql2@3.22.3(@types/node@24.10.1))(tedious@16.7.1(@azure/core-client@1.10.1)) stoppable: 1.1.0 tedious: 16.7.1(@azure/core-client@1.10.1) to-readable-stream: 2.1.0 @@ -23662,9 +23701,9 @@ snapshots: mute-stream@0.0.8: {} - mysql2@3.22.3(@types/node@22.19.15): + mysql2@3.22.3(@types/node@24.10.1): dependencies: - '@types/node': 22.19.15 + '@types/node': 24.10.1 aws-ssl-profiles: 1.1.2 denque: 2.1.0 generate-function: 2.3.1 @@ -25485,7 +25524,7 @@ snapshots: sequelize-pool@7.1.0: {} - sequelize@6.37.8(mysql2@3.22.3(@types/node@22.19.15))(tedious@16.7.1(@azure/core-client@1.10.1)): + sequelize@6.37.8(mysql2@3.22.3(@types/node@24.10.1))(tedious@16.7.1(@azure/core-client@1.10.1)): dependencies: '@types/debug': 4.1.12 '@types/validator': 13.15.10 @@ -25504,7 +25543,7 @@ snapshots: validator: 13.15.35 wkx: 0.5.0 optionalDependencies: - mysql2: 3.22.3(@types/node@22.19.15) + mysql2: 3.22.3(@types/node@24.10.1) tedious: 16.7.1(@azure/core-client@1.10.1) transitivePeerDependencies: - supports-color diff --git a/scripts/local-dev/dev-process-exit.mjs b/scripts/local-dev/dev-process-exit.mjs deleted file mode 100644 index ed0232915..000000000 --- a/scripts/local-dev/dev-process-exit.mjs +++ /dev/null @@ -1,25 +0,0 @@ -/** @typedef {import('node:child_process').ChildProcess} ChildProcess */ - -/** - * @param {NodeJS.Signals | null | undefined} signal - * @param {number | null | undefined} code - * @returns {boolean} - */ -export const isGracefulInterruptExit = (signal, code) => signal === 'SIGINT' || signal === 'SIGTERM' || signal === 'SIGQUIT' || code === 130 || code === 143; - -/** - * Wires a spawned dev child process to forward its exit status to the parent, - * treating Turbo's interrupt signals as graceful exits. Every `start-dev.mjs` - * runner ends with the same handler, so this is the single source of truth. - * @param {ChildProcess} child - * @returns {void} - */ -export function forwardChildExit(child) { - child.on('exit', (code, signal) => { - if (isGracefulInterruptExit(signal, code)) { - process.exitCode = 0; - return; - } - process.exitCode = code ?? 1; - }); -} diff --git a/scripts/local-dev/portless-hostnames.mjs b/scripts/local-dev/portless-hostnames.mjs deleted file mode 100644 index 976c82e92..000000000 --- a/scripts/local-dev/portless-hostnames.mjs +++ /dev/null @@ -1,97 +0,0 @@ -import { existsSync, readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -/** - * @typedef {Record} DotEnvValues - * @typedef {object} PortlessHostnames - * @property {string} uiCommunity - * @property {string} uiStaff - * @property {string} api - * @property {string} mockAuth - * @property {string} docs - */ - -const PORTLESS_PORT = 1355; -const scriptDir = fileURLToPath(new URL('.', import.meta.url)); -const workspaceRoot = resolve(scriptDir, '../..'); - -/** - * @param {string} filePath - * @returns {DotEnvValues} - */ -function readDotEnv(filePath) { - if (!existsSync(filePath)) return {}; - /** @type {DotEnvValues} */ - const result = {}; - for (const line of readFileSync(filePath, 'utf-8').split('\n')) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) continue; - const eqIdx = trimmed.indexOf('='); - if (eqIdx === -1) continue; - result[trimmed.slice(0, eqIdx)] = trimmed.slice(eqIdx + 1); - } - return result; -} - -/** - * @param {string} url - * @returns {string | null} - */ -function hostnameFrom(url) { - try { - return new URL(url).hostname; - } catch { - return null; - } -} - -/** - * @param {string} key - * @param {DotEnvValues} values - * @returns {string | null} - */ -function hostnameFor(key, values) { - return hostnameFrom(process.env[key] ?? values[key] ?? ''); -} - -function applyWorktreeSuffix(hostname, worktreeName) { - if (!worktreeName) return hostname; - return hostname.replace('.localhost', `.${worktreeName}.localhost`); -} - -export function getHostnames() { - const uiEnv = readDotEnv(resolve(workspaceRoot, 'apps/ui-community/.env')); - const staffEnv = readDotEnv(resolve(workspaceRoot, 'apps/ui-staff/.env')); - const wt = process.env.WORKTREE_NAME ?? ''; - - const uiCommunity = hostnameFor('VITE_APP_UI_COMMUNITY_BASE_URL', uiEnv); - const api = hostnameFor('VITE_COMMON_API_ENDPOINT', uiEnv); - const mockAuth = hostnameFor('VITE_APP_UI_COMMUNITY_B2C_AUTHORITY', uiEnv); - const uiStaff = hostnameFor('VITE_APP_UI_STAFF_AAD_REDIRECT_URI', staffEnv); - - if (!uiCommunity || !api || !mockAuth || !uiStaff) { - throw new Error('portless-hostnames: could not derive all hostnames from .env files. ' + 'Ensure apps/ui-community/.env and apps/ui-staff/.env are present.'); - } - const docs = `docs.${uiCommunity}`; - - return { - uiCommunity: applyWorktreeSuffix(uiCommunity, wt), - uiStaff: applyWorktreeSuffix(uiStaff, wt), - api: applyWorktreeSuffix(api, wt), - mockAuth: applyWorktreeSuffix(mockAuth, wt), - docs: applyWorktreeSuffix(docs, wt), - }; -} - -/** - * Builds a full portless-proxied URL for the given hostname and optional path. - * @param {string} hostname - * @param {string} [path] - * @returns {string} - */ -export function buildPortlessUrl(hostname, path = '') { - return `https://${hostname}:${PORTLESS_PORT}${path}`; -} - -export { PORTLESS_PORT }; diff --git a/scripts/local-dev/vite-dev-args.mjs b/scripts/local-dev/vite-dev-args.mjs deleted file mode 100644 index dcc39bc77..000000000 --- a/scripts/local-dev/vite-dev-args.mjs +++ /dev/null @@ -1,26 +0,0 @@ -/** - * @param {NodeJS.ProcessEnv} [env] - * @returns {boolean} - */ -export function isE2E(env = process.env) { - return ['1', 'true', 'yes'].includes((env.E2E ?? '').toLowerCase()); -} - -/** - * @param {{ host?: string; port?: string; env?: NodeJS.ProcessEnv }} [options] - * @returns {string[]} - */ -export function buildViteArgs(options = {}) { - const { host = '127.0.0.1', port, env = process.env } = options; - const args = ['--host', host]; - if (port) { - args.push('--port', port); - } - - const viteMode = env.E2E_VITE_MODE ?? (isE2E(env) || env.TF_BUILD ? 'e2e' : undefined); - if (viteMode) { - args.push('--mode', viteMode); - } - - return args; -} diff --git a/scripts/local-dev/worktree-ports.mjs b/scripts/local-dev/worktree-ports.mjs deleted file mode 100644 index de11700d6..000000000 --- a/scripts/local-dev/worktree-ports.mjs +++ /dev/null @@ -1,130 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -/** - * @typedef {Record} SettingsValues - * @typedef {{ blob: number, queue: number, table: number }} AzuritePorts - */ - -/** - * Worktree-scoped port computation for service isolation. - * - * When WORKTREE_NAME is set, each worktree gets a deterministic port offset - * so MongoDB and Azurite instances don't collide between worktrees. - * - * Default worktree (no WORKTREE_NAME): uses base ports (50000, 10000–10002). - * Named worktree: base + deterministic offset derived from the name's hash. - * - * Collision safety: the unset case always returns 0, and any named worktree - * always returns ≥ 100, so the default worktree can never collide with a - * named one. With 49 buckets the chance of two *named* worktrees colliding - * is ~2% per pair — acceptable for the typical 1–3 concurrent worktrees. - */ - -const workspaceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); -const apiLocalSettingsPaths = [path.join(workspaceRoot, 'apps', 'api', 'deploy', 'local.settings.json'), path.join(workspaceRoot, 'apps', 'api', 'local.settings.json')]; -/** @type {SettingsValues | undefined} */ -let apiLocalSettingsValues; - -/** - * @param {string} name - * @param {SettingsValues} [values] - * @returns {string | undefined} - */ -function getSetting(name, values) { - return process.env[name] ?? values?.[name] ?? getApiLocalSetting(name); -} - -/** - * @param {string} name - * @returns {string | undefined} - */ -function getApiLocalSetting(name) { - apiLocalSettingsValues ??= readApiLocalSettingsValues(); - return apiLocalSettingsValues[name]; -} - -/** - * @returns {SettingsValues} - */ -function readApiLocalSettingsValues() { - for (const settingsPath of apiLocalSettingsPaths) { - if (!fs.existsSync(settingsPath)) continue; - const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); - return settings.Values ?? {}; - } - return {}; -} - -/** - * Returns a deterministic port offset in the range [100, 4900] (step 100) - * for the current worktree. Returns 0 when WORKTREE_NAME is not set. - * @returns {number} - */ -export function getWorktreePortOffset() { - const name = process.env.WORKTREE_NAME; - if (!name) return 0; - let hash = 0; - for (const c of name) hash = ((hash << 5) - hash + c.charCodeAt(0)) | 0; - return ((Math.abs(hash) % 49) + 1) * 100; -} - -/** - * MongoDB port for the current worktree. - * @returns {number} - */ -export function getMongoPort() { - return 50000 + getWorktreePortOffset(); -} - -/** - * Azurite blob/queue/table ports for the current worktree. - * @returns {AzuritePorts} - */ -export function getAzuritePorts() { - const offset = getWorktreePortOffset(); - return { - blob: 10000 + offset, - queue: 10001 + offset, - table: 10002 + offset, - }; -} - -/** - * Azurite connection string for worktree-specific ports. - * Returns `UseDevelopmentStorage=true` for the default worktree (port 10000). - * @param {SettingsValues} [values] - * @returns {string} - */ -export function getAzuriteConnectionString(values) { - const ports = getAzuritePorts(); - if (ports.blob === 10000) return 'UseDevelopmentStorage=true'; - const accountName = getSetting('STORAGE_ACCOUNT_NAME', values); - const accountKey = getSetting('STORAGE_ACCOUNT_KEY', values); - if (!accountName || !accountKey) { - throw new Error('[worktree-ports] STORAGE_ACCOUNT_NAME and STORAGE_ACCOUNT_KEY must be set to build a worktree Azurite connection string'); - } - return [ - 'DefaultEndpointsProtocol=http', - `AccountName=${accountName}`, - `AccountKey=${accountKey}`, - `BlobEndpoint=http://127.0.0.1:${ports.blob}/${accountName}`, - `QueueEndpoint=http://127.0.0.1:${ports.queue}/${accountName}`, - `TableEndpoint=http://127.0.0.1:${ports.table}/${accountName}`, - ].join(';'); -} - -/** - * MongoDB connection string with the worktree-specific port patched in. - * Reads COSMOSDB_CONNECTION_STRING from env or local.settings.json and replaces - * the host:port segment. - * @returns {string} - */ -export function getMongoConnectionString() { - const base = getSetting('COSMOSDB_CONNECTION_STRING'); - if (!base) throw new Error('[worktree-ports] COSMOSDB_CONNECTION_STRING must be set'); - const url = new URL(base); - url.port = String(getMongoPort()); - return url.toString(); -} diff --git a/sonar-project.properties b/sonar-project.properties index 0cffddb63..e5b7d641a 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -95,7 +95,7 @@ sonar.test.inclusions=**/*.test.ts,**/*.test.tsx,**/*.spec.ts,**/*.spec.tsx,**/* sonar.exclusions=**/*.config.ts,**/tsconfig.json,**/.storybook/**,**/*.stories.ts,**/*.test.ts,**/*.test.tsx,**/*.generated.ts,**/*.generated.tsx,**/*.d.ts,**/dist/**,**/deploy/**,**/coverage/**,apps/docs/src/test/**,packages/ocom/domain/tests/**,packages/cellix/server-oauth2-mock-seedwork/**,packages/cellix/server-mongodb-memory-mock-seedwork/** # Coverage exclusions -sonar.coverage.exclusions=**/*.config.ts,**/tsconfig.json,**/.storybook/**,**/*.stories.ts,**/*.stories.tsx,**/*.test.ts,**/*.test.tsx,**/*.generated.ts,**/*.generated.tsx,**/*.d.ts,**/dist/**,**/deploy/**,**/coverage/**,apps/docs/src/test/**,build-pipeline/scripts/**,scripts/local-dev/**,packages/ocom/domain/tests/**,packages/cellix/server-oauth2-mock-seedwork/**,packages/cellix/server-mongodb-memory-mock-seedwork/**,packages/ocom/data-sources-mongoose-models/**,packages/ocom/graphql/src/schema/builder/schema-builder.ts,apps/api/src/index.ts,apps/api/src/service-config/**,packages/cellix/archunit-tests/**,packages/ocom-verification/archunit-tests/**,packages/cellix/ui-core/**,apps/ui-community/**,packages/ocom/ui-shared/src/components/organisms/header/index.tsx +sonar.coverage.exclusions=**/*.config.ts,**/tsconfig.json,**/.storybook/**,**/*.stories.ts,**/*.stories.tsx,**/*.test.ts,**/*.test.tsx,**/*.generated.ts,**/*.generated.tsx,**/*.d.ts,**/dist/**,**/deploy/**,**/coverage/**,apps/docs/src/test/**,build-pipeline/scripts/**,packages/ocom/domain/tests/**,packages/cellix/server-oauth2-mock-seedwork/**,packages/cellix/server-mongodb-memory-mock-seedwork/**,packages/ocom/data-sources-mongoose-models/**,packages/ocom/graphql/src/schema/builder/schema-builder.ts,apps/api/src/index.ts,apps/api/src/service-config/**,packages/cellix/archunit-tests/**,packages/ocom-verification/archunit-tests/**,packages/cellix/ui-core/**,apps/ui-community/**,packages/ocom/ui-shared/src/components/organisms/header/index.tsx # CPD (code duplication) exclusions sonar.cpd.exclusions=**/*.test.ts,**/*.generated.ts,**/*.generated.tsx,packages/cellix/archunit-tests/src/test-suites/**,packages/cellix/archunit-tests/src/fixtures/** From 0f53ffce70d2ab0a0b1ed7b5958cb8aad1a7a64a Mon Sep 17 00:00:00 2001 From: Jason Morais Date: Fri, 5 Jun 2026 16:02:10 -0400 Subject: [PATCH 3/3] progress - cleaned up cellix package and created ocom package for local app specific settings --- .pnpm-store/v11/index.db | Bin 0 -> 8192 bytes apps/api/package.json | 9 +- apps/api/start-azurite.ts | 16 ++ apps/api/start-dev.ts | 3 + apps/api/sync-local-settings.ts | 64 ++++++ apps/docs/package.json | 4 +- apps/docs/start-dev.ts | 3 + apps/server-mongodb-memory-mock/package.json | 4 +- apps/server-mongodb-memory-mock/start-dev.ts | 8 + apps/server-oauth2-mock/package.json | 5 +- apps/server-oauth2-mock/start-dev.ts | 14 ++ apps/ui-community/package.json | 5 +- apps/ui-community/start-dev.ts | 15 ++ apps/ui-staff/package.json | 7 +- apps/ui-staff/start-dev.ts | 15 ++ knip.json | 12 +- packages/cellix/local-dev/README.md | 109 +++++----- .../cellix/local-dev/bin/cellix-local-dev.mjs | 22 --- packages/cellix/local-dev/manifest.md | 45 ++--- packages/cellix/local-dev/package.json | 6 +- .../local-dev/src/api-local-settings.ts | 85 -------- packages/cellix/local-dev/src/bin.ts | 7 - packages/cellix/local-dev/src/cli.ts | 121 ------------ packages/cellix/local-dev/src/dotenv.ts | 31 +++ packages/cellix/local-dev/src/hostnames.ts | 91 --------- packages/cellix/local-dev/src/index.test.ts | 141 +++++++------ packages/cellix/local-dev/src/index.ts | 33 ++-- packages/cellix/local-dev/src/json-files.ts | 42 ++++ packages/cellix/local-dev/src/runners.ts | 187 ++++++------------ packages/cellix/local-dev/src/urls.ts | 40 ++++ packages/cellix/local-dev/src/vite.ts | 12 +- .../cellix/local-dev/src/worktree-ports.ts | 78 ++------ .../e2e-tests/src/cucumber-lifecycle-hooks.ts | 1 + .../e2e-tests/src/infrastructure.ts | 4 +- .../shared/environment/test-environment.ts | 14 ++ packages/ocom/local-dev-config/.gitignore | 2 + packages/ocom/local-dev-config/package.json | 47 +++++ .../ocom/local-dev-config/src/hostnames.ts | 51 +++++ packages/ocom/local-dev-config/src/index.ts | 4 + packages/ocom/local-dev-config/src/types.ts | 25 +++ packages/ocom/local-dev-config/src/urls.ts | 22 +++ .../ocom/local-dev-config/src/workspace.ts | 5 + packages/ocom/local-dev-config/tsconfig.json | 10 + pnpm-lock.yaml | 31 +++ turbo.json | 7 +- 45 files changed, 747 insertions(+), 710 deletions(-) create mode 100644 .pnpm-store/v11/index.db create mode 100644 apps/api/start-azurite.ts create mode 100644 apps/api/start-dev.ts create mode 100644 apps/api/sync-local-settings.ts create mode 100644 apps/docs/start-dev.ts create mode 100644 apps/server-mongodb-memory-mock/start-dev.ts create mode 100644 apps/server-oauth2-mock/start-dev.ts create mode 100644 apps/ui-community/start-dev.ts create mode 100644 apps/ui-staff/start-dev.ts delete mode 100755 packages/cellix/local-dev/bin/cellix-local-dev.mjs delete mode 100644 packages/cellix/local-dev/src/api-local-settings.ts delete mode 100644 packages/cellix/local-dev/src/bin.ts delete mode 100644 packages/cellix/local-dev/src/cli.ts create mode 100644 packages/cellix/local-dev/src/dotenv.ts delete mode 100644 packages/cellix/local-dev/src/hostnames.ts create mode 100644 packages/cellix/local-dev/src/json-files.ts create mode 100644 packages/cellix/local-dev/src/urls.ts create mode 100644 packages/ocom/local-dev-config/.gitignore create mode 100644 packages/ocom/local-dev-config/package.json create mode 100644 packages/ocom/local-dev-config/src/hostnames.ts create mode 100644 packages/ocom/local-dev-config/src/index.ts create mode 100644 packages/ocom/local-dev-config/src/types.ts create mode 100644 packages/ocom/local-dev-config/src/urls.ts create mode 100644 packages/ocom/local-dev-config/src/workspace.ts create mode 100644 packages/ocom/local-dev-config/tsconfig.json diff --git a/.pnpm-store/v11/index.db b/.pnpm-store/v11/index.db new file mode 100644 index 0000000000000000000000000000000000000000..044636a65ff67410fad601f0c39978108fefcea0 GIT binary patch literal 8192 zcmeIuzpBD86bA4#2p0s=&GDX11#$5OY&BppTCFMSqD0LV@%|C%po4=C;0rt5R!Xsx zd-*<+oFpepe$$EEhlalXPCq)NHmfksS%-)*#*-P9XRK%~B>T9;=Xc?(b$^tiS5|q+ zqJcmF0uX=z1Rwwb2tWV=5P$##awu^7v_7h}nsvK|di`yVdUMb_v)cb|%{g=6U0>Kr zkg^>qDAS^Pkk?+mi kUUFZI)hjuq$Cn@g0SG_<0uX=z1Rwwb2tWV=5J(070rxR8{{R30 literal 0 HcmV?d00001 diff --git a/apps/api/package.json b/apps/api/package.json index 330501df0..ec7246dd2 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -9,9 +9,9 @@ "prebuild": "pnpm run lint", "build": "tsgo --build && rolldown -c rolldown.config.ts", "predev": "pnpm run prepare:deploy && pnpm run sync-local-settings", - "dev": "pnpm exec portless data-access.ownercommunity.localhost --force cellix-local-dev azure-functions", + "dev": "pnpm exec portless data-access.ownercommunity.localhost --force node start-dev.ts", "predev:worktree": "pnpm run prepare:deploy && pnpm run sync-local-settings", - "dev:worktree": "pnpm exec portless data-access.ownercommunity.${WORKTREE_NAME}.localhost --force cellix-local-dev azure-functions", + "dev:worktree": "pnpm exec portless data-access.ownercommunity.${WORKTREE_NAME}.localhost --force node start-dev.ts", "prepare:deploy": "cellix-prepare-func-deploy", "watch": "tsgo --watch", "test": "vitest run --silent --reporter=dot", @@ -23,8 +23,8 @@ "clean": "rimraf dist deploy", "prestart": "pnpm run prepare:deploy && pnpm run sync-local-settings", "start": "func start --typescript --script-root deploy/", - "sync-local-settings": "cellix-local-dev sync-api-local-settings", - "azurite": "cellix-local-dev azurite" + "sync-local-settings": "node sync-local-settings.ts", + "azurite": "node start-azurite.ts" }, "dependencies": { "@azure/functions": "catalog:", @@ -49,6 +49,7 @@ "@cellix/config-typescript": "workspace:*", "@cellix/config-vitest": "workspace:*", "@cellix/local-dev": "workspace:*", + "@ocom/local-dev-config": "workspace:*", "@vitest/coverage-istanbul": "catalog:", "azurite": "^3.35.0", "rimraf": "catalog:", diff --git a/apps/api/start-azurite.ts b/apps/api/start-azurite.ts new file mode 100644 index 000000000..a95930199 --- /dev/null +++ b/apps/api/start-azurite.ts @@ -0,0 +1,16 @@ +import path from 'node:path'; +import { getAzuritePorts, resolveWorkspaceRoot, runAzuriteDev } from '@cellix/local-dev'; + +const workspaceRoot = resolveWorkspaceRoot(); +const worktreeName = process.env['WORKTREE_NAME']; +const ports = getAzuritePorts(worktreeName); +const storageSuffix = worktreeName ? `-${worktreeName}` : ''; + +runAzuriteDev({ + blobPort: ports.blob, + blobLocation: path.join(workspaceRoot, `__blobstorage__${storageSuffix}`), + queuePort: ports.queue, + queueLocation: path.join(workspaceRoot, `__queuestorage__${storageSuffix}`), + tablePort: ports.table, + tableLocation: path.join(workspaceRoot, `__tablestorage__${storageSuffix}`), +}); diff --git a/apps/api/start-dev.ts b/apps/api/start-dev.ts new file mode 100644 index 000000000..f637f6bc0 --- /dev/null +++ b/apps/api/start-dev.ts @@ -0,0 +1,3 @@ +import { runAzureFunctionsDev } from '@cellix/local-dev'; + +runAzureFunctionsDev(); diff --git a/apps/api/sync-local-settings.ts b/apps/api/sync-local-settings.ts new file mode 100644 index 000000000..6998ba94c --- /dev/null +++ b/apps/api/sync-local-settings.ts @@ -0,0 +1,64 @@ +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { buildAzuriteConnectionString, getAzuritePorts, getMongoPort, replaceUrlPort, resolveWorkspaceRoot, syncJsonFile } from '@cellix/local-dev'; +import { buildOcomUrls } from '@ocom/local-dev-config'; + +const workspaceRoot = resolveWorkspaceRoot(); +const appDir = process.cwd(); +const env = process.env; +const isE2E = ['1', 'true', 'yes'].includes((env['E2E'] ?? '').toLowerCase()); +const localSettingsPath = path.join(appDir, 'local.settings.json'); +const e2eLocalSettingsPath = path.join(appDir, 'local-settings.e2e.json'); +const targetPath = path.join(appDir, 'deploy', 'local.settings.json'); + +if (!isE2E) { + if (!existsSync(localSettingsPath)) { + process.exit(0); + } + + syncJsonFile({ + sourcePath: localSettingsPath, + targetPath, + }); + process.exit(0); +} + +const urls = buildOcomUrls({ env, workspaceRoot }); +const worktreeName = env['WORKTREE_NAME']; +const ports = getAzuritePorts(worktreeName); + +syncJsonFile({ + sourcePath: e2eLocalSettingsPath, + targetPath, + transform: (document) => { + const settings = document; + const values = { ...(settings.Values ?? {}) }; + const accountName = String(values['STORAGE_ACCOUNT_NAME'] ?? ''); + const accountKey = String(values['STORAGE_ACCOUNT_KEY'] ?? ''); + + values['ACCOUNT_PORTAL_OIDC_ISSUER'] = urls.mockCommunityAuthorityUrl; + values['ACCOUNT_PORTAL_OIDC_ENDPOINT'] = urls.mockCommunityJwksUrl; + values['STAFF_PORTAL_OIDC_ISSUER'] = urls.mockStaffAuthorityUrl; + values['STAFF_PORTAL_OIDC_ENDPOINT'] = urls.mockStaffJwksUrl; + + if (accountName && accountKey && worktreeName) { + const azuriteConnectionString = buildAzuriteConnectionString({ + accountKey, + accountName, + ports, + }); + values['AZURE_STORAGE_CONNECTION_STRING'] = azuriteConnectionString; + values['AzureWebJobsStorage'] = azuriteConnectionString; + } + + const cosmosConnectionString = env['COSMOSDB_CONNECTION_STRING'] ?? values['COSMOSDB_CONNECTION_STRING']; + if (typeof cosmosConnectionString === 'string' && cosmosConnectionString) { + values['COSMOSDB_CONNECTION_STRING'] = worktreeName ? replaceUrlPort(cosmosConnectionString, getMongoPort(worktreeName)) : cosmosConnectionString; + } + + return { + ...settings, + Values: values, + }; + }, +}); diff --git a/apps/docs/package.json b/apps/docs/package.json index a8a526048..d810004d4 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -4,8 +4,8 @@ "private": true, "scripts": { "docusaurus": "docusaurus", - "dev": "pnpm exec portless docs.ownercommunity.localhost --force cellix-local-dev docusaurus", - "dev:worktree": "pnpm exec portless docs.ownercommunity.${WORKTREE_NAME}.localhost --force cellix-local-dev docusaurus", + "dev": "pnpm exec portless docs.ownercommunity.localhost --force node start-dev.ts", + "dev:worktree": "pnpm exec portless docs.ownercommunity.${WORKTREE_NAME}.localhost --force node start-dev.ts", "start": "docusaurus start --port 3001", "build": "docusaurus build", "swizzle": "docusaurus swizzle", diff --git a/apps/docs/start-dev.ts b/apps/docs/start-dev.ts new file mode 100644 index 000000000..7b8c1655c --- /dev/null +++ b/apps/docs/start-dev.ts @@ -0,0 +1,3 @@ +import { runDocusaurusDev } from '@cellix/local-dev'; + +runDocusaurusDev(); diff --git a/apps/server-mongodb-memory-mock/package.json b/apps/server-mongodb-memory-mock/package.json index 96109e079..1a5acc021 100644 --- a/apps/server-mongodb-memory-mock/package.json +++ b/apps/server-mongodb-memory-mock/package.json @@ -11,8 +11,8 @@ "format": "biome format --write", "format:check": "biome format .", "start": "node dist/index.js", - "dev": "cellix-local-dev tsx --profile mongo-memory-mock", - "dev:worktree": "cellix-local-dev tsx --profile mongo-memory-mock" + "dev": "node start-dev.ts", + "dev:worktree": "node start-dev.ts" }, "dependencies": { "@cellix/server-mongodb-memory-mock-seedwork": "workspace:*", diff --git a/apps/server-mongodb-memory-mock/start-dev.ts b/apps/server-mongodb-memory-mock/start-dev.ts new file mode 100644 index 000000000..a1afd8bd2 --- /dev/null +++ b/apps/server-mongodb-memory-mock/start-dev.ts @@ -0,0 +1,8 @@ +import { getMongoPort, runTsxDev } from '@cellix/local-dev'; + +runTsxDev({ + env: { + ...process.env, + PORT: String(getMongoPort(process.env['WORKTREE_NAME'])), + }, +}); diff --git a/apps/server-oauth2-mock/package.json b/apps/server-oauth2-mock/package.json index 6c0c8ae07..28e3fadc3 100644 --- a/apps/server-oauth2-mock/package.json +++ b/apps/server-oauth2-mock/package.json @@ -11,14 +11,15 @@ "format": "biome format --write", "format:check": "biome format .", "start": "node dist/index.js", - "dev": "pnpm exec portless mock-auth.ownercommunity.localhost --force cellix-local-dev tsx --profile oauth2-mock", - "dev:worktree": "pnpm exec portless mock-auth.ownercommunity.${WORKTREE_NAME}.localhost --force cellix-local-dev tsx --profile oauth2-mock", + "dev": "pnpm exec portless mock-auth.ownercommunity.localhost --force node start-dev.ts", + "dev:worktree": "pnpm exec portless mock-auth.ownercommunity.${WORKTREE_NAME}.localhost --force node start-dev.ts", "test": "vitest run", "test:coverage": "vitest run --coverage", "test:watch": "vitest" }, "dependencies": { "@cellix/server-oauth2-mock-seedwork": "workspace:*", + "@ocom/local-dev-config": "workspace:*", "dotenv": "^16.4.5" }, "devDependencies": { diff --git a/apps/server-oauth2-mock/start-dev.ts b/apps/server-oauth2-mock/start-dev.ts new file mode 100644 index 000000000..d26144b72 --- /dev/null +++ b/apps/server-oauth2-mock/start-dev.ts @@ -0,0 +1,14 @@ +/// +import { runTsxDev } from '@cellix/local-dev'; +import { buildOcomUrls } from '@ocom/local-dev-config'; + +const urls = buildOcomUrls(); + +runTsxDev({ + env: { + ...process.env, + BASE_URL: urls.mockCommunityAuthorityUrl.replace('/community', ''), + VITE_APP_UI_COMMUNITY_B2C_REDIRECT_URI: urls.uiCommunityRedirectUrl, + VITE_APP_UI_STAFF_AAD_REDIRECT_URI: urls.uiStaffRedirectUrl, + }, +}); diff --git a/apps/ui-community/package.json b/apps/ui-community/package.json index a4391587e..51b4896d4 100644 --- a/apps/ui-community/package.json +++ b/apps/ui-community/package.json @@ -9,8 +9,8 @@ "format:check": "biome format .", "prebuild": "pnpm run lint", "build": "tsgo --build && vite build", - "dev": "pnpm exec portless ownercommunity.localhost --force cellix-local-dev vite --profile ui-community", - "dev:worktree": "pnpm exec portless ownercommunity.${WORKTREE_NAME}.localhost --force cellix-local-dev vite --profile ui-community", + "dev": "pnpm exec portless ownercommunity.localhost --force node start-dev.ts", + "dev:worktree": "pnpm exec portless ownercommunity.${WORKTREE_NAME}.localhost --force node start-dev.ts", "start": "vite", "preview": "vite preview", "test": "vitest run --silent --reporter=dot", @@ -24,6 +24,7 @@ "@cellix/ui-core": "workspace:*", "@dr.pogodin/react-helmet": "^3.0.2", "@graphql-typed-document-node/core": "^3.2.0", + "@ocom/local-dev-config": "workspace:*", "@ocom/ui-community-route-accounts": "workspace:*", "@ocom/ui-community-route-admin": "workspace:*", "@ocom/ui-community-route-root": "workspace:*", diff --git a/apps/ui-community/start-dev.ts b/apps/ui-community/start-dev.ts new file mode 100644 index 000000000..47cf4d758 --- /dev/null +++ b/apps/ui-community/start-dev.ts @@ -0,0 +1,15 @@ +/// +import { runViteDev } from '@cellix/local-dev'; +import { buildOcomUrls } from '@ocom/local-dev-config'; + +const urls = buildOcomUrls(); + +runViteDev({ + env: { + ...process.env, + VITE_APP_UI_COMMUNITY_B2C_AUTHORITY: urls.mockCommunityAuthorityUrl, + VITE_APP_UI_COMMUNITY_B2C_REDIRECT_URI: urls.uiCommunityRedirectUrl, + VITE_COMMON_API_ENDPOINT: urls.apiGraphqlUrl, + VITE_APP_UI_COMMUNITY_BASE_URL: urls.uiCommunityBaseUrl, + }, +}); diff --git a/apps/ui-staff/package.json b/apps/ui-staff/package.json index 70158d368..e270f814a 100644 --- a/apps/ui-staff/package.json +++ b/apps/ui-staff/package.json @@ -9,8 +9,8 @@ "format:check": "biome format .", "prebuild": "pnpm run lint", "build": "tsgo --build && vite build", - "dev": "pnpm exec portless staff.ownercommunity.localhost --force cellix-local-dev vite --profile ui-staff", - "dev:worktree": "pnpm exec portless staff.ownercommunity.${WORKTREE_NAME}.localhost --force cellix-local-dev vite --profile ui-staff", + "dev": "pnpm exec portless staff.ownercommunity.localhost --force node start-dev.ts", + "dev:worktree": "pnpm exec portless staff.ownercommunity.${WORKTREE_NAME}.localhost --force node start-dev.ts", "start": "vite", "preview": "vite preview", "test": "vitest run --silent --reporter=dot", @@ -21,8 +21,9 @@ "@apollo/client": "^3.13.9", "@dr.pogodin/react-helmet": "^3.0.2", "@cellix/ui-core": "workspace:*", - "@ocom/ui-shared": "workspace:*", "@graphql-typed-document-node/core": "^3.2.0", + "@ocom/local-dev-config": "workspace:*", + "@ocom/ui-shared": "workspace:*", "@ocom/ui-staff-route-root": "workspace:*", "@ocom/ui-staff-route-community-management": "workspace:*", "@ocom/ui-staff-route-user-management": "workspace:*", diff --git a/apps/ui-staff/start-dev.ts b/apps/ui-staff/start-dev.ts new file mode 100644 index 000000000..f77a3550b --- /dev/null +++ b/apps/ui-staff/start-dev.ts @@ -0,0 +1,15 @@ +/// +import { runViteDev } from '@cellix/local-dev'; +import { buildOcomUrls } from '@ocom/local-dev-config'; + +const urls = buildOcomUrls(); + +runViteDev({ + env: { + ...process.env, + VITE_APP_UI_STAFF_AAD_AUTHORITY: urls.mockStaffAuthorityUrl, + VITE_APP_UI_STAFF_AAD_REDIRECT_URI: urls.uiStaffRedirectUrl, + VITE_APP_UI_STAFF_BASE_URL: urls.uiStaffBaseUrl, + VITE_COMMON_API_ENDPOINT: urls.apiGraphqlUrl, + }, +}); diff --git a/knip.json b/knip.json index fa91a2bc3..eaff37f94 100644 --- a/knip.json +++ b/knip.json @@ -7,14 +7,14 @@ "ignoreDependencies": ["azurite"] }, "apps/ui-community": { - "entry": ["src/main.tsx"], - "project": ["src/**/*.{ts,tsx}"], + "entry": ["src/main.tsx", "start-dev.ts"], + "project": ["src/**/*.{ts,tsx}", "start-dev.ts"], "ignore": ["**/apollo-client-links.tsx"], "ignoreDependencies": ["@cellix/local-dev"] }, "apps/ui-staff": { - "entry": ["src/main.tsx"], - "project": ["src/**/*.{ts,tsx}"], + "entry": ["src/main.tsx", "start-dev.ts"], + "project": ["src/**/*.{ts,tsx}", "start-dev.ts"], "ignore": [], "ignoreDependencies": ["@cellix/local-dev"] }, @@ -91,8 +91,8 @@ "ignoreUnresolved": ["progress-bar"] }, "apps/server-oauth2-mock": { - "entry": ["src/index.ts"], - "project": ["src/**/*.ts"], + "entry": ["src/index.ts", "start-dev.ts"], + "project": ["src/**/*.ts", "start-dev.ts"], "ignoreDependencies": ["@cellix/local-dev", "tsx"] }, "apps/server-mongodb-memory-mock": { diff --git a/packages/cellix/local-dev/README.md b/packages/cellix/local-dev/README.md index 84dd87026..3fe57fdab 100644 --- a/packages/cellix/local-dev/README.md +++ b/packages/cellix/local-dev/README.md @@ -1,20 +1,8 @@ # @cellix/local-dev -Shared local-development runtime for Cellix application packages. +Generic local-development helpers for Cellix app wrappers. -This package replaces duplicated `start-dev.*`, `start-mongo.*`, `start-azurite.*`, and root `scripts/local-dev/*` orchestration with one reusable package plus a small CLI. - -## What this package provides - -- Worktree-aware portless hostname derivation -- Worktree-aware MongoDB and Azurite port derivation -- API local-settings sync for normal and `e2e` modes -- Shared dev runners for: - - Vite apps - - Docusaurus docs - - Azure Functions local startup - - TSX-backed mock servers - - Azurite +This package is intentionally policy-free. It owns reusable mechanics such as worktree port math, URL helpers, JSON and dotenv utilities, process exit forwarding, and generic dev runners. App-specific env keys, hostnames, auth routes, and `local.settings.json` mutations belong in app-owned wrapper scripts or internal repo helpers, not here. ## Install @@ -28,55 +16,74 @@ In this monorepo, app packages consume the workspace package directly: } ``` -## CLI usage - -```bash -cellix-local-dev vite --profile ui-community -cellix-local-dev vite --profile ui-staff -cellix-local-dev docusaurus -cellix-local-dev azure-functions -cellix-local-dev tsx --profile oauth2-mock -cellix-local-dev tsx --profile mongo-memory-mock -cellix-local-dev azurite -cellix-local-dev sync-api-local-settings -cellix-local-dev sync-api-local-settings e2e -``` +## What this package provides -## Example app scripts +- Workspace-root discovery +- Dotenv parsing and JSON file sync helpers +- Worktree-aware hostname and URL utilities +- Worktree-aware MongoDB and Azurite port derivation +- Azurite connection-string construction from explicit credentials +- Generic dev runners for: + - Vite + - Docusaurus + - Azure Functions + - TSX-backed processes + - Azurite -```json -{ - "scripts": { - "dev": "pnpm exec portless ownercommunity.localhost --force cellix-local-dev vite --profile ui-community", - "dev:worktree": "pnpm exec portless ownercommunity.${WORKTREE_NAME}.localhost --force cellix-local-dev vite --profile ui-community" - } -} +## Recommended consumption pattern + +Keep the app policy in a wrapper script and compose this package's generic helpers inside it: + +```js +import { buildPortlessUrl, getMongoPort, runTsxDev } from '@cellix/local-dev'; + +runTsxDev({ + env: { + ...process.env, + BASE_URL: buildPortlessUrl('mock-auth.example.localhost'), + PORT: String(getMongoPort(process.env.WORKTREE_NAME)), + }, +}); ``` -```json -{ - "scripts": { - "sync-local-settings": "cellix-local-dev sync-api-local-settings", - "dev": "pnpm exec portless data-access.ownercommunity.localhost --force cellix-local-dev azure-functions", - "azurite": "cellix-local-dev azurite" - } -} +For settings files, let the app decide what to change: + +```js +import { syncJsonFile } from '@cellix/local-dev'; + +syncJsonFile({ + sourcePath: 'local-settings.e2e.json', + targetPath: 'deploy/local.settings.json', + transform: (document) => ({ + ...document, + Values: { + ...(document.Values ?? {}), + MODE: 'e2e', + }, + }), +}); ``` ## Public API -The package also exports the local-dev primitives for tests or scripted composition: - - `resolveWorkspaceRoot` -- `resolvePortlessHostnames` +- `readDotEnv` +- `readJsonFile` +- `writeJsonFile` +- `syncJsonFile` +- `hostnameFromUrl` +- `applyWorktreeSuffix` - `buildPortlessUrl` +- `replaceUrlPort` +- `PORTLESS_PORT` - `buildViteArgs` +- `isE2E` +- `forwardChildExit` +- `isGracefulInterruptExit` - `getWorktreePortOffset` - `getMongoPort` - `getAzuritePorts` -- `getAzuriteConnectionString` -- `getMongoConnectionString` -- `syncApiLocalSettings` +- `buildAzuriteConnectionString` - `runViteDev` - `runDocusaurusDev` - `runAzureFunctionsDev` @@ -85,5 +92,5 @@ The package also exports the local-dev primitives for tests or scripted composit ## Notes -- The implementation is in TypeScript, but the package exposes a normal Node bin so consuming app scripts do not need to boot shared `.ts` files through `tsx`. -- The package derives the workspace root from the caller's current working directory, so app packages do not need to hardcode repo-relative paths. +- The package derives workspace roots from the caller's current working directory, but it does not infer app layouts or env-variable names. +- If a helper only exists to support one app's local policy, it should usually live with that app instead of being exported here. diff --git a/packages/cellix/local-dev/bin/cellix-local-dev.mjs b/packages/cellix/local-dev/bin/cellix-local-dev.mjs deleted file mode 100755 index ffe9cc635..000000000 --- a/packages/cellix/local-dev/bin/cellix-local-dev.mjs +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env node - -import { spawn } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; - -const child = spawn(process.execPath, ['--experimental-strip-types', fileURLToPath(new URL('../src/bin.ts', import.meta.url)), ...process.argv.slice(2)], { - stdio: 'inherit', -}); - -child.on('error', (error) => { - console.error(`[local-dev] failed to start CLI: ${error.message}`); - process.exit(1); -}); - -child.on('exit', (code, signal) => { - if (signal) { - process.kill(process.pid, signal); - return; - } - - process.exit(code ?? 1); -}); diff --git a/packages/cellix/local-dev/manifest.md b/packages/cellix/local-dev/manifest.md index b6883617f..d577a0634 100644 --- a/packages/cellix/local-dev/manifest.md +++ b/packages/cellix/local-dev/manifest.md @@ -2,59 +2,60 @@ ## Purpose -Provide a single local-development runtime for Cellix app packages so app-level `dev`, `dev:worktree`, `azurite`, and local-settings scripts can stay configuration-shaped while the real orchestration lives in one reusable package. +Provide generic local-development primitives for Cellix app packages so app-owned wrapper scripts can supply project-specific env keys, hostnames, and local-settings policy without hardcoding those details into the shared package. ## Scope -This package owns worktree-aware hostname and port derivation, portless URL building, API local-settings rewriting, child-process lifecycle handling, and the shared dev-runner orchestration for Vite, Docusaurus, Azure Functions, TSX-based mock servers, and Azurite. +This package owns generic worktree-aware port math, URL helpers, dotenv and JSON file utilities, child-process lifecycle handling, and generic dev-runner orchestration for Vite, Docusaurus, Azure Functions, TSX-based processes, and Azurite. ## Non-goals - Production runtime behavior -- Generic process management beyond Cellix local-development use cases -- Replacing app build/start scripts that are not part of the local-development workflow +- App-specific env-variable names, hostnames, auth-provider paths, or local-settings keys +- Discovering repo-specific app folders from inside the package +- Replacing app-owned wrapper scripts that express local project policy ## Public API shape - `resolveWorkspaceRoot(options?)` -- `resolvePortlessHostnames(options?)`, `buildPortlessUrl(hostname, path?)`, `PORTLESS_PORT` +- `readDotEnv(filePath)` +- `readJsonFile(filePath)`, `writeJsonFile(filePath, data)`, `syncJsonFile(options)` +- `hostnameFromUrl(url)`, `applyWorktreeSuffix(hostname, worktreeName)`, `buildPortlessUrl(hostname, path?)`, `replaceUrlPort(url, port)`, `PORTLESS_PORT` - `isE2E(env?)`, `buildViteArgs(options?)` - `isGracefulInterruptExit(signal, code)`, `forwardChildExit(child)` -- `getWorktreePortOffset(worktreeName?)`, `getMongoPort(worktreeName?)`, `getAzuritePorts(worktreeName?)` -- `getAzuriteConnectionString(options?)`, `getMongoConnectionString(options?)` -- `syncApiLocalSettings(options?)` -- `runViteDev(profile, options?)`, `runDocusaurusDev(options?)`, `runAzureFunctionsDev(options?)`, `runTsxDev(profile, options?)`, `runAzuriteDev(options?)` +- `getWorktreePortOffset(worktreeName?)`, `getMongoPort(worktreeName?)`, `getAzuritePorts(worktreeName?)`, `buildAzuriteConnectionString(options)` +- `runViteDev(options?)`, `runDocusaurusDev(options?)`, `runAzureFunctionsDev(options?)`, `runTsxDev(options?)`, `runAzuriteDev(options)` ## Core concepts -- App packages should express local dev behavior as small parameter choices such as profile names or entry paths, not as duplicated process wiring. -- Worktree isolation is deterministic and should keep hostnames, MongoDB ports, and Azurite ports aligned across all participating apps. -- The package is allowed to know Cellix app profiles such as `ui-community`, `ui-staff`, `oauth2-mock`, and `mongo-memory-mock`, because those profiles are the stable consumer contract that replaces ad hoc app wrapper scripts. +- App packages own local-development policy such as env-variable names, URL mappings, auth-provider routes, and settings-file transforms. +- This package should expose only reusable mechanics that make those wrappers smaller and more consistent. +- Worktree isolation is deterministic and should keep MongoDB ports, Azurite ports, and hostname suffixing aligned across all participating apps. ## Package boundaries -- Keep CLI argument parsing, process spawning helpers, file-system helpers, and app profile definitions internal unless consumers outside this package need them directly. -- Do not leak app-relative path assumptions into consumers; the package must derive workspace paths from the caller's current working directory. -- Avoid widening the public surface with one-off helpers that only exist to support a single internal branch. +- Do not encode OCOM app names, env-variable names, or `local.settings.json` schemas in this package. +- Keep repo-specific hostname and env mapping logic in app-owned scripts or internal repo helpers outside this package. +- Avoid widening the public surface with one-off helpers that only exist to support a single app branch. ## Dependencies / relationships - Downstream consumers in this monorepo: `@apps/api`, `@apps/docs`, `@apps/ui-community`, `@apps/ui-staff`, `@apps/server-oauth2-mock`, `@apps/server-mongodb-memory-mock` -- Consumed from package scripts through the `cellix-local-dev` bin and from tests through the TypeScript API +- Consumed from app-owned wrapper scripts and from tests through the TypeScript API ## Testing strategy -- Prefer public-entrypoint tests for hostname derivation, worktree port derivation, Vite arg building, connection-string patching, and API local-settings rewriting. -- Avoid tests that reach into internal CLI parsing or helper modules when a public function already proves the observable behavior. +- Prefer public-entrypoint tests for dotenv parsing, URL helpers, worktree port derivation, Vite arg building, Azurite connection-string building, and generic JSON syncing. +- Avoid tests that prove repo-specific wrapper policy through this package's public contract. ## Documentation obligations -- Keep `README.md` focused on how app packages consume the package. +- Keep `README.md` focused on the generic helper surface and how app-owned wrappers compose it. - Keep TSDoc aligned on public exports that define package behavior. -- Update this manifest when app profiles, public exports, or scope boundaries change. +- Update this manifest when public exports or scope boundaries change. ## Release-readiness standards -- App packages should need only profile/entry configuration in `package.json` after consuming this package. +- App packages should be able to express their policy without modifying this package. - Package build and package tests must pass, plus affected app builds/tests as justified by the migration. -- Any new app profile should be added deliberately and documented as part of the contract. +- New helper exports must solve a reusable mechanical problem rather than expose app policy. diff --git a/packages/cellix/local-dev/package.json b/packages/cellix/local-dev/package.json index 10c3d2454..a1ea2b63e 100644 --- a/packages/cellix/local-dev/package.json +++ b/packages/cellix/local-dev/package.json @@ -1,11 +1,10 @@ { "name": "@cellix/local-dev", "version": "1.0.0", - "description": "Shared local-development runner orchestration for Cellix applications", + "description": "Generic local-development helpers for Cellix app wrappers", "type": "module", "files": [ "dist", - "bin", "src" ], "main": "dist/index.js", @@ -16,9 +15,6 @@ "default": "./dist/index.js" } }, - "bin": { - "cellix-local-dev": "bin/cellix-local-dev.mjs" - }, "scripts": { "prebuild": "pnpm run lint", "build": "tsgo --build", diff --git a/packages/cellix/local-dev/src/api-local-settings.ts b/packages/cellix/local-dev/src/api-local-settings.ts deleted file mode 100644 index f91a2a82b..000000000 --- a/packages/cellix/local-dev/src/api-local-settings.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import path from 'node:path'; -import { buildPortlessUrl, resolvePortlessHostnames } from './hostnames.ts'; -import { getAzuriteConnectionString } from './worktree-ports.ts'; - -interface ApiLocalSettingsDocument { - Values?: Record; -} - -interface SyncApiLocalSettingsOptions { - appDir?: string; - workspaceRoot?: string; - env?: NodeJS.ProcessEnv; - mode?: 'e2e'; -} - -/** - * Applies the shared Cellix e2e and worktree overrides to an API - * `local.settings.json` document. - */ -export function applyApiLocalSettingsOverrides(settings: ApiLocalSettingsDocument, options: Pick = {}): ApiLocalSettingsDocument { - const env = options.env ?? process.env; - const values = { ...(settings.Values ?? {}) }; - - if (env['WORKTREE_NAME']) { - const hostnames = resolvePortlessHostnames({ - ...(options.workspaceRoot ? { startDir: options.workspaceRoot } : {}), - env, - }); - values['ACCOUNT_PORTAL_OIDC_ISSUER'] = buildPortlessUrl(hostnames.mockAuth, '/community'); - values['ACCOUNT_PORTAL_OIDC_ENDPOINT'] = buildPortlessUrl(hostnames.mockAuth, '/community/.well-known/jwks.json'); - values['STAFF_PORTAL_OIDC_ISSUER'] = buildPortlessUrl(hostnames.mockAuth, '/staff'); - values['STAFF_PORTAL_OIDC_ENDPOINT'] = buildPortlessUrl(hostnames.mockAuth, '/staff/.well-known/jwks.json'); - const azurite = getAzuriteConnectionString({ - ...(options.workspaceRoot ? { startDir: options.workspaceRoot } : {}), - env, - values, - }); - values['AZURE_STORAGE_CONNECTION_STRING'] = azurite; - values['AzureWebJobsStorage'] = azurite; - } - - if (env['COSMOSDB_CONNECTION_STRING']) { - values['COSMOSDB_CONNECTION_STRING'] = env['COSMOSDB_CONNECTION_STRING']; - } - - settings.Values = values; - return settings; -} - -/** - * Syncs `apps/api/deploy/local.settings.json` from the local or e2e source file. - */ -export function syncApiLocalSettings(options: SyncApiLocalSettingsOptions = {}): void { - const env = options.env ?? process.env; - const appDir = path.resolve(options.appDir ?? process.cwd()); - const mode = options.mode ?? (['1', 'true', 'yes'].includes((env['E2E'] ?? '').toLowerCase()) ? 'e2e' : undefined); - const localSettingsPath = path.join(appDir, 'local.settings.json'); - const e2eLocalSettingsPath = path.join(appDir, 'local-settings.e2e.json'); - const targetPath = path.join(appDir, 'deploy', 'local.settings.json'); - - mkdirSync(path.dirname(targetPath), { recursive: true }); - - if (!mode) { - if (existsSync(localSettingsPath)) { - copyFileSync(localSettingsPath, targetPath); - } - return; - } - - if (mode !== 'e2e') { - throw new Error(`[local-dev] Invalid mode: expected one of e2e, received "${mode}"`); - } - - if (!existsSync(e2eLocalSettingsPath)) { - throw new Error(`[local-dev] Missing local settings for mode "e2e": ${e2eLocalSettingsPath}`); - } - - const settings = JSON.parse(readFileSync(e2eLocalSettingsPath, 'utf8')) as ApiLocalSettingsDocument; - applyApiLocalSettingsOverrides(settings, { - workspaceRoot: options.workspaceRoot ?? appDir, - env, - }); - writeFileSync(targetPath, `${JSON.stringify(settings, null, '\t')}\n`); -} diff --git a/packages/cellix/local-dev/src/bin.ts b/packages/cellix/local-dev/src/bin.ts deleted file mode 100644 index e6bbb3f23..000000000 --- a/packages/cellix/local-dev/src/bin.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { runCli } from './cli.ts'; - -const exitCode = runCli(process.argv.slice(2)); - -if (typeof exitCode === 'number' && exitCode !== 0) { - process.exit(exitCode); -} diff --git a/packages/cellix/local-dev/src/cli.ts b/packages/cellix/local-dev/src/cli.ts deleted file mode 100644 index 1d9e1da4c..000000000 --- a/packages/cellix/local-dev/src/cli.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { syncApiLocalSettings } from './api-local-settings.ts'; -import { runAzureFunctionsDev, runAzuriteDev, runDocusaurusDev, runTsxDev, runViteDev, type TsxDevProfile, type ViteDevProfile } from './runners.ts'; - -type CliCommand = 'vite' | 'docusaurus' | 'azure-functions' | 'tsx' | 'azurite' | 'sync-api-local-settings'; - -type FlagMap = Record; - -function parseFlags(args: string[]): { flags: FlagMap; positionals: string[] } { - const flags: FlagMap = {}; - const positionals: string[] = []; - - for (let index = 0; index < args.length; index += 1) { - const arg = args[index]; - if (!arg) { - continue; - } - if (arg === '--help' || arg === '-h') { - flags['help'] = 'true'; - continue; - } - - if (arg.startsWith('--')) { - const key = arg.slice(2); - const value = args[index + 1]; - if (!value || value.startsWith('--')) { - throw new Error(`[local-dev] Missing value for --${key}`); - } - flags[key] = value; - index += 1; - continue; - } - - positionals.push(arg); - } - - return { flags, positionals }; -} - -function usage(): string { - return [ - 'Usage:', - ' cellix-local-dev vite --profile ', - ' cellix-local-dev docusaurus', - ' cellix-local-dev azure-functions', - ' cellix-local-dev tsx --profile [--entry src/index.ts]', - ' cellix-local-dev azurite', - ' cellix-local-dev sync-api-local-settings [e2e]', - ].join('\n'); -} - -function asViteProfile(value: string | undefined): ViteDevProfile { - if (value === 'ui-community' || value === 'ui-staff') { - return value; - } - throw new Error(`[local-dev] Invalid vite profile "${value ?? ''}"`); -} - -function asTsxProfile(value: string | undefined): TsxDevProfile { - if (value === 'oauth2-mock' || value === 'mongo-memory-mock') { - return value; - } - throw new Error(`[local-dev] Invalid tsx profile "${value ?? ''}"`); -} - -/** - * Runs the `cellix-local-dev` CLI. - */ -export function runCli(argv = process.argv.slice(2)): number { - const [command, ...rest] = argv as [CliCommand | undefined, ...string[]]; - - if (!command) { - console.error(usage()); - return 1; - } - - try { - const { flags, positionals } = parseFlags(rest); - if (flags['help'] === 'true') { - console.log(usage()); - return 0; - } - - switch (command) { - case 'vite': - runViteDev(asViteProfile(flags['profile'])); - return 0; - case 'docusaurus': - runDocusaurusDev(); - return 0; - case 'azure-functions': - runAzureFunctionsDev(); - return 0; - case 'tsx': - runTsxDev(asTsxProfile(flags['profile']), { - ...(flags['entry'] ? { entry: flags['entry'] } : {}), - }); - return 0; - case 'azurite': - runAzuriteDev(); - return 0; - case 'sync-api-local-settings': - if (positionals[0] && positionals[0] !== 'e2e') { - throw new Error(`[local-dev] Invalid sync-api-local-settings mode "${positionals[0]}"`); - } - syncApiLocalSettings( - positionals[0] === 'e2e' - ? { - mode: 'e2e', - } - : {}, - ); - return 0; - default: - console.error(usage()); - return 1; - } - } catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - return 1; - } -} diff --git a/packages/cellix/local-dev/src/dotenv.ts b/packages/cellix/local-dev/src/dotenv.ts new file mode 100644 index 000000000..5dca2928d --- /dev/null +++ b/packages/cellix/local-dev/src/dotenv.ts @@ -0,0 +1,31 @@ +import { existsSync, readFileSync } from 'node:fs'; + +export type DotEnvValues = Record; + +/** + * Reads a dotenv-style file into a plain key/value object. + * + * Lines without `=` and comment lines are ignored. + */ +export function readDotEnv(filePath: string): DotEnvValues { + if (!existsSync(filePath)) { + return {}; + } + + const values: DotEnvValues = {}; + for (const line of readFileSync(filePath, 'utf8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) { + continue; + } + + const separatorIndex = trimmed.indexOf('='); + if (separatorIndex === -1) { + continue; + } + + values[trimmed.slice(0, separatorIndex)] = trimmed.slice(separatorIndex + 1); + } + + return values; +} diff --git a/packages/cellix/local-dev/src/hostnames.ts b/packages/cellix/local-dev/src/hostnames.ts deleted file mode 100644 index 1afa4c8ae..000000000 --- a/packages/cellix/local-dev/src/hostnames.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { existsSync, readFileSync } from 'node:fs'; -import path from 'node:path'; -import { type ResolveWorkspaceRootOptions, resolveWorkspaceRoot } from './workspace.ts'; - -export interface PortlessHostnames { - uiCommunity: string; - uiStaff: string; - api: string; - mockAuth: string; - docs: string; -} - -export type PortlessHostnameKey = keyof PortlessHostnames; - -interface ResolvePortlessHostnamesOptions extends ResolveWorkspaceRootOptions { - env?: NodeJS.ProcessEnv; -} - -type DotEnvValues = Record; - -export const PORTLESS_PORT = 1355; - -function readDotEnv(filePath: string): DotEnvValues { - if (!existsSync(filePath)) return {}; - - const result: DotEnvValues = {}; - for (const line of readFileSync(filePath, 'utf8').split('\n')) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) continue; - - const eqIdx = trimmed.indexOf('='); - if (eqIdx === -1) continue; - result[trimmed.slice(0, eqIdx)] = trimmed.slice(eqIdx + 1); - } - - return result; -} - -function hostnameFrom(url: string): string | null { - try { - return new URL(url).hostname; - } catch { - return null; - } -} - -function hostnameFor(key: string, values: DotEnvValues, env: NodeJS.ProcessEnv): string | null { - return hostnameFrom(env[key] ?? values[key] ?? ''); -} - -function applyWorktreeSuffix(hostname: string, worktreeName: string | undefined): string { - if (!worktreeName) return hostname; - return hostname.replace('.localhost', `.${worktreeName}.localhost`); -} - -/** - * Resolves the local hostnames shared by the main Cellix dev applications. - */ -export function resolvePortlessHostnames(options: ResolvePortlessHostnamesOptions = {}): PortlessHostnames { - const env = options.env ?? process.env; - const workspaceRoot = resolveWorkspaceRoot(options); - const uiEnv = readDotEnv(path.join(workspaceRoot, 'apps', 'ui-community', '.env')); - const staffEnv = readDotEnv(path.join(workspaceRoot, 'apps', 'ui-staff', '.env')); - - const uiCommunity = hostnameFor('VITE_APP_UI_COMMUNITY_BASE_URL', uiEnv, env); - const api = hostnameFor('VITE_COMMON_API_ENDPOINT', uiEnv, env); - const mockAuth = hostnameFor('VITE_APP_UI_COMMUNITY_B2C_AUTHORITY', uiEnv, env); - const uiStaff = hostnameFor('VITE_APP_UI_STAFF_AAD_REDIRECT_URI', staffEnv, env); - - if (!uiCommunity || !api || !mockAuth || !uiStaff) { - throw new Error('[local-dev] Could not derive all portless hostnames. Ensure apps/ui-community/.env and apps/ui-staff/.env are present.'); - } - - const worktreeName = env['WORKTREE_NAME']; - const docs = `docs.${uiCommunity}`; - - return { - uiCommunity: applyWorktreeSuffix(uiCommunity, worktreeName), - uiStaff: applyWorktreeSuffix(uiStaff, worktreeName), - api: applyWorktreeSuffix(api, worktreeName), - mockAuth: applyWorktreeSuffix(mockAuth, worktreeName), - docs: applyWorktreeSuffix(docs, worktreeName), - }; -} - -/** - * Builds a portless-proxied HTTPS URL for a hostname and optional path. - */ -export function buildPortlessUrl(hostname: string, relativePath = ''): string { - return `https://${hostname}:${PORTLESS_PORT}${relativePath}`; -} diff --git a/packages/cellix/local-dev/src/index.test.ts b/packages/cellix/local-dev/src/index.test.ts index d0bfe34c1..4f79f00f9 100644 --- a/packages/cellix/local-dev/src/index.test.ts +++ b/packages/cellix/local-dev/src/index.test.ts @@ -1,51 +1,32 @@ -import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { - applyApiLocalSettingsOverrides, + applyWorktreeSuffix, + buildAzuriteConnectionString, buildPortlessUrl, buildViteArgs, - getAzuriteConnectionString, getAzuritePorts, - getMongoConnectionString, getMongoPort, getWorktreePortOffset, + hostnameFromUrl, PORTLESS_PORT, - resolvePortlessHostnames, + readDotEnv, + replaceUrlPort, resolveWorkspaceRoot, + syncJsonFile, } from '@cellix/local-dev'; import { describe, expect, it } from 'vitest'; +type DotEnvFixtureValues = Record & { + API_URL?: string; + BASE_URL?: string; +}; + function createWorkspaceFixture(): string { const root = mkdtempSync(path.join(tmpdir(), 'cellix-local-dev-')); writeFileSync(path.join(root, 'pnpm-workspace.yaml'), 'packages:\n - "apps/*"\n'); - mkdirSync(path.join(root, 'apps', 'ui-community'), { recursive: true }); - mkdirSync(path.join(root, 'apps', 'ui-staff'), { recursive: true }); - mkdirSync(path.join(root, 'apps', 'api'), { recursive: true }); - - writeFileSync( - path.join(root, 'apps', 'ui-community', '.env'), - [ - 'VITE_APP_UI_COMMUNITY_BASE_URL=https://ownercommunity.localhost:1355', - 'VITE_COMMON_API_ENDPOINT=https://data-access.ownercommunity.localhost:1355/api/graphql', - 'VITE_APP_UI_COMMUNITY_B2C_AUTHORITY=https://mock-auth.ownercommunity.localhost:1355/community', - ].join('\n'), - ); - writeFileSync(path.join(root, 'apps', 'ui-staff', '.env'), 'VITE_APP_UI_STAFF_AAD_REDIRECT_URI=https://staff.ownercommunity.localhost:1355/auth-redirect\n'); - writeFileSync( - path.join(root, 'apps', 'api', 'local.settings.json'), - JSON.stringify( - { - Values: { - COSMOSDB_CONNECTION_STRING: 'mongodb://127.0.0.1:50000/test?replicaSet=rs0', - STORAGE_ACCOUNT_NAME: 'devstoreaccount1', - STORAGE_ACCOUNT_KEY: 'key', - }, - }, - null, - 2, - ), - ); + mkdirSync(path.join(root, 'fixtures'), { recursive: true }); return root; } @@ -53,27 +34,33 @@ function createWorkspaceFixture(): string { describe('@cellix/local-dev', () => { it('resolves the workspace root from a nested directory', () => { const workspaceRoot = createWorkspaceFixture(); - const nestedDir = path.join(workspaceRoot, 'apps', 'ui-community'); + const nestedDir = path.join(workspaceRoot, 'fixtures'); expect(resolveWorkspaceRoot({ startDir: nestedDir })).toBe(workspaceRoot); }); - it('derives shared hostnames and applies the worktree suffix', () => { + it('parses dotenv values and applies worktree-aware URL helpers generically', () => { const workspaceRoot = createWorkspaceFixture(); + const envPath = path.join(workspaceRoot, 'fixtures', '.env'); + writeFileSync(envPath, ['BASE_URL=https://ownercommunity.localhost:1355', 'API_URL=https://data-access.ownercommunity.localhost:1355/api/graphql'].join('\n')); - expect( - resolvePortlessHostnames({ - startDir: path.join(workspaceRoot, 'apps', 'ui-community'), - env: { WORKTREE_NAME: 'feature-123' }, - }), - ).toEqual({ - uiCommunity: 'ownercommunity.feature-123.localhost', - uiStaff: 'staff.ownercommunity.feature-123.localhost', - api: 'data-access.ownercommunity.feature-123.localhost', - mockAuth: 'mock-auth.ownercommunity.feature-123.localhost', - docs: 'docs.ownercommunity.feature-123.localhost', + const envValues = readDotEnv(envPath) as DotEnvFixtureValues; + const baseUrl = envValues.BASE_URL; + const apiUrl = envValues.API_URL; + + expect(envValues).toEqual({ + BASE_URL: 'https://ownercommunity.localhost:1355', + API_URL: 'https://data-access.ownercommunity.localhost:1355/api/graphql', }); + expect(baseUrl).toBe('https://ownercommunity.localhost:1355'); + expect(apiUrl).toBe('https://data-access.ownercommunity.localhost:1355/api/graphql'); + if (!baseUrl || !apiUrl) { + throw new Error('Expected dotenv fixture values to be defined'); + } + expect(hostnameFromUrl(baseUrl)).toBe('ownercommunity.localhost'); + expect(applyWorktreeSuffix('ownercommunity.localhost', 'feature-123')).toBe('ownercommunity.feature-123.localhost'); expect(buildPortlessUrl('ownercommunity.localhost')).toBe(`https://ownercommunity.localhost:${PORTLESS_PORT}`); + expect(replaceUrlPort(apiUrl, 50900)).toBe('https://data-access.ownercommunity.localhost:50900/api/graphql'); }); it('builds shared Vite args including e2e mode', () => { @@ -87,9 +74,6 @@ describe('@cellix/local-dev', () => { }); it('derives deterministic worktree ports and connection strings', () => { - const workspaceRoot = createWorkspaceFixture(); - const env = { WORKTREE_NAME: 'feature-123' }; - expect(getWorktreePortOffset('feature-123')).toBeGreaterThanOrEqual(100); expect(getMongoPort('feature-123')).toBe(50000 + getWorktreePortOffset('feature-123')); expect(getAzuritePorts('feature-123')).toEqual({ @@ -98,40 +82,47 @@ describe('@cellix/local-dev', () => { table: 10002 + getWorktreePortOffset('feature-123'), }); expect( - getMongoConnectionString({ - startDir: path.join(workspaceRoot, 'apps', 'api'), - env, - }), - ).toContain(`:${getMongoPort('feature-123')}/test?replicaSet=rs0`); - expect( - getAzuriteConnectionString({ - startDir: path.join(workspaceRoot, 'apps', 'api'), - env, + buildAzuriteConnectionString({ + accountKey: 'key', + accountName: 'devstoreaccount1', + ports: getAzuritePorts('feature-123'), }), ).toContain(`BlobEndpoint=http://127.0.0.1:${getAzuritePorts('feature-123').blob}/devstoreaccount1`); }); - it('applies worktree and runtime overrides to API local settings', () => { + it('syncs json files through a consumer-supplied transform', () => { const workspaceRoot = createWorkspaceFixture(); - const settings = { - Values: { - STORAGE_ACCOUNT_NAME: 'devstoreaccount1', - STORAGE_ACCOUNT_KEY: 'key', - AZURE_STORAGE_CONNECTION_STRING: 'UseDevelopmentStorage=true', - }, - }; + const sourcePath = path.join(workspaceRoot, 'fixtures', 'source.json'); + const targetPath = path.join(workspaceRoot, 'fixtures', 'target', 'settings.json'); + writeFileSync( + sourcePath, + JSON.stringify( + { + Values: { + MODE: 'local', + }, + }, + null, + 2, + ), + ); - const updated = applyApiLocalSettingsOverrides(settings, { - workspaceRoot, - env: { - WORKTREE_NAME: 'feature-123', - COSMOSDB_CONNECTION_STRING: 'mongodb://127.0.0.1:61234/override?replicaSet=rs0', - }, + syncJsonFile({ + sourcePath, + targetPath, + transform: (document: { Values?: Record }) => ({ + ...document, + Values: { + ...(document.Values ?? {}), + MODE: 'e2e', + }, + }), }); - expect(updated.Values?.['ACCOUNT_PORTAL_OIDC_ISSUER']).toBe('https://mock-auth.ownercommunity.feature-123.localhost:1355/community'); - expect(updated.Values?.['STAFF_PORTAL_OIDC_ENDPOINT']).toBe('https://mock-auth.ownercommunity.feature-123.localhost:1355/staff/.well-known/jwks.json'); - expect(updated.Values?.['COSMOSDB_CONNECTION_STRING']).toBe('mongodb://127.0.0.1:61234/override?replicaSet=rs0'); - expect(updated.Values?.['AZURE_STORAGE_CONNECTION_STRING']).not.toBe('UseDevelopmentStorage=true'); + expect(JSON.parse(readFileSync(targetPath, 'utf8'))).toEqual({ + Values: { + MODE: 'e2e', + }, + }); }); }); diff --git a/packages/cellix/local-dev/src/index.ts b/packages/cellix/local-dev/src/index.ts index 2cdaacbbf..53edbb87e 100644 --- a/packages/cellix/local-dev/src/index.ts +++ b/packages/cellix/local-dev/src/index.ts @@ -1,31 +1,38 @@ -export { applyApiLocalSettingsOverrides, syncApiLocalSettings } from './api-local-settings.ts'; -export { runCli } from './cli.ts'; export { forwardChildExit, isGracefulInterruptExit } from './dev-process.ts'; export { - buildPortlessUrl, - PORTLESS_PORT, - type PortlessHostnameKey, - type PortlessHostnames, - resolvePortlessHostnames, -} from './hostnames.ts'; + type DotEnvValues, + readDotEnv, +} from './dotenv.ts'; +export { + readJsonFile, + type SyncJsonFileOptions, + syncJsonFile, + writeJsonFile, +} from './json-files.ts'; export { + type AzureFunctionsDevOptions, + type AzuriteDevOptions, type RunnerOptions, runAzureFunctionsDev, runAzuriteDev, runDocusaurusDev, runTsxDev, runViteDev, - type TsxDevProfile, - type ViteDevProfile, + type TsxRunnerOptions, } from './runners.ts'; +export { + applyWorktreeSuffix, + buildPortlessUrl, + hostnameFromUrl, + PORTLESS_PORT, + replaceUrlPort, +} from './urls.ts'; export { type BuildViteArgsOptions, buildViteArgs, isE2E } from './vite.ts'; export { type ResolveWorkspaceRootOptions, resolveWorkspaceRoot } from './workspace.ts'; export { type AzuritePorts, - type ConnectionStringOptions, - getAzuriteConnectionString, + buildAzuriteConnectionString, getAzuritePorts, - getMongoConnectionString, getMongoPort, getWorktreePortOffset, } from './worktree-ports.ts'; diff --git a/packages/cellix/local-dev/src/json-files.ts b/packages/cellix/local-dev/src/json-files.ts new file mode 100644 index 000000000..4c9c5296c --- /dev/null +++ b/packages/cellix/local-dev/src/json-files.ts @@ -0,0 +1,42 @@ +import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; + +export interface SyncJsonFileOptions { + sourcePath: string; + targetPath: string; + transform?: (data: TData) => TData; +} + +/** + * Reads a JSON file and returns its parsed contents. + */ +export function readJsonFile(filePath: string): TData { + return JSON.parse(readFileSync(filePath, 'utf8')) as TData; +} + +/** + * Writes a JSON document with stable indentation and a trailing newline. + */ +export function writeJsonFile(filePath: string, data: unknown): void { + mkdirSync(path.dirname(filePath), { recursive: true }); + writeFileSync(filePath, `${JSON.stringify(data, null, '\t')}\n`); +} + +/** + * Copies a JSON file to a target location and optionally applies a transform. + */ +export function syncJsonFile(options: SyncJsonFileOptions): void { + mkdirSync(path.dirname(options.targetPath), { recursive: true }); + + if (!options.transform) { + copyFileSync(options.sourcePath, options.targetPath); + return; + } + + if (!existsSync(options.sourcePath)) { + throw new Error(`[local-dev] Missing JSON source file: ${options.sourcePath}`); + } + + const sourceData = readJsonFile(options.sourcePath); + writeJsonFile(options.targetPath, options.transform(sourceData)); +} diff --git a/packages/cellix/local-dev/src/runners.ts b/packages/cellix/local-dev/src/runners.ts index aecd430ac..1afa324f1 100644 --- a/packages/cellix/local-dev/src/runners.ts +++ b/packages/cellix/local-dev/src/runners.ts @@ -2,23 +2,39 @@ import { type ChildProcess, spawn } from 'node:child_process'; import os from 'node:os'; import path from 'node:path'; import { forwardChildExit, isGracefulInterruptExit } from './dev-process.ts'; -import { buildPortlessUrl, type PortlessHostnameKey, resolvePortlessHostnames } from './hostnames.ts'; import { buildViteArgs } from './vite.ts'; -import { type ResolveWorkspaceRootOptions, resolveWorkspaceRoot } from './workspace.ts'; -import { getAzuriteConnectionString, getAzuritePorts, getMongoConnectionString, getMongoPort } from './worktree-ports.ts'; -export type ViteDevProfile = 'ui-community' | 'ui-staff'; -export type TsxDevProfile = 'oauth2-mock' | 'mongo-memory-mock'; +type RunnerEnv = NodeJS.ProcessEnv & { + HOST?: string; + NODE_OPTIONS?: string; + PORT?: string; + PORTLESS_CA_PATH?: string; +}; -export interface RunnerOptions extends ResolveWorkspaceRootOptions { +export interface RunnerOptions { env?: NodeJS.ProcessEnv; } -interface TsxRunnerOptions extends RunnerOptions { +export interface TsxRunnerOptions extends RunnerOptions { entry?: string; } -type OverrideSpec = Record; +export interface AzureFunctionsDevOptions extends RunnerOptions { + port?: string; + scriptRoot?: string; + cors?: string; + typescript?: boolean; +} + +export interface AzuriteDevOptions extends RunnerOptions { + blobPort: number; + queuePort: number; + tablePort: number; + blobLocation: string; + queueLocation: string; + tableLocation: string; + silent?: boolean; +} function spawnInherited(command: string, args: string[], options: { env?: NodeJS.ProcessEnv } = {}): ChildProcess { return spawn(command, args, { @@ -27,67 +43,19 @@ function spawnInherited(command: string, args: string[], options: { env?: NodeJS }); } -function applyPortlessEnvOverrides(childEnv: NodeJS.ProcessEnv, overrideSpec: OverrideSpec, options: RunnerOptions, settings: { preserveExisting?: boolean } = {}): void { - const env = options.env ?? process.env; - if (!env['WORKTREE_NAME']) return; - - const hostnames = resolvePortlessHostnames(options); - for (const [key, target] of Object.entries(overrideSpec)) { - if (settings.preserveExisting && childEnv[key]) { - continue; - } - - childEnv[key] = buildPortlessUrl(hostnames[target.hostname], target.path ?? ''); - } -} - /** - * Starts a Vite dev process using one of the shared Cellix UI profiles. + * Starts a Vite dev process using the caller-provided environment. */ -export function runViteDev(profile: ViteDevProfile, options: RunnerOptions = {}): ChildProcess { - const env = options.env ?? process.env; - const childEnv = { ...env }; - - if (profile === 'ui-community') { - applyPortlessEnvOverrides( - childEnv, - { - VITE_APP_UI_COMMUNITY_B2C_AUTHORITY: { - hostname: 'mockAuth', - path: '/community', - }, - VITE_APP_UI_COMMUNITY_B2C_REDIRECT_URI: { - hostname: 'uiCommunity', - path: '/auth-redirect', - }, - VITE_COMMON_API_ENDPOINT: { hostname: 'api', path: '/api/graphql' }, - VITE_APP_UI_COMMUNITY_BASE_URL: { hostname: 'uiCommunity' }, - }, - options, - ); - } else { - applyPortlessEnvOverrides( - childEnv, - { - VITE_APP_UI_STAFF_AAD_AUTHORITY: { hostname: 'mockAuth', path: '/staff' }, - VITE_APP_UI_STAFF_AAD_REDIRECT_URI: { - hostname: 'uiStaff', - path: '/auth-redirect', - }, - VITE_COMMON_API_ENDPOINT: { hostname: 'api', path: '/api/graphql' }, - }, - options, - ); - } - +export function runViteDev(options: RunnerOptions = {}): ChildProcess { + const env = (options.env ?? process.env) as RunnerEnv; const child = spawnInherited( 'vite', buildViteArgs({ - ...(env['HOST'] ? { host: env['HOST'] } : {}), - ...(env['PORT'] ? { port: env['PORT'] } : {}), + ...(env.HOST ? { host: env.HOST } : {}), + ...(env.PORT ? { port: env.PORT } : {}), env, }), - { env: childEnv }, + { env }, ); forwardChildExit(child); return child; @@ -97,18 +65,19 @@ export function runViteDev(profile: ViteDevProfile, options: RunnerOptions = {}) * Starts the Docusaurus dev server with the shared local-dev defaults. */ export function runDocusaurusDev(options: RunnerOptions = {}): ChildProcess { - const env = options.env ?? process.env; - const child = spawnInherited('docusaurus', ['start', '--port', env['PORT'] ?? '3001', '--host', '127.0.0.1', '--no-open']); + const env = (options.env ?? process.env) as RunnerEnv; + const child = spawnInherited('docusaurus', ['start', '--port', env.PORT ?? '3001', '--host', '127.0.0.1', '--no-open'], { env }); forwardChildExit(child); return child; } /** - * Starts the API Azure Functions process with shared CA, CORS, and worktree overrides. + * Starts an Azure Functions dev process using caller-supplied runtime + * configuration and environment variables. */ -export function runAzureFunctionsDev(options: RunnerOptions = {}): ChildProcess { - const env = options.env ?? process.env; - const envPort = env['PORT']; +export function runAzureFunctionsDev(options: AzureFunctionsDevOptions = {}): ChildProcess { + const env = (options.env ?? process.env) as RunnerEnv; + const envPort = options.port ?? env.PORT; if (!envPort) { throw new Error('[local-dev] PORT environment variable is not set. Start this command through portless.'); @@ -116,88 +85,46 @@ export function runAzureFunctionsDev(options: RunnerOptions = {}): ChildProcess const childEnv: NodeJS.ProcessEnv = { ...env, - NODE_EXTRA_CA_CERTS: env['PORTLESS_CA_PATH'] ?? path.join(os.homedir(), '.portless', 'ca.pem'), - NODE_OPTIONS: `${env['NODE_OPTIONS'] ?? ''} --use-system-ca`.trim(), + NODE_EXTRA_CA_CERTS: env.PORTLESS_CA_PATH ?? path.join(os.homedir(), '.portless', 'ca.pem'), + NODE_OPTIONS: `${env.NODE_OPTIONS ?? ''} --use-system-ca`.trim(), }; - if (env['WORKTREE_NAME']) { - applyPortlessEnvOverrides( - childEnv, - { - ACCOUNT_PORTAL_OIDC_ISSUER: { hostname: 'mockAuth', path: '/community' }, - ACCOUNT_PORTAL_OIDC_ENDPOINT: { - hostname: 'mockAuth', - path: '/community/.well-known/jwks.json', - }, - STAFF_PORTAL_OIDC_ISSUER: { hostname: 'mockAuth', path: '/staff' }, - STAFF_PORTAL_OIDC_ENDPOINT: { - hostname: 'mockAuth', - path: '/staff/.well-known/jwks.json', - }, - }, - options, - { preserveExisting: true }, - ); - childEnv['COSMOSDB_CONNECTION_STRING'] ??= getMongoConnectionString(options); - childEnv['AZURE_STORAGE_CONNECTION_STRING'] ??= getAzuriteConnectionString(options); - childEnv['AzureWebJobsStorage'] ??= getAzuriteConnectionString(options); - childEnv['languageWorkers__node__arguments'] ??= ''; + const args = ['start']; + if (options.typescript ?? true) { + args.push('--typescript'); } - const child = spawnInherited('func', ['start', '--typescript', '--script-root', 'deploy/', '--port', envPort, '--cors', '*'], { env: childEnv }); + args.push('--script-root', options.scriptRoot ?? 'deploy/', '--port', envPort, '--cors', options.cors ?? '*'); + + const child = spawnInherited('func', args, { env: childEnv }); forwardChildExit(child); return child; } /** - * Starts a TSX-backed mock service using one of the shared Cellix profiles. + * Starts a TSX-backed dev process using the caller-provided entrypoint and + * environment. */ -export function runTsxDev(profile: TsxDevProfile, options: TsxRunnerOptions = {}): ChildProcess { +export function runTsxDev(options: TsxRunnerOptions = {}): ChildProcess { const env = options.env ?? process.env; - const childEnv: NodeJS.ProcessEnv = { ...env }; - - if (profile === 'oauth2-mock') { - applyPortlessEnvOverrides( - childEnv, - { - BASE_URL: { hostname: 'mockAuth' }, - VITE_APP_UI_COMMUNITY_B2C_REDIRECT_URI: { - hostname: 'uiCommunity', - path: '/auth-redirect', - }, - VITE_APP_UI_STAFF_AAD_REDIRECT_URI: { - hostname: 'uiStaff', - path: '/auth-redirect', - }, - }, - options, - ); - } else if (profile === 'mongo-memory-mock') { - childEnv['PORT'] = String(getMongoPort(env['WORKTREE_NAME'])); - } - - const child = spawnInherited('tsx', [options.entry ?? 'src/index.ts'], { env: childEnv }); + const child = spawnInherited('tsx', [options.entry ?? 'src/index.ts'], { env }); forwardChildExit(child); return child; } /** - * Starts the three Azurite worker processes with worktree-scoped ports and storage paths. + * Starts the three Azurite worker processes using caller-supplied ports and + * storage paths. */ -export function runAzuriteDev(options: RunnerOptions = {}): ChildProcess[] { - const env = options.env ?? process.env; - const workspaceRoot = resolveWorkspaceRoot(options); - const ports = getAzuritePorts(env['WORKTREE_NAME']); - const storageSuffix = env['WORKTREE_NAME'] ? `-${env['WORKTREE_NAME']}` : ''; - +export function runAzuriteDev(options: AzuriteDevOptions): ChildProcess[] { const procSpecs: Array<[string, string[]]> = [ - ['azurite-blob', ['--silent', '--blobPort', String(ports.blob), '--location', path.join(workspaceRoot, `__blobstorage__${storageSuffix}`)]], - ['azurite-queue', ['--silent', '--queuePort', String(ports.queue), '--location', path.join(workspaceRoot, `__queuestorage__${storageSuffix}`)]], - ['azurite-table', ['--silent', '--tablePort', String(ports.table), '--location', path.join(workspaceRoot, `__tablestorage__${storageSuffix}`)]], + ['azurite-blob', [...((options.silent ?? true) ? ['--silent'] : []), '--blobPort', String(options.blobPort), '--location', path.resolve(options.blobLocation)]], + ['azurite-queue', [...((options.silent ?? true) ? ['--silent'] : []), '--queuePort', String(options.queuePort), '--location', path.resolve(options.queueLocation)]], + ['azurite-table', [...((options.silent ?? true) ? ['--silent'] : []), '--tablePort', String(options.tablePort), '--location', path.resolve(options.tableLocation)]], ]; const procs = procSpecs.map(([command, args]) => { - const proc = spawnInherited(command, args); + const proc = spawnInherited(command, args, options.env ? { env: options.env } : {}); proc.on('error', (error) => { console.error(`[azurite] failed to start ${command}: ${error.message}`); for (const runningProc of procs) { @@ -208,7 +135,7 @@ export function runAzuriteDev(options: RunnerOptions = {}): ChildProcess[] { return proc; }); - console.log(`[azurite] started (blob=${ports.blob}, queue=${ports.queue}, table=${ports.table})`); + console.log(`[azurite] started (blob=${options.blobPort}, queue=${options.queuePort}, table=${options.tablePort})`); let exited = 0; for (const proc of procs) { diff --git a/packages/cellix/local-dev/src/urls.ts b/packages/cellix/local-dev/src/urls.ts new file mode 100644 index 000000000..8b4b71b73 --- /dev/null +++ b/packages/cellix/local-dev/src/urls.ts @@ -0,0 +1,40 @@ +export const PORTLESS_PORT = 1355; + +/** + * Returns the hostname portion of a URL string, or `null` when the value is not + * a valid URL. + */ +export function hostnameFromUrl(url: string): string | null { + try { + return new URL(url).hostname; + } catch { + return null; + } +} + +/** + * Applies a worktree suffix to a `.localhost` hostname. + */ +export function applyWorktreeSuffix(hostname: string, worktreeName: string | undefined): string { + if (!worktreeName) { + return hostname; + } + + return hostname.replace('.localhost', `.${worktreeName}.localhost`); +} + +/** + * Builds a portless-proxied HTTPS URL for a hostname and optional path. + */ +export function buildPortlessUrl(hostname: string, relativePath = ''): string { + return `https://${hostname}:${PORTLESS_PORT}${relativePath}`; +} + +/** + * Replaces the port component of a URL and returns the updated string. + */ +export function replaceUrlPort(url: string, port: number | string): string { + const parsedUrl = new URL(url); + parsedUrl.port = String(port); + return parsedUrl.toString(); +} diff --git a/packages/cellix/local-dev/src/vite.ts b/packages/cellix/local-dev/src/vite.ts index 7944136f1..40c9b0b15 100644 --- a/packages/cellix/local-dev/src/vite.ts +++ b/packages/cellix/local-dev/src/vite.ts @@ -4,11 +4,18 @@ export interface BuildViteArgsOptions { env?: NodeJS.ProcessEnv; } +type ViteEnv = NodeJS.ProcessEnv & { + E2E?: string; + E2E_VITE_MODE?: string; + TF_BUILD?: string; +}; + /** * Returns true when the current process is running in an e2e-oriented mode. */ export function isE2E(env: NodeJS.ProcessEnv = process.env): boolean { - return ['1', 'true', 'yes'].includes((env['E2E'] ?? '').toLowerCase()); + const viteEnv = env as ViteEnv; + return ['1', 'true', 'yes'].includes((viteEnv.E2E ?? '').toLowerCase()); } /** @@ -16,13 +23,14 @@ export function isE2E(env: NodeJS.ProcessEnv = process.env): boolean { */ export function buildViteArgs(options: BuildViteArgsOptions = {}): string[] { const { host = '127.0.0.1', port, env = process.env } = options; + const viteEnv = env as ViteEnv; const args = ['--host', host]; if (port) { args.push('--port', port); } - const viteMode = env['E2E_VITE_MODE'] ?? (isE2E(env) || env['TF_BUILD'] ? 'e2e' : undefined); + const viteMode = viteEnv.E2E_VITE_MODE ?? (isE2E(viteEnv) || viteEnv.TF_BUILD ? 'e2e' : undefined); if (viteMode) { args.push('--mode', viteMode); } diff --git a/packages/cellix/local-dev/src/worktree-ports.ts b/packages/cellix/local-dev/src/worktree-ports.ts index d9b4ff806..493c20fb3 100644 --- a/packages/cellix/local-dev/src/worktree-ports.ts +++ b/packages/cellix/local-dev/src/worktree-ports.ts @@ -1,42 +1,21 @@ -import { existsSync, readFileSync } from 'node:fs'; -import path from 'node:path'; -import { type ResolveWorkspaceRootOptions, resolveWorkspaceRoot } from './workspace.ts'; - -type SettingsValues = Record; - export interface AzuritePorts { blob: number; queue: number; table: number; } -export interface ConnectionStringOptions extends ResolveWorkspaceRootOptions { - env?: NodeJS.ProcessEnv; - values?: SettingsValues; -} +type WorktreeEnv = NodeJS.ProcessEnv & { + WORKTREE_NAME?: string; +}; -function readApiLocalSettingsValues(workspaceRoot: string): SettingsValues { - const candidatePaths = [path.join(workspaceRoot, 'apps', 'api', 'deploy', 'local.settings.json'), path.join(workspaceRoot, 'apps', 'api', 'local.settings.json')]; - - for (const settingsPath of candidatePaths) { - if (!existsSync(settingsPath)) continue; - const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) as { - Values?: SettingsValues; - }; - return settings.Values ?? {}; - } - - return {}; -} - -function getSetting(name: string, options: ConnectionStringOptions, workspaceRoot: string): string | undefined { - return options.env?.[name] ?? options.values?.[name] ?? readApiLocalSettingsValues(workspaceRoot)[name]; +function getDefaultWorktreeName(): string | undefined { + return (process.env as WorktreeEnv).WORKTREE_NAME; } /** * Returns a deterministic worktree port offset in increments of 100. */ -export function getWorktreePortOffset(worktreeName = process.env['WORKTREE_NAME']): number { +export function getWorktreePortOffset(worktreeName = getDefaultWorktreeName()): number { if (!worktreeName) return 0; let hash = 0; @@ -50,14 +29,14 @@ export function getWorktreePortOffset(worktreeName = process.env['WORKTREE_NAME' /** * Returns the MongoDB port for the current worktree. */ -export function getMongoPort(worktreeName = process.env['WORKTREE_NAME']): number { +export function getMongoPort(worktreeName = getDefaultWorktreeName()): number { return 50000 + getWorktreePortOffset(worktreeName); } /** * Returns the Azurite ports for the current worktree. */ -export function getAzuritePorts(worktreeName = process.env['WORKTREE_NAME']): AzuritePorts { +export function getAzuritePorts(worktreeName = getDefaultWorktreeName()): AzuritePorts { const offset = getWorktreePortOffset(worktreeName); return { @@ -68,40 +47,17 @@ export function getAzuritePorts(worktreeName = process.env['WORKTREE_NAME']): Az } /** - * Returns the Azurite connection string for the current worktree. + * Builds an Azurite connection string from explicit account credentials and + * ports supplied by the consumer. */ -export function getAzuriteConnectionString(options: ConnectionStringOptions = {}): string { - const workspaceRoot = resolveWorkspaceRoot(options); - const ports = getAzuritePorts(options.env?.['WORKTREE_NAME'] ?? process.env['WORKTREE_NAME']); - if (ports.blob === 10000) return 'UseDevelopmentStorage=true'; - - const accountName = getSetting('STORAGE_ACCOUNT_NAME', options, workspaceRoot); - const accountKey = getSetting('STORAGE_ACCOUNT_KEY', options, workspaceRoot); - if (!accountName || !accountKey) { - throw new Error('[local-dev] STORAGE_ACCOUNT_NAME and STORAGE_ACCOUNT_KEY must be set to build a worktree Azurite connection string'); - } - +export function buildAzuriteConnectionString(options: { accountName: string; accountKey: string; ports: AzuritePorts; host?: string }): string { + const host = options.host ?? '127.0.0.1'; return [ 'DefaultEndpointsProtocol=http', - `AccountName=${accountName}`, - `AccountKey=${accountKey}`, - `BlobEndpoint=http://127.0.0.1:${ports.blob}/${accountName}`, - `QueueEndpoint=http://127.0.0.1:${ports.queue}/${accountName}`, - `TableEndpoint=http://127.0.0.1:${ports.table}/${accountName}`, + `AccountName=${options.accountName}`, + `AccountKey=${options.accountKey}`, + `BlobEndpoint=http://${host}:${options.ports.blob}/${options.accountName}`, + `QueueEndpoint=http://${host}:${options.ports.queue}/${options.accountName}`, + `TableEndpoint=http://${host}:${options.ports.table}/${options.accountName}`, ].join(';'); } - -/** - * Reads the API Mongo connection string and patches in the worktree-specific port. - */ -export function getMongoConnectionString(options: ConnectionStringOptions = {}): string { - const workspaceRoot = resolveWorkspaceRoot(options); - const base = getSetting('COSMOSDB_CONNECTION_STRING', options, workspaceRoot); - if (!base) { - throw new Error('[local-dev] COSMOSDB_CONNECTION_STRING must be set'); - } - - const url = new URL(base); - url.port = String(getMongoPort(options.env?.['WORKTREE_NAME'] ?? process.env['WORKTREE_NAME'])); - return url.toString(); -} diff --git a/packages/ocom-verification/e2e-tests/src/cucumber-lifecycle-hooks.ts b/packages/ocom-verification/e2e-tests/src/cucumber-lifecycle-hooks.ts index 511fc56e0..59ac1efed 100644 --- a/packages/ocom-verification/e2e-tests/src/cucumber-lifecycle-hooks.ts +++ b/packages/ocom-verification/e2e-tests/src/cucumber-lifecycle-hooks.ts @@ -12,6 +12,7 @@ const currentDir = fileURLToPath(new URL('.', import.meta.url)); /** Register the Cucumber Before/After/AfterAll and screenshot hooks for the E2E suite. */ export function registerLifecycleHooks(): void { registerWorldLifecycleHooks({ + beforeTimeout: getTimeout('serverStartup') + getTimeout('uiInit') * 3, scenarioTimeout: getTimeout('scenario'), before: async (world) => { await world.init(); diff --git a/packages/ocom-verification/e2e-tests/src/infrastructure.ts b/packages/ocom-verification/e2e-tests/src/infrastructure.ts index 89d401faa..6a20da5ca 100644 --- a/packages/ocom-verification/e2e-tests/src/infrastructure.ts +++ b/packages/ocom-verification/e2e-tests/src/infrastructure.ts @@ -18,7 +18,9 @@ const infrastructure = E2EInfrastructure.create({ }) .addServer('azurite', () => createTestAzuriteServer()) .addServer('auth', () => createTestOAuth2Server()) - .addServer('api', (ctx) => createTestApiServer(() => ctx.server('mongo').getConnectionString()), { dependsOn: ['mongo'] }) + .addServer('api', (ctx) => createTestApiServer(() => ctx.server('mongo').getConnectionString()), { + dependsOn: ['mongo', 'azurite'], + }) .addUiPortal('community', () => createCommunityUiPortalServer()) .addUiPortal('staff', () => createStaffUiPortalServer()); diff --git a/packages/ocom-verification/e2e-tests/src/shared/environment/test-environment.ts b/packages/ocom-verification/e2e-tests/src/shared/environment/test-environment.ts index cfb488e4c..5c3383122 100644 --- a/packages/ocom-verification/e2e-tests/src/shared/environment/test-environment.ts +++ b/packages/ocom-verification/e2e-tests/src/shared/environment/test-environment.ts @@ -43,9 +43,23 @@ export function initTestEnvironment() { timeout: 10_000, stdio: 'pipe', }); + try { + execFileSync(getPortlessPath(), ['proxy', 'stop', '-p', '1355'], { + timeout: 10_000, + stdio: 'pipe', + }); + } catch { + // It's fine if no proxy was already running on the test port. + } execFileSync(getPortlessPath(), ['proxy', 'start', '--https', '-p', '1355'], { timeout: 15_000, stdio: 'pipe', + env: { + ...process.env, + // E2E needs exact host matches so portal probes do not mistake the + // community app for the staff app via wildcard subdomain fallback. + PORTLESS_WILDCARD: '0', + }, }); proxyInitialized = true; diff --git a/packages/ocom/local-dev-config/.gitignore b/packages/ocom/local-dev-config/.gitignore new file mode 100644 index 000000000..de4d1f007 --- /dev/null +++ b/packages/ocom/local-dev-config/.gitignore @@ -0,0 +1,2 @@ +dist +node_modules diff --git a/packages/ocom/local-dev-config/package.json b/packages/ocom/local-dev-config/package.json new file mode 100644 index 000000000..0f60c1930 --- /dev/null +++ b/packages/ocom/local-dev-config/package.json @@ -0,0 +1,47 @@ +{ + "name": "@ocom/local-dev-config", + "version": "1.0.0", + "description": "OCOM-specific local-development hostname/URL helpers built on @cellix/local-dev", + "type": "module", + "files": [ + "dist", + "src" + ], + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./hostnames": { + "types": "./dist/hostnames.d.ts", + "default": "./dist/hostnames.js" + }, + "./urls": { + "types": "./dist/urls.d.ts", + "default": "./dist/urls.js" + }, + "./workspace": { + "types": "./dist/workspace.d.ts", + "default": "./dist/workspace.js" + } + }, + "scripts": { + "prebuild": "pnpm run lint", + "build": "tsgo --build", + "clean": "rimraf dist", + "lint": "biome lint", + "format": "biome format --write", + "format:check": "biome format ." + }, + "dependencies": { + "@cellix/local-dev": "workspace:*" + }, + "devDependencies": { + "@cellix/config-typescript": "workspace:*", + "@types/node": "catalog:", + "rimraf": "catalog:", + "typescript": "catalog:" + } +} diff --git a/packages/ocom/local-dev-config/src/hostnames.ts b/packages/ocom/local-dev-config/src/hostnames.ts new file mode 100644 index 000000000..191cb173e --- /dev/null +++ b/packages/ocom/local-dev-config/src/hostnames.ts @@ -0,0 +1,51 @@ +import path from 'node:path'; +import { applyWorktreeSuffix, type DotEnvValues, hostnameFromUrl, readDotEnv } from '@cellix/local-dev'; +import type { OcomHostnames, OcomLocalDevOptions } from './types.ts'; +import { getWorkspaceRoot } from './workspace.ts'; + +interface OcomEnvValues { + WORKTREE_NAME?: string; + VITE_APP_UI_COMMUNITY_BASE_URL?: string; + VITE_COMMON_API_ENDPOINT?: string; + VITE_APP_UI_COMMUNITY_B2C_AUTHORITY?: string; + VITE_APP_UI_STAFF_BASE_URL?: string; + VITE_APP_UI_STAFF_AAD_REDIRECT_URI?: string; +} + +function requiredHostname(url: string, key: string): string { + const hostname = hostnameFromUrl(url); + if (!hostname) { + throw new Error(`[ocom-local-dev] Missing or invalid URL for ${key}`); + } + + return hostname; +} + +function readAppEnv(workspaceRoot: string, appName: string): DotEnvValues & OcomEnvValues { + return readDotEnv(path.join(workspaceRoot, 'apps', appName, '.env')) as DotEnvValues & OcomEnvValues; +} + +export function getOcomHostnames(options: OcomLocalDevOptions = {}): OcomHostnames { + const env = (options.env ?? process.env) as NodeJS.ProcessEnv & OcomEnvValues; + const workspaceRoot = options.workspaceRoot ?? getWorkspaceRoot(); + const communityEnv = readAppEnv(workspaceRoot, 'ui-community'); + const staffEnv = readAppEnv(workspaceRoot, 'ui-staff'); + const worktreeName = env.WORKTREE_NAME; + const communityHostname = requiredHostname(env.VITE_APP_UI_COMMUNITY_BASE_URL ?? communityEnv.VITE_APP_UI_COMMUNITY_BASE_URL ?? '', 'VITE_APP_UI_COMMUNITY_BASE_URL'); + const apiHostname = requiredHostname(env.VITE_COMMON_API_ENDPOINT ?? communityEnv.VITE_COMMON_API_ENDPOINT ?? '', 'VITE_COMMON_API_ENDPOINT'); + const mockAuthHostname = requiredHostname(env.VITE_APP_UI_COMMUNITY_B2C_AUTHORITY ?? communityEnv.VITE_APP_UI_COMMUNITY_B2C_AUTHORITY ?? '', 'VITE_APP_UI_COMMUNITY_B2C_AUTHORITY'); + const staffHostname = requiredHostname( + env.VITE_APP_UI_STAFF_BASE_URL ?? staffEnv.VITE_APP_UI_STAFF_BASE_URL ?? env.VITE_APP_UI_STAFF_AAD_REDIRECT_URI ?? staffEnv.VITE_APP_UI_STAFF_AAD_REDIRECT_URI ?? '', + 'VITE_APP_UI_STAFF_BASE_URL', + ); + + return { + uiCommunity: applyWorktreeSuffix(communityHostname, worktreeName), + uiStaff: applyWorktreeSuffix(staffHostname, worktreeName), + api: applyWorktreeSuffix(apiHostname, worktreeName), + mockAuth: applyWorktreeSuffix(mockAuthHostname, worktreeName), + docs: applyWorktreeSuffix(`docs.${communityHostname}`, worktreeName), + }; +} + +export type { OcomHostnames, OcomLocalDevOptions } from './types.ts'; diff --git a/packages/ocom/local-dev-config/src/index.ts b/packages/ocom/local-dev-config/src/index.ts new file mode 100644 index 000000000..93cbebc32 --- /dev/null +++ b/packages/ocom/local-dev-config/src/index.ts @@ -0,0 +1,4 @@ +export { getOcomHostnames } from './hostnames.ts'; +export type { OcomHostnames, OcomLocalDevOptions, OcomUrls } from './types.ts'; +export { buildOcomUrls } from './urls.ts'; +export { getWorkspaceRoot } from './workspace.ts'; diff --git a/packages/ocom/local-dev-config/src/types.ts b/packages/ocom/local-dev-config/src/types.ts new file mode 100644 index 000000000..b862ea512 --- /dev/null +++ b/packages/ocom/local-dev-config/src/types.ts @@ -0,0 +1,25 @@ +export interface OcomLocalDevOptions { + env?: NodeJS.ProcessEnv; + workspaceRoot?: string; +} + +export interface OcomHostnames { + uiCommunity: string; + uiStaff: string; + api: string; + mockAuth: string; + docs: string; +} + +export interface OcomUrls { + uiCommunityBaseUrl: string; + uiCommunityRedirectUrl: string; + uiStaffBaseUrl: string; + uiStaffRedirectUrl: string; + apiGraphqlUrl: string; + mockCommunityAuthorityUrl: string; + mockCommunityJwksUrl: string; + mockStaffAuthorityUrl: string; + mockStaffJwksUrl: string; + docsBaseUrl: string; +} diff --git a/packages/ocom/local-dev-config/src/urls.ts b/packages/ocom/local-dev-config/src/urls.ts new file mode 100644 index 000000000..2302d5d32 --- /dev/null +++ b/packages/ocom/local-dev-config/src/urls.ts @@ -0,0 +1,22 @@ +import { buildPortlessUrl } from '@cellix/local-dev'; +import { getOcomHostnames } from './hostnames.ts'; +import type { OcomLocalDevOptions, OcomUrls } from './types.ts'; + +export function buildOcomUrls(options: OcomLocalDevOptions = {}): OcomUrls { + const hostnames = getOcomHostnames(options); + + return { + uiCommunityBaseUrl: buildPortlessUrl(hostnames.uiCommunity), + uiCommunityRedirectUrl: buildPortlessUrl(hostnames.uiCommunity, '/auth-redirect'), + uiStaffBaseUrl: buildPortlessUrl(hostnames.uiStaff), + uiStaffRedirectUrl: buildPortlessUrl(hostnames.uiStaff, '/auth-redirect'), + apiGraphqlUrl: buildPortlessUrl(hostnames.api, '/api/graphql'), + mockCommunityAuthorityUrl: buildPortlessUrl(hostnames.mockAuth, '/community'), + mockCommunityJwksUrl: buildPortlessUrl(hostnames.mockAuth, '/community/.well-known/jwks.json'), + mockStaffAuthorityUrl: buildPortlessUrl(hostnames.mockAuth, '/staff'), + mockStaffJwksUrl: buildPortlessUrl(hostnames.mockAuth, '/staff/.well-known/jwks.json'), + docsBaseUrl: buildPortlessUrl(hostnames.docs), + }; +} + +export type { OcomLocalDevOptions, OcomUrls } from './types.ts'; diff --git a/packages/ocom/local-dev-config/src/workspace.ts b/packages/ocom/local-dev-config/src/workspace.ts new file mode 100644 index 000000000..2c66b424e --- /dev/null +++ b/packages/ocom/local-dev-config/src/workspace.ts @@ -0,0 +1,5 @@ +import { resolveWorkspaceRoot } from '@cellix/local-dev'; + +export function getWorkspaceRoot(startDir: string = process.cwd()): string { + return resolveWorkspaceRoot({ startDir }); +} diff --git a/packages/ocom/local-dev-config/tsconfig.json b/packages/ocom/local-dev-config/tsconfig.json new file mode 100644 index 000000000..50002ee6d --- /dev/null +++ b/packages/ocom/local-dev-config/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@cellix/config-typescript/node", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" + }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../../cellix/local-dev" }] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d8fd2dd2e..a68aba219 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -302,6 +302,9 @@ importers: '@cellix/local-dev': specifier: workspace:* version: link:../../packages/cellix/local-dev + '@ocom/local-dev-config': + specifier: workspace:* + version: link:../../packages/ocom/local-dev-config '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.6(vitest@4.1.6) @@ -421,6 +424,9 @@ importers: '@cellix/server-oauth2-mock-seedwork': specifier: workspace:* version: link:../../packages/cellix/server-oauth2-mock-seedwork + '@ocom/local-dev-config': + specifier: workspace:* + version: link:../../packages/ocom/local-dev-config dotenv: specifier: ^16.4.5 version: 16.6.1 @@ -464,6 +470,9 @@ importers: '@graphql-typed-document-node/core': specifier: ^3.2.0 version: 3.2.0(graphql@16.12.0) + '@ocom/local-dev-config': + specifier: workspace:* + version: link:../../packages/ocom/local-dev-config '@ocom/ui-community-route-accounts': specifier: workspace:* version: link:../../packages/ocom/ui-community-route-accounts @@ -588,6 +597,9 @@ importers: '@graphql-typed-document-node/core': specifier: ^3.2.0 version: 3.2.0(graphql@16.12.0) + '@ocom/local-dev-config': + specifier: workspace:* + version: link:../../packages/ocom/local-dev-config '@ocom/ui-shared': specifier: workspace:* version: link:../../packages/ocom/ui-shared @@ -1640,6 +1652,25 @@ importers: specifier: 'catalog:' version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-istanbul@4.1.6)(happy-dom@20.9.0)(jsdom@26.1.0)(vite@8.0.5(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@types/node@24.10.1)(esbuild@0.27.4)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + packages/ocom/local-dev-config: + dependencies: + '@cellix/local-dev': + specifier: workspace:* + version: link:../../cellix/local-dev + devDependencies: + '@cellix/config-typescript': + specifier: workspace:* + version: link:../../cellix/config-typescript + '@types/node': + specifier: 'catalog:' + version: 22.19.15 + rimraf: + specifier: 'catalog:' + version: 6.0.1 + typescript: + specifier: 'catalog:' + version: 6.0.3 + packages/ocom/persistence: dependencies: '@cellix/domain-seedwork': diff --git a/turbo.json b/turbo.json index 6c391b388..d1ddf8147 100644 --- a/turbo.json +++ b/turbo.json @@ -67,7 +67,7 @@ }, "test:e2e": { "description": "Runs end-to-end tests from packages that expose them", - "dependsOn": ["^build"], + "dependsOn": ["^build", "@cellix/local-dev#build", "@ocom/local-dev-config#build"], "inputs": ["$TURBO_DEFAULT$", "!coverage/**", "!target/**", "!dist/**", "!build/**", "!deploy/**"], "outputs": ["target/**", "reports/**"], "cache": false @@ -105,18 +105,19 @@ }, "dev": { "description": "Starts application packages dev servers in watch mode", - "dependsOn": ["^build"], + "dependsOn": ["^build", "@cellix/local-dev#build", "@ocom/local-dev-config#build"], "cache": false, "persistent": true }, "dev:worktree": { "description": "Starts dev servers with worktree-scoped portless hostnames for git worktree isolation", - "dependsOn": ["^build"], + "dependsOn": ["^build", "@cellix/local-dev#build", "@ocom/local-dev-config#build"], "cache": false, "persistent": true }, "azurite": { "description": "Starts the Azurite storage emulator", + "dependsOn": ["^build", "@cellix/local-dev#build", "@ocom/local-dev-config#build"], "cache": false, "persistent": true },