diff --git a/.pnpm-store/v11/index.db b/.pnpm-store/v11/index.db new file mode 100644 index 000000000..044636a65 Binary files /dev/null and b/.pnpm-store/v11/index.db differ diff --git a/apps/api/package.json b/apps/api/package.json index f4d026525..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 node start-dev.mjs", + "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 node start-dev.mjs", + "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": "node scripts/sync-local-settings.mjs", - "azurite": "node start-azurite.mjs" + "sync-local-settings": "node sync-local-settings.ts", + "azurite": "node start-azurite.ts" }, "dependencies": { "@azure/functions": "catalog:", @@ -48,6 +48,8 @@ "@cellix/config-rolldown": "workspace:*", "@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/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-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.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/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/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..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 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 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", @@ -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/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/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..1a5acc021 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": "node start-dev.ts", + "dev:worktree": "node start-dev.ts" }, "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-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-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..28e3fadc3 100644 --- a/apps/server-oauth2-mock/package.json +++ b/apps/server-oauth2-mock/package.json @@ -11,17 +11,19 @@ "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 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": { + "@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/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/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..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 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 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:*", @@ -39,6 +40,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/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-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..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 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 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:*", @@ -40,6 +41,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/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/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..eaff37f94 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"] + "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}"], - "ignore": [] + "entry": ["src/main.tsx", "start-dev.ts"], + "project": ["src/**/*.{ts,tsx}", "start-dev.ts"], + "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"], @@ -88,27 +91,18 @@ "ignoreUnresolved": ["progress-bar"] }, "apps/server-oauth2-mock": { + "entry": ["src/index.ts", "start-dev.ts"], + "project": ["src/**/*.ts", "start-dev.ts"], + "ignoreDependencies": ["@cellix/local-dev", "tsx"] + }, + "apps/server-mongodb-memory-mock": { "entry": ["src/index.ts"], "project": ["src/**/*.ts"], - "ignoreDependencies": ["tsx"] + "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..3fe57fdab --- /dev/null +++ b/packages/cellix/local-dev/README.md @@ -0,0 +1,96 @@ +# @cellix/local-dev + +Generic local-development helpers for Cellix app wrappers. + +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 + +In this monorepo, app packages consume the workspace package directly: + +```json +{ + "devDependencies": { + "@cellix/local-dev": "workspace:*" + } +} +``` + +## What this package provides + +- 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 + +## 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)), + }, +}); +``` + +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 + +- `resolveWorkspaceRoot` +- `readDotEnv` +- `readJsonFile` +- `writeJsonFile` +- `syncJsonFile` +- `hostnameFromUrl` +- `applyWorktreeSuffix` +- `buildPortlessUrl` +- `replaceUrlPort` +- `PORTLESS_PORT` +- `buildViteArgs` +- `isE2E` +- `forwardChildExit` +- `isGracefulInterruptExit` +- `getWorktreePortOffset` +- `getMongoPort` +- `getAzuritePorts` +- `buildAzuriteConnectionString` +- `runViteDev` +- `runDocusaurusDev` +- `runAzureFunctionsDev` +- `runTsxDev` +- `runAzuriteDev` + +## Notes + +- 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/manifest.md b/packages/cellix/local-dev/manifest.md new file mode 100644 index 000000000..d577a0634 --- /dev/null +++ b/packages/cellix/local-dev/manifest.md @@ -0,0 +1,61 @@ +# manifest.md - @cellix/local-dev + +## Purpose + +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 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 +- 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?)` +- `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?)`, `buildAzuriteConnectionString(options)` +- `runViteDev(options?)`, `runDocusaurusDev(options?)`, `runAzureFunctionsDev(options?)`, `runTsxDev(options?)`, `runAzuriteDev(options)` + +## Core concepts + +- 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 + +- 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 app-owned wrapper scripts and from tests through the TypeScript API + +## Testing strategy + +- 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 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 public exports or scope boundaries change. + +## Release-readiness standards + +- 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. +- 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 new file mode 100644 index 000000000..a1ea2b63e --- /dev/null +++ b/packages/cellix/local-dev/package.json @@ -0,0 +1,38 @@ +{ + "name": "@cellix/local-dev", + "version": "1.0.0", + "description": "Generic local-development helpers for Cellix app wrappers", + "type": "module", + "files": [ + "dist", + "src" + ], + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "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/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/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/index.test.ts b/packages/cellix/local-dev/src/index.test.ts new file mode 100644 index 000000000..4f79f00f9 --- /dev/null +++ b/packages/cellix/local-dev/src/index.test.ts @@ -0,0 +1,128 @@ +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { + applyWorktreeSuffix, + buildAzuriteConnectionString, + buildPortlessUrl, + buildViteArgs, + getAzuritePorts, + getMongoPort, + getWorktreePortOffset, + hostnameFromUrl, + PORTLESS_PORT, + 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, 'fixtures'), { recursive: true }); + + return root; +} + +describe('@cellix/local-dev', () => { + it('resolves the workspace root from a nested directory', () => { + const workspaceRoot = createWorkspaceFixture(); + const nestedDir = path.join(workspaceRoot, 'fixtures'); + + expect(resolveWorkspaceRoot({ startDir: nestedDir })).toBe(workspaceRoot); + }); + + 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')); + + 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', () => { + 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', () => { + 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( + buildAzuriteConnectionString({ + accountKey: 'key', + accountName: 'devstoreaccount1', + ports: getAzuritePorts('feature-123'), + }), + ).toContain(`BlobEndpoint=http://127.0.0.1:${getAzuritePorts('feature-123').blob}/devstoreaccount1`); + }); + + it('syncs json files through a consumer-supplied transform', () => { + const workspaceRoot = createWorkspaceFixture(); + 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, + ), + ); + + syncJsonFile({ + sourcePath, + targetPath, + transform: (document: { Values?: Record }) => ({ + ...document, + Values: { + ...(document.Values ?? {}), + MODE: 'e2e', + }, + }), + }); + + 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 new file mode 100644 index 000000000..53edbb87e --- /dev/null +++ b/packages/cellix/local-dev/src/index.ts @@ -0,0 +1,38 @@ +export { forwardChildExit, isGracefulInterruptExit } from './dev-process.ts'; +export { + 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 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, + buildAzuriteConnectionString, + getAzuritePorts, + 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 new file mode 100644 index 000000000..1afa324f1 --- /dev/null +++ b/packages/cellix/local-dev/src/runners.ts @@ -0,0 +1,170 @@ +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 { buildViteArgs } from './vite.ts'; + +type RunnerEnv = NodeJS.ProcessEnv & { + HOST?: string; + NODE_OPTIONS?: string; + PORT?: string; + PORTLESS_CA_PATH?: string; +}; + +export interface RunnerOptions { + env?: NodeJS.ProcessEnv; +} + +export interface TsxRunnerOptions extends RunnerOptions { + entry?: string; +} + +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, { + stdio: 'inherit', + env: options.env, + }); +} + +/** + * Starts a Vite dev process using the caller-provided environment. + */ +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, + }), + { env }, + ); + 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) as RunnerEnv; + const child = spawnInherited('docusaurus', ['start', '--port', env.PORT ?? '3001', '--host', '127.0.0.1', '--no-open'], { env }); + forwardChildExit(child); + return child; +} + +/** + * Starts an Azure Functions dev process using caller-supplied runtime + * configuration and environment variables. + */ +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.'); + } + + 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(), + }; + + const args = ['start']; + if (options.typescript ?? true) { + args.push('--typescript'); + } + + 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 dev process using the caller-provided entrypoint and + * environment. + */ +export function runTsxDev(options: TsxRunnerOptions = {}): ChildProcess { + const env = options.env ?? process.env; + const child = spawnInherited('tsx', [options.entry ?? 'src/index.ts'], { env }); + forwardChildExit(child); + return child; +} + +/** + * Starts the three Azurite worker processes using caller-supplied ports and + * storage paths. + */ +export function runAzuriteDev(options: AzuriteDevOptions): ChildProcess[] { + const procSpecs: Array<[string, string[]]> = [ + ['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, options.env ? { env: options.env } : {}); + 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=${options.blobPort}, queue=${options.queuePort}, table=${options.tablePort})`); + + 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/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 new file mode 100644 index 000000000..40c9b0b15 --- /dev/null +++ b/packages/cellix/local-dev/src/vite.ts @@ -0,0 +1,39 @@ +export interface BuildViteArgsOptions { + host?: string; + port?: string; + 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 { + const viteEnv = env as ViteEnv; + return ['1', 'true', 'yes'].includes((viteEnv.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 viteEnv = env as ViteEnv; + const args = ['--host', host]; + + if (port) { + args.push('--port', port); + } + + const viteMode = viteEnv.E2E_VITE_MODE ?? (isE2E(viteEnv) || viteEnv.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..493c20fb3 --- /dev/null +++ b/packages/cellix/local-dev/src/worktree-ports.ts @@ -0,0 +1,63 @@ +export interface AzuritePorts { + blob: number; + queue: number; + table: number; +} + +type WorktreeEnv = NodeJS.ProcessEnv & { + WORKTREE_NAME?: string; +}; + +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 = getDefaultWorktreeName()): 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 = getDefaultWorktreeName()): number { + return 50000 + getWorktreePortOffset(worktreeName); +} + +/** + * Returns the Azurite ports for the current worktree. + */ +export function getAzuritePorts(worktreeName = getDefaultWorktreeName()): AzuritePorts { + const offset = getWorktreePortOffset(worktreeName); + + return { + blob: 10000 + offset, + queue: 10001 + offset, + table: 10002 + offset, + }; +} + +/** + * Builds an Azurite connection string from explicit account credentials and + * ports supplied by the consumer. + */ +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=${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(';'); +} 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/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-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/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 e935c90aa..a68aba219 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -299,12 +299,18 @@ importers: '@cellix/config-vitest': specifier: workspace:* version: link:../../packages/cellix/config-vitest + '@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) 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 +322,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 +357,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 +409,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 @@ -415,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 @@ -425,6 +437,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) @@ -455,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 @@ -495,6 +513,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))) @@ -576,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 @@ -625,6 +649,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 +886,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': @@ -1601,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': @@ -19760,7 +19830,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 +19844,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 +23732,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 +25555,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 +25574,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/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 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/** 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 },