diff --git a/node-packages/wp-tooling/CHANGELOG.md b/node-packages/wp-tooling/CHANGELOG.md index 0648c52..84c0b9d 100644 --- a/node-packages/wp-tooling/CHANGELOG.md +++ b/node-packages/wp-tooling/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to `@rtcamp/wp-tooling` are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## Unreleased + +### Fixed + +- Feature finalization rolls back successful hooks when detection or state persistence fails, and defers next-step messages until persistence succeeds. Setup/reinit identity and version writes share rollback protection. Rename batches reuse one plan, support chains and swaps, and detect case-insensitive destination collisions. Cleanup targets and explicit example markers reject malformed values before mutation. + +- Init validates shared setup/manage selections before mutation, confirms setup choices before applying identity, and uses current persisted tokens for reinitialization. Version-only edits now update files and identity; reinit preserves one-shot example choices and refuses corrupt identity. +- Feature hooks are awaited inside their rollback journal. Cancellation/no-op manage calls leave persisted state unchanged; failed transitions stop, retain shared dependency ownership, and report rollback failures. +- Init rejects rename collisions, unsafe configured paths and symlink escapes, propagates filesystem and external-step failures, and reports partial setup instead of unconditional success. Identity edits roll back file changes on failure and preserve unrelated metadata. Fully rolled-back feature transitions leave saved state untouched, and dependency/script values must be strings. Git-step failures report that project setup completed; failures during project setup still require inspecting partial changes before retrying. + ## [1.0.0] - 2026-07-30 ### Added diff --git a/node-packages/wp-tooling/src/init/cleanup.js b/node-packages/wp-tooling/src/init/cleanup.js index c2ca1d5..3fa1bc9 100644 --- a/node-packages/wp-tooling/src/init/cleanup.js +++ b/node-packages/wp-tooling/src/init/cleanup.js @@ -5,39 +5,52 @@ 'use strict'; const fs = require('fs'); -const { resolveWithin } = require('./transform'); +const path = require('path'); +const { resolveWithin, validateRelativePath } = require('./transform'); /** - * Delete each target (file or directory) under `root` if it exists. Targets that - * resolve outside the project root are refused, never deleted. + * Validate every cleanup target before deletion starts. * * @param {string} root - Project root. * @param {string[]} targets - Project-relative paths to remove. - * @param {Object} ui - `@rtcamp/wp-tooling/ui`. - * @return {number} Count of targets removed. + * @return {Object[]} Validated relative and absolute paths. + */ +const resolveCleanupTargets = (root, targets = []) => { + if (!Array.isArray(targets)) { + throw new Error( + `Expected cleanup.targets to be an array, received ${JSON.stringify(targets)}` + ); + } + return targets.map((target) => { + validateRelativePath(target); + const full = resolveWithin(root, target); + if (full === path.resolve(root)) { + throw new Error(`Refusing to remove the project root: ${target}`); + } + return { target, full }; + }); +}; + +/** + * Delete validated cleanup targets, skipping paths that no longer exist. + * + * @param {string} root Project root. + * @param {string[]} targets Relative paths to remove. + * @param {Object} ui Status output. + * @return {number} Number of targets removed. */ const runCleanup = (root, targets, ui) => { + const resolved = resolveCleanupTargets(root, targets); let removed = 0; - (targets || []).forEach((target) => { - let full; - try { - full = resolveWithin(root, target); - } catch (err) { - ui.warn(err.message); - return; - } + for (const { target, full } of resolved) { if (!fs.existsSync(full)) { - return; + continue; } - try { - fs.rmSync(full, { recursive: true, force: true }); - ui.info(`removed ${target}`); - removed++; - } catch (err) { - ui.warn(`Could not remove ${target}: ${err.message}`); - } - }); + fs.rmSync(full, { recursive: true, force: true }); + ui.info(`removed ${target}`); + removed++; + } return removed; }; -module.exports = { runCleanup }; +module.exports = { runCleanup, resolveCleanupTargets }; diff --git a/node-packages/wp-tooling/src/init/examples.js b/node-packages/wp-tooling/src/init/examples.js index ed4fb77..493079b 100644 --- a/node-packages/wp-tooling/src/init/examples.js +++ b/node-packages/wp-tooling/src/init/examples.js @@ -43,12 +43,20 @@ const expandGlob = (root, pattern) => { let entries = []; try { entries = fs.readdirSync(base); - } catch { + } catch (error) { + if (!['ENOENT', 'ENOTDIR'].includes(error.code)) { + throw error; + } return; } entries.forEach((name) => { if (re.test(name)) { - next.push(path.join(base, name)); + next.push( + resolveWithin( + root, + path.relative(root, path.join(base, name)) + ) + ); } }); }); @@ -142,15 +150,7 @@ const applyExamples = (config, root, ui, removeKeys) => { // Strip this group's markers from its registration files (drop the code too // when removing). Only regions tagged with this group's marker are touched. (group.strip || []).forEach((rel) => { - let file; - try { - file = resolveWithin(root, rel); - } catch (err) { - if (ui && ui.warn) { - ui.warn(err.message); - } - return; - } + const file = resolveWithin(root, rel); if (!fs.existsSync(file)) { return; } diff --git a/node-packages/wp-tooling/src/init/features.js b/node-packages/wp-tooling/src/init/features.js index 5ecc07e..00d84da 100644 --- a/node-packages/wp-tooling/src/init/features.js +++ b/node-packages/wp-tooling/src/init/features.js @@ -15,7 +15,18 @@ const fs = require('fs'); const path = require('path'); const { writeFeatures } = require('./persist'); -const { resolveWithin } = require('./transform'); +const { resolveWithin, validateRelativePath } = require('./transform'); + +/** + * Whether a manifest value is a plain object rather than an array or scalar. + * + * @param {*} value Manifest value. + * @return {boolean} Whether the value is an object record. + */ +const isRecord = (value) => + Boolean(value) && + (Object.getPrototypeOf(value) === Object.prototype || + Object.getPrototypeOf(value) === null); /** * Validate a config's feature manifest. Throws on first problem so the caller @@ -26,11 +37,20 @@ const { resolveWithin } = require('./transform'); */ const validateFeatures = (config) => { const features = config.features || []; + if (!Array.isArray(features)) { + throw new Error('Expected features to be an array.'); + } const keys = new Set(); const labels = new Set(); features.forEach((feature) => { - if (!feature.key || !/^[a-z][a-z0-9-]*$/.test(feature.key)) { + if (!isRecord(feature)) { + throw new Error('Expected each feature to be an object.'); + } + if ( + 'string' !== typeof feature.key || + !/^[a-z][a-z0-9-]*$/.test(feature.key) + ) { throw new Error( `Feature key must match /^[a-z][a-z0-9-]*$/, got ${JSON.stringify(feature.key)}` ); @@ -40,7 +60,7 @@ const validateFeatures = (config) => { } keys.add(feature.key); - if (!feature.label) { + if ('string' !== typeof feature.label || !feature.label.trim()) { throw new Error(`Feature ${feature.key} is missing a label`); } if (labels.has(feature.label)) { @@ -48,27 +68,99 @@ const validateFeatures = (config) => { } labels.add(feature.label); - ((feature.apply && feature.apply.files) || []).forEach((file) => { - ['from', 'to'].forEach((side) => { - const value = file[side]; - if ( - !value || - path.isAbsolute(value) || - value.split(/[\\/]/).includes('..') - ) { + if (undefined !== feature.apply && !isRecord(feature.apply)) { + throw new Error( + `Feature ${feature.key}: expected apply to be an object.` + ); + } + const apply = feature.apply || {}; + if (undefined !== apply.files && !Array.isArray(apply.files)) { + throw new Error( + `Feature ${feature.key}: expected apply.files to be an array.` + ); + } + for (const file of apply.files || []) { + validateRelativePath(file?.from); + validateRelativePath(file?.to); + } + for (const field of ['dependencies', 'devDependencies', 'scripts']) { + if (undefined !== apply[field] && !isRecord(apply[field])) { + throw new Error( + `Feature ${feature.key}: expected apply.${field} to be an object.` + ); + } + for (const [name, value] of Object.entries(apply[field] || {})) { + if ('string' !== typeof value) { throw new Error( - `Feature ${feature.key}: file.${side} must be a relative path without "..", got ${JSON.stringify(value)}` + `Feature ${feature.key}: expected apply.${field}.${name} to be a string, received ${JSON.stringify(value)}.` ); } - }); - }); + } + } + for (const hook of ['onEnable', 'onDisable', 'detect']) { + if ( + undefined !== feature[hook] && + 'function' !== typeof feature[hook] + ) { + throw new Error( + `Feature ${feature.key}: expected ${hook} to be a function, received ${typeof feature[hook]}` + ); + } + } if (Boolean(feature.onEnable) !== Boolean(feature.onDisable)) { throw new Error( `Feature ${feature.key}: onEnable and onDisable must both be present or both absent` ); } }); + const validateMarker = (marker, label) => { + if ( + undefined !== marker && + ('string' !== typeof marker || !marker.trim()) + ) { + throw new Error( + `Expected ${label} to be a nonempty string, received ${JSON.stringify(marker)}` + ); + } + }; + validateMarker(config.examples?.marker, 'examples.marker'); + const groups = config.examples?.groups || []; + if (!Array.isArray(groups)) { + throw new Error('Expected examples.groups to be an array.'); + } + const groupKeys = new Set(); + for (const group of groups) { + validateMarker(group?.marker, `example ${group?.key} marker`); + if ( + !group || + 'string' !== typeof group.key || + !group.key.trim() || + groupKeys.has(group.key) + ) { + throw new Error( + `Expected a unique example key, received ${JSON.stringify(group?.key)}` + ); + } + if ( + 'string' !== typeof group.label || + !group.label.trim() || + labels.has(group.label) + ) { + throw new Error( + `Expected a unique capability label, received ${JSON.stringify(group.label)}` + ); + } + groupKeys.add(group.key); + labels.add(group.label); + for (const field of ['strip', 'remove']) { + if (undefined !== group[field] && !Array.isArray(group[field])) { + throw new Error( + `Example ${group.key}: expected ${field} to be an array.` + ); + } + } + } }; /** @@ -339,24 +431,31 @@ const makeFeatureApi = (root, identity, ui) => { /** * Run `fn` inside the api journal; on throw, replay undo entries in reverse to - * restore exactly what `fn` mutated, then rethrow. + * restore exactly what `fn` mutated, then rethrow. Nested successes retain their + * undo entries until the outer transaction commits. * * @param {Object} api - FeatureApi. - * @param {Function} fn - Synchronous mutation function. - * @return {void} + * @param {Function} fn - Synchronous or asynchronous mutation function. + * @return {Promise<*>} Callback result, or rejection after rollback. */ -const runJournaled = (api, fn) => { +const runJournaled = async (api, fn) => { const start = api._journal.length; const notesStart = (api._notes || []).length; + const depth = api._transactionDepth || 0; + api._transactionDepth = depth + 1; try { - fn(); - api._journal.length = start; + const result = await fn(); + if (!depth) { + api._journal.length = start; + } + return result; } catch (err) { + const rollbackErrors = []; for (let i = api._journal.length - 1; i >= start; i--) { try { - api._journal[i].undo(); - } catch { - // Best-effort rollback; surface nothing further. + await api._journal[i].undo(); + } catch (rollbackError) { + rollbackErrors.push(rollbackError); } } api._journal.length = start; @@ -364,7 +463,15 @@ const runJournaled = (api, fn) => { if (api._notes) { api._notes.length = notesStart; } + if (rollbackErrors.length) { + throw new AggregateError( + [err, ...rollbackErrors], + `${err.message}; rollback failed: ${rollbackErrors.map((error) => error.message).join('; ')}` + ); + } throw err; + } finally { + api._transactionDepth = depth; } }; @@ -448,7 +555,7 @@ const unmergePackage = (pkg, apply, survivors = []) => { }; /** - * Whether a feature looks installed on disk. + * Whether a feature looks installed on disk. Detect probes must be synchronous. * * @param {Object} feature - Feature definition. * @param {Object} api - FeatureApi. @@ -456,7 +563,15 @@ const unmergePackage = (pkg, apply, survivors = []) => { */ const detectFeature = (feature, api) => { if (feature.detect) { - return Boolean(feature.detect(api)); + const detected = feature.detect(api); + if (detected && 'function' === typeof detected.then) { + // Observe rejections so an invalid async probe cannot become unhandled. + Promise.resolve(detected).catch(() => {}); + throw new Error( + `Feature ${feature.key}: detect must return a synchronous value, received a Promise.` + ); + } + return Boolean(detected); } const apply = feature.apply || {}; const files = apply.files || []; @@ -480,10 +595,10 @@ const detectFeature = (feature, api) => { * @param {Object} feature - Feature definition. * @param {Object} api - FeatureApi. * @param {string} featuresDir - Project-relative dir holding feature assets. - * @return {void} + * @return {Promise} Resolves after the feature is enabled. */ -const enableFeature = (feature, api, featuresDir) => { - runJournaled(api, () => { +const enableFeature = async (feature, api, featuresDir) => { + await runJournaled(api, async () => { const apply = feature.apply || {}; (apply.files || []).forEach((file) => { @@ -505,7 +620,7 @@ const enableFeature = (feature, api, featuresDir) => { } if (feature.onEnable) { - feature.onEnable(api); + await feature.onEnable(api); } }); }; @@ -517,12 +632,12 @@ const enableFeature = (feature, api, featuresDir) => { * @param {Object} feature - Feature definition. * @param {Object} api - FeatureApi. * @param {Array} survivors - Features that remain enabled (shared keys are kept). - * @return {void} + * @return {Promise} Resolves after the feature is disabled. */ -const disableFeature = (feature, api, survivors = []) => { - runJournaled(api, () => { +const disableFeature = async (feature, api, survivors = []) => { + await runJournaled(api, async () => { if (feature.onDisable) { - feature.onDisable(api); + await feature.onDisable(api); } const apply = feature.apply || {}; (apply.files || []).forEach((file) => api.remove(file.to)); @@ -670,12 +785,14 @@ const toggleFeatures = async (config, root, opts) => { unknown = r.unknown; } - // Every exit goes through finalize: in manage mode it rewrites the persisted - // map from a fresh detect sweep, so intent trails disk and drift self-heals. + // Persist only when at least one transition succeeded. Failed, cancelled, + // and no-op requests must not rewrite identity just to reconcile drift. const finalize = (changed, failed) => { const finalMap = detectMap(config, api); - if ('manage' === mode) { - writeFeatures(root, finalMap, ui); + if ('manage' === mode && changed) { + writeFeatures(root, finalMap, ui, (file, body) => + api.write(path.relative(root, file), body) + ); } if (failed && failed.length) { process.exitCode = 1; @@ -764,53 +881,74 @@ const toggleFeatures = async (config, root, opts) => { } } - const featuresDir = config.featuresDir || 'bin/features'; - const survivors = (config.features || []).filter((f) => wantOn.has(f.key)); - const failed = []; - let depsChanged = false; - - // Disable before enable: frees files/deps before any re-add. - toDisable.forEach((r) => { - const spin = ui.spinner(`Disabling ${r.label}...`); - spin.start(); - try { - disableFeature(r.feature, api, survivors); - spin.succeed(`Disabled ${r.label}`); - depsChanged = depsChanged || touchesPackage(r.feature); - } catch (err) { - spin.fail(`Failed to disable ${r.label}`); - ui.error(err.message); - failed.push(r.key); + return runJournaled(api, async () => { + const featuresDir = config.featuresDir || 'bin/features'; + const enabledKeys = new Set( + rows.filter((row) => row.on).map((row) => row.key) + ); + const failed = []; + let depsChanged = false; + let changed = false; + + // Disable before enable: frees files/deps before any re-add. + for (const r of toDisable) { + const spin = ui.spinner(`Disabling ${r.label}...`); + spin.start(); + try { + const survivors = (config.features || []).filter( + (feature) => + feature.key !== r.key && + (enabledKeys.has(feature.key) || + wantOn.has(feature.key)) + ); + await disableFeature(r.feature, api, survivors); + changed = true; + enabledKeys.delete(r.key); + spin.succeed(`Disabled ${r.label}`); + depsChanged = depsChanged || touchesPackage(r.feature); + } catch (err) { + spin.fail(`Failed to disable ${r.label}`); + ui.error(err.message); + failed.push(r.key); + } + if (failed.length) { + break; + } } - }); - toEnable.forEach((r) => { - const spin = ui.spinner(`Enabling ${r.label}...`); - spin.start(); - try { - enableFeature(r.feature, api, featuresDir); - spin.succeed(`Enabled ${r.label}`); - depsChanged = depsChanged || touchesPackage(r.feature); - } catch (err) { - spin.fail(`Failed to enable ${r.label}`); - ui.error(err.message); - failed.push(r.key); + for (const r of toEnable) { + if (failed.length) { + break; + } + const spin = ui.spinner(`Enabling ${r.label}...`); + spin.start(); + try { + await enableFeature(r.feature, api, featuresDir); + changed = true; + spin.succeed(`Enabled ${r.label}`); + depsChanged = depsChanged || touchesPackage(r.feature); + } catch (err) { + spin.fail(`Failed to enable ${r.label}`); + ui.error(err.message); + failed.push(r.key); + } } - }); - if (depsChanged) { - ui.warn('Dependencies changed -- run `npm install` to sync.'); - } - // Drained, so a manage-mode loop that toggles twice doesn't reprint them. - const notes = api._notes || []; - if (notes.length) { - ui.heading('Next steps'); - notes.splice(0).forEach((message) => ui.info(message)); - } - if (!failed.length) { - ui.success('Features updated.'); - } + const result = finalize(changed, failed); + if (depsChanged) { + ui.warn('Dependencies changed -- run `npm install` to sync.'); + } + // Drained, so a manage-mode loop that toggles twice doesn't reprint them. + const notes = api._notes || []; + if (notes.length) { + ui.heading('Next steps'); + notes.splice(0).forEach((message) => ui.info(message)); + } + if (!failed.length) { + ui.success('Features updated.'); + } - return finalize(true, failed); + return result; + }); }; module.exports = { diff --git a/node-packages/wp-tooling/src/init/git.js b/node-packages/wp-tooling/src/init/git.js index c0349a7..a0ce271 100644 --- a/node-packages/wp-tooling/src/init/git.js +++ b/node-packages/wp-tooling/src/init/git.js @@ -41,8 +41,10 @@ const initRepo = (root, ui) => { fs.rmSync(gitDir, { recursive: true, force: true }); } } catch (err) { - ui.error(`Could not remove existing .git directory: ${err.message}`); - return false; + throw new Error( + `Could not remove existing .git directory: ${err.message}`, + { cause: err } + ); } try { @@ -50,8 +52,7 @@ const initRepo = (root, ui) => { ui.success('Git repository initialized'); return true; } catch (err) { - ui.error(`git init failed: ${err.message}`); - return false; + throw new Error(`git init failed: ${err.message}`, { cause: err }); } }; @@ -70,8 +71,9 @@ const commitAll = (root, message, ui) => { ui.success('Created initial commit'); return true; } catch (err) { - ui.warn(`Could not create initial commit: ${err.message}`); - return false; + throw new Error(`Could not create initial commit: ${err.message}`, { + cause: err, + }); } }; @@ -138,8 +140,9 @@ const installGitHooks = async (root, ui) => { return true; } catch (err) { spin.fail('Git hook installation failed'); - ui.warn(err.message); - return false; + throw new Error(`Git hook installation failed: ${err.message}`, { + cause: err, + }); } }; diff --git a/node-packages/wp-tooling/src/init/identity.js b/node-packages/wp-tooling/src/init/identity.js index 6f5f7e8..84ca2cf 100644 --- a/node-packages/wp-tooling/src/init/identity.js +++ b/node-packages/wp-tooling/src/init/identity.js @@ -6,14 +6,21 @@ 'use strict'; +const path = require('path'); const { collectFiles, + withFileRollback, + planRenames, replaceInFiles, - renameFiles, + executeRenames, applyVersion, PHP_RESERVED_WORDS, } = require('./transform'); -const { writeIdentityFile, readIdentityFile } = require('./persist'); +const { + writeIdentityFile, + readIdentityFile, + IDENTITY_FILE, +} = require('./persist'); /** * Split a name into words on spaces/hyphens/underscores and camelCase, @@ -295,6 +302,7 @@ const detailsOf = (config, id) => const FIELD_VALIDATORS = { name: validateName, version: (v) => + 'string' === typeof v && /^\d+(\.\d+)*(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/.test(v.trim()) ? undefined : 'Version must look like 1.2.3.', @@ -326,6 +334,29 @@ const FIELD_VALIDATORS = { : 'CSS prefix: lowercase letters, numbers, hyphens; start with a letter.', }; +/** + * Validate resolved identity fields before accepting or applying them. + * + * @param {Object} identity Resolved identity. + * @return {void} + */ +const validateIdentity = (identity) => { + for (const [field, validate] of Object.entries(FIELD_VALIDATORS)) { + const value = identity[field]; + // A project without PHP namespacing may explicitly use the global namespace. + if ('namespace' === field && '' === value) { + continue; + } + const error = + 'string' === typeof value ? validate(value) : 'Expected a string.'; + if (error) { + throw new Error( + `Identity ${field}: ${error} Received ${JSON.stringify(value)}.` + ); + } + } +}; + /** * Interactively edit identity fields until the user confirms or cancels. * @@ -357,6 +388,8 @@ const editIdentityFields = async ( }); if (flags.yes) { + validateIdentity(id); + id.slug = id.textDomain; return { id, confirmed: true }; } @@ -365,6 +398,8 @@ const editIdentityFields = async ( gate && (await ui.confirm({ message: 'Looks good?', defaultValue: true })) ) { + validateIdentity(id); + id.slug = id.textDomain; return { id, confirmed: true }; } @@ -379,6 +414,8 @@ const editIdentityFields = async ( }); if ('Confirm' === choice) { + validateIdentity(id); + id.slug = id.textDomain; return { id, confirmed: true }; } if ('Cancel' === choice) { @@ -403,6 +440,9 @@ const editIdentityFields = async ( id.version = value; } else { id[field] = value; + if ('textDomain' === field) { + id.slug = value; + } overridden.add(field); } } @@ -420,44 +460,59 @@ const editIdentityFields = async ( * @return {boolean} Whether anything changed. */ const applyIdentityEdit = (config, root, oldId, newId, ui) => { + validateIdentity(newId); const replacements = buildIdentityReplacements(oldId, newId); - if (!replacements.length) { + if (!replacements.length && oldId.version === newId.version) { ui.info('No identity changes to apply.'); return false; } - const files = collectFiles(root); - const changed = replaceInFiles(files, replacements, ui); - const renamed = renameFiles(files, replacements, ui); - ui.success(`Updated ${changed} file(s), renamed ${renamed} file(s)`); - - if (oldId.version !== newId.version && Array.isArray(config.versionFiles)) { - const target = { ...newId, kebab: newId.textDomain }; - const versionFiles = config.versionFiles.map((spec) => ({ - ...spec, - path: - 'function' === typeof spec.path ? spec.path(target) : spec.path, - })); - applyVersion(root, versionFiles, newId.version, ui); - } - + const identityPath = path.join(root, IDENTITY_FILE); const current = readIdentityFile(root) || {}; - writeIdentityFile( - root, - { - ...current, - name: newId.name, - version: newId.version, - slug: newId.textDomain, - textDomain: newId.textDomain, - package: newId.package, - namespace: newId.namespace, - functionPrefix: newId.functionPrefix, - constantPrefix: newId.constantPrefix, - cssPrefix: newId.cssPrefix, - }, - ui + // Persist explicit identity fields separately; metadata is not source code. + const files = collectFiles(root).filter((file) => file !== identityPath); + const renames = planRenames(files, replacements); + const result = withFileRollback(({ writeFile, renameFile }) => { + const changed = replaceInFiles(files, replacements, writeFile); + const renamed = executeRenames(renames, renameFile); + + if ( + oldId.version !== newId.version && + Array.isArray(config.versionFiles) + ) { + const target = { ...newId, kebab: newId.textDomain }; + const versionFiles = config.versionFiles.map((spec) => ({ + ...spec, + path: + 'function' === typeof spec.path + ? spec.path(target) + : spec.path, + })); + applyVersion(root, versionFiles, newId.version, ui, writeFile); + } + + writeIdentityFile( + root, + { + ...current, + name: newId.name, + version: newId.version, + slug: newId.textDomain, + textDomain: newId.textDomain, + package: newId.package, + namespace: newId.namespace, + functionPrefix: newId.functionPrefix, + constantPrefix: newId.constantPrefix, + cssPrefix: newId.cssPrefix, + }, + ui, + writeFile + ); + return { changed, renamed }; + }); + ui.success( + `Updated ${result.changed} file(s), renamed ${result.renamed} file(s)` ); return true; @@ -529,6 +584,8 @@ const editDetailsFlow = async (config, root, identity, ui, flags = {}) => { }; module.exports = { + validateIdentity, + validateVersion: FIELD_VALIDATORS.version, generateIdentity, identityFromName, CASE_KEYS, diff --git a/node-packages/wp-tooling/src/init/index.js b/node-packages/wp-tooling/src/init/index.js index 29127cb..c659ee5 100644 --- a/node-packages/wp-tooling/src/init/index.js +++ b/node-packages/wp-tooling/src/init/index.js @@ -7,9 +7,9 @@ * `run( config, { root } )` with a per-project `scaffold.config.js`. All terminal * I/O goes through `@rtcamp/wp-tooling/ui`. * - * Flow: confirm -> name (validated) -> review identity -> rename + search-replace - * -> apply version -> select features -> remove declined examples -> persist - * `.wp-scaffold.json` -> composer dump-autoload -> cleanup -> optional git + Husky + * Flow: confirm -> name (validated) -> review identity -> select capabilities + * -> confirm changes -> apply identity/version/capabilities -> persist + * `.wp-scaffold.json` -> composer dump-autoload -> cleanup -> optional git + hooks * -> initial commit. */ @@ -27,31 +27,40 @@ const debug = require('../debug'); const { identityFromName, validateName, + validateIdentity, buildIdentityReplacements, editIdentityFields, } = require('./identity'); const { collectFiles, + planRenames, replaceInFiles, - renameFiles, + executeRenames, + withFileRollback, applyVersion, } = require('./transform'); const { writeIdentityFile, readIdentityFile, IdentityFileError, + IDENTITY_FILE, } = require('./persist'); const { initRepo, commitAll, installGitHooks } = require('./git'); -const { runCleanup } = require('./cleanup'); +const { runCleanup, resolveCleanupTargets } = require('./cleanup'); const { validateFeatures, makeFeatureApi, - detectMap, safeDetectMap, retiredKeys, toggleFeatures, } = require('./features'); +const { + usageError, + validateSetupFlags, + validatePaths, +} = require('./validation'); const { manageFlow, showStatus } = require('./manage'); +const { selectCapabilities } = require('./selection'); const { applyExamples } = require('./examples'); const { listCapabilities, showCapabilities } = require('./capabilities'); const { formatErrorPayload } = require('../scaffolds/errors'); @@ -93,7 +102,7 @@ Scaffold options (first run): --features=a,b Enable exactly these optional features (empty = none). --enable=a,b Enable features (delta over defaults). --disable=a,b Disable features (delta over defaults). - --reinit Force a fresh scaffold even if already set up. + --reinit Reapply identity/features; preserve one-shot example choices. Interactive runs show ONE grouped "Select the capabilities to include" prompt (by category); unchecking a capability removes it entirely. @@ -180,17 +189,19 @@ const placeholderIdentity = (config) => /** * Build replacement pairs + persisted payload for a chosen target identity. * - * @param {Object} config - Per-project scaffold config. - * @param {Object} targetId - The full target identity (post-edit). + * @param {Object} config - Per-project scaffold config. + * @param {Object} targetId - The full target identity (post-edit). + * @param {Object|null} existing - Current identity for reinitialization. * @return {Object} { replacements, persistPayload } */ -const contextFromIdentity = (config, targetId) => { +const contextFromIdentity = (config, targetId, existing) => { const replacements = buildIdentityReplacements( - placeholderIdentity(config), + existing || placeholderIdentity(config), targetId ); const persistPayload = { + ...existing, name: targetId.name, kind: config.kind, version: targetId.version, @@ -201,7 +212,7 @@ const contextFromIdentity = (config, targetId) => { functionPrefix: targetId.functionPrefix, constantPrefix: targetId.constantPrefix, cssPrefix: targetId.cssPrefix, - features: {}, + features: existing?.features || {}, generatedBy: GENERATED_BY, }; @@ -227,8 +238,8 @@ const composerDump = (root) => { }); spin.succeed('Autoloader regenerated'); } catch (err) { - spin.fail('composer dump-autoload failed (continuing)'); - ui.warn(err.message); + spin.fail('composer dump-autoload failed'); + throw err; } }; @@ -243,17 +254,14 @@ const composerDump = (root) => { const setupSteps = (config, root, flags) => { const kind = config.kind || 'project'; const steps = config.steps || {}; - // Corrupt identity reads as absent here: run() already refused to enter - // setup on corruption without --reinit, so reaching this point means the - // file may be discarded. - let existing = null; - try { - existing = readIdentityFile(root); - } catch (err) { - if (!(err instanceof IdentityFileError)) { - throw err; - } - existing = null; + const existing = readIdentityFile(root); + if (!existing && validateName(config.source?.name)) { + throw new Error( + 'Expected config.source.name to contain a valid starter name.' + ); + } + if (existing) { + validateIdentity(existing); } return [ @@ -314,7 +322,10 @@ const setupSteps = (config, root, flags) => { async run(c) { const start = identityFromName(c.name, config, {}); start.version = - flags.version || config.version || DEFAULT_VERSION; + flags.version ?? + existing?.version ?? + config.version ?? + DEFAULT_VERSION; const { id, confirmed } = await editIdentityFields( config, @@ -329,7 +340,7 @@ const setupSteps = (config, root, flags) => { return; } - const ctx = contextFromIdentity(config, id); + const ctx = contextFromIdentity(config, id, existing); c.target = id; c.version = id.version; c.replacements = ctx.replacements; @@ -337,176 +348,99 @@ const setupSteps = (config, root, flags) => { }, }, { - name: 'Apply identity', + name: 'Select capabilities', skip: (c) => c.cancelled, async run(c) { - const files = collectFiles(root); - const changed = replaceInFiles(files, c.replacements, ui); - const renamed = renameFiles(files, c.replacements, ui); - ui.success( - `Updated ${changed} file(s), renamed ${renamed} file(s)` + const selection = await selectCapabilities( + config, + root, + flags, + existing || placeholderIdentity(config), + Boolean(existing), + ui ); + c.removeKeys = selection.removeKeys; + c.wantOn = selection.wantOn; }, }, { - name: 'Apply version', - skip: (c) => c.cancelled || !config.versionFiles, + name: 'Confirm changes', + skip: (c) => c.cancelled, async run(c) { - // Resolve function paths against the chosen identity; files may have just been renamed. - const target = { ...c.target, kebab: c.target.textDomain }; - const files = config.versionFiles.map((spec) => ({ - ...spec, - path: - 'function' === typeof spec.path - ? spec.path(target) - : spec.path, - })); - applyVersion(root, files, c.version, ui); + validatePaths(config, root, c.target); + c.files = collectFiles(root).filter( + (file) => file !== path.join(root, IDENTITY_FILE) + ); + c.renames = planRenames(c.files, c.replacements); + ui.info(`Apply identity ${c.target.name} (${c.version}).`); + ui.info( + `Enabled features: ${[...(c.wantOn || [])].join(', ') || '(none)'}` + ); + ui.info( + `Remove examples: ${[...(c.removeKeys || [])].join(', ') || '(none)'}` + ); + if ( + !flags.yes && + !(await ui.confirm({ + message: 'Apply these changes?', + defaultValue: true, + })) + ) { + c.cancelled = true; + } }, }, { - // One categorized prompt for BOTH example capabilities (keep/remove) - // and optional features (enable/disable). Non-interactive when any - // selection flag is present (AI/CI); otherwise a grouped "keep" tree. - name: 'Select capabilities', - skip: (c) => - c.cancelled || - (!(config.features || []).length && - !( - config.examples && (config.examples.groups || []).length - )), + name: 'Apply identity', + skip: (c) => c.cancelled, async run(c) { - const features = config.features || []; - const groups = - (config.examples && config.examples.groups) || []; - const api = makeFeatureApi(root, c.persistPayload, ui); - - let removeKeys; // example group keys to remove - let wantOn; // feature keys to enable - - const interactive = - !flags.yes && - undefined === flags.removeExamples && - !flags.keepExamples && - undefined === flags.features && - !flags.enable && - !flags.disable; - - if (interactive) { - removeKeys = new Set(); - wantOn = new Set(); - if (features.length || groups.length) { - // Build capabilities (examples default-checked = kept; - // features checked iff defaultOn), grouped by category. - const caps = [ - ...groups.map((g) => ({ - key: g.key, - label: g.label, - category: g.category || 'Other', - checked: true, - })), - ...features.map((f) => ({ - key: f.key, - label: f.label, - category: f.category || 'Other', - checked: !!f.defaultOn, - })), - ]; - const order = []; - const byCat = new Map(); - for (const entry of caps) { - if (!byCat.has(entry.category)) { - byCat.set(entry.category, []); - order.push(entry.category); - } - byCat.get(entry.category).push(entry); - } - const treeGroups = order.map((category) => ({ - label: category, - items: byCat.get(category).map((entry) => ({ - label: entry.label, - checked: entry.checked, - })), - })); - const checked = new Set( - await ui.checkboxTree({ - message: - 'Select the capabilities to include in your plugin', - groups: treeGroups, - }) - ); - removeKeys = new Set( - groups - .filter((g) => !checked.has(g.label)) - .map((g) => g.key) - ); - wantOn = new Set( - features - .filter((f) => checked.has(f.label)) - .map((f) => f.key) - ); - } - } else { - // Examples from flags. - if (true === flags.removeExamples) { - removeKeys = new Set(groups.map((g) => g.key)); - } else if (Array.isArray(flags.removeExamples)) { - const validKeys = new Set(groups.map((g) => g.key)); - const unknownKeys = flags.removeExamples.filter( - (k) => !validKeys.has(k) - ); - if (unknownKeys.length) { - throw usageError( - `Unknown --remove-examples key(s): ${unknownKeys.join( - ', ' - )}. Valid keys: ${ - [...validKeys].sort().join(', ') || - '(none declared)' - }` - ); - } - removeKeys = new Set(flags.removeExamples); - } else { - removeKeys = new Set(); // --keep-examples / --yes default - } - // Features from flags (--features exact set, or --enable / - // --disable deltas over defaultOn + detected). - if (undefined !== flags.features) { - wantOn = new Set(flags.features); - } else { - const detected = detectMap(config, api); - wantOn = new Set( - features - .filter((f) => f.defaultOn || detected[f.key]) - .map((f) => f.key) - ); - (flags.enable || []).forEach((k) => wantOn.add(k)); - (flags.disable || []).forEach((k) => wantOn.delete(k)); - } - } - - // Remove declined example capabilities, then toggle features. - if (groups.length) { - applyExamples(config, root, ui, removeKeys); + c.applicationStarted = true; + withFileRollback(({ writeFile, renameFile }) => { + const changed = replaceInFiles( + c.files, + c.replacements, + writeFile + ); + const renamed = executeRenames(c.renames, renameFile); + const target = { ...c.target, kebab: c.target.textDomain }; + const files = (config.versionFiles || []).map((spec) => ({ + ...spec, + path: + 'function' === typeof spec.path + ? spec.path(target) + : spec.path, + })); + applyVersion(root, files, c.version, ui, writeFile); + ui.success( + `Updated ${changed} file(s), renamed ${renamed} file(s)` + ); + }); + }, + }, + { + name: 'Apply capabilities', + skip: (c) => c.cancelled, + async run(c) { + if (!existing && config.examples?.groups?.length) { + applyExamples(config, root, ui, c.removeKeys || new Set()); + c.persistPayload.examples = { + removed: [...(c.removeKeys || [])].sort(), + }; } - // Record the selection: `--list` reconciles this intent against - // detected reality instead of re-deriving it from disk alone. - c.persistPayload.examples = { - removed: Array.from(removeKeys).sort(), - }; - if (features.length) { - const result = await toggleFeatures(config, root, { - mode: 'scaffold', - wantOn, - flags, - api, - ui, - }); - c.persistPayload.features = - (result && result.finalMap) || detectMap(config, api); - } else { - c.persistPayload.features = {}; + const api = makeFeatureApi(root, c.persistPayload, ui); + const result = await toggleFeatures(config, root, { + mode: 'scaffold', + wantOn: c.wantOn || new Set(), + flags: { ...flags, yes: true }, + api, + ui, + }); + if (result.failed.length) { + throw new Error( + `Feature setup failed: ${result.failed.join(', ')}` + ); } + c.persistPayload.features = result.finalMap; }, }, { @@ -534,17 +468,14 @@ const setupSteps = (config, root, flags) => { name: 'Cleanup', skip: (c) => c.cancelled || !steps.cleanup, async run() { - runCleanup( - root, - (config.cleanup && config.cleanup.targets) || [], - ui - ); + runCleanup(root, config.cleanup?.targets, ui); }, }, { name: 'Git', skip: (c) => c.cancelled || !steps.git, async run(c) { + c.projectSetupComplete = true; const go = flags.yes ? false : await ui.confirm({ @@ -599,13 +530,30 @@ const setupSteps = (config, root, flags) => { * @return {Promise} */ const setupFlow = async (config, root, flags) => { + validatePaths(config, root); const kind = config.kind || 'project'; ui.heading(`${cap(kind)} setup`); const ctx = { cancelled: false }; - await new ui.Wizard(setupSteps(config, root, flags), ctx).run(); + try { + await new ui.Wizard(setupSteps(config, root, flags), ctx).run(); + } catch (error) { + if (ctx.projectSetupComplete) { + ui.warn( + error instanceof ui.CancelledError + ? 'Project setup completed. Git setup was cancelled; finish it manually when ready.' + : 'Project setup completed, but Git setup failed. Resolve the Git error and finish Git setup manually; do not rerun project setup.' + ); + } else if (ctx.applicationStarted) { + ui.warn( + 'Setup stopped after application began. Some changes may remain; inspect the diff and restore your starter backup before retrying setup.' + ); + } + throw error; + } if (ctx.cancelled) { + process.exitCode = process.exitCode || 130; ui.warn('\nNothing was changed.'); return; } @@ -625,6 +573,7 @@ const setupFlow = async (config, root, flags) => { * @return {Promise} */ const cleanFlow = async (config, root) => { + resolveCleanupTargets(root, config.cleanup?.targets); const kind = config.kind || 'project'; const go = await ui.confirm({ message: `Run ${kind} cleanup now?`, @@ -634,11 +583,7 @@ const cleanFlow = async (config, root) => { ui.warn('Cleanup skipped.'); return; } - const removed = runCleanup( - root, - (config.cleanup && config.cleanup.targets) || [], - ui - ); + const removed = runCleanup(root, config.cleanup?.targets, ui); ui.success(`Cleanup complete (${removed} removed).`); }; @@ -653,15 +598,6 @@ const emitJsonError = (err) => { process.stderr.write(`${JSON.stringify(formatErrorPayload(err))}\n`); }; -/** - * Build a usage error carrying the stable `EUSAGE` machine code. - * - * @param {string} message - Human-readable message. - * @return {Error} Coded error. - */ -const usageError = (message) => - Object.assign(new Error(message), { code: 'EUSAGE' }); - /** * `--list`: report the project's capabilities and optional features, in either * mode, without mutating anything. Human tables by default; a single JSON line @@ -843,18 +779,17 @@ const run = async (config, options = {}) => { try { identity = readIdentityFile(root); } catch (err) { - // Only an actual corrupt-identity error is discardable via - // --reinit; a filesystem error (EACCES, EIO, ...) must not be - // treated as "no identity, safe to overwrite". + // A corrupt identity permits read-only setup inspection with + // --reinit. Filesystem errors must still fail the query. if ( !(err instanceof IdentityFileError) || !argv.includes('--reinit') ) { throw err; } - // --reinit means "discard what's there": report setup mode. + // Read-only inspection is allowed; mutation still requires valid source tokens. seedWarnings.push( - '.wp-scaffold.json is corrupt; --reinit will overwrite it.' + '.wp-scaffold.json is corrupt; restore a valid identity before running setup with --reinit.' ); } const mode = @@ -901,27 +836,14 @@ const run = async (config, options = {}) => { return; } - // A corrupt identity file must not silently re-enter setup mode (that - // would re-run destructive scaffold steps on an initialized project). - // Only an explicit --reinit may discard it. - let identity = null; + // Mutating reinit needs the current tokens; a corrupt file cannot be guessed. + let identity; try { identity = readIdentityFile(root); } catch (err) { - // Only an actual corrupt-identity error is discardable via - // --reinit; a filesystem error (EACCES, EIO, ...) must not be - // treated as "no identity, safe to overwrite". - if ( - !(err instanceof IdentityFileError) || - !argv.includes('--reinit') - ) { - ui.error(err.message); - process.exitCode = 1; - return; - } - ui.warn( - '.wp-scaffold.json is corrupt; --reinit will overwrite it.' - ); + ui.error(err.message); + process.exitCode = 1; + return; } // Manage mode: already scaffolded (unless forced to re-scaffold with --reinit). @@ -946,6 +868,15 @@ const run = async (config, options = {}) => { return; } + validateSetupFlags(config, flags); + if ( + identity && + (flags.keepExamples || undefined !== flags.removeExamples) + ) { + throw usageError( + 'Examples are a one-shot setup choice; reinit preserves the original selection.' + ); + } await setupFlow(config, root, flags); } catch (err) { if (err instanceof ui.CancelledError) { @@ -954,14 +885,15 @@ const run = async (config, options = {}) => { process.exitCode = 130; return; } - if (err instanceof IdentityFileError) { - // Mid-flow corruption (e.g. a manage re-read): report, don't crash. + if (err instanceof IdentityFileError || 'EUSAGE' === err.code) { + // Usage errors and mid-flow identity corruption are reported to the caller. result = 'error'; ui.error(err.message); process.exitCode = 1; return; } result = 'error'; + process.exitCode = 1; throw err; } } finally { diff --git a/node-packages/wp-tooling/src/init/manage.js b/node-packages/wp-tooling/src/init/manage.js index 4def62e..6556ece 100644 --- a/node-packages/wp-tooling/src/init/manage.js +++ b/node-packages/wp-tooling/src/init/manage.js @@ -12,6 +12,8 @@ const { makeFeatureApi, reconcile, toggleFeatures } = require('./features'); const { readIdentityFile, readFeatures } = require('./persist'); const { editDetailsFlow } = require('./identity'); +const { selectFeatures } = require('./selection'); +const { validateFeatureFlags, validatePaths } = require('./validation'); /** * Capitalise the first letter. @@ -148,13 +150,10 @@ const manageFlow = async (config, root, argv, identity, ui, reinit) => { process.exitCode = 1; return; } - if (flags.features && (flags.enable || flags.disable)) { - ui.error('--features cannot be combined with --enable/--disable.'); - process.exitCode = 1; - return; - } - + validateFeatureFlags(config, flags); + validatePaths(config, root, identity); const features = config.features || []; + const api = makeFeatureApi(root, identity, ui); const { rows, unknown: retired } = reconcile( config, @@ -162,21 +161,6 @@ const manageFlow = async (config, root, argv, identity, ui, reinit) => { api ); - const validKeys = new Set(features.map((f) => f.key)); - const requested = [ - ...(flags.features || []), - ...(flags.enable || []), - ...(flags.disable || []), - ]; - const badKey = requested.find((k) => !validKeys.has(k)); - if (badKey) { - ui.error( - `Unknown feature "${badKey}". Valid: ${[...validKeys].join(', ') || '(none)'}` - ); - process.exitCode = 1; - return; - } - const hasFlagSelection = Boolean( flags.features || flags.enable || flags.disable ); @@ -189,14 +173,10 @@ const manageFlow = async (config, root, argv, identity, ui, reinit) => { } if (hasFlagSelection) { - let wantOn; - if (flags.features) { - wantOn = new Set(flags.features); - } else { - wantOn = new Set(rows.filter((r) => r.on).map((r) => r.key)); - (flags.enable || []).forEach((k) => wantOn.add(k)); - (flags.disable || []).forEach((k) => wantOn.delete(k)); - } + const wantOn = selectFeatures( + flags, + rows.filter((row) => row.on).map((row) => row.key) + ); await toggleFeatures(config, root, { mode: 'manage', wantOn, @@ -234,6 +214,7 @@ const manageFlow = async (config, root, argv, identity, ui, reinit) => { ui, flags ); + api.identity = readIdentityFile(root) || identity; continue; } if ('Show status' === choice) { @@ -247,7 +228,7 @@ const manageFlow = async (config, root, argv, identity, ui, reinit) => { } // Toggle features: re-read fresh rows each loop. const r = reconcile(config, reqReadFeatures(root), api); - await toggleFeatures(config, root, { + const result = await toggleFeatures(config, root, { mode: 'manage', flags, api, @@ -255,6 +236,9 @@ const manageFlow = async (config, root, argv, identity, ui, reinit) => { rows: r.rows, unknown: r.unknown, }); + if (result.failed.length) { + return; + } } }; diff --git a/node-packages/wp-tooling/src/init/persist.js b/node-packages/wp-tooling/src/init/persist.js index 281b75e..97d4cc4 100644 --- a/node-packages/wp-tooling/src/init/persist.js +++ b/node-packages/wp-tooling/src/init/persist.js @@ -8,7 +8,7 @@ 'use strict'; const fs = require('fs'); -const path = require('path'); +const { resolveWithin } = require('./transform'); /** Name of the persisted identity file at the project root. */ const IDENTITY_FILE = '.wp-scaffold.json'; @@ -31,18 +31,15 @@ class IdentityFileError extends Error { /** * Write the identity payload to `/.wp-scaffold.json` (tab-indented). * - * @param {string} root - Project root. - * @param {Object} payload - Identity payload to persist. - * @param {Object} [ui] - `@rtcamp/wp-tooling/ui` for an optional log line. + * @param {string} root - Project root. + * @param {Object} payload - Identity payload to persist. + * @param {Object} [ui] - `@rtcamp/wp-tooling/ui` for an optional log line. + * @param {Function} [writeFile] Synchronous writer (journaled during identity edits). * @return {string} Absolute path written. */ -const writeIdentityFile = (root, payload, ui) => { - const filePath = path.join(root, IDENTITY_FILE); - fs.writeFileSync( - filePath, - `${JSON.stringify(payload, null, '\t')}\n`, - 'utf8' - ); +const writeIdentityFile = (root, payload, ui, writeFile = fs.writeFileSync) => { + const filePath = resolveWithin(root, IDENTITY_FILE); + writeFile(filePath, `${JSON.stringify(payload, null, '\t')}\n`, 'utf8'); if (ui) { ui.info(`wrote ${IDENTITY_FILE}`); } @@ -59,7 +56,7 @@ const writeIdentityFile = (root, payload, ui) => { * discard it). */ const readIdentityFile = (root) => { - const filePath = path.join(root, IDENTITY_FILE); + const filePath = resolveWithin(root, IDENTITY_FILE); if (!fs.existsSync(filePath)) { return null; } @@ -77,7 +74,7 @@ const readIdentityFile = (root) => { } catch (err) { throw new IdentityFileError( `${IDENTITY_FILE} exists but does not contain a valid identity object (${err.message}). ` + - 'Fix or delete the file, or pass --reinit to discard it.', + 'Restore a valid identity file or use a fresh starter.', { path: filePath } ); } @@ -98,15 +95,16 @@ const readFeatures = (root) => { * Update only the `features` field of the persisted identity, preserving every * other field and the file's tab indentation. * - * @param {string} root - Project root. - * @param {Object} featuresMap - Features map to persist. - * @param {Object} [ui] - UI for an optional log line. + * @param {string} root - Project root. + * @param {Object} featuresMap - Features map to persist. + * @param {Object} [ui] - UI for an optional log line. + * @param {Function} [writeFile] Journaled synchronous writer. * @return {string} Absolute path written. */ -const writeFeatures = (root, featuresMap, ui) => { +const writeFeatures = (root, featuresMap, ui, writeFile = fs.writeFileSync) => { const identity = readIdentityFile(root) || {}; identity.features = featuresMap; - return writeIdentityFile(root, identity, ui); + return writeIdentityFile(root, identity, ui, writeFile); }; module.exports = { diff --git a/node-packages/wp-tooling/src/init/selection.js b/node-packages/wp-tooling/src/init/selection.js new file mode 100644 index 0000000..fc2d35b --- /dev/null +++ b/node-packages/wp-tooling/src/init/selection.js @@ -0,0 +1,117 @@ +/** Resolve setup selections without modifying project files. */ +'use strict'; + +const { detectMap, makeFeatureApi } = require('./features'); + +/** + * Apply exact or delta flags to a starting feature selection. + * + * @param {Object} flags Parsed flags. + * @param {Iterable} defaults Starting enabled keys. + * @return {Set} Desired enabled keys; disable takes precedence. + */ +const selectFeatures = (flags, defaults) => { + if (undefined !== flags.features) { + return new Set(flags.features); + } + const selected = new Set(defaults); + for (const key of flags.enable || []) { + selected.add(key); + } + for (const key of flags.disable || []) { + selected.delete(key); + } + return selected; +}; + +/** + * Group capabilities for the checkbox UI, preserving declaration order. + * + * @param {Object[]} entries Labeled entries with checked defaults. + * @return {Object[]} Checkbox groups. + */ +const groupCapabilities = (entries) => { + const categories = new Map(); + for (const entry of entries) { + const category = entry.category || 'Other'; + if (!categories.has(category)) { + categories.set(category, []); + } + categories + .get(category) + .push({ label: entry.label, checked: entry.checked }); + } + return [...categories].map(([label, items]) => ({ label, items })); +}; + +/** + * Resolve example and feature choices before setup applies identity changes. + * + * @param {Object} config Scaffold configuration. + * @param {string} root Project root. + * @param {Object} flags Parsed flags. + * @param {Object} identity Target identity supplied to feature probes. + * @param {boolean} reinitializing Whether one-shot examples are already consumed. + * @param {Object} ui Prompt interface. + * @return {Promise} Removal keys and enabled feature keys. + */ +const selectCapabilities = async ( + config, + root, + flags, + identity, + reinitializing, + ui +) => { + const features = config.features || []; + const groups = reinitializing ? [] : config.examples?.groups || []; + const detected = detectMap(config, makeFeatureApi(root, identity, ui)); + const defaults = features + .filter((feature) => feature.defaultOn || detected[feature.key]) + .map((feature) => feature.key); + const interactive = + !flags.yes && + !flags.keepExamples && + ['removeExamples', 'features', 'enable', 'disable'].every( + (key) => undefined === flags[key] + ); + + if (!interactive || (!features.length && !groups.length)) { + return { + wantOn: selectFeatures(flags, defaults), + removeKeys: new Set( + true === flags.removeExamples + ? groups.map((group) => group.key) + : flags.removeExamples || [] + ), + }; + } + const selected = new Set(defaults); + const entries = [ + ...groups.map((group) => ({ ...group, checked: true })), + ...features.map((feature) => ({ + ...feature, + checked: selected.has(feature.key), + })), + ]; + const checked = new Set( + await ui.checkboxTree({ + message: `Select the capabilities to include in your ${config.kind || 'project'}`, + groups: groupCapabilities(entries), + }) + ); + return { + wantOn: new Set( + features + .filter((feature) => checked.has(feature.label)) + .map((feature) => feature.key) + ), + removeKeys: new Set( + groups + .filter((group) => !checked.has(group.label)) + .map((group) => group.key) + ), + }; +}; + +module.exports = { selectFeatures, selectCapabilities }; diff --git a/node-packages/wp-tooling/src/init/transform.js b/node-packages/wp-tooling/src/init/transform.js index 3185807..6336fb3 100644 --- a/node-packages/wp-tooling/src/init/transform.js +++ b/node-packages/wp-tooling/src/init/transform.js @@ -10,6 +10,26 @@ const fs = require('fs'); const path = require('path'); +const { randomUUID } = require('crypto'); + +/** + * Require a nonempty relative path without parent traversal. + * + * @param {string} value Configured path. + * @return {void} + */ +const validateRelativePath = (value) => { + if ( + 'string' !== typeof value || + !value || + path.isAbsolute(value) || + value.split(/[\\/]/).includes('..') + ) { + throw new Error( + `Expected a relative path without "..", received ${JSON.stringify(value)}` + ); + } +}; /** * Resolve a project-relative path and assert it stays inside `root`, so a config @@ -26,6 +46,26 @@ const resolveWithin = (root, rel) => { if (abs !== base && !abs.startsWith(base + path.sep)) { throw new Error(`Refusing path outside the project root: ${rel}`); } + if (!fs.existsSync(base)) { + return abs; + } + const realBase = fs.realpathSync(base); + let ancestor = abs; + while (!fs.existsSync(ancestor)) { + if ( + fs.lstatSync(ancestor, { throwIfNoEntry: false })?.isSymbolicLink() + ) { + throw new Error(`Refusing unresolved symlink: ${rel}`); + } + ancestor = path.dirname(ancestor); + } + const realAncestor = fs.realpathSync(ancestor); + if ( + realAncestor !== realBase && + !realAncestor.startsWith(realBase + path.sep) + ) { + throw new Error(`Refusing path outside the project root: ${rel}`); + } return abs; }; @@ -174,12 +214,7 @@ const PHP_RESERVED_WORDS = [ * @return {string[]} Absolute file paths. */ const collectFiles = (dir, ignore = DEFAULT_IGNORE) => { - let entries; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } catch { - return []; - } + const entries = fs.readdirSync(dir, { withFileTypes: true }); let files = []; entries.forEach((entry) => { @@ -239,15 +274,63 @@ const applyReplacements = (text, replacements) => { return text.replace(new RegExp(pattern, 'g'), (match) => map.get(match)); }; +/** + * Run synchronous file edits with rollback on failure. Only written contents + * and completed renames are journaled; this is not a process-crash recovery log. + * Reverse order restores paths before earlier writes at those paths are undone. + * + * @param {Function} apply Callback receiving journaled writeFile and renameFile. + * @return {*} Callback result. + */ +const withFileRollback = (apply) => { + const undo = []; + const writeFile = (file, content, options) => { + const existed = fs.existsSync(file); + const original = existed ? fs.readFileSync(file) : null; + // Register before writing: a failed write may have truncated the file. + undo.push(() => { + if (existed) { + fs.writeFileSync(file, original); + } else { + fs.rmSync(file, { force: true }); + } + }); + fs.writeFileSync(file, content, options); + }; + const renameFile = (from, to) => { + fs.renameSync(from, to); + undo.push(() => fs.renameSync(to, from)); + }; + try { + return apply({ writeFile, renameFile }); + } catch (error) { + const rollbackErrors = []; + for (const restore of undo.reverse()) { + try { + restore(); + } catch (rollbackError) { + rollbackErrors.push(rollbackError); + } + } + if (rollbackErrors.length) { + throw new AggregateError( + [error, ...rollbackErrors], + `${error.message}; rollback failed: ${rollbackErrors.map((failure) => failure.message).join('; ')}. Inspect the project before retrying.` + ); + } + throw error; + } +}; + /** * Replace token content across files in place. * * @param {string[]} files - Absolute file paths. * @param {Array<[string, string]>} replacements - Ordered [ from, to ] pairs. - * @param {Object} ui - `@rtcamp/wp-tooling/ui`. + * @param {Function} [writeFile] Synchronous writer (journaled during identity edits). * @return {number} Count of files changed. */ -const replaceInFiles = (files, replacements, ui) => { +const replaceInFiles = (files, replacements, writeFile = fs.writeFileSync) => { let changed = 0; files.forEach((filePath) => { try { @@ -258,43 +341,107 @@ const replaceInFiles = (files, replacements, ui) => { const original = buffer.toString('utf8'); const updated = applyReplacements(original, replacements); if (updated !== original) { - fs.writeFileSync(filePath, updated, 'utf8'); + writeFile(filePath, updated, 'utf8'); changed++; } } catch (err) { - ui.warn(`Skipped ${path.basename(filePath)}: ${err.message}`); + throw new Error(`Could not replace ${filePath}: ${err.message}`, { + cause: err, + }); } }); return changed; }; /** - * Rename files whose basename contains any token. + * Check every rename destination before any content or path changes. * - * @param {string[]} files - Absolute file paths. - * @param {Array<[string, string]>} replacements - Ordered [ from, to ] pairs. - * @param {Object} ui - `@rtcamp/wp-tooling/ui`. - * @return {number} Count of files renamed. + * @param {string[]} files Source file paths. + * @param {Array} replacements Literal replacement pairs. + * @return {Array} Validated source/destination pairs. */ -const renameFiles = (files, replacements, ui) => { - let renamed = 0; - files.forEach((filePath) => { - const base = path.basename(filePath); - const newBase = applyReplacements(base, replacements); - if (newBase === base) { - return; +const planRenames = (files, replacements) => { + const renames = files + .map((from) => ({ + from, + to: path.join( + path.dirname(from), + applyReplacements(path.basename(from), replacements) + ), + })) + .filter(({ from, to }) => from !== to); + const sources = new Set( + renames.map(({ from }) => fs.realpathSync.native(from)) + ); + const destinations = new Set(); + const caseSensitive = new Map(); + for (const { from, to } of renames) { + const dir = path.dirname(to); + if (!caseSensitive.has(dir)) { + // Probe the actual directory's filesystem, not the host OS default. + const probe = fs.mkdtempSync(path.join(dir, '.wp-init-case-')); + try { + fs.writeFileSync(path.join(probe, 'probe'), ''); + caseSensitive.set( + dir, + !fs.existsSync(path.join(probe, 'PROBE')) + ); + } finally { + fs.rmSync(probe, { recursive: true, force: true }); + } } - try { - fs.renameSync(filePath, path.join(path.dirname(filePath), newBase)); - // No per-file line here: the caller prints a "renamed N file(s)" - // summary. One line per renamed file is pure noise for a human and - // wasted tokens for an AI reading the run. Failures still warn below. - renamed++; - } catch (err) { - ui.warn(`Could not rename ${base}: ${err.message}`); + const key = caseSensitive.get(dir) ? to : to.toLowerCase(); + const existing = fs.lstatSync(to, { throwIfNoEntry: false }); + const vacated = + existing && + !existing.isSymbolicLink() && + sources.has(fs.realpathSync.native(to)); + if (destinations.has(key) || (existing && !vacated)) { + throw new Error( + `Rename collision: expected an unused destination for ${from}, received ${to}` + ); } - }); - return renamed; + destinations.add(key); + } + return renames; +}; + +/** + * Execute a validated plan, staging sources so chains and swaps cannot overwrite. + * + * @param {Array} renames Validated source/destination pairs. + * @param {Function} renameFile Journaled synchronous rename. + * @return {number} Number of logical renames. + */ +const executeRenames = (renames, renameFile) => { + const staged = renames.map((entry) => ({ + ...entry, + temp: path.join( + path.dirname(entry.from), + `.wp-init-rename-${randomUUID()}` + ), + })); + for (const { from, temp } of staged) { + renameFile(from, temp); + } + for (const { temp, to } of staged) { + renameFile(temp, to); + } + return renames.length; +}; + +/** + * Plan and atomically apply a batch of file renames on synchronous failure. + * + * @param {string[]} files Source paths. + * @param {Array} replacements Literal replacement pairs. + * @return {number} Number of renamed files. + */ +const renameFiles = (files, replacements) => { + const plan = planRenames(files, replacements); + return withFileRollback(({ renameFile }) => + executeRenames(plan, renameFile) + ); }; /** @@ -312,21 +459,22 @@ const renameFiles = (files, replacements, ui) => { * @param {Array<{path: string, kind: string}>} versionFiles - Targets. * @param {string} version - Version to apply. * @param {Object} ui - `@rtcamp/wp-tooling/ui`. + * @param {Function} [writeFile] Synchronous writer (journaled during identity edits). * @return {void} */ -const applyVersion = (root, versionFiles, version, ui) => { +const applyVersion = ( + root, + versionFiles, + version, + ui, + writeFile = fs.writeFileSync +) => { if (!version || !Array.isArray(versionFiles)) { return; } versionFiles.forEach((spec) => { - let filePath; - try { - filePath = resolveWithin(root, spec.path); - } catch (err) { - ui.warn(err.message); - return; - } + const filePath = resolveWithin(root, spec.path); if (!fs.existsSync(filePath)) { return; } @@ -347,15 +495,22 @@ const applyVersion = (root, versionFiles, version, ui) => { ); } - fs.writeFileSync(filePath, content, 'utf8'); + writeFile(filePath, content, 'utf8'); ui.info(`version ${version} -> ${spec.path}`); } catch (err) { - ui.warn(`Could not set version in ${spec.path}: ${err.message}`); + throw new Error( + `Could not set version in ${spec.path}: ${err.message}`, + { cause: err } + ); } }); }; module.exports = { + withFileRollback, + validateRelativePath, + planRenames, + executeRenames, resolveWithin, collectFiles, applyReplacements, diff --git a/node-packages/wp-tooling/src/init/validation.js b/node-packages/wp-tooling/src/init/validation.js new file mode 100644 index 0000000..640e296 --- /dev/null +++ b/node-packages/wp-tooling/src/init/validation.js @@ -0,0 +1,158 @@ +/** Shared validation for mutating setup and manage requests. */ +'use strict'; + +const path = require('path'); +const { resolveWithin, validateRelativePath } = require('./transform'); +const { resolveCleanupTargets } = require('./cleanup'); +const { validateName, validateVersion } = require('./identity'); + +/** + * Build a CLI usage error with a stable machine code. + * + * @param {string} message Expected input and received value. + * @return {Error} Coded error. + */ +const usageError = (message) => + Object.assign(new Error(message), { code: 'EUSAGE' }); + +/** + * Validate keys against a declared capability collection. + * + * @param {string[]} requested Requested keys. + * @param {Object[]} declared Manifest entries. + * @param {string} label CLI flag or capability name. + * @return {void} + */ +const validateKeys = (requested, declared, label) => { + const valid = new Set(declared.map((entry) => entry.key)); + const unknown = requested.filter((key) => !valid.has(key)); + if (unknown.length) { + throw usageError( + `Unknown ${label} key(s): ${unknown.join(', ')}. Valid keys: ${[...valid].sort().join(', ') || '(none declared)'}` + ); + } +}; + +/** + * Validate feature flags consistently before detection or mutation. + * + * @param {Object} config Scaffold configuration. + * @param {Object} flags Parsed flags, preserving empty exact selections. + * @return {void} + */ +const validateFeatureFlags = (config, flags) => { + if ( + undefined !== flags.features && + (undefined !== flags.enable || undefined !== flags.disable) + ) { + throw usageError( + '--features cannot be combined with --enable/--disable.' + ); + } + validateKeys( + [ + ...(flags.features || []), + ...(flags.enable || []), + ...(flags.disable || []), + ], + config.features || [], + 'feature' + ); +}; + +/** + * Validate setup flags before the wizard can write any files. + * + * @param {Object} config Scaffold configuration. + * @param {Object} flags Parsed setup flags. + * @return {void} + */ +const validateSetupFlags = (config, flags) => { + validateFeatureFlags(config, flags); + if (flags.keepExamples && undefined !== flags.removeExamples) { + throw usageError( + '--keep-examples cannot be combined with --remove-examples.' + ); + } + if (Array.isArray(flags.removeExamples)) { + validateKeys( + flags.removeExamples, + config.examples?.groups || [], + '--remove-examples' + ); + } + if (undefined !== flags.name) { + const error = validateName(flags.name); + if (error) { + throw usageError( + `--name: ${error} Received ${JSON.stringify(flags.name)}.` + ); + } + } + const version = flags.version ?? config.version; + if (undefined !== version) { + const error = validateVersion(version); + if (error) { + throw usageError( + `--version: ${error} Received ${JSON.stringify(version)}.` + ); + } + } +}; + +/** + * Validate configured paths before setup changes identity or removes examples. + * + * @param {Object} config Scaffold configuration. + * @param {string} root Project root. + * @param {Object} [identity] Resolved identity for functional version paths. + * @return {void} + */ +const validatePaths = (config, root, identity) => { + const check = (value, boundary = root) => { + validateRelativePath(value); + if (resolveWithin(boundary, value) === path.resolve(boundary)) { + throw new Error( + `Expected a path below ${boundary}, received ${JSON.stringify(value)}` + ); + } + }; + resolveCleanupTargets(root, config.cleanup?.targets); + check(config.featuresDir || 'bin/features'); + for (const feature of config.features || []) { + for (const file of feature.apply?.files || []) { + check(file.to); + check( + file.from, + path.resolve(root, config.featuresDir || 'bin/features') + ); + } + } + for (const group of config.examples?.groups || []) { + for (const target of [ + ...(group.strip || []), + ...(group.remove || []), + ]) { + check(target); + } + } + for (const spec of config.versionFiles || []) { + if (!['json', 'css-header', 'php-header'].includes(spec.kind)) { + throw new Error( + `Expected version kind json, css-header or php-header, received ${JSON.stringify(spec.kind)}` + ); + } + if ('function' !== typeof spec.path) { + check(spec.path); + } else if (identity) { + check(spec.path({ ...identity, kebab: identity.textDomain })); + } + } +}; + +module.exports = { + usageError, + validateFeatureFlags, + validateSetupFlags, + validatePaths, +}; diff --git a/node-packages/wp-tooling/tests/init/capabilities.test.js b/node-packages/wp-tooling/tests/init/capabilities.test.js index a1661bc..9255131 100644 --- a/node-packages/wp-tooling/tests/init/capabilities.test.js +++ b/node-packages/wp-tooling/tests/init/capabilities.test.js @@ -510,7 +510,7 @@ describe('run --list (usage + failure contract)', () => { const payload = JSON.parse(stdout.trim()); expect(payload.mode).toBe('setup'); expect( - payload.warnings.some((w) => /--reinit will overwrite/.test(w)) + payload.warnings.some((w) => /restore a valid identity/i.test(w)) ).toBe(true); expect(process.exitCode || 0).toBe(0); }); @@ -670,20 +670,6 @@ describe('setup records the capability selection', () => { drift: false, }); }); - - it('rejects an unknown --remove-examples key without persisting anything', async () => { - await expect( - run(FULL_CONFIG, { - root, - argv: [ - '--yes', - '--name=Acme Blog', - '--remove-examples=cron,not-a-real-group', - ], - }) - ).rejects.toThrow(/Unknown --remove-examples key.*not-a-real-group/); - expect(fs.existsSync(path.join(root, IDENTITY_FILE))).toBe(false); - }); }); describe('init debug logging', () => { diff --git a/node-packages/wp-tooling/tests/init/cleanup.test.js b/node-packages/wp-tooling/tests/init/cleanup.test.js new file mode 100644 index 0000000..d2de877 --- /dev/null +++ b/node-packages/wp-tooling/tests/init/cleanup.test.js @@ -0,0 +1,43 @@ +'use strict'; +const { runCleanup } = require('../../src/init/cleanup'); +const fs = require('fs'); +const path = require('path'); +const { makeRoot, touch } = require('./_helpers'); + +describe('runCleanup', () => { + let root; + const ui = { info: jest.fn() }; + beforeEach(() => { + root = makeRoot(); + }); + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + jest.restoreAllMocks(); + }); + test('cleanup validates all paths before deleting any target', () => { + touch(root, 'keep.txt', 'keep'); + expect(() => runCleanup(root, ['keep.txt', '.'], ui)).toThrow( + /project root/ + ); + expect(fs.readFileSync(path.join(root, 'keep.txt'), 'utf8')).toBe( + 'keep' + ); + }); + + test.each(['keep.txt', {}, [null]])( + 'rejects malformed cleanup targets %j before deletion', + (targets) => { + touch(root, 'keep.txt', 'keep'); + expect(() => runCleanup(root, targets, ui)).toThrow(/Expected/); + expect(fs.readFileSync(path.join(root, 'keep.txt'), 'utf8')).toBe( + 'keep' + ); + } + ); + test('removes files and directories and skips missing targets', () => { + touch(root, 'file.txt'); + touch(root, 'nested/file.txt'); + expect(runCleanup(root, ['file.txt', 'nested', 'missing'], ui)).toBe(2); + expect(fs.readdirSync(root)).toEqual([]); + }); +}); diff --git a/node-packages/wp-tooling/tests/init/features.test.js b/node-packages/wp-tooling/tests/init/features.test.js index 364cf44..6f49ddf 100644 --- a/node-packages/wp-tooling/tests/init/features.test.js +++ b/node-packages/wp-tooling/tests/init/features.test.js @@ -9,6 +9,8 @@ const fs = require('fs'); const path = require('path'); const { + validateFeatures, + safeDetectMap, makeFeatureApi, enableFeature, disableFeature, @@ -63,7 +65,7 @@ describe('api.editJson', () => { afterEach(() => fs.rmSync(root, { recursive: true, force: true })); - it('preserves tab indentation', () => { + it('preserves tab indentation', async () => { touch(root, 'composer.json', '{\n\t"name": "acme/demo"\n}\n'); api.editJson('composer.json', (obj) => { @@ -75,7 +77,7 @@ describe('api.editJson', () => { ); }); - it('preserves two-space indentation', () => { + it('preserves two-space indentation', async () => { touch(root, '.wp-env.json', '{\n "core": null\n}\n'); api.editJson('.wp-env.json', (obj) => { @@ -87,14 +89,14 @@ describe('api.editJson', () => { ); }); - it('throws for a missing file without create', () => { + it('throws for a missing file without create', async () => { expect(() => api.editJson('nope.json', () => {})).toThrow( 'editJson: nope.json not found' ); expect(fs.existsSync(path.join(root, 'nope.json'))).toBe(false); }); - it('names the offending file when the JSON is unparseable', () => { + it('names the offending file when the JSON is unparseable', async () => { touch(root, '.wp-env.override.json', '{ this is not json'); expect(() => api.editJson('.wp-env.override.json', () => {})).toThrow( @@ -105,7 +107,7 @@ describe('api.editJson', () => { expect(raw(root, '.wp-env.override.json')).toBe('{ this is not json'); }); - it('creates a missing file with create, defaulting to tab indent', () => { + it('creates a missing file with create, defaulting to tab indent', async () => { api.editJson( '.wp-env.override.json', (obj) => { @@ -119,7 +121,7 @@ describe('api.editJson', () => { ); }); - it('takes a created file indent from indentFrom', () => { + it('takes a created file indent from indentFrom', async () => { touch(root, '.wp-env.json', '{\n "core": null\n}\n'); api.editJson( @@ -135,7 +137,7 @@ describe('api.editJson', () => { ); }); - it('leaves an existing file untouched when the mutator throws', () => { + it('leaves an existing file untouched when the mutator throws', async () => { const before = '{\n\t"name": "acme/demo"\n}\n'; touch(root, 'composer.json', before); @@ -149,7 +151,7 @@ describe('api.editJson', () => { expect(raw(root, 'composer.json')).toBe(before); }); - it('rolls back a completed edit when a later hook step throws', () => { + it('rolls back edits made after an async hook resumes and then fails', async () => { const before = '{\n\t"name": "acme/demo"\n}\n'; touch(root, 'composer.json', before); touch(root, 'package.json', '{\n\t"name": "demo"\n}\n'); @@ -157,7 +159,8 @@ describe('api.editJson', () => { const feature = { key: 'rollback', label: 'Rollback', - onEnable: (a) => { + onEnable: async (a) => { + await Promise.resolve(); a.editJson('composer.json', (obj) => { obj['require-dev'] = { 'rtcamp/wp-dev-tools': 'dev-main' }; }); @@ -173,9 +176,9 @@ describe('api.editJson', () => { onDisable: () => {}, }; - expect(() => enableFeature(feature, api, 'bin/features')).toThrow( - 'late failure' - ); + await expect( + enableFeature(feature, api, 'bin/features') + ).rejects.toThrow('late failure'); expect(raw(root, 'composer.json')).toBe(before); expect(fs.existsSync(path.join(root, '.wp-env.override.json'))).toBe( @@ -183,7 +186,7 @@ describe('api.editJson', () => { ); }); - it('rolls back directory creation for a newly created nested path', () => { + it('rolls back directory creation for a newly created nested path', async () => { const feature = { key: 'rollback-dir', label: 'Rollback dir', @@ -200,14 +203,14 @@ describe('api.editJson', () => { onDisable: () => {}, }; - expect(() => enableFeature(feature, api, 'bin/features')).toThrow( - 'late failure' - ); + await expect( + enableFeature(feature, api, 'bin/features') + ).rejects.toThrow('late failure'); expect(fs.existsSync(path.join(root, 'nested'))).toBe(false); }); - it('preserves a pre-existing ancestor directory on rollback', () => { + it('preserves a pre-existing ancestor directory on rollback', async () => { touch(root, 'a/existing.txt', 'keep me\n'); const feature = { @@ -226,15 +229,15 @@ describe('api.editJson', () => { onDisable: () => {}, }; - expect(() => enableFeature(feature, api, 'bin/features')).toThrow( - 'late failure' - ); + await expect( + enableFeature(feature, api, 'bin/features') + ).rejects.toThrow('late failure'); expect(fs.existsSync(path.join(root, 'a', 'b'))).toBe(false); expect(raw(root, 'a/existing.txt')).toBe('keep me\n'); }); - it('still backs editPackageJson, which keeps its own indent', () => { + it('still backs editPackageJson, which keeps its own indent', async () => { touch(root, 'package.json', '{\n "name": "demo"\n}\n'); api.editPackageJson((pkg) => { @@ -262,7 +265,7 @@ describe('makeFeatureApi from the public ./init entry point', () => { afterEach(() => fs.rmSync(root, { recursive: true, force: true })); - it('editJson works when destructured off the api', () => { + it('editJson works when destructured off the api', async () => { touch(root, '.wp-env.json', '{\n "core": null\n}\n'); const { editJson } = api; @@ -279,7 +282,7 @@ describe('makeFeatureApi from the public ./init entry point', () => { ); }); - it('setDefine works when destructured off the api', () => { + it('setDefine works when destructured off the api', async () => { touch(root, 'entry.php', " { detect: (a) => a.exists('enabled.flag'), }); - it('queues rather than printing during the hook', () => { + it('queues rather than printing during the hook', async () => { const ui = fakeUi(); const api = makeFeatureApi(root, IDENTITY, ui); - enableFeature(noteFeature('run composer install'), api, 'bin/features'); + await enableFeature( + noteFeature('run composer install'), + api, + 'bin/features' + ); expect(ui.calls.info).toEqual([]); expect(api._notes).toEqual(['run composer install']); @@ -335,7 +342,7 @@ describe('api.note', () => { expect(api._notes).toEqual([]); }); - it('drops notes queued by a hook that then threw', () => { + it('drops notes queued by a hook that then threw', async () => { const api = makeFeatureApi(root, IDENTITY, fakeUi()); const feature = { key: 'boom', @@ -347,9 +354,9 @@ describe('api.note', () => { onDisable: () => {}, }; - expect(() => enableFeature(feature, api, 'bin/features')).toThrow( - 'nope' - ); + await expect( + enableFeature(feature, api, 'bin/features') + ).rejects.toThrow('nope'); expect(api._notes).toEqual([]); }); @@ -488,11 +495,11 @@ describe('enable / disable / detect round trip', () => { afterEach(() => fs.rmSync(root, { recursive: true, force: true })); - it('writes both halves on enable and reads back as detected', () => { + it('writes both halves on enable and reads back as detected', async () => { const api = makeFeatureApi(root, IDENTITY, fakeUi()); expect(detectFeature(feature, api)).toBe(false); - enableFeature(feature, api, 'bin/features'); + await enableFeature(feature, api, 'bin/features'); expect(readJson(root, 'composer.json')['require-dev']).toEqual({ 'acme/probe': 'dev-main', @@ -506,21 +513,21 @@ describe('enable / disable / detect round trip', () => { expect(detectFeature(feature, api)).toBe(true); }); - it('is idempotent on re-enable', () => { + it('is idempotent on re-enable', async () => { const api = makeFeatureApi(root, IDENTITY, fakeUi()); - enableFeature(feature, api, 'bin/features'); + await enableFeature(feature, api, 'bin/features'); const after = raw(root, 'composer.json'); - enableFeature(feature, api, 'bin/features'); + await enableFeature(feature, api, 'bin/features'); expect(raw(root, 'composer.json')).toBe(after); }); - it('leaves no trace on disable', () => { + it('leaves no trace on disable', async () => { const api = makeFeatureApi(root, IDENTITY, fakeUi()); - enableFeature(feature, api, 'bin/features'); + await enableFeature(feature, api, 'bin/features'); - disableFeature(feature, api, []); + await disableFeature(feature, api, []); expect(readJson(root, 'composer.json')['require-dev']).toEqual({}); expect(fs.existsSync(path.join(root, '.env.override.json'))).toBe( @@ -530,21 +537,21 @@ describe('enable / disable / detect round trip', () => { expect(detectFeature(feature, api)).toBe(false); }); - it('keeps unrelated override content on disable', () => { + it('keeps unrelated override content on disable', async () => { const api = makeFeatureApi(root, IDENTITY, fakeUi()); - enableFeature(feature, api, 'bin/features'); + await enableFeature(feature, api, 'bin/features'); api.editJson('.env.override.json', (obj) => { obj.config.MY_OWN_KEY = 'keep me'; }); - disableFeature(feature, api, []); + await disableFeature(feature, api, []); expect(readJson(root, '.env.override.json').config).toEqual({ MY_OWN_KEY: 'keep me', }); }); - it('reads as disabled when only the committed half is present', () => { + it('reads as disabled when only the committed half is present', async () => { const api = makeFeatureApi(root, IDENTITY, fakeUi()); api.editJson('composer.json', (obj) => { obj['require-dev'] = { 'acme/probe': 'dev-main' }; @@ -553,7 +560,7 @@ describe('enable / disable / detect round trip', () => { expect(detectFeature(feature, api)).toBe(false); }); - it('reads as disabled when only the local half is present', () => { + it('reads as disabled when only the local half is present', async () => { const api = makeFeatureApi(root, IDENTITY, fakeUi()); api.editJson( '.env.override.json', @@ -566,7 +573,7 @@ describe('enable / disable / detect round trip', () => { expect(detectFeature(feature, api)).toBe(false); }); - it('does not throw probing an empty project', () => { + it('does not throw probing an empty project', async () => { const bare = makeRoot(); try { const api = makeFeatureApi(bare, IDENTITY, fakeUi()); @@ -577,3 +584,372 @@ describe('enable / disable / detect round trip', () => { } }); }); + +describe('feature transitions', () => { + let root; + const ui = { ...fakeUi(), radio: jest.fn(), confirm: jest.fn() }; + beforeEach(() => { + root = makeRoot(); + process.exitCode = undefined; + jest.clearAllMocks(); + }); + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + process.exitCode = undefined; + jest.restoreAllMocks(); + }); + test.each(['persist', 'detect'])( + 'rolls back the entire batch after %s failure', + async (failure) => { + const localUi = fakeUi(); + const original = '{"features":{},"custom":"keep"}'; + touch(root, '.wp-scaffold.json', original); + touch(root, 'shared.txt', 'before'); + const api = makeFeatureApi(root, IDENTITY, localUi); + const features = ['first', 'second'].map((key) => ({ + key, + label: key, + detect: () => { + if ('detect' === failure && api.exists('second.flag')) { + throw new Error('detect failed'); + } + return api.exists(`${key}.flag`); + }, + onEnable: () => { + api.write(`${key}.flag`, key); + api.write('shared.txt', key); + api.note('must not print'); + }, + })); + const write = fs.writeFileSync; + let failed = false; + const spy = jest + .spyOn(fs, 'writeFileSync') + .mockImplementation((file, ...args) => { + if ( + 'persist' === failure && + path.basename(file) === '.wp-scaffold.json' && + !failed + ) { + failed = true; + write(file, 'truncated'); + throw new Error('persist failed'); + } + return write(file, ...args); + }); + try { + await expect( + toggleFeatures({ features }, root, { + mode: 'manage', + api, + ui: localUi, + flags: { yes: true }, + wantOn: new Set(['first', 'second']), + }) + ).rejects.toThrow(`${failure} failed`); + expect(raw(root, '.wp-scaffold.json')).toBe(original); + expect(raw(root, 'shared.txt')).toBe('before'); + expect(api.exists('first.flag')).toBe(false); + expect(api.exists('second.flag')).toBe(false); + expect(api._journal).toHaveLength(0); + expect(api._notes).toHaveLength(0); + expect(localUi.calls.info).not.toContain('must not print'); + expect(localUi.calls.success).not.toContain( + 'Features updated.' + ); + } finally { + spy.mockRestore(); + } + } + ); + + test.each(['Cancel', 'Apply changes'])( + '%s without toggling does not persist drift', + async (choice) => { + ui.radio.mockResolvedValueOnce(choice); + const identity = { name: 'Acme Blog', features: { demo: false } }; + const original = JSON.stringify(identity); + touch(root, '.wp-scaffold.json', original); + const config = { + features: [{ key: 'demo', label: 'Demo', detect: () => true }], + }; + await toggleFeatures(config, root, { + mode: 'manage', + api: makeFeatureApi(root, identity, ui), + ui, + }); + expect( + fs.readFileSync(path.join(root, '.wp-scaffold.json'), 'utf8') + ).toBe(original); + expect(ui.radio).toHaveBeenCalledTimes(1); + expect(ui.confirm).not.toHaveBeenCalled(); + } + ); + + test.each([false, true])( + 'persists only successful transitions (earlier success: %s)', + async (earlierSuccess) => { + const identity = { + name: 'Acme Blog', + features: { + first: false, + failing: false, + drift: false, + retired: true, + }, + }; + const original = JSON.stringify(identity); + touch(root, '.wp-scaffold.json', original); + const config = { + features: [ + { + key: 'first', + label: 'First', + detect: (api) => api.exists('first.flag'), + onEnable: (api) => api.write('first.flag', 'enabled'), + }, + { + key: 'failing', + label: 'Failing', + detect: (api) => api.exists('failing.flag'), + onEnable(api) { + api.write('failing.flag', 'enabled'); + throw new Error('hook failed'); + }, + }, + { key: 'drift', label: 'Drift', detect: () => true }, + ], + }; + const result = await toggleFeatures(config, root, { + mode: 'manage', + api: makeFeatureApi(root, identity, ui), + ui, + wantOn: new Set([ + 'failing', + 'drift', + ...(earlierSuccess ? ['first'] : []), + ]), + flags: { yes: true }, + }); + expect(result.changed).toBe(earlierSuccess); + expect(result.failed).toEqual(['failing']); + expect(process.exitCode).toBe(1); + expect(fs.existsSync(path.join(root, 'failing.flag'))).toBe(false); + const saved = fs.readFileSync( + path.join(root, '.wp-scaffold.json'), + 'utf8' + ); + const expected = earlierSuccess + ? `${JSON.stringify({ ...identity, features: { first: true, failing: false, drift: true } }, null, '\t')}\n` + : original; + expect(saved).toBe(expected); + } + ); + + test('rollback failures are reported alongside the original hook failure', async () => { + touch(root, 'file.txt', 'before'); + const originalWrite = fs.writeFileSync; + jest.spyOn(fs, 'writeFileSync').mockImplementation( + (file, body, ...options) => { + if (String(body) === 'before') { + throw new Error('undo failed'); + } + return originalWrite(file, body, ...options); + } + ); + const api = makeFeatureApi(root, {}, ui); + await expect( + enableFeature( + { + onEnable(featureApi) { + featureApi.write('file.txt', 'after'); + throw new Error('original failure'); + }, + }, + api, + 'bin/features' + ) + ).rejects.toThrow(/original failure; rollback failed: undo failed/); + }); + + test('a failed disable stops the transition and protects shared dependencies', async () => { + touch( + root, + 'package.json', + JSON.stringify({ devDependencies: { shared: '1' } }) + ); + touch(root, 'first.flag'); + touch(root, 'second.flag'); + const config = { + features: [ + { + key: 'first', + label: 'First', + detect: (api) => api.exists('first.flag'), + apply: { devDependencies: { shared: '1' } }, + onDisable() { + throw new Error('cannot disable'); + }, + }, + { + key: 'second', + label: 'Second', + detect: (api) => api.exists('second.flag'), + apply: { devDependencies: { shared: '1' } }, + onDisable: (api) => api.remove('second.flag'), + }, + ], + }; + const result = await toggleFeatures(config, root, { + mode: 'scaffold', + api: makeFeatureApi(root, {}, ui), + ui, + wantOn: new Set(), + flags: { yes: true }, + }); + expect(result.failed).toEqual(['first']); + expect(fs.existsSync(path.join(root, 'second.flag'))).toBe(true); + expect( + JSON.parse(fs.readFileSync(path.join(root, 'package.json'))) + .devDependencies.shared + ).toBe('1'); + }); +}); + +describe('validateFeatures', () => { + test.each([ + { features: {} }, + { features: [null] }, + { features: [{ key: 'demo', label: 'Demo', detect: true }] }, + { + features: [ + { + key: 'demo', + label: 'Demo', + onEnable: 'bad', + onDisable: 'bad', + }, + ], + }, + { features: [{ key: 'demo', label: 'Demo', onEnable() {} }] }, + { + features: [{ key: 'demo', label: 'Demo' }], + examples: { groups: [{ key: 'example', label: 'Demo' }] }, + }, + { + examples: { + groups: [ + { key: 'a', label: 'A' }, + { key: 'a', label: 'B' }, + ], + }, + }, + { examples: { groups: [{ key: 'a', label: 'A', strip: 'file.php' }] } }, + ])('rejects malformed manifest %j', (manifest) => { + expect(() => validateFeatures(manifest)).toThrow(); + }); + + test.each([ + ['dependencies', null], + ['devDependencies', 42], + ['scripts', false], + ['scripts', ['echo hi']], + ['dependencies', {}], + ])('rejects non-string apply.%s value %j', (field, value) => { + expect(() => + validateFeatures({ + features: [ + { + key: 'demo', + label: 'Demo', + apply: { [field]: { example: value } }, + }, + ], + }) + ).toThrow(`apply.${field}.example`); + }); + test('accepts npm dependency references and empty script strings', () => { + expect(() => + validateFeatures({ + features: [ + { + key: 'demo', + label: 'Demo', + apply: { + dependencies: { + local: 'file:../local', + remote: 'github:owner/repo#main', + range: '^1.0.0', + }, + devDependencies: { any: '*' }, + scripts: { disabled: '', build: 'node build.js' }, + }, + }, + ], + }) + ).not.toThrow(); + }); + + test.each([[], { files: null }, { scripts: [] }])( + 'rejects malformed apply %j', + (apply) => { + expect(() => + validateFeatures({ + features: [{ key: 'demo', label: 'Demo', apply }], + }) + ).toThrow(/apply/); + } + ); + test.each([{ label: 'Example' }, { key: 'example', label: ' ' }])( + 'rejects an incomplete example group %j', + (group) => { + expect(() => + validateFeatures({ examples: { groups: [group] } }) + ).toThrow(/example key|capability label/); + } + ); + test('preserves existing non-kebab example keys', () => { + expect(() => + validateFeatures({ + examples: { + groups: [{ key: 'Example_one', label: 'Example' }], + }, + }) + ).not.toThrow(); + }); +}); +test.each([ + ['resolved', async () => false], + [ + 'rejected', + async () => { + throw new Error('probe failed'); + }, + ], +])( + '%s promises from detect probes are reported as unknown', + async (_state, detect) => { + const feature = { + key: 'demo', + label: 'Demo', + detect, + }; + const result = safeDetectMap({ features: [feature] }, {}); + expect(result.map.demo).toBeNull(); + expect(result.errors[0].message).toMatch(/synchronous/); + } +); + +test.each([null, false, 1, '', ' ', {}])( + 'rejects explicit invalid marker %j', + (marker) => { + expect(() => validateFeatures({ examples: { marker } })).toThrow( + /nonempty string/ + ); + expect(() => + validateFeatures({ + examples: { groups: [{ key: 'demo', label: 'Demo', marker }] }, + }) + ).toThrow(/nonempty string/); + } +); diff --git a/node-packages/wp-tooling/tests/init/git.test.js b/node-packages/wp-tooling/tests/init/git.test.js index 5f991de..3a3d04f 100644 --- a/node-packages/wp-tooling/tests/init/git.test.js +++ b/node-packages/wp-tooling/tests/init/git.test.js @@ -4,12 +4,16 @@ */ 'use strict'; +jest.mock('child_process', () => { + const actual = jest.requireActual('child_process'); + return { ...actual, execFileSync: jest.fn(actual.execFileSync) }; +}); const { execFileSync } = require('child_process'); const fs = require('fs'); const os = require('os'); const path = require('path'); -const { installGitHooks } = require('../../src/init/git'); +const { installGitHooks, initRepo, commitAll } = require('../../src/init/git'); const noopUi = { spinner: () => ({ start() {}, succeed() {}, fail() {} }), @@ -101,3 +105,29 @@ describe('installGitHooks', () => { expect(await installGitHooks(dir, noopUi)).toBe(false); }); }); + +describe('Git operation failures', () => { + let root; + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'init-git-failure-')); + }); + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + test.each([ + ['init', (dir) => initRepo(dir, noopUi)], + ['commit', (dir) => commitAll(dir, 'Initial commit', noopUi)], + ])('%s propagates command failures', (_name, operation) => { + execFileSync.mockImplementationOnce(() => { + throw new Error('command failed'); + }); + expect(() => operation(root)).toThrow(/command failed/); + }); + test('hook setup rejects when prepare cannot be written', async () => { + execFileSync('git', ['init', '--quiet', root]); + fs.writeFileSync(path.join(root, 'package.json'), '{broken'); + await expect(installGitHooks(root, noopUi)).rejects.toThrow( + /Git hook installation failed/ + ); + }); +}); diff --git a/node-packages/wp-tooling/tests/init/identity.test.js b/node-packages/wp-tooling/tests/init/identity.test.js index d6d511d..7f73263 100644 --- a/node-packages/wp-tooling/tests/init/identity.test.js +++ b/node-packages/wp-tooling/tests/init/identity.test.js @@ -10,6 +10,14 @@ const { validateName, buildIdentityReplacements, } = require('../../src/init/identity'); +const fs = require('fs'); +const path = require('path'); +const { makeRoot, touch } = require('./_helpers'); +const { + applyIdentityEdit, + editIdentityFields, + editDetailsFlow, +} = require('../../src/init/identity'); const CONFIG = { vendor: 'rtcamp', @@ -86,3 +94,212 @@ describe('buildIdentityReplacements', () => { } }); }); + +describe('applyIdentityEdit', () => { + let root; + const ui = { info: jest.fn(), success: jest.fn() }; + beforeEach(() => { + root = makeRoot(); + }); + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + jest.restoreAllMocks(); + }); + + test.each(['replace', 'rename', 'version', 'persist'])( + '%s failure restores identity contents, filenames and permissions', + (stage) => { + const config = { + versionFiles: [{ path: 'package.json', kind: 'json' }], + }; + const oldId = { + ...identityFromName('Acme Blog', config), + version: '1.0.0', + custom: 'Acme Blog', + }; + const newId = { + ...identityFromName('Cedar Blog', config), + version: '2.0.0', + }; + touch(root, '.wp-scaffold.json', JSON.stringify(oldId)); + touch(root, 'a-acme-blog.txt', 'Acme Blog first'); + touch(root, 'b-acme-blog.txt', 'Acme Blog second'); + touch( + root, + 'package.json', + '{"name":"acme-blog","version":"1.0.0"}' + ); + fs.chmodSync(path.join(root, 'a-acme-blog.txt'), 0o755); + const snapshot = () => + Object.fromEntries( + fs + .readdirSync(root) + .sort() + .map((file) => [ + file, + { + body: fs.readFileSync(path.join(root, file)), + mode: fs.statSync(path.join(root, file)).mode, + }, + ]) + ); + const before = snapshot(); + const write = fs.writeFileSync; + const rename = fs.renameSync; + let failed = false; + jest.spyOn(fs, 'writeFileSync').mockImplementation( + (file, body, ...args) => { + const basename = path.basename(file); + const shouldFail = + (stage === 'replace' && + basename === 'b-acme-blog.txt') || + (stage === 'version' && + basename === 'package.json' && + String(body).includes('2.0.0')) || + (stage === 'persist' && + basename === '.wp-scaffold.json'); + if (!failed && shouldFail) { + failed = true; + write(file, 'partial write'); + throw new Error(`${stage} failed`); + } + return write(file, body, ...args); + } + ); + jest.spyOn(fs, 'renameSync').mockImplementation((from, to) => { + if ( + !failed && + stage === 'rename' && + path.basename(from) === 'b-acme-blog.txt' + ) { + failed = true; + throw new Error('rename failed'); + } + return rename(from, to); + }); + expect(() => + applyIdentityEdit(config, root, oldId, newId, ui) + ).toThrow(`${stage} failed`); + expect(failed).toBe(true); + expect(snapshot()).toEqual(before); + } + ); + + test('successful identity rename preserves unrelated persisted metadata', () => { + const oldId = { + ...identityFromName('Acme Blog', {}), + version: '1.0.0', + custom: 'Acme Blog', + features: { 'acme-blog': true }, + }; + const newId = { + ...identityFromName('Cedar Blog', {}), + version: '1.0.0', + }; + touch(root, '.wp-scaffold.json', JSON.stringify(oldId)); + touch(root, 'acme-blog.txt', 'Acme Blog'); + expect(applyIdentityEdit({}, root, oldId, newId, ui)).toBe(true); + expect(fs.readFileSync(path.join(root, 'cedar-blog.txt'), 'utf8')).toBe( + 'Cedar Blog' + ); + expect( + JSON.parse(fs.readFileSync(path.join(root, '.wp-scaffold.json'))) + ).toMatchObject({ + ...newId, + custom: 'Acme Blog', + features: { 'acme-blog': true }, + }); + }); + + test('version-only edit updates files and persisted identity', () => { + const config = { + kind: 'theme', + versionFiles: [{ path: 'package.json', kind: 'json' }], + }; + const identity = { + ...identityFromName('Acme Blog', config), + version: '1.0.0', + custom: true, + }; + touch(root, 'package.json', '{"version":"1.0.0"}'); + touch(root, '.wp-scaffold.json', JSON.stringify(identity)); + expect( + applyIdentityEdit( + config, + root, + identity, + { ...identity, version: '2.0.0' }, + ui + ) + ).toBe(true); + expect( + JSON.parse(fs.readFileSync(path.join(root, 'package.json'))).version + ).toBe('2.0.0'); + expect( + JSON.parse(fs.readFileSync(path.join(root, '.wp-scaffold.json'))) + ).toMatchObject({ version: '2.0.0', custom: true }); + }); +}); + +describe('interactive identity editor', () => { + const config = { kind: 'theme' }; + const original = { + ...identityFromName('Acme Blog', config), + version: '1.0.0', + }; + const makeUi = (choices, values = []) => ({ + radio: jest.fn(async () => choices.shift()), + text: jest.fn(async () => values.shift()), + table: jest.fn(), + confirm: jest.fn(async () => false), + warn: jest.fn(), + info: jest.fn(), + heading: jest.fn(), + }); + test('name edits rederive defaults while retaining explicit overrides and version', async () => { + const ui = makeUi( + ['Text Domain', 'Version', 'Name', 'Confirm'], + ['custom-domain', '2.0.0', 'Cedar Blog'] + ); + const result = await editIdentityFields(config, original, ui); + expect(result.confirmed).toBe(true); + expect(result.id).toMatchObject({ + name: 'Cedar Blog', + textDomain: 'custom-domain', + slug: 'custom-domain', + functionPrefix: 'cedar_blog_', + version: '2.0.0', + }); + expect(original.textDomain).toBe('acme-blog'); + }); + test.each([ + ['cancel the editor', ['Name', 'Cancel'], ['Cedar Blog']], + ['confirm without edits', ['Confirm'], []], + ['decline application', ['Version', 'Confirm'], ['2.0.0']], + ])('%s leaves the project untouched', async (_label, choices, values) => { + const root = makeRoot(); + try { + const before = JSON.stringify(original); + touch(root, '.wp-scaffold.json', before); + touch(root, 'acme-blog.txt', 'Acme Blog'); + await editDetailsFlow( + config, + root, + original, + makeUi([...choices], [...values]) + ); + expect( + fs.readFileSync(path.join(root, '.wp-scaffold.json'), 'utf8') + ).toBe(before); + expect( + fs.readFileSync(path.join(root, 'acme-blog.txt'), 'utf8') + ).toBe('Acme Blog'); + expect(fs.readdirSync(root).sort()).toEqual([ + '.wp-scaffold.json', + 'acme-blog.txt', + ]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/node-packages/wp-tooling/tests/init/index.test.js b/node-packages/wp-tooling/tests/init/index.test.js new file mode 100644 index 0000000..d9ae388 --- /dev/null +++ b/node-packages/wp-tooling/tests/init/index.test.js @@ -0,0 +1,543 @@ +'use strict'; +jest.mock('child_process', () => ({ execFileSync: jest.fn() })); +jest.mock('../../src/init/git', () => ({ + initRepo: jest.fn(), + installGitHooks: jest.fn(), + commitAll: jest.fn(), +})); + +const { execFileSync } = require('child_process'); +const git = require('../../src/init/git'); +const ui = require('../../src/ui'); +const fs = require('fs'); +const path = require('path'); +const { run } = require('../../src/init'); +const { makeRoot, touch, capture } = require('./_helpers'); + +const config = { + kind: 'theme', + source: { name: 'Starter Theme' }, + features: [ + { key: 'dev-tools', label: 'Developer tools', detect: () => false }, + ], + examples: { + groups: [ + { key: 'demo', label: 'Demo', remove: ['demo.txt'], strip: [] }, + ], + }, + steps: {}, +}; + +/** + * Capture every fixture file, including its exact bytes. + * + * @param {string} root Fixture root. + * @return {Object} Relative paths and contents. + */ +const snapshot = (root) => + Object.fromEntries( + fs + .readdirSync(root, { recursive: true }) + .sort() + .filter((file) => fs.statSync(path.join(root, file)).isFile()) + .map((file) => [ + file, + fs.readFileSync(path.join(root, file)).toString('base64'), + ]) + ); + +describe('setup validates before mutation', () => { + let root; + beforeEach(() => { + root = makeRoot(); + touch(root, 'starter-theme.txt', 'Starter Theme starter-theme'); + touch(root, 'demo.txt', 'demo'); + process.exitCode = undefined; + }); + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + process.exitCode = undefined; + }); + + test.each([ + ['--features=typo'], + ['--enable=typo'], + ['--disable=typo'], + ['--remove-examples=typo'], + ['--features=', '--enable=dev-tools'], + ['--keep-examples', '--remove-examples=demo'], + ['--version=invalid'], + ['--version='], + ['--name=123'], + ])('rejects %j without changing files', async (...flags) => { + const before = snapshot(root); + await capture(async () => { + await expect( + run(config, { + root, + argv: ['--yes', '--name=Acme Blog', ...flags], + }) + ).resolves.toBeUndefined(); + }); + expect(snapshot(root)).toEqual(before); + expect(process.exitCode).toBe(1); + }); +}); + +describe('setup sequencing and reinitialization', () => { + let root; + beforeEach(() => { + root = makeRoot(); + touch(root, 'starter-theme.txt', 'Starter Theme starter-theme'); + process.exitCode = undefined; + }); + afterEach(() => { + jest.restoreAllMocks(); + fs.rmSync(root, { recursive: true, force: true }); + process.exitCode = undefined; + }); + + test('an unreadable project directory aborts before identity writes', async () => { + touch(root, 'inc/Module.php', 'Starter Theme'); + const before = snapshot(root); + const readDirectory = fs.readdirSync; + jest.spyOn(fs, 'readdirSync').mockImplementation((dir, ...args) => { + if (dir === path.join(root, 'inc')) { + throw new Error('EACCES: inc'); + } + return readDirectory(dir, ...args); + }); + await capture(async () => { + await expect( + run(config, { root, argv: ['--yes', '--name=Acme Blog'] }) + ).rejects.toThrow('EACCES: inc'); + }); + expect(snapshot(root)).toEqual(before); + expect(process.exitCode).toBe(1); + }); + + test('cancelled capability confirmation leaves the entire tree unchanged', async () => { + jest.spyOn(ui, 'confirm') + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); + const before = snapshot(root); + await capture(() => + run(config, { + root, + argv: ['--name=Acme Blog', '--features=dev-tools'], + }) + ); + expect(snapshot(root)).toEqual(before); + expect(process.exitCode).toBe(130); + }); + + test('setup detection uses source identity before rename and target identity after it', async () => { + touch(root, 'starter-theme.flag', 'enabled'); + const seen = []; + const feature = { + key: 'demo', + label: 'Demo feature', + detect(api) { + seen.push(api.identity.slug); + return api.exists(`${api.identity.slug}.flag`); + }, + onEnable: jest.fn(), + onDisable: jest.fn(), + }; + await capture(() => + run( + { ...config, features: [feature] }, + { + root, + argv: ['--yes', '--name=Acme Blog'], + } + ) + ); + expect(seen[0]).toBe('starter-theme'); + expect(seen.slice(1)).toEqual(['acme-blog', 'acme-blog']); + expect(feature.onEnable).not.toHaveBeenCalled(); + expect(feature.onDisable).not.toHaveBeenCalled(); + expect( + JSON.parse(fs.readFileSync(path.join(root, '.wp-scaffold.json'))) + .features + ).toEqual({ demo: true }); + }); + + test('reinit replaces the current identity and preserves unrelated metadata', async () => { + await capture(() => + run(config, { root, argv: ['--yes', '--name=Acme Blog'] }) + ); + const identityPath = path.join(root, '.wp-scaffold.json'); + const identity = JSON.parse(fs.readFileSync(identityPath)); + identity.custom = { retained: true }; + fs.writeFileSync(identityPath, JSON.stringify(identity)); + await capture(() => + run(config, { + root, + argv: ['--reinit', '--yes', '--name=Cedar Blog'], + }) + ); + expect(fs.readFileSync(path.join(root, 'cedar-blog.txt'), 'utf8')).toBe( + 'Cedar Blog cedar-blog' + ); + expect(JSON.parse(fs.readFileSync(identityPath)).custom).toEqual({ + retained: true, + }); + }); + + test('mutating reinit refuses corrupt identity without modifying files', async () => { + touch(root, '.wp-scaffold.json', '{broken'); + const before = snapshot(root); + await capture(() => + run(config, { + root, + argv: ['--reinit', '--yes', '--name=Acme Blog'], + }) + ); + expect(snapshot(root)).toEqual(before); + expect(process.exitCode).toBe(1); + }); +}); + +describe('reinit example selection', () => { + let root; + beforeEach(() => { + root = makeRoot(); + process.exitCode = undefined; + }); + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + process.exitCode = undefined; + }); + + test('keeps removed examples consumed across reinit and refuses new example choices', async () => { + touch(root, 'demo.txt', 'example'); + const consumerConfig = config; + await capture(() => + run(consumerConfig, { + root, + argv: ['--yes', '--name=Acme Blog', '--remove-examples=demo'], + }) + ); + await capture(() => + run(consumerConfig, { + root, + argv: ['--yes', '--reinit', '--name=Cedar Blog'], + }) + ); + expect( + JSON.parse(fs.readFileSync(path.join(root, '.wp-scaffold.json'))) + .examples.removed + ).toEqual(['demo']); + expect(fs.existsSync(path.join(root, 'demo.txt'))).toBe(false); + const before = snapshot(root); + await capture(async () => { + await expect( + run(consumerConfig, { + root, + argv: [ + '--yes', + '--reinit', + '--name=Cedar Blog', + '--keep-examples', + ], + }) + ).resolves.toBeUndefined(); + }); + expect(snapshot(root)).toEqual(before); + expect(process.exitCode).toBe(1); + }); +}); + +describe('setup step failures and optional steps', () => { + let root; + const stepConfig = { + kind: 'theme', + source: { name: 'Starter Theme' }, + cleanup: { targets: ['keep.txt'] }, + steps: { cleanup: true, git: true, hooks: true, composer: true }, + }; + beforeEach(() => { + root = makeRoot(); + touch(root, 'keep.txt', 'Starter Theme'); + process.exitCode = undefined; + jest.spyOn(ui, 'confirm').mockResolvedValue(true); + git.initRepo.mockReturnValue(true); + git.installGitHooks.mockResolvedValue(true); + git.commitAll.mockReturnValue(true); + }); + afterEach(() => { + jest.restoreAllMocks(); + jest.clearAllMocks(); + fs.rmSync(root, { recursive: true, force: true }); + process.exitCode = undefined; + }); + + test('Composer failure stops cleanup and Git and reports partial setup', async () => { + touch(root, 'composer.json', '{}'); + execFileSync.mockImplementationOnce(() => { + throw new Error('Composer failed'); + }); + const output = await capture(async () => { + await expect( + run(stepConfig, { root, argv: ['--name=Acme Blog'] }) + ).rejects.toThrow('Composer failed'); + }); + expect(fs.existsSync(path.join(root, 'keep.txt'))).toBe(true); + expect(git.initRepo).not.toHaveBeenCalled(); + expect(output.stdout + output.stderr).toContain( + 'Some changes may remain' + ); + expect(output.stdout).not.toContain('Your new theme is ready'); + expect(process.exitCode).toBe(1); + }); + + test.each(['initRepo', 'installGitHooks', 'commitAll'])( + '%s failure stops setup', + async (operation) => { + git[operation].mockImplementation(() => { + throw new Error(`${operation} failed`); + }); + const output = await capture(async () => { + await expect( + run(stepConfig, { root, argv: ['--name=Acme Blog'] }) + ).rejects.toThrow(/failed/); + }); + expect(process.exitCode).toBe(1); + expect(output.stdout + output.stderr).toContain( + 'Project setup completed' + ); + expect(output.stdout + output.stderr).not.toContain( + 'restore your starter backup' + ); + expect( + JSON.parse( + fs.readFileSync(path.join(root, '.wp-scaffold.json')) + ).name + ).toBe('Acme Blog'); + expect(fs.existsSync(path.join(root, 'keep.txt'))).toBe(false); + } + ); + + test('cancelling a Git prompt preserves completed project setup', async () => { + jest.spyOn(ui, 'confirm').mockImplementation(async ({ message }) => { + if (message.startsWith('Initialize a git')) { + throw new ui.CancelledError(); + } + return true; + }); + const output = await capture(() => + run(stepConfig, { root, argv: ['--name=Acme Blog'] }) + ); + expect(process.exitCode).toBe(130); + expect(output.stdout + output.stderr).toContain( + 'Git setup was cancelled' + ); + expect(output.stdout + output.stderr).not.toContain( + 'restore your starter backup' + ); + expect(git.initRepo).not.toHaveBeenCalled(); + }); + + test('declining optional Git initialization succeeds without invoking Git', async () => { + jest.spyOn(ui, 'confirm').mockImplementation( + async ({ message }) => !message.startsWith('Initialize a git') + ); + await capture(() => + run(stepConfig, { root, argv: ['--name=Acme Blog'] }) + ); + expect(git.initRepo).not.toHaveBeenCalled(); + expect(process.exitCode).toBeUndefined(); + }); + + test('feature failure prevents identity persistence and cleanup', async () => { + const feature = { + key: 'demo', + label: 'Demo', + detect: () => false, + onEnable() { + throw new Error('hook failed'); + }, + onDisable() {}, + }; + await capture(async () => { + await expect( + run( + { ...stepConfig, features: [feature] }, + { + root, + argv: ['--yes', '--name=Acme Blog', '--features=demo'], + } + ) + ).rejects.toThrow('Feature setup failed'); + }); + expect(fs.existsSync(path.join(root, 'keep.txt'))).toBe(true); + expect(fs.existsSync(path.join(root, '.wp-scaffold.json'))).toBe(false); + expect(git.initRepo).not.toHaveBeenCalled(); + }); + + test('identity persistence failure prevents cleanup and Git', async () => { + const originalWrite = fs.writeFileSync; + jest.spyOn(fs, 'writeFileSync').mockImplementation((file, ...args) => { + if (path.basename(file) === '.wp-scaffold.json') { + throw new Error('identity write failed'); + } + return originalWrite(file, ...args); + }); + await capture(async () => { + await expect( + run(stepConfig, { root, argv: ['--yes', '--name=Acme Blog'] }) + ).rejects.toThrow('identity write failed'); + }); + expect(fs.existsSync(path.join(root, 'keep.txt'))).toBe(true); + expect(git.initRepo).not.toHaveBeenCalled(); + }); + + test('declining optional hooks still permits the already-approved Git flow', async () => { + jest.spyOn(ui, 'confirm').mockImplementation( + async ({ message }) => !message.startsWith('Install git hooks') + ); + await capture(() => + run(stepConfig, { root, argv: ['--name=Acme Blog'] }) + ); + expect(git.initRepo).toHaveBeenCalledTimes(1); + expect(git.installGitHooks).not.toHaveBeenCalled(); + expect(git.commitAll).toHaveBeenCalledTimes(1); + expect(process.exitCode).toBeUndefined(); + }); +}); + +describe('entry-point error reporting', () => { + let root; + beforeEach(() => { + root = makeRoot(); + process.exitCode = undefined; + }); + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + process.exitCode = undefined; + jest.restoreAllMocks(); + }); + test('manage usage errors are reported without rejecting or probing features', async () => { + touch(root, '.wp-scaffold.json', JSON.stringify({ name: 'Acme Blog' })); + const detect = jest.fn(); + const output = await capture(async () => { + await expect( + run( + { + ...config, + features: [ + { key: 'demo', label: 'Feature demo', detect }, + ], + }, + { root, argv: ['--features=typo'] } + ) + ).resolves.toBeUndefined(); + }); + expect(output.stdout + output.stderr).toMatch(/Unknown feature.*typo/); + expect(process.exitCode).toBe(1); + expect(detect).not.toHaveBeenCalled(); + }); + test.each(['keep.txt', null, false, ''])( + 'standalone cleanup rejects malformed targets %j before deleting files', + async (targets) => { + touch(root, 'keep.txt', 'keep'); + jest.spyOn(ui, 'confirm').mockResolvedValue(true); + await capture(async () => { + await expect( + run( + { ...config, cleanup: { targets } }, + { root, argv: ['--clean'] } + ) + ).rejects.toThrow(/Expected cleanup.targets to be an array/); + }); + expect(fs.readFileSync(path.join(root, 'keep.txt'), 'utf8')).toBe( + 'keep' + ); + expect(process.exitCode).toBe(1); + } + ); +}); + +describe('setup identity transaction', () => { + let root; + beforeEach(() => { + root = makeRoot(); + process.exitCode = undefined; + }); + afterEach(() => { + jest.restoreAllMocks(); + fs.rmSync(root, { recursive: true, force: true }); + process.exitCode = undefined; + }); + test.each( + ['replace', 'rename', 'version'].flatMap((phase) => [ + [phase, false], + [phase, true], + ]) + )( + 'restores identity files after %s fails (reinit: %s)', + async (phase, reinit) => { + touch(root, 'starter-theme.txt', 'Starter Theme'); + touch(root, 'z.txt', 'Starter Theme'); + touch(root, 'package.json', '{"version":"1.0.0"}'); + if (reinit) { + await capture(() => + run(config, { + root, + argv: ['--yes', '--name=First Project'], + }) + ); + } + const before = snapshot(root); + const write = fs.writeFileSync; + let failed = false; + jest.spyOn(fs, 'writeFileSync').mockImplementation( + (file, ...args) => { + if ( + !failed && + (('replace' === phase && + path.basename(file) === 'z.txt') || + ('version' === phase && + path.basename(file) === 'package.json')) + ) { + failed = true; + write(file, 'partial'); + throw new Error('injected failure'); + } + return write(file, ...args); + } + ); + const rename = fs.renameSync; + jest.spyOn(fs, 'renameSync').mockImplementation((...args) => { + if (!failed && 'rename' === phase) { + failed = true; + throw new Error('injected failure'); + } + return rename(...args); + }); + await capture(async () => { + await expect( + run( + { + ...config, + versionFiles: [ + { path: 'package.json', kind: 'json' }, + ], + }, + { + root, + argv: [ + '--yes', + '--name=Acme Blog', + ...(reinit ? ['--reinit'] : []), + ], + } + ) + ).rejects.toThrow('injected failure'); + }); + expect(snapshot(root)).toEqual(before); + } + ); +}); diff --git a/node-packages/wp-tooling/tests/init/manage.test.js b/node-packages/wp-tooling/tests/init/manage.test.js index aab5045..350a494 100644 --- a/node-packages/wp-tooling/tests/init/manage.test.js +++ b/node-packages/wp-tooling/tests/init/manage.test.js @@ -6,6 +6,10 @@ 'use strict'; const { manageFlow } = require('../../src/init/manage'); +const fs = require('fs'); +const path = require('path'); +const { makeRoot, touch } = require('./_helpers'); +const { identityFromName } = require('../../src/init/identity'); afterEach(() => { // manageFlow sets process.exitCode as a side effect on these paths. @@ -46,3 +50,111 @@ describe('manageFlow unknown-argument handling', () => { expect(calls.error).toEqual(['Unknown argument(s): --name=X --bogus']); }); }); + +describe('interactive manage session', () => { + let root; + let identity; + let ui; + const config = { kind: 'theme' }; + beforeEach(() => { + root = makeRoot(); + identity = { + ...identityFromName('Acme Blog', config), + version: '1.0.0', + features: { demo: false }, + }; + touch(root, '.wp-scaffold.json', JSON.stringify(identity)); + ui = { + radio: jest.fn(), + text: jest.fn(), + confirm: jest.fn(async () => true), + table: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + success: jest.fn(), + error: jest.fn(), + heading: jest.fn(), + spinner: () => ({ start() {}, succeed() {}, fail() {} }), + }; + }); + afterEach(() => fs.rmSync(root, { recursive: true, force: true })); + + test('editing details refreshes the identity used by subsequent feature hooks', async () => { + const feature = { + key: 'demo', + label: 'Demo', + detect: (api) => api.exists(`${api.identity.slug}.flag`), + onEnable: (api) => + api.write(`${api.identity.slug}.flag`, api.identity.name), + onDisable: (api) => api.remove(`${api.identity.slug}.flag`), + }; + for (const choice of [ + 'Edit project details', + 'Name', + 'Confirm', + 'Toggle features', + '[ ] Demo', + 'Apply changes', + 'Show status', + 'Exit', + ]) { + ui.radio.mockResolvedValueOnce(choice); + } + ui.text.mockResolvedValueOnce('Cedar Blog'); + await manageFlow( + { ...config, features: [feature] }, + root, + [], + identity, + ui, + jest.fn() + ); + expect( + fs.readFileSync(path.join(root, 'cedar-blog.flag'), 'utf8') + ).toBe('Cedar Blog'); + expect( + JSON.parse(fs.readFileSync(path.join(root, '.wp-scaffold.json'))) + ).toMatchObject({ name: 'Cedar Blog', features: { demo: true } }); + expect(ui.table).toHaveBeenLastCalledWith([['Demo', 'enabled']], { + title: 'Feature status', + }); + expect(ui.radio).toHaveBeenCalledTimes(8); + }); + + test('a failed interactive toggle exits the session without requesting another action', async () => { + for (const choice of ['Toggle features', '[ ] Demo', 'Apply changes']) { + ui.radio.mockResolvedValueOnce(choice); + } + const feature = { + key: 'demo', + label: 'Demo', + detect: () => false, + onEnable() { + throw new Error('failed hook'); + }, + onDisable() {}, + }; + await manageFlow( + { ...config, features: [feature] }, + root, + [], + identity, + ui, + jest.fn() + ); + expect(ui.radio).toHaveBeenCalledTimes(3); + expect(process.exitCode).toBe(1); + expect( + JSON.parse(fs.readFileSync(path.join(root, '.wp-scaffold.json'))) + .features.demo + ).toBe(false); + }); + + test('re-run full setup invokes the callback once and leaves the menu', async () => { + const reinit = jest.fn(); + ui.radio.mockResolvedValueOnce('Re-run full setup'); + await manageFlow(config, root, [], identity, ui, reinit); + expect(reinit).toHaveBeenCalledTimes(1); + expect(ui.radio).toHaveBeenCalledTimes(1); + }); +}); diff --git a/node-packages/wp-tooling/tests/init/persist.test.js b/node-packages/wp-tooling/tests/init/persist.test.js index 73d7adc..15aa8c4 100644 --- a/node-packages/wp-tooling/tests/init/persist.test.js +++ b/node-packages/wp-tooling/tests/init/persist.test.js @@ -51,7 +51,7 @@ describe('readIdentityFile', () => { expect(caught).toBeInstanceOf(IdentityFileError); expect(caught.code).toBe('EIDENTITYCORRUPT'); expect(caught.path).toBe(path.join(root, IDENTITY_FILE)); - expect(caught.message).toMatch(/--reinit/); + expect(caught.message).toMatch(/Restore a valid identity/); }); it.each([ diff --git a/node-packages/wp-tooling/tests/init/selection.test.js b/node-packages/wp-tooling/tests/init/selection.test.js new file mode 100644 index 0000000..89257eb --- /dev/null +++ b/node-packages/wp-tooling/tests/init/selection.test.js @@ -0,0 +1,56 @@ +'use strict'; +const fs = require('fs'); +const { + selectFeatures, + selectCapabilities, +} = require('../../src/init/selection'); +const { makeRoot } = require('./_helpers'); + +test('exact empty selection overrides defaults', () => { + expect([...selectFeatures({ features: [] }, ['demo'])]).toEqual([]); +}); + +test('disable wins when both deltas name the same feature', () => { + expect([ + ...selectFeatures({ enable: ['demo'], disable: ['demo'] }, []), + ]).toEqual([]); +}); + +test.each(['theme', 'plugin'])( + 'interactive %s defaults agree with detected features', + async (kind) => { + const root = makeRoot(); + const ui = { checkboxTree: jest.fn(async () => ['Detected']) }; + try { + const result = await selectCapabilities( + { + kind, + features: [ + { + key: 'detected', + label: 'Detected', + detect: () => true, + }, + ], + }, + root, + {}, + {}, + false, + ui + ); + expect([...result.wantOn]).toEqual(['detected']); + expect(ui.checkboxTree).toHaveBeenCalledWith({ + message: `Select the capabilities to include in your ${kind}`, + groups: [ + { + label: 'Other', + items: [{ label: 'Detected', checked: true }], + }, + ], + }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + } +); diff --git a/node-packages/wp-tooling/tests/init/transform.test.js b/node-packages/wp-tooling/tests/init/transform.test.js index 9136d56..d784e2d 100644 --- a/node-packages/wp-tooling/tests/init/transform.test.js +++ b/node-packages/wp-tooling/tests/init/transform.test.js @@ -7,12 +7,14 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); - const { applyReplacements, + withFileRollback, resolveWithin, applyVersion, } = require('../../src/init/transform'); +const { makeRoot, touch } = require('./_helpers'); +const { renameFiles, replaceInFiles } = require('../../src/init/transform'); const noopUi = { info() {}, warn() {}, success() {}, error() {} }; @@ -107,3 +109,193 @@ describe('applyVersion', () => { ); }); }); + +describe('filesystem transforms', () => { + let root; + beforeEach(() => { + root = makeRoot(); + }); + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + jest.restoreAllMocks(); + }); + + test('rollback continues after an undo failure and reports both errors', () => { + const restored = path.join(root, 'restored.txt'); + const failing = path.join(root, 'failing.txt'); + const created = path.join(root, 'created.txt'); + touch(root, 'restored.txt', 'original'); + touch(root, 'failing.txt', 'original'); + const write = fs.writeFileSync; + const originalError = new Error('edit failed'); + const rollbackError = new Error('undo failed'); + jest.spyOn(fs, 'writeFileSync').mockImplementation( + (file, body, ...args) => { + if (file === failing && String(body) === 'original') { + throw rollbackError; + } + return write(file, body, ...args); + } + ); + let caught; + try { + withFileRollback(({ writeFile }) => { + writeFile(restored, 'changed'); + writeFile(failing, 'changed'); + writeFile(created, 'new'); + throw originalError; + }); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(AggregateError); + expect(caught.errors).toEqual([originalError, rollbackError]); + expect(caught.message).toContain( + 'edit failed; rollback failed: undo failed' + ); + expect(fs.readFileSync(restored, 'utf8')).toBe('original'); + expect(fs.existsSync(created)).toBe(false); + }); + + test('rename collision never overwrites an existing destination', () => { + touch(root, 'starter.txt', 'source'); + touch(root, 'acme.txt', 'destination'); + expect(() => + renameFiles([path.join(root, 'starter.txt')], [['starter', 'acme']]) + ).toThrow(/collision/i); + expect(fs.readFileSync(path.join(root, 'acme.txt'), 'utf8')).toBe( + 'destination' + ); + }); + + test('path containment rejects a symlink escape', () => { + fs.symlinkSync(path.dirname(root), path.join(root, 'escape')); + expect(() => resolveWithin(root, 'escape/file.txt')).toThrow(/outside/); + }); + + test('replacement failure stops before subsequent files and reports the failing path', () => { + const files = ['first.txt', 'failing.txt', 'last.txt'].map((name) => + path.join(root, name) + ); + for (const file of files) { + fs.writeFileSync(file, 'starter'); + } + const originalWrite = fs.writeFileSync; + jest.spyOn(fs, 'writeFileSync').mockImplementation((file, ...args) => { + if (file === files[1]) { + throw new Error('disk full'); + } + return originalWrite(file, ...args); + }); + expect(() => replaceInFiles(files, [['starter', 'acme']])).toThrow( + /failing.txt: disk full/ + ); + expect(files.map((file) => fs.readFileSync(file, 'utf8'))).toEqual([ + 'acme', + 'starter', + 'starter', + ]); + }); + + test('case-only rename changes the directory entry without losing contents', () => { + touch(root, 'Starter.txt', 'content'); + expect( + renameFiles( + [path.join(root, 'Starter.txt')], + [['Starter', 'starter']] + ) + ).toBe(1); + expect(fs.readdirSync(root)).toEqual(['starter.txt']); + expect(fs.readFileSync(path.join(root, 'starter.txt'), 'utf8')).toBe( + 'content' + ); + }); + test('distinct case-variant files remain a collision', () => { + touch(root, 'Starter.txt', 'source'); + // A case-insensitive filesystem cannot host both directory entries. + if (fs.existsSync(path.join(root, 'starter.txt'))) { + return; + } + touch(root, 'starter.txt', 'destination'); + expect(() => + renameFiles( + [path.join(root, 'Starter.txt')], + [['Starter', 'starter']] + ) + ).toThrow(/collision/); + expect(fs.readFileSync(path.join(root, 'starter.txt'), 'utf8')).toBe( + 'destination' + ); + }); +}); + +describe('batch rename staging', () => { + let root; + beforeEach(() => { + root = makeRoot(); + touch(root, 'a.txt', 'A'); + touch(root, 'b.txt', 'B'); + }); + afterEach(() => { + jest.restoreAllMocks(); + fs.rmSync(root, { recursive: true, force: true }); + }); + test.each(['a', 'c'])( + 'supports a batch ending at %s.txt', + (destination) => { + renameFiles( + ['a.txt', 'b.txt'].map((file) => path.join(root, file)), + [ + ['a', 'b'], + ['b', destination], + ] + ); + expect(fs.readFileSync(path.join(root, 'b.txt'), 'utf8')).toBe('A'); + expect( + fs.readFileSync(path.join(root, `${destination}.txt`), 'utf8') + ).toBe('B'); + expect(fs.readdirSync(root)).toHaveLength(2); + } + ); + test.each([2, 4])( + 'restores every source after rename number %s fails', + (failAt) => { + const rename = fs.renameSync; + let calls = 0; + jest.spyOn(fs, 'renameSync').mockImplementation((...args) => { + if (++calls === failAt) { + throw new Error('rename failed'); + } + return rename(...args); + }); + expect(() => + renameFiles( + ['a.txt', 'b.txt'].map((file) => path.join(root, file)), + [ + ['a', 'b'], + ['b', 'a'], + ] + ) + ).toThrow('rename failed'); + expect(fs.readFileSync(path.join(root, 'a.txt'), 'utf8')).toBe('A'); + expect(fs.readFileSync(path.join(root, 'b.txt'), 'utf8')).toBe('B'); + expect(fs.readdirSync(root).sort()).toEqual(['a.txt', 'b.txt']); + } + ); + test('rejects case-folded destinations on an insensitive filesystem', () => { + const exists = fs.existsSync; + jest.spyOn(fs, 'existsSync').mockImplementation( + (file) => path.basename(file) === 'PROBE' || exists(file) + ); + expect(() => + renameFiles( + ['a.txt', 'b.txt'].map((file) => path.join(root, file)), + [ + ['a', 'x'], + ['b', 'X'], + ] + ) + ).toThrow(/collision/); + expect(fs.readdirSync(root).sort()).toEqual(['a.txt', 'b.txt']); + }); +}); diff --git a/node-packages/wp-tooling/tests/init/validation.test.js b/node-packages/wp-tooling/tests/init/validation.test.js new file mode 100644 index 0000000..76425c6 --- /dev/null +++ b/node-packages/wp-tooling/tests/init/validation.test.js @@ -0,0 +1,74 @@ +'use strict'; +const fs = require('fs'); +const path = require('path'); +const { + validateSetupFlags, + validateFeatureFlags, + validatePaths, +} = require('../../src/init/validation'); +const { makeRoot } = require('./_helpers'); +const config = { + features: [{ key: 'demo', label: 'Demo' }], + examples: { groups: [{ key: 'example', label: 'Example' }] }, +}; + +test('feature flags reject unknown keys and incompatible selections', () => { + expect(() => + validateFeatureFlags(config, { features: ['missing'] }) + ).toThrow(/Unknown feature/); + expect(() => + validateFeatureFlags(config, { features: [], enable: [] }) + ).toThrow(/cannot be combined/); + expect(() => validateFeatureFlags(config, { features: [] })).not.toThrow(); + expect(() => + validateFeatureFlags(config, { enable: ['demo'], disable: ['demo'] }) + ).not.toThrow(); +}); + +test.each(['.', '..', '../outside', '/absolute'])( + 'rejects destructive or escaping configured path %s', + (target) => { + const root = makeRoot(); + try { + expect(() => + validatePaths({ cleanup: { targets: [target] } }, root) + ).toThrow(); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + } +); + +test('setup validates explicit names, including empty strings', () => { + expect(() => validateSetupFlags(config, { name: '' })).toThrow(/--name/); + expect(() => validateSetupFlags(config, { name: '123' })).toThrow(/--name/); +}); +test('feature source symlinks must stay inside the feature assets directory', () => { + const root = makeRoot(); + try { + fs.mkdirSync(path.join(root, 'bin/features'), { recursive: true }); + fs.writeFileSync(path.join(root, 'outside.txt'), 'content'); + fs.symlinkSync( + '../../outside.txt', + path.join(root, 'bin/features/escape.txt') + ); + expect(() => + validatePaths( + { + features: [ + { + key: 'demo', + label: 'Demo', + apply: { + files: [{ from: 'escape.txt', to: 'demo.txt' }], + }, + }, + ], + }, + root + ) + ).toThrow(/outside/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +});