diff --git a/.snyk b/.snyk index 78207903f..4ce123d5c 100644 --- a/.snyk +++ b/.snyk @@ -47,11 +47,6 @@ ignore: reason: 'Transitive dependency in @azure/functions; upgrade path has type errors' expires: '2026-07-28T00:00:00.000Z' created: '2026-01-15T11:04:00.000Z' - 'SNYK-JS-YAUZL-15467445': - - '* > yauzl@<3.2.1': - reason: 'Transitive dependency in @mongodb-memory-server; not exploitable in current usage.' - expires: '2026-03-26T00:00:00.000Z' - created: '2026-03-12T12:35:00.000Z' 'SNYK-JS-UUID-16133035': - '* > uuid@8.3.2': reason: 'Transitive dependency in Docusaurus and Azurite dev-only stacks; no compatible fix path available yet from upstream.' @@ -71,11 +66,6 @@ ignore: reason: 'Mongoose 8.22.1 has TypeScript constraint errors in type definitions (types/inferrawdoctype.d.ts, types/inferschematype.d.ts) that break compilation. Patch attempts failed due to external library type incompatibilities. Risk is Low: requires control of query field names and values.' expires: '2026-11-07T00:00:00.000Z' created: '2026-05-07T09:00:00.000Z' - 'SNYK-JS-BABELPLUGINTRANSFORMMODULESSYSTEMJS-16624576': - - '* > @babel/plugin-transform-modules-systemjs@7.28.5': - reason: 'Transitive dependency in Docusaurus; not exploitable in current usage.' - expires: '2026-06-28T00:00:00.000Z' - created: '2026-05-11T10:00:00.000Z' 'SNYK-JS-AI-16734889': - '@docusaurus/preset-classic@3.10.1 > * > ai@5.0.105': reason: 'Transitive dependency in Docusaurus docsearch; Snyk reports no fixed upgrade or patch available.' @@ -101,11 +91,6 @@ ignore: reason: 'Transitive dependency in Docusaurus CSS optimization/build tooling; Snyk reports no fixed upgrade or patch available. Not exploitable at runtime because docs CSS is repository-controlled and processed at build time.' expires: '2026-09-18T00:00:00.000Z' created: '2026-06-08T00:00:00.000Z' - 'SNYK-JS-SHELLQUOTE-17457810': - - '* > shell-quote@1.8.4': - reason: 'Transitive dependency in @docusaurus/core via webpack-dev-server > launch-editor; fixed in shell-quote@1.9.0 but no direct upgrade path available. Dev-time only; not exploitable in current usage.' - expires: '2026-12-31T00:00:00.000Z' - created: '2026-06-25T00:00:00.000Z' 'SNYK-JS-IMAGESIZE-17295814': - '* > image-size@<=2.0.2': reason: 'Transitive dependency in vitest@4.1.6; not exploitable in current usage.' diff --git a/apps/server-oauth2-mock/src/index.ts b/apps/server-oauth2-mock/src/index.ts index a29ab59c6..e594c99c5 100644 --- a/apps/server-oauth2-mock/src/index.ts +++ b/apps/server-oauth2-mock/src/index.ts @@ -1,6 +1,6 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { createMockOAuth2Manager, type MockOAuth2PortalConfig, normalizeBaseUrl, discoverPortalConfigs, type PortalOidcConfig, ensurePortInUrl, createFileUserStore } from '@cellix/server-oauth2-mock-seedwork'; +import { createFileUserStore, createMockOAuth2Manager, discoverPortalConfigs, ensurePortInUrl, type MockOAuth2PortalConfig, normalizeBaseUrl, type PortalOidcConfig } from '@cellix/server-oauth2-mock-seedwork'; import { setupEnvironment } from './setup-environment.ts'; setupEnvironment(); diff --git a/apps/ui-community/mock-oidc.users.json b/apps/ui-community/mock-oidc.users.json index ade0b4f42..f5518cba9 100644 --- a/apps/ui-community/mock-oidc.users.json +++ b/apps/ui-community/mock-oidc.users.json @@ -3,7 +3,7 @@ "username": "test@example.com", "sub": "00000000-0000-4000-8000-000000000001", "password": "password", - "oidcConfigName": "end-user", + "oidcConfigName": "end-user", "claims": { "email": "test@example.com", "given_name": "Test", @@ -15,7 +15,7 @@ "username": "john.doe@example.com", "sub": "00000000-0000-4000-8000-000000000002", "password": "password", - "oidcConfigName": "end-user", + "oidcConfigName": "end-user", "claims": { "email": "john.doe@example.com", "given_name": "John", @@ -27,7 +27,7 @@ "username": "owner@test.example", "sub": "aaaaaaaa-bbbb-1ccc-9ddd-eeeeeeeeee01", "password": "password", - "oidcConfigName": "end-user", + "oidcConfigName": "end-user", "claims": { "email": "owner@test.example", "given_name": "Test", diff --git a/apps/ui-staff/mock-oidc.users.json b/apps/ui-staff/mock-oidc.users.json index 6b635dfb4..1c753ea7c 100644 --- a/apps/ui-staff/mock-oidc.users.json +++ b/apps/ui-staff/mock-oidc.users.json @@ -3,7 +3,7 @@ "username": "staff@ownercommunity.onmicrosoft.com", "sub": "10000000-0000-4000-8000-000000000001", "password": "password", - "oidcConfigName": "staff-user", + "oidcConfigName": "staff-user", "claims": { "email": "staff@ownercommunity.onmicrosoft.com", "given_name": "Staff", diff --git a/biome.json b/biome.json index 9da5b4793..2970dcc8b 100644 --- a/biome.json +++ b/biome.json @@ -9,7 +9,7 @@ "!**/dist", "!**/build", "!**/deploy", - "!**/target", + "!**/target", "!**/node_modules", "!**/coverage", "!**/.turbo", diff --git a/build-pipeline/scripts/verify.ts b/build-pipeline/scripts/verify.ts new file mode 100644 index 000000000..e630e08b0 --- /dev/null +++ b/build-pipeline/scripts/verify.ts @@ -0,0 +1,37 @@ +#!/usr/bin/env node +/// + +import { architectureTests, coverageMerge, e2eTests, knipCheck, pnpmAudit, pnpmScript, snykCodeScan, snykDependencyScan, sonarPullRequestAnalysis, sonarQualityGate, verificationSequence } from '@cellix/local-dev/silent-runners'; + +const snykOrgArgs = ['--org=cellixjs', '--remote-repo-url=https://github.com/CellixJs/cellixjs']; + +const cellixVerify = verificationSequence + .addStep(pnpmScript('format:check')) + .addStep(architectureTests()) + .addStep(coverageMerge()) + .addStep(e2eTests()) + .addStep(knipCheck()) + .addStep(pnpmAudit({ auditLevel: 'high', dependencyType: 'prod', name: 'audit:prod' })) + .addStep(pnpmAudit({ auditLevel: 'critical', dependencyType: 'dev', name: 'audit:dev' })) + .addStep( + snykDependencyScan({ + args: [...snykOrgArgs, '--policy-path=.snyk', '--file=package.json', '--severity-threshold=high'], + }), + ) + .addStep( + snykCodeScan({ + args: snykOrgArgs, + }), + ) + .addStep(sonarPullRequestAnalysis()) + .addStep(sonarQualityGate()); + +function runVerifyCommand(): void { + const result = cellixVerify.run(); + if (result.status === 0) { + process.stdout.write('verify passed\n'); + } + process.exitCode = result.status; +} + +runVerifyCommand(); diff --git a/knip.json b/knip.json index c96fff069..d30847b3b 100644 --- a/knip.json +++ b/knip.json @@ -1,6 +1,10 @@ { "$schema": "https://unpkg.com/knip@5/schema.json", "workspaces": { + ".": { + "entry": ["build-pipeline/scripts/verify.ts"], + "project": ["build-pipeline/scripts/**/*.ts"] + }, "apps/api": { "entry": ["src/index.ts", "start-*.mjs"], "project": ["src/**/*.ts", "*.mjs"], @@ -82,7 +86,7 @@ "entry": ["cucumber.js", "src/world.ts", "src/step-definitions/index.ts", "src/shared/abilities/index.ts"], "project": ["src/**/*.ts"], "ignoreBinaries": ["report"], - "ignoreUnresolved": ["progress-bar"] + "ignoreUnresolved": ["progress-bar"] }, "packages/ocom-verification/acceptance-ui": { "entry": ["cucumber.js", "src/world.ts", "src/step-definitions/index.ts"], @@ -93,7 +97,7 @@ "packages/ocom-verification/e2e-tests": { "entry": ["cucumber.js", "src/world.ts", "src/contexts/**/step-definitions/**/*.steps.ts", "src/shared/environment/**/*.ts", "src/shared/abilities/**/*.ts"], "project": ["src/**/*.ts"], - "ignoreUnresolved": ["progress-bar"] + "ignoreUnresolved": ["progress-bar"] }, "apps/server-oauth2-mock": { "entry": ["src/index.ts"], diff --git a/package.json b/package.json index 3314071b1..0b6a2538b 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "sonar:pr": "export PR_NUMBER=$(node build-pipeline/scripts/get-pr-number.cjs) && sonar-scanner -Dsonar.pullrequest.key=$PR_NUMBER -Dsonar.pullrequest.branch=$(git branch --show-current) -Dsonar.pullrequest.base=main", "sonar:pr-windows": "for /f %i in ('node build-pipeline/scripts/get-pr-number.cjs') do set PR_NUMBER=%i && sonar-scanner -Dsonar.pullrequest.key=%PR_NUMBER% -Dsonar.pullrequest.branch=%BRANCH_NAME% -Dsonar.pullrequest.base=main", "check-sonar": "node build-pipeline/scripts/check-sonar-quality-gate.cjs", - "verify": "pnpm run format:check && pnpm run test:arch && pnpm run test:coverage:merge && pnpm run test:e2e:worktree && pnpm run knip && pnpm run audit && pnpm run snyk", + "verify": "node --conditions=source build-pipeline/scripts/verify.ts", "knip": "knip", "snyk": "pnpm run snyk:test && pnpm run snyk:code", "snyk:report": "pnpm run snyk:monitor && pnpm run snyk:code:report", @@ -73,6 +73,7 @@ "@ant-design/cli": "^6.3.5", "@biomejs/biome": "2.4.10", "@cellix/graphql-codegen": "workspace:*", + "@cellix/local-dev": "workspace:*", "@graphql-codegen/cli": "^5.0.7", "@graphql-codegen/introspection": "^4.0.3", "@graphql-codegen/typed-document-node": "^5.1.2", 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..c0873af1d --- /dev/null +++ b/packages/cellix/local-dev/README.md @@ -0,0 +1,119 @@ +# @cellix/local-dev + +Local developer wrappers for Cellix verification commands. + +This package is intentionally small on this branch. Other pull requests add +additional local-development modules to the same package; this change owns only +the silent command runner export and its portable tool wrappers. + +## Install + +In this monorepo, consumers use the workspace package directly: + +```json +{ + "devDependencies": { + "@cellix/local-dev": "workspace:*" + } +} +``` + +## Silent Runners + +`runSilentCommand` captures stdout and stderr while a command is running. If the +command succeeds, nothing is printed. If it fails, whatever the command wrote to +stdout and stderr is replayed before the failing status is returned. + +```js +import { runSilentCommand } from '@cellix/local-dev/silent-runners'; + +const result = runSilentCommand({ + command: 'snyk', + args: ['test', '--all-projects'], +}); + +process.exitCode = result.status; +``` + +Use `runSilentCommandSequence` when a wrapper needs to run several commands in +order. Steps are silent by default; mark a step with `output: 'inherit'` only +when its live output is part of the intended consumer experience. + +```js +import { runSilentCommandSequence } from '@cellix/local-dev/silent-runners'; + +const result = runSilentCommandSequence({ + steps: [ + { name: 'format:check', command: 'pnpm', args: ['run', 'format:check'] }, + { name: 'test:e2e', command: 'pnpm', args: ['run', 'test:e2e'] }, + ], +}); + +process.exitCode = result.status; +``` + +For reusable verification workflows, use the fluent sequence builder: + +```js +import { pnpmScript, verificationSequence } from '@cellix/local-dev/silent-runners'; + +const verify = verificationSequence + .addStep(pnpmScript('format:check')) + .addStep(pnpmScript('test')); + +const result = verify.run(); +process.exitCode = result.status; +``` + +Prefer the named tool wrappers when a command has a known CLI shape: + +```js +import { knipCheck, pnpmAudit, runSilentCommandSequence, snykCodeScan, snykDependencyScan } from '@cellix/local-dev/silent-runners'; + +const result = runSilentCommandSequence({ + steps: [ + knipCheck(), + pnpmAudit({ auditLevel: 'high', dependencyType: 'prod' }), + snykDependencyScan({ args: ['--all-projects', '--org=my-org'] }), + snykCodeScan({ args: ['--org=my-org'] }), + ], +}); +``` + +## Public API + +Exports are available from `@cellix/local-dev` and +`@cellix/local-dev/silent-runners`: + +- `runSilentCommand` +- `runSilentCommandSequence` +- `CommandOutputMode` +- `CommandSequenceStep` +- `architectureTests` +- `coverageMerge` +- `e2eTests` +- `knipCheck` +- `livePnpmScript` +- `pnpmAudit` +- `pnpmScript` +- `SilentCommandOptions` +- `SilentCommandResult` +- `SilentCommandSequenceOptions` +- `SilentCommandSequenceResult` +- `SilentRunnerSpawnSync` +- `SilentRunnerStreams` +- `snykCodeScan` +- `snykDependencyScan` +- `snykIacScan` +- `sonarPullRequestAnalysis` +- `sonarQualityGate` +- `VerificationSequence` +- `VerificationSequenceOptions` +- `verificationSequence` + +## Notes + +- Tool wrappers encode reusable CLI shape; scripts still own project-specific arguments such as org names, paths, and CI policy. +- Success is silent; failure replays whatever the command wrote to stdout/stderr. +- Captured output defaults to 64 MiB and can be adjusted with `maxBuffer` for a command, sequence, or individual sequence step. +- Commands run without shell interpolation. Pass the executable as `command` and arguments as `args`. diff --git a/packages/cellix/local-dev/manifest.md b/packages/cellix/local-dev/manifest.md new file mode 100644 index 000000000..ddc6b4e24 --- /dev/null +++ b/packages/cellix/local-dev/manifest.md @@ -0,0 +1,75 @@ +# manifest.md - @cellix/local-dev + +## Purpose + +Provide local developer wrappers for Cellix commands that should be quiet on +success and diagnostic on failure. + +## Scope + +This branch owns only the silent verification runner surface and portable tool +wrappers. Other local-dev modules from related pull requests will share this +package after the branches are merged. + +## Non-goals + +- Project-specific Snyk, Edgescan, or scanner policy such as org names, + repository URLs, token handling, or report publishing +- Worktree ports, app dev-server runners, URL transforms, or settings sync + +## Public API shape + +Published entrypoints: + +- `@cellix/local-dev` +- `@cellix/local-dev/silent-runners` + +Root entrypoint exports: + +- `runSilentCommand(options)` +- `runSilentCommandSequence(options)` +- `VerificationSequence` +- `verificationSequence` +- tool-wrapper builders such as `knipCheck()`, `pnpmAudit(options)`, + `snykDependencyScan(options)`, `snykCodeScan(options)`, and + `sonarPullRequestAnalysis(options)` + +## Core concepts + +- Silent verification runners capture external command output, emit nothing on + success, and replay whatever the command wrote to stdout/stderr on failure. +- Silent command sequences run ordered verification steps, defaulting each step + to silent output while allowing explicit passthrough where live output is + intentionally part of the command contract. +- Fluent verification sequences provide a reusable `.addStep(...).run(...)` + object API over the same ordered, stop-on-failure behavior. +- Tool wrappers encode portable CLI shapes; root or app scripts own + project-specific arguments. +- Commands are spawned without shell interpolation. + +## Package boundaries + +- Do not encode project-specific Snyk, Edgescan, or scanner policy here. +- Keep this branch scoped to the silent runner module so it can merge cleanly + with other `@cellix/local-dev` modular exports. + +## Dependencies / relationships + +- Downstream consumers in this monorepo: the root `verify` script. + +## Testing strategy + +- Public-entrypoint tests prove success output is suppressed, failure output is + replayed, and the root verify sequence uses the expected tool wrappers while + remaining fully silent on success. + +## Documentation obligations + +- Keep README.md consumer-facing and focused on silent runners until the broader + local-dev package exports merge. +- Keep TSDoc aligned on the public runner API. + +## Release-readiness standards + +- Package build and package tests must pass. +- Root verification scripts should preserve their existing command arguments. diff --git a/packages/cellix/local-dev/package.json b/packages/cellix/local-dev/package.json new file mode 100644 index 000000000..b54978a76 --- /dev/null +++ b/packages/cellix/local-dev/package.json @@ -0,0 +1,44 @@ +{ + "name": "@cellix/local-dev", + "version": "1.0.0", + "description": "Local developer wrappers for Cellix verification commands", + "type": "module", + "files": [ + "dist", + "src" + ], + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "source": "./src/index.ts", + "default": "./dist/index.js" + }, + "./silent-runners": { + "types": "./dist/silent-runners/index.d.ts", + "source": "./src/silent-runners/index.ts", + "default": "./dist/silent-runners/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/index.test.ts b/packages/cellix/local-dev/src/index.test.ts new file mode 100644 index 000000000..b0b3827fb --- /dev/null +++ b/packages/cellix/local-dev/src/index.test.ts @@ -0,0 +1,300 @@ +import type { SpawnSyncOptionsWithStringEncoding, SpawnSyncReturns } from 'node:child_process'; +import { + architectureTests, + coverageMerge, + e2eTests, + knipCheck, + pnpmAudit, + pnpmScript, + runSilentCommand, + runSilentCommandSequence, + type SilentRunnerSpawnSync, + snykCodeScan, + snykDependencyScan, + sonarPullRequestAnalysis, + sonarQualityGate, + verificationSequence, +} from '@cellix/local-dev/silent-runners'; +import { describe, expect, it } from 'vitest'; + +function createVerifySpawn(options: { failStep?: string } = {}) { + const calls: Array<{ args: string[]; command: string; stdio: SpawnSyncOptionsWithStringEncoding['stdio'] }> = []; + const spawn = (command: string, args: string[], spawnOptions: SpawnSyncOptionsWithStringEncoding): SpawnSyncReturns => { + const stepName = getStepName(command, args); + calls.push({ + args, + command, + stdio: spawnOptions.stdio, + }); + + return { + output: [`${stepName} stdout`, '', ''], + pid: 123, + signal: null, + status: options.failStep === stepName ? 9 : 0, + stderr: `${stepName} stderr\n`, + stdout: `${stepName} stdout\n`, + }; + }; + + return { calls, spawn }; +} + +function getStepName(command: string, args: string[]): string { + if (command === 'pnpm' && args[0] === 'run' && args[1]) { + return args[1]; + } + + if (command === 'pnpm' && args[0] === 'audit') { + return args.includes('--prod') ? 'audit:prod' : 'audit:dev'; + } + + if (command === 'pnpm' && args[0] === 'exec' && args[1] === 'knip') { + return 'knip'; + } + + if (command === 'pnpm' && args[0] === 'exec' && args[1] === 'snyk' && args[2] === 'test') { + return 'snyk:test'; + } + + if (command === 'pnpm' && args[0] === 'exec' && args[1] === 'snyk' && args[2] === 'code' && args[3] === 'test') { + return 'snyk:code'; + } + + return command; +} + +describe('@cellix/local-dev/silent-runners', () => { + it('keeps successful local verification commands silent', () => { + const output = { + stderr: '', + stdout: '', + }; + const spawn: SilentRunnerSpawnSync = (command, args, options) => { + expect(command).toBe('snyk'); + expect(args).toEqual(['test']); + expect(options.maxBuffer).toBe(64 * 1024 * 1024); + expect(options.stdio).toBe('pipe'); + return { + output: ['tool output', '', ''], + pid: 123, + signal: null, + status: 0, + stderr: 'hidden success stderr', + stdout: 'hidden success stdout', + }; + }; + + const result = runSilentCommand({ + args: ['test'], + command: 'snyk', + spawn, + streams: { + stderr: { + write: (chunk: string) => { + output.stderr += chunk; + return true; + }, + }, + stdout: { + write: (chunk: string) => { + output.stdout += chunk; + return true; + }, + }, + }, + }); + + expect(result).toMatchObject({ + status: 0, + stderr: 'hidden success stderr', + stdout: 'hidden success stdout', + }); + expect(output).toEqual({ + stderr: '', + stdout: '', + }); + }); + + it('replays local verification command output when a command fails', () => { + const output = { + stderr: '', + stdout: '', + }; + const spawn: SilentRunnerSpawnSync = () => ({ + output: ['tool output', '', ''], + pid: 123, + signal: null, + status: 2, + stderr: 'failure stderr', + stdout: 'failure stdout', + }); + + const result = runSilentCommand({ + args: ['code', 'test'], + command: 'snyk', + spawn, + streams: { + stderr: { + write: (chunk: string) => { + output.stderr += chunk; + return true; + }, + }, + stdout: { + write: (chunk: string) => { + output.stdout += chunk; + return true; + }, + }, + }, + }); + + expect(result.status).toBe(2); + expect(output).toEqual({ + stderr: '\nCommand failed (exit 2): snyk code test\n\nfailure stderr', + stdout: 'failure stdout', + }); + }); + + it('allows callers to configure the captured output buffer', () => { + const observedMaxBuffers: Array = []; + const spawn: SilentRunnerSpawnSync = (_command, _args, options) => { + observedMaxBuffers.push(options.maxBuffer); + return { + output: ['', '', ''], + pid: 123, + signal: null, + status: 0, + stderr: '', + stdout: '', + }; + }; + + runSilentCommand({ + command: 'large-output-tool', + maxBuffer: 128 * 1024 * 1024, + spawn, + }); + runSilentCommandSequence({ + maxBuffer: 96 * 1024 * 1024, + spawn, + steps: [ + { command: 'first-tool', name: 'first-tool' }, + { command: 'second-tool', maxBuffer: 160 * 1024 * 1024, name: 'second-tool' }, + ], + }); + + expect(observedMaxBuffers).toEqual([128 * 1024 * 1024, 96 * 1024 * 1024, 160 * 1024 * 1024]); + }); + + it('keeps the successful verify sequence fully silent', () => { + const { calls, spawn } = createVerifySpawn(); + const output = { stderr: '', stdout: '' }; + const steps = [ + pnpmScript('format:check'), + architectureTests(), + coverageMerge(), + e2eTests(), + knipCheck(), + pnpmAudit({ auditLevel: 'high', dependencyType: 'prod', name: 'audit:prod' }), + pnpmAudit({ auditLevel: 'critical', dependencyType: 'dev', name: 'audit:dev' }), + snykDependencyScan({ args: ['--all-projects'] }), + snykCodeScan(), + sonarPullRequestAnalysis(), + sonarQualityGate(), + ]; + + const result = runSilentCommandSequence({ + spawn, + steps, + streams: { + stderr: { + write: (chunk: string) => { + output.stderr += chunk; + return true; + }, + }, + stdout: { + write: (chunk: string) => { + output.stdout += chunk; + return true; + }, + }, + }, + }); + + expect(result.status).toBe(0); + expect(output).toEqual({ stderr: '', stdout: '' }); + expect(calls.map((call) => getStepName(call.command, call.args))).toEqual(steps.map((step) => step.name)); + expect(calls).toEqual( + steps.map((step) => ({ + args: step.args ?? [], + command: step.command, + stdio: 'pipe', + })), + ); + }); + + it('replays output from the failing silent verify step and stops before later work', () => { + const { calls, spawn } = createVerifySpawn({ failStep: 'snyk:code' }); + const output = { stderr: '', stdout: '' }; + const steps = [ + pnpmScript('format:check'), + architectureTests(), + coverageMerge(), + e2eTests(), + knipCheck(), + pnpmAudit({ auditLevel: 'high', dependencyType: 'prod', name: 'audit:prod' }), + pnpmAudit({ auditLevel: 'critical', dependencyType: 'dev', name: 'audit:dev' }), + snykDependencyScan({ args: ['--all-projects'] }), + snykCodeScan(), + sonarPullRequestAnalysis(), + sonarQualityGate(), + ]; + + const result = runSilentCommandSequence({ + spawn, + steps, + streams: { + stderr: { + write: (chunk: string) => { + output.stderr += chunk; + return true; + }, + }, + stdout: { + write: (chunk: string) => { + output.stdout += chunk; + return true; + }, + }, + }, + }); + + expect(result.status).toBe(9); + expect(result.step.name).toBe('snyk:code'); + expect(output).toEqual({ + stderr: '\nCommand failed (exit 9): pnpm exec snyk code test\n\nsnyk:code stderr\n', + stdout: 'snyk:code stdout\n', + }); + expect(calls.map((call) => getStepName(call.command, call.args))).toEqual(['format:check', 'test:arch', 'test:coverage:merge', 'test:e2e', 'knip', 'audit:prod', 'audit:dev', 'snyk:test', 'snyk:code']); + }); + + it('builds and runs reusable verification sequences with a fluent addStep API', () => { + const { calls, spawn } = createVerifySpawn(); + const sequence = verificationSequence.addStep(pnpmScript('format:check')).addStep(architectureTests()); + + const result = sequence.run({ spawn }); + + expect(result.status).toBe(0); + expect(calls.map((call) => getStepName(call.command, call.args))).toEqual(['format:check', 'test:arch']); + }); + + it('keeps the shared verification sequence immutable', () => { + const configuredSequence = verificationSequence.addStep(pnpmScript('format:check')); + + expect(() => verificationSequence.run()).toThrow('runSilentCommandSequence requires at least one step'); + expect(configuredSequence).not.toBe(verificationSequence); + }); +}); diff --git a/packages/cellix/local-dev/src/index.ts b/packages/cellix/local-dev/src/index.ts new file mode 100644 index 000000000..2a937b8c2 --- /dev/null +++ b/packages/cellix/local-dev/src/index.ts @@ -0,0 +1,31 @@ +export { + architectureTests, + type CommandOutputMode, + type CommandSequenceStep, + coverageMerge, + e2eTests, + knipCheck, + livePnpmScript, + type PnpmAuditOptions, + type PnpmScriptOptions, + pnpmAudit, + pnpmScript, + runSilentCommand, + runSilentCommandSequence, + type SilentCommandOptions, + type SilentCommandResult, + type SilentCommandSequenceOptions, + type SilentCommandSequenceResult, + type SilentRunnerSpawnSync, + type SilentRunnerStreams, + type SnykScanOptions, + type SonarScriptOptions, + snykCodeScan, + snykDependencyScan, + snykIacScan, + sonarPullRequestAnalysis, + sonarQualityGate, + VerificationSequence, + type VerificationSequenceOptions, + verificationSequence, +} from './silent-runners/index.ts'; diff --git a/packages/cellix/local-dev/src/silent-runners/index.ts b/packages/cellix/local-dev/src/silent-runners/index.ts new file mode 100644 index 000000000..5b5685b5a --- /dev/null +++ b/packages/cellix/local-dev/src/silent-runners/index.ts @@ -0,0 +1,326 @@ +import { type SpawnSyncOptionsWithStringEncoding, type SpawnSyncReturns, spawnSync } from 'node:child_process'; + +const DEFAULT_MAX_BUFFER_BYTES = 64 * 1024 * 1024; + +export { knipCheck } from './knip.ts'; +export { livePnpmScript, type PnpmScriptOptions, pnpmScript } from './pnpm.ts'; +export { type PnpmAuditOptions, pnpmAudit } from './pnpm-audit.ts'; +export { type SnykScanOptions, snykCodeScan, snykDependencyScan, snykIacScan } from './snyk.ts'; +export { type SonarScriptOptions, sonarPullRequestAnalysis, sonarQualityGate } from './sonar.ts'; +export { architectureTests, coverageMerge, e2eTests } from './test-runners.ts'; + +/** + * Spawn function used by silent command runners. + * + * Consumers usually rely on the default `child_process.spawnSync`; tests and + * advanced wrappers can inject this to observe command execution without + * running the external tool. + */ +export type SilentRunnerSpawnSync = (command: string, args: string[], options: SpawnSyncOptionsWithStringEncoding) => SpawnSyncReturns; + +/** + * Writable stream surface used when replaying captured output after a command + * fails. + */ +export interface SilentRunnerStreams { + /** Stream that receives captured stdout when the command fails. */ + stdout?: Pick; + /** Stream that receives captured stderr when the command fails. */ + stderr?: Pick; +} + +/** + * Options for running an external command silently until failure. + */ +export interface SilentCommandOptions { + /** Executable name. */ + command: string; + /** Command-line arguments passed without shell interpolation. */ + args?: string[]; + /** Working directory for the command. Defaults to the current process cwd. */ + cwd?: string; + /** Environment to pass to the command. Defaults to the current process env. */ + env?: NodeJS.ProcessEnv; + /** Maximum captured output in bytes. Defaults to 64 MiB. */ + maxBuffer?: number; + /** Process spawner used by tests and advanced consumers. Defaults to `child_process.spawnSync`. */ + spawn?: SilentRunnerSpawnSync; + /** Streams used when replaying captured failure output. Defaults to `process.stdout` and `process.stderr`. */ + streams?: SilentRunnerStreams; +} + +/** + * How a command in a verification sequence should handle output. + */ +export type CommandOutputMode = 'silent' | 'inherit'; + +/** + * One command in a verification sequence. + */ +export interface CommandSequenceStep { + /** Human-readable step name for tests and orchestration logs. */ + name: string; + /** Executable name. */ + command: string; + /** Command-line arguments passed without shell interpolation. */ + args?: string[]; + /** Output policy for this step. Defaults to `silent`. */ + output?: CommandOutputMode; + /** Maximum captured output in bytes for this step. */ + maxBuffer?: number; +} + +/** + * Options for running a verification sequence. + */ +export interface SilentCommandSequenceOptions { + /** Steps to run in order until one fails. */ + steps: CommandSequenceStep[]; + /** Working directory for each command. Defaults to the current process cwd. */ + cwd?: string; + /** Environment to pass to each command. Defaults to the current process env. */ + env?: NodeJS.ProcessEnv; + /** Default maximum captured output in bytes for silent steps. Defaults to 64 MiB. */ + maxBuffer?: number; + /** Process spawner used by tests and advanced consumers. Defaults to `child_process.spawnSync`. */ + spawn?: SilentRunnerSpawnSync; + /** Streams used when replaying captured failure output. Defaults to `process.stdout` and `process.stderr`. */ + streams?: SilentRunnerStreams; +} + +interface CommandSequenceContext { + cwd: string | undefined; + env: NodeJS.ProcessEnv | undefined; + maxBuffer: number | undefined; + spawn: SilentRunnerSpawnSync | undefined; + streams: SilentRunnerStreams | undefined; +} + +/** + * Result returned by a silent command runner. + */ +export interface SilentCommandResult { + /** Exit status reported by the child process. */ + status: number; + /** Signal reported by the child process, when it was terminated by a signal. */ + signal: NodeJS.Signals | null; + /** Captured stdout. This is replayed only when the command fails. */ + stdout: string; + /** Captured stderr. This is replayed only when the command fails. */ + stderr: string; + /** Spawn error, such as an executable that could not be found. */ + error?: Error; +} + +/** + * Result returned by a command sequence. + */ +export interface SilentCommandSequenceResult extends SilentCommandResult { + /** Step that produced the returned status. */ + step: CommandSequenceStep; +} + +export type VerificationSequenceOptions = Omit; + +/** + * Runs a command with stdout and stderr captured instead of inherited. + * + * Successful commands produce no terminal output. Failed commands replay their + * captured stdout and stderr so local verification stays quiet when healthy and + * still shows whatever the command wrote when something breaks. + * + * @param options - Command, arguments, process context, and optional test seams. + * @returns The child-process result with captured output and normalized status. + * + * @example + * ```ts + * import { runSilentCommand } from '@cellix/local-dev/silent-runners'; + * + * const result = runSilentCommand({ + * command: 'snyk', + * args: ['test', '--all-projects'], + * }); + * + * process.exitCode = result.status; + * ``` + */ +export function runSilentCommand(options: SilentCommandOptions): SilentCommandResult { + const { + args = [], + command, + cwd, + env, + maxBuffer = DEFAULT_MAX_BUFFER_BYTES, + spawn = spawnCommandSync, + streams = { + stderr: process.stderr, + stdout: process.stdout, + }, + } = options; + const result = spawn(command, args, { + cwd, + encoding: 'utf8', + env, + maxBuffer, + stdio: 'pipe', + }); + const normalizedResult = toSilentCommandResult(result); + + if (normalizedResult.status !== 0) { + replayFailureOutput(normalizedResult, streams, command, args); + } + + return normalizedResult; +} + +/** + * Runs commands in order, stopping at the first failure. + * + * Steps default to silent output. Use `output: 'inherit'` only when live output + * is part of the command's intended consumer experience. + * + * @param options - Sequence steps and optional process context. + * @returns The final successful step result or the first failing step result. + */ +export function runSilentCommandSequence(options: SilentCommandSequenceOptions): SilentCommandSequenceResult { + const { cwd, env, maxBuffer, spawn, steps, streams } = options; + + let lastResult: SilentCommandSequenceResult | undefined; + + for (const step of steps) { + const context: CommandSequenceContext = { cwd, env, maxBuffer, spawn, streams }; + const result = step.output === 'inherit' ? runInheritedCommand(buildCommandOptions(step, context)) : runSilentCommand(buildCommandOptions(step, context)); + lastResult = { + ...result, + step, + }; + + if (result.status !== 0) { + return lastResult; + } + } + + if (!lastResult) { + throw new Error('runSilentCommandSequence requires at least one step'); + } + + return lastResult; +} + +/** + * Fluent builder for an ordered sequence of verification commands. + * + * Steps execute in insertion order and stop at the first failure. The sequence + * can be configured once with shared process options and run repeatedly. + * + * @example + * ```ts + * const verify = verificationSequence + * .addStep(pnpmScript('format:check')) + * .addStep(pnpmScript('test')); + * + * const result = verify.run(); + * process.exitCode = result.status; + * ``` + */ +export class VerificationSequence { + readonly #options: VerificationSequenceOptions; + readonly #steps: CommandSequenceStep[]; + + constructor(options: VerificationSequenceOptions = {}, steps: CommandSequenceStep[] = []) { + this.#options = options; + this.#steps = steps; + } + + /** + * Appends a command to the sequence. + * + * @param step - Command descriptor to execute after previously added steps. + * @returns A new sequence containing the appended step. + */ + addStep(step: CommandSequenceStep): VerificationSequence { + return new VerificationSequence(this.#options, [...this.#steps, step]); + } + + /** + * Runs the configured steps in insertion order. + * + * Per-run options override options supplied when the sequence was created. + * + * @param options - Optional process context and test seams for this run. + * @returns The final successful result or the first failing step result. + * @throws When the sequence has no steps. + */ + run(options: VerificationSequenceOptions = {}): SilentCommandSequenceResult { + return runSilentCommandSequence({ + ...this.#options, + ...options, + steps: [...this.#steps], + }); + } +} + +/** + * Empty reusable verification sequence for fluent workflow definitions. + * + * Adding a step returns a new sequence, so this shared object remains empty and + * can safely be reused by multiple consumers. + */ +export const verificationSequence = new VerificationSequence(); + +function runInheritedCommand(options: SilentCommandOptions): SilentCommandResult { + const { args = [], command, cwd, env, spawn = spawnCommandSync } = options; + return toSilentCommandResult( + spawn(command, args, { + cwd, + encoding: 'utf8', + env, + stdio: 'inherit', + }), + ); +} + +function buildCommandOptions(step: CommandSequenceStep, options: CommandSequenceContext): SilentCommandOptions { + const maxBuffer = step.maxBuffer ?? options.maxBuffer; + + return { + ...(step.args ? { args: step.args } : {}), + ...(options.cwd ? { cwd: options.cwd } : {}), + ...(options.env ? { env: options.env } : {}), + ...(maxBuffer !== undefined ? { maxBuffer } : {}), + ...(options.spawn ? { spawn: options.spawn } : {}), + ...(options.streams ? { streams: options.streams } : {}), + command: step.command, + }; +} + +function toSilentCommandResult(result: SpawnSyncReturns): SilentCommandResult { + return { + ...(result.error ? { error: result.error } : {}), + signal: result.signal, + status: result.status ?? 1, + stderr: result.stderr ?? '', + stdout: result.stdout ?? '', + }; +} + +function spawnCommandSync(command: string, args: string[], options: SpawnSyncOptionsWithStringEncoding): SpawnSyncReturns { + return spawnSync(command, args, { + ...options, + encoding: 'utf8', + }); +} + +function replayFailureOutput(result: SilentCommandResult, streams: SilentRunnerStreams, command: string, args: string[]): void { + const commandLine = [command, ...args].join(' '); + streams.stderr?.write(`\nCommand failed (exit ${result.status}): ${commandLine}\n\n`); + if (result.stdout) { + streams.stdout?.write(result.stdout); + } + if (result.stderr) { + streams.stderr?.write(result.stderr); + } + if (result.error) { + streams.stderr?.write(`${result.error.message}\n`); + } +} diff --git a/packages/cellix/local-dev/src/silent-runners/knip.ts b/packages/cellix/local-dev/src/silent-runners/knip.ts new file mode 100644 index 000000000..32e96651f --- /dev/null +++ b/packages/cellix/local-dev/src/silent-runners/knip.ts @@ -0,0 +1,12 @@ +import type { CommandSequenceStep } from './index.ts'; + +/** + * Runs Knip unused-code/dependency analysis. + */ +export function knipCheck(): CommandSequenceStep { + return { + args: ['exec', 'knip'], + command: 'pnpm', + name: 'knip', + }; +} diff --git a/packages/cellix/local-dev/src/silent-runners/pnpm-audit.ts b/packages/cellix/local-dev/src/silent-runners/pnpm-audit.ts new file mode 100644 index 000000000..68ed5e50e --- /dev/null +++ b/packages/cellix/local-dev/src/silent-runners/pnpm-audit.ts @@ -0,0 +1,18 @@ +import type { CommandSequenceStep } from './index.ts'; + +export interface PnpmAuditOptions { + auditLevel: 'low' | 'moderate' | 'high' | 'critical'; + dependencyType: 'prod' | 'dev'; + name?: string; +} + +/** + * Runs `pnpm audit` for one dependency class and audit threshold. + */ +export function pnpmAudit(options: PnpmAuditOptions): CommandSequenceStep { + return { + args: ['audit', `--audit-level=${options.auditLevel}`, `--${options.dependencyType}`], + command: 'pnpm', + name: options.name ?? `audit:${options.dependencyType}`, + }; +} diff --git a/packages/cellix/local-dev/src/silent-runners/pnpm.ts b/packages/cellix/local-dev/src/silent-runners/pnpm.ts new file mode 100644 index 000000000..d73c8881f --- /dev/null +++ b/packages/cellix/local-dev/src/silent-runners/pnpm.ts @@ -0,0 +1,31 @@ +import type { CommandOutputMode, CommandSequenceStep } from './index.ts'; + +export interface PnpmScriptOptions { + name?: string; + output?: CommandOutputMode; +} + +/** + * Runs a package-manager script. + * + * Use this for project-owned scripts whose internals should remain delegated to + * the consuming repository. + */ +export function pnpmScript(script: string, options: PnpmScriptOptions = {}): CommandSequenceStep { + return { + args: ['run', script], + command: 'pnpm', + name: options.name ?? script, + ...(options.output ? { output: options.output } : {}), + }; +} + +/** + * Runs a package-manager script with live output. + */ +export function livePnpmScript(script: string, options: Omit = {}): CommandSequenceStep { + return pnpmScript(script, { + ...options, + output: 'inherit', + }); +} diff --git a/packages/cellix/local-dev/src/silent-runners/snyk.ts b/packages/cellix/local-dev/src/silent-runners/snyk.ts new file mode 100644 index 000000000..e19c00805 --- /dev/null +++ b/packages/cellix/local-dev/src/silent-runners/snyk.ts @@ -0,0 +1,39 @@ +import type { CommandSequenceStep } from './index.ts'; + +export interface SnykScanOptions { + args?: string[]; + name?: string; +} + +/** + * Runs Snyk dependency scanning (`snyk test`). + */ +export function snykDependencyScan(options: SnykScanOptions = {}): CommandSequenceStep { + return { + args: ['exec', 'snyk', 'test', ...(options.args ?? [])], + command: 'pnpm', + name: options.name ?? 'snyk:test', + }; +} + +/** + * Runs Snyk code scanning (`snyk code test`). + */ +export function snykCodeScan(options: SnykScanOptions = {}): CommandSequenceStep { + return { + args: ['exec', 'snyk', 'code', 'test', ...(options.args ?? [])], + command: 'pnpm', + name: options.name ?? 'snyk:code', + }; +} + +/** + * Runs Snyk IaC scanning (`snyk iac test`). + */ +export function snykIacScan(targets: string[] = [], options: SnykScanOptions = {}): CommandSequenceStep { + return { + args: ['exec', 'snyk', 'iac', 'test', ...targets, ...(options.args ?? [])], + command: 'pnpm', + name: options.name ?? 'snyk:iac', + }; +} diff --git a/packages/cellix/local-dev/src/silent-runners/sonar.ts b/packages/cellix/local-dev/src/silent-runners/sonar.ts new file mode 100644 index 000000000..ac6cdc64d --- /dev/null +++ b/packages/cellix/local-dev/src/silent-runners/sonar.ts @@ -0,0 +1,27 @@ +import type { CommandSequenceStep } from './index.ts'; +import { pnpmScript } from './pnpm.ts'; + +export interface SonarScriptOptions { + script?: string; +} + +/** + * Runs a repository-owned Sonar pull-request analysis script. + * + * The PR-number and branch-resolution policy is intentionally delegated to the + * consuming repository because those details vary by CI provider. + */ +export function sonarPullRequestAnalysis(options: SonarScriptOptions = {}): CommandSequenceStep { + return pnpmScript(options.script ?? 'sonar:pr', { + name: 'sonar:pr', + }); +} + +/** + * Runs a repository-owned Sonar quality-gate script. + */ +export function sonarQualityGate(options: SonarScriptOptions = {}): CommandSequenceStep { + return pnpmScript(options.script ?? 'check-sonar', { + name: 'check-sonar', + }); +} diff --git a/packages/cellix/local-dev/src/silent-runners/test-runners.ts b/packages/cellix/local-dev/src/silent-runners/test-runners.ts new file mode 100644 index 000000000..0699f2c95 --- /dev/null +++ b/packages/cellix/local-dev/src/silent-runners/test-runners.ts @@ -0,0 +1,23 @@ +import type { CommandSequenceStep } from './index.ts'; +import { pnpmScript } from './pnpm.ts'; + +/** + * Runs the repository's architecture test script silently. + */ +export function architectureTests(script = 'test:arch'): CommandSequenceStep { + return pnpmScript(script); +} + +/** + * Runs the repository's coverage-and-merge script silently. + */ +export function coverageMerge(script = 'test:coverage:merge'): CommandSequenceStep { + return pnpmScript(script); +} + +/** + * Runs the repository's e2e test script silently until failure. + */ +export function e2eTests(script = 'test:e2e'): CommandSequenceStep { + return pnpmScript(script); +} 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..50c446b6b --- /dev/null +++ b/packages/cellix/local-dev/tsconfig.vitest.json @@ -0,0 +1,11 @@ +{ + "extends": ["./tsconfig.json", "@cellix/config-typescript/vitest"], + "compilerOptions": { + "rootDir": "../../..", + "paths": { + "@cellix/local-dev": ["./src/index.ts"], + "@cellix/local-dev/silent-runners": ["./src/silent-runners/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..629142ed9 --- /dev/null +++ b/packages/cellix/local-dev/vitest.config.ts @@ -0,0 +1,19 @@ +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/silent-runners': join(dirname, 'src/silent-runners/index.ts'), + '@cellix/local-dev': join(dirname, 'src/index.ts'), + }, + }, +}); diff --git a/packages/cellix/serenity-framework/src/formatters/agent-formatter.ts b/packages/cellix/serenity-framework/src/formatters/agent-formatter.ts index c06a3b36c..f41105253 100644 --- a/packages/cellix/serenity-framework/src/formatters/agent-formatter.ts +++ b/packages/cellix/serenity-framework/src/formatters/agent-formatter.ts @@ -104,6 +104,10 @@ export default class AgentFormatter extends Formatter { } private onTestRunFinished(testRunFinished: TestRunFinished): void { + if (this.issueCount === 0) { + return; + } + this.log('\n--- (Agent) Results ---\n'); const parts = Object.entries(this.statusCounts).map(([status, count]) => `${status}: ${count}`); @@ -114,6 +118,6 @@ export default class AgentFormatter extends Formatter { this.log(`Duration: ${ms}ms\n`); } - this.log(this.issueCount === 0 ? 'All scenarios passed.\n' : `Issues: ${this.issueCount}\n`); + this.log(`Issues: ${this.issueCount}\n`); } } diff --git a/packages/cellix/server-oauth2-mock-seedwork/tests/file-user-store.test.ts b/packages/cellix/server-oauth2-mock-seedwork/tests/file-user-store.test.ts index 04772575f..f6fdee1ca 100644 --- a/packages/cellix/server-oauth2-mock-seedwork/tests/file-user-store.test.ts +++ b/packages/cellix/server-oauth2-mock-seedwork/tests/file-user-store.test.ts @@ -126,9 +126,7 @@ describe('file user store oidcConfigName filtering', () => { it('users without oidcConfigName are visible to all scoped stores', async () => { if (!tmp) throw new Error('tmp not created'); - writeUsers(tmp, 'mock-oidc.users.json', [ - { username: 'shared@example.com', sub: 'sub-shared' }, - ]); + writeUsers(tmp, 'mock-oidc.users.json', [{ username: 'shared@example.com', sub: 'sub-shared' }]); const endStore = createFileUserStore(tmp, 'end-user'); const staffStore = createFileUserStore(tmp, 'staff-user'); @@ -191,15 +189,7 @@ describe('file user store oidcConfigName filtering', () => { expect(users).toHaveLength(1); expect(users[0]?.username).toBe('good@example.com'); expect(warnSpy.length).toBeGreaterThan(0); - expect( - warnSpy.some((args) => - (args as unknown[]).some( - (arg) => - typeof arg === 'string' && - arg.includes('"oidcConfigName" must be a string'), - ), - ), - ).toBe(true); + expect(warnSpy.some((args) => (args as unknown[]).some((arg) => typeof arg === 'string' && arg.includes('"oidcConfigName" must be a string')))).toBe(true); } finally { console.warn = origWarn; } diff --git a/packages/cellix/server-oauth2-mock-seedwork/tests/portal-discovery.test.ts b/packages/cellix/server-oauth2-mock-seedwork/tests/portal-discovery.test.ts index d364915c8..565ece8ac 100644 --- a/packages/cellix/server-oauth2-mock-seedwork/tests/portal-discovery.test.ts +++ b/packages/cellix/server-oauth2-mock-seedwork/tests/portal-discovery.test.ts @@ -569,11 +569,7 @@ describe('discoverPortalConfigs', () => { envVars: { clientId: 'VITE_APP_PORTAL_STAFF_CLIENTID', redirectUri: 'VITE_APP_PORTAL_STAFF_REDIRECT' }, }, ]); - writeEnv( - tmp, - 'ui-portal/.env', - 'VITE_APP_PORTAL_END_CLIENTID=end-cid\nVITE_APP_PORTAL_END_REDIRECT=https://portal/end/cb\nVITE_APP_PORTAL_STAFF_CLIENTID=staff-cid\nVITE_APP_PORTAL_STAFF_REDIRECT=https://portal/staff/cb\n', - ); + writeEnv(tmp, 'ui-portal/.env', 'VITE_APP_PORTAL_END_CLIENTID=end-cid\nVITE_APP_PORTAL_END_REDIRECT=https://portal/end/cb\nVITE_APP_PORTAL_STAFF_CLIENTID=staff-cid\nVITE_APP_PORTAL_STAFF_REDIRECT=https://portal/staff/cb\n'); const portals = discoverPortalConfigs(tmp); expect(portals).toHaveLength(2); diff --git a/packages/cellix/service-queue-storage/src/index.ts b/packages/cellix/service-queue-storage/src/index.ts index 583824e3f..1e24249e1 100644 --- a/packages/cellix/service-queue-storage/src/index.ts +++ b/packages/cellix/service-queue-storage/src/index.ts @@ -1,7 +1,9 @@ -export type { InboundQueueDefinition, LoggingFieldSpec, OutboundQueueDefinition, QueueDefinition, QueueLoggingConfig, QueueMessage, QueueStorageConfig, QueueTriggerMetadata } from './interfaces.ts'; +export type { FromSchema, JSONSchema } from 'json-schema-to-ts'; export { defineQueue } from './define-queue.ts'; -export { $payload, payloadFields, resolveLoggingFields } from './logging-fields.ts'; +export type { InboundQueueDefinition, LoggingFieldSpec, OutboundQueueDefinition, QueueDefinition, QueueLoggingConfig, QueueMessage, QueueStorageConfig, QueueTriggerMetadata } from './interfaces.ts'; +export type { QueueServiceLifecycle, QueueServiceLogging } from './internal-queue-storage-service.ts'; export type { IQueueMessageLogger, MessageLogEnvelope, QueueMessageLogBlobStorage } from './logging.ts'; +export { $payload, payloadFields, resolveLoggingFields } from './logging-fields.ts'; export type { QueueConsumerContext } from './queue-consumer.ts'; export type { QueueProducerContext } from './queue-producer.ts'; export type { @@ -14,5 +16,3 @@ export type { RegisteredQueueService, } from './register-queues.ts'; export { createRegisteredQueueService, deriveProvisionQueues, registerQueues } from './register-queues.ts'; -export type { QueueServiceLifecycle, QueueServiceLogging } from './internal-queue-storage-service.ts'; -export type { FromSchema, JSONSchema } from 'json-schema-to-ts'; diff --git a/packages/ocom-verification/acceptance-api/src/mock-application-services.ts b/packages/ocom-verification/acceptance-api/src/mock-application-services.ts index 308365adb..7b162160a 100644 --- a/packages/ocom-verification/acceptance-api/src/mock-application-services.ts +++ b/packages/ocom-verification/acceptance-api/src/mock-application-services.ts @@ -4,8 +4,8 @@ import type { ApiContextSpec } from '@ocom/context-spec'; import { Persistence } from '@ocom/persistence'; import type { ServiceApolloServer } from '@ocom/service-apollo-server'; import type { BlobAddress, BlobStorageOperations, ClientUploadOperations, ListBlobsRequest, UploadTextBlobRequest } from '@ocom/service-blob-storage'; -import type { EndUserUpdatePayload, QueueStorageOperations } from '@ocom/service-queue-storage'; import type { ServiceMongoose } from '@ocom/service-mongoose'; +import type { EndUserUpdatePayload, QueueStorageOperations } from '@ocom/service-queue-storage'; import type { TokenValidation, TokenValidationResult } from '@ocom/service-token-validation'; import { actors } from '@ocom-verification/verification-shared/test-data'; diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/create-community.steps.tsx b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/create-community.steps.tsx index be435d792..831e08c3b 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/create-community.steps.tsx +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/create-community.steps.tsx @@ -16,11 +16,7 @@ Given('{word} is an authenticated community owner', async (actorName: string) => const actor = actorCalled(actorName); const onSave = async (values: { name: string }): Promise => { - await actor.attemptsTo( - notes().set('formSubmitted', true), - notes().set('communityName', values.name ?? ''), - notes().set('communityCreationQueued', true), - ); + await actor.attemptsTo(notes().set('formSubmitted', true), notes().set('communityName', values.name ?? ''), notes().set('communityCreationQueued', true)); }; await actor.attemptsTo( diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/create-community.steps.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/create-community.steps.ts index 300450fec..97e7876fd 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/create-community.steps.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/create-community.steps.ts @@ -4,12 +4,12 @@ import { type DataTable, Given, Then, When } from '@cucumber/cucumber'; import { actors } from '@ocom-verification/verification-shared/test-data'; import { actorCalled, notes } from '@serenity-js/core'; import { LogInWithOAuth2 } from '../../../shared/abilities/oauth2-login.ts'; +import { clearKnownQueueMessages, waitForCommunityCreationQueueMessage } from '../../../shared/support/queue-storage.ts'; import type { CommunityE2ENotes } from '../notes/community-notes.ts'; import { CommunityCreatedFlag } from '../questions/community-created-flag.ts'; import { CommunityErrorMessage } from '../questions/community-error-message.ts'; import { CommunityName } from '../questions/community-name.ts'; import { CreateCommunity } from '../tasks/create-community.ts'; -import { clearKnownQueueMessages, waitForCommunityCreationQueueMessage } from '../../../shared/support/queue-storage.ts'; let lastActorName = actors.CommunityOwner.name; diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/queue-storage.ts b/packages/ocom-verification/e2e-tests/src/shared/support/queue-storage.ts index 24464c766..96159eab8 100644 --- a/packages/ocom-verification/e2e-tests/src/shared/support/queue-storage.ts +++ b/packages/ocom-verification/e2e-tests/src/shared/support/queue-storage.ts @@ -1,4 +1,4 @@ -import { ServiceQueueStorage, type CommunityCreationPayload } from '@ocom/service-queue-storage'; +import { type CommunityCreationPayload, ServiceQueueStorage } from '@ocom/service-queue-storage'; const communityCreationQueueName = 'community-creation'; let queueStorageService: ServiceQueueStorage | undefined; @@ -56,9 +56,7 @@ export async function waitForCommunityCreationQueueMessage(expected: { community while (Date.now() < deadline) { const matchingMessage = (await service.peekAtCommunityCreationQueue(32)).find( (message) => - message.payload.name === expected.name && - (expected.createdBy === undefined || message.payload.createdBy === expected.createdBy) && - (expected.communityId == null || message.payload.communityId === expected.communityId), + message.payload.name === expected.name && (expected.createdBy === undefined || message.payload.createdBy === expected.createdBy) && (expected.communityId == null || message.payload.communityId === expected.communityId), )?.payload; if (matchingMessage) { diff --git a/packages/ocom/persistence/src/datasources/domain/community/community/community.domain-adapter.ts b/packages/ocom/persistence/src/datasources/domain/community/community/community.domain-adapter.ts index bdc5d2536..2a238d9f6 100644 --- a/packages/ocom/persistence/src/datasources/domain/community/community/community.domain-adapter.ts +++ b/packages/ocom/persistence/src/datasources/domain/community/community/community.domain-adapter.ts @@ -1,9 +1,8 @@ import { MongooseSeedwork } from '@cellix/mongoose-seedwork'; -import { Domain } from '@ocom/domain'; - -import { EndUserDomainAdapter } from '../../user/end-user/end-user.domain-adapter.ts'; import type { Community } from '@ocom/data-sources-mongoose-models/community'; import type { EndUser } from '@ocom/data-sources-mongoose-models/user/end-user'; +import { Domain } from '@ocom/domain'; +import { EndUserDomainAdapter } from '../../user/end-user/end-user.domain-adapter.ts'; export class CommunityConverter extends MongooseSeedwork.MongoTypeConverter> { constructor() { diff --git a/packages/ocom/service-queue-storage/src/schemas/inbound/end-user-update.ts b/packages/ocom/service-queue-storage/src/schemas/inbound/end-user-update.ts index 7dd6f8bad..7753f436c 100644 --- a/packages/ocom/service-queue-storage/src/schemas/inbound/end-user-update.ts +++ b/packages/ocom/service-queue-storage/src/schemas/inbound/end-user-update.ts @@ -1,5 +1,5 @@ import { defineQueue } from '@cellix/service-queue-storage'; -import { schema as endUserUpdateSchema, type Schema as EndUserUpdatePayload } from './end-user-update.schema.generated.ts'; +import { type Schema as EndUserUpdatePayload, schema as endUserUpdateSchema } from './end-user-update.schema.generated.ts'; export type { EndUserUpdatePayload }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf48ef53c..29e3cc045 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -129,9 +129,11 @@ overrides: jiti: 2.6.1 rollup: ^4.59.0 '@ant-design/pro-layout>path-to-regexp': ^8.4.0 - brace-expansion@1.1.12: 1.1.13 - brace-expansion@5.0.4: 5.0.6 - brace-expansion@5.0.5: 5.0.6 + brace-expansion@1.1.12: 5.0.7 + brace-expansion@1.1.13: 5.0.7 + brace-expansion@5.0.4: 5.0.7 + brace-expansion@5.0.5: 5.0.7 + brace-expansion@5.0.6: 5.0.7 diff@4.0.2: 4.0.4 '@protobufjs/codegen': 2.0.5 '@protobufjs/utf8': 1.1.1 @@ -166,11 +168,11 @@ overrides: '@babel/plugin-transform-modules-systemjs': 7.29.4 '@babel/core': ^7.29.6 js-yaml@4.1.1: 4.2.0 - shell-quote@<1.8.4: 1.8.4 + shell-quote@<1.9.0: 1.9.0 '@opentelemetry/exporter-prometheus@0.57.2': 0.217.0 '@opentelemetry/core@2.7.1': 2.8.0 ws: 8.21.0 - shell-quote: 1.8.4 + shell-quote: 1.9.0 '@grpc/grpc-js': '>=1.14.4' form-data: ^4.0.6 webpack-dev-server>http-proxy-middleware: 3.0.7 @@ -208,6 +210,9 @@ importers: '@cellix/graphql-codegen': specifier: workspace:* version: link:packages/cellix/graphql-codegen + '@cellix/local-dev': + specifier: workspace:* + version: link:packages/cellix/local-dev '@graphql-codegen/cli': specifier: ^5.0.7 version: 5.0.7(@parcel/watcher@2.5.1)(@types/node@22.19.15)(graphql@16.12.0)(supports-color@8.1.1)(typescript@6.0.3) @@ -910,6 +915,30 @@ importers: specifier: 'catalog:' version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + packages/cellix/local-dev: + devDependencies: + '@cellix/config-typescript': + specifier: workspace:* + version: link:../config-typescript + '@cellix/config-vitest': + specifier: workspace:* + version: link:../config-vitest + '@types/node': + specifier: 'catalog:' + version: 22.19.15 + '@vitest/coverage-istanbul': + specifier: 'catalog:' + version: 4.1.8(vitest@4.1.8) + rimraf: + specifier: 'catalog:' + version: 6.0.1 + typescript: + specifier: 'catalog:' + version: 6.0.3 + vitest: + specifier: 'catalog:' + version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.15)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + packages/cellix/mongoose-seedwork: dependencies: '@cellix/domain-seedwork': @@ -7818,14 +7847,11 @@ packages: resolution: {integrity: sha512-2hCgjEmP8YLWQ130n2FerGv7rYpfBmnmp9Uy2Le1vge6X3gZIfSmEzP5QTDElFxcvVcXlEn8Aq6MU/PZygIOog==} engines: {node: '>=14.16'} - brace-expansion@1.1.13: - resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} - brace-expansion@2.0.3: resolution: {integrity: sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==} - brace-expansion@5.0.6: - resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + brace-expansion@5.0.7: + resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} engines: {node: 18 || 20 || >=22} braces@3.0.3: @@ -8258,9 +8284,6 @@ packages: compute-scroll-into-view@3.1.1: resolution: {integrity: sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==} - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - concurrently@9.2.1: resolution: {integrity: sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==} engines: {node: '>=18'} @@ -12659,8 +12682,8 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - shell-quote@1.8.4: - resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==} + shell-quote@1.9.0: + resolution: {integrity: sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==} engines: {node: '>= 0.4'} shimmer@1.2.1: @@ -17067,7 +17090,7 @@ snapshots: listr2: 4.0.5 log-symbols: 4.1.0 micromatch: 4.0.8 - shell-quote: 1.8.4 + shell-quote: 1.9.0 string-env-interpolation: 1.0.1 ts-log: 2.2.7 tslib: 2.8.1 @@ -20606,16 +20629,11 @@ snapshots: widest-line: 4.0.1 wrap-ansi: 8.1.0 - brace-expansion@1.1.13: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - brace-expansion@2.0.3: dependencies: balanced-match: 1.0.2 - brace-expansion@5.0.6: + brace-expansion@5.0.7: dependencies: balanced-match: 4.0.4 @@ -21072,13 +21090,11 @@ snapshots: compute-scroll-into-view@3.1.1: {} - concat-map@0.0.1: {} - concurrently@9.2.1: dependencies: chalk: 4.1.2 rxjs: 7.8.2 - shell-quote: 1.8.4 + shell-quote: 1.9.0 supports-color: 8.1.1 tree-kill: 1.2.2 yargs: 17.7.2 @@ -23368,7 +23384,7 @@ snapshots: launch-editor@2.14.1: dependencies: picocolors: 1.1.1 - shell-quote: 1.8.4 + shell-quote: 1.9.0 less@4.4.2: dependencies: @@ -24189,11 +24205,11 @@ snapshots: minimatch@10.2.4: dependencies: - brace-expansion: 5.0.6 + brace-expansion: 5.0.7 minimatch@3.1.5: dependencies: - brace-expansion: 1.1.13 + brace-expansion: 5.0.7 minimatch@9.0.9: dependencies: @@ -26359,7 +26375,7 @@ snapshots: shebang-regex@3.0.0: {} - shell-quote@1.8.4: {} + shell-quote@1.9.0: {} shimmer@1.2.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a5e8c4715..50ba38468 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -80,9 +80,11 @@ overrides: jiti: 2.6.1 rollup: ^4.59.0 '@ant-design/pro-layout>path-to-regexp': ^8.4.0 - 'brace-expansion@1.1.12': 1.1.13 - 'brace-expansion@5.0.4': 5.0.6 - 'brace-expansion@5.0.5': 5.0.6 + 'brace-expansion@1.1.12': 5.0.7 + 'brace-expansion@1.1.13': 5.0.7 + 'brace-expansion@5.0.4': 5.0.7 + 'brace-expansion@5.0.5': 5.0.7 + 'brace-expansion@5.0.6': 5.0.7 'diff@4.0.2': 4.0.4 '@protobufjs/codegen': 2.0.5 '@protobufjs/utf8': 1.1.1 @@ -117,11 +119,11 @@ overrides: '@babel/plugin-transform-modules-systemjs': 7.29.4 '@babel/core': ^7.29.6 'js-yaml@4.1.1': 4.2.0 - 'shell-quote@<1.8.4': 1.8.4 + 'shell-quote@<1.9.0': 1.9.0 '@opentelemetry/exporter-prometheus@0.57.2': 0.217.0 '@opentelemetry/core@2.7.1': 2.8.0 ws: 8.21.0 - shell-quote: 1.8.4 + shell-quote: 1.9.0 '@grpc/grpc-js': '>=1.14.4' form-data: ^4.0.6 'webpack-dev-server>http-proxy-middleware': 3.0.7 diff --git a/readme.md b/readme.md index 345a2fcb3..05d5d6d10 100644 --- a/readme.md +++ b/readme.md @@ -169,6 +169,7 @@ The local mock OpenID Connect provider used for UI development is configured per } ``` + The envVars values are environment variable names (from the UI app's `.env` file) that the server resolves to actual values at startup. Per-developer overrides @@ -231,6 +232,7 @@ CellixJS integrates [Agent Skills](https://agentskills.io) to provide AI coding The managed skill set lives in [`.agents/skills/`](.agents/skills/) and is mirrored for Copilot discovery through symlinks in [`.github/skills/`](.github/skills/). Only skills relevant to normal work in this repo are committed. + **Current managed skills:** - **madr-enforcement**: Enforces ADR-defined architectural standards across the codebase - **turborepo**: Guides monorepo task graph, caching, and pipeline work diff --git a/turbo.json b/turbo.json index 3a716dd69..e214fb9fc 100644 --- a/turbo.json +++ b/turbo.json @@ -34,7 +34,7 @@ "test": { "description": "Runs the default test suite for each package", "dependsOn": ["^build", "@cellix/config-vitest#build"], - "inputs": ["$TURBO_DEFAULT$", "**/*.test.ts", "**/*.stories.tsx", "!coverage/**", "!target/**", "!dist/**", "!build/**", "!deploy/**"] + "inputs": ["$TURBO_DEFAULT$", "**/*.test.ts", "**/*.stories.tsx", "!coverage/**", "!target/**", "!dist/**", "!build/**", "!deploy/**"] }, "test:arch": { "description": "Runs architecture unit tests for each package",