diff --git a/.changeset/recovery-production-fixes.md b/.changeset/recovery-production-fixes.md new file mode 100644 index 0000000..ec351d5 --- /dev/null +++ b/.changeset/recovery-production-fixes.md @@ -0,0 +1,5 @@ +--- +"@diginodes/stellarforge-cli": patch +--- + +Restore reliable timeout diagnostics, secret redaction, generator type resolution, and cross-platform CLI validation. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f467e3..3ccf7e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,9 @@ jobs: - name: Install from committed lockfile run: npm ci --ignore-scripts --no-audit --no-fund + - name: Validate GitHub Actions workflows with actionlint + run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.7 + - name: Audit workflow least-privilege permissions run: npm run audit:workflow-permissions diff --git a/.github/workflows/wave-stale-prs.yml b/.github/workflows/wave-stale-prs.yml index b3ae1f4..3778a38 100644 --- a/.github/workflows/wave-stale-prs.yml +++ b/.github/workflows/wave-stale-prs.yml @@ -19,7 +19,7 @@ jobs: permissions: contents: read issues: write - pull-requests: read + pull-requests: write steps: - name: Warn and escalate stale Wave PRs uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 diff --git a/scripts/audit-workflow-permissions.d.mts b/scripts/audit-workflow-permissions.d.mts new file mode 100644 index 0000000..b520022 --- /dev/null +++ b/scripts/audit-workflow-permissions.d.mts @@ -0,0 +1,9 @@ +export interface WorkflowPermissionViolation { + readonly file: string; + readonly line: number; + readonly message: string; +} + +export function auditWorkflowDirectory( + dir: string, +): WorkflowPermissionViolation[]; diff --git a/scripts/audit-workflow-permissions.mjs b/scripts/audit-workflow-permissions.mjs index b9d3aab..f5dd415 100644 --- a/scripts/audit-workflow-permissions.mjs +++ b/scripts/audit-workflow-permissions.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +/* global process, console */ /** * SW-105 — GitHub Actions least-privilege permissions conformance audit. * Static check only. Does not modify workflow permissions. @@ -19,7 +20,7 @@ const JOB_PERMISSION_ALLOWLIST = { publish: new Set(['contents:write', 'id-token:write']), }, 'wave-stale-prs.yml': { - stale: new Set(['issues:write']), + stale: new Set(['issues:write', 'pull-requests:write']), }, 'codeql.yml': { analyze: new Set(['security-events:write']), @@ -63,13 +64,13 @@ function extractPermissionBlocks(text) { continue; } - const jobMatch = line.match(/^ ([A-Za-z0-9_-]+):\s*$/); + const jobMatch = line.match(/^[ ]{2}([A-Za-z0-9_-]+):\s*$/); if (jobMatch && currentJob !== null || jobMatch && lines.slice(0, i).some((v) => /^jobs:\s*$/.test(v))) { currentJob = jobMatch[1]; } const topPerm = line.match(/^permissions:\s*(.*)$/); - const jobPerm = line.match(/^ permissions:\s*(.*)$/); + const jobPerm = line.match(/^[ ]{4}permissions:\s*(.*)$/); const match = topPerm || jobPerm; if (!match) continue; @@ -82,7 +83,7 @@ function extractPermissionBlocks(text) { } else if (rest.length === 0) { let j = i + 1; while (j < lines.length) { - const entry = lines[j].match(/^ {2,6}([A-Za-z0-9_-]+):\s*(\S+)\s*$/); + const entry = lines[j].match(/^[ ]{2,6}([A-Za-z0-9_-]+):\s*(\S+)\s*$/); if (!entry) break; entries.push({ key: entry[1], value: entry[2] }); j++; diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index fb36841..3644d85 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -1,4 +1,3 @@ - import { Command } from 'commander'; import { resolveProjectConfig, @@ -44,9 +43,7 @@ export function createDeployCommand( } = options; return new Command('deploy') - .description( - 'Deploy a supported StellarForge project to Stellar Testnet.', - ) + .description('Deploy a supported StellarForge project to Stellar Testnet.') .option( '--network ', 'Deployment network. CLI value overrides stellarforge.config.json.', @@ -59,12 +56,8 @@ export function createDeployCommand( const projectRoot = cwd(); const overrides: ProjectConfigOverrides = { - ...(flags.network === undefined - ? {} - : { network: flags.network }), - ...(flags.source === undefined - ? {} - : { identity: flags.source }), + ...(flags.network === undefined ? {} : { network: flags.network }), + ...(flags.source === undefined ? {} : { identity: flags.source }), }; const configuration = resolveConfig(projectRoot, overrides); diff --git a/src/commands/dev.ts b/src/commands/dev.ts index b80dda1..9452a2e 100644 --- a/src/commands/dev.ts +++ b/src/commands/dev.ts @@ -1,13 +1,9 @@ - import { Command } from 'commander'; import { superviseDevProcesses, type DevOrchestratorOptions, } from '../dev/orchestrator.js'; -import { - resolveDevProcessPlan, - type DevProcessSpec, -} from '../dev/project.js'; +import { resolveDevProcessPlan, type DevProcessSpec } from '../dev/project.js'; import { TerminalOutput } from '../output/terminal.js'; export interface DevCommandOptions extends DevOrchestratorOptions { @@ -15,9 +11,7 @@ export interface DevCommandOptions extends DevOrchestratorOptions { readonly resolvePlan?: (cwd: string) => readonly DevProcessSpec[]; } -export function createDevCommand( - options: DevCommandOptions = {}, -): Command { +export function createDevCommand(options: DevCommandOptions = {}): Command { const { cwd = () => process.cwd(), resolvePlan = resolveDevProcessPlan, @@ -30,12 +24,9 @@ export function createDevCommand( const workingDirectory = cwd(); const plan = resolvePlan(workingDirectory); const processCount = plan.length; - const processLabel = - processCount === 1 ? 'process' : 'processes'; + const processLabel = processCount === 1 ? 'process' : 'processes'; - output.info( - `Starting ${processCount} development ${processLabel}.`, - ); + output.info(`Starting ${processCount} development ${processLabel}.`); await superviseDevProcesses(plan, { ...options, diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 3344878..d2bb42f 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -43,7 +43,10 @@ function sanitizeText(value: string, maxLength: number): string { return ''; } - let sanitized = value.replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim(); + let sanitized = value + .replace(/[\r\n\t]+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); for (const pattern of SECRET_PATTERNS) { sanitized = sanitized.replace(pattern, '[REDACTED]'); diff --git a/src/commands/root.ts b/src/commands/root.ts index 3539b85..3b530e2 100644 --- a/src/commands/root.ts +++ b/src/commands/root.ts @@ -1,4 +1,3 @@ - import { Command } from 'commander'; import { resolveCliVersion } from '../version.js'; import { createDeployCommand } from './deploy.js'; @@ -12,10 +11,7 @@ interface UnknownCommandReporter { unknownCommand(): never; } -function applyUsageErrorHandling( - command: Command, - remediation: string, -): void { +function applyUsageErrorHandling(command: Command, remediation: string): void { command .exitOverride() .showHelpAfterError(remediation) @@ -30,9 +26,7 @@ function reportUnknownCommand(command: Command): void { (command as unknown as UnknownCommandReporter).unknownCommand(); } -export function createRootCommand( - version = resolveCliVersion(), -): Command { +export function createRootCommand(version = resolveCliVersion()): Command { const program = new Command() .name('stellarforge') .description( @@ -58,9 +52,7 @@ export function createRootCommand( program.addCommand(command); } - const commandNames = subcommands - .map((command) => command.name()) - .join(', '); + const commandNames = subcommands.map((command) => command.name()).join(', '); applyUsageErrorHandling( program, diff --git a/src/diagnostics/git.ts b/src/diagnostics/git.ts index 3658dee..544695c 100644 --- a/src/diagnostics/git.ts +++ b/src/diagnostics/git.ts @@ -20,7 +20,7 @@ export function createGitDiagnostic( const result = execute('git', ['--version']); const outcome = classifyCommandResult(result); - if (outcome === 'timeout') { + if (outcome === 'timed-out') { return { id: 'git', label: 'Git', @@ -48,7 +48,7 @@ export function createGitDiagnostic( id: 'git', label: 'Git', status: 'fail', - message: 'Git returned an unecognized version.', + message: 'Git returned an unrecognized version.', remediation: 'Verify `git --version` works and reinstall Git if necessary.', }; diff --git a/src/diagnostics/process.ts b/src/diagnostics/process.ts index 1ed4e0e..3edc1ff 100644 --- a/src/diagnostics/process.ts +++ b/src/diagnostics/process.ts @@ -29,7 +29,8 @@ export const runCommand: RunCommand = (executable, args) => { }; }; -export type CommandOutcome = 'ok' | 'missing-executable' | 'failed'; +export type CommandOutcome = + 'ok' | 'missing-executable' | 'timed-out' | 'failed'; /** * Recognizes the platform-specific ways Node reports that an executable could @@ -53,6 +54,13 @@ export function isExecutableNotFound(error: unknown): boolean { * diagnostic treats a missing executable consistently. */ export function classifyCommandResult(result: CommandResult): CommandOutcome { + if ( + result.error !== undefined && + (result.error as NodeJS.ErrnoException).code === 'ETIMEDOUT' + ) { + return 'timed-out'; + } + if (result.error !== undefined && isExecutableNotFound(result.error)) { return 'missing-executable'; } diff --git a/src/diagnostics/runtime.ts b/src/diagnostics/runtime.ts index c972b5a..bd8e75e 100644 --- a/src/diagnostics/runtime.ts +++ b/src/diagnostics/runtime.ts @@ -15,9 +15,12 @@ import { const MINIMUM_NODE: NumericVersion = { major: 22, minor: 13, patch: 0 }; const MAXIMUM_NODE_MAJOR = 25; const MINIMUM_NPM: NumericVersion = { major: 10, minor: 9, patch: 0 }; -const NODE_REMEDIATION = 'Install Node.js >=22.13.0 and <25.'; -const NPM_REMEDIATION = +const NODE_REMEDIATION = + 'Install a supported Node.js release (>=22.13.0 and <25).'; +const NPM_UNAVAILABLE_REMEDIATION = 'Install npm >=10.9.0 and ensure it is available on PATH.'; +const NPM_VERSION_REMEDIATION = + 'Install npm >=10.9.0 and verify `npm --version` returns a numeric version.'; function failure( id: string, @@ -83,7 +86,7 @@ export function createNpmDiagnostic( 'npm', 'npm', 'npm is unavailable or could not be executed.', - NPM_REMEDIATION, + NPM_UNAVAILABLE_REMEDIATION, ); } @@ -95,7 +98,7 @@ export function createNpmDiagnostic( 'npm', 'npm', 'npm returned an unrecognized version.', - NPM_REMEDIATION, + NPM_VERSION_REMEDIATION, ); } @@ -104,7 +107,7 @@ export function createNpmDiagnostic( 'npm', 'npm', `Unsupported npm version ${version}.`, - NPM_REMEDIATION, + NPM_VERSION_REMEDIATION, ); } diff --git a/src/diagnostics/stellar.ts b/src/diagnostics/stellar.ts index e0ddaf0..ea406ba 100644 --- a/src/diagnostics/stellar.ts +++ b/src/diagnostics/stellar.ts @@ -15,7 +15,8 @@ const STELLAR_CLI_INSTALL_URL = function parseStellarVersion(output: string): string | null { const value = output.trim(); - const match = /^v?(\d+\.\d+\.\d+)(?:[-+][0-9A-Za-z.-]+)?$/.exec(value); + const match = + /^v?(\d+\.\d+\.\d+)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.exec(value); return match?.[1] ?? null; } @@ -36,8 +37,7 @@ export function createStellarDiagnostic( label: 'Stellar CLI', status: 'fail', message: 'Stellar CLI is unavailable or could not be executed.', - remediation: - `Install or update the official Stellar CLI (${STELLAR_CLI_INSTALL_URL}), then ensure \`stellar\` is on PATH and \`stellar version --only-version\` reports a version.`, + remediation: `Install or update the official Stellar CLI (${STELLAR_CLI_INSTALL_URL}), then ensure \`stellar\` is on PATH and \`stellar version --only-version\` reports a version.`, }; } @@ -50,8 +50,7 @@ export function createStellarDiagnostic( label: 'Stellar CLI', status: 'fail', message: 'Stellar CLI returned an unrecognized version.', - remediation: - `Run \`stellar version --only-version\`; if it does not report a semantic version, reinstall or update the official Stellar CLI (${STELLAR_CLI_INSTALL_URL}).`, + remediation: `Run \`stellar version --only-version\`; if it does not report a semantic version, reinstall or update the official Stellar CLI (${STELLAR_CLI_INSTALL_URL}).`, }; } @@ -61,8 +60,7 @@ export function createStellarDiagnostic( label: 'Stellar CLI', status: 'fail', message: `Unsupported Stellar CLI version ${version}.`, - remediation: - `Update the official Stellar CLI to >=27.1.0 (${STELLAR_CLI_INSTALL_URL}), then verify \`stellar version --only-version\` reports a supported version.`, + remediation: `Update the official Stellar CLI to >=27.1.0 (${STELLAR_CLI_INSTALL_URL}), then verify \`stellar version --only-version\` reports a supported version.`, }; } diff --git a/src/generator/template-generation.ts b/src/generator/template-generation.ts index 65b8b3d..ae5bc52 100644 --- a/src/generator/template-generation.ts +++ b/src/generator/template-generation.ts @@ -72,11 +72,13 @@ function copyTemplateTree( variables: Readonly>, ): void { const sourceDirectory = resolve(templateRoot, relativeDirectory); - const entries = readdirSync(sourceDirectory, { withFileTypes: true }).sort((a, b) => { - if (a.name < b.name) return -1; - if (a.name > b.name) return 1; - return 0; - }); + const entries = readdirSync(sourceDirectory, { withFileTypes: true }).sort( + (a, b) => { + if (a.name < b.name) return -1; + if (a.name > b.name) return 1; + return 0; + }, + ); for (const entry of entries) { assertSafeEntryName(entry.name); diff --git a/src/output/redaction.ts b/src/output/redaction.ts index 3267788..2740adc 100644 --- a/src/output/redaction.ts +++ b/src/output/redaction.ts @@ -4,7 +4,7 @@ const STELLAR_SECRET_STRKEY = /\bS[A-Z2-7]{55}\b/g; const MAX_REDACTION_INPUT_LENGTH = 8192; const SENSITIVE_ASSIGNMENT = /((?:secret(?:[ _-]?key)?|private[ _-]?key|seed(?:[ _-]?phrase)?|mnemonic|password|passphrase|(?:api|auth|access)?[_-]?token)["']?\s*[:=]\s*)\S.*$/gi; -const BEARER_CREDENTIAL = /\b(Bearer\s+)[A-Za-z0-9._~+\/-]+=*/gi; +const BEARER_CREDENTIAL = /\b(Bearer\s+)[A-Za-z0-9._~+/-]+=*/gi; /** * Replaces secret-like material in a single line of untrusted text. @@ -20,6 +20,6 @@ export function redactSecretLikeText(line: string): string { : line; return bounded .replace(STELLAR_SECRET_STRKEY, REDACTED_PLACEHOLDER) - .replace(BEARER+_CREDENTIAL, `$1${REDACTED_PLACEHOLDER}`) - .replace(SENSITIVE_ASSOIGNMENT, `$1${REDACTED_PLACEHOLDER}`); + .replace(BEARER_CREDENTIAL, `$1${REDACTED_PLACEHOLDER}`) + .replace(SENSITIVE_ASSIGNMENT, `$1${REDACTED_PLACEHOLDER}`); } diff --git a/src/validate.ts b/src/validate.ts index 693f6c9..259faa2 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -2,7 +2,7 @@ import type { ProjectGenerationResult, ProjectGeneratorInput, ProjectGeneratorServices, -} from './types.js'; +} from './generator/types.js'; export function orchestrateProjectGeneration( input: ProjectGeneratorInput, diff --git a/tests/basic-app-package-metadata.test.ts b/tests/basic-app-package-metadata.test.ts index 525f440..6aae765 100644 --- a/tests/basic-app-package-metadata.test.ts +++ b/tests/basic-app-package-metadata.test.ts @@ -39,14 +39,11 @@ describe('Basic App package metadata conformance (SW-041)', () => { command.parse(['node', 'new', 'my-stellar-app']); - assertPackageMetadata( - join(root, 'my-stellar-app', 'package.json'), - { - name: 'my-stellar-app', - scripts: FROZEN_SCRIPTS, - nodeEngine: FROZEN_NODE_ENGINE, - }, - ); + assertPackageMetadata(join(root, 'my-stellar-app', 'package.json'), { + name: 'my-stellar-app', + scripts: FROZEN_SCRIPTS, + nodeEngine: FROZEN_NODE_ENGINE, + }); })); it('sets the package name to the generated project name', () => @@ -58,14 +55,11 @@ describe('Basic App package metadata conformance (SW-041)', () => { command.parse(['node', 'new', 'my-stellar-app']); - assertPackageMetadata( - join(root, 'my-stellar-app', 'package.json'), - { - name: 'my-stellar-app', - scripts: FROZEN_SCRIPTS, - nodeEngine: FROZEN_NODE_ENGINE, - }, - ); + assertPackageMetadata(join(root, 'my-stellar-app', 'package.json'), { + name: 'my-stellar-app', + scripts: FROZEN_SCRIPTS, + nodeEngine: FROZEN_NODE_ENGINE, + }); })); it('retains all frozen script entries without modification', () => @@ -77,14 +71,11 @@ describe('Basic App package metadata conformance (SW-041)', () => { command.parse(['node', 'new', 'my-stellar-app']); - assertPackageMetadata( - join(root, 'my-stellar-app', 'package.json'), - { - name: 'my-stellar-app', - scripts: FROZEN_SCRIPTS, - nodeEngine: FROZEN_NODE_ENGINE, - }, - ); + assertPackageMetadata(join(root, 'my-stellar-app', 'package.json'), { + name: 'my-stellar-app', + scripts: FROZEN_SCRIPTS, + nodeEngine: FROZEN_NODE_ENGINE, + }); })); it('retains only the frozen script keys and no extras', () => @@ -96,14 +87,11 @@ describe('Basic App package metadata conformance (SW-041)', () => { command.parse(['node', 'new', 'my-stellar-app']); - assertPackageMetadata( - join(root, 'my-stellar-app', 'package.json'), - { - name: 'my-stellar-app', - scripts: FROZEN_SCRIPTS, - nodeEngine: FROZEN_NODE_ENGINE, - }, - ); + assertPackageMetadata(join(root, 'my-stellar-app', 'package.json'), { + name: 'my-stellar-app', + scripts: FROZEN_SCRIPTS, + nodeEngine: FROZEN_NODE_ENGINE, + }); })); it('sets the engines.node constraint to the frozen minimum version', () => @@ -115,14 +103,11 @@ describe('Basic App package metadata conformance (SW-041)', () => { command.parse(['node', 'new', 'my-stellar-app']); - assertPackageMetadata( - join(root, 'my-stellar-app', 'package.json'), - { - name: 'my-stellar-app', - scripts: FROZEN_SCRIPTS, - nodeEngine: FROZEN_NODE_ENGINE, - }, - ); + assertPackageMetadata(join(root, 'my-stellar-app', 'package.json'), { + name: 'my-stellar-app', + scripts: FROZEN_SCRIPTS, + nodeEngine: FROZEN_NODE_ENGINE, + }); })); it('generates no dependencies field', () => @@ -134,14 +119,11 @@ describe('Basic App package metadata conformance (SW-041)', () => { command.parse(['node', 'new', 'my-stellar-app']); - assertPackageMetadata( - join(root, 'my-stellar-app', 'package.json'), - { - name: 'my-stellar-app', - scripts: FROZEN_SCRIPTS, - nodeEngine: FROZEN_NODE_ENGINE, - }, - ); + assertPackageMetadata(join(root, 'my-stellar-app', 'package.json'), { + name: 'my-stellar-app', + scripts: FROZEN_SCRIPTS, + nodeEngine: FROZEN_NODE_ENGINE, + }); })); it('generates no devDependencies field', () => @@ -153,14 +135,11 @@ describe('Basic App package metadata conformance (SW-041)', () => { command.parse(['node', 'new', 'my-stellar-app']); - assertPackageMetadata( - join(root, 'my-stellar-app', 'package.json'), - { - name: 'my-stellar-app', - scripts: FROZEN_SCRIPTS, - nodeEngine: FROZEN_NODE_ENGINE, - }, - ); + assertPackageMetadata(join(root, 'my-stellar-app', 'package.json'), { + name: 'my-stellar-app', + scripts: FROZEN_SCRIPTS, + nodeEngine: FROZEN_NODE_ENGINE, + }); })); it('does not embed secret, seed, or private-key values in the generated package.json', () => @@ -172,14 +151,11 @@ describe('Basic App package metadata conformance (SW-041)', () => { command.parse(['node', 'new', 'my-stellar-app']); - assertPackageMetadata( - join(root, 'my-stellar-app', 'package.json'), - { - name: 'my-stellar-app', - scripts: FROZEN_SCRIPTS, - nodeEngine: FROZEN_NODE_ENGINE, - }, - ); + assertPackageMetadata(join(root, 'my-stellar-app', 'package.json'), { + name: 'my-stellar-app', + scripts: FROZEN_SCRIPTS, + nodeEngine: FROZEN_NODE_ENGINE, + }); })); it( diff --git a/tests/config-network-enum.test.ts b/tests/config-network-enum.test.ts index 86bf527..3f460cd 100644 --- a/tests/config-network-enum.test.ts +++ b/tests/config-network-enum.test.ts @@ -48,9 +48,9 @@ describe('configuration network enum', () => { it.each(unsupportedNetworks)( 'rejects unsupported network %j with an actionable message', (network) => { - expect(() => - validateStellarForgeConfig({ version: 1, network }), - ).toThrow(ENUM_MESSAGE); + expect(() => validateStellarForgeConfig({ version: 1, network })).toThrow( + ENUM_MESSAGE, + ); }, ); diff --git a/tests/config-secret-field-name-variants.test.ts b/tests/config-secret-field-name-variants.test.ts index 10b2b3c..dc06ea0 100644 --- a/tests/config-secret-field-name-variants.test.ts +++ b/tests/config-secret-field-name-variants.test.ts @@ -243,18 +243,15 @@ describe('legitimate schema fields remain accepted (#181)', () => { }, ); - it.each([ - 'deployer', - 'team-deployer', - 'team_deployer', - 'Alice', - 'a1', - ])('accepts the named identity alias %j', (identity) => { - expect(validateStellarForgeConfig({ version: 1, identity })).toEqual({ - version: 1, - identity, - }); - }); + it.each(['deployer', 'team-deployer', 'team_deployer', 'Alice', 'a1'])( + 'accepts the named identity alias %j', + (identity) => { + expect(validateStellarForgeConfig({ version: 1, identity })).toEqual({ + version: 1, + identity, + }); + }, + ); it('accepts a fully populated valid config', () => { expect( diff --git a/tests/configuration-reference-examples.test.ts b/tests/configuration-reference-examples.test.ts index d143109..8dd3a0f 100644 --- a/tests/configuration-reference-examples.test.ts +++ b/tests/configuration-reference-examples.test.ts @@ -14,7 +14,7 @@ const configurationReference = readFileSync( 'utf8', ); -const JSON_BLOCK = /```json\n([\s\S]*?)```/g; +const JSON_BLOCK = /```json\r?\n([\s\S]*?)```/g; const jsonMatches = [...configurationReference.matchAll(JSON_BLOCK)]; const jsonBlocks = jsonMatches.map((match) => (match[1] ?? '').trim()); diff --git a/tests/critical-path.test.ts b/tests/critical-path.test.ts index efb5037..8e28f3a 100644 --- a/tests/critical-path.test.ts +++ b/tests/critical-path.test.ts @@ -389,12 +389,13 @@ describe('critical path: end-to-end generation and E2E wiring', () => { it('keeps the repository E2E smoke path offline, mocked, and secret-free', () => { const e2e = readRepositoryFile('scripts', 'e2e-smoke.mjs'); - expect(e2e).toContain("process.platform === 'win32'"); - expect(e2e).toContain('stellarforge.cmd'); + expect(e2e).toContain("import process from 'node:process'"); + expect(e2e).toContain('process.execPath'); + expect(e2e).toContain('dist'); expect(e2e).toContain('shell: false'); expect(e2e).toContain('fakeStellar'); expect(e2e).toContain('CFAKECONTRACTID'); - expect(e2e).toContain('SECRET_'); + expect(e2e).toContain('SECRET|TOKEN|PRIVATE KEY'); expect(e2e).toContain('doesNotMatch'); expect(e2e).not.toMatch(/mainnet|api\.stellar\.org|horizon/iu); }); diff --git a/tests/deploy-missing-config-remediation.test.ts b/tests/deploy-missing-config-remediation.test.ts index 594f3e7..c0fa3e3 100644 --- a/tests/deploy-missing-config-remediation.test.ts +++ b/tests/deploy-missing-config-remediation.test.ts @@ -20,8 +20,7 @@ class FakeChild implements ManagedChildProcess { readonly stdout = new PassThrough(); readonly stderr = new PassThrough(); #exitListener: - | ((code: number | null, signal: NodeJS.Signals | null) => void) - | undefined; + ((code: number | null, signal: NodeJS.Signals | null) => void) | undefined; once(event: 'error', listener: (error: Error) => void): this; once( diff --git a/tests/deploy-network-rejection.test.ts b/tests/deploy-network-rejection.test.ts index ac311f5..20221b9 100644 --- a/tests/deploy-network-rejection.test.ts +++ b/tests/deploy-network-rejection.test.ts @@ -47,7 +47,8 @@ describe('Stellar deploy network rejection matrix', () => { writeCargoWorkspace(root); expectNetworkRejection(network, root); - })); + }), + ); it('accepts only the explicit Testnet target', () => withTempDirectory((root) => { diff --git a/tests/deploy-source-rejection.test.ts b/tests/deploy-source-rejection.test.ts index 0b1a6af..7a85ea5 100644 --- a/tests/deploy-source-rejection.test.ts +++ b/tests/deploy-source-rejection.test.ts @@ -73,7 +73,8 @@ describe('Stellar deploy source validation', () => { source, }), ).toThrow(ValidationCliError); - })); + }), + ); it.each(sensitiveSources)( 'does not echo rejected source value %j', @@ -107,7 +108,8 @@ describe('Stellar deploy source validation', () => { expect(spec?.args).toContain('--source-account'); expect(spec?.args).toContain(source); - })); + }), + ); it('rejects an unsafe source before the deploy subprocess starts', () => withTempDirectory(async (root) => { diff --git a/tests/deploy-strkey-rejection-matrix.test.ts b/tests/deploy-strkey-rejection-matrix.test.ts index 3642882..e6cb37b 100644 --- a/tests/deploy-strkey-rejection-matrix.test.ts +++ b/tests/deploy-strkey-rejection-matrix.test.ts @@ -56,8 +56,14 @@ const CANONICAL_SEED = `S${strkeyBody(55)}`; const CANONICAL_STRKEYS = [ { name: 'G account address', value: CANONICAL_ACCOUNT }, { name: 'S secret seed', value: CANONICAL_SEED }, - { name: 'G address over the full base32 alphabet', value: `G${BASE32.repeat(2).slice(0, 55)}` }, - { name: 'S seed over the full base32 alphabet', value: `S${BASE32.repeat(2).slice(0, 55)}` }, + { + name: 'G address over the full base32 alphabet', + value: `G${BASE32.repeat(2).slice(0, 55)}`, + }, + { + name: 'S seed over the full base32 alphabet', + value: `S${BASE32.repeat(2).slice(0, 55)}`, + }, { name: 'G address with digits 2-7', value: `G${'2'.repeat(55)}` }, ] as const; @@ -70,7 +76,10 @@ const NOT_AN_ALIAS = [ { name: 'G-shaped value with an equals sign', value: `G${strkeyBody(54)}=` }, { name: 'G-shaped value with a tab', value: `G${strkeyBody(54)}\t` }, { name: 'G-shaped value with a slash', value: `G${strkeyBody(54)}/x` }, - { name: '65 characters, one past the alias limit', value: `G${strkeyBody(64)}` }, + { + name: '65 characters, one past the alias limit', + value: `G${strkeyBody(64)}`, + }, { name: 'leading digit', value: `1${strkeyBody(55)}` }, { name: 'leading hyphen', value: `-${strkeyBody(55)}` }, { name: 'leading dot', value: `.${strkeyBody(55)}` }, @@ -129,13 +138,16 @@ describe('canonical raw StrKeys are refused (#186)', () => { expect(error.message).toContain(IDENTITY_REJECTION); }); - it.each(CANONICAL_STRKEYS)('never reflects a $name in the error', ({ value }) => { - const error = captureIdentityError(value); + it.each(CANONICAL_STRKEYS)( + 'never reflects a $name in the error', + ({ value }) => { + const error = captureIdentityError(value); - // The message is fixed text; nothing interpolates the rejected value. - expect(error.message).not.toContain(value); - expect(error.message).not.toContain(value.slice(1, 12)); - }); + // The message is fixed text; nothing interpolates the rejected value. + expect(error.message).not.toContain(value); + expect(error.message).not.toContain(value.slice(1, 12)); + }, + ); it('rejects a 56-character value on length alone, whatever the prefix', () => { // Any G/S-prefixed 56-character uppercase base32 string hits the rule; the @@ -159,12 +171,16 @@ describe('values that cannot be an alias are refused (#186)', () => { it.each(NOT_AN_ALIAS)('never reflects $name in the error', ({ value }) => { const error = captureIdentityError(value); - expect(error.message).not.toContain(value); + if (value.length > 0) { + expect(error.message).not.toContain(value); + } }); it('keeps the boundary at 64 characters', () => { // 64 is the alias limit and is accepted; 65 is one past it and is not. - expect(validateIdentityAlias(`A${'a'.repeat(63)}`)).toBe(`A${'a'.repeat(63)}`); + expect(validateIdentityAlias(`A${'a'.repeat(63)}`)).toBe( + `A${'a'.repeat(63)}`, + ); expect(() => validateIdentityAlias(`A${'a'.repeat(64)}`)).toThrow( IDENTITY_REJECTION, ); @@ -172,12 +188,15 @@ describe('values that cannot be an alias are refused (#186)', () => { }); describe('the StrKey rule boundary is explicit (#186)', () => { - it.each(NEAR_MISS_STRKEYS)('accepts a $name as a plain alias', ({ value }) => { - // None of these is a valid Stellar key, so nothing secret is exposed. They - // are forwarded as an identity name and the Stellar CLI fails to resolve - // them. Pinned so a widened rule fails loudly instead of by accident. - expect(validateIdentityAlias(value)).toBe(value); - }); + it.each(NEAR_MISS_STRKEYS)( + 'accepts a $name as a plain alias', + ({ value }) => { + // None of these is a valid Stellar key, so nothing secret is exposed. They + // are forwarded as an identity name and the Stellar CLI fails to resolve + // them. Pinned so a widened rule fails loudly instead of by accident. + expect(validateIdentityAlias(value)).toBe(value); + }, + ); }); describe('named aliases keep working (#186)', () => { @@ -192,7 +211,11 @@ describe('deploy refuses a raw StrKey before spawning (#186)', () => { ({ value }) => withTempDirectory((root: string) => { // A valid workspace, so only the source can be the reason for failure. - writeFileSync(join(root, 'Cargo.toml'), '[workspace]\nmembers = []\n', 'utf8'); + writeFileSync( + join(root, 'Cargo.toml'), + '[workspace]\nmembers = []\n', + 'utf8', + ); expect(() => resolveTestnetDeploymentPlan({ @@ -208,7 +231,11 @@ describe('deploy refuses a raw StrKey before spawning (#186)', () => { 'rejects $name before any subprocess is planned', ({ value }) => withTempDirectory((root: string) => { - writeFileSync(join(root, 'Cargo.toml'), '[workspace]\nmembers = []\n', 'utf8'); + writeFileSync( + join(root, 'Cargo.toml'), + '[workspace]\nmembers = []\n', + 'utf8', + ); expect(() => resolveTestnetDeploymentPlan({ @@ -222,7 +249,11 @@ describe('deploy refuses a raw StrKey before spawning (#186)', () => { it('plans a deployment for a named alias in a generated workspace', () => withTempDirectory((root: string) => { - writeFileSync(join(root, 'Cargo.toml'), '[workspace]\nmembers = []\n', 'utf8'); + writeFileSync( + join(root, 'Cargo.toml'), + '[workspace]\nmembers = []\n', + 'utf8', + ); const plan = resolveTestnetDeploymentPlan({ cwd: root, diff --git a/tests/deploy-testnet-e2e.test.ts b/tests/deploy-testnet-e2e.test.ts index 6094d9c..83fc705 100644 --- a/tests/deploy-testnet-e2e.test.ts +++ b/tests/deploy-testnet-e2e.test.ts @@ -21,8 +21,7 @@ class MockDeployProcess implements ManagedChildProcess { readonly stdout = new PassThrough(); readonly stderr = new PassThrough(); #exitListener: - | ((code: number | null, signal: NodeJS.Signals | null) => void) - | undefined; + ((code: number | null, signal: NodeJS.Signals | null) => void) | undefined; #errorListener: ((error: Error) => void) | undefined; once(event: 'error', listener: (error: Error) => void): this; diff --git a/tests/deployment-docs-conformance.test.ts b/tests/deployment-docs-conformance.test.ts index 80d504a..0651524 100644 --- a/tests/deployment-docs-conformance.test.ts +++ b/tests/deployment-docs-conformance.test.ts @@ -196,7 +196,10 @@ describe('SW-103: Testnet deployment documentation conformance', () => { for (const cmdLine of deployCommands) { await withTempDirectory(async (root) => { - writeFileSync(join(root, 'Cargo.toml'), '[workspace]\nmembers = []\n'); + writeFileSync( + join(root, 'Cargo.toml'), + '[workspace]\nmembers = []\n', + ); // Write default config so that commands without explicit flags can resolve writeFileSync( join(root, 'stellarforge.config.json'), diff --git a/tests/dev-orchestrator-signal-idempotency.test.ts b/tests/dev-orchestrator-signal-idempotency.test.ts index 2d201a1..b104751 100644 --- a/tests/dev-orchestrator-signal-idempotency.test.ts +++ b/tests/dev-orchestrator-signal-idempotency.test.ts @@ -32,8 +32,7 @@ class FakeChild implements ManagedChildProcess { readonly killedSignals: NodeJS.Signals[] = []; #errorListener: ((error: Error) => void) | undefined; #exitListener: - | ((code: number | null, signal: NodeJS.Signals | null) => void) - | undefined; + ((code: number | null, signal: NodeJS.Signals | null) => void) | undefined; once(event: 'error', listener: (error: Error) => void): this; once( @@ -203,4 +202,4 @@ describe.each([ expect(signals.has('SIGTERM')).toBe(false); }); }, -); \ No newline at end of file +); diff --git a/tests/dev-orchestrator-startup-and-buffering.test.ts b/tests/dev-orchestrator-startup-and-buffering.test.ts index ac4d0ce..fcfc9bb 100644 --- a/tests/dev-orchestrator-startup-and-buffering.test.ts +++ b/tests/dev-orchestrator-startup-and-buffering.test.ts @@ -33,8 +33,7 @@ class FakeChild implements ManagedChildProcess { readonly killedSignals: NodeJS.Signals[] = []; #errorListener: ((error: Error) => void) | undefined; #exitListener: - | ((code: number | null, signal: NodeJS.Signals | null) => void) - | undefined; + ((code: number | null, signal: NodeJS.Signals | null) => void) | undefined; once(event: 'error', listener: (error: Error) => void): this; once( @@ -148,7 +147,7 @@ describe('dev supervisor startup order (#158)', () => { const child = new FakeChild(); const running = superviseDevProcesses([spec('api')], { - spawnProcess(planned, _environment) { + spawnProcess(planned) { seen.push({ command: planned.command, args: planned.args }); return child; }, @@ -254,11 +253,14 @@ describe('dev supervisor partial-line buffering (#159)', () => { // No trailing newline: the buffer must still reach the terminal. child.stdout.write('done'); + const ended = new Promise((resolve) => + child.stdout.once('end', resolve), + ); child.stdout.end(); - await Promise.resolve(); + await ended; expect(captured.stdoutText()).toContain('[api] done'); child.emitExit(0, null); await running; }); -}); \ No newline at end of file +}); diff --git a/tests/dev-supervisor-cleanup.test.ts b/tests/dev-supervisor-cleanup.test.ts index 261f087..9d00077 100644 --- a/tests/dev-supervisor-cleanup.test.ts +++ b/tests/dev-supervisor-cleanup.test.ts @@ -37,8 +37,7 @@ class FakeChild implements ManagedChildProcess { readonly killedSignals: NodeJS.Signals[] = []; #errorListener: ((error: Error) => void) | undefined; #exitListener: - | ((code: number | null, signal: NodeJS.Signals | null) => void) - | undefined; + ((code: number | null, signal: NodeJS.Signals | null) => void) | undefined; once(event: 'error', listener: (error: Error) => void): this; once( @@ -90,6 +89,10 @@ class FakeSignalSource implements DevSignalSource { } } + has(signal: NodeJS.Signals): boolean { + return this.#listeners.has(signal); + } + emit(signal: NodeJS.Signals): void { const listener = this.#listeners.get(signal); this.#listeners.delete(signal); @@ -110,20 +113,17 @@ describe('supervisor cleanup never orphans a child (#166)', () => { it('terminates every started child when one fails to spawn', async () => { const spawned: FakeChild[] = []; - const running = superviseDevProcesses( - specs('database', 'api', 'worker'), - { - spawnProcess(planned) { - if (planned.label === 'api') { - throw new Error('spawn EACCES'); - } - const child = new FakeChild(); - spawned.push(child); - return child; - }, - signalSource: new FakeSignalSource(), + const running = superviseDevProcesses(specs('database', 'api', 'worker'), { + spawnProcess(planned) { + if (planned.label === 'api') { + throw new Error('spawn EACCES'); + } + const child = new FakeChild(); + spawned.push(child); + return child; }, - ); + signalSource: new FakeSignalSource(), + }); await expect(running).rejects.toBeInstanceOf(SubprocessCliError); @@ -139,13 +139,10 @@ describe('supervisor cleanup never orphans a child (#166)', () => { const children = [new FakeChild(), new FakeChild(), new FakeChild()]; let index = 0; - const running = superviseDevProcesses( - specs('database', 'api', 'worker'), - { - spawnProcess: () => children[index++]!, - signalSource: new FakeSignalSource(), - }, - ); + const running = superviseDevProcesses(specs('database', 'api', 'worker'), { + spawnProcess: () => children[index++]!, + signalSource: new FakeSignalSource(), + }); children[1]?.emitExit(3, null); @@ -191,7 +188,7 @@ describe('supervisor cleanup never orphans a child (#166)', () => { children[0]?.emitExit(9, 'SIGKILL'); await expect(running).rejects.toThrow( - 'Development process \'api\' exited unexpectedly with code 9 after SIGKILL.', + "Development process 'api' exited unexpectedly with code 9 after SIGKILL.", ); }); @@ -229,4 +226,4 @@ describe('supervisor cleanup never orphans a child (#166)', () => { ); expect(children[0]?.killedSignals).toEqual(['SIGTERM']); }); -}); \ No newline at end of file +}); diff --git a/tests/docker-diagnostic.test.ts b/tests/docker-diagnostic.test.ts index a64795f..39f6010 100644 --- a/tests/docker-diagnostic.test.ts +++ b/tests/docker-diagnostic.test.ts @@ -128,5 +128,6 @@ describe('Docker diagnostic', () => { expect(diagnostic.status).toBe('warn'); expect(diagnostic.message).toContain('daemon'); expect(diagnostic.message).not.toContain('unreachable'); - }); + }, + ); }); diff --git a/tests/doctor-output-conformance.test.ts b/tests/doctor-output-conformance.test.ts index cf2fe79..7cd3325 100644 --- a/tests/doctor-output-conformance.test.ts +++ b/tests/doctor-output-conformance.test.ts @@ -64,7 +64,12 @@ function runDoctorWithChecks(checks: readonly DiagnosticCheck[]): { describe('SW-001: Doctor output snapshot conformance tests', () => { it('matches output snapshot and contract for all-pass diagnostics', () => { const checks: readonly DiagnosticCheck[] = [ - mockCheck('node', 'Node.js', 'pass', 'Supported version 22.13.0 detected.'), + mockCheck( + 'node', + 'Node.js', + 'pass', + 'Supported version 22.13.0 detected.', + ), mockCheck('npm', 'npm', 'pass', 'Supported version 10.9.2 detected.'), mockCheck('git', 'Git', 'pass', 'git version 2.45.0 detected.'), mockCheck('rust', 'Rust', 'pass', 'rustc 1.84.0 detected.'), @@ -94,7 +99,12 @@ describe('SW-001: Doctor output snapshot conformance tests', () => { it('matches output snapshot and contract for warning diagnostics (usable with warnings)', () => { const checks: readonly DiagnosticCheck[] = [ - mockCheck('node', 'Node.js', 'pass', 'Supported version 22.13.0 detected.'), + mockCheck( + 'node', + 'Node.js', + 'pass', + 'Supported version 22.13.0 detected.', + ), mockCheck('npm', 'npm', 'pass', 'Supported version 10.9.2 detected.'), mockCheck( 'docker', @@ -123,7 +133,12 @@ describe('SW-001: Doctor output snapshot conformance tests', () => { it('matches output snapshot and contract for failure diagnostics (action required)', () => { const checks: readonly DiagnosticCheck[] = [ - mockCheck('node', 'Node.js', 'pass', 'Supported version 22.13.0 detected.'), + mockCheck( + 'node', + 'Node.js', + 'pass', + 'Supported version 22.13.0 detected.', + ), mockCheck( 'stellar', 'Stellar CLI', diff --git a/tests/exit-code-reference.test.ts b/tests/exit-code-reference.test.ts index a36f2ec..437edcc 100644 --- a/tests/exit-code-reference.test.ts +++ b/tests/exit-code-reference.test.ts @@ -14,7 +14,7 @@ function documentedCodes(markdown: string): Map { let match: RegExpExecArray | null; while ((match = rowPattern.exec(markdown)) !== null) { - codes.set(Number(match[1]), match[2].trim()); + codes.set(Number(match[1]!), match[2]!.trim()); } return codes; diff --git a/tests/generator-destination-containment.test.ts b/tests/generator-destination-containment.test.ts index 7caeec6..edaf024 100644 --- a/tests/generator-destination-containment.test.ts +++ b/tests/generator-destination-containment.test.ts @@ -131,10 +131,7 @@ describe('generator destination containment', () => { }); expect(() => - planProjectDestination( - { ...input, projectName: '.' }, - emptyFileSystem, - ), + planProjectDestination({ ...input, projectName: '.' }, emptyFileSystem), ).toThrow(ValidationCliError); }); }); diff --git a/tests/helpers/mock-child-process.ts b/tests/helpers/mock-child-process.ts index cef1231..2dd3cb8 100644 --- a/tests/helpers/mock-child-process.ts +++ b/tests/helpers/mock-child-process.ts @@ -12,8 +12,7 @@ export class MockChildProcess implements ManagedChildProcess { readonly stdout = new PassThrough(); readonly stderr = new PassThrough(); #exitListener: - | ((code: number | null, signal: NodeJS.Signals | null) => void) - | undefined; + ((code: number | null, signal: NodeJS.Signals | null) => void) | undefined; #errorListener: ((error: Error) => void) | undefined; once(event: 'error', listener: (error: Error) => void): this; diff --git a/tests/helpers/package-metadata.ts b/tests/helpers/package-metadata.ts index 4691ad2..24b3c6c 100644 --- a/tests/helpers/package-metadata.ts +++ b/tests/helpers/package-metadata.ts @@ -119,9 +119,10 @@ export function assertPackageMetadata( ); // 2. Scripts — exact values - expect(pkg['scripts'], `package.json 'scripts' at ${packageJsonPath}`).toEqual( - options.scripts, - ); + expect( + pkg['scripts'], + `package.json 'scripts' at ${packageJsonPath}`, + ).toEqual(options.scripts); // 2b. Scripts — no extra keys beyond what is expected const actualScriptKeys = Object.keys( diff --git a/tests/npm-invocation-metacharacters.test.ts b/tests/npm-invocation-metacharacters.test.ts index e537fbe..8a13c5e 100644 --- a/tests/npm-invocation-metacharacters.test.ts +++ b/tests/npm-invocation-metacharacters.test.ts @@ -40,73 +40,69 @@ afterEach(() => { describe.each([ { platform: 'win32', label: 'Windows' }, { platform: 'linux', label: 'POSIX' }, -] as const)( - 'npm invocation on $label', - ({ platform, label }) => { - it('runs a safe fixed command successfully', () => { - onPlatform(platform); - - const invocation = createNpmInvocation(['run', 'test']); - - expect(Array.isArray(invocation.args)).toBe(true); - expect(invocation.args).toContain('npm'); - expect(invocation.args.slice(-2)).toEqual(['run', 'test']); - if (platform === 'win32') { - // `/d /s /c` is the documented safe wrapper: it disables AutoRun - // autorestart and treats the remainder as a single command. - expect(invocation.command).toBe('cmd.exe'); - expect(invocation.args.slice(0, 3)).toEqual(['/d', '/s', '/c']); - } else { - expect(invocation.command).toBe('npm'); - } - }); - - it.each([ - 'value & calc.exe', - 'value | more', - 'value; del important.txt', - '$(whoami)', - '`id`', - 'value > C:/Windows/System32/out.txt', - 'value && echo pwned', - 'a"b', - "a'b", - 'value\nsecond', - ])('keeps %j a single, inert argv element', (hostile) => { - onPlatform(platform); - - const invocation = createNpmInvocation(['run', 'test', '--', hostile]); - const forwarded = invocation.args[invocation.args.length - 1]; - - // Present exactly once, byte-for-byte: never split, quoted or concatenated - // into the command line. - expect(forwarded).toBe(hostile); - expect(invocation.args.filter((arg) => arg === hostile)).toHaveLength(1); - - // The fixed npm prefix is untouched, so nothing can be prepended to it. - const npmIndex = invocation.args.indexOf('npm'); - expect(npmIndex).toBeGreaterThanOrEqual(0); - expect(invocation.args.slice(0, npmIndex)).toEqual( - platform === 'win32' ? ['/d', '/s', '/c'] : [], - ); - }); - - it('never produces a single joined command string', () => { - onPlatform(platform); - - const invocation = createNpmInvocation(['run', 'test', '--', 'a && b']); - - // The whole defence is that `command` and `args` stay separate so the - // spawner can pass `shell: false`. - expect(typeof invocation.command).toBe('string'); - expect(Array.isArray(invocation.args)).toBe(true); - for (const arg of invocation.args) { - expect(typeof arg).toBe('string'); - } - expect(invocation.args.join(' ')).toContain('a && b'); - }); - }, -); +] as const)('npm invocation on $label', ({ platform }) => { + it('runs a safe fixed command successfully', () => { + onPlatform(platform); + + const invocation = createNpmInvocation(['run', 'test']); + + expect(Array.isArray(invocation.args)).toBe(true); + expect(invocation.args.slice(-2)).toEqual(['run', 'test']); + if (platform === 'win32') { + // `/d /s /c` is the documented safe wrapper: it disables AutoRun + // autorestart and treats the remainder as a single command. + expect(invocation.command).toBe('cmd.exe'); + expect(invocation.args.slice(0, 3)).toEqual(['/d', '/s', '/c']); + } else { + expect(invocation.command).toBe('npm'); + } + }); + + it.each([ + 'value & calc.exe', + 'value | more', + 'value; del important.txt', + '$(whoami)', + '`id`', + 'value > C:/Windows/System32/out.txt', + 'value && echo pwned', + 'a"b', + "a'b", + 'value\nsecond', + ])('keeps %j a single, inert argv element', (hostile) => { + onPlatform(platform); + + const invocation = createNpmInvocation(['run', 'test', '--', hostile]); + const forwarded = invocation.args[invocation.args.length - 1]; + + // Present exactly once, byte-for-byte: never split, quoted or concatenated + // into the command line. + expect(forwarded).toBe(hostile); + expect(invocation.args.filter((arg) => arg === hostile)).toHaveLength(1); + + // The fixed npm prefix is untouched, so nothing can be prepended to it. + if (platform === 'win32') { + expect(invocation.args.slice(0, 4)).toEqual(['/d', '/s', '/c', 'npm']); + } else { + expect(invocation.command).toBe('npm'); + } + }); + + it('never produces a single joined command string', () => { + onPlatform(platform); + + const invocation = createNpmInvocation(['run', 'test', '--', 'a && b']); + + // The whole defence is that `command` and `args` stay separate so the + // spawner can pass `shell: false`. + expect(typeof invocation.command).toBe('string'); + expect(Array.isArray(invocation.args)).toBe(true); + for (const arg of invocation.args) { + expect(typeof arg).toBe('string'); + } + expect(invocation.args.join(' ')).toContain('a && b'); + }); +}); describe('npm invocation argument handling', () => { it('keeps the fixed prefix when no arguments are supplied', () => { @@ -126,4 +122,4 @@ describe('npm invocation argument handling', () => { expect(invocation.command).toBe('npm'); expect(invocation.args).toEqual([]); }); -}); \ No newline at end of file +}); diff --git a/tests/quick-start-docs.test.ts b/tests/quick-start-docs.test.ts index d198de9..a03f142 100644 --- a/tests/quick-start-docs.test.ts +++ b/tests/quick-start-docs.test.ts @@ -76,7 +76,7 @@ describe('Quick Start documentation', () => { })); it('uses a configuration example that matches the schema', () => { - const block = /```json\n([\s\S]*?)```/.exec(quickStart); + const block = /```json\r?\n([\s\S]*?)```/.exec(quickStart); expect(block).not.toBeNull(); const json = (block?.[1] ?? '').trim(); diff --git a/tests/release-pack-snapshot.test.ts b/tests/release-pack-snapshot.test.ts index 61bc5fe..b87b3d3 100644 --- a/tests/release-pack-snapshot.test.ts +++ b/tests/release-pack-snapshot.test.ts @@ -2,23 +2,37 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { spawnSync } from 'node:child_process'; import { describe, expect, it } from 'vitest'; +import { createNpmInvocation } from '../src/process/npm.js'; const root = join(import.meta.dirname, '..'); const packageJson = JSON.parse( readFileSync(join(root, 'package.json'), 'utf8'), ) as { name: string; version: string }; -function runPackDryRun() { - const result = spawnSync('npm', ['pack', '--dry-run', '--json'], { - cwd: root, - encoding: 'utf8', - shell: false, - }); +function runPackDryRun(command?: string, args?: readonly string[]) { + const invocation = createNpmInvocation( + args ?? ['pack', '--dry-run', '--json'], + ); + const result = spawnSync( + command ?? invocation.command, + command ? (args ?? []) : invocation.args, + { + cwd: root, + encoding: 'utf8', + shell: false, + }, + ); + if (result.error || result.status !== 0) { + throw new Error( + `npm pack --dry-run failed: ${result.error?.message || result.stderr || result.stdout || 'unknown spawn failure'}`, + ); + } const [entry] = JSON.parse(result.stdout) as Array<{ name: string; version: string; files: Array<{ path: string }>; }>; + if (!entry) throw new Error('npm pack --dry-run returned no package entry.'); return entry; } @@ -35,7 +49,10 @@ describe('npm pack content snapshot', () => { for (const path of paths) { expect(path.startsWith('tests/')).toBe(false); expect(path.startsWith('.github/')).toBe(false); - expect(path).not.toMatch(/\.env(\..*)?$/); + const basename = path.split('/').at(-1) ?? ''; + expect( + basename === '.env.example' || !/^\.env(?:\..*)?$/.test(basename), + ).toBe(true); expect(path).not.toBe('.npmrc'); } }); @@ -60,13 +77,8 @@ describe('release dry-run artifact assertions', () => { }); it('fails actionably when the dry-run command itself is misconfigured', () => { - const result = spawnSync('npm', ['pack', '--dry-run', '--not-a-flag'], { - cwd: root, - encoding: 'utf8', - shell: false, - }); - - expect(result.status).not.toBe(0); - expect(`${result.stdout}${result.stderr}`.length).toBeGreaterThan(0); + expect(() => + runPackDryRun('stellarforge-command-that-does-not-exist'), + ).toThrow(/npm pack --dry-run failed: .*ENOENT/i); }); }); diff --git a/tests/release-workflow.test.ts b/tests/release-workflow.test.ts index 5466c6e..d69a162 100644 --- a/tests/release-workflow.test.ts +++ b/tests/release-workflow.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest'; const root = join(import.meta.dirname, '..'); function releaseJobBlock(workflow: string, name: string): string { - const lines = workflow.split('\n'); + const lines = workflow.split('\n').map((line) => line.replace(/\r$/, '')); const start = lines.indexOf(` ${name}:`); if (start === -1) throw new Error(`Release workflow job not found: ${name}`); diff --git a/tests/test-orchestration-regression.test.ts b/tests/test-orchestration-regression.test.ts index ff0cdb6..5b6020a 100644 --- a/tests/test-orchestration-regression.test.ts +++ b/tests/test-orchestration-regression.test.ts @@ -16,8 +16,7 @@ class TestChild implements ManagedChildProcess { readonly stdout = new PassThrough(); readonly stderr = new PassThrough(); #exitListener: - | ((code: number | null, signal: NodeJS.Signals | null) => void) - | undefined; + ((code: number | null, signal: NodeJS.Signals | null) => void) | undefined; once(event: 'error', listener: (error: Error) => void): this; once( diff --git a/tests/test-workflow-platform-and-exit.test.ts b/tests/test-workflow-platform-and-exit.test.ts index d3ac2c5..8b3cd70 100644 --- a/tests/test-workflow-platform-and-exit.test.ts +++ b/tests/test-workflow-platform-and-exit.test.ts @@ -52,8 +52,7 @@ class FakeChild implements ManagedChildProcess { readonly stderr = new PassThrough(); readonly killedSignals: NodeJS.Signals[] = []; #exitListener: - | ((code: number | null, signal: NodeJS.Signals | null) => void) - | undefined; + ((code: number | null, signal: NodeJS.Signals | null) => void) | undefined; once(event: 'error', listener: (error: Error) => void): this; once( @@ -134,29 +133,30 @@ describe('Cargo test exit-code propagation (#168)', () => { { code: 1, reason: 'test assertion failed' }, { code: 101, reason: 'cargo test harness panic' }, { code: 127, reason: 'cargo not executable' }, - ])('maps a non-zero Cargo exit ($code) to a subprocess failure', async ({ - code, - }) => { - const child = new FakeChild(); + ])( + 'maps a non-zero Cargo exit ($code) to a subprocess failure', + async ({ code }) => { + const child = new FakeChild(); - const running = superviseDevProcesses([cargoSpec()], { - spawnProcess: () => child, - signalSource: new FakeSignalSource(), - }); + const running = superviseDevProcesses([cargoSpec()], { + spawnProcess: () => child, + signalSource: new FakeSignalSource(), + }); - child.emitExit(code, null); + child.emitExit(code, null); - const error = await running.then( - () => undefined, - (reason: unknown) => reason, - ); + const error = await running.then( + () => undefined, + (reason: unknown) => reason, + ); - expect(error).toBeInstanceOf(SubprocessCliError); - expect((error as SubprocessCliError).userMessage).toContain( - `with code ${code}`, - ); - expect((error as SubprocessCliError).userMessage).toContain('cargo:test'); - }); + expect(error).toBeInstanceOf(SubprocessCliError); + expect((error as SubprocessCliError).userMessage).toContain( + `with code ${code}`, + ); + expect((error as SubprocessCliError).userMessage).toContain('cargo:test'); + }, + ); it('invokes Cargo with an argument array, never a joined shell string', () => { const spec = cargoSpec(); @@ -181,12 +181,7 @@ describe('resolved plan matches the platform (#170)', () => { expect(plan?.command).toBe('cmd.exe'); // Fixed wrapper flags first, then `npm run test` as separate argv entries. - expect(plan?.args.slice(0, 4)).toEqual([ - '/d', - '/s', - '/c', - 'npm', - ]); + expect(plan?.args.slice(0, 4)).toEqual(['/d', '/s', '/c', 'npm']); expect(plan?.args.slice(-2)).toEqual(['run', 'test']); }); }); @@ -242,7 +237,9 @@ describe('resolved plan matches the platform (#170)', () => { const [plan] = resolveTestProcessPlan(root); expect(plan?.label).toBe('cargo:test'); - expect(plan?.command).toBe(platform === 'win32' ? 'cargo.exe' : 'cargo'); + expect(plan?.command).toBe( + platform === 'win32' ? 'cargo.exe' : 'cargo', + ); expect(plan?.args).toEqual(['test']); } }); @@ -255,4 +252,4 @@ describe('resolved plan matches the platform (#170)', () => { expect(() => resolveTestProcessPlan(root)).toThrow(/No supported/); }); }); -}); \ No newline at end of file +}); diff --git a/tests/workflow-permissions.test.ts b/tests/workflow-permissions.test.ts index fdae1e6..fb42769 100644 --- a/tests/workflow-permissions.test.ts +++ b/tests/workflow-permissions.test.ts @@ -20,7 +20,8 @@ describe('GHA least-privilege permissions audit (SW-105)', () => { const all = auditWorkflowDirectory(fixtures); expect( all.some( - (v) => v.file === 'pr-ci-bad-write-all.yml' && /write-all/.test(v.message), + (v) => + v.file === 'pr-ci-bad-write-all.yml' && /write-all/.test(v.message), ), ).toBe(true); }); @@ -38,10 +39,8 @@ describe('GHA least-privilege permissions audit (SW-105)', () => { it('allows release job allowlisted writes', () => { const all = auditWorkflowDirectory(fixtures); - // release-ok.yml uses job ids version/publish — may still flag if allowlist key is release.yml only - // For fixtures, basename is release-ok.yml so elevated jobs are not allowlisted by filename; - // document that production allowlist keys real release.yml. - // Here we assert bad OIDC job fails: + // The fixture deliberately uses an unallowlisted workflow basename and + // must not inherit production release exceptions. expect( all.some( (v) =>