diff --git a/.gitignore b/.gitignore index 3c750e935..a9dc6a54a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules +.pnpm-store .DS_Store **/coverage # .gitignore template - https://github.com/github/gitignore/blob/main/Node.gitignore diff --git a/.snyk b/.snyk index 78207903f..1e83d06a5 100644 --- a/.snyk +++ b/.snyk @@ -107,16 +107,58 @@ ignore: expires: '2026-12-31T00:00:00.000Z' created: '2026-06-25T00:00:00.000Z' 'SNYK-JS-IMAGESIZE-17295814': + - '* > image-size': + reason: 'Transitive dependency in @docusaurus/mdx-loader and dev build tooling; no upgrade or patch available from upstream. Not exploitable in current usage (build-time image dimension parsing only).' + expires: '2026-09-11T00:00:00.000Z' + created: '2026-06-11T00:00:00.000Z' - '* > image-size@<=2.0.2': reason: 'Transitive dependency in vitest@4.1.6; not exploitable in current usage.' expires: '2026-07-11T00:00:00.000Z' created: '2026-06-11T10:00:00.000Z' 'SNYK-JS-IMAGESIZE-17295816': + - '* > image-size': + reason: 'Transitive dependency in @docusaurus/mdx-loader and dev build tooling; no upgrade or patch available from upstream. Not exploitable in current usage (build-time image dimension parsing only).' + expires: '2026-09-11T00:00:00.000Z' + created: '2026-06-11T00:00:00.000Z' - '* > image-size@<=2.0.2': reason: 'Transitive dependency in vitest@4.1.6; not exploitable in current usage.' expires: '2026-07-11T00:00:00.000Z' created: '2026-06-11T10:00:00.000Z' + 'SNYK-JS-AZUREIDENTITY-7964589': + - '* > @azure/identity@3.4.2': + reason: 'Transitive dependency via azurite>tedious; upgrade to 4.x is a breaking major-version change incompatible with azurite. Not exploitable in dev-only local storage emulation context.' + expires: '2026-09-16T00:00:00.000Z' + created: '2026-06-16T00:00:00.000Z' + 'SNYK-JS-XML2JS-5414874': + - '* > xml2js@0.4.23': + reason: 'Transitive dependency via azurite>@azure/ms-rest-js; 0.4→0.5 is a breaking major change incompatible with @azure/ms-rest-js. Not exploitable in dev-only local storage emulation context.' + expires: '2026-09-16T00:00:00.000Z' + created: '2026-06-16T00:00:00.000Z' + 'SNYK-JS-TOUGHCOOKIE-5672873': + - '* > tough-cookie@2.5.5': + reason: 'Transitive dependency via azurite>@azure/ms-rest-js; 2.x→4.x is a breaking major change. Not exploitable in dev-only local storage emulation context.' + expires: '2026-09-16T00:00:00.000Z' + created: '2026-06-16T00:00:00.000Z' + 'SNYK-JS-OPENTELEMETRYCORE-17000000': + - '* > @opentelemetry/core': + reason: 'OTEL core 2.8.0+ requires sdk-node upgrade which has TypeScript compilation errors. Tracked alongside SNYK-JS-OPENTELEMETRYEXPORTERPROMETHEUS-16758050 for resolution in the planned OTEL 2.x migration.' + expires: '2026-09-16T00:00:00.000Z' + created: '2026-06-16T00:00:00.000Z' + 'SNYK-JS-BABELCORE-17342497': + - '* > @babel/core@7.29.0': + reason: 'No 7.29.x fix release exists; next release is major v8.0.0 which is a breaking change for all transitive consumers. Low severity, dev tooling only.' + expires: '2026-09-16T00:00:00.000Z' + created: '2026-06-16T00:00:00.000Z' + 'SNYK-JS-JOI-17317897': + - '* > joi': + reason: 'No upgrade or patch available per Snyk; overridden to 17.13.4 which fixes the pnpm advisory. Accepting residual Snyk finding until a patched release is published.' + expires: '2026-09-16T00:00:00.000Z' + created: '2026-06-16T00:00:00.000Z' 'SNYK-JS-JSYAML-17342520': + - '* > js-yaml@3.14.2': + reason: 'js-yaml 3.x→4.x is a breaking major change for all transitive consumers (gray-matter/Docusaurus chain). js-yaml@4.1.1 patched to 4.2.0 via pnpm override; 3.x path has no safe upgrade.' + expires: '2026-09-16T00:00:00.000Z' + created: '2026-06-16T00:00:00.000Z' - '* > js-yaml': reason: 'Transitive in Docusaurus build tooling (gray-matter pins js-yaml@3.x; fix is in 4.2.0, a major bump blocked by gray-matter). Build-time parsing of repository-controlled input only.' expires: '2026-09-18T00:00:00.000Z' @@ -126,3 +168,8 @@ ignore: reason: 'The fix requires migrating the Azure Functions telemetry stack from OpenTelemetry 1.x to 2.8.0 or newer. The current Azure exporter and 0.57.x SDK packages require the 1.x API family; a forced major override is type-compatible at build time but leaves mixed SDK internals. Accepted temporarily while the existing OTEL 2.x migration is completed.' expires: '2026-07-18T00:00:00.000Z' created: '2026-06-18T00:00:00.000Z' + 'SNYK-JS-BRACEEXPANSION-17706650': + - '* > brace-expansion@1.1.13': + reason: 'Transitive in Docusaurus serve-handler for local static docs serving. The fixed brace-expansion 5.x line requires minimatch 10, whose named-export API is incompatible with serve-handler 6.1.7.' + expires: '2026-09-30T00:00:00.000Z' + created: '2026-06-30T00:00:00.000Z' diff --git a/apps/api/package.json b/apps/api/package.json index 6467e1ff0..9702bbf82 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -8,10 +8,10 @@ "scripts": { "prebuild": "pnpm run lint", "build": "tsgo --build && tsgo --noEmit --project tsconfig.rolldown.json && 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", - "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", + "predev": "pnpm run prepare:deploy", + "dev": "pnpm exec portless data-access.ownercommunity.localhost --force node start-dev.ts", + "predev:worktree": "pnpm run prepare:deploy", + "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", @@ -24,8 +24,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:", @@ -50,6 +50,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:", "archunit": "catalog:", "azurite": "^3.35.0", diff --git a/apps/api/rolldown.config.ts b/apps/api/rolldown.config.ts index 321246680..ff3c68e5e 100644 --- a/apps/api/rolldown.config.ts +++ b/apps/api/rolldown.config.ts @@ -17,7 +17,6 @@ * By adding this reference directive, we can ensure that the NodeJS types are available for use in this file without causing phantom errors in the rest of the project. */ /// - import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { createCellixAzureFunctionsRolldownConfig } from '@cellix/config-rolldown'; diff --git a/apps/api/scripts/sync-local-settings.mjs b/apps/api/scripts/sync-local-settings.mjs deleted file mode 100644 index 876a5f717..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-end-user'); - values.ACCOUNT_PORTAL_OIDC_ENDPOINT = buildPortlessUrl(hostnames.mockAuth, '/community-end-user/.well-known/jwks.json'); - values.STAFF_PORTAL_OIDC_ISSUER = buildPortlessUrl(hostnames.mockAuth, '/staff-staff-user'); - values.STAFF_PORTAL_OIDC_ENDPOINT = buildPortlessUrl(hostnames.mockAuth, '/staff-staff-user/.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/src/index.ts b/apps/api/src/index.ts index 70051f0a6..f4a297cd4 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,11 +1,9 @@ import './service-config/otel-starter.ts'; -import type { ApplicationServices } from '@ocom/application-services'; -import { buildApplicationServicesFactory } from '@ocom/application-services'; +import { type ApplicationServices, buildApplicationServicesFactory } from '@ocom/application-services'; import type { ApiContextSpec } from '@ocom/context-spec'; import { RegisterEventHandlers } from '@ocom/event-handler'; -import type { GraphContext } from '@ocom/graphql-handler'; -import { graphHandlerCreator } from '@ocom/graphql-handler'; +import { type GraphContext, graphHandlerCreator } from '@ocom/graphql-handler'; import { restHandlerCreator } from '@ocom/rest'; import { ServiceApolloServer } from '@ocom/service-apollo-server'; import { ServiceBlobStorage, ServiceClientBlobStorage } from '@ocom/service-blob-storage'; diff --git a/apps/api/src/service-config/apollo-server/index.ts b/apps/api/src/service-config/apollo-server/index.ts index fa09139af..ea3a0366e 100644 --- a/apps/api/src/service-config/apollo-server/index.ts +++ b/apps/api/src/service-config/apollo-server/index.ts @@ -1,5 +1,5 @@ -import type { ServiceApolloServerOptions } from '@ocom/service-apollo-server'; import { combinedSchema, permissions } from '@ocom/graphql'; +import type { ServiceApolloServerOptions } from '@ocom/service-apollo-server'; const { NODE_ENV } = process.env; const isDev = NODE_ENV !== 'production'; diff --git a/apps/api/start-azurite.mjs b/apps/api/start-azurite.mjs deleted file mode 100644 index 594d9b8d1..000000000 --- a/apps/api/start-azurite.mjs +++ /dev/null @@ -1,48 +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 commonArgs = ['--silent', '--skipApiVersionCheck']; - -const procSpecs = [ - ['azurite-blob', [...commonArgs, '--blobPort', String(ports.blob), '--location', blobDir]], - ['azurite-queue', [...commonArgs, '--queuePort', String(ports.queue), '--location', queueDir]], - ['azurite-table', [...commonArgs, '--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..b45ce9a2c --- /dev/null +++ b/apps/api/start-azurite.ts @@ -0,0 +1,3 @@ +import { AzuriteDevRunner } from '@cellix/local-dev'; + +new AzuriteDevRunner().start(); diff --git a/apps/api/start-dev.mjs b/apps/api/start-dev.mjs deleted file mode 100644 index 7a4e66a50..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-end-user'); - childEnv.ACCOUNT_PORTAL_OIDC_ENDPOINT ??= buildPortlessUrl(hostnames.mockAuth, '/community-end-user/.well-known/jwks.json'); - childEnv.STAFF_PORTAL_OIDC_ISSUER ??= buildPortlessUrl(hostnames.mockAuth, '/staff-staff-user'); - childEnv.STAFF_PORTAL_OIDC_ENDPOINT ??= buildPortlessUrl(hostnames.mockAuth, '/staff-staff-user/.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..b58d1a220 --- /dev/null +++ b/apps/api/start-dev.ts @@ -0,0 +1,6 @@ +import { AzureFunctionsDevRunner } from '@cellix/local-dev'; +import { buildOcomApiLocalSettings } from '@ocom/local-dev-config'; + +new AzureFunctionsDevRunner({ + localSettings: buildOcomApiLocalSettings(), +}).start(); diff --git a/apps/api/sync-local-settings.ts b/apps/api/sync-local-settings.ts new file mode 100644 index 000000000..5cdd1b83b --- /dev/null +++ b/apps/api/sync-local-settings.ts @@ -0,0 +1,4 @@ +import { AzureFunctionsLocalSettings } from '@cellix/local-dev'; +import { buildOcomApiLocalSettings } from '@ocom/local-dev-config'; + +new AzureFunctionsLocalSettings(buildOcomApiLocalSettings()).sync(); 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 53d302c10..5c69625c4 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.10.1", "@docusaurus/tsconfig": "^3.10.1", "@docusaurus/types": "^3.10.1", 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..6b14f748d --- /dev/null +++ b/apps/docs/start-dev.ts @@ -0,0 +1,3 @@ +import { DocusaurusDevRunner } from '@cellix/local-dev'; + +new DocusaurusDevRunner().start(); diff --git a/apps/docs/turbo.json b/apps/docs/turbo.json index 304f1bb57..888ffa539 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", "start-dev.ts", "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 733c492bf..1e3502fef 100644 --- a/apps/server-mongodb-memory-mock/package.json +++ b/apps/server-mongodb-memory-mock/package.json @@ -11,8 +11,8 @@ "format": "biome format --write", "format:check": "biome format .", "start": "node dist/index.js", - "dev": "tsx src/index.ts", - "dev:worktree": "node start-mongo.mjs" + "dev": "node src/index.ts", + "dev:worktree": "node start-dev.ts" }, "dependencies": { "@cellix/server-mongodb-memory-mock-seedwork": "workspace:*", @@ -22,9 +22,9 @@ "mongoose": "catalog:" }, "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..a77256137 --- /dev/null +++ b/apps/server-mongodb-memory-mock/start-dev.ts @@ -0,0 +1,7 @@ +import { NodeDevRunner } from '@cellix/local-dev'; + +new NodeDevRunner({ + settings: { + PORT: '50000', + }, +}).start(); 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..0d810fa79 100644 --- a/apps/server-oauth2-mock/package.json +++ b/apps/server-oauth2-mock/package.json @@ -11,22 +11,23 @@ "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 src/index.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:", "rimraf": "catalog:", - "tsx": "catalog:", "typescript": "catalog:", "vitest": "catalog:" } diff --git a/apps/server-oauth2-mock/src/index.ts b/apps/server-oauth2-mock/src/index.ts index a29ab59c6..e594c99c5 100644 --- a/apps/server-oauth2-mock/src/index.ts +++ b/apps/server-oauth2-mock/src/index.ts @@ -1,6 +1,6 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { createMockOAuth2Manager, type MockOAuth2PortalConfig, normalizeBaseUrl, discoverPortalConfigs, type PortalOidcConfig, ensurePortInUrl, createFileUserStore } from '@cellix/server-oauth2-mock-seedwork'; +import { createFileUserStore, createMockOAuth2Manager, discoverPortalConfigs, ensurePortInUrl, type MockOAuth2PortalConfig, normalizeBaseUrl, type PortalOidcConfig } from '@cellix/server-oauth2-mock-seedwork'; import { setupEnvironment } from './setup-environment.ts'; setupEnvironment(); diff --git a/apps/server-oauth2-mock/start-dev.mjs b/apps/server-oauth2-mock/start-dev.mjs deleted file mode 100644 index 751d91f92..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_END_USER_B2C_REDIRECT_URI = buildPortlessUrl(hostnames.uiCommunity, '/auth-redirect'); - childEnv.VITE_APP_UI_STAFF_STAFF_USER_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..e4511ff7e --- /dev/null +++ b/apps/server-oauth2-mock/start-dev.ts @@ -0,0 +1,14 @@ +/// +import { NodeDevRunner } from '@cellix/local-dev'; +import { buildOcomUrls } from '@ocom/local-dev-config'; + +const urls = buildOcomUrls(); +const mockAuthBaseUrl = new URL(urls.mockCommunityAuthorityUrl).origin; + +new NodeDevRunner({ + settings: { + BASE_URL: mockAuthBaseUrl, + VITE_APP_UI_COMMUNITY_END_USER_B2C_REDIRECT_URI: urls.uiCommunityRedirectUrl, + VITE_APP_UI_STAFF_STAFF_USER_AAD_REDIRECT_URI: urls.uiStaffRedirectUrl, + }, +}).start(); 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/mock-oidc.users.json b/apps/ui-community/mock-oidc.users.json index ade0b4f42..f5518cba9 100644 --- a/apps/ui-community/mock-oidc.users.json +++ b/apps/ui-community/mock-oidc.users.json @@ -3,7 +3,7 @@ "username": "test@example.com", "sub": "00000000-0000-4000-8000-000000000001", "password": "password", - "oidcConfigName": "end-user", + "oidcConfigName": "end-user", "claims": { "email": "test@example.com", "given_name": "Test", @@ -15,7 +15,7 @@ "username": "john.doe@example.com", "sub": "00000000-0000-4000-8000-000000000002", "password": "password", - "oidcConfigName": "end-user", + "oidcConfigName": "end-user", "claims": { "email": "john.doe@example.com", "given_name": "John", @@ -27,7 +27,7 @@ "username": "owner@test.example", "sub": "aaaaaaaa-bbbb-1ccc-9ddd-eeeeeeeeee01", "password": "password", - "oidcConfigName": "end-user", + "oidcConfigName": "end-user", "claims": { "email": "owner@test.example", "given_name": "Test", diff --git a/apps/ui-community/package.json b/apps/ui-community/package.json index 7d3f4dfac..1055de59f 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 vite", + "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": "^5.2.1", "@storybook/addon-a11y": "catalog:", "@storybook/addon-docs": "catalog:", diff --git a/apps/ui-community/start-dev.mjs b/apps/ui-community/start-dev.mjs deleted file mode 100644 index 2dc585de0..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_END_USER_B2C_AUTHORITY = buildPortlessUrl(hostnames.mockAuth, '/community-end-user'); - childEnv.VITE_APP_UI_COMMUNITY_END_USER_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..7cec1c260 --- /dev/null +++ b/apps/ui-community/start-dev.ts @@ -0,0 +1,13 @@ +import { ViteDevRunner } from '@cellix/local-dev'; +import { buildOcomUrls } from '@ocom/local-dev-config'; + +const urls = buildOcomUrls(); + +new ViteDevRunner({ + settings: { + VITE_APP_UI_COMMUNITY_END_USER_B2C_AUTHORITY: urls.mockCommunityAuthorityUrl, + VITE_APP_UI_COMMUNITY_END_USER_B2C_REDIRECT_URI: urls.uiCommunityRedirectUrl, + VITE_COMMON_API_ENDPOINT: urls.apiGraphqlUrl, + VITE_APP_UI_COMMUNITY_BASE_URL: urls.uiCommunityBaseUrl, + }, +}).start(); 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/mock-oidc.users.json b/apps/ui-staff/mock-oidc.users.json index 6b635dfb4..1c753ea7c 100644 --- a/apps/ui-staff/mock-oidc.users.json +++ b/apps/ui-staff/mock-oidc.users.json @@ -3,7 +3,7 @@ "username": "staff@ownercommunity.onmicrosoft.com", "sub": "10000000-0000-4000-8000-000000000001", "password": "password", - "oidcConfigName": "staff-user", + "oidcConfigName": "staff-user", "claims": { "email": "staff@ownercommunity.onmicrosoft.com", "given_name": "Staff", diff --git a/apps/ui-staff/package.json b/apps/ui-staff/package.json index f5c74fc0b..df6341849 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 vite", + "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 07dd7d480..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_STAFF_USER_AAD_AUTHORITY = buildPortlessUrl(hostnames.mockAuth, '/staff-staff-user'); - childEnv.VITE_APP_UI_STAFF_STAFF_USER_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..2c7f008c0 --- /dev/null +++ b/apps/ui-staff/start-dev.ts @@ -0,0 +1,13 @@ +import { ViteDevRunner } from '@cellix/local-dev'; +import { buildOcomUrls } from '@ocom/local-dev-config'; + +const urls = buildOcomUrls(); + +new ViteDevRunner({ + settings: { + VITE_APP_UI_STAFF_STAFF_USER_AAD_AUTHORITY: urls.mockStaffAuthorityUrl, + VITE_APP_UI_STAFF_STAFF_USER_AAD_REDIRECT_URI: urls.uiStaffRedirectUrl, + VITE_APP_UI_STAFF_BASE_URL: urls.uiStaffBaseUrl, + VITE_COMMON_API_ENDPOINT: urls.apiGraphqlUrl, + }, +}).start(); diff --git a/biome.json b/biome.json index 9da5b4793..45ac63cf0 100644 --- a/biome.json +++ b/biome.json @@ -9,7 +9,7 @@ "!**/dist", "!**/build", "!**/deploy", - "!**/target", + "!**/target", "!**/node_modules", "!**/coverage", "!**/.turbo", @@ -167,7 +167,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 c96fff069..99a9f8c1d 100644 --- a/knip.json +++ b/knip.json @@ -2,23 +2,23 @@ "$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", "start-*.ts", "sync-local-settings.ts", "rolldown.config.ts"], + "project": ["src/**/*.ts", "*.ts"], "ignoreDependencies": ["azurite"] }, "apps/ui-community": { - "entry": ["src/main.tsx"], + "entry": ["src/main.tsx", "start-dev.ts"], "project": ["src/**/*.{ts,tsx}"], "ignore": ["**/apollo-client-links.tsx"] }, "apps/ui-staff": { - "entry": ["src/main.tsx"], + "entry": ["src/main.tsx", "start-dev.ts"], "project": ["src/**/*.{ts,tsx}"], "ignore": [] }, "apps/docs": { - "entry": ["src/**/*.{ts,tsx,js,jsx}"], - "project": ["src/**/*.{ts,tsx,js,jsx}"] + "entry": ["src/**/*.{ts,tsx,js,jsx}", "start-dev.ts"], + "project": ["src/**/*.{ts,tsx,js,jsx}", "*.ts"] }, "packages/cellix/*": { "project": ["src/**/*.ts"], @@ -82,7 +82,7 @@ "entry": ["cucumber.js", "src/world.ts", "src/step-definitions/index.ts", "src/shared/abilities/index.ts"], "project": ["src/**/*.ts"], "ignoreBinaries": ["report"], - "ignoreUnresolved": ["progress-bar"] + "ignoreUnresolved": ["progress-bar"] }, "packages/ocom-verification/acceptance-ui": { "entry": ["cucumber.js", "src/world.ts", "src/step-definitions/index.ts"], @@ -93,30 +93,20 @@ "packages/ocom-verification/e2e-tests": { "entry": ["cucumber.js", "src/world.ts", "src/contexts/**/step-definitions/**/*.steps.ts", "src/shared/environment/**/*.ts", "src/shared/abilities/**/*.ts"], "project": ["src/**/*.ts"], - "ignoreUnresolved": ["progress-bar"] + "ignoreUnresolved": ["progress-bar"] }, "apps/server-oauth2-mock": { - "entry": ["src/index.ts"], - "project": ["src/**/*.ts"], - "ignoreDependencies": ["tsx"] + "entry": ["src/index.ts", "start-dev.ts"], + "project": ["src/**/*.ts"] + }, + "apps/server-mongodb-memory-mock": { + "entry": ["src/index.ts", "start-dev.ts"], + "project": ["src/**/*.ts", "*.ts"], + "ignoreDependencies": [] } }, "ignoreWorkspaces": ["packages/cellix/config-typescript"], - "ignore": [ - "build-pipeline/scripts/**", - "scripts/local-dev/**", - "**/fixtures/**", - "**/*.test.ts", - "**/*.spec.ts", - "**/*.stories.tsx", - "**/dist/**", - "**/coverage/**", - "**/__tests__/**", - "**/tests/**", - ".agents/**", - ".github/**", - "portless.config.cjs" - ], + "ignore": ["build-pipeline/scripts/**", "**/fixtures/**", "**/*.test.ts", "**/*.spec.ts", "**/*.stories.tsx", "**/dist/**", "**/coverage/**", "**/__tests__/**", "**/tests/**", ".agents/**", ".github/**", "portless.config.cjs"], "ignoreIssues": { "codegen.yml": ["unlisted"] }, diff --git a/packages/cellix/local-dev/.gitignore b/packages/cellix/local-dev/.gitignore new file mode 100644 index 000000000..c925c21d5 --- /dev/null +++ b/packages/cellix/local-dev/.gitignore @@ -0,0 +1,2 @@ +/dist +/node_modules diff --git a/packages/cellix/local-dev/README.md b/packages/cellix/local-dev/README.md new file mode 100644 index 000000000..06d0d6ebe --- /dev/null +++ b/packages/cellix/local-dev/README.md @@ -0,0 +1,166 @@ +# @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 +- Worktree-aware settings transforms for arbitrary env and JSON values +- Generic dev runners for: + - Vite + - Docusaurus + - Azure Functions + - Node-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, NodeDevRunner } from '@cellix/local-dev'; + +new NodeDevRunner({ + settings: { + BASE_URL: buildPortlessUrl('mock-auth.example.localhost'), + PORT: '50000', + }, +}).start(); +``` + +For settings files, let the app decide the document shape and any app-owned transform: + +```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', + }, + }), +}); +``` + +Azure Functions can sync worktree-aware values directly in the runner so `func start` sees the prepared `local.settings.json` in its script root: + +```js +import { AzureFunctionsDevRunner } from '@cellix/local-dev'; + +new AzureFunctionsDevRunner({ + localSettings: { + e2eValues: { + ACCOUNT_PORTAL_OIDC_ISSUER: 'https://mock-auth.example.localhost:1355/community', + }, + worktreeConversion: { + urlKeys: ['ACCOUNT_PORTAL_OIDC_ISSUER'], + azuriteKeys: ['AzureWebJobsStorage'], + }, + }, +}).start(); +``` + +Code that consumes the same settings outside the Functions host can resolve +them without writing `local.settings.json`: + +```js +import { resolveAzureFunctionsLocalSettingsValues } from '@cellix/local-dev'; + +const values = resolveAzureFunctionsLocalSettingsValues({ + values: { DATABASE_URL: 'mongodb://127.0.0.1:50000/app' }, + worktreeConversion: { mongoKeys: ['DATABASE_URL'] }, +}); +``` + +The resolver applies worktree conversion only when `WORKTREE_NAME` (or an +explicit `worktreeName`) is present. Regular E2E and dev runs keep the base +values. + +Worktree transforms are enabled when a worktree name is available, either through +`worktreeName` or `WORKTREE_NAME`. This lets regular and worktree package scripts +share the same wrapper: the root `dev:worktree` command supplies `WORKTREE_NAME`, +while regular `dev` does not. Advanced programmatic callers can pass +`worktree: false` to ignore an ambient worktree name. + +`WorktreeSettings` transforms complete `http(s)://` and `mongodb://` string +values, including complete URL values nested in objects or arrays. It leaves URLs +embedded in descriptive text unchanged. Use `convertSettingsForWorktree` when +the application needs explicit key-level conversion policy. + +## Public API + +All exports are available from `@cellix/local-dev`. Folder-level subpaths are +also published for consumers that want narrower imports: + +- `@cellix/local-dev/files` +- `@cellix/local-dev/process` +- `@cellix/local-dev/runners` +- `@cellix/local-dev/urls` +- `@cellix/local-dev/vite` +- `@cellix/local-dev/workspace` +- `@cellix/local-dev/worktree` + +- `resolveWorkspaceRoot` +- `readDotEnv` +- `readJsonFile` +- `writeJsonFile` +- `syncJsonFile` +- `hostnameFromUrl` +- `sanitizeWorktreeHostnameLabel` +- `applyWorktreeSuffix` +- `buildPortlessUrl` +- `replaceUrlPort` +- `PORTLESS_PORT` +- `buildViteArgs` +- `isE2E` +- `forwardChildExit` +- `isGracefulInterruptExit` +- `ViteDevRunner` +- `DocusaurusDevRunner` +- `AzureFunctionsDevRunner` +- `NodeDevRunner` +- `AzuriteDevRunner` +- `WorktreeSettings` +- `AzureFunctionsLocalSettings` +- `resolveAzureFunctionsLocalSettingsValues` +- `WorktreeMode` +- `convertSettingsForWorktree` +- `WorktreeConversionPlan` +- `getWorktreePortOffset` +- `getMongoPort` +- `getAzuritePorts` +- `buildAzuriteConnectionString` +- `runViteDev` +- `runDocusaurusDev` +- `runAzureFunctionsDev` +- `runNodeDev` +- `runAzuriteDev` +- `runTsxDev` deprecated compatibility alias + +## Notes + +- The package derives workspace roots from the caller's current working directory, but it does not infer app layouts or env-variable names. +- Worktree names are sanitized before they are inserted into `.localhost` hostnames so branch-style names such as `jason/my-feature` become DNS-safe labels such as `jason-my-feature`. Suffixing is idempotent for hostnames that already contain the sanitized worktree label. +- 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..5e75d69ea --- /dev/null +++ b/packages/cellix/local-dev/manifest.md @@ -0,0 +1,81 @@ +# 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, worktree settings transforms, and generic dev-runner orchestration for Vite, Docusaurus, Azure Functions, Node-backed 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 + +Published entrypoints: + +- `@cellix/local-dev` +- `@cellix/local-dev/files` +- `@cellix/local-dev/process` +- `@cellix/local-dev/runners` +- `@cellix/local-dev/urls` +- `@cellix/local-dev/vite` +- `@cellix/local-dev/workspace` +- `@cellix/local-dev/worktree` + +Root entrypoint exports: + +- `resolveWorkspaceRoot(options?)` +- `readDotEnv(filePath)` +- `readJsonFile(filePath)`, `writeJsonFile(filePath, data)`, `syncJsonFile(options)` +- `hostnameFromUrl(url)`, `sanitizeWorktreeHostnameLabel(worktreeName)`, `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)` +- `ViteDevRunner`, `DocusaurusDevRunner`, `AzureFunctionsDevRunner`, `NodeDevRunner`, `AzuriteDevRunner` +- `WorktreeSettings`, `AzureFunctionsLocalSettings`, `resolveAzureFunctionsLocalSettingsValues`, `convertSettingsForWorktree`, `WorktreeConversionPlan`, `WorktreeMode` +- `runViteDev(options?)`, `runDocusaurusDev(options?)`, `runAzureFunctionsDev(options?)`, `runNodeDev(options?)`, `runAzuriteDev(options)` +- `runTsxDev(options?)` remains as a deprecated compatibility alias for `runNodeDev(options?)` + +## Core concepts + +- App packages own local-development policy such as env-variable names, URL mappings, auth-provider routes, and which settings values are passed into the worktree transformer. +- 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, hostname suffixing, URL-like env values, and JSON settings aligned across all participating apps. +- Worktree transforms are auto-detected from `WORKTREE_NAME`; callers can still override that decision programmatically with `worktree` when needed. Generic settings transforms apply only to complete URL-valued strings, while explicit key-level policy belongs in `convertSettingsForWorktree`. +- Azure Functions dev runners may prepare `local.settings.json` before startup because the Functions host reads settings from its script root rather than the process environment alone. +- Consumers that need the same Azure Functions values outside `func start` can resolve them without writing a file; regular mode remains unscoped unless a worktree name is explicitly available. +- Hostname suffixing must sanitize raw worktree names before inserting them into `.localhost` domains, and repeated suffixing with the same worktree label must leave hostnames unchanged. + +## Package boundaries + +- Do not encode OCOM app names, auth paths, 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, runner object spawning, Vite arg building, Azurite connection-string building, worktree settings transforms, 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..b16991873 --- /dev/null +++ b/packages/cellix/local-dev/package.json @@ -0,0 +1,62 @@ +{ + "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" + }, + "./files": { + "types": "./dist/files/index.d.ts", + "default": "./dist/files/index.js" + }, + "./process": { + "types": "./dist/process/index.d.ts", + "default": "./dist/process/index.js" + }, + "./runners": { + "types": "./dist/runners/index.d.ts", + "default": "./dist/runners/index.js" + }, + "./urls": { + "types": "./dist/urls/index.d.ts", + "default": "./dist/urls/index.js" + }, + "./workspace": { + "types": "./dist/workspace/index.d.ts", + "default": "./dist/workspace/index.js" + }, + "./worktree": { + "types": "./dist/worktree/index.d.ts", + "default": "./dist/worktree/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/env/index.ts b/packages/cellix/local-dev/src/env/index.ts new file mode 100644 index 000000000..b87ec3b5b --- /dev/null +++ b/packages/cellix/local-dev/src/env/index.ts @@ -0,0 +1,17 @@ +type E2EEnv = NodeJS.ProcessEnv & { + E2E?: string; +}; + +/** + * Returns true when the current process is running in an e2e-oriented mode. + * + * Shared so Vite arg building, Azure Functions local-settings sync, and + * consumer-defined settings builders agree on what counts as "e2e" instead of + * each re-implementing the same truthy-string check. + * + * @param env - Environment to inspect. Defaults to `process.env`. + * @returns Whether `E2E` is enabled with a truthy local-dev value. + */ +export function isE2E(env: NodeJS.ProcessEnv = process.env): boolean { + return ['1', 'true', 'yes'].includes(((env as E2EEnv).E2E ?? '').toLowerCase()); +} diff --git a/packages/cellix/local-dev/src/files/dotenv.ts b/packages/cellix/local-dev/src/files/dotenv.ts new file mode 100644 index 000000000..5f8e322ef --- /dev/null +++ b/packages/cellix/local-dev/src/files/dotenv.ts @@ -0,0 +1,41 @@ +import { existsSync, readFileSync } from 'node:fs'; + +export type DotEnvValues = Record; + +/** + * Reads a dotenv-style file into a plain key/value object. + * + * This helper intentionally supports only the portable subset needed by local + * wrapper scripts: `KEY=value` lines, optional `export` prefixes, comments, + * empty lines, and matching single or double quotes around values. It does not + * expand variables or execute shell syntax. + * + * @param filePath - Dotenv file to read. + * @returns Parsed values, or an empty object when the file does not exist. + */ +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; + } + + const key = trimmed.slice(0, separatorIndex).replace(/^export\s+/, ''); + const rawValue = trimmed.slice(separatorIndex + 1); + const quote = rawValue.at(0); + const value = (quote === '"' || quote === "'") && rawValue.endsWith(quote) ? rawValue.slice(1, -1) : rawValue; + values[key] = value; + } + + return values; +} diff --git a/packages/cellix/local-dev/src/files/index.ts b/packages/cellix/local-dev/src/files/index.ts new file mode 100644 index 000000000..0eca37078 --- /dev/null +++ b/packages/cellix/local-dev/src/files/index.ts @@ -0,0 +1,2 @@ +export { type DotEnvValues, readDotEnv } from './dotenv.ts'; +export { readJsonFile, type SyncJsonFileOptions, syncJsonFile } from './json.ts'; diff --git a/packages/cellix/local-dev/src/files/json.ts b/packages/cellix/local-dev/src/files/json.ts new file mode 100644 index 000000000..12f7a3156 --- /dev/null +++ b/packages/cellix/local-dev/src/files/json.ts @@ -0,0 +1,54 @@ +import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; + +export interface SyncJsonFileOptions { + /** Source JSON document to copy or transform. */ + sourcePath: string; + /** Destination path. Parent directories are created automatically. */ + targetPath: string; + /** Optional consumer-owned transform applied before writing the target. */ + transform?: (data: TData) => TData; +} + +/** + * Reads a JSON file and returns its parsed contents. + * + * @param filePath - JSON file to parse. + * @returns Parsed JSON typed by the caller. + */ +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. + * + * @param filePath - Destination path. Parent directories are created + * automatically. + * @param data - JSON-serializable document to write. + */ +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. + * + * @param options - Source, target, and optional transform owned by the caller. + */ +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/index.test.ts b/packages/cellix/local-dev/src/index.test.ts new file mode 100644 index 000000000..48d60d4b9 --- /dev/null +++ b/packages/cellix/local-dev/src/index.test.ts @@ -0,0 +1,520 @@ +import { EventEmitter } from 'node:events'; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { + AzureFunctionsDevRunner, + AzuriteDevRunner, + applyWorktreeSuffix, + buildAzuriteConnectionString, + buildPortlessUrl, + convertSettingsForWorktree, + getAzuritePorts, + getMongoPort, + getWorktreePortOffset, + hostnameFromUrl, + NodeDevRunner, + PORTLESS_PORT, + type RunnerSpawn, + readDotEnv, + replaceUrlPort, + resolveAzureFunctionsLocalSettingsValues, + resolveWorkspaceRoot, + sanitizeWorktreeHostnameLabel, + syncJsonFile, + ViteDevRunner, + WorktreeSettings, +} from '@cellix/local-dev'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { buildViteArgs } from './vite/index.ts'; + +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; +} + +function createMockChildProcess(): ReturnType { + const proc = new EventEmitter() as ReturnType; + proc.kill = vi.fn(() => true); + return proc; +} + +function expectNoDirectProcessExit(): ReturnType { + return vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit should not be called by embeddable runners'); + }) as never); +} + +describe('@cellix/local-dev', () => { + afterEach(() => { + vi.restoreAllMocks(); + process.exitCode = undefined; + }); + + 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, ['export BASE_URL="https://ownercommunity.localhost:1355"', "API_URL='https://data-access.ownercommunity.localhost:1355/api/graphql'", 'IGNORED_LINE_WITHOUT_SEPARATOR'].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(sanitizeWorktreeHostnameLabel('Jason/Feature 123')).toBe('jason-feature-123'); + expect(sanitizeWorktreeHostnameLabel('---')).toBeUndefined(); + expect(applyWorktreeSuffix('ownercommunity.localhost', 'Jason/Feature 123')).toBe('ownercommunity.jason-feature-123.localhost'); + expect(applyWorktreeSuffix('ownercommunity.jason-feature-123.localhost', 'Jason/Feature 123')).toBe('ownercommunity.jason-feature-123.localhost'); + expect(applyWorktreeSuffix('localhost', 'Jason/Feature 123')).toBe('jason-feature-123.localhost'); + expect(applyWorktreeSuffix('example.com', 'feature-123')).toBe('example.com'); + 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', + }, + }); + }); + + it('starts runner objects with inherited stdio and caller-provided environment', () => { + const calls: Array<{ command: string; args: string[]; env?: NodeJS.ProcessEnv }> = []; + const spawn: RunnerSpawn = (command, args, options) => { + calls.push({ + command, + args, + ...(options.env ? { env: options.env } : {}), + }); + return new EventEmitter() as ReturnType; + }; + + new ViteDevRunner({ + env: { PORT: '4444', HOST: '127.0.0.1' }, + spawn, + }).start(); + new NodeDevRunner({ + env: { PORT: '50000' }, + spawn, + }).start(); + + expect(calls).toEqual([ + { + command: 'vite', + args: ['--host', '127.0.0.1', '--port', '4444'], + env: { PORT: '4444', HOST: '127.0.0.1' }, + }, + { + command: 'node', + args: ['src/index.ts'], + env: { PORT: '50000' }, + }, + ]); + }); + + it('applies worktree transforms to arbitrary env settings', () => { + const env = new WorktreeSettings({ + env: { WORKTREE_NAME: 'Jason/Feature 123' }, + settings: { + BASE_URL: 'https://mock-auth.ownercommunity.localhost:1355', + API_URL: 'https://data-access.ownercommunity.localhost:1355/api/graphql', + COSMOSDB_CONNECTION_STRING: 'mongodb://127.0.0.1:50000/ocom', + PORT: '50000', + }, + }).toEnv(); + + expect(env).toMatchObject({ + BASE_URL: 'https://mock-auth.ownercommunity.jason-feature-123.localhost:1355', + API_URL: 'https://data-access.ownercommunity.jason-feature-123.localhost:1355/api/graphql', + COSMOSDB_CONNECTION_STRING: expect.not.stringMatching(':50000/ocom'), + PORT: String(getMongoPort('Jason/Feature 123')), + }); + }); + + it('allows programmatic callers to disable worktree transforms explicitly', () => { + const env = new WorktreeSettings({ + env: { WORKTREE_NAME: 'Jason/Feature 123' }, + worktree: false, + settings: { + BASE_URL: 'https://mock-auth.ownercommunity.localhost:1355', + PORT: '50000', + }, + }).toEnv(); + + expect(env).toMatchObject({ + BASE_URL: 'https://mock-auth.ownercommunity.localhost:1355', + PORT: '50000', + }); + }); + + it('starts worktree-aware Vite and Node runners with transformed settings', () => { + const calls: Array<{ command: string; args: string[]; env?: NodeJS.ProcessEnv }> = []; + const spawn: RunnerSpawn = (command, args, options) => { + calls.push({ + command, + args, + ...(options.env ? { env: options.env } : {}), + }); + return new EventEmitter() as ReturnType; + }; + + new ViteDevRunner({ + env: { WORKTREE_NAME: 'Jason/Feature 123' }, + settings: { + VITE_COMMON_API_ENDPOINT: 'https://data-access.ownercommunity.localhost:1355/api/graphql', + }, + spawn, + }).start(); + new NodeDevRunner({ + env: { WORKTREE_NAME: 'Jason/Feature 123' }, + settings: { + PORT: '50000', + }, + spawn, + }).start(); + + expect(calls[0]).toMatchObject({ + command: 'vite', + env: { + VITE_COMMON_API_ENDPOINT: 'https://data-access.ownercommunity.jason-feature-123.localhost:1355/api/graphql', + }, + }); + expect(calls[1]).toMatchObject({ + command: 'node', + args: ['src/index.ts'], + env: { + PORT: String(getMongoPort('Jason/Feature 123')), + }, + }); + }); + + it('derives worktree-aware Azurite options generically', () => { + const workspaceRoot = createWorkspaceFixture(); + const options = new AzuriteDevRunner({ + env: { WORKTREE_NAME: 'Jason/Feature 123' }, + workspaceRoot, + }).resolveOptions(); + + expect(options).toMatchObject({ + blobPort: getAzuritePorts('Jason/Feature 123').blob, + queuePort: getAzuritePorts('Jason/Feature 123').queue, + tablePort: getAzuritePorts('Jason/Feature 123').table, + blobLocation: path.join(workspaceRoot, '__blobstorage__-jason-feature-123'), + }); + }); + + it('starts Azurite with API version checks disabled for newer Azure SDK clients', () => { + const calls: Array<{ command: string; args: string[] }> = []; + const spawn: RunnerSpawn = (command, args) => { + calls.push({ command, args }); + return createMockChildProcess(); + }; + + new AzuriteDevRunner({ spawn }).start(); + + expect(calls).toEqual([ + expect.objectContaining({ + command: 'azurite-blob', + args: expect.arrayContaining(['--skipApiVersionCheck']), + }), + expect.objectContaining({ + command: 'azurite-queue', + args: expect.arrayContaining(['--skipApiVersionCheck']), + }), + expect.objectContaining({ + command: 'azurite-table', + args: expect.arrayContaining(['--skipApiVersionCheck']), + }), + ]); + }); + + it('sets exitCode instead of terminating the parent process when Azurite spawn fails', () => { + const children: ReturnType[] = []; + const spawn: RunnerSpawn = () => { + const child = createMockChildProcess(); + children.push(child); + return child; + }; + const exitSpy = expectNoDirectProcessExit(); + const listenerCountBeforeStart = process.listenerCount('SIGINT'); + + new AzuriteDevRunner({ spawn }).start(); + children[1]?.emit('error', new Error('missing binary')); + + expect(exitSpy).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(process.listenerCount('SIGINT')).toBe(listenerCountBeforeStart); + expect(children.map((child) => child.kill)).toEqual([expect.any(Function), expect.any(Function), expect.any(Function)]); + expect(children.every((child) => vi.mocked(child.kill).mock.calls.length === 1)).toBe(true); + }); + + it('sets exitCode instead of terminating the parent process when Azurite exits unexpectedly', () => { + const children: ReturnType[] = []; + const spawn: RunnerSpawn = () => { + const child = createMockChildProcess(); + children.push(child); + return child; + }; + const exitSpy = expectNoDirectProcessExit(); + const listenerCountBeforeStart = process.listenerCount('SIGTERM'); + + new AzuriteDevRunner({ spawn }).start(); + children[0]?.emit('exit', 17, null); + + expect(exitSpy).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(17); + expect(process.listenerCount('SIGTERM')).toBe(listenerCountBeforeStart); + expect(children.every((child) => vi.mocked(child.kill).mock.calls.length === 1)).toBe(true); + }); + + it('sets a successful exitCode after all Azurite processes stop gracefully', () => { + const children: ReturnType[] = []; + const spawn: RunnerSpawn = () => { + const child = createMockChildProcess(); + children.push(child); + return child; + }; + const exitSpy = expectNoDirectProcessExit(); + + new AzuriteDevRunner({ spawn }).start(); + for (const child of children) { + child.emit('exit', 130, 'SIGINT'); + } + + expect(exitSpy).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(0); + }); + + it('preserves an existing non-zero exitCode when Azurite later reports another failure', () => { + const children: ReturnType[] = []; + const spawn: RunnerSpawn = () => { + const child = createMockChildProcess(); + children.push(child); + return child; + }; + process.exitCode = 9; + + new AzuriteDevRunner({ spawn }).start(); + children[2]?.emit('exit', 17, null); + + expect(process.exitCode).toBe(9); + }); + + it('converts only the named settings for a worktree, leaving the rest untouched', () => { + const azuriteConnectionString = + 'DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=key;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;QueueEndpoint=http://127.0.0.1:10001/devstoreaccount1;TableEndpoint=http://127.0.0.1:10002/devstoreaccount1'; + const converted = convertSettingsForWorktree( + { + ACCOUNT_PORTAL_OIDC_ISSUER: 'https://mock-auth.ownercommunity.localhost:1355/community', + COSMOSDB_CONNECTION_STRING: 'mongodb://127.0.0.1:50000/ocom', + AZURE_STORAGE_CONNECTION_STRING: azuriteConnectionString, + AzureWebJobsStorage: azuriteConnectionString, + COSMOSDB_DBNAME: 'ocom', + }, + 'Jason/Feature 123', + { + urlKeys: ['ACCOUNT_PORTAL_OIDC_ISSUER'], + mongoKeys: ['COSMOSDB_CONNECTION_STRING'], + azuriteKeys: ['AZURE_STORAGE_CONNECTION_STRING', 'AzureWebJobsStorage'], + }, + ); + const typedConverted = converted as typeof converted & { + COSMOSDB_CONNECTION_STRING: string; + }; + const ports = getAzuritePorts('Jason/Feature 123'); + + expect(converted).toMatchObject({ + ACCOUNT_PORTAL_OIDC_ISSUER: 'https://mock-auth.ownercommunity.jason-feature-123.localhost:1355/community', + AZURE_STORAGE_CONNECTION_STRING: expect.stringContaining(`BlobEndpoint=http://127.0.0.1:${ports.blob}/devstoreaccount1`), + AzureWebJobsStorage: expect.stringContaining(`QueueEndpoint=http://127.0.0.1:${ports.queue}/devstoreaccount1`), + // Unlisted keys are passed through untouched. + COSMOSDB_DBNAME: 'ocom', + }); + expect(typedConverted.COSMOSDB_CONNECTION_STRING).toBe(`mongodb://127.0.0.1:${getMongoPort('Jason/Feature 123')}/ocom`); + }); + + it('resolves Azure Functions settings with worktree conversion only when a worktree is active', () => { + const options = { + values: { + COSMOSDB_CONNECTION_STRING: 'mongodb://127.0.0.1:50000/ocom', + UNCHANGED: 'value', + }, + worktreeConversion: { + mongoKeys: ['COSMOSDB_CONNECTION_STRING'], + }, + }; + + const regular = resolveAzureFunctionsLocalSettingsValues({ ...options, env: {} }); + const worktree = resolveAzureFunctionsLocalSettingsValues({ + ...options, + env: { WORKTREE_NAME: 'feature-a' }, + }); + + expect(regular).toEqual({ + COSMOSDB_CONNECTION_STRING: 'mongodb://127.0.0.1:50000/ocom', + UNCHANGED: 'value', + }); + expect(worktree).toEqual({ + COSMOSDB_CONNECTION_STRING: `mongodb://127.0.0.1:${getMongoPort('feature-a')}/ocom`, + UNCHANGED: 'value', + }); + }); + + it('generates Azure Functions local settings before starting func', () => { + const workspaceRoot = createWorkspaceFixture(); + const appDir = path.join(workspaceRoot, 'fixtures', 'api'); + const calls: Array<{ command: string; args: string[]; env?: NodeJS.ProcessEnv }> = []; + const spawn: RunnerSpawn = (command, args, options) => { + calls.push({ + command, + args, + ...(options.env ? { env: options.env } : {}), + }); + return new EventEmitter() as ReturnType; + }; + + new AzureFunctionsDevRunner({ + env: { E2E: 'true', PORT: '7071', WORKTREE_NAME: 'Jason/Feature 123' }, + localSettings: { + appDir, + values: { + COSMOSDB_CONNECTION_STRING: 'mongodb://127.0.0.1:50000/ocom', + AzureWebJobsStorage: + 'DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=key;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;QueueEndpoint=http://127.0.0.1:10001/devstoreaccount1;TableEndpoint=http://127.0.0.1:10002/devstoreaccount1', + ACCOUNT_PORTAL_OIDC_ISSUER: 'https://mock-auth.ownercommunity.localhost:1355/community', + languageWorkers__node__arguments: '--inspect=5858', + }, + e2eValues: { + languageWorkers__node__arguments: '', + }, + worktreeConversion: { + urlKeys: ['ACCOUNT_PORTAL_OIDC_ISSUER'], + mongoKeys: ['COSMOSDB_CONNECTION_STRING'], + azuriteKeys: ['AzureWebJobsStorage'], + }, + }, + spawn, + }).start(); + + const settings = JSON.parse(readFileSync(path.join(appDir, 'deploy', 'local.settings.json'), 'utf8')); + expect(settings.Values).toMatchObject({ + ACCOUNT_PORTAL_OIDC_ISSUER: 'https://mock-auth.ownercommunity.jason-feature-123.localhost:1355/community', + AzureWebJobsStorage: expect.stringContaining('BlobEndpoint=http://127.0.0.1:'), + // E2E override applied over the base value. + languageWorkers__node__arguments: '', + }); + expect(settings.Values.COSMOSDB_CONNECTION_STRING).not.toBe('mongodb://127.0.0.1:50000/ocom'); + expect(calls[0]).toMatchObject({ + command: 'func', + args: ['start', '--typescript', '--script-root', 'deploy/', '--port', '7071', '--cors', '*'], + }); + }); + + it('skips worktree conversion outside a worktree', () => { + const workspaceRoot = createWorkspaceFixture(); + const appDir = path.join(workspaceRoot, 'fixtures', 'api'); + const spawn: RunnerSpawn = () => new EventEmitter() as ReturnType; + + new AzureFunctionsDevRunner({ + env: { PORT: '7071' }, + localSettings: { + appDir, + values: { + COSMOSDB_CONNECTION_STRING: 'mongodb://127.0.0.1:50000/ocom', + AzureWebJobsStorage: 'UseDevelopmentStorage=true', + }, + worktreeConversion: { + mongoKeys: ['COSMOSDB_CONNECTION_STRING'], + azuriteKeys: ['AzureWebJobsStorage'], + }, + }, + spawn, + }).start(); + + const settings = JSON.parse(readFileSync(path.join(appDir, 'deploy', 'local.settings.json'), 'utf8')); + expect(settings.Values).toMatchObject({ + COSMOSDB_CONNECTION_STRING: 'mongodb://127.0.0.1:50000/ocom', + AzureWebJobsStorage: 'UseDevelopmentStorage=true', + }); + }); +}); diff --git a/packages/cellix/local-dev/src/index.ts b/packages/cellix/local-dev/src/index.ts new file mode 100644 index 000000000..7dbfeb397 --- /dev/null +++ b/packages/cellix/local-dev/src/index.ts @@ -0,0 +1,46 @@ +export { isE2E } from './env/index.ts'; +export { type DotEnvValues, readDotEnv } from './files/dotenv.ts'; +export { readJsonFile, type SyncJsonFileOptions, syncJsonFile } from './files/json.ts'; +export { forwardChildExit, isGracefulInterruptExit, setProcessExitCode } from './process/index.ts'; +export { + type AzureFunctionsDevOptions, + AzureFunctionsDevRunner, + AzureFunctionsLocalSettings, + type AzureFunctionsLocalSettingsOptions, + type AzuriteDevOptions, + AzuriteDevRunner, + DocusaurusDevRunner, + type EnvRunnerOptions, + type NodeDevOptions, + NodeDevRunner, + type ResolvedAzuriteOptions, + type RunnerOptions, + type RunnerSpawn, + resolveAzureFunctionsLocalSettingsValues, + type ViteDevOptions, + ViteDevRunner, +} from './runners/index.ts'; +export { + applyWorktreeSuffix, + buildPortlessUrl, + hostnameFromUrl, + PORTLESS_PORT, + replaceUrlPort, + sanitizeWorktreeHostnameLabel, +} from './urls/index.ts'; +export { buildViteArgs } from './vite/index.ts'; +export { type ResolveWorkspaceRootOptions, resolveWorkspaceRoot } from './workspace/index.ts'; +export { + convertSettingsForWorktree, + type WorktreeConversionPlan, + type WorktreeMode, + WorktreeSettings, + type WorktreeSettingsOptions, +} from './worktree/index.ts'; +export { + type AzuritePorts, + buildAzuriteConnectionString, + getAzuritePorts, + getMongoPort, + getWorktreePortOffset, +} from './worktree/ports.ts'; diff --git a/packages/cellix/local-dev/src/process/index.ts b/packages/cellix/local-dev/src/process/index.ts new file mode 100644 index 000000000..536a28cf9 --- /dev/null +++ b/packages/cellix/local-dev/src/process/index.ts @@ -0,0 +1,44 @@ +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; +} + +/** + * Sets `process.exitCode` without clobbering a previously recorded failure. + * + * A wrapper script can own more than one child process (for example a runner + * that also forwards an Azurite or Mongo helper's exit). Once any of them has + * recorded a non-zero code, later successful or graceful exits must not erase + * it, or the original failure goes unreported. + */ +export function setProcessExitCode(code: number): void { + if (process.exitCode === undefined || process.exitCode === 0) { + process.exitCode = code; + } +} + +/** + * Forwards a spawned child process exit code back to the parent process while + * treating common interrupt exits as a successful shutdown. + * + * This helper sets `process.exitCode` instead of calling `process.exit()`, so a + * wrapper script can finish naturally and embedders do not lose control of the + * current Node process. It preserves an already-recorded non-zero exit code + * rather than overwriting it, so a later, unrelated child exiting cleanly does + * not mask an earlier failure. + */ +export function forwardChildExit(child: ChildProcess): void { + child.on('exit', (code, signal) => { + if (isGracefulInterruptExit(signal, code)) { + setProcessExitCode(0); + return; + } + + setProcessExitCode(code ?? 1); + }); +} diff --git a/packages/cellix/local-dev/src/runners/azure-functions-local-settings.ts b/packages/cellix/local-dev/src/runners/azure-functions-local-settings.ts new file mode 100644 index 000000000..80ddc6fcf --- /dev/null +++ b/packages/cellix/local-dev/src/runners/azure-functions-local-settings.ts @@ -0,0 +1,115 @@ +import path from 'node:path'; +import { isE2E } from '../env/index.ts'; +import { writeJsonFile } from '../files/json.ts'; +import { convertSettingsForWorktree, type WorktreeConversionPlan } from '../worktree/conversion.ts'; +import type { SettingsRecord, WorktreeMode } from '../worktree/types.ts'; +import { resolveWorktreeName } from '../worktree/worktree-name.ts'; + +function resolveTargetPath(options: AzureFunctionsLocalSettingsOptions): string { + const { appDir = process.cwd(), scriptRoot = 'deploy/' } = options; + return options.targetPath ?? path.join(appDir, scriptRoot, 'local.settings.json'); +} + +function resolveSettingsWorktreeName(options: AzureFunctionsLocalSettingsOptions, env: NodeJS.ProcessEnv): string | undefined { + return resolveWorktreeName({ + env, + ...(typeof options.worktree === 'boolean' ? { worktree: options.worktree } : {}), + ...(options.worktreeName ? { worktreeName: options.worktreeName } : {}), + }); +} + +function buildModeValues(options: AzureFunctionsLocalSettingsOptions, env: NodeJS.ProcessEnv): SettingsRecord { + const values: SettingsRecord = { ...options.values }; + if (isE2E(env) && options.e2eValues) { + Object.assign(values, options.e2eValues); + } + return values; +} + +function applyWorktreeConversion(values: SettingsRecord, worktreeName: string | undefined, plan: WorktreeConversionPlan | undefined): SettingsRecord { + return worktreeName && plan ? convertSettingsForWorktree(values, worktreeName, plan) : values; +} + +export interface AzureFunctionsLocalSettingsOptions { + /** App directory used to resolve the default target path. Defaults to `process.cwd()`. */ + appDir?: string; + /** Functions script root. Defaults to `deploy/`. */ + scriptRoot?: string; + /** Base environment. Defaults to `process.env`. */ + env?: NodeJS.ProcessEnv; + /** Whether to apply worktree transforms. Defaults to auto-detection from `WORKTREE_NAME`. */ + worktree?: WorktreeMode; + /** Worktree name. Defaults to `env.WORKTREE_NAME`. */ + worktreeName?: string; + /** Target settings file. Defaults to `//local.settings.json`. */ + targetPath?: string; + /** Functions `Values`, written in every mode. */ + values?: SettingsRecord; + /** `Values` overrides merged only when `E2E` is truthy. */ + e2eValues?: SettingsRecord; + /** Functions `Host` block (e.g. `{ LocalHttpPort, CORS }`). */ + host?: SettingsRecord; + /** Which `Values` keys get worktree-scoped, and how. Applied only in a worktree. */ + worktreeConversion?: WorktreeConversionPlan; +} + +/** + * Resolves the mode-appropriate `Values` for the API's local settings, + * worktree-scoped the same way {@link AzureFunctionsLocalSettings.sync} scopes + * them before writing `local.settings.json`. + * + * Centralizes the worktree-conversion mechanics here so callers that need + * these values for something other than the Functions host's own + * `local.settings.json` (e.g. an E2E test process reading the API's mock + * service endpoints directly) get the same worktree-scoped values without + * reimplementing the conversion themselves. + * + * @param options - Same options accepted by {@link AzureFunctionsLocalSettings}. + * @returns The resolved values, converted only when a worktree is active. + * + * @example + * ```ts + * const values = resolveAzureFunctionsLocalSettingsValues({ + * env: { WORKTREE_NAME: 'feature-a' }, + * values: { DATABASE_URL: 'mongodb://127.0.0.1:50000/app' }, + * worktreeConversion: { mongoKeys: ['DATABASE_URL'] }, + * }); + * ``` + */ +export function resolveAzureFunctionsLocalSettingsValues(options: AzureFunctionsLocalSettingsOptions = {}): SettingsRecord { + const env = options.env ?? process.env; + const worktreeName = resolveSettingsWorktreeName(options, env); + const values = buildModeValues(options, env); + + return applyWorktreeConversion(values, worktreeName, options.worktreeConversion); +} + +/** + * Generates Azure Functions `local.settings.json` before `func start`. + * + * The flow is a flat three steps: load the mode's settings, convert the + * caller-named keys for the active worktree (a no-op outside a worktree), and + * write the document the Functions host reads from the script root. + */ +export class AzureFunctionsLocalSettings { + private readonly options: AzureFunctionsLocalSettingsOptions; + + public constructor(options: AzureFunctionsLocalSettingsOptions = {}) { + this.options = options; + } + + /** + * Writes the worktree-scoped settings into the Functions script root. + */ + public sync(): void { + const targetPath = resolveTargetPath(this.options); + const values = resolveAzureFunctionsLocalSettingsValues(this.options); + + writeJsonFile(targetPath, { + IsEncrypted: false, + Values: values, + ConnectionStrings: {}, + ...(this.options.host ? { Host: this.options.host } : {}), + }); + } +} diff --git a/packages/cellix/local-dev/src/runners/azure-functions.ts b/packages/cellix/local-dev/src/runners/azure-functions.ts new file mode 100644 index 000000000..4603948dc --- /dev/null +++ b/packages/cellix/local-dev/src/runners/azure-functions.ts @@ -0,0 +1,111 @@ +import type { ChildProcess } from 'node:child_process'; +import os from 'node:os'; +import path from 'node:path'; +import { forwardChildExit } from '../process/index.ts'; +import { WorktreeSettings } from '../worktree/settings.ts'; +import type { WorktreeMode } from '../worktree/types.ts'; +import { resolveWorktreeName } from '../worktree/worktree-name.ts'; +import { AzureFunctionsLocalSettings, type AzureFunctionsLocalSettingsOptions } from './azure-functions-local-settings.ts'; +import { spawnInherited } from './spawn.ts'; +import type { EnvRunnerOptions } from './types.ts'; + +type RunnerEnv = NodeJS.ProcessEnv & { + NODE_OPTIONS?: string; + PORT?: string; + PORTLESS_CA_PATH?: string; +}; + +export interface AzureFunctionsDevOptions extends EnvRunnerOptions { + /** + * Whether to apply worktree transforms. Defaults to auto-detection from + * `WORKTREE_NAME`. + */ + worktree?: WorktreeMode; + /** Worktree name. Defaults to `env.WORKTREE_NAME`. */ + worktreeName?: string; + /** Functions host port. Defaults to `env.PORT`; required after resolution. */ + port?: string; + /** Azure Functions script root. Defaults to `deploy/`. */ + scriptRoot?: string; + /** CORS value passed to the Functions host. Defaults to `*`. */ + cors?: string; + /** Whether to pass `--typescript`. Defaults to `true`. */ + typescript?: boolean; + /** Local settings to sync into the script root before starting Functions. */ + localSettings?: AzureFunctionsLocalSettingsOptions; +} + +/** + * Resolves the worktree decision once so the env transform and local-settings + * sync agree without each layer re-reading the environment. + * + * @param options - Worktree mode and name (falling back to the environment). + * @returns `{ worktree: false }` when transforms are disabled, otherwise + * `{ worktree: true, worktreeName }`. + */ +function resolveWorktreeContext(options: { env?: NodeJS.ProcessEnv; worktree?: WorktreeMode; worktreeName?: string }): { worktree: boolean; worktreeName?: string } { + const worktreeName = resolveWorktreeName(options); + return worktreeName === undefined ? { worktree: false } : { worktree: true, worktreeName }; +} + +/** + * Prepares Azure Functions local settings and starts the host with a + * worktree-aware environment. + * + * The runner injects local Portless certificate settings so Functions can call + * other local HTTPS services during development. + */ +export class AzureFunctionsDevRunner { + private readonly options: AzureFunctionsDevOptions; + + public constructor(options: AzureFunctionsDevOptions = {}) { + this.options = options; + } + + /** + * Optionally syncs Functions local settings, then spawns `func start` with + * inherited stdio. + * + * @returns The spawned Functions host process. + * @throws When no port is supplied through `options.port` or `env.PORT`. + */ + public start(): ChildProcess { + const worktreeContext = resolveWorktreeContext(this.options); + const env = new WorktreeSettings({ ...this.options, ...worktreeContext }).toEnv() as RunnerEnv; + + const { localSettings } = this.options; + if (localSettings) { + const scriptRoot = localSettings.scriptRoot ?? this.options.scriptRoot; + new AzureFunctionsLocalSettings({ + ...localSettings, + ...worktreeContext, + env, + ...(scriptRoot ? { scriptRoot } : {}), + }).sync(); + } + + const envPort = this.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 (this.options.typescript ?? true) { + args.push('--typescript'); + } + args.push('--script-root', this.options.scriptRoot ?? 'deploy/', '--port', envPort, '--cors', this.options.cors ?? '*'); + + const child = spawnInherited('func', args, { + env: childEnv, + ...(this.options.spawn ? { spawn: this.options.spawn } : {}), + }); + forwardChildExit(child); + return child; + } +} diff --git a/packages/cellix/local-dev/src/runners/azurite.ts b/packages/cellix/local-dev/src/runners/azurite.ts new file mode 100644 index 000000000..1fe7f15ea --- /dev/null +++ b/packages/cellix/local-dev/src/runners/azurite.ts @@ -0,0 +1,153 @@ +import type { ChildProcess } from 'node:child_process'; +import path from 'node:path'; +import { isGracefulInterruptExit, setProcessExitCode } from '../process/index.ts'; +import { sanitizeWorktreeHostnameLabel } from '../urls/index.ts'; +import { resolveWorkspaceRoot } from '../workspace/index.ts'; +import { getAzuritePorts } from '../worktree/ports.ts'; +import { resolveWorktreeName } from '../worktree/worktree-name.ts'; +import { spawnInherited } from './spawn.ts'; +import type { RunnerOptions } from './types.ts'; + +function getWorktreeStorageSuffix(worktreeName: string | undefined): string { + const safeLabel = sanitizeWorktreeHostnameLabel(worktreeName); + return safeLabel ? `-${safeLabel}` : ''; +} + +export interface AzuriteDevOptions extends RunnerOptions { + /** Workspace root used for storage directories. Defaults to auto-discovery. */ + workspaceRoot?: string; + /** Storage directory names before worktree suffixing. */ + storageDirectories?: { + blob?: string; + queue?: string; + table?: string; + }; + /** Whether Azurite should run quietly. Defaults to `true`. */ + silent?: boolean; +} + +/** + * Resolved Azurite ports and storage locations after worktree scoping. + */ +export interface ResolvedAzuriteOptions { + /** Blob service port. */ + blobPort: number; + /** Queue service port. */ + queuePort: number; + /** Table service port. */ + tablePort: number; + /** Blob storage working directory. */ + blobLocation: string; + /** Queue storage working directory. */ + queueLocation: string; + /** Table storage working directory. */ + tableLocation: string; +} + +/** + * Starts the three Azurite worker processes on worktree-scoped ports and + * storage directories. + * + * When no worktree is active the runner falls back to Azurite's default ports + * and unsuffixed storage directories. All three services inherit stdio; if one + * exits unexpectedly the runner terminates the others and sets the parent + * process exit code for CLI callers without forcing embedders to exit. + */ +export class AzuriteDevRunner { + private readonly options: AzuriteDevOptions; + + public constructor(options: AzuriteDevOptions = {}) { + this.options = options; + } + + /** + * Resolves the worktree-scoped Azurite ports and storage locations. + * + * @returns Ports and storage directories for the active worktree, or the + * defaults when no worktree is active. + */ + public resolveOptions(): ResolvedAzuriteOptions { + const worktreeName = resolveWorktreeName(this.options); + const workspaceRoot = this.options.workspaceRoot ?? resolveWorkspaceRoot(); + const ports = getAzuritePorts(worktreeName); + const storageSuffix = getWorktreeStorageSuffix(worktreeName); + const directories = this.options.storageDirectories ?? {}; + + return { + blobPort: ports.blob, + blobLocation: path.join(workspaceRoot, `${directories.blob ?? '__blobstorage__'}${storageSuffix}`), + queuePort: ports.queue, + queueLocation: path.join(workspaceRoot, `${directories.queue ?? '__queuestorage__'}${storageSuffix}`), + tablePort: ports.table, + tableLocation: path.join(workspaceRoot, `${directories.table ?? '__tablestorage__'}${storageSuffix}`), + }; + } + + /** + * Spawns blob, queue, and table Azurite processes. + * + * @returns The spawned Azurite child processes. + */ + public start(): ChildProcess[] { + const resolved = this.resolveOptions(); + const silent = this.options.silent ?? true; + const commonArgs = [...(silent ? ['--silent'] : []), '--skipApiVersionCheck']; + const azuriteProcesses: Array<[string, string[]]> = [ + ['azurite-blob', [...commonArgs, '--blobPort', String(resolved.blobPort), '--location', path.resolve(resolved.blobLocation)]], + ['azurite-queue', [...commonArgs, '--queuePort', String(resolved.queuePort), '--location', path.resolve(resolved.queueLocation)]], + ['azurite-table', [...commonArgs, '--tablePort', String(resolved.tablePort), '--location', path.resolve(resolved.tableLocation)]], + ]; + + let procs: ChildProcess[] = []; + const stopAzuriteProcesses = (signal?: NodeJS.Signals) => { + for (const proc of procs) { + proc.kill(signal); + } + }; + const stopOnSigint = () => stopAzuriteProcesses('SIGINT'); + const stopOnSigterm = () => stopAzuriteProcesses('SIGTERM'); + const removeSignalHandlers = () => { + process.off('SIGINT', stopOnSigint); + process.off('SIGTERM', stopOnSigterm); + }; + + procs = azuriteProcesses.map(([command, args]) => { + const proc = spawnInherited(command, args, { + ...(this.options.env ? { env: this.options.env } : {}), + ...(this.options.spawn ? { spawn: this.options.spawn } : {}), + }); + proc.on('error', (error) => { + console.error(`[azurite] failed to start ${command}: ${error.message}`); + stopAzuriteProcesses(); + removeSignalHandlers(); + setProcessExitCode(1); + }); + return proc; + }); + + console.log(`[azurite] started (blob=${resolved.blobPort}, queue=${resolved.queuePort}, table=${resolved.tablePort})`); + + let exited = 0; + for (const proc of procs) { + proc.on('exit', (code, signal) => { + if (isGracefulInterruptExit(signal, code)) { + if (++exited === procs.length) { + removeSignalHandlers(); + setProcessExitCode(0); + } + return; + } + + console.error(`[azurite] process exited unexpectedly: code=${code} signal=${signal}`); + stopAzuriteProcesses(); + removeSignalHandlers(); + setProcessExitCode(code ?? 1); + }); + } + + process.on('SIGINT', stopOnSigint); + process.on('SIGTERM', stopOnSigterm); + + return procs; + } +} diff --git a/packages/cellix/local-dev/src/runners/docusaurus.ts b/packages/cellix/local-dev/src/runners/docusaurus.ts new file mode 100644 index 000000000..12e75ac89 --- /dev/null +++ b/packages/cellix/local-dev/src/runners/docusaurus.ts @@ -0,0 +1,37 @@ +import type { ChildProcess } from 'node:child_process'; +import { forwardChildExit } from '../process/index.ts'; +import { spawnInherited } from './spawn.ts'; +import type { RunnerOptions } from './types.ts'; + +type RunnerEnv = NodeJS.ProcessEnv & { + PORT?: string; +}; + +/** + * Starts the Docusaurus dev server with the shared local-dev defaults. + * + * The server binds to `127.0.0.1`, uses `env.PORT` or `3001`, and does not + * auto-open a browser. + */ +export class DocusaurusDevRunner { + private readonly options: RunnerOptions; + + public constructor(options: RunnerOptions = {}) { + this.options = options; + } + + /** + * Spawns the Docusaurus dev server with inherited stdio. + * + * @returns The spawned child process. + */ + public start(): ChildProcess { + const env = (this.options.env ?? process.env) as RunnerEnv; + const child = spawnInherited('docusaurus', ['start', '--port', env.PORT ?? '3001', '--host', '127.0.0.1', '--no-open'], { + env, + ...(this.options.spawn ? { spawn: this.options.spawn } : {}), + }); + forwardChildExit(child); + return child; + } +} diff --git a/packages/cellix/local-dev/src/runners/index.ts b/packages/cellix/local-dev/src/runners/index.ts new file mode 100644 index 000000000..d1cf4a36c --- /dev/null +++ b/packages/cellix/local-dev/src/runners/index.ts @@ -0,0 +1,11 @@ +export { type AzureFunctionsDevOptions, AzureFunctionsDevRunner } from './azure-functions.ts'; +export { + AzureFunctionsLocalSettings, + type AzureFunctionsLocalSettingsOptions, + resolveAzureFunctionsLocalSettingsValues, +} from './azure-functions-local-settings.ts'; +export { type AzuriteDevOptions, AzuriteDevRunner, type ResolvedAzuriteOptions } from './azurite.ts'; +export { DocusaurusDevRunner } from './docusaurus.ts'; +export { type NodeDevOptions, NodeDevRunner } from './node.ts'; +export type { EnvRunnerOptions, RunnerOptions, RunnerSpawn } from './types.ts'; +export { type ViteDevOptions, ViteDevRunner } from './vite.ts'; diff --git a/packages/cellix/local-dev/src/runners/node.ts b/packages/cellix/local-dev/src/runners/node.ts new file mode 100644 index 000000000..905c9a41a --- /dev/null +++ b/packages/cellix/local-dev/src/runners/node.ts @@ -0,0 +1,44 @@ +import type { ChildProcess } from 'node:child_process'; +import { forwardChildExit } from '../process/index.ts'; +import { WorktreeSettings } from '../worktree/settings.ts'; +import { spawnInherited } from './spawn.ts'; +import type { EnvRunnerOptions } from './types.ts'; + +export interface NodeDevOptions extends EnvRunnerOptions { + /** Node entrypoint to execute. Defaults to `src/index.ts`. */ + entry?: string; + /** Additional Node CLI arguments passed before the entrypoint. */ + nodeArgs?: string[]; +} + +/** + * Starts a Node-backed dev process. On the supported workspace Node version, + * this can execute TypeScript entrypoints without the TSX runtime. + * + * @example + * ```ts + * new NodeDevRunner({ env, entry: 'src/index.ts' }).start(); + * ``` + */ +export class NodeDevRunner { + private readonly options: NodeDevOptions; + + public constructor(options: NodeDevOptions = {}) { + this.options = options; + } + + /** + * Spawns the configured Node process with inherited stdio. + * + * @returns The spawned child process. + */ + public start(): ChildProcess { + const env = new WorktreeSettings(this.options).toEnv(); + const child = spawnInherited('node', [...(this.options.nodeArgs ?? []), this.options.entry ?? 'src/index.ts'], { + env, + ...(this.options.spawn ? { spawn: this.options.spawn } : {}), + }); + forwardChildExit(child); + return child; + } +} diff --git a/packages/cellix/local-dev/src/runners/spawn.ts b/packages/cellix/local-dev/src/runners/spawn.ts new file mode 100644 index 000000000..b059987f3 --- /dev/null +++ b/packages/cellix/local-dev/src/runners/spawn.ts @@ -0,0 +1,17 @@ +import { type ChildProcess, spawn } from 'node:child_process'; +import type { RunnerOptions } from './types.ts'; + +/** + * Spawns a command with inherited stdio and the caller-provided environment. + * + * @param command - Executable name. + * @param args - Command-line arguments. + * @param options - Optional env and spawn override. + * @returns The spawned child process. + */ +export function spawnInherited(command: string, args: string[], options: RunnerOptions = {}): ChildProcess { + return (options.spawn ?? spawn)(command, args, { + stdio: 'inherit', + env: options.env, + }); +} diff --git a/packages/cellix/local-dev/src/runners/types.ts b/packages/cellix/local-dev/src/runners/types.ts new file mode 100644 index 000000000..821a68397 --- /dev/null +++ b/packages/cellix/local-dev/src/runners/types.ts @@ -0,0 +1,34 @@ +import type { ChildProcess, SpawnOptions } from 'node:child_process'; +import type { SettingsRecord } from '../worktree/types.ts'; + +/** + * Spawn function used by dev runners. + * + * Consumers usually rely on the default `child_process.spawn`; tests and + * advanced wrappers can inject this to observe or customize process startup. + */ +export type RunnerSpawn = (command: string, args: string[], options: SpawnOptions) => ChildProcess; + +/** + * Common process options shared by local-dev runner objects. + */ +export interface RunnerOptions { + /** Environment to pass to the spawned process. Defaults to `process.env`. */ + env?: NodeJS.ProcessEnv; + /** Process spawner used by tests and advanced consumers. Defaults to `child_process.spawn`. */ + spawn?: RunnerSpawn; +} + +/** + * Runner options for processes whose environment is built from caller-provided + * settings. + * + * Settings are merged over `env` and worktree-transformed when a worktree is + * active (auto-detected from `WORKTREE_NAME`). These runners are invoked only by + * the `dev:worktree` scripts, so they have no enable/disable toggle: plain `dev` + * runs the underlying tool directly without a wrapper. + */ +export interface EnvRunnerOptions extends RunnerOptions { + /** Settings merged over `env` before worktree transforms are applied. */ + settings?: SettingsRecord; +} diff --git a/packages/cellix/local-dev/src/runners/vite.ts b/packages/cellix/local-dev/src/runners/vite.ts new file mode 100644 index 000000000..086d4f219 --- /dev/null +++ b/packages/cellix/local-dev/src/runners/vite.ts @@ -0,0 +1,48 @@ +import type { ChildProcess } from 'node:child_process'; +import { forwardChildExit } from '../process/index.ts'; +import { buildViteArgs } from '../vite/index.ts'; +import { WorktreeSettings } from '../worktree/settings.ts'; +import { spawnInherited } from './spawn.ts'; +import type { EnvRunnerOptions } from './types.ts'; + +export type ViteDevOptions = EnvRunnerOptions; + +/** + * Starts a Vite dev process using the caller-provided environment. + * + * @example + * ```ts + * new ViteDevRunner({ env }).start(); + * ``` + */ +export class ViteDevRunner { + private readonly options: ViteDevOptions; + + public constructor(options: ViteDevOptions = {}) { + this.options = options; + } + + /** + * Spawns Vite with host, port, and mode derived from the supplied env. + * + * @returns The spawned Vite child process. + */ + public start(): ChildProcess { + const env = new WorktreeSettings(this.options).toEnv(); + const { HOST: host, PORT: port } = env; + const child = spawnInherited( + 'vite', + buildViteArgs({ + ...(host ? { host } : {}), + ...(port ? { port } : {}), + env, + }), + { + env, + ...(this.options.spawn ? { spawn: this.options.spawn } : {}), + }, + ); + forwardChildExit(child); + return child; + } +} diff --git a/packages/cellix/local-dev/src/urls/index.ts b/packages/cellix/local-dev/src/urls/index.ts new file mode 100644 index 000000000..f39b4f865 --- /dev/null +++ b/packages/cellix/local-dev/src/urls/index.ts @@ -0,0 +1,109 @@ +export const PORTLESS_PORT = 1355; + +const LOCALHOST_SUFFIX_PATTERN = /(^|\.)localhost$/; + +/** + * Converts a worktree name into a DNS-label-safe hostname segment. + * + * The returned value is lower-case, replaces non-alphanumeric separators with + * `-`, trims leading/trailing dashes, and is capped to one DNS label. When the + * input cannot produce a valid label, `undefined` is returned so callers can + * leave the base hostname unchanged. + * + * @param worktreeName - Raw worktree identifier, often copied from a branch or + * directory name. + * @returns A hostname-safe label, or `undefined` when no usable label exists. + * + * @example + * ```ts + * sanitizeWorktreeHostnameLabel('jason/feature one'); + * // 'jason-feature-one' + * ``` + */ +export function sanitizeWorktreeHostnameLabel(worktreeName: string | undefined): string | undefined { + const label = worktreeName + ?.toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-/, '') + .replace(/-$/, '') + .slice(0, 63) + .replace(/-$/, ''); + + return label || undefined; +} + +/** + * Returns the hostname portion of a URL string, or `null` when the value is not + * a valid URL. + * + * @param url - Absolute URL string to inspect. + * @returns The parsed hostname, or `null` for invalid URL strings. + */ +export function hostnameFromUrl(url: string): string | null { + try { + return new URL(url).hostname; + } catch { + return null; + } +} + +/** + * Applies a worktree suffix to a `.localhost` hostname. + * + * The worktree segment is sanitized before use so raw branch-style names such + * as `jason/my-feature` do not create invalid local hostnames. Hostnames that + * do not end in `.localhost` are returned unchanged. + * + * @param hostname - Base local hostname, for example `ownercommunity.localhost`. + * @param worktreeName - Optional raw worktree identifier. + * @returns The hostname with a worktree label inserted before `.localhost`. + * + * @example + * ```ts + * applyWorktreeSuffix('ownercommunity.localhost', 'jason/my-feature'); + * // 'ownercommunity.jason-my-feature.localhost' + * ``` + */ +export function applyWorktreeSuffix(hostname: string, worktreeName: string | undefined): string { + const label = sanitizeWorktreeHostnameLabel(worktreeName); + if (!label || !LOCALHOST_SUFFIX_PATTERN.test(hostname)) { + return hostname; + } + + if (hostname === 'localhost') { + return `${label}.localhost`; + } + + const localhostIndex = hostname.lastIndexOf('.localhost'); + const labelsBeforeLocalhost = hostname.slice(0, localhostIndex).split('.'); + if (labelsBeforeLocalhost.at(-1) === label) { + return hostname; + } + + return hostname.replace(LOCALHOST_SUFFIX_PATTERN, `.${label}.localhost`); +} + +/** + * Builds a portless-proxied HTTPS URL for a hostname and optional path. + * + * @param hostname - Public local hostname managed by portless. + * @param relativePath - Optional path to append. Include the leading slash when + * a path is needed. + * @returns An HTTPS URL using Cellix's local portless port. + */ +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. + * + * @param url - Absolute URL string to update. + * @param port - Replacement port number or numeric string. + * @returns The updated URL 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/index.ts b/packages/cellix/local-dev/src/vite/index.ts new file mode 100644 index 000000000..593be79b4 --- /dev/null +++ b/packages/cellix/local-dev/src/vite/index.ts @@ -0,0 +1,38 @@ +import { isE2E } from '../env/index.ts'; + +interface BuildViteArgsOptions { + /** Host passed to Vite. Defaults to `127.0.0.1`. */ + host?: string; + /** Optional port passed to Vite. */ + port?: string; + /** Environment used to infer e2e mode. Defaults to `process.env`. */ + env?: NodeJS.ProcessEnv; +} + +type ViteEnv = NodeJS.ProcessEnv & { + E2E_VITE_MODE?: string; + TF_BUILD?: string; +}; + +/** + * Builds the shared argument list for Vite dev startup across Cellix apps. + * + * @param options - Host, port, and env values used to build Vite arguments. + * @returns CLI arguments suitable for `vite`. + */ +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/index.ts b/packages/cellix/local-dev/src/workspace/index.ts new file mode 100644 index 000000000..19db913ff --- /dev/null +++ b/packages/cellix/local-dev/src/workspace/index.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/conversion.ts b/packages/cellix/local-dev/src/worktree/conversion.ts new file mode 100644 index 000000000..f6aa4b817 --- /dev/null +++ b/packages/cellix/local-dev/src/worktree/conversion.ts @@ -0,0 +1,93 @@ +import { applyWorktreeSuffix, replaceUrlPort } from '../urls/index.ts'; +import { getAzuritePorts, getMongoPort } from './ports.ts'; +import type { SettingsRecord } from './types.ts'; + +/** + * Explicit description of which settings change for a worktree, and how. + * + * The caller names the keys; this package only knows the mechanics. Keys not + * listed here are passed through untouched. + */ +export interface WorktreeConversionPlan { + /** Keys whose `http(s)` URL value gets the worktree hostname suffix. */ + urlKeys?: string[]; + /** Keys whose `mongodb://` URL value gets the worktree Mongo port. */ + mongoKeys?: string[]; + /** + * Keys whose Azure Storage connection string value has its `BlobEndpoint`, + * `QueueEndpoint`, and `TableEndpoint` ports replaced with worktree-scoped + * ports. Values without explicit endpoint segments are left untouched. + */ + azuriteKeys?: string[]; +} + +function suffixUrlHostname(value: string, worktreeName: string): string { + try { + const url = new URL(value); + url.hostname = applyWorktreeSuffix(url.hostname, worktreeName); + const result = url.toString(); + // `URL.toString()` appends a trailing slash to bare-origin URLs; keep input shape. + if (!value.endsWith('/') && url.pathname === '/' && !url.search && !url.hash) { + return result.slice(0, -1); + } + return result; + } catch { + return value; + } +} + +function replaceAzuriteConnectionStringPorts(value: string, worktreeName: string): string { + const ports = getAzuritePorts(worktreeName); + return value + .split(';') + .map((segment) => { + if (segment.startsWith('BlobEndpoint=')) { + return `BlobEndpoint=${replaceUrlPort(segment.slice('BlobEndpoint='.length), ports.blob)}`; + } + if (segment.startsWith('QueueEndpoint=')) { + return `QueueEndpoint=${replaceUrlPort(segment.slice('QueueEndpoint='.length), ports.queue)}`; + } + if (segment.startsWith('TableEndpoint=')) { + return `TableEndpoint=${replaceUrlPort(segment.slice('TableEndpoint='.length), ports.table)}`; + } + return segment; + }) + .join(';'); +} + +/** + * Returns a copy of `values` with the worktree conversion applied to exactly the + * keys named in `plan` — URL hostnames suffixed, Mongo ports shifted, and + * Azurite endpoint ports replaced with worktree-scoped ports. + * + * @param values - Settings to convert (not mutated). + * @param worktreeName - Active worktree label. + * @param plan - Which keys change and how. + * @returns A converted copy of `values`. + */ +export function convertSettingsForWorktree(values: SettingsRecord, worktreeName: string, plan: WorktreeConversionPlan): SettingsRecord { + const converted: SettingsRecord = { ...values }; + + for (const key of plan.urlKeys ?? []) { + const value = converted[key]; + if (typeof value === 'string') { + converted[key] = suffixUrlHostname(value, worktreeName); + } + } + + for (const key of plan.mongoKeys ?? []) { + const value = converted[key]; + if (typeof value === 'string') { + converted[key] = replaceUrlPort(value, getMongoPort(worktreeName)); + } + } + + for (const key of plan.azuriteKeys ?? []) { + const value = converted[key]; + if (typeof value === 'string') { + converted[key] = replaceAzuriteConnectionStringPorts(value, worktreeName); + } + } + + return converted; +} diff --git a/packages/cellix/local-dev/src/worktree/index.ts b/packages/cellix/local-dev/src/worktree/index.ts new file mode 100644 index 000000000..b5023b551 --- /dev/null +++ b/packages/cellix/local-dev/src/worktree/index.ts @@ -0,0 +1,10 @@ +export { convertSettingsForWorktree, type WorktreeConversionPlan } from './conversion.ts'; +export { + type AzuritePorts, + buildAzuriteConnectionString, + getAzuritePorts, + getMongoPort, + getWorktreePortOffset, +} from './ports.ts'; +export { WorktreeSettings } from './settings.ts'; +export type { SettingsRecord, WorktreeMode, WorktreeSettingsOptions } from './types.ts'; 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..7d17a6824 --- /dev/null +++ b/packages/cellix/local-dev/src/worktree/ports.ts @@ -0,0 +1,86 @@ +export interface AzuritePorts { + /** Blob service port. */ + blob: number; + /** Queue service port. */ + queue: number; + /** Table service port. */ + 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. + * + * The default worktree uses offset `0`. Named worktrees use one of 49 buckets + * from `100` through `4900`, which keeps them away from the default service + * ports while remaining deterministic for repeat local runs. + * + * @param worktreeName - Optional worktree identifier. Defaults to + * `process.env.WORKTREE_NAME`. + * @returns Port offset to add to service base ports. + */ +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. + * + * @param worktreeName - Optional worktree identifier. Defaults to + * `process.env.WORKTREE_NAME`. + * @returns MongoDB port derived from base `50000` plus the worktree offset. + */ +export function getMongoPort(worktreeName = getDefaultWorktreeName()): number { + return 50000 + getWorktreePortOffset(worktreeName); +} + +/** + * Returns the Azurite ports for the current worktree. + * + * @param worktreeName - Optional worktree identifier. Defaults to + * `process.env.WORKTREE_NAME`. + * @returns Blob, queue, and table ports derived from Azurite's default ports + * plus the worktree offset. + */ +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. + * + * @param options - Account credentials and explicit Azurite ports to encode. + * @returns A connection string for the selected worktree-scoped Azurite ports. + */ +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/src/worktree/settings.ts b/packages/cellix/local-dev/src/worktree/settings.ts new file mode 100644 index 000000000..6b5773a25 --- /dev/null +++ b/packages/cellix/local-dev/src/worktree/settings.ts @@ -0,0 +1,141 @@ +import { applyWorktreeSuffix, replaceUrlPort } from '../urls/index.ts'; +import { getMongoPort, getWorktreePortOffset } from './ports.ts'; +import type { KnownWorktreeSettings, SettingsRecord, WorktreeSettingsOptions } from './types.ts'; +import { resolveWorktreeName } from './worktree-name.ts'; + +const TRANSFORMABLE_URL_PATTERN = /^(?:https?:\/\/|mongodb:\/\/)[^\s]+$/; + +function isPlainRecord(value: unknown): value is SettingsRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function stringifyEnvValue(value: unknown): string | undefined { + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + return undefined; +} + +function preservesBareOriginShape(originalValue: string, parsedUrl: URL): boolean { + return !originalValue.endsWith('/') && parsedUrl.pathname === '/' && !parsedUrl.search && !parsedUrl.hash; +} + +function transformUrl(urlValue: string, worktreeName: string | undefined): string { + try { + const url = new URL(urlValue); + if (url.protocol === 'mongodb:') { + return worktreeName ? replaceUrlPort(urlValue, getMongoPort(worktreeName)) : urlValue; + } + + if (url.hostname) { + url.hostname = applyWorktreeSuffix(url.hostname, worktreeName); + } + + const transformedUrl = url.toString(); + if (preservesBareOriginShape(urlValue, url)) { + return transformedUrl.slice(0, -1); + } + + return transformedUrl; + } catch { + return urlValue; + } +} + +function transformStringValue(value: string, worktreeName: string | undefined): string { + return TRANSFORMABLE_URL_PATTERN.test(value) ? transformUrl(value, worktreeName) : value; +} + +function transformValue(value: unknown, worktreeName: string | undefined): unknown { + if (typeof value === 'string') { + return transformStringValue(value, worktreeName); + } + + if (Array.isArray(value)) { + return value.map((item) => transformValue(item, worktreeName)); + } + + if (isPlainRecord(value)) { + return Object.fromEntries(Object.entries(value).map(([key, entryValue]) => [key, transformValue(entryValue, worktreeName)])); + } + + return value; +} + +/** + * Applies deterministic worktree transforms to caller-provided local-dev + * settings without knowing the consuming application's env-key policy. + * + * URL-like strings receive `.localhost` hostname suffixes, MongoDB URLs receive + * worktree-specific ports, and `PORT` receives the generic worktree port offset. + * + * Unlike {@link convertSettingsForWorktree}, which only touches the keys an + * explicit {@link WorktreeConversionPlan} names, this class checks every string + * value (including nested objects/arrays). Only values that begin with a + * supported URL scheme and parse as a complete `http(s)://` or `mongodb://` + * URL are rewritten. URLs embedded in descriptions or log lines are left + * untouched. Prefer {@link convertSettingsForWorktree} when callers need + * explicit key-level policy. + * + * @example + * ```ts + * new WorktreeSettings({ + * env: { WORKTREE_NAME: 'feature-a' }, + * settings: { + * BASE_URL: 'https://ownercommunity.localhost:1355', + * COSMOSDB_CONNECTION_STRING: 'mongodb://127.0.0.1:50000/ocom', + * }, + * }).toEnv(); + * ``` + */ +export class WorktreeSettings { + private readonly env: NodeJS.ProcessEnv; + private readonly settings: SettingsRecord; + private readonly worktreeName: string | undefined; + + public constructor(options: WorktreeSettingsOptions = {}) { + this.env = options.env ?? process.env; + this.settings = options.settings ?? {}; + this.worktreeName = resolveWorktreeName({ ...options, env: this.env }); + } + + /** + * Returns a process environment with transformed settings merged over the + * base env. + * + * Non-string, non-number, and non-boolean setting values are ignored because + * process environments can only carry strings. + * + * @returns A new environment object. + */ + public toEnv(): NodeJS.ProcessEnv { + const transformedSettings = this.transformRecord(this.settings); + return { + ...this.env, + ...Object.fromEntries( + Object.entries(transformedSettings) + .map(([key, value]) => [key, stringifyEnvValue(value)]) + .filter((entry): entry is [string, string] => typeof entry[1] === 'string'), + ), + }; + } + + /** + * Applies worktree transforms to a settings record while preserving its + * object shape. + * + * @param record - Settings object to transform. + * @returns A transformed copy of the provided record. + */ + public transformRecord(record: TRecord): TRecord { + const transformed = Object.fromEntries(Object.entries(record).map(([key, value]) => [key, transformValue(value, this.worktreeName)])) as KnownWorktreeSettings; + + if (typeof transformed.PORT === 'string' && this.worktreeName) { + const port = Number(transformed.PORT); + if (Number.isInteger(port)) { + transformed.PORT = String(port + getWorktreePortOffset(this.worktreeName)); + } + } + + return transformed as TRecord; + } +} diff --git a/packages/cellix/local-dev/src/worktree/types.ts b/packages/cellix/local-dev/src/worktree/types.ts new file mode 100644 index 000000000..99f73e7c0 --- /dev/null +++ b/packages/cellix/local-dev/src/worktree/types.ts @@ -0,0 +1,46 @@ +/** + * JSON-compatible settings object accepted by worktree transformers. + */ +export type SettingsRecord = Record; + +/** + * Settings keys with special generic worktree behavior. + */ +export type KnownWorktreeSettings = SettingsRecord & { + /** Numeric port string offset when a worktree is active. */ + PORT?: unknown; + /** Azurite storage key used to build connection strings. */ + STORAGE_ACCOUNT_KEY?: unknown; + /** Azurite storage account used to build connection strings. */ + STORAGE_ACCOUNT_NAME?: unknown; +}; + +/** + * Environment shape used by worktree helpers. + */ +export type WorktreeEnv = NodeJS.ProcessEnv & { + /** Raw worktree name used for hostname suffixes and port offsets. */ + WORKTREE_NAME?: string; +}; + +/** + * Explicit worktree transform mode. + * + * `true` enables transforms when a name is available, and `false` disables + * transforms even if `WORKTREE_NAME` is present in the environment. + */ +export type WorktreeMode = boolean; + +/** + * Options shared by worktree env and settings transformers. + */ +export interface WorktreeSettingsOptions { + /** Base environment. Defaults to `process.env`. */ + env?: NodeJS.ProcessEnv; + /** Settings to merge over `env` before worktree transforms are applied. */ + settings?: SettingsRecord; + /** Whether to apply worktree transforms. Defaults to auto-detection from `WORKTREE_NAME`. */ + worktree?: WorktreeMode; + /** Worktree name. Defaults to `env.WORKTREE_NAME`. */ + worktreeName?: string; +} diff --git a/packages/cellix/local-dev/src/worktree/worktree-name.ts b/packages/cellix/local-dev/src/worktree/worktree-name.ts new file mode 100644 index 000000000..0b8b572e5 --- /dev/null +++ b/packages/cellix/local-dev/src/worktree/worktree-name.ts @@ -0,0 +1,18 @@ +import type { WorktreeEnv, WorktreeMode } from './types.ts'; + +/** + * Resolves the effective worktree name for a local-dev operation. + * + * A `WORKTREE_NAME` enables worktree behavior automatically. Callers can pass + * `worktree: false` to disable it explicitly. + * + * @param options - Optional env, explicit mode, and explicit worktree name. + * @returns The worktree name to apply, or `undefined` when transforms should be + * disabled or no name is available. + */ +export function resolveWorktreeName(options: { env?: NodeJS.ProcessEnv; worktree?: WorktreeMode; worktreeName?: string }): string | undefined { + const env = (options.env ?? process.env) as WorktreeEnv; + if (options.worktree === false) return undefined; + + return options.worktreeName ?? env.WORKTREE_NAME; +} 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/infrastructure/shutdown-handlers.test.ts b/packages/cellix/serenity-framework/src/infrastructure/shutdown-handlers.test.ts index 8cb89dffa..94c69e6fe 100644 --- a/packages/cellix/serenity-framework/src/infrastructure/shutdown-handlers.test.ts +++ b/packages/cellix/serenity-framework/src/infrastructure/shutdown-handlers.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { TestServer } from '../servers/index.ts'; +import { ApiInfrastructure, E2EInfrastructure } from './index.ts'; class FakeServer implements TestServer { private running = false; @@ -26,12 +27,10 @@ class FakeServer implements TestServer { describe('infrastructure shutdown handlers', () => { afterEach(() => { vi.restoreAllMocks(); - vi.resetModules(); }); it('removes stopped API infrastructure instances from the process shutdown registry', async () => { const once = vi.spyOn(process, 'once').mockImplementation(() => process); - const { ApiInfrastructure } = await import('./index.ts'); const first = ApiInfrastructure.create().addServer('api', new FakeServer()).finalize(); expect(once).toHaveBeenCalledTimes(2); @@ -44,7 +43,6 @@ describe('infrastructure shutdown handlers', () => { it('removes stopped E2E infrastructure instances from the process shutdown registry', async () => { const once = vi.spyOn(process, 'once').mockImplementation(() => process); - const { E2EInfrastructure } = await import('./index.ts'); const first = E2EInfrastructure.create().addServer('api', new FakeServer()).finalize(); expect(once).toHaveBeenCalledTimes(2); diff --git a/packages/cellix/server-oauth2-mock-seedwork/tests/file-user-store.test.ts b/packages/cellix/server-oauth2-mock-seedwork/tests/file-user-store.test.ts index 04772575f..f6fdee1ca 100644 --- a/packages/cellix/server-oauth2-mock-seedwork/tests/file-user-store.test.ts +++ b/packages/cellix/server-oauth2-mock-seedwork/tests/file-user-store.test.ts @@ -126,9 +126,7 @@ describe('file user store oidcConfigName filtering', () => { it('users without oidcConfigName are visible to all scoped stores', async () => { if (!tmp) throw new Error('tmp not created'); - writeUsers(tmp, 'mock-oidc.users.json', [ - { username: 'shared@example.com', sub: 'sub-shared' }, - ]); + writeUsers(tmp, 'mock-oidc.users.json', [{ username: 'shared@example.com', sub: 'sub-shared' }]); const endStore = createFileUserStore(tmp, 'end-user'); const staffStore = createFileUserStore(tmp, 'staff-user'); @@ -191,15 +189,7 @@ describe('file user store oidcConfigName filtering', () => { expect(users).toHaveLength(1); expect(users[0]?.username).toBe('good@example.com'); expect(warnSpy.length).toBeGreaterThan(0); - expect( - warnSpy.some((args) => - (args as unknown[]).some( - (arg) => - typeof arg === 'string' && - arg.includes('"oidcConfigName" must be a string'), - ), - ), - ).toBe(true); + expect(warnSpy.some((args) => (args as unknown[]).some((arg) => typeof arg === 'string' && arg.includes('"oidcConfigName" must be a string')))).toBe(true); } finally { console.warn = origWarn; } diff --git a/packages/cellix/server-oauth2-mock-seedwork/tests/portal-discovery.test.ts b/packages/cellix/server-oauth2-mock-seedwork/tests/portal-discovery.test.ts index d364915c8..565ece8ac 100644 --- a/packages/cellix/server-oauth2-mock-seedwork/tests/portal-discovery.test.ts +++ b/packages/cellix/server-oauth2-mock-seedwork/tests/portal-discovery.test.ts @@ -569,11 +569,7 @@ describe('discoverPortalConfigs', () => { envVars: { clientId: 'VITE_APP_PORTAL_STAFF_CLIENTID', redirectUri: 'VITE_APP_PORTAL_STAFF_REDIRECT' }, }, ]); - writeEnv( - tmp, - 'ui-portal/.env', - 'VITE_APP_PORTAL_END_CLIENTID=end-cid\nVITE_APP_PORTAL_END_REDIRECT=https://portal/end/cb\nVITE_APP_PORTAL_STAFF_CLIENTID=staff-cid\nVITE_APP_PORTAL_STAFF_REDIRECT=https://portal/staff/cb\n', - ); + writeEnv(tmp, 'ui-portal/.env', 'VITE_APP_PORTAL_END_CLIENTID=end-cid\nVITE_APP_PORTAL_END_REDIRECT=https://portal/end/cb\nVITE_APP_PORTAL_STAFF_CLIENTID=staff-cid\nVITE_APP_PORTAL_STAFF_REDIRECT=https://portal/staff/cb\n'); const portals = discoverPortalConfigs(tmp); expect(portals).toHaveLength(2); diff --git a/packages/cellix/service-blob-storage/package.json b/packages/cellix/service-blob-storage/package.json index 771aec044..a2de29666 100644 --- a/packages/cellix/service-blob-storage/package.json +++ b/packages/cellix/service-blob-storage/package.json @@ -26,7 +26,7 @@ "clean": "rimraf dist" }, "dependencies": { - "@azure/storage-blob": "^12.31.0", + "@azure/storage-blob": "catalog:", "@azure/identity": "^4.13.1", "@cellix/api-services-spec": "workspace:*" }, diff --git a/packages/cellix/service-queue-storage/src/index.ts b/packages/cellix/service-queue-storage/src/index.ts index 583824e3f..1e24249e1 100644 --- a/packages/cellix/service-queue-storage/src/index.ts +++ b/packages/cellix/service-queue-storage/src/index.ts @@ -1,7 +1,9 @@ -export type { InboundQueueDefinition, LoggingFieldSpec, OutboundQueueDefinition, QueueDefinition, QueueLoggingConfig, QueueMessage, QueueStorageConfig, QueueTriggerMetadata } from './interfaces.ts'; +export type { FromSchema, JSONSchema } from 'json-schema-to-ts'; export { defineQueue } from './define-queue.ts'; -export { $payload, payloadFields, resolveLoggingFields } from './logging-fields.ts'; +export type { InboundQueueDefinition, LoggingFieldSpec, OutboundQueueDefinition, QueueDefinition, QueueLoggingConfig, QueueMessage, QueueStorageConfig, QueueTriggerMetadata } from './interfaces.ts'; +export type { QueueServiceLifecycle, QueueServiceLogging } from './internal-queue-storage-service.ts'; export type { IQueueMessageLogger, MessageLogEnvelope, QueueMessageLogBlobStorage } from './logging.ts'; +export { $payload, payloadFields, resolveLoggingFields } from './logging-fields.ts'; export type { QueueConsumerContext } from './queue-consumer.ts'; export type { QueueProducerContext } from './queue-producer.ts'; export type { @@ -14,5 +16,3 @@ export type { RegisteredQueueService, } from './register-queues.ts'; export { createRegisteredQueueService, deriveProvisionQueues, registerQueues } from './register-queues.ts'; -export type { QueueServiceLifecycle, QueueServiceLogging } from './internal-queue-storage-service.ts'; -export type { FromSchema, JSONSchema } from 'json-schema-to-ts'; diff --git a/packages/ocom-verification/acceptance-api/package.json b/packages/ocom-verification/acceptance-api/package.json index 1f4d662af..06e434cb3 100644 --- a/packages/ocom-verification/acceptance-api/package.json +++ b/packages/ocom-verification/acceptance-api/package.json @@ -42,7 +42,7 @@ "@types/node": "catalog:", "c8": "^11.0.0", "rimraf": "^6.0.1", - "tsx": "^4.20.3", + "tsx": "catalog:", "typescript": "catalog:" } } diff --git a/packages/ocom-verification/acceptance-api/src/mock-application-services.ts b/packages/ocom-verification/acceptance-api/src/mock-application-services.ts index 308365adb..7b162160a 100644 --- a/packages/ocom-verification/acceptance-api/src/mock-application-services.ts +++ b/packages/ocom-verification/acceptance-api/src/mock-application-services.ts @@ -4,8 +4,8 @@ import type { ApiContextSpec } from '@ocom/context-spec'; import { Persistence } from '@ocom/persistence'; import type { ServiceApolloServer } from '@ocom/service-apollo-server'; import type { BlobAddress, BlobStorageOperations, ClientUploadOperations, ListBlobsRequest, UploadTextBlobRequest } from '@ocom/service-blob-storage'; -import type { EndUserUpdatePayload, QueueStorageOperations } from '@ocom/service-queue-storage'; import type { ServiceMongoose } from '@ocom/service-mongoose'; +import type { EndUserUpdatePayload, QueueStorageOperations } from '@ocom/service-queue-storage'; import type { TokenValidation, TokenValidationResult } from '@ocom/service-token-validation'; import { actors } from '@ocom-verification/verification-shared/test-data'; diff --git a/packages/ocom-verification/acceptance-ui/package.json b/packages/ocom-verification/acceptance-ui/package.json index d0943ce3d..9cb328852 100644 --- a/packages/ocom-verification/acceptance-ui/package.json +++ b/packages/ocom-verification/acceptance-ui/package.json @@ -33,7 +33,7 @@ "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "c8": "^10.1.3", - "tsx": "^4.20.3", + "tsx": "catalog:", "typescript": "catalog:" } } diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/create-community.steps.tsx b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/create-community.steps.tsx index be435d792..831e08c3b 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/create-community.steps.tsx +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/create-community.steps.tsx @@ -16,11 +16,7 @@ Given('{word} is an authenticated community owner', async (actorName: string) => const actor = actorCalled(actorName); const onSave = async (values: { name: string }): Promise => { - await actor.attemptsTo( - notes().set('formSubmitted', true), - notes().set('communityName', values.name ?? ''), - notes().set('communityCreationQueued', true), - ); + await actor.attemptsTo(notes().set('formSubmitted', true), notes().set('communityName', values.name ?? ''), notes().set('communityCreationQueued', true)); }; await actor.attemptsTo( diff --git a/packages/ocom-verification/e2e-tests/package.json b/packages/ocom-verification/e2e-tests/package.json index 620767bff..57262c353 100644 --- a/packages/ocom-verification/e2e-tests/package.json +++ b/packages/ocom-verification/e2e-tests/package.json @@ -25,13 +25,15 @@ "std-env": "^4.0.0" }, "devDependencies": { + "@cellix/local-dev": "workspace:*", + "@ocom/local-dev-config": "workspace:*", "@ocom/service-queue-storage": "workspace:*", "@cellix/config-typescript": "workspace:*", "@ocom-verification/verification-shared": "workspace:*", "@types/node": "catalog:", "playwright": "catalog:", "rimraf": "^6.0.1", - "tsx": "^4.20.3", + "tsx": "catalog:", "typescript": "catalog:" } } diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/create-community.steps.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/create-community.steps.ts index 300450fec..97e7876fd 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/create-community.steps.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/create-community.steps.ts @@ -4,12 +4,12 @@ import { type DataTable, Given, Then, When } from '@cucumber/cucumber'; import { actors } from '@ocom-verification/verification-shared/test-data'; import { actorCalled, notes } from '@serenity-js/core'; import { LogInWithOAuth2 } from '../../../shared/abilities/oauth2-login.ts'; +import { clearKnownQueueMessages, waitForCommunityCreationQueueMessage } from '../../../shared/support/queue-storage.ts'; import type { CommunityE2ENotes } from '../notes/community-notes.ts'; import { CommunityCreatedFlag } from '../questions/community-created-flag.ts'; import { CommunityErrorMessage } from '../questions/community-error-message.ts'; import { CommunityName } from '../questions/community-name.ts'; import { CreateCommunity } from '../tasks/create-community.ts'; -import { clearKnownQueueMessages, waitForCommunityCreationQueueMessage } from '../../../shared/support/queue-storage.ts'; let lastActorName = actors.CommunityOwner.name; 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 3b5b23a65..f97c38705 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/shared/environment/test-environment.ts b/packages/ocom-verification/e2e-tests/src/shared/environment/test-environment.ts index 56c41feb2..2948691e4 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 @@ -2,6 +2,8 @@ import { execFileSync } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { resolveAzureFunctionsLocalSettingsValues } from '@cellix/local-dev'; +import { buildOcomApiLocalSettings } from '@ocom/local-dev-config'; import { getPortlessPath } from './resolve-portless.ts'; let proxyInitialized = false; @@ -43,9 +45,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; @@ -64,7 +80,7 @@ function loadE2EEnvDefaults(): void { const currentDir = dirname(fileURLToPath(import.meta.url)); const workspaceRoot = resolve(currentDir, '../../../../../..'); - loadApiLocalSettings(resolve(workspaceRoot, 'apps/api/local-settings.e2e.json')); + loadApiLocalSettings(); for (const filePath of [resolve(workspaceRoot, 'apps/ui-community/.env.e2e'), resolve(workspaceRoot, 'apps/ui-staff/.env.e2e')]) { if (!existsSync(filePath)) continue; for (const line of readFileSync(filePath, 'utf-8').split('\n')) { @@ -78,14 +94,18 @@ function loadE2EEnvDefaults(): void { } } -function loadApiLocalSettings(filePath: string): void { - if (!existsSync(filePath)) return; - - const parsed = JSON.parse(readFileSync(filePath, 'utf-8')) as { - Values?: Record; - }; +/** + * Loads `apps/api/local-settings.e2e.json` into `process.env`. The values + * are already worktree-scoped by `@cellix/local-dev` — the same conversion + * the API's own Functions host applies to `local.settings.json` — so code + * running directly in this cucumber-js process (e.g. + * `shared/support/queue-storage.ts`) sees this worktree's actual ports + * instead of the settings file's un-scoped base ports. + */ +function loadApiLocalSettings(): void { + const values = resolveAzureFunctionsLocalSettingsValues(buildOcomApiLocalSettings()); - for (const [key, value] of Object.entries(parsed.Values ?? {})) { + for (const [key, value] of Object.entries(values)) { process.env[key] ??= String(value); } } diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/queue-storage.ts b/packages/ocom-verification/e2e-tests/src/shared/support/queue-storage.ts index 24464c766..96159eab8 100644 --- a/packages/ocom-verification/e2e-tests/src/shared/support/queue-storage.ts +++ b/packages/ocom-verification/e2e-tests/src/shared/support/queue-storage.ts @@ -1,4 +1,4 @@ -import { ServiceQueueStorage, type CommunityCreationPayload } from '@ocom/service-queue-storage'; +import { type CommunityCreationPayload, ServiceQueueStorage } from '@ocom/service-queue-storage'; const communityCreationQueueName = 'community-creation'; let queueStorageService: ServiceQueueStorage | undefined; @@ -56,9 +56,7 @@ export async function waitForCommunityCreationQueueMessage(expected: { community while (Date.now() < deadline) { const matchingMessage = (await service.peekAtCommunityCreationQueue(32)).find( (message) => - message.payload.name === expected.name && - (expected.createdBy === undefined || message.payload.createdBy === expected.createdBy) && - (expected.communityId == null || message.payload.communityId === expected.communityId), + message.payload.name === expected.name && (expected.createdBy === undefined || message.payload.createdBy === expected.createdBy) && (expected.communityId == null || message.payload.communityId === expected.communityId), )?.payload; if (matchingMessage) { 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/README.md b/packages/ocom/local-dev-config/README.md new file mode 100644 index 000000000..fd5832b66 --- /dev/null +++ b/packages/ocom/local-dev-config/README.md @@ -0,0 +1,55 @@ +# @ocom/local-dev-config + +OCOM-specific local-development URL and hostname helpers built on `@cellix/local-dev`. + +This package owns Owner Community application policy: which app `.env` files define local hostnames, which auth issuer paths are used, and which URLs app wrapper scripts should inject. Generic mechanics such as dotenv parsing, portless URL construction, and worktree-safe hostname suffixing stay in `@cellix/local-dev`. + +## Install + +```json +{ + "devDependencies": { + "@ocom/local-dev-config": "workspace:*" + } +} +``` + +## Usage + +Use this package as the OCOM source of local URLs, then pass those values into the generic Cellix worktree runners: + +```ts +import { ViteDevRunner } from '@cellix/local-dev'; +import { buildOcomUrls } from '@ocom/local-dev-config'; + +const urls = buildOcomUrls(); + +new ViteDevRunner({ + settings: { + VITE_APP_UI_COMMUNITY_BASE_URL: urls.uiCommunityBaseUrl, + VITE_COMMON_API_ENDPOINT: urls.apiGraphqlUrl, + }, +}).start(); +``` + +## Public API + +All exports are available from `@ocom/local-dev-config`. Focused subpaths are +also published for narrower imports: + +- `@ocom/local-dev-config/hostnames` +- `@ocom/local-dev-config/urls` + +- `getOcomHostnames(options?)` +- `buildOcomUrls(options?)` +- `buildOcomApiLocalSettings(options?)` +- `OcomLocalDevOptions` +- `OcomHostnames` +- `OcomUrls` + +## Boundaries + +- Keep reusable process runners, dotenv parsing, JSON syncing, and port math in `@cellix/local-dev`. +- Keep OCOM-specific hostname derivation, auth paths, redirect paths, and app `.env` lookup policy here. +- Keep one-off runtime behavior in the consuming app wrapper script instead of widening this package. +- Keep app wrapper scripts thin: get OCOM URL values here, pass them into a generic `@cellix/local-dev` worktree object, and call `start()` or `sync()`. diff --git a/packages/ocom/local-dev-config/manifest.md b/packages/ocom/local-dev-config/manifest.md new file mode 100644 index 000000000..506060345 --- /dev/null +++ b/packages/ocom/local-dev-config/manifest.md @@ -0,0 +1,67 @@ +# manifest.md - @ocom/local-dev-config + +## Purpose + +Provide Owner Community local-development URL and hostname policy as a reusable package, while delegating generic local-development mechanics to `@cellix/local-dev`. + +## Scope + +This package resolves OCOM hostnames from app `.env` files and environment overrides, applies the shared hostname suffixing helper from `@cellix/local-dev`, and builds the complete local URL set needed by UI, API, mock-auth, docs, e2e, and other build-time consumers. + +## Non-goals + +- Generic process runners, port math, JSON syncing, or dotenv parsing +- Production runtime configuration +- Non-OCOM app defaults +- Generic local dev process orchestration + +## Public API shape + +Published entrypoints: + +- `@ocom/local-dev-config` +- `@ocom/local-dev-config/hostnames` +- `@ocom/local-dev-config/urls` + +Root entrypoint exports: + +- `getOcomHostnames(options?)` +- `buildOcomUrls(options?)` +- `buildOcomApiLocalSettings(options?)` +- `OcomLocalDevOptions` +- `OcomHostnames` +- `OcomUrls` + +## Core concepts + +- OCOM app wrappers should use this package to get OCOM URL values, then pass those values into generic `@cellix/local-dev` worktree-aware runners or settings syncers. +- Environment values override app `.env` file values so task runners can inject per-process configuration. +- Worktree suffixing, Mongo ports, Azurite ports, and settings-file connection-string transforms are delegated to `@cellix/local-dev` so all participating apps share one rule. + +## Package boundaries + +- App-specific auth paths and redirect paths are allowed here because they are OCOM local-dev policy. +- Generic local-development primitives must not be reimplemented here. +- One-off behavior for a single app should stay in that app's wrapper script unless at least two OCOM consumers need the same policy. + +## Dependencies / relationships + +- Depends on `@cellix/local-dev` +- Downstream consumers in this branch: `@apps/api`, `@apps/ui-community`, `@apps/ui-staff`, and `@apps/server-oauth2-mock` + +## Testing strategy + +- Test through the package entrypoint. +- Prove hostnames are derived from fixture app `.env` files, environment overrides take precedence, worktree suffixes are applied safely without duplication, and the full URL contract remains stable. + +## Documentation obligations + +- Keep `README.md` consumer-facing and focused on app-wrapper usage. +- Keep this manifest aligned with the exported package contract and OCOM-specific boundaries. +- Maintain TSDoc on exported helpers and public option/result types. + +## Release-readiness standards + +- Package build and tests must pass. +- The public surface should remain small and policy-focused. +- Any expansion of exported URL fields should be justified by a real app-wrapper consumer. diff --git a/packages/ocom/local-dev-config/package.json b/packages/ocom/local-dev-config/package.json new file mode 100644 index 000000000..ba177169d --- /dev/null +++ b/packages/ocom/local-dev-config/package.json @@ -0,0 +1,49 @@ +{ + "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/index.d.ts", + "default": "./dist/hostnames/index.js" + }, + "./urls": { + "types": "./dist/urls/index.d.ts", + "default": "./dist/urls/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" + }, + "dependencies": { + "@cellix/local-dev": "workspace:*" + }, + "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/ocom/local-dev-config/src/api-settings/index.ts b/packages/ocom/local-dev-config/src/api-settings/index.ts new file mode 100644 index 000000000..e2aff8c28 --- /dev/null +++ b/packages/ocom/local-dev-config/src/api-settings/index.ts @@ -0,0 +1,53 @@ +import path from 'node:path'; +import { type AzureFunctionsLocalSettingsOptions, isE2E } from '@cellix/local-dev'; +import { readJsonFile } from '@cellix/local-dev/files'; +import { resolveWorkspaceRoot } from '@cellix/local-dev/workspace'; +import type { OcomLocalDevOptions } from '../types.ts'; + +interface ApiSettingsDocument { + Values?: Record; + Host?: Record; +} + +/** + * The exact api settings that get scoped to a worktree, named explicitly so it + * is obvious what changes. Everything else in the settings file is passed + * through untouched. The sync applies these only when a worktree is active. + */ +const API_WORKTREE_CONVERSION = { + // OIDC URLs: hostname gains the worktree suffix. + urlKeys: ['ACCOUNT_PORTAL_OIDC_ISSUER', 'ACCOUNT_PORTAL_OIDC_ENDPOINT', 'STAFF_PORTAL_OIDC_ISSUER', 'STAFF_PORTAL_OIDC_ENDPOINT'], + // Mongo connection string: port shifts to the worktree Mongo port. + mongoKeys: ['COSMOSDB_CONNECTION_STRING'], + // Azure storage: replaced with a worktree-scoped Azurite connection string. + azuriteKeys: ['AZURE_STORAGE_CONNECTION_STRING', 'AzureWebJobsStorage'], +} as const; + +/** + * The single entry point for the OCOM api's Azure Functions local settings. + * + * The values themselves live in `apps/api/local.settings.json` (normal dev) and + * `apps/api/local-settings.e2e.json` (E2E); this reads the mode-appropriate file + * and declares — via {@link API_WORKTREE_CONVERSION} — exactly which keys the + * sync should scope to the active worktree. + * + * @param options - Optional environment (selects normal vs E2E) and + * workspace-root overrides. + * @returns Local-settings options for the api's dev runner and standalone sync. + */ +export function buildOcomApiLocalSettings(options: OcomLocalDevOptions = {}): AzureFunctionsLocalSettingsOptions { + const env = options.env ?? process.env; + const workspaceRoot = options.workspaceRoot ?? resolveWorkspaceRoot(); + const fileName = isE2E(env) ? 'local-settings.e2e.json' : 'local.settings.json'; + const document = readJsonFile(path.join(workspaceRoot, 'apps', 'api', fileName)); + + return { + ...(document.Values ? { values: document.Values } : {}), + ...(document.Host ? { host: document.Host } : {}), + worktreeConversion: { + urlKeys: [...API_WORKTREE_CONVERSION.urlKeys], + mongoKeys: [...API_WORKTREE_CONVERSION.mongoKeys], + azuriteKeys: [...API_WORKTREE_CONVERSION.azuriteKeys], + }, + }; +} diff --git a/packages/ocom/local-dev-config/src/hostnames/index.ts b/packages/ocom/local-dev-config/src/hostnames/index.ts new file mode 100644 index 000000000..0b804c8e8 --- /dev/null +++ b/packages/ocom/local-dev-config/src/hostnames/index.ts @@ -0,0 +1,91 @@ +import path from 'node:path'; +import { type DotEnvValues, readDotEnv } from '@cellix/local-dev/files'; +import { applyWorktreeSuffix, hostnameFromUrl } from '@cellix/local-dev/urls'; +import { resolveWorkspaceRoot } from '@cellix/local-dev/workspace'; +import type { OcomHostnames, OcomLocalDevOptions } from '../types.ts'; + +interface OcomEnvValues { + WORKTREE_NAME?: string; + VITE_APP_UI_COMMUNITY_BASE_URL?: string; + VITE_COMMON_API_ENDPOINT?: string; + VITE_APP_UI_COMMUNITY_END_USER_B2C_AUTHORITY?: string; + VITE_APP_UI_STAFF_BASE_URL?: string; + VITE_APP_UI_STAFF_STAFF_USER_AAD_AUTHORITY?: string; + VITE_APP_UI_STAFF_STAFF_USER_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; +} + +function firstDefinedUrl(...values: Array): string { + return values.find((value) => value !== undefined) ?? ''; +} + +function requiredHostnameFromSources(key: string, ...values: Array): string { + return requiredHostname(firstDefinedUrl(...values), key); +} + +/** + * Resolves the OCOM local-development hostnames from app `.env` files and + * optional process environment overrides. + * + * The package keeps OCOM-specific policy here: which app env files to read, + * which env keys define the public local hostnames, and how the documentation + * hostname is derived. Generic URL parsing and worktree suffixing remain in + * `@cellix/local-dev`. + * + * @param options - Optional environment and workspace-root overrides. + * @returns Worktree-aware OCOM hostnames. + * @throws When a required URL cannot be found or parsed. + * + * @example + * ```ts + * const hostnames = getOcomHostnames({ + * env: { WORKTREE_NAME: 'jason/feature' }, + * workspaceRoot, + * }); + * ``` + */ +export function getOcomHostnames(options: OcomLocalDevOptions = {}): OcomHostnames { + const env = (options.env ?? process.env) as NodeJS.ProcessEnv & OcomEnvValues; + const workspaceRoot = options.workspaceRoot ?? resolveWorkspaceRoot(); + const communityEnv = readAppEnv(workspaceRoot, 'ui-community'); + const staffEnv = readAppEnv(workspaceRoot, 'ui-staff'); + const worktreeName = env.WORKTREE_NAME; + const communityHostname = requiredHostnameFromSources('VITE_APP_UI_COMMUNITY_BASE_URL', env.VITE_APP_UI_COMMUNITY_BASE_URL, communityEnv.VITE_APP_UI_COMMUNITY_BASE_URL); + const apiHostname = requiredHostnameFromSources('VITE_COMMON_API_ENDPOINT', env.VITE_COMMON_API_ENDPOINT, communityEnv.VITE_COMMON_API_ENDPOINT); + const mockAuthHostname = requiredHostnameFromSources( + 'VITE_APP_UI_COMMUNITY_END_USER_B2C_AUTHORITY', + env.VITE_APP_UI_COMMUNITY_END_USER_B2C_AUTHORITY, + communityEnv.VITE_APP_UI_COMMUNITY_END_USER_B2C_AUTHORITY, + env.VITE_APP_UI_STAFF_STAFF_USER_AAD_AUTHORITY, + staffEnv.VITE_APP_UI_STAFF_STAFF_USER_AAD_AUTHORITY, + ); + const staffHostname = requiredHostnameFromSources( + 'VITE_APP_UI_STAFF_BASE_URL', + env.VITE_APP_UI_STAFF_BASE_URL, + staffEnv.VITE_APP_UI_STAFF_BASE_URL, + env.VITE_APP_UI_STAFF_STAFF_USER_AAD_REDIRECT_URI, + staffEnv.VITE_APP_UI_STAFF_STAFF_USER_AAD_REDIRECT_URI, + ); + + 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.test.ts b/packages/ocom/local-dev-config/src/index.test.ts new file mode 100644 index 000000000..8d1224cd0 --- /dev/null +++ b/packages/ocom/local-dev-config/src/index.test.ts @@ -0,0 +1,111 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { buildOcomUrls, getOcomHostnames } from '@ocom/local-dev-config'; +import { describe, expect, it } from 'vitest'; + +function createWorkspaceFixture(): string { + const workspaceRoot = mkdtempSync(path.join(tmpdir(), 'ocom-local-dev-config-')); + writeFileSync(path.join(workspaceRoot, 'pnpm-workspace.yaml'), 'packages:\n - "apps/*"\n'); + mkdirSync(path.join(workspaceRoot, 'apps', 'ui-community'), { recursive: true }); + mkdirSync(path.join(workspaceRoot, 'apps', 'ui-staff'), { recursive: true }); + writeFileSync( + path.join(workspaceRoot, 'apps', 'ui-community', '.env'), + [ + 'VITE_APP_UI_COMMUNITY_BASE_URL=https://ownercommunity.localhost:1355', + 'VITE_COMMON_API_ENDPOINT=https://data-access.ownercommunity.localhost:1355/api/graphql', + 'VITE_APP_UI_COMMUNITY_END_USER_B2C_AUTHORITY=https://mock-auth.ownercommunity.localhost:1355/community-end-user', + ].join('\n'), + ); + writeFileSync( + path.join(workspaceRoot, 'apps', 'ui-staff', '.env'), + [ + 'VITE_APP_UI_STAFF_BASE_URL=https://staff.ownercommunity.localhost:1355', + 'VITE_APP_UI_STAFF_STAFF_USER_AAD_AUTHORITY=https://mock-auth.ownercommunity.localhost:1355/staff-staff-user', + 'VITE_APP_UI_STAFF_STAFF_USER_AAD_REDIRECT_URI=https://staff.ownercommunity.localhost:1355/auth-redirect', + ].join('\n'), + ); + + return workspaceRoot; +} + +describe('@ocom/local-dev-config', () => { + it('resolves OCOM hostnames from app env files and applies a safe worktree suffix', () => { + const workspaceRoot = createWorkspaceFixture(); + + expect( + getOcomHostnames({ + env: { WORKTREE_NAME: 'Jason/Feature 123' }, + workspaceRoot, + }), + ).toEqual({ + uiCommunity: 'ownercommunity.jason-feature-123.localhost', + uiStaff: 'staff.ownercommunity.jason-feature-123.localhost', + api: 'data-access.ownercommunity.jason-feature-123.localhost', + mockAuth: 'mock-auth.ownercommunity.jason-feature-123.localhost', + docs: 'docs.ownercommunity.jason-feature-123.localhost', + }); + }); + + it('builds the complete OCOM local URL set for app wrapper scripts', () => { + const workspaceRoot = createWorkspaceFixture(); + + expect(buildOcomUrls({ env: {}, workspaceRoot })).toEqual({ + uiCommunityBaseUrl: 'https://ownercommunity.localhost:1355', + uiCommunityRedirectUrl: 'https://ownercommunity.localhost:1355/auth-redirect', + uiStaffBaseUrl: 'https://staff.ownercommunity.localhost:1355', + uiStaffRedirectUrl: 'https://staff.ownercommunity.localhost:1355/auth-redirect', + apiGraphqlUrl: 'https://data-access.ownercommunity.localhost:1355/api/graphql', + mockCommunityAuthorityUrl: 'https://mock-auth.ownercommunity.localhost:1355/community-end-user', + mockCommunityJwksUrl: 'https://mock-auth.ownercommunity.localhost:1355/community-end-user/.well-known/jwks.json', + mockStaffAuthorityUrl: 'https://mock-auth.ownercommunity.localhost:1355/staff-staff-user', + mockStaffJwksUrl: 'https://mock-auth.ownercommunity.localhost:1355/staff-staff-user/.well-known/jwks.json', + docsBaseUrl: 'https://docs.ownercommunity.localhost:1355', + }); + }); + + it('lets environment values override app env files', () => { + const workspaceRoot = createWorkspaceFixture(); + + expect( + getOcomHostnames({ + env: { + VITE_APP_UI_COMMUNITY_BASE_URL: 'https://community.override.localhost:1355', + VITE_COMMON_API_ENDPOINT: 'https://api.override.localhost:1355/api/graphql', + VITE_APP_UI_COMMUNITY_END_USER_B2C_AUTHORITY: 'https://auth.override.localhost:1355/community-end-user', + VITE_APP_UI_STAFF_BASE_URL: 'https://staff.override.localhost:1355', + }, + workspaceRoot, + }), + ).toEqual({ + uiCommunity: 'community.override.localhost', + uiStaff: 'staff.override.localhost', + api: 'api.override.localhost', + mockAuth: 'auth.override.localhost', + docs: 'docs.community.override.localhost', + }); + }); + + it('does not duplicate a worktree suffix when hostnames are already scoped', () => { + const workspaceRoot = createWorkspaceFixture(); + + expect( + getOcomHostnames({ + env: { + WORKTREE_NAME: 'Jason/Feature 123', + VITE_APP_UI_COMMUNITY_BASE_URL: 'https://ownercommunity.jason-feature-123.localhost:1355', + VITE_COMMON_API_ENDPOINT: 'https://data-access.ownercommunity.jason-feature-123.localhost:1355/api/graphql', + VITE_APP_UI_COMMUNITY_END_USER_B2C_AUTHORITY: 'https://mock-auth.ownercommunity.jason-feature-123.localhost:1355/community-end-user', + VITE_APP_UI_STAFF_BASE_URL: 'https://staff.ownercommunity.jason-feature-123.localhost:1355', + }, + workspaceRoot, + }), + ).toEqual({ + uiCommunity: 'ownercommunity.jason-feature-123.localhost', + uiStaff: 'staff.ownercommunity.jason-feature-123.localhost', + api: 'data-access.ownercommunity.jason-feature-123.localhost', + mockAuth: 'mock-auth.ownercommunity.jason-feature-123.localhost', + docs: 'docs.ownercommunity.jason-feature-123.localhost', + }); + }); +}); 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..c265511f7 --- /dev/null +++ b/packages/ocom/local-dev-config/src/index.ts @@ -0,0 +1,4 @@ +export { buildOcomApiLocalSettings } from './api-settings/index.ts'; +export { getOcomHostnames } from './hostnames/index.ts'; +export type { OcomHostnames, OcomLocalDevOptions, OcomUrls } from './types.ts'; +export { buildOcomUrls } from './urls/index.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..a09750de2 --- /dev/null +++ b/packages/ocom/local-dev-config/src/types.ts @@ -0,0 +1,51 @@ +/** + * Options shared by OCOM local-development config helpers. + */ +export interface OcomLocalDevOptions { + /** Environment override source. Defaults to `process.env`. */ + env?: NodeJS.ProcessEnv; + /** Workspace root to read app `.env` files from. Defaults to auto-discovery. */ + workspaceRoot?: string; +} + +/** + * Portless hostnames used by OCOM browser-facing apps and local HTTP mocks. + */ +export interface OcomHostnames { + /** Community portal hostname. */ + uiCommunity: string; + /** Staff portal hostname. */ + uiStaff: string; + /** API hostname. */ + api: string; + /** Mock authentication server hostname. */ + mockAuth: string; + /** Documentation site hostname. */ + docs: string; +} + +/** + * Fully qualified local URLs consumed by OCOM app wrappers. + */ +export interface OcomUrls { + /** Community portal base URL. */ + uiCommunityBaseUrl: string; + /** Community portal OIDC redirect URL. */ + uiCommunityRedirectUrl: string; + /** Staff portal base URL. */ + uiStaffBaseUrl: string; + /** Staff portal OIDC redirect URL. */ + uiStaffRedirectUrl: string; + /** API GraphQL endpoint URL. */ + apiGraphqlUrl: string; + /** Community OIDC issuer URL on the mock auth server. */ + mockCommunityAuthorityUrl: string; + /** Community OIDC JWKS URL on the mock auth server. */ + mockCommunityJwksUrl: string; + /** Staff OIDC issuer URL on the mock auth server. */ + mockStaffAuthorityUrl: string; + /** Staff OIDC JWKS URL on the mock auth server. */ + mockStaffJwksUrl: string; + /** Documentation site base URL. */ + docsBaseUrl: string; +} diff --git a/packages/ocom/local-dev-config/src/urls/index.ts b/packages/ocom/local-dev-config/src/urls/index.ts new file mode 100644 index 000000000..8b683fe72 --- /dev/null +++ b/packages/ocom/local-dev-config/src/urls/index.ts @@ -0,0 +1,36 @@ +import { buildPortlessUrl } from '@cellix/local-dev/urls'; +import { getOcomHostnames } from '../hostnames/index.ts'; +import type { OcomLocalDevOptions, OcomUrls } from '../types.ts'; + +/** + * Builds the full set of local URLs required by OCOM app wrapper scripts. + * + * @param options - Optional environment and workspace-root overrides forwarded + * to `getOcomHostnames`. + * @returns Portless HTTPS URLs for UI apps, API GraphQL, mock OIDC issuers, + * JWKS endpoints, and docs. + * + * @example + * ```ts + * const urls = buildOcomUrls(); + * process.env.VITE_COMMON_API_ENDPOINT = urls.apiGraphqlUrl; + * ``` + */ +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-end-user'), + mockCommunityJwksUrl: buildPortlessUrl(hostnames.mockAuth, '/community-end-user/.well-known/jwks.json'), + mockStaffAuthorityUrl: buildPortlessUrl(hostnames.mockAuth, '/staff-staff-user'), + mockStaffJwksUrl: buildPortlessUrl(hostnames.mockAuth, '/staff-staff-user/.well-known/jwks.json'), + docsBaseUrl: buildPortlessUrl(hostnames.docs), + }; +} + +export type { OcomLocalDevOptions, OcomUrls } from '../types.ts'; 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/packages/ocom/local-dev-config/tsconfig.vitest.json b/packages/ocom/local-dev-config/tsconfig.vitest.json new file mode 100644 index 000000000..fc31246f1 --- /dev/null +++ b/packages/ocom/local-dev-config/tsconfig.vitest.json @@ -0,0 +1,10 @@ +{ + "extends": ["./tsconfig.json", "@cellix/config-typescript/vitest"], + "compilerOptions": { + "paths": { + "@ocom/local-dev-config": ["./src/index.ts"], + "@cellix/local-dev": ["../../cellix/local-dev/src/index.ts"] + } + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/ocom/local-dev-config/vitest.config.ts b/packages/ocom/local-dev-config/vitest.config.ts new file mode 100644 index 000000000..44d181500 --- /dev/null +++ b/packages/ocom/local-dev-config/vitest.config.ts @@ -0,0 +1,26 @@ +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: [ + { find: '@ocom/local-dev-config/hostnames', replacement: join(dirname, 'src/hostnames/index.ts') }, + { find: '@ocom/local-dev-config/urls', replacement: join(dirname, 'src/urls/index.ts') }, + { find: '@ocom/local-dev-config', replacement: join(dirname, 'src/index.ts') }, + { find: '@cellix/local-dev/files', replacement: join(dirname, '../../cellix/local-dev/src/files/index.ts') }, + { find: '@cellix/local-dev/runners', replacement: join(dirname, '../../cellix/local-dev/src/runners/index.ts') }, + { find: '@cellix/local-dev/urls', replacement: join(dirname, '../../cellix/local-dev/src/urls/index.ts') }, + { find: '@cellix/local-dev/workspace', replacement: join(dirname, '../../cellix/local-dev/src/workspace/index.ts') }, + { find: '@cellix/local-dev/worktree', replacement: join(dirname, '../../cellix/local-dev/src/worktree/index.ts') }, + { find: '@cellix/local-dev', replacement: join(dirname, '../../cellix/local-dev/src/index.ts') }, + ], + }, +}); diff --git a/packages/ocom/persistence/src/datasources/domain/community/community/community.domain-adapter.ts b/packages/ocom/persistence/src/datasources/domain/community/community/community.domain-adapter.ts index bdc5d2536..2a238d9f6 100644 --- a/packages/ocom/persistence/src/datasources/domain/community/community/community.domain-adapter.ts +++ b/packages/ocom/persistence/src/datasources/domain/community/community/community.domain-adapter.ts @@ -1,9 +1,8 @@ import { MongooseSeedwork } from '@cellix/mongoose-seedwork'; -import { Domain } from '@ocom/domain'; - -import { EndUserDomainAdapter } from '../../user/end-user/end-user.domain-adapter.ts'; import type { Community } from '@ocom/data-sources-mongoose-models/community'; import type { EndUser } from '@ocom/data-sources-mongoose-models/user/end-user'; +import { Domain } from '@ocom/domain'; +import { EndUserDomainAdapter } from '../../user/end-user/end-user.domain-adapter.ts'; export class CommunityConverter extends MongooseSeedwork.MongoTypeConverter> { constructor() { diff --git a/packages/ocom/service-queue-storage/src/schemas/inbound/end-user-update.ts b/packages/ocom/service-queue-storage/src/schemas/inbound/end-user-update.ts index 7dd6f8bad..7753f436c 100644 --- a/packages/ocom/service-queue-storage/src/schemas/inbound/end-user-update.ts +++ b/packages/ocom/service-queue-storage/src/schemas/inbound/end-user-update.ts @@ -1,5 +1,5 @@ import { defineQueue } from '@cellix/service-queue-storage'; -import { schema as endUserUpdateSchema, type Schema as EndUserUpdatePayload } from './end-user-update.schema.generated.ts'; +import { type Schema as EndUserUpdatePayload, schema as endUserUpdateSchema } from './end-user-update.schema.generated.ts'; export type { EndUserUpdatePayload }; diff --git a/packages/ocom/ui-staff-route-user-management/.storybook/main.ts b/packages/ocom/ui-staff-route-user-management/.storybook/main.ts index 055dca4e5..89a44ade1 100644 --- a/packages/ocom/ui-staff-route-user-management/.storybook/main.ts +++ b/packages/ocom/ui-staff-route-user-management/.storybook/main.ts @@ -1,6 +1,9 @@ +import { createRequire } from 'node:module'; import { dirname, join } from 'node:path'; import type { StorybookConfig } from '@storybook/react-vite'; +const require = createRequire(import.meta.url); + function getAbsolutePath(value: string) { return dirname(require.resolve(join(value, 'package.json'))); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf48ef53c..786a30593 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,6 +15,9 @@ catalogs: '@azure/functions': specifier: 4.11.0 version: 4.11.0 + '@azure/storage-blob': + specifier: 12.31.0 + version: 12.31.0 '@cucumber/cucumber': specifier: 12.8.1 version: 12.8.1 @@ -129,9 +132,7 @@ overrides: jiti: 2.6.1 rollup: ^4.59.0 '@ant-design/pro-layout>path-to-regexp': ^8.4.0 - brace-expansion@1.1.12: 1.1.13 - brace-expansion@5.0.4: 5.0.6 - brace-expansion@5.0.5: 5.0.6 + brace-expansion@5: 5.0.7 diff@4.0.2: 4.0.4 '@protobufjs/codegen': 2.0.5 '@protobufjs/utf8': 1.1.1 @@ -341,6 +342,12 @@ 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.8(vitest@4.1.8) @@ -396,6 +403,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.10.1 version: 3.10.1(esbuild@0.28.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -454,12 +464,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 @@ -469,6 +479,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 @@ -479,15 +492,15 @@ importers: '@cellix/config-vitest': specifier: workspace:* version: link:../../packages/cellix/config-vitest + '@cellix/local-dev': + specifier: workspace:* + version: link:../../packages/cellix/local-dev '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.8(vitest@4.1.8) rimraf: specifier: 'catalog:' version: 6.0.1 - tsx: - specifier: 'catalog:' - version: 4.21.0 typescript: specifier: 'catalog:' version: 6.0.3 @@ -509,6 +522,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 @@ -549,6 +565,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: ^5.2.1 version: 5.2.1(storybook@10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)) @@ -627,6 +646,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 @@ -676,6 +698,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 @@ -910,6 +935,30 @@ importers: specifier: 'catalog:' version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(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.8(vitest@4.1.8) + rimraf: + specifier: 'catalog:' + version: 6.0.1 + typescript: + specifier: 'catalog:' + version: 6.0.3 + vitest: + specifier: 'catalog:' + version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.15)(esbuild@0.28.1)(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': @@ -1082,7 +1131,7 @@ importers: specifier: ^4.13.1 version: 4.13.1 '@azure/storage-blob': - specifier: ^12.31.0 + specifier: 'catalog:' version: 12.31.0 '@cellix/api-services-spec': specifier: workspace:* @@ -1320,7 +1369,7 @@ importers: specifier: ^6.0.1 version: 6.1.2 tsx: - specifier: ^4.20.3 + specifier: 'catalog:' version: 4.21.0 typescript: specifier: 'catalog:' @@ -1393,7 +1442,7 @@ importers: specifier: ^10.1.3 version: 10.1.3 tsx: - specifier: ^4.20.3 + specifier: 'catalog:' version: 4.21.0 typescript: specifier: 'catalog:' @@ -1450,9 +1499,15 @@ importers: '@cellix/config-typescript': specifier: workspace:* version: link:../../cellix/config-typescript + '@cellix/local-dev': + specifier: workspace:* + version: link:../../cellix/local-dev '@ocom-verification/verification-shared': specifier: workspace:* version: link:../verification-shared + '@ocom/local-dev-config': + specifier: workspace:* + version: link:../../ocom/local-dev-config '@ocom/service-queue-storage': specifier: workspace:* version: link:../../ocom/service-queue-storage @@ -1466,7 +1521,7 @@ importers: specifier: ^6.0.1 version: 6.1.2 tsx: - specifier: ^4.20.3 + specifier: 'catalog:' version: 4.21.0 typescript: specifier: 'catalog:' @@ -1762,6 +1817,34 @@ importers: specifier: 'catalog:' version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(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 + '@cellix/config-vitest': + specifier: workspace:* + version: link:../../cellix/config-vitest + '@types/node': + specifier: 'catalog:' + version: 22.19.15 + '@vitest/coverage-istanbul': + specifier: 'catalog:' + version: 4.1.8(vitest@4.1.8) + rimraf: + specifier: 'catalog:' + version: 6.0.1 + typescript: + specifier: 'catalog:' + version: 6.0.3 + vitest: + specifier: 'catalog:' + version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.15)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + packages/ocom/persistence: dependencies: '@cellix/domain-seedwork': @@ -7824,8 +7907,8 @@ packages: brace-expansion@2.0.3: resolution: {integrity: sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==} - brace-expansion@5.0.6: - resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + brace-expansion@5.0.7: + resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} engines: {node: 18 || 20 || >=22} braces@3.0.3: @@ -10839,6 +10922,10 @@ packages: resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} engines: {node: 18 || 20 || >=22} + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} @@ -19456,7 +19543,7 @@ snapshots: '@ts-morph/common@0.28.1': dependencies: - minimatch: 10.2.4 + minimatch: 10.2.5 path-browserify: 1.0.1 tinyglobby: 0.2.17 @@ -20615,7 +20702,7 @@ snapshots: dependencies: balanced-match: 1.0.2 - brace-expansion@5.0.6: + brace-expansion@5.0.7: dependencies: balanced-match: 4.0.4 @@ -24189,7 +24276,11 @@ snapshots: minimatch@10.2.4: dependencies: - brace-expansion: 5.0.6 + brace-expansion: 5.0.7 + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.7 minimatch@3.1.5: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a5e8c4715..470e82a01 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -80,9 +80,7 @@ overrides: jiti: 2.6.1 rollup: ^4.59.0 '@ant-design/pro-layout>path-to-regexp': ^8.4.0 - 'brace-expansion@1.1.12': 1.1.13 - 'brace-expansion@5.0.4': 5.0.6 - 'brace-expansion@5.0.5': 5.0.6 + 'brace-expansion@5': 5.0.7 'diff@4.0.2': 4.0.4 '@protobufjs/codegen': 2.0.5 '@protobufjs/utf8': 1.1.1 diff --git a/readme.md b/readme.md index 345a2fcb3..570439ab0 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 93ffcd945..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_END_USER_B2C_AUTHORITY', uiEnv); - const uiStaff = hostnameFor('VITE_APP_UI_STAFF_STAFF_USER_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 3d7d2ad33..000000000 --- a/scripts/local-dev/worktree-ports.mjs +++ /dev/null @@ -1,131 +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('AZURE_STORAGE_ACCOUNT_NAME', values); - const connectionString = getSetting('AZURE_STORAGE_CONNECTION_STRING', values); - const accountKey = connectionString ? /(?:^|;)AccountKey=([^;]+)/.exec(connectionString)?.[1] : undefined; - if (!accountName || !accountKey) { - throw new Error('[worktree-ports] AZURE_STORAGE_ACCOUNT_NAME and an AZURE_STORAGE_CONNECTION_STRING containing AccountKey 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 d02ad968a..958f0dc01 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -86,7 +86,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 3a716dd69..48fa3ab13 100644 --- a/turbo.json +++ b/turbo.json @@ -34,7 +34,7 @@ "test": { "description": "Runs the default test suite for each package", "dependsOn": ["^build", "@cellix/config-vitest#build"], - "inputs": ["$TURBO_DEFAULT$", "**/*.test.ts", "**/*.stories.tsx", "!coverage/**", "!target/**", "!dist/**", "!build/**", "!deploy/**"] + "inputs": ["$TURBO_DEFAULT$", "**/*.test.ts", "**/*.stories.tsx", "!coverage/**", "!target/**", "!dist/**", "!build/**", "!deploy/**"] }, "test:arch": { "description": "Runs architecture unit tests for each package", @@ -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 },