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
},