From 88bedc771cb7073ff70c3ac06944d669fce80144 Mon Sep 17 00:00:00 2001 From: Jason Joseph Nathan <780157+pipewrk@users.noreply.github.com> Date: Thu, 13 Nov 2025 10:05:44 +0800 Subject: [PATCH 1/3] Task 65b: Installer timing budgets --- .env.example | 6 + .../critical-create-generate-failure.md | 3 + .../commands/__tests__/create.command.test.ts | 14 +- .../commands/__tests__/init.runtime.test.ts | 28 ++-- .../commands/__tests__/init.workflow.test.ts | 135 ++++++++++++++++++ packages/cli/src/commands/create.ts | 22 ++- .../cli/src/commands/init/command-runtime.ts | 14 +- packages/cli/src/commands/init/pipeline.ts | 97 ++++++++++++- packages/cli/src/commands/init/shared.ts | 11 +- packages/cli/src/commands/init/timing.ts | 78 ++++++++++ packages/cli/src/commands/init/types.ts | 34 ++++- .../cli/src/commands/init/workflow-support.ts | 4 + packages/cli/src/commands/init/workflow.ts | 22 ++- 13 files changed, 439 insertions(+), 29 deletions(-) create mode 100644 packages/cli/src/commands/init/timing.ts diff --git a/.env.example b/.env.example index e2c2f4c9..6a49b303 100644 --- a/.env.example +++ b/.env.example @@ -71,6 +71,12 @@ WPK_PREFER_REGISTRY_VERSIONS=0 # The URL of the npm registry to use. # REGISTRY_URL=https://registry.npmjs.org/ +# Maximum duration (ms) allowed for npm installation during wpk init/create before failing. +# WPK_INIT_INSTALL_NODE_MAX_MS=180000 + +# Maximum duration (ms) allowed for composer installation during wpk init/create before failing. +# WPK_INIT_INSTALL_COMPOSER_MAX_MS=120000 + # The import.meta.url of the CLI. WPK_CLI_IMPORT_META_URL= diff --git a/docs/.vitepress/critical-create-generate-failure.md b/docs/.vitepress/critical-create-generate-failure.md index 480d4eaa..055a2c77 100644 --- a/docs/.vitepress/critical-create-generate-failure.md +++ b/docs/.vitepress/critical-create-generate-failure.md @@ -67,6 +67,7 @@ Capture deterministic timing metrics for installers and composer healers. **Completion log.** Update after each run: - [x] Pipeline adapter (65a) now routes init/create through `createInitPipeline` for staged logging. +- [x] Installers instrumented (65b) capturing npm/composer timings with env-configurable budgets. #### 65a — Pipeline adapter for init/create @@ -219,6 +220,8 @@ What to do: - Mention the new env vars / budgets so CI (and the smoke harness) know how to raise or lower tolerances when containers are slow. +- Current overrides: `WPK_INIT_INSTALL_NODE_MAX_MS` (default 180000ms) and + `WPK_INIT_INSTALL_COMPOSER_MAX_MS` (default 120000ms). - Note that the timing metrics are now available in the pipeline artifact for Task 65d and the packed workflow. diff --git a/packages/cli/src/commands/__tests__/create.command.test.ts b/packages/cli/src/commands/__tests__/create.command.test.ts index ef9540b0..59b01344 100644 --- a/packages/cli/src/commands/__tests__/create.command.test.ts +++ b/packages/cli/src/commands/__tests__/create.command.test.ts @@ -117,6 +117,10 @@ describe('CreateCommand', () => { expect.objectContaining({ workspace, projectName: 'demo', + installDependencies: true, + installers: expect.objectContaining({ + installNodeDependencies: npmInstall, + }), }) ); expect(ensureDirectory).toHaveBeenCalledWith( @@ -126,7 +130,6 @@ describe('CreateCommand', () => { force: false, }) ); - expect(npmInstall).toHaveBeenCalledWith(workspace.root); expect(readinessPlan).toHaveBeenCalledWith([ 'workspace-hygiene', 'git', @@ -186,7 +189,14 @@ describe('CreateCommand', () => { const exit = await command.execute(); expect(exit).toBe(WPK_EXIT_CODES.SUCCESS); - expect(npmInstall).not.toHaveBeenCalled(); + expect(workflow).toHaveBeenCalledWith( + expect.objectContaining({ + installDependencies: false, + installers: expect.objectContaining({ + installNodeDependencies: npmInstall, + }), + }) + ); expect(readinessPlan).toHaveBeenCalledWith([ 'workspace-hygiene', 'git', diff --git a/packages/cli/src/commands/__tests__/init.runtime.test.ts b/packages/cli/src/commands/__tests__/init.runtime.test.ts index 149c1fa3..a8958f0b 100644 --- a/packages/cli/src/commands/__tests__/init.runtime.test.ts +++ b/packages/cli/src/commands/__tests__/init.runtime.test.ts @@ -74,19 +74,21 @@ describe('init command runtime helpers', () => { await runtime.runWorkflow(); - expect(runWorkflow).toHaveBeenCalledWith({ - workspace, - reporter, - projectName: 'demo', - template: 'plugin', - force: true, - verbose: false, - preferRegistryVersionsFlag: true, - env: { - WPK_PREFER_REGISTRY_VERSIONS: '1', - REGISTRY_URL: 'https://registry.test', - }, - }); + expect(runWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ + workspace, + reporter, + projectName: 'demo', + template: 'plugin', + force: true, + verbose: false, + preferRegistryVersionsFlag: true, + env: expect.objectContaining({ + WPK_PREFER_REGISTRY_VERSIONS: '1', + REGISTRY_URL: 'https://registry.test', + }), + }) + ); expect(runtime.readiness.registry).toBeDefined(); expect(runtime.readiness.context.workspace).toBe(workspace); diff --git a/packages/cli/src/commands/__tests__/init.workflow.test.ts b/packages/cli/src/commands/__tests__/init.workflow.test.ts index 0552640e..5db3498d 100644 --- a/packages/cli/src/commands/__tests__/init.workflow.test.ts +++ b/packages/cli/src/commands/__tests__/init.workflow.test.ts @@ -1,7 +1,18 @@ import { createCommandReporterHarness } from '@wpkernel/test-utils/cli'; +import { WPKernelError } from '@wpkernel/core/error'; import { makeWorkspaceMock } from '../../../tests/workspace.test-support'; import { runInitWorkflow } from '../init/workflow'; import type { InitWorkflowOptions } from '../init/types'; +import { + installNodeDependencies, + installComposerDependencies, +} from '../init/installers'; +import { + measureStage, + resolveInstallBudgets, + DEFAULT_NODE_INSTALL_BUDGET_MS, + DEFAULT_COMPOSER_INSTALL_BUDGET_MS, +} from '../init/timing'; import { assertNoCollisions, buildPathsReplacement, @@ -42,6 +53,20 @@ jest.mock('../init/dependency-versions', () => ({ resolveDependencyVersions: jest.fn(), })); +jest.mock('../init/installers', () => ({ + installNodeDependencies: jest.fn(), + installComposerDependencies: jest.fn(), +})); + +jest.mock('../init/timing', () => { + const actual = jest.requireActual('../init/timing'); + return { + ...actual, + measureStage: jest.fn(), + resolveInstallBudgets: jest.fn(), + } satisfies Partial; +}); + const resolveDependencyVersionsMock = resolveDependencyVersions as jest.MockedFunction; const writeScaffoldFilesMock = @@ -57,6 +82,20 @@ const buildPathsReplacementMock = buildPathsReplacement as jest.MockedFunction; const writePackageJsonMock = writePackageJson as jest.MockedFunction; +const installNodeDependenciesMock = + installNodeDependencies as jest.MockedFunction< + typeof installNodeDependencies + >; +const installComposerDependenciesMock = + installComposerDependencies as jest.MockedFunction< + typeof installComposerDependencies + >; +const measureStageMock = measureStage as jest.MockedFunction< + typeof measureStage +>; +const resolveInstallBudgetsMock = resolveInstallBudgets as jest.MockedFunction< + typeof resolveInstallBudgets +>; function createWorkspace(overrides: Partial = {}): Workspace { return makeWorkspaceMock({ @@ -93,6 +132,16 @@ describe('runInitWorkflow', () => { buildReplacementMapMock.mockReturnValue(new Map()); writePackageJsonMock.mockResolvedValue('updated'); resolveDependencyVersionsMock.mockResolvedValue(dependencyResolution); + installNodeDependenciesMock.mockResolvedValue(undefined); + installComposerDependenciesMock.mockResolvedValue(undefined); + resolveInstallBudgetsMock.mockReturnValue({ + npm: DEFAULT_NODE_INSTALL_BUDGET_MS, + composer: DEFAULT_COMPOSER_INSTALL_BUDGET_MS, + }); + measureStageMock.mockImplementation(async ({ run, budgetMs }) => { + await run(); + return { durationMs: 0, budgetMs }; + }); }); it('logs dependency resolution details when verbose and env requests registry versions', async () => { @@ -155,4 +204,90 @@ describe('runInitWorkflow', () => { expect(rollback).toHaveBeenCalledWith('init'); expect(reporter.info).not.toHaveBeenCalled(); }); + + it('captures installer timings when installation is enabled', async () => { + const workspace = createWorkspace(); + const reporters = createCommandReporterHarness(); + const reporter = reporters.create(); + + writeScaffoldFilesMock.mockResolvedValue([ + { path: 'wpk.config.ts', status: 'created' }, + { path: 'composer.json', status: 'created' }, + ]); + + resolveInstallBudgetsMock.mockReturnValue({ + npm: 5_000, + composer: 7_000, + }); + measureStageMock + .mockImplementationOnce(async ({ run, budgetMs }) => { + await run(); + return { durationMs: 1_234, budgetMs }; + }) + .mockImplementationOnce(async ({ run, budgetMs }) => { + await run(); + return { durationMs: 2_345, budgetMs }; + }); + + const result = await runInitWorkflow({ + workspace, + reporter, + installDependencies: true, + }); + + expect(measureStageMock).toHaveBeenCalledWith( + expect.objectContaining({ stage: 'init.install.npm' }) + ); + expect(installNodeDependenciesMock).toHaveBeenCalledWith( + workspace.root + ); + expect(measureStageMock).toHaveBeenCalledWith( + expect.objectContaining({ stage: 'init.install.composer' }) + ); + expect(installComposerDependenciesMock).toHaveBeenCalledWith( + workspace.root + ); + expect(result.installations).toEqual({ + npm: { durationMs: 1_234, budgetMs: 5_000 }, + composer: { durationMs: 2_345, budgetMs: 7_000 }, + }); + }); + + it('propagates budget errors emitted by measured installers', async () => { + const workspace = createWorkspace(); + const reporters = createCommandReporterHarness(); + const reporter = reporters.create(); + + writeScaffoldFilesMock.mockResolvedValueOnce([ + { path: 'wpk.config.ts', status: 'created' }, + { path: 'composer.json', status: 'created' }, + { path: 'package.json', status: 'updated' }, + ]); + + measureStageMock + .mockImplementationOnce(async ({ run, budgetMs }) => { + await run(); + return { durationMs: 1_000, budgetMs }; + }) + .mockImplementationOnce(async () => { + throw new WPKernelError('EnvironmentalError', { + data: { reason: 'budget.exceeded' }, + }); + }); + + await expect( + runInitWorkflow({ + workspace, + reporter, + installDependencies: true, + }) + ).rejects.toEqual( + expect.objectContaining({ + code: 'EnvironmentalError', + data: expect.objectContaining({ reason: 'budget.exceeded' }), + }) + ); + expect(installNodeDependenciesMock).toHaveBeenCalledTimes(1); + expect(installComposerDependenciesMock).not.toHaveBeenCalled(); + }); }); diff --git a/packages/cli/src/commands/create.ts b/packages/cli/src/commands/create.ts index 9fa01efa..9e9a3e7f 100644 --- a/packages/cli/src/commands/create.ts +++ b/packages/cli/src/commands/create.ts @@ -5,7 +5,10 @@ import { WPKernelError } from '@wpkernel/core/error'; import { createReporterCLI as buildReporter } from '../utils/reporter.js'; import { buildWorkspace, ensureCleanDirectory } from '../workspace'; import { runInitWorkflow } from './init/workflow'; -import { installNodeDependencies } from './init/installers'; +import { + installNodeDependencies, + installComposerDependencies, +} from './init/installers'; import { type InitCommandRuntimeDependencies } from './init/command-runtime'; import { InitCommandBase, @@ -47,6 +50,8 @@ export interface BuildCreateCommandOptions { readonly ensureCleanDirectory?: typeof ensureCleanDirectory; /** Optional: Custom Node.js dependency installer function. */ readonly installNodeDependencies?: typeof installNodeDependencies; + /** Optional: Custom Composer dependency installer function. */ + readonly installComposerDependencies?: typeof installComposerDependencies; } /** @@ -72,6 +77,7 @@ interface CreateDependencies { readonly runtime: InitCommandRuntimeDependencies; readonly ensureCleanDirectory: typeof ensureCleanDirectory; readonly installNodeDependencies: typeof installNodeDependencies; + readonly installComposerDependencies: typeof installComposerDependencies; readonly loadWPKernelConfig: () => Promise; } @@ -88,6 +94,8 @@ function mergeDependencies( ensureCleanDirectoryOverride = ensureCleanDirectory, installNodeDependencies: installNodeDependenciesOverride = installNodeDependencies, + installComposerDependencies: + installComposerDependenciesOverride = installComposerDependencies, } = options; return { @@ -99,6 +107,7 @@ function mergeDependencies( }, ensureCleanDirectory: ensureCleanDirectoryOverride, installNodeDependencies: installNodeDependenciesOverride, + installComposerDependencies: installComposerDependenciesOverride, loadWPKernelConfig: loadWPKernelConfigOverride, } satisfies CreateDependencies; } @@ -158,6 +167,13 @@ export function buildCreateCommand( dependencies, readinessHelperFactories ), + installDependencies: this.skipInstall !== true, + installers: { + installNodeDependencies: + dependencies.installNodeDependencies, + installComposerDependencies: + dependencies.installComposerDependencies, + }, }); } } @@ -224,11 +240,7 @@ function buildCreateCommandHooks( runtime.reporter.warn( 'Skipping dependency installation (--skip-install provided).' ); - return; } - - runtime.reporter.info('Installing npm dependencies...'); - await dependencies.installNodeDependencies(runtime.workspace.root); }, } satisfies InitCommandHooks; } diff --git a/packages/cli/src/commands/init/command-runtime.ts b/packages/cli/src/commands/init/command-runtime.ts index eee98dcf..671a16ef 100644 --- a/packages/cli/src/commands/init/command-runtime.ts +++ b/packages/cli/src/commands/init/command-runtime.ts @@ -6,7 +6,11 @@ import type { import { type WPKernelError } from '@wpkernel/core/error'; import type { Command } from 'clipanion'; import type { Workspace } from '../../workspace'; -import type { InitWorkflowOptions, InitWorkflowResult } from './types'; +import type { + InitWorkflowOptions, + InitWorkflowResult, + InitWorkflowInstallers, +} from './types'; import { parseStringOption } from './utils'; import { type BuildDefaultReadinessRegistryOptions, @@ -45,6 +49,8 @@ export interface InitCommandRuntimeOptions { readonly env?: InitWorkflowOptions['env']; readonly readiness?: BuildDefaultReadinessRegistryOptions; readonly allowDirty?: boolean; + readonly installDependencies?: boolean; + readonly installers?: Partial; } export interface InitCommandRuntimeResult { @@ -126,7 +132,13 @@ export function createInitCommandRuntime( WPK_PREFER_REGISTRY_VERSIONS: process.env.WPK_PREFER_REGISTRY_VERSIONS, REGISTRY_URL: process.env.REGISTRY_URL, + WPK_INIT_INSTALL_NODE_MAX_MS: + process.env.WPK_INIT_INSTALL_NODE_MAX_MS, + WPK_INIT_INSTALL_COMPOSER_MAX_MS: + process.env.WPK_INIT_INSTALL_COMPOSER_MAX_MS, }, + installDependencies: options.installDependencies, + installers: options.installers, }; const readinessRegistry = buildReadinessRegistry(options.readiness); diff --git a/packages/cli/src/commands/init/pipeline.ts b/packages/cli/src/commands/init/pipeline.ts index e5e19116..c5f88103 100644 --- a/packages/cli/src/commands/init/pipeline.ts +++ b/packages/cli/src/commands/init/pipeline.ts @@ -21,6 +21,7 @@ import { parseStringOption, shouldPreferRegistryVersions, slugify, + fileExists, } from './utils'; import { createEmptyPluginDetection, @@ -32,12 +33,19 @@ import { buildWorkflowResult, logDependencyResolution, } from './workflow-support'; +import { + DEFAULT_COMPOSER_INSTALL_BUDGET_MS, + DEFAULT_NODE_INSTALL_BUDGET_MS, + measureStage, + resolveInstallBudgets, +} from './timing'; import type { InitPipelineArtifact, InitPipelineContext, InitPipelineDraft, InitPipelineRunOptions, InitWorkflowResult, + InstallationMeasurements, } from './types'; const INIT_FRAGMENT_KEY_NAMESPACE = 'init.namespace'; @@ -45,6 +53,7 @@ const INIT_FRAGMENT_KEY_DETECT = 'init.detect'; const INIT_FRAGMENT_KEY_COLLISIONS = 'init.collisions'; const INIT_FRAGMENT_KEY_DEPENDENCIES = 'init.dependencies'; const INIT_BUILDER_KEY_SCAFFOLD = 'init.scaffold'; +const INIT_BUILDER_KEY_INSTALL = 'init.install'; const INIT_BUILDER_KEY_RESULT = 'init.result'; type InitFragmentHelper = Helper< InitPipelineContext, @@ -122,6 +131,7 @@ export function createInitPipeline() { pipeline.ir.use(createCollisionHelper()); pipeline.ir.use(createDependencyHelper()); pipeline.builders.use(createScaffoldBuilder()); + pipeline.builders.use(createInstallBuilder()); pipeline.builders.use(createResultBuilder()); return pipeline; @@ -143,7 +153,7 @@ function createNamespaceHelper(): InitFragmentHelper { apply({ context, output }) { const namespace = slugify( parseStringOption(context.options.projectName) ?? - path.basename(context.workspace.root) + path.basename(context.workspace.root) ); output.namespace = namespace; @@ -264,11 +274,71 @@ function createScaffoldBuilder(): InitBuilderHelper { }); } +function createInstallBuilder(): InitBuilderHelper { + return createHelper({ + key: INIT_BUILDER_KEY_INSTALL, + kind: 'builder', + dependsOn: [INIT_BUILDER_KEY_SCAFFOLD], + async apply({ context, input, output, reporter }) { + if (!context.options.installDependencies) { + return; + } + + const budgets = resolveInstallBudgets(context.options.env); + const installers = context.options.installers; + const existingInstallations: InstallationMeasurements = + output.installations ?? {}; + + reporter.info('Installing npm dependencies...'); + const npmMeasurement = await measureStage({ + stage: 'init.install.npm', + label: 'Installing npm dependencies', + budgetMs: budgets.npm ?? DEFAULT_NODE_INSTALL_BUDGET_MS, + reporter, + run: () => + installers.installNodeDependencies(context.workspace.root), + }); + + let nextInstallations: InstallationMeasurements = { + ...existingInstallations, + npm: npmMeasurement, + }; + + if ( + await shouldInstallComposer({ + summaries: input.summaries ?? [], + workspace: context.workspace, + }) + ) { + reporter.info('Installing composer dependencies...'); + const composerMeasurement = await measureStage({ + stage: 'init.install.composer', + label: 'Installing composer dependencies', + budgetMs: + budgets.composer ?? DEFAULT_COMPOSER_INSTALL_BUDGET_MS, + reporter, + run: () => + installers.installComposerDependencies( + context.workspace.root + ), + }); + + nextInstallations = { + ...nextInstallations, + composer: composerMeasurement, + }; + } + + output.installations = nextInstallations; + }, + }); +} + function createResultBuilder(): InitBuilderHelper { return createHelper({ key: INIT_BUILDER_KEY_RESULT, kind: 'builder', - dependsOn: [INIT_BUILDER_KEY_SCAFFOLD], + dependsOn: [INIT_BUILDER_KEY_INSTALL], apply({ input, output }) { if (!input.manifest) { throw new WPKernelError('DeveloperError', { @@ -283,6 +353,7 @@ function createResultBuilder(): InitBuilderHelper { templateName: input.templateName, namespace: input.namespace, dependencySource: input.dependencyResolution.source, + installations: input.installations, }); }, }); @@ -296,6 +367,7 @@ function finalizeDraft(draft: InitPipelineDraft): InitPipelineArtifact { draft.pluginDetection ?? createEmptyPluginDetection(); const dependencyResolution = draft.dependencyResolution; const replacements = draft.replacements; + const installations = draft.installations; if ( !namespace || @@ -321,5 +393,24 @@ function finalizeDraft(draft: InitPipelineDraft): InitPipelineArtifact { summaries: draft.summaries, manifest: draft.manifest, result: draft.result, - }; + installations, + } satisfies InitPipelineArtifact; +} + +async function shouldInstallComposer({ + summaries, + workspace, +}: { + readonly summaries: InitPipelineArtifact['summaries']; + readonly workspace: InitPipelineContext['workspace']; +}): Promise { + const composerSummary = summaries.find( + (entry) => entry.path === 'composer.json' + ); + + if (composerSummary && composerSummary.status !== 'skipped') { + return true; + } + + return fileExists(workspace, 'composer.json'); } diff --git a/packages/cli/src/commands/init/shared.ts b/packages/cli/src/commands/init/shared.ts index 5cc9f9e7..ac08e2e8 100644 --- a/packages/cli/src/commands/init/shared.ts +++ b/packages/cli/src/commands/init/shared.ts @@ -14,7 +14,7 @@ import { type ReadinessHelperDescriptor, type ReadinessKey, } from '../../dx'; -import type { InitWorkflowResult } from './types'; +import type { InitWorkflowResult, InitWorkflowInstallers } from './types'; export interface InitCommandState { name?: string; @@ -31,8 +31,7 @@ export interface InitCommandState { export abstract class InitCommandBase extends Command - implements InitCommandState -{ + implements InitCommandState { name = Option.String('--name', { description: 'Project slug used for namespace/package defaults', required: false, @@ -128,6 +127,8 @@ export interface RunInitCommandOptions { readonly dependencies: InitCommandRuntimeDependencies; readonly hooks?: InitCommandHooks; readonly allowDirty?: boolean; + readonly installDependencies?: boolean; + readonly installers?: Partial; } export interface RunInitCommandResult { @@ -149,6 +150,8 @@ export async function runInitCommand({ dependencies, allowDirty = false, hooks = {}, + installDependencies, + installers, }: RunInitCommandOptions): Promise { const cwd = resolveCommandCwd(command.context); const workspaceRoot = resolveWorkspaceRootForCommand(cwd, command, hooks); @@ -165,6 +168,8 @@ export async function runInitCommand({ preferRegistryVersions: command.preferRegistryVersions, readiness, allowDirty, + installDependencies, + installers, }); const context: InitCommandContext = { workspaceRoot, cwd }; diff --git a/packages/cli/src/commands/init/timing.ts b/packages/cli/src/commands/init/timing.ts new file mode 100644 index 00000000..406e5620 --- /dev/null +++ b/packages/cli/src/commands/init/timing.ts @@ -0,0 +1,78 @@ +import { performance } from 'node:perf_hooks'; +import { WPKernelError } from '@wpkernel/core/error'; +import type { Reporter } from '@wpkernel/core/reporter'; +import type { InitWorkflowEnv, StageMeasurement } from './types'; + +export interface MeasureStageOptions { + readonly stage: string; + readonly label: string; + readonly budgetMs: number; + readonly reporter: Reporter; + readonly run: () => Promise; +} + +export interface InstallBudgets { + readonly npm: number; + readonly composer: number; +} + +export const DEFAULT_NODE_INSTALL_BUDGET_MS = 180_000; +export const DEFAULT_COMPOSER_INSTALL_BUDGET_MS = 120_000; + +export async function measureStage({ + stage, + label, + budgetMs, + reporter, + run, +}: MeasureStageOptions): Promise { + const start = performance.now(); + + await run(); + + const durationMs = Math.round(performance.now() - start); + + reporter.info( + `${label} completed in ${durationMs}ms (budget ${budgetMs}ms)` + ); + + if (budgetMs > 0 && durationMs > budgetMs) { + throw new WPKernelError('EnvironmentalError', { + message: `${label} exceeded ${budgetMs}ms budget (took ${durationMs}ms)`, + data: { + reason: 'budget.exceeded', + stage, + durationMs, + budgetMs, + }, + }); + } + + return { durationMs, budgetMs }; +} + +export function resolveInstallBudgets(env?: InitWorkflowEnv): InstallBudgets { + return { + npm: parseBudget( + env?.WPK_INIT_INSTALL_NODE_MAX_MS, + DEFAULT_NODE_INSTALL_BUDGET_MS + ), + composer: parseBudget( + env?.WPK_INIT_INSTALL_COMPOSER_MAX_MS, + DEFAULT_COMPOSER_INSTALL_BUDGET_MS + ), + } satisfies InstallBudgets; +} + +function parseBudget(value: string | undefined, fallback: number): number { + if (!value) { + return fallback; + } + + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return fallback; + } + + return parsed; +} diff --git a/packages/cli/src/commands/init/types.ts b/packages/cli/src/commands/init/types.ts index 46048fb5..17a30a9f 100644 --- a/packages/cli/src/commands/init/types.ts +++ b/packages/cli/src/commands/init/types.ts @@ -1,11 +1,17 @@ import type { Reporter } from '@wpkernel/core/reporter'; import type { FileManifest, Workspace } from '../../workspace'; import type { DependencyResolution } from './dependency-versions'; +import type { + installComposerDependencies, + installNodeDependencies, +} from './installers'; import type { ScaffoldFileDescriptor, ScaffoldStatus } from './utils'; export interface InitWorkflowEnv { readonly WPK_PREFER_REGISTRY_VERSIONS?: string; readonly REGISTRY_URL?: string; + readonly WPK_INIT_INSTALL_NODE_MAX_MS?: string; + readonly WPK_INIT_INSTALL_COMPOSER_MAX_MS?: string; } export interface InitWorkflowOptions { @@ -17,6 +23,8 @@ export interface InitWorkflowOptions { readonly verbose?: boolean; readonly preferRegistryVersionsFlag?: boolean; readonly env?: InitWorkflowEnv; + readonly installDependencies?: boolean; + readonly installers?: Partial; } export type ScaffoldSummary = { path: string; status: ScaffoldStatus }; @@ -28,6 +36,7 @@ export interface InitWorkflowResult { readonly dependencySource: string; readonly namespace: string; readonly templateName: string; + readonly installations?: InstallationMeasurements; } export interface PluginDetectionResult { @@ -36,6 +45,21 @@ export interface PluginDetectionResult { readonly skipTargets: readonly string[]; } +export interface StageMeasurement { + readonly durationMs: number; + readonly budgetMs: number; +} + +export interface InstallationMeasurements { + readonly npm?: StageMeasurement; + readonly composer?: StageMeasurement; +} + +export interface InitWorkflowInstallers { + readonly installNodeDependencies: typeof installNodeDependencies; + readonly installComposerDependencies: typeof installComposerDependencies; +} + export interface InitPipelineDraft { namespace?: string; templateName?: string; @@ -47,6 +71,7 @@ export interface InitPipelineDraft { summaries: ScaffoldSummary[]; manifest?: FileManifest; result?: InitWorkflowResult; + installations?: InstallationMeasurements; } export interface InitPipelineArtifact { @@ -60,9 +85,16 @@ export interface InitPipelineArtifact { summaries: ScaffoldSummary[]; manifest?: FileManifest; result?: InitWorkflowResult; + installations?: InstallationMeasurements; } -export type InitPipelineRunOptions = InitWorkflowOptions; +export type InitPipelineRunOptions = Omit< + InitWorkflowOptions, + 'installers' | 'installDependencies' +> & { + readonly installDependencies: boolean; + readonly installers: InitWorkflowInstallers; +}; export interface InitPipelineContext { readonly workspace: Workspace; diff --git a/packages/cli/src/commands/init/workflow-support.ts b/packages/cli/src/commands/init/workflow-support.ts index 47cee1c1..5f195b1d 100644 --- a/packages/cli/src/commands/init/workflow-support.ts +++ b/packages/cli/src/commands/init/workflow-support.ts @@ -5,6 +5,7 @@ import { writeScaffoldFiles } from './scaffold'; import type { DependencyResolution } from './dependency-versions'; import type { InitWorkflowResult, + InstallationMeasurements, PluginDetectionResult, ScaffoldSummary, } from './types'; @@ -85,12 +86,14 @@ export function buildWorkflowResult({ templateName, namespace, dependencySource, + installations, }: { readonly manifest: InitWorkflowResult['manifest']; readonly summaries: ScaffoldSummary[]; readonly templateName: string; readonly namespace: string; readonly dependencySource: string; + readonly installations?: InstallationMeasurements; }): InitWorkflowResult { return { manifest, @@ -103,6 +106,7 @@ export function buildWorkflowResult({ dependencySource, namespace, templateName, + installations, }; } diff --git a/packages/cli/src/commands/init/workflow.ts b/packages/cli/src/commands/init/workflow.ts index 09951f98..42717ffa 100644 --- a/packages/cli/src/commands/init/workflow.ts +++ b/packages/cli/src/commands/init/workflow.ts @@ -1,10 +1,30 @@ import type { InitWorkflowOptions, InitWorkflowResult } from './types'; import { runInitPipeline } from './pipeline'; +import { + installComposerDependencies, + installNodeDependencies, +} from './installers'; export type { InitWorkflowOptions, InitWorkflowResult } from './types'; export async function runInitWorkflow( options: InitWorkflowOptions ): Promise { - return runInitPipeline(options); + const { + installers: installerOverrides, + installDependencies, + ...baseOptions + } = options; + + const installers = { + installNodeDependencies, + installComposerDependencies, + ...installerOverrides, + }; + + return runInitPipeline({ + ...baseOptions, + installDependencies: installDependencies === true, + installers, + }); } From 52318c5915d67b49c26e407ec90daa7797c35083 Mon Sep 17 00:00:00 2001 From: Jason Nathan <780157+jasonnathan@users.noreply.github.com> Date: Thu, 13 Nov 2025 14:25:33 +0800 Subject: [PATCH 2/3] massive fixes for hygiene --- artifacts/storage-states/admin.json | 1 - .../cli/type-aliases/CreateCommandInstance.md | 8 - .../__tests__/controller.routeNames.test.ts | 47 ++++++ .../controller.storageArtifacts.test.ts | 124 ++++++++++++++ .../commands/__tests__/create.command.test.ts | 107 ++++--------- .../__tests__/init.installers.test.ts | 144 +++++++++-------- .../commands/__tests__/init.workflow.test.ts | 1 - packages/cli/src/commands/apply.ts | 42 +++-- packages/cli/src/commands/create.ts | 26 +-- packages/cli/src/commands/doctor.ts | 15 +- packages/cli/src/commands/generate.ts | 26 ++- packages/cli/src/commands/init/installers.ts | 136 ++++++++++++---- packages/cli/src/commands/init/pipeline.ts | 151 +++++++++++++++--- packages/cli/src/commands/init/timing.ts | 12 +- .../src/dx/readiness/__tests__/helper.test.ts | 73 +++++++++ .../cli/src/dx/readiness/test/test-support.ts | 1 + .../cli/src/utils/__tests__/progress.test.ts | 106 ++++++++++++ packages/cli/src/utils/progress.ts | 117 ++++++++++++++ packages/cli/src/utils/reporter.ts | 4 + packages/create-wpk/CHANGELOG.md | 2 +- packages/create-wpk/README.md | 4 +- packages/create-wpk/src/index.ts | 2 - scripts/test/smoke-create-generate.mjs | 88 +++++++++- 23 files changed, 970 insertions(+), 267 deletions(-) delete mode 100644 artifacts/storage-states/admin.json create mode 100644 packages/cli/src/builders/php/__tests__/controller.routeNames.test.ts create mode 100644 packages/cli/src/builders/php/__tests__/controller.storageArtifacts.test.ts create mode 100644 packages/cli/src/dx/readiness/__tests__/helper.test.ts create mode 100644 packages/cli/src/utils/__tests__/progress.test.ts create mode 100644 packages/cli/src/utils/progress.ts diff --git a/artifacts/storage-states/admin.json b/artifacts/storage-states/admin.json deleted file mode 100644 index e10a19bf..00000000 --- a/artifacts/storage-states/admin.json +++ /dev/null @@ -1 +0,0 @@ -{"cookies":[{"name":"wordpress_test_cookie","value":"WP%20Cookie%20check","domain":"localhost","path":"/","expires":-1,"httpOnly":true,"secure":false,"sameSite":"Lax"},{"name":"wp-settings-time-1","value":"1759841276","domain":"localhost","path":"/","expires":1791377276.628,"httpOnly":false,"secure":false,"sameSite":"Lax"},{"name":"wordpress_23778236db82f19306f247e20a353a99","value":"admin%7C1760014076%7CYZxMGzejhwPVeco8ZiGqY9GuszS5sTiFY0SYFi5toxv%7C705dbda0012e20b91dc66bbfffbd1e41582221fbc5b9a437f43acad1fb781487","domain":"localhost","path":"/wp-content/plugins","expires":-1,"httpOnly":true,"secure":false,"sameSite":"Lax"},{"name":"wordpress_23778236db82f19306f247e20a353a99","value":"admin%7C1760014076%7CYZxMGzejhwPVeco8ZiGqY9GuszS5sTiFY0SYFi5toxv%7C705dbda0012e20b91dc66bbfffbd1e41582221fbc5b9a437f43acad1fb781487","domain":"localhost","path":"/wp-admin","expires":-1,"httpOnly":true,"secure":false,"sameSite":"Lax"},{"name":"wordpress_logged_in_23778236db82f19306f247e20a353a99","value":"admin%7C1760014076%7CYZxMGzejhwPVeco8ZiGqY9GuszS5sTiFY0SYFi5toxv%7C33787e01c2b8d522310bb76146566510079dcdaadede650d40f7f191530d1495","domain":"localhost","path":"/","expires":-1,"httpOnly":true,"secure":false,"sameSite":"Lax"}],"nonce":"e11e845dd8","rootURL":"http://localhost:8889/wp-json/"} \ No newline at end of file diff --git a/docs/api/@wpkernel/cli/type-aliases/CreateCommandInstance.md b/docs/api/@wpkernel/cli/type-aliases/CreateCommandInstance.md index 569a857b..2517841c 100644 --- a/docs/api/@wpkernel/cli/type-aliases/CreateCommandInstance.md +++ b/docs/api/@wpkernel/cli/type-aliases/CreateCommandInstance.md @@ -14,14 +14,6 @@ Represents an instance of the `create` command. ## Type Declaration -### skipInstall - -```ts -skipInstall: boolean; -``` - -Whether to skip dependency installation. - ### target? ```ts diff --git a/packages/cli/src/builders/php/__tests__/controller.routeNames.test.ts b/packages/cli/src/builders/php/__tests__/controller.routeNames.test.ts new file mode 100644 index 00000000..ce6e2026 --- /dev/null +++ b/packages/cli/src/builders/php/__tests__/controller.routeNames.test.ts @@ -0,0 +1,47 @@ +import { + buildRouteMethodName, + deriveRouteSegments, +} from '../controller.routeNames'; +import type { IRRoute, IRv1 } from '../../../ir/publicTypes'; + +function createIr(overrides: Partial = {}): IRv1 { + return { + meta: { + namespace: 'wpk/v1', + sanitizedNamespace: 'wpk/v1', + ...overrides, + }, + } as unknown as IRv1; +} + +describe('controller route naming helpers', () => { + it('builds semantic method names by removing namespace segments', () => { + const ir = createIr(); + const route = { + method: 'GET', + path: '/wpk/v1/jobs/:jobId', + } as IRRoute; + + expect(buildRouteMethodName(route, ir)).toBe('getJobsJobId'); + }); + + it('falls back to Route suffix when no path segments exist', () => { + const ir = createIr(); + const route = { + method: 'POST', + path: '/', + } as IRRoute; + + expect(buildRouteMethodName(route, ir)).toBe('postRoute'); + }); + + it('derives segments when namespace does not match', () => { + const ir = createIr({ + namespace: 'other/v2', + sanitizedNamespace: 'other/v2', + }); + const segments = deriveRouteSegments('/wpk/v1/jobs/:id', ir); + + expect(segments).toEqual(['wpk', 'v1', 'jobs', 'id']); + }); +}); diff --git a/packages/cli/src/builders/php/__tests__/controller.storageArtifacts.test.ts b/packages/cli/src/builders/php/__tests__/controller.storageArtifacts.test.ts new file mode 100644 index 00000000..ace1aa60 --- /dev/null +++ b/packages/cli/src/builders/php/__tests__/controller.storageArtifacts.test.ts @@ -0,0 +1,124 @@ +import { + buildStorageArtifacts, + resolveRouteMutationMetadata, + buildCacheKeyPlan, +} from '../controller.storageArtifacts'; +import type { ResourceStorageHelperState } from '../types'; +import type { IRResource } from '../../../ir'; + +jest.mock('@wpkernel/wp-json-ast', () => ({ + buildResourceCacheKeysPlan: jest.fn().mockReturnValue('cache-plan'), +})); + +const { buildResourceCacheKeysPlan } = jest.requireMock( + '@wpkernel/wp-json-ast' +); + +function createState(): ResourceStorageHelperState { + return { + transient: new Map(), + wpOption: new Map(), + wpTaxonomy: new Map(), + }; +} + +function createResource(overrides: Partial = {}): IRResource { + return { + name: 'jobs', + cacheKeys: { + list: { segments: ['jobs'] }, + get: { segments: ['jobs', ':id'] }, + create: { segments: ['jobs', 'create'] }, + update: { segments: ['jobs', 'update'] }, + remove: { segments: ['jobs', 'remove'] }, + }, + ...overrides, + } as unknown as IRResource; +} + +describe('controller storage artifacts', () => { + it('returns taxonomy artifacts when state contains entries', () => { + const state = createState(); + state.wpTaxonomy.set('jobs', { + helperMethods: ['taxonomyMethod'], + helperSignatures: ['taxonomySignature'], + routeHandlers: ['taxonomyHandler'] as unknown as never, + }); + + const artifacts = buildStorageArtifacts({ + resource: createResource({ storage: { mode: 'wp-taxonomy' } }), + storageState: state, + }); + + expect(artifacts).toEqual({ + helperMethods: ['taxonomyMethod'], + helperSignatures: ['taxonomySignature'], + routeHandlers: ['taxonomyHandler'], + }); + }); + + it('returns transient artifacts and empty signatures when missing state', () => { + const state = createState(); + state.transient.set('jobs', { + helperMethods: ['transientMethod'], + routeHandlers: ['transientHandler'] as unknown as never, + }); + + const artifacts = buildStorageArtifacts({ + resource: createResource({ storage: { mode: 'transient' } }), + storageState: state, + }); + + expect(artifacts).toEqual({ + helperMethods: ['transientMethod'], + helperSignatures: [], + transientHandlers: ['transientHandler'], + }); + }); + + it('returns default artifacts when resource has no storage mode', () => { + const state = createState(); + const artifacts = buildStorageArtifacts({ + resource: createResource({ storage: undefined }), + storageState: state, + }); + + expect(artifacts).toEqual({ + helperMethods: [], + helperSignatures: [], + }); + }); + + it('resolves route mutation metadata for wp-post storage', () => { + expect( + resolveRouteMutationMetadata( + createResource({ storage: { mode: 'wp-post' } }) + ) + ).toEqual({ channelTag: 'resource.wpPost.mutation' }); + expect( + resolveRouteMutationMetadata( + createResource({ storage: { mode: 'transient' } }) + ) + ).toBeUndefined(); + }); + + it('builds cache key plan with optional segments removed when undefined', () => { + const resource = createResource({ + cacheKeys: { + list: { segments: ['jobs'] }, + get: { segments: ['jobs', ':id'] }, + create: undefined, + update: undefined, + remove: undefined, + }, + }); + + const result = buildCacheKeyPlan(resource); + + expect(result).toBe('cache-plan'); + expect(buildResourceCacheKeysPlan).toHaveBeenCalledWith({ + list: { segments: ['jobs'] }, + get: { segments: ['jobs', ':id'] }, + }); + }); +}); diff --git a/packages/cli/src/commands/__tests__/create.command.test.ts b/packages/cli/src/commands/__tests__/create.command.test.ts index 59b01344..1d9c3440 100644 --- a/packages/cli/src/commands/__tests__/create.command.test.ts +++ b/packages/cli/src/commands/__tests__/create.command.test.ts @@ -5,6 +5,7 @@ import { assignCommandContext } from '@wpkernel/test-utils/cli'; import { makeWorkspaceMock } from '../../../tests/workspace.test-support'; import { buildCreateCommand } from '../create'; import type { buildWorkspace } from '../../workspace/filesystem'; +import type { InitWorkflowOptions } from '../init/types'; describe('CreateCommand', () => { let loadWPKernelConfig: jest.Mock; @@ -63,16 +64,24 @@ describe('CreateCommand', () => { }, ]; - it('runs init workflow, readiness plan, and installs npm dependencies', async () => { - const workflow = jest.fn().mockResolvedValue({ - manifest: { writes: [], deletes: [] }, - summaryText: '[wpk] init created plugin scaffold for demo\n', - summaries: [], - dependencySource: 'fallback', - namespace: 'demo', - templateName: 'plugin', - }); + it('runs init workflow, readiness plan, and configures installers', async () => { + let capturedWorkflowOptions: InitWorkflowOptions | undefined; + const workflow = jest + .fn() + .mockImplementation(async (options: InitWorkflowOptions) => { + capturedWorkflowOptions = options; + return { + manifest: { writes: [], deletes: [] }, + summaryText: + '[wpk] init created plugin scaffold for demo\n', + summaries: [], + dependencySource: 'fallback', + namespace: 'demo', + templateName: 'plugin', + }; + }); const npmInstall = jest.fn().mockResolvedValue(undefined); + const composerInstall = jest.fn().mockResolvedValue(undefined); const ensureDirectory = jest.fn().mockResolvedValue(undefined); const readinessRun = jest.fn().mockResolvedValue({ outcomes: [] }); let capturedContext: unknown; @@ -101,6 +110,7 @@ describe('CreateCommand', () => { ensureCleanDirectory: ensureDirectory, buildReadinessRegistry: buildReadinessRegistry as never, loadWPKernelConfig, + installComposerDependencies: composerInstall, }); const command = new CreateCommand(); @@ -113,16 +123,7 @@ describe('CreateCommand', () => { const exit = await command.execute(); expect(exit).toBe(WPK_EXIT_CODES.SUCCESS); - expect(workflow).toHaveBeenCalledWith( - expect.objectContaining({ - workspace, - projectName: 'demo', - installDependencies: true, - installers: expect.objectContaining({ - installNodeDependencies: npmInstall, - }), - }) - ); + expect(workflow).toHaveBeenCalled(); expect(ensureDirectory).toHaveBeenCalledWith( expect.objectContaining({ workspace, @@ -145,67 +146,13 @@ describe('CreateCommand', () => { .environment.allowDirty ).toBe(false); expect(stdout.toString()).toContain('plugin scaffold'); - }); - - it('skips installers when --skip-install is provided', async () => { - const workflow = jest.fn().mockResolvedValue({ - manifest: { writes: [], deletes: [] }, - summaryText: 'summary\n', - summaries: [], - dependencySource: 'fallback', - namespace: 'demo', - templateName: 'plugin', - }); - const npmInstall = jest.fn().mockResolvedValue(undefined); - const readinessRun = jest.fn().mockResolvedValue({ outcomes: [] }); - const readinessPlan = jest - .fn() - .mockImplementation((keys: string[]) => ({ - keys, - run: readinessRun, - })); - - const workspace = makeWorkspaceMock({ root: process.cwd() }); - - const CreateCommand = buildCreateCommand({ - buildWorkspace: (() => workspace) as typeof buildWorkspace, - runWorkflow: workflow, - installNodeDependencies: npmInstall, - ensureCleanDirectory: jest.fn().mockResolvedValue(undefined), - buildReadinessRegistry: (() => ({ - register: jest.fn(), - plan: readinessPlan, - describe: () => helperDescriptors, - })) as never, - loadWPKernelConfig, - }); - - const command = new CreateCommand(); - command.skipInstall = true; - const { stdout } = assignCommandContext(command, { - cwd: process.cwd(), - }); - - const exit = await command.execute(); - - expect(exit).toBe(WPK_EXIT_CODES.SUCCESS); - expect(workflow).toHaveBeenCalledWith( - expect.objectContaining({ - installDependencies: false, - installers: expect.objectContaining({ - installNodeDependencies: npmInstall, - }), - }) - ); - expect(readinessPlan).toHaveBeenCalledWith([ - 'workspace-hygiene', - 'git', - 'php-runtime', - 'php-codemod-ingestion', - 'php-printer-path', - ]); - expect(readinessRun).toHaveBeenCalledTimes(1); - expect(stdout.toString()).toContain('summary'); + expect(capturedWorkflowOptions?.installDependencies).toBe(true); + expect( + capturedWorkflowOptions?.installers?.installNodeDependencies + ).toBe(npmInstall); + expect( + capturedWorkflowOptions?.installers?.installComposerDependencies + ).toBe(composerInstall); }); it('propagates --allow-dirty to readiness context', async () => { diff --git a/packages/cli/src/commands/__tests__/init.installers.test.ts b/packages/cli/src/commands/__tests__/init.installers.test.ts index df1322f7..16a17ad8 100644 --- a/packages/cli/src/commands/__tests__/init.installers.test.ts +++ b/packages/cli/src/commands/__tests__/init.installers.test.ts @@ -1,3 +1,4 @@ +import { EventEmitter } from 'node:events'; import { WPKernelError } from '@wpkernel/core/error'; import { installComposerDependencies, @@ -5,90 +6,103 @@ import { } from '../init/installers'; import type { InstallerDependencies } from '../init/installers'; -describe('init installers', () => { - it('executes npm install with the provided exec implementation', async () => { - const execMock = jest.fn( - async ( - command: string, - args: string[], - options: { cwd: string } - ) => { - expect(command).toBe('npm'); - expect(args).toEqual(['install']); - expect(options.cwd).toBe('/tmp/project'); +function createSpawnMock({ + onClose, + onError, +}: { + onClose?: (emit: EventEmitter['emit']) => void; + onError?: (emit: EventEmitter['emit']) => void; +} = {}) { + return jest.fn(() => { + const child = new EventEmitter(); + process.nextTick(() => { + if (onClose) { + onClose(child.emit.bind(child)); + } else { + child.emit('close', 0, null); } - ); - const exec = execMock as unknown as InstallerDependencies['exec']; + }); + + if (onError) { + process.nextTick(() => onError(child.emit.bind(child))); + } + return child as unknown as ReturnType< + NonNullable + >; + }); +} + +describe('init installers', () => { + it('spawns npm install while streaming output', async () => { + const spawnMock = createSpawnMock(); await expect( - installNodeDependencies('/tmp/project', { exec }) - ).resolves.toBeUndefined(); - expect(execMock).toHaveBeenCalledTimes(1); + installNodeDependencies('/tmp/project', { + spawn: spawnMock as unknown as InstallerDependencies['spawn'], + }) + ).resolves.toEqual({ stdout: '', stderr: '' }); + expect(spawnMock).toHaveBeenCalledWith('npm', ['install'], { + cwd: '/tmp/project', + stdio: ['inherit', 'pipe', 'pipe'], + }); }); it('wraps npm installation failures in a developer wpk error', async () => { - const execMock = jest.fn(async () => { - const error = new Error('npm failure'); - (error as { stderr?: string }).stderr = 'fatal'; - (error as { stdout?: string }).stdout = 'diagnostics'; - throw error; + const spawnMock = createSpawnMock({ + onClose: (emit) => emit('close', 1, null), }); - const exec = execMock as unknown as InstallerDependencies['exec']; - await expect( - installNodeDependencies('/tmp/project', { exec }) + installNodeDependencies('/tmp/project', { + spawn: spawnMock as unknown as InstallerDependencies['spawn'], + }) ).rejects.toBeInstanceOf(WPKernelError); - await installNodeDependencies('/tmp/project', { exec }).catch( - (error) => { - expect(error).toBeInstanceOf(WPKernelError); - const kernelError = error as WPKernelError; - expect(kernelError.code).toBe('DeveloperError'); - expect(kernelError.message).toBe( - 'Failed to install npm dependencies.' - ); - expect(kernelError.context).toEqual({ - message: 'npm failure', - stderr: 'fatal', - stdout: 'diagnostics', - }); - } - ); + await installNodeDependencies('/tmp/project', { + spawn: spawnMock as unknown as InstallerDependencies['spawn'], + }).catch((error) => { + expect(error).toBeInstanceOf(WPKernelError); + const kernelError = error as WPKernelError; + expect(kernelError.code).toBe('DeveloperError'); + expect(kernelError.message).toBe( + 'Failed to install npm dependencies.' + ); + expect(kernelError.context).toEqual({ + message: 'npm exited with code 1', + exitCode: 1, + signal: undefined, + }); + }); }); it('executes composer install and surfaces error context on failure', async () => { - const successExecMock = jest.fn( - async ( - command: string, - args: string[], - options: { cwd: string } - ) => { - expect(command).toBe('composer'); - expect(args).toEqual(['install']); - expect(options.cwd).toBe('/tmp/project'); - } - ); - const successExec = - successExecMock as unknown as InstallerDependencies['exec']; - + const successSpawn = createSpawnMock(); await expect( - installComposerDependencies('/tmp/project', { exec: successExec }) - ).resolves.toBeUndefined(); + installComposerDependencies('/tmp/project', { + spawn: successSpawn as unknown as InstallerDependencies['spawn'], + }) + ).resolves.toEqual({ stdout: '', stderr: '' }); + expect(successSpawn).toHaveBeenCalledWith('composer', ['install'], { + cwd: '/tmp/project', + stdio: ['inherit', 'pipe', 'pipe'], + }); - const failingExecMock = jest.fn(async () => { - const error = new Error('composer failure'); - (error as { stderr?: string }).stderr = 'stderr output'; - throw error; + const failingSpawn = createSpawnMock({ + onClose: () => undefined, + onError: (emit) => + emit('error', { + message: 'composer failure', + exitCode: 127, + }), }); - const failingExec = - failingExecMock as unknown as InstallerDependencies['exec']; await expect( - installComposerDependencies('/tmp/project', { exec: failingExec }) + installComposerDependencies('/tmp/project', { + spawn: failingSpawn as unknown as InstallerDependencies['spawn'], + }) ).rejects.toBeInstanceOf(WPKernelError); await installComposerDependencies('/tmp/project', { - exec: failingExec, + spawn: failingSpawn as unknown as InstallerDependencies['spawn'], }).catch((error) => { expect(error).toBeInstanceOf(WPKernelError); const kernelError = error as WPKernelError; @@ -98,8 +112,8 @@ describe('init installers', () => { ); expect(kernelError.context).toEqual({ message: 'composer failure', - stderr: 'stderr output', - stdout: undefined, + exitCode: 127, + signal: undefined, }); }); }); diff --git a/packages/cli/src/commands/__tests__/init.workflow.test.ts b/packages/cli/src/commands/__tests__/init.workflow.test.ts index 5db3498d..940b1f3e 100644 --- a/packages/cli/src/commands/__tests__/init.workflow.test.ts +++ b/packages/cli/src/commands/__tests__/init.workflow.test.ts @@ -202,7 +202,6 @@ describe('runInitWorkflow', () => { ).rejects.toThrow('scaffold failed'); expect(rollback).toHaveBeenCalledWith('init'); - expect(reporter.info).not.toHaveBeenCalled(); }); it('captures installer timings when installation is enabled', async () => { diff --git a/packages/cli/src/commands/apply.ts b/packages/cli/src/commands/apply.ts index ef688c7c..7ac7f9fb 100644 --- a/packages/cli/src/commands/apply.ts +++ b/packages/cli/src/commands/apply.ts @@ -25,6 +25,7 @@ import type { Workspace } from '../workspace'; import { cleanupWorkspaceTargets } from './apply/cleanup'; import { runCommandReadiness } from './readiness'; import { resolveCommandCwd } from './init/command-runtime'; +import { runWithProgress, formatDuration } from '../utils/progress'; /** * The path to the apply log file within the workspace. @@ -134,10 +135,17 @@ export function buildApplyCommand( allowDirty: flags.allowDirty, }); - const preview = await previewPatches({ - dependencies, - workspace: activeWorkspace, - loaded, + const { result: preview } = await runWithProgress({ + reporter, + label: 'Evaluating pending patches', + run: () => + previewPatches({ + dependencies, + workspace: activeWorkspace, + loaded, + }), + successMessage: (durationMs) => + `✓ Patch preview completed in ${formatDuration(durationMs)}.`, }); if (flags.cleanup.length > 0) { @@ -162,18 +170,30 @@ export function buildApplyCommand( } if (flags.backup) { - await dependencies.createBackups({ - workspace: activeWorkspace, - manifest: preview.workspaceManifest, + await runWithProgress({ reporter, + label: 'Creating workspace backups', + run: () => + dependencies.createBackups({ + workspace: activeWorkspace, + manifest: preview.workspaceManifest, + reporter, + }), }); } - const manifest = await executeApply({ - dependencies, - workspace: activeWorkspace, - loaded, + const { result: manifest } = await runWithProgress({ reporter, + label: 'Applying workspace patches', + run: () => + executeApply({ + dependencies, + workspace: activeWorkspace, + loaded, + reporter, + }), + successMessage: (durationMs) => + `✓ Apply completed in ${formatDuration(durationMs)}.`, }); withCommandState(this, manifest); diff --git a/packages/cli/src/commands/create.ts b/packages/cli/src/commands/create.ts index 9e9a3e7f..34183e50 100644 --- a/packages/cli/src/commands/create.ts +++ b/packages/cli/src/commands/create.ts @@ -62,8 +62,6 @@ export interface BuildCreateCommandOptions { export type CreateCommandInstance = InitCommandBase & { /** The target directory for the new project. */ target?: string; - /** Whether to skip dependency installation. */ - skipInstall: boolean; }; /** @@ -142,15 +140,10 @@ export function buildCreateCommand( examples: [ ['Create project in current directory', 'wpk create'], ['Create project in ./demo-plugin', 'wpk create demo-plugin'], - [ - 'Create without installing dependencies', - 'wpk create demo --skip-install', - ], ], }); target = Option.String({ name: 'directory', required: false }); - skipInstall = Option.Boolean('--skip-install', false); override async execute(): Promise { const readinessHelperFactories = @@ -167,7 +160,7 @@ export function buildCreateCommand( dependencies, readinessHelperFactories ), - installDependencies: this.skipInstall !== true, + installDependencies: true, installers: { installNodeDependencies: dependencies.installNodeDependencies, @@ -216,15 +209,7 @@ function buildCreateCommandHooks( .map((helper) => helper.key) .filter((key) => allowed.has(key)); - if (command.skipInstall !== true) { - return ordered; - } - - return ordered.filter((key) => { - const helper = helpers.find((entry) => entry.key === key); - const tags = helper?.metadata.tags ?? []; - return !tags.includes('requires-install'); - }); + return ordered; }, prepare: async (runtime, context: InitCommandContext) => { await dependencies.ensureCleanDirectory({ @@ -235,13 +220,6 @@ function buildCreateCommandHooks( reporter: runtime.reporter, }); }, - afterReadiness: async (runtime) => { - if (command.skipInstall === true) { - runtime.reporter.warn( - 'Skipping dependency installation (--skip-install provided).' - ); - } - }, } satisfies InitCommandHooks; } diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 4fc182fc..9c155da7 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -20,6 +20,7 @@ import { } from '../dx'; import { getCliPackageRoot } from '../utils/module-url'; import { resolveCommandCwd } from './init/command-runtime'; +import { runWithProgress, formatDuration } from '../utils/progress'; /** * Status of a doctor check. @@ -371,13 +372,19 @@ export function buildDoctorCommand( workspaceRoot, cwd, }); - const result = await plan.run(context); + const { result: readinessResult } = await runWithProgress({ + reporter, + label: 'Running doctor readiness checks', + run: () => plan.run(context), + successMessage: (durationMs) => + `✓ Doctor readiness completed in ${formatDuration(durationMs)}.`, + }); - if (result.error) { - throw result.error; + if (readinessResult.error) { + throw readinessResult.error; } - return result.outcomes.map((outcome) => + return readinessResult.outcomes.map((outcome) => mapReadinessOutcome(outcome, helperLookup) ); } diff --git a/packages/cli/src/commands/generate.ts b/packages/cli/src/commands/generate.ts index ceeaf0e7..56692bbb 100644 --- a/packages/cli/src/commands/generate.ts +++ b/packages/cli/src/commands/generate.ts @@ -28,6 +28,7 @@ import { } from '../apply/manifest'; import { runCommandReadiness } from './readiness'; import { resolveCommandCwd } from './init/command-runtime'; +import { runWithProgress, formatDuration } from '../utils/progress'; // Re-export types from sub-modules for TypeDoc export type { GenerationSummary } from './run-generate/types'; @@ -147,17 +148,26 @@ async function runGenerateWorkflow( tracked.workspace.begin(TRANSACTION_LABEL); try { - const result = await pipeline.run({ - phase: 'generate', - config: loaded.config, - namespace: loaded.namespace, - origin: loaded.configOrigin, - sourcePath: loaded.sourcePath, - workspace: tracked.workspace, + const { result: pipelineResult } = await runWithProgress({ reporter, - generationState: previousGenerationState, + label: 'Running generation pipeline', + run: () => + pipeline.run({ + phase: 'generate', + config: loaded.config, + namespace: loaded.namespace, + origin: loaded.configOrigin, + sourcePath: loaded.sourcePath, + workspace: tracked.workspace, + reporter, + generationState: previousGenerationState, + }), + successMessage: (durationMs) => + `✓ Generation pipeline completed in ${formatDuration(durationMs)}.`, }); + const result = pipelineResult; + logDiagnostics(reporter, result.diagnostics); const nextGenerationState = buildGenerationManifestFromIr( diff --git a/packages/cli/src/commands/init/installers.ts b/packages/cli/src/commands/init/installers.ts index 06d04ff1..ce3daca2 100644 --- a/packages/cli/src/commands/init/installers.ts +++ b/packages/cli/src/commands/init/installers.ts @@ -1,57 +1,131 @@ -import { execFile as execFileCallback } from 'node:child_process'; -import { promisify } from 'node:util'; +import { spawn as spawnProcess } from 'node:child_process'; import { WPKernelError } from '@wpkernel/core/error'; -const execFile = promisify(execFileCallback); - export interface InstallerDependencies { - readonly exec?: typeof execFile; + readonly spawn?: typeof spawnProcess; +} + +export interface InstallerResult { + stdout: string; + stderr: string; } export async function installNodeDependencies( cwd: string, - { exec = execFile }: InstallerDependencies = {} -): Promise { - try { - await exec('npm', ['install'], { cwd }); - } catch (error) { - throw new WPKernelError('DeveloperError', { - message: 'Failed to install npm dependencies.', - context: serialiseExecError(error), - }); - } + dependencies: InstallerDependencies = {} +): Promise { + return runInstallerCommand( + { + command: 'npm', + args: ['install'], + cwd, + errorMessage: 'Failed to install npm dependencies.', + }, + dependencies + ); } export async function installComposerDependencies( cwd: string, - { exec = execFile }: InstallerDependencies = {} -): Promise { - try { - await exec('composer', ['install'], { cwd }); - } catch (error) { - throw new WPKernelError('DeveloperError', { - message: 'Failed to install composer dependencies.', - context: serialiseExecError(error), + dependencies: InstallerDependencies = {} +): Promise { + return runInstallerCommand( + { + command: 'composer', + args: ['install'], + cwd, + errorMessage: 'Failed to install composer dependencies.', + }, + dependencies + ); +} + +async function runInstallerCommand( + { + command, + args, + cwd, + errorMessage, + }: { + command: string; + args: readonly string[]; + cwd: string; + errorMessage: string; + }, + { spawn = spawnProcess }: InstallerDependencies +): Promise { + let capturedStdout = ''; + let capturedStderr = ''; + + const child = spawn(command, args, { + cwd, + stdio: ['inherit', 'pipe', 'pipe'], + }); + + child.stdout?.on('data', (chunk) => { + const value = chunk.toString(); + capturedStdout += value; + process.stdout.write(value); + }); + + child.stderr?.on('data', (chunk) => { + const value = chunk.toString(); + capturedStderr += value; + process.stderr.write(value); + }); + + return new Promise((resolve, reject) => { + const handleError = (error: unknown) => { + reject( + new WPKernelError('DeveloperError', { + message: errorMessage, + context: serialiseSpawnError(error), + }) + ); + }; + + child.on('error', handleError); + child.on('close', (code, signal) => { + if (code === 0) { + resolve({ + stdout: capturedStdout, + stderr: capturedStderr, + }); + return; + } + + handleError({ + message: + typeof code === 'number' + ? `${command} exited with code ${code}` + : `Process terminated by signal ${signal}`, + exitCode: code ?? undefined, + signal: signal ?? undefined, + }); }); - } + }); } -function serialiseExecError(error: unknown): { +function serialiseSpawnError(error: unknown): { message: string; - stderr?: string; - stdout?: string; + exitCode?: number; + signal?: NodeJS.Signals; } { if (!error || typeof error !== 'object') { return { message: String(error) }; } const message = String((error as { message?: unknown }).message ?? error); - const stderr = (error as { stderr?: unknown }).stderr; - const stdout = (error as { stdout?: unknown }).stdout; + const exitCode = (error as { exitCode?: unknown }).exitCode; + const signal = (error as { signal?: unknown }).signal; return { message, - stderr: typeof stderr === 'string' ? stderr : undefined, - stdout: typeof stdout === 'string' ? stdout : undefined, + exitCode: + typeof exitCode === 'number' && Number.isFinite(exitCode) + ? exitCode + : undefined, + signal: + typeof signal === 'string' ? (signal as NodeJS.Signals) : undefined, }; } diff --git a/packages/cli/src/commands/init/pipeline.ts b/packages/cli/src/commands/init/pipeline.ts index c5f88103..e7c7fb19 100644 --- a/packages/cli/src/commands/init/pipeline.ts +++ b/packages/cli/src/commands/init/pipeline.ts @@ -36,9 +36,9 @@ import { import { DEFAULT_COMPOSER_INSTALL_BUDGET_MS, DEFAULT_NODE_INSTALL_BUDGET_MS, - measureStage, resolveInstallBudgets, } from './timing'; +import { measureStageWithProgress } from '../../utils/progress'; import type { InitPipelineArtifact, InitPipelineContext, @@ -46,6 +46,7 @@ import type { InitPipelineRunOptions, InitWorkflowResult, InstallationMeasurements, + ScaffoldSummary, } from './types'; const INIT_FRAGMENT_KEY_NAMESPACE = 'init.namespace'; @@ -150,15 +151,22 @@ function createNamespaceHelper(): InitFragmentHelper { key: INIT_FRAGMENT_KEY_NAMESPACE, kind: 'fragment', mode: 'override', - apply({ context, output }) { + apply({ context, output, reporter }) { + reporter.info('Preparing project namespace and template...'); + const namespace = slugify( parseStringOption(context.options.projectName) ?? - path.basename(context.workspace.root) + path.basename(context.workspace.root) ); + const templateName = context.options.template ?? 'plugin'; output.namespace = namespace; - output.templateName = context.options.template ?? 'plugin'; + output.templateName = templateName; output.scaffoldFiles = buildScaffoldDescriptors(namespace); + + reporter.info( + `Using namespace "${namespace}" with the ${templateName} template.` + ); }, }); } @@ -168,18 +176,34 @@ function createDetectionHelper(): InitFragmentHelper { key: INIT_FRAGMENT_KEY_DETECT, kind: 'fragment', dependsOn: [INIT_FRAGMENT_KEY_NAMESPACE], - apply: async ({ context, output }) => { + apply: async ({ context, output, reporter }) => { const force = context.options.force === true; if (force) { + reporter.info( + 'Force mode enabled; skipping existing plugin detection.' + ); output.pluginDetection = createEmptyPluginDetection(); return; } + reporter.info('Scanning workspace for existing plugin files...'); const descriptors = output.scaffoldFiles ?? []; output.pluginDetection = await detectExistingPlugin({ workspace: context.workspace, descriptors, }); + + if (!output.pluginDetection.detected) { + reporter.info( + 'No existing plugin detected. Writing full template files.' + ); + return; + } + + const reasonText = formatReasonList(output.pluginDetection.reasons); + reporter.warn( + `Existing plugin detected (${reasonText}); preserving author-owned files.` + ); }, }); } @@ -189,7 +213,9 @@ function createCollisionHelper(): InitFragmentHelper { key: INIT_FRAGMENT_KEY_COLLISIONS, kind: 'fragment', dependsOn: [INIT_FRAGMENT_KEY_DETECT], - apply: async ({ context, output }) => { + apply: async ({ context, output, reporter }) => { + reporter.info('Checking for conflicting files...'); + const descriptors = output.scaffoldFiles ?? []; const detection = output.pluginDetection ?? createEmptyPluginDetection(); @@ -205,6 +231,15 @@ function createCollisionHelper(): InitFragmentHelper { collisionSkips: skipped, pluginDetection: detection, }); + + if (skipped.length === 0) { + reporter.info('No conflicting files detected.'); + return; + } + + reporter.info( + `Skipping ${skipped.length} conflicting file${skipped.length === 1 ? '' : 's'} (use --force to overwrite).` + ); }, }); } @@ -215,6 +250,8 @@ function createDependencyHelper(): InitFragmentHelper { kind: 'fragment', dependsOn: [INIT_FRAGMENT_KEY_COLLISIONS], apply: async ({ context, output, reporter }) => { + reporter.info('Resolving dependency versions...'); + const preferRegistryVersions = shouldPreferRegistryVersions({ cliFlag: context.options.preferRegistryVersionsFlag === true, env: context.options.env?.WPK_PREFER_REGISTRY_VERSIONS, @@ -228,6 +265,9 @@ function createDependencyHelper(): InitFragmentHelper { } ); + reporter.info( + `Dependencies resolved from ${dependencyResolution.source}.` + ); logDependencyResolution({ reporter, verbose: context.options.verbose === true, @@ -248,6 +288,7 @@ function createScaffoldBuilder(): InitBuilderHelper { key: INIT_BUILDER_KEY_SCAFFOLD, kind: 'builder', apply: async ({ context, input, output, reporter }) => { + reporter.info('Scaffolding WPKernel project files...'); context.workspace.begin('init'); try { @@ -266,6 +307,14 @@ function createScaffoldBuilder(): InitBuilderHelper { const manifest = await context.workspace.commit('init'); output.summaries = summaries; output.manifest = manifest; + + const stats = summarizeScaffoldSummaries(summaries); + reporter.info( + formatScaffoldSummaryMessage( + stats, + manifest?.writes.length ?? 0 + ) + ); } catch (error) { await context.workspace.rollback('init').catch(() => undefined); throw error; @@ -289,14 +338,16 @@ function createInstallBuilder(): InitBuilderHelper { const existingInstallations: InstallationMeasurements = output.installations ?? {}; - reporter.info('Installing npm dependencies...'); - const npmMeasurement = await measureStage({ - stage: 'init.install.npm', + const npmMeasurement = await measureStageWithProgress({ + reporter, label: 'Installing npm dependencies', + stage: 'init.install.npm', budgetMs: budgets.npm ?? DEFAULT_NODE_INSTALL_BUDGET_MS, - reporter, - run: () => - installers.installNodeDependencies(context.workspace.root), + run: async () => { + await installers.installNodeDependencies( + context.workspace.root + ); + }, }); let nextInstallations: InstallationMeasurements = { @@ -310,17 +361,17 @@ function createInstallBuilder(): InitBuilderHelper { workspace: context.workspace, }) ) { - reporter.info('Installing composer dependencies...'); - const composerMeasurement = await measureStage({ - stage: 'init.install.composer', + const composerMeasurement = await measureStageWithProgress({ + reporter, label: 'Installing composer dependencies', + stage: 'init.install.composer', budgetMs: budgets.composer ?? DEFAULT_COMPOSER_INSTALL_BUDGET_MS, - reporter, - run: () => - installers.installComposerDependencies( + run: async () => { + await installers.installComposerDependencies( context.workspace.root - ), + ); + }, }); nextInstallations = { @@ -414,3 +465,65 @@ async function shouldInstallComposer({ return fileExists(workspace, 'composer.json'); } + +function formatReasonList(values: readonly string[]): string { + if (values.length === 0) { + return 'unknown reason'; + } + + if (values.length === 1) { + return values[0] ?? 'unknown reason'; + } + + const head = values.slice(0, -1).join(', '); + const tail = values[values.length - 1] ?? ''; + return `${head} and ${tail}`; +} + +function summarizeScaffoldSummaries(summaries: readonly ScaffoldSummary[]): { + created: number; + updated: number; + skipped: number; + total: number; +} { + const stats = { created: 0, updated: 0, skipped: 0, total: 0 }; + + for (const summary of summaries) { + stats.total += 1; + switch (summary.status) { + case 'created': { + stats.created += 1; + + break; + } + case 'updated': { + stats.updated += 1; + + break; + } + case 'skipped': { + stats.skipped += 1; + + break; + } + // No default + } + } + + return stats; +} + +function formatScaffoldSummaryMessage( + stats: ReturnType, + manifestWrites: number +): string { + const segments = [ + `created: ${stats.created}`, + `updated: ${stats.updated}`, + `skipped: ${stats.skipped}`, + ]; + + const totalWrites = + manifestWrites > 0 ? ` (${manifestWrites} files committed)` : ''; + return `Project files written${totalWrites} (${segments.join(', ')}).`; +} diff --git a/packages/cli/src/commands/init/timing.ts b/packages/cli/src/commands/init/timing.ts index 406e5620..d7314f6d 100644 --- a/packages/cli/src/commands/init/timing.ts +++ b/packages/cli/src/commands/init/timing.ts @@ -7,8 +7,9 @@ export interface MeasureStageOptions { readonly stage: string; readonly label: string; readonly budgetMs: number; - readonly reporter: Reporter; + readonly reporter?: Reporter; readonly run: () => Promise; + readonly logCompletion?: boolean; } export interface InstallBudgets { @@ -25,6 +26,7 @@ export async function measureStage({ budgetMs, reporter, run, + logCompletion = true, }: MeasureStageOptions): Promise { const start = performance.now(); @@ -32,9 +34,11 @@ export async function measureStage({ const durationMs = Math.round(performance.now() - start); - reporter.info( - `${label} completed in ${durationMs}ms (budget ${budgetMs}ms)` - ); + if (logCompletion && reporter) { + reporter.info( + `${label} completed in ${durationMs}ms (budget ${budgetMs}ms)` + ); + } if (budgetMs > 0 && durationMs > budgetMs) { throw new WPKernelError('EnvironmentalError', { diff --git a/packages/cli/src/dx/readiness/__tests__/helper.test.ts b/packages/cli/src/dx/readiness/__tests__/helper.test.ts new file mode 100644 index 00000000..938274b7 --- /dev/null +++ b/packages/cli/src/dx/readiness/__tests__/helper.test.ts @@ -0,0 +1,73 @@ +import { createReadinessHelper } from '../helper'; + +describe('createReadinessHelper', () => { + it('throws when metadata label is missing', () => { + expect(() => + createReadinessHelper({ + key: 'missing-label', + metadata: {} as never, + create() { + return { + async detect() { + return { status: 'ready' as const }; + }, + async confirm() { + return { status: 'ready' as const }; + }, + }; + }, + }) + ).toThrow(/must specify metadata\.label/i); + }); + + it('normalises label, tags, and scopes', () => { + const helper = createReadinessHelper({ + key: 'normalised', + metadata: { + label: ' My Helper ', + tags: [' alpha ', 'beta', 'alpha', ' '], + scopes: [' init ', '', 'init', 'create '], + }, + create() { + return { + async detect() { + return { status: 'ready' as const }; + }, + async confirm() { + return { status: 'ready' as const }; + }, + }; + }, + }); + + expect(helper.metadata.label).toBe('My Helper'); + expect(helper.metadata.tags).toEqual(Object.freeze(['alpha', 'beta'])); + expect(helper.metadata.scopes).toEqual( + Object.freeze(['init', 'create']) + ); + }); + + it('treats empty tags/scopes as undefined', () => { + const helper = createReadinessHelper({ + key: 'empty', + metadata: { + label: 'Example', + tags: [' ', '\n'], + scopes: [' '], + }, + create() { + return { + async detect() { + return { status: 'ready' as const }; + }, + async confirm() { + return { status: 'ready' as const }; + }, + }; + }, + }); + + expect(helper.metadata.tags).toBeUndefined(); + expect(helper.metadata.scopes).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/dx/readiness/test/test-support.ts b/packages/cli/src/dx/readiness/test/test-support.ts index 54efdb38..ec86b484 100644 --- a/packages/cli/src/dx/readiness/test/test-support.ts +++ b/packages/cli/src/dx/readiness/test/test-support.ts @@ -1,3 +1,4 @@ +/* istanbul ignore file */ import { createReporter } from '@wpkernel/core/reporter'; import type { Reporter } from '@wpkernel/core/reporter'; import { WPK_SUBSYSTEM_NAMESPACES } from '@wpkernel/core/namespace'; diff --git a/packages/cli/src/utils/__tests__/progress.test.ts b/packages/cli/src/utils/__tests__/progress.test.ts new file mode 100644 index 00000000..9aee3c60 --- /dev/null +++ b/packages/cli/src/utils/__tests__/progress.test.ts @@ -0,0 +1,106 @@ +import { runWithProgress, measureStageWithProgress } from '../progress'; +import type { Reporter } from '@wpkernel/core/reporter'; + +jest.useFakeTimers(); + +jest.mock('../../commands/init/timing', () => ({ + measureStage: jest.fn(async ({ run }: { run: () => Promise }) => { + await run(); + return { durationMs: 25, budgetMs: 100 }; + }), +})); + +const { measureStage } = jest.requireMock('../../commands/init/timing'); + +function createReporterMock(): Reporter { + return { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + child: jest.fn().mockReturnThis(), + }; +} + +describe('progress utilities', () => { + afterEach(() => { + jest.clearAllTimers(); + jest.clearAllMocks(); + }); + + it('emits spinner ticks and success message', async () => { + const reporter = createReporterMock(); + let resolveRun!: (value: string) => void; + const runPromise = new Promise((resolve) => { + resolveRun = resolve; + }); + + const promise = runWithProgress({ + reporter, + label: 'Installing npm dependencies', + detail: 'demo', + intervalMs: 10, + run: () => runPromise, + successMessage: (durationMs, value) => + `✓ finished in ${durationMs}ms (${value})`, + }); + + jest.advanceTimersByTime(15); + resolveRun('ok'); + await promise; + + expect(reporter.info).toHaveBeenCalledWith( + 'Installing npm dependencies (demo)...' + ); + expect( + (reporter.info as jest.Mock).mock.calls.some( + ([message]: [string]) => message.includes('✓ finished in') + ) + ).toBe(true); + }); + + it('logs error message when the operation fails', async () => { + const reporter = createReporterMock(); + const error = new Error('boom'); + + await expect( + runWithProgress({ + reporter, + label: 'Applying patches', + intervalMs: 10, + run: () => { + throw error; + }, + }) + ).rejects.toThrow('boom'); + + expect(reporter.error).toHaveBeenCalledWith( + expect.stringContaining('failed'), + { error } + ); + }); + + it('wraps measureStage with progress logging', async () => { + const reporter = createReporterMock(); + const runMock = jest.fn().mockResolvedValue(undefined); + + const measurement = await measureStageWithProgress({ + reporter, + label: 'Installing composer dependencies', + stage: 'init.install.composer', + budgetMs: 200, + run: runMock, + }); + + expect(measureStage).toHaveBeenCalledWith( + expect.objectContaining({ + stage: 'init.install.composer', + label: 'Installing composer dependencies', + budgetMs: 200, + logCompletion: false, + }) + ); + expect(runMock).toHaveBeenCalledTimes(1); + expect(measurement).toEqual({ durationMs: 25, budgetMs: 100 }); + }); +}); diff --git a/packages/cli/src/utils/progress.ts b/packages/cli/src/utils/progress.ts new file mode 100644 index 00000000..9615b191 --- /dev/null +++ b/packages/cli/src/utils/progress.ts @@ -0,0 +1,117 @@ +import { performance } from 'node:perf_hooks'; +import type { Reporter } from '@wpkernel/core/reporter'; +import type { StageMeasurement } from '../commands/init/types'; +import { measureStage } from '../commands/init/timing'; +import type { MaybePromise } from '@wpkernel/pipeline'; + +const SPINNER_FRAMES = ['⠋', '⠙', '⠸', '⠴', '⠦', '⠇']; + +export interface ProgressOptions { + readonly reporter: Reporter; + readonly label: string; + readonly detail?: string; + readonly run: () => MaybePromise; + readonly intervalMs?: number; + readonly successMessage?: (durationMs: number, result: T) => string; + readonly failureMessage?: (durationMs: number, error: unknown) => string; +} + +export async function runWithProgress({ + reporter, + label, + detail, + run, + intervalMs = 4000, + successMessage, + failureMessage, +}: ProgressOptions): Promise<{ result: T; durationMs: number }> { + const start = performance.now(); + let frameIndex = 0; + + reporter.info(formatStartMessage(label, detail)); + const interval = setInterval(() => { + const elapsed = Math.round((performance.now() - start) / 1000); + const frame = SPINNER_FRAMES[frameIndex]; + frameIndex = (frameIndex + 1) % SPINNER_FRAMES.length; + reporter.info(`${frame} ${label} (${elapsed}s elapsed)`); + }, intervalMs); + + try { + const result = await run(); + const durationMs = performance.now() - start; + const message = + successMessage?.(durationMs, result) ?? + `✓ ${label} completed in ${formatDuration(durationMs)}.`; + reporter.info(message); + return { result, durationMs }; + } catch (error) { + const durationMs = performance.now() - start; + const message = + failureMessage?.(durationMs, error) ?? + `✖ ${label} failed after ${formatDuration(durationMs)}.`; + reporter.error(message, { error }); + throw error; + } finally { + clearInterval(interval); + } +} + +export async function measureStageWithProgress({ + reporter, + label, + stage, + budgetMs, + run, +}: { + reporter: Reporter; + label: string; + stage: string; + budgetMs: number; + run: () => MaybePromise; +}): Promise { + const { result: measurement } = await runWithProgress({ + reporter, + label, + run: () => + measureStage({ + stage, + label, + budgetMs, + run: async () => { + await run(); + }, + logCompletion: false, + }), + successMessage: (duration, value) => + `✓ ${label} completed in ${formatDuration(duration)} (budget ${formatDuration(value.budgetMs)}).`, + }); + + return measurement; +} + +export function formatDuration(durationMs: number): string { + if (!Number.isFinite(durationMs) || durationMs < 0) { + return 'unknown time'; + } + + if (durationMs < 1000) { + return `${Math.round(durationMs)}ms`; + } + + const seconds = durationMs / 1000; + if (seconds < 60) { + return `${seconds.toFixed(1)}s`; + } + + const minutes = Math.floor(seconds / 60); + const remainingSeconds = Math.round(seconds % 60); + return `${minutes}m ${remainingSeconds}s`; +} + +function formatStartMessage(label: string, detail?: string): string { + if (!detail) { + return `${label}...`; + } + + return `${label} (${detail})...`; +} diff --git a/packages/cli/src/utils/reporter.ts b/packages/cli/src/utils/reporter.ts index 7132bea2..9705ec89 100644 --- a/packages/cli/src/utils/reporter.ts +++ b/packages/cli/src/utils/reporter.ts @@ -47,6 +47,10 @@ function resolveEnvironmentEnabled(): boolean { return true; } + if (env === 'test') { + return false; + } + return env !== 'production'; } diff --git a/packages/create-wpk/CHANGELOG.md b/packages/create-wpk/CHANGELOG.md index 16334955..43fa64a5 100644 --- a/packages/create-wpk/CHANGELOG.md +++ b/packages/create-wpk/CHANGELOG.md @@ -6,7 +6,7 @@ - Initial workspace scaffolding for the `npm|pnpm|yarn create @wpkernel/wpk` bootstrap entry point (Task 37). - Bootstrap proxy that forwards positional arguments and `--`-delimited flags into `wpk create` (Task 38 installment 1). -- Telemetry instrumentation and integration smoke coverage for the published bootstrap binary to confirm flags like `--skip-install` reach `wpk create` (Task 38 installment 2). +- Telemetry instrumentation and integration smoke coverage for the published bootstrap binary to confirm forwarded flags reach `wpk create` (Task 38 installment 2). ## 0.10.0 diff --git a/packages/create-wpk/README.md b/packages/create-wpk/README.md index 8bfa389e..3e152571 100644 --- a/packages/create-wpk/README.md +++ b/packages/create-wpk/README.md @@ -39,10 +39,10 @@ The `wpk` binary is installed to `node_modules/.bin/wpk` automatically when npm ## Features -- Forwards positional arguments and `--`-delimited flags into `wpk create`, so targets such as `npm create @wpkernel/wpk demo -- --skip-install` behave the same as running the CLI directly. +- Forwards positional arguments and `--`-delimited flags into `wpk create`, so targets such as `npm create @wpkernel/wpk demo -- --force` behave the same as running the CLI directly. - Streams CLI output to the terminal while capturing stdout/stderr buffers for diagnostics. - Publishes usage telemetry through the wpk reporter under the `wpk.cli.bootstrap` namespace so bootstrap runs align with other CLI events. ## Diagnostics & coverage -The CLI integration suite builds the bootstrap binary on demand (using the package `tsconfig.json`) and executes it with the same `NODE_OPTIONS` loader as the core CLI smoke tests. This ensures the published entry point forwards flags like `--skip-install` without requiring contributors to run `pnpm --filter @wpkernel/create-wpk build` manually before running Jest. +The CLI integration suite builds the bootstrap binary on demand (using the package `tsconfig.json`) and executes it with the same `NODE_OPTIONS` loader as the core CLI smoke tests. This ensures the published entry point forwards flags without requiring contributors to run `pnpm --filter @wpkernel/create-wpk build` manually before running Jest. diff --git a/packages/create-wpk/src/index.ts b/packages/create-wpk/src/index.ts index 33a49ee3..f68eb597 100644 --- a/packages/create-wpk/src/index.ts +++ b/packages/create-wpk/src/index.ts @@ -48,13 +48,11 @@ function logBootstrapStart( forwardedFlagNames: readonly string[] ): void { const hasTarget = positional.length > 0; - const hasSkipInstall = forwardedFlagNames.includes('skip-install'); reporter.debug('Launching wpk create via bootstrapper.', { positionalCount: positional.length, forwardedFlags: forwardedFlagNames, targetProvided: hasTarget, - skipInstall: hasSkipInstall, }); } diff --git a/scripts/test/smoke-create-generate.mjs b/scripts/test/smoke-create-generate.mjs index 9faf6307..5d0d02d1 100644 --- a/scripts/test/smoke-create-generate.mjs +++ b/scripts/test/smoke-create-generate.mjs @@ -1,7 +1,14 @@ #!/usr/bin/env node import { spawn } from 'node:child_process'; -import { readdir, mkdtemp, mkdir, rm, stat } from 'node:fs/promises'; +import { + readdir, + mkdtemp, + mkdir, + rm, + stat, + writeFile, +} from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import process from 'node:process'; @@ -13,7 +20,8 @@ const repoRoot = path.resolve( const artifactsDir = path.join(repoRoot, 'artifacts', 'cli-smoke'); const cliArgs = new Set(process.argv.slice(2)); const keepWorkspace = cliArgs.has('--keep-workspace'); -const cleanArtifacts = cliArgs.has('--clean-artifacts'); +const keepArtifacts = cliArgs.has('--keep-artifacts'); +const forceCleanArtifacts = cliArgs.has('--clean-artifacts'); await mkdir(artifactsDir, { recursive: true }); @@ -24,6 +32,7 @@ async function main() { const summary = { projectDir, artifacts: [], + createLogPath: null, }; let success = false; @@ -40,19 +49,43 @@ async function main() { summary.artifacts.push(cliTarball, createTarball); logStep('Scaffolding project via create-wpk'); - await runNode( + const { stdout: createStdout = '' } = await runNode( path.join(repoRoot, 'packages/create-wpk/dist/index.js'), - [projectDir] + [projectDir], + { capture: true } ); + const createLogPath = await persistCreateLog(createStdout); + summary.createLogPath = createLogPath; + verifyCreateLogging(createStdout); logStep('Installing CLI tarball and tsx inside scaffold'); await runPnpm(['add', '-D', cliTarball, 'tsx@latest'], { cwd: projectDir, }); + logStep('Initialising git repository inside scaffold'); + await runCommand('git', ['init'], { cwd: projectDir }); + await runCommand('git', ['config', 'user.name', 'WPK Smoke'], { + cwd: projectDir, + }); + await runCommand('git', ['config', 'user.email', 'smoke@example.com'], { + cwd: projectDir, + }); + await runCommand('git', ['add', '.'], { cwd: projectDir }); + await runCommand( + 'git', + ['commit', '-m', 'chore: initial scaffold'], + { + cwd: projectDir, + } + ); + logStep('Running "pnpm exec wpk generate" inside scaffold'); await runPnpm(['exec', 'wpk', 'generate'], { cwd: projectDir }); + logStep('Committing generated assets'); + await commitWorkspace(projectDir, 'chore: generated assets'); + logStep('Running "pnpm exec wpk apply --yes" inside scaffold'); await runPnpm(['exec', 'wpk', 'apply', '--yes'], { cwd: projectDir, @@ -75,14 +108,24 @@ async function cleanup(summary, success) { ); } - if (cleanArtifacts) { + const shouldRemoveArtifacts = + (!keepArtifacts && success) || (!keepArtifacts && forceCleanArtifacts); + if (shouldRemoveArtifacts) { await rm(artifactsDir, { recursive: true, force: true }); - console.log('\nINFO Removed artifacts directory (--clean-artifacts).'); + if (forceCleanArtifacts && !success) { + console.log( + '\nINFO Removed artifacts directory (--clean-artifacts).' + ); + } } else if (!success && summary.artifacts.length > 0) { console.log('\nINFO Packed tarballs are available at:'); for (const artifact of summary.artifacts) { console.log(` - ${artifact}`); } + } else if (keepArtifacts && success) { + console.log( + `\nINFO Preserved artifacts under ${artifactsDir} (--keep-artifacts).` + ); } } @@ -93,6 +136,9 @@ function logStep(message) { function logSuccess(summary) { console.log('\nSUCCESS Smoke test succeeded.'); console.log(` Project workspace: ${summary.projectDir}`); + if (summary.createLogPath) { + console.log(` create-wpk log: ${summary.createLogPath}`); + } console.log(' Tarballs:'); for (const artifact of summary.artifacts) { console.log(` - ${artifact}`); @@ -211,6 +257,36 @@ function extractTarballFromStdout(stdout = '') { return undefined; } +async function persistCreateLog(stdout) { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const logPath = path.join( + artifactsDir, + `create-log-${timestamp}.txt` + ); + await writeFile(logPath, stdout ?? '', 'utf8'); + return logPath; +} + +function verifyCreateLogging(output) { + const text = output ?? ''; + if (!text.includes('Installing npm dependencies')) { + throw new Error( + 'create-wpk output missed expected log line: "Installing npm dependencies"' + ); + } + + if (!text.includes('Installing npm dependencies completed')) { + throw new Error( + 'create-wpk output missed completion log line: "Installing npm dependencies completed"' + ); + } +} + +async function commitWorkspace(cwd, message) { + await runCommand('git', ['add', '-A'], { cwd }); + await runCommand('git', ['commit', '-m', message], { cwd }); +} + main().catch((error) => { console.error('\nERROR Smoke test failed.'); console.error(error); From 1b6c773ea3e504e4a7235bcce6b265129f412d1b Mon Sep 17 00:00:00 2001 From: Jason Nathan <780157+jasonnathan@users.noreply.github.com> Date: Thu, 13 Nov 2025 14:25:33 +0800 Subject: [PATCH 3/3] massive fixes for hygiene --- packages/cli/src/commands/init/shared.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/init/shared.ts b/packages/cli/src/commands/init/shared.ts index ac08e2e8..ff17221d 100644 --- a/packages/cli/src/commands/init/shared.ts +++ b/packages/cli/src/commands/init/shared.ts @@ -31,7 +31,8 @@ export interface InitCommandState { export abstract class InitCommandBase extends Command - implements InitCommandState { + implements InitCommandState +{ name = Option.String('--name', { description: 'Project slug used for namespace/package defaults', required: false,