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