diff --git a/jest.config.cjs b/jest.config.cjs index 6856a75..900dd29 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -30,9 +30,7 @@ module.exports = { transform: { '\\.[jt]sx?$': ['ts-jest', tsJestOptions], }, - transformIgnorePatterns: [ - 'node_modules[\\\\/](?!(@inquirer|fast-.+)[\\\\/])', - ], + transformIgnorePatterns: ['node_modules[\\\\/](?!(fast-.+)[\\\\/])'], moduleNameMapper: { '^(\\.\\.?\\/.+)\\.js$': '$1', }, diff --git a/jest.setup.cjs b/jest.setup.cjs index 4e1c206..9ea74a4 100644 --- a/jest.setup.cjs +++ b/jest.setup.cjs @@ -51,4 +51,43 @@ jest.mock('fs-extra', () => ({ })); jest.mock('child_process'); -jest.mock('progress'); + +// @clack/prompts ships ESM only and draws to a real terminal, so it is mocked +// globally. Suites that assert on a specific prompt override this with their +// own factory; the defaults here simply keep every other suite loadable. +jest.mock('@clack/prompts', () => { + const CANCEL_SYMBOL = Symbol.for('clack:cancel'); + + return { + __esModule: true, + CANCEL_SYMBOL, + isCancel: (value) => value === CANCEL_SYMBOL, + intro: jest.fn(), + outro: jest.fn(), + cancel: jest.fn(), + note: jest.fn(), + box: jest.fn(), + text: jest.fn(), + select: jest.fn(), + confirm: jest.fn(), + multiselect: jest.fn(), + spinner: jest.fn(() => ({ + start: jest.fn(), + stop: jest.fn(), + message: jest.fn(), + cancel: jest.fn(), + error: jest.fn(), + clear: jest.fn(), + isCancelled: false, + })), + log: { + message: jest.fn(), + info: jest.fn(), + success: jest.fn(), + step: jest.fn(), + warn: jest.fn(), + warning: jest.fn(), + error: jest.fn(), + }, + }; +}); diff --git a/package-lock.json b/package-lock.json index 6db14c9..d9d456a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,23 +1,20 @@ { "name": "@emulsify/cli", - "version": "2.4.1", + "version": "2.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@emulsify/cli", - "version": "2.4.1", + "version": "2.5.0", "license": "GPL-2.0", "dependencies": { - "@inquirer/prompts": "^8.7.0", + "@clack/prompts": "^1.8.1", "ajv": "^8.20.0", "ajv-formats": "^3.0.1", - "boxen": "^8.0.1", "colorette": "^2.0.20", "commander": "^15.0.0", - "consola": "^3.4.2", "fs-extra": "^11.4.0", - "progress": "^2.0.3", "simple-git": "^3.36.0" }, "bin": { @@ -33,7 +30,6 @@ "@types/fs-extra": "^11.0.4", "@types/jest": "^30.0.0", "@types/node": "^26.4.0", - "@types/progress": "^2.0.7", "husky": "^9.1.7", "jest": "^30.5.0", "json-schema-to-typescript": "^16.0.0", @@ -632,6 +628,34 @@ "dev": true, "license": "MIT" }, + "node_modules/@clack/core": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.5.1.tgz", + "integrity": "sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ==", + "license": "MIT", + "dependencies": { + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@clack/prompts": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.8.1.tgz", + "integrity": "sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ==", + "license": "MIT", + "dependencies": { + "@clack/core": "1.5.1", + "fast-string-width": "^3.0.2", + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -989,334 +1013,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@inquirer/ansi": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", - "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==", - "license": "MIT", - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - } - }, - "node_modules/@inquirer/checkbox": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.2.3.tgz", - "integrity": "sha512-XEYX2WA8SBkLPczL6/yXPHLPCvDoptmh9v56Cy05BSV1Smk1vWy19bTC4qJBuIffw7+6l4CcaYYzGqG60RfW1g==", - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.7", - "@inquirer/core": "^12.0.1", - "@inquirer/figures": "^2.0.8", - "@inquirer/type": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/confirm": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.3.0.tgz", - "integrity": "sha512-pZHXJImFtERmSNMBHcjwuz8Ck5vEFEYNUZnwbb8aJpjHv/TwGuFErNxF2Hp8+V+pNJs2EYPMlyWscvFEqO9jOQ==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^12.0.1", - "@inquirer/type": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/core": { - "version": "12.0.1", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.1.tgz", - "integrity": "sha512-JMD5Jy/ScL5TZE18m83Nw25HjqGFLoWXwnEkW7IdwwAhZpB9Bus55/WU7zn3UqR1MOCjjTQOIYpiD4vjWA3LPw==", - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.7", - "@inquirer/figures": "^2.0.8", - "@inquirer/type": "^4.1.0", - "cli-width": "^4.1.0", - "fast-wrap-ansi": "^0.2.0", - "mute-stream": "^3.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/editor": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.3.1.tgz", - "integrity": "sha512-y43COoyVUjPWIobn2Qep/uI1drPS78aaZZZ9kVi94Tyu/GuW2N8d8Q4rifJXGAXCEAXCPTTMjD8gC1HyvM5ukA==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^12.0.1", - "@inquirer/external-editor": "^3.0.4", - "@inquirer/type": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/expand": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.1.3.tgz", - "integrity": "sha512-3NQJiXNJ/aj9wiAsr7pECdp5Qe9J0X9YUJCKsaFXS+ddOxfL6J4AIl3w3T4Gq3kK0WsQY5GMoDokK5X94m6lHw==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^12.0.1", - "@inquirer/type": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/external-editor": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-3.0.4.tgz", - "integrity": "sha512-tZbbaK2ovq6vlrRBNQvjrypmrED/p5x2ncIHQ79cD55tei3dD96v5glMMA+6tiq7K104i/25DVYKWVPJuV6ptA==", - "license": "MIT", - "dependencies": { - "chardet": "^2.1.1", - "iconv-lite": "^0.7.2" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/figures": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.8.tgz", - "integrity": "sha512-tApbon79GM9ry56ja/Ud3SY2CL4TQsao9fIwDQbgTeNY55025GdMzQ2+UdegV/lx51VNGUB59M0v0nMpybYY4Q==", - "license": "MIT", - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - } - }, - "node_modules/@inquirer/input": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.1.4.tgz", - "integrity": "sha512-3xQkQrOvgOzpSN2ciTVdRDlg1FWMCA8l+0KfB6SNlILoTCGzJTzO/gc0Rwjcb3usuGyKdaGtI6OiyMdeMeLWkg==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^12.0.1", - "@inquirer/type": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/number": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.2.1.tgz", - "integrity": "sha512-5KaqwZNLRpUuWcoCrYghPP9TMaXL5v2Sk4xqePM7RCVegcJStoXdWibio60YIC1bec+z1fCyb67N6XPJIkZtGA==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^12.0.1", - "@inquirer/type": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/password": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.2.0.tgz", - "integrity": "sha512-CvVcW09emkBESEOW+4R8CjLNkP3fB3XrjeL8CDvfpjgrJN+V9oerXmJAXXM3l+4xqYPD5Yaujzy/Ph0PLOdDuA==", - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.7", - "@inquirer/core": "^12.0.1", - "@inquirer/type": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/prompts": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.7.0.tgz", - "integrity": "sha512-yQwBMYvpJ6jqrXtKiOwRD5XezjJoyt3VQvIyjsr5Arqb519nfIohOQymWVJ8/vEgg8xtZerrCsqlSaqt/LPC9A==", - "license": "MIT", - "dependencies": { - "@inquirer/checkbox": "^5.2.3", - "@inquirer/confirm": "^6.3.0", - "@inquirer/editor": "^5.3.1", - "@inquirer/expand": "^5.1.3", - "@inquirer/input": "^5.1.4", - "@inquirer/number": "^4.2.1", - "@inquirer/password": "^5.2.0", - "@inquirer/rawlist": "^5.3.3", - "@inquirer/search": "^4.3.1", - "@inquirer/select": "^5.2.3" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/rawlist": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.3.3.tgz", - "integrity": "sha512-Mu7WrtmDLaXBDEyrRLS70SZgX9ZSm4Up1w0ZxiH8C1OOp9oaVCn2k8q3QGgmlnhsKYUhuaU3zFWhAP6wxkVIMA==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^12.0.1", - "@inquirer/type": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/search": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.3.1.tgz", - "integrity": "sha512-0VWOvsHWI0rPj6CG70MoP4oXNCB6adcyN8bVFZXnh11eLDdPIK2f2XCmva78acPPDfJisfab7qNakwBh7hdBXw==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^12.0.1", - "@inquirer/figures": "^2.0.8", - "@inquirer/type": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/select": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.2.3.tgz", - "integrity": "sha512-KuRTodDa6xBXX2noIpjuitpX/QT7Sfav7dIZ/OfUY54Hxg95nrGoshSzxx6Ey7qqLbImKdiGkSDt7KjPXjQgmA==", - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.7", - "@inquirer/core": "^12.0.1", - "@inquirer/figures": "^2.0.8", - "@inquirer/type": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/type": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.1.0.tgz", - "integrity": "sha512-FMiJpuHUG3Dk0ex+UIXkre7i+i4OcwHWk9YdcVtZHFwb/r2rnrU2ipTCNAB7A+QOP0ryzIcqOfy76fRyyvOEAw==", - "license": "MIT", - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -3154,7 +2850,7 @@ "version": "26.4.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz", "integrity": "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~8.3.0" @@ -3167,16 +2863,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/progress": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/progress/-/progress-2.0.7.tgz", - "integrity": "sha512-iadjw02vte8qWx7U0YM++EybBha2CQLPGu9iJ97whVgJUT5Zq9MjAPYUnbfRI2Kpehimf1QjFJYxD0t8nqzu5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -3607,35 +3293,6 @@ } } }, - "node_modules/ansi-align": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", - "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", - "license": "ISC", - "dependencies": { - "string-width": "^4.1.0" - } - }, - "node_modules/ansi-align/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/ansi-align/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -3669,6 +3326,7 @@ "version": "6.3.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -3947,28 +3605,6 @@ "dev": true, "license": "MIT" }, - "node_modules/boxen": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/boxen/-/boxen-8.0.1.tgz", - "integrity": "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==", - "license": "MIT", - "dependencies": { - "ansi-align": "^3.0.1", - "camelcase": "^8.0.0", - "chalk": "^5.3.0", - "cli-boxes": "^3.0.0", - "string-width": "^7.2.0", - "type-fest": "^4.21.0", - "widest-line": "^5.0.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/brace-expansion": { "version": "5.0.9", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", @@ -4062,18 +3698,6 @@ "node": ">=6" } }, - "node_modules/camelcase": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz", - "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", - "license": "MIT", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/caniuse-lite": { "version": "1.0.30001810", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", @@ -4099,6 +3723,7 @@ "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, "license": "MIT", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" @@ -4117,12 +3742,6 @@ "node": ">=10" } }, - "node_modules/chardet": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", - "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", - "license": "MIT" - }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -4187,18 +3806,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/cli-boxes": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", - "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/cli-highlight": { "version": "2.1.11", "resolved": "https://registry.npmjs.org/cli-highlight/-/cli-highlight-2.1.11.tgz", @@ -4373,15 +3980,6 @@ "node": ">=8" } }, - "node_modules/cli-width": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", - "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", - "license": "ISC", - "engines": { - "node": ">= 12" - } - }, "node_modules/cliui": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", @@ -4495,15 +4093,6 @@ "dev": true, "license": "ISC" }, - "node_modules/consola": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", - "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", - "license": "MIT", - "engines": { - "node": "^14.18.0 || >=16.10.0" - } - }, "node_modules/content-type": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/content-type/-/content-type-3.0.0.tgz", @@ -4876,6 +4465,7 @@ "version": "10.6.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, "license": "MIT" }, "node_modules/emojilib": { @@ -5383,6 +4973,7 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -5627,22 +5218,6 @@ "url": "https://github.com/sponsors/typicode" } }, - "node_modules/iconv-lite": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", - "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/ignore-by-default": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", @@ -5809,6 +5384,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7758,15 +7334,6 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/mute-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", - "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -10376,15 +9943,6 @@ "dev": true, "license": "MIT" }, - "node_modules/progress": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", - "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/proto-list": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", @@ -10755,12 +10313,6 @@ "dev": true, "license": "MIT" }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, "node_modules/semantic-release": { "version": "25.0.9", "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-25.0.9.tgz", @@ -10904,6 +10456,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, "license": "ISC", "engines": { "node": ">=14" @@ -11048,6 +10601,12 @@ "node": ">=10" } }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, "node_modules/skin-tone": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/skin-tone/-/skin-tone-2.0.0.tgz", @@ -11213,6 +10772,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^10.3.0", @@ -11253,6 +10813,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.2.2" @@ -11268,6 +10829,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -11304,6 +10866,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -11813,6 +11376,7 @@ "version": "4.41.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=16" @@ -11870,7 +11434,7 @@ "version": "8.3.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/unicode-emoji-modifier-base": { @@ -12070,21 +11634,6 @@ "node": ">= 8" } }, - "node_modules/widest-line": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", - "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==", - "license": "MIT", - "dependencies": { - "string-width": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/wordwrap": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", @@ -12096,6 +11645,7 @@ "version": "9.0.2", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", @@ -12170,6 +11720,7 @@ "version": "6.2.3", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -12182,6 +11733,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.2.2" diff --git a/package.json b/package.json index 3edf643..5dcb9e9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@emulsify/cli", "productName": "Emulsify CLI", - "version": "2.4.1", + "version": "2.5.0", "description": "Build and use component systems in Drupal, WordPress, or standalone front ends.", "repository": "git@github.com:emulsify-ds/emulsify-cli.git", "author": "Patrick Coffey ", @@ -58,7 +58,6 @@ "@types/fs-extra": "^11.0.4", "@types/jest": "^30.0.0", "@types/node": "^26.4.0", - "@types/progress": "^2.0.7", "husky": "^9.1.7", "jest": "^30.5.0", "json-schema-to-typescript": "^16.0.0", @@ -71,15 +70,12 @@ "typescript": "^6.0.3" }, "dependencies": { - "@inquirer/prompts": "^8.7.0", + "@clack/prompts": "^1.8.1", "ajv": "^8.20.0", "ajv-formats": "^3.0.1", - "boxen": "^8.0.1", "colorette": "^2.0.20", "commander": "^15.0.0", - "consola": "^3.4.2", "fs-extra": "^11.4.0", - "progress": "^2.0.3", "simple-git": "^3.36.0" } } diff --git a/scripts/smoke-pack.mjs b/scripts/smoke-pack.mjs index 4cd0e90..a2a6b05 100644 --- a/scripts/smoke-pack.mjs +++ b/scripts/smoke-pack.mjs @@ -169,10 +169,11 @@ function smokeTest(tempRoot) { 'emulsify --help', ); + // The version is a value scripts read, so it prints as one plain line. const versionResult = runCommand(localBin, ['--version'], commandOptions); assertCommandOutput( versionResult, - `Version: ${packageManifest.version}`, + packageManifest.version, 'emulsify --version', ); diff --git a/src/handlers/cacheClear.test.ts b/src/handlers/cacheClear.test.ts index 39eb1bd..cb194f6 100644 --- a/src/handlers/cacheClear.test.ts +++ b/src/handlers/cacheClear.test.ts @@ -45,28 +45,24 @@ describe('cacheClear', () => { it('reports bucket and entry counts before removing the cache', async () => { mockPopulatedCache(); - await cacheClear(); + await expect(cacheClear()).resolves.toBe( + 'Cleared the Emulsify cache: removed 2 buckets and 3 entries.', + ); expect(rmMock).toHaveBeenCalledWith('/home/uname/.emulsify/cache', { recursive: true, force: true, }); - expect(logMock).toHaveBeenCalledWith( - 'success', - 'Cleared the Emulsify cache: removed 2 buckets and 3 entries.', - ); }); it('reports cache contents without removing them during a dry run', async () => { mockPopulatedCache(); - await cacheClear({ dryRun: true }); - - expect(rmMock).not.toHaveBeenCalled(); - expect(logMock).toHaveBeenCalledWith( - 'info', + await expect(cacheClear({ dryRun: true })).resolves.toBe( 'Dry run: the Emulsify cache contains 2 buckets and 3 entries. No files were removed.', ); + + expect(rmMock).not.toHaveBeenCalled(); }); it('exits successfully when the cache directory does not exist', async () => { @@ -74,27 +70,32 @@ describe('cacheClear', () => { Object.assign(new Error('missing'), { code: 'ENOENT' }), ); - await expect(cacheClear()).resolves.toBeUndefined(); - - expect(rmMock).not.toHaveBeenCalled(); - expect(logMock).toHaveBeenCalledWith( - 'info', + await expect(cacheClear()).resolves.toBe( 'The Emulsify cache is already empty: 0 buckets and 0 entries.', ); + + expect(rmMock).not.toHaveBeenCalled(); }); it('clears an existing cache with no buckets', async () => { readdirMock.mockResolvedValueOnce([]); - await cacheClear(); + const summary = await cacheClear(); expect(rmMock).toHaveBeenCalledTimes(1); - expect(logMock).toHaveBeenCalledWith( - 'success', + expect(summary).toBe( 'Cleared the Emulsify cache: removed 0 buckets and 0 entries.', ); }); + it('reports its outcome as the session summary, not as a loose line', async () => { + mockPopulatedCache(); + + await cacheClear(); + + expect(logMock).not.toHaveBeenCalled(); + }); + it('wraps cache inspection failures in CliError', async () => { readdirMock.mockRejectedValueOnce( Object.assign(new Error('permission denied'), { code: 'EACCES' }), diff --git a/src/handlers/cacheClear.ts b/src/handlers/cacheClear.ts index 21102b1..f40db9d 100644 --- a/src/handlers/cacheClear.ts +++ b/src/handlers/cacheClear.ts @@ -4,7 +4,6 @@ import { promises as fs } from 'fs'; import { join } from 'path'; import { CACHE_DIR } from '../lib/constants.js'; import CliError from '../lib/CliError.js'; -import log from '../lib/log.js'; type CacheStats = { exists: boolean; @@ -66,26 +65,19 @@ async function inspectCache(): Promise { * Clear all local Emulsify repository cache entries. * * @param options command options, including dry-run behavior. + * @returns a short summary of what the cache held and what was removed. * @throws {CliError} if the cache cannot be inspected or removed. */ export default async function cacheClear({ dryRun = false, -}: ClearCacheHandlerOptions = {}): Promise { +}: ClearCacheHandlerOptions = {}): Promise { const stats = await inspectCache(); if (!stats.exists) { - log( - 'info', - 'The Emulsify cache is already empty: 0 buckets and 0 entries.', - ); - return; + return 'The Emulsify cache is already empty: 0 buckets and 0 entries.'; } if (dryRun) { - log( - 'info', - `Dry run: the Emulsify cache contains ${stats.bucketCount} buckets and ${stats.entryCount} entries. No files were removed.`, - ); - return; + return `Dry run: the Emulsify cache contains ${stats.bucketCount} buckets and ${stats.entryCount} entries. No files were removed.`; } try { @@ -96,8 +88,5 @@ export default async function cacheClear({ ); } - log( - 'success', - `Cleared the Emulsify cache: removed ${stats.bucketCount} buckets and ${stats.entryCount} entries.`, - ); + return `Cleared the Emulsify cache: removed ${stats.bucketCount} buckets and ${stats.entryCount} entries.`; } diff --git a/src/handlers/componentCreate.test.ts b/src/handlers/componentCreate.test.ts index f15feb5..5eb7aa6 100644 --- a/src/handlers/componentCreate.test.ts +++ b/src/handlers/componentCreate.test.ts @@ -6,9 +6,33 @@ jest.mock('../util/project/getEmulsifyConfig', () => jest.fn()); jest.mock('../util/cache/getJsonFromCachedFile', () => jest.fn()); jest.mock('../util/cache/cloneIntoCache', () => jest.fn()); jest.mock('../util/project/generateComponent', () => jest.fn()); -jest.mock('@inquirer/prompts'); +// The prompt library is ESM-only, so the mock is built from a factory rather +// than automocked from the real module. +jest.mock('@clack/prompts', () => { + const cancelSymbol = Symbol('clack:cancel'); + + return { + __esModule: true, + CANCEL_SYMBOL: cancelSymbol, + isCancel: (value: unknown) => value === cancelSymbol, + text: jest.fn(), + select: jest.fn(), + confirm: jest.fn(), + intro: jest.fn(), + outro: jest.fn(), + cancel: jest.fn(), + note: jest.fn(), + log: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + success: jest.fn(), + message: jest.fn(), + }, + }; +}); -import { input } from '@inquirer/prompts'; +import { text } from '@clack/prompts'; import type { EmulsifySystem } from '@emulsify-cli/config'; import CliError from '../lib/CliError.js'; import { EMULSIFY_SYSTEM_CONFIG_FILE } from '../lib/constants.js'; @@ -16,6 +40,7 @@ import getEmulsifyConfig from '../util/project/getEmulsifyConfig.js'; import getJsonFromCachedFile from '../util/cache/getJsonFromCachedFile.js'; import cloneIntoCache from '../util/cache/cloneIntoCache.js'; import generateComponent from '../util/project/generateComponent.js'; +import { PromptCancelledError } from '../util/prompt/index.js'; import componentCreate from './componentCreate.js'; const getEmulsifyConfigMock = getEmulsifyConfig as jest.Mock; @@ -23,7 +48,7 @@ const getJsonFromCachedFileMock = getJsonFromCachedFile as jest.Mock; const cloneIntoCacheMock = cloneIntoCache as jest.Mock; const cloneSystemMock = jest.fn(); const generateComponentMock = generateComponent as jest.Mock; -const inputMock = input as jest.Mock; +const textMock = text as jest.Mock; const originalStdinIsTTY = process.stdin.isTTY; function setStdinIsTTY(value: boolean | undefined) { @@ -89,8 +114,8 @@ describe('componentCreate', () => { cloneSystemMock.mockResolvedValue(undefined); getEmulsifyConfigMock.mockResolvedValue(projectConfig); getJsonFromCachedFileMock.mockResolvedValue(system); - generateComponentMock.mockResolvedValue(undefined); - inputMock.mockResolvedValue('button'); + generateComponentMock.mockResolvedValue('Created the button component'); + textMock.mockResolvedValue('button'); }); afterAll(() => { @@ -126,7 +151,7 @@ describe('componentCreate', () => { 'You must select and install a system before you can create components.', ); - expect(inputMock).not.toHaveBeenCalled(); + expect(textMock).not.toHaveBeenCalled(); expect(generateComponentMock).not.toHaveBeenCalled(); }); @@ -225,28 +250,29 @@ describe('componentCreate', () => { componentCreate('', { refresh: true, type: 'twig' }), ).rejects.toThrow('Please specify a name for the new component.'); - expect(inputMock).not.toHaveBeenCalled(); + expect(textMock).not.toHaveBeenCalled(); expect(getEmulsifyConfigMock).not.toHaveBeenCalled(); expect(generateComponentMock).not.toHaveBeenCalled(); }); it('prompts for a missing component name and validates it before continuing', async () => { - inputMock.mockImplementationOnce(async ({ validate }) => { + textMock.mockImplementationOnce(async ({ validate }) => { expect(validate('promo card')).toBe( 'Component name may only include letters, numbers, and single hyphens between words.', ); expect(validate('---')).toBe( 'Component name must include at least one letter or number.', ); - expect(validate('promo-card')).toBe(true); + // A valid name returns no validation message. + expect(validate('promo-card')).toBeUndefined(); return 'promo-card'; }); const options = { directory: 'base', type: 'twig' }; await componentCreate(undefined, options); - expect(inputMock).toHaveBeenCalledWith({ - message: 'Component name:', + expect(textMock).toHaveBeenCalledWith({ + message: 'Component name', validate: expect.any(Function), }); expect(generateComponentMock).toHaveBeenCalledWith( @@ -347,12 +373,40 @@ describe('componentCreate', () => { }); it('preserves prompt cancellation for the top-level handler', async () => { - const cancellation = new Error('User force closed the prompt'); - cancellation.name = 'ExitPromptError'; + const cancellation = new PromptCancelledError(); generateComponentMock.mockRejectedValueOnce(cancellation); await expect(componentCreate('button', { directory: 'base' })).rejects.toBe( cancellation, ); }); + + it('summarizes the created component for the session', async () => { + await expect( + componentCreate('button', { directory: 'base', type: 'twig' }), + ).resolves.toBe('Created the button component'); + }); + + it('summarizes a dry run without claiming the component was created', async () => { + generateComponentMock.mockResolvedValueOnce('Dry run complete'); + + await expect( + componentCreate('button', { + directory: 'base', + type: 'twig', + dryRun: true, + }), + ).resolves.toBe('Dry run complete'); + }); + + it('reports the outcome generateComponent actually reached', async () => { + // A declined overwrite must not be summarized as a successful creation. + generateComponentMock.mockResolvedValueOnce( + 'Canceled; the existing button component was kept', + ); + + await expect( + componentCreate('button', { directory: 'base', type: 'twig' }), + ).resolves.toBe('Canceled; the existing button component was kept'); + }); }); diff --git a/src/handlers/componentCreate.ts b/src/handlers/componentCreate.ts index cd4f4ea..6e995f4 100644 --- a/src/handlers/componentCreate.ts +++ b/src/handlers/componentCreate.ts @@ -1,11 +1,11 @@ import type { CreateComponentHandlerOptions } from '@emulsify-cli/handlers'; -import { input } from '@inquirer/prompts'; +import { text } from '@clack/prompts'; import generateComponent from '../util/project/generateComponent.js'; import { withEmulsifySystem } from './hofs/withEmulsifySystem.js'; import CliError from '../lib/CliError.js'; import deriveComponentNames from '../util/deriveComponentNames.js'; import { - isExitPromptError, + isPromptCancelledError, requireInteractiveTerminal, runPrompt, } from '../util/prompt/index.js'; @@ -17,10 +17,10 @@ import { const MISSING_COMPONENT_NAME_ERROR = 'Please specify a name for the new component.'; -function validateComponentName(name: string): true | string { +function validateComponentName(name: string | undefined): string | undefined { try { - deriveComponentNames(name); - return true; + deriveComponentNames(name ?? ''); + return undefined; } catch (error) { return (error as Error).message; } @@ -29,6 +29,8 @@ function validateComponentName(name: string): true | string { /** * Handler for the `component create` command. * + * @returns summary shown on the closing line of the session. + * * @throws {CliError} if the component name is missing. * @throws {CliError} if the current project does not have a usable system and variant configuration. * @throws {CliError} if component generation fails. @@ -36,7 +38,7 @@ function validateComponentName(name: string): true | string { export default async function componentCreate( name: string | void, options: CreateComponentHandlerOptions = {}, -): Promise { +): Promise { const providedComponentName = name?.trim() ? name : undefined; // Missing prompt values can be rejected before loading or refreshing the @@ -61,24 +63,28 @@ export default async function componentCreate( const componentName = providedComponentName ?? - (await runPrompt({ + (await runPrompt({ prompt: () => - input({ - message: 'Component name:', + text({ + message: 'Component name', validate: validateComponentName, }), nonInteractive: { error: MISSING_COMPONENT_NAME_ERROR }, })); try { - await generateComponent( + // generateComponent reports the outcome it actually reached, which may be a + // declined overwrite rather than a new component. + return await generateComponent( variantConf, emulsifyConfig, componentName, options, ); } catch (e) { - if (isExitPromptError(e)) { + // A cancelled prompt is closed by the top-level runner, which reports it as + // a cancellation rather than a failure. Wrapping it here would hide it. + if (isPromptCancelledError(e)) { throw e; } diff --git a/src/handlers/componentEjectTemplates.test.ts b/src/handlers/componentEjectTemplates.test.ts index 33e919c..2d84dfb 100644 --- a/src/handlers/componentEjectTemplates.test.ts +++ b/src/handlers/componentEjectTemplates.test.ts @@ -1,8 +1,42 @@ -jest.mock('@inquirer/prompts'); +jest.mock('../lib/session', () => ({ note: jest.fn() })); + +jest.mock('@clack/prompts', () => { + const CANCEL_SYMBOL = Symbol.for('clack:cancel'); + + return { + __esModule: true, + CANCEL_SYMBOL, + isCancel: (value: unknown) => value === CANCEL_SYMBOL, + multiselect: jest.fn(), + note: jest.fn(), + intro: jest.fn(), + outro: jest.fn(), + cancel: jest.fn(), + spinner: jest.fn(() => ({ + start: jest.fn(), + stop: jest.fn(), + message: jest.fn(), + cancel: jest.fn(), + error: jest.fn(), + clear: jest.fn(), + isCancelled: false, + })), + log: { + message: jest.fn(), + info: jest.fn(), + success: jest.fn(), + step: jest.fn(), + warn: jest.fn(), + warning: jest.fn(), + error: jest.fn(), + }, + }; +}); jest.mock('../lib/log', () => jest.fn()); jest.mock('../util/fs/findFileInCurrentPath', () => jest.fn()); -import { checkbox } from '@inquirer/prompts'; +import { multiselect } from '@clack/prompts'; +import { note } from '../lib/session.js'; import { constants as fsConstants, promises as fs } from 'fs'; import { basename, dirname, join, resolve } from 'path'; import { pathExists } from 'fs-extra'; @@ -19,7 +53,8 @@ import componentEjectTemplates, { MISSING_TEMPLATE_TYPE_ERROR, } from './componentEjectTemplates.js'; -const checkboxMock = checkbox as jest.Mock; +const multiselectMock = multiselect as jest.Mock; +const noteMock = note as jest.Mock; const copyFileMock = fs.copyFile as jest.Mock; const findFileMock = findFileInCurrentPath as jest.Mock; const logMock = log as jest.Mock; @@ -188,7 +223,7 @@ describe('componentEjectTemplates', () => { await componentEjectTemplates('twig'); const artifacts = buildEjectableComponentTemplates('twig'); - expect(checkboxMock).not.toHaveBeenCalled(); + expect(multiselectMock).not.toHaveBeenCalled(); expect(pathExistsMock).toHaveBeenCalledTimes(artifacts.length); expect(mkdirMock).toHaveBeenCalledTimes(artifacts.length); expect(writeFileMock).toHaveBeenCalledTimes(artifacts.length); @@ -220,13 +255,11 @@ describe('componentEjectTemplates', () => { Math.min(...linkMock.mock.invocationCallOrder), ); - expect(logMock).toHaveBeenNthCalledWith( - 1, - 'success', + expect(noteMock).toHaveBeenCalledWith( expect.stringContaining(destination('twig', 'component.twig')), + 'Ejected', ); - expect(logMock).toHaveBeenNthCalledWith( - 2, + expect(logMock).toHaveBeenCalledWith( 'info', 'Edit these files to customize component create. Delete an override to restore its built-in template.', ); @@ -286,7 +319,7 @@ describe('componentEjectTemplates', () => { it('writes all 15 templates without prompting when --all is passed', async () => { await componentEjectTemplates(undefined, { all: true }); - expect(checkboxMock).not.toHaveBeenCalled(); + expect(multiselectMock).not.toHaveBeenCalled(); expect(pathExistsMock).toHaveBeenCalledTimes(15); expect(mkdirMock).toHaveBeenCalledTimes(15); expect(writeFileMock).toHaveBeenCalledTimes(15); @@ -329,20 +362,22 @@ describe('componentEjectTemplates', () => { it('prompts interactively for one, several, or all component types', async () => { setStdinIsTTY(true); - checkboxMock.mockResolvedValueOnce(['web-component', 'twig']); + multiselectMock.mockResolvedValueOnce(['web-component', 'twig']); await componentEjectTemplates(undefined); - expect(checkboxMock).toHaveBeenCalledTimes(1); - const prompt = checkboxMock.mock.calls[0][0]; + expect(multiselectMock).toHaveBeenCalledTimes(1); + const prompt = multiselectMock.mock.calls[0][0]; expect(prompt).toMatchObject({ message: 'Which component template types should be ejected?', }); - expect(prompt.choices.map(({ value }: { value: string }) => value)).toEqual( + expect(prompt.options.map(({ value }: { value: string }) => value)).toEqual( ['twig', 'twig-sdc', 'react', 'web-component'], ); - expect(prompt.validate([])).toBe('Select at least one component type.'); - expect(prompt.validate([{ value: 'twig' }])).toBe(true); + // Every option explains what it writes, so the list is readable without docs. + expect( + prompt.options.every(({ hint }: { hint?: string }) => Boolean(hint)), + ).toBe(true); const expectedCount = buildEjectableComponentTemplates('twig').length + @@ -365,7 +400,7 @@ describe('componentEjectTemplates', () => { }); expect(findFileMock).not.toHaveBeenCalled(); - expect(checkboxMock).not.toHaveBeenCalled(); + expect(multiselectMock).not.toHaveBeenCalled(); expect(pathExistsMock).not.toHaveBeenCalled(); expectNoWrites(); }, @@ -382,7 +417,7 @@ describe('componentEjectTemplates', () => { MISSING_TEMPLATE_TYPE_ERROR, ); - expect(checkboxMock).not.toHaveBeenCalled(); + expect(multiselectMock).not.toHaveBeenCalled(); expect(pathExistsMock).not.toHaveBeenCalled(); expectNoWrites(); }); @@ -397,7 +432,7 @@ describe('componentEjectTemplates', () => { exitCode: 1, }); - expect(checkboxMock).not.toHaveBeenCalled(); + expect(multiselectMock).not.toHaveBeenCalled(); expect(pathExistsMock).not.toHaveBeenCalled(); expectNoWrites(); }); @@ -414,7 +449,7 @@ describe('componentEjectTemplates', () => { it('rejects an empty interactive selection defensively', async () => { setStdinIsTTY(true); - checkboxMock.mockResolvedValueOnce([]); + multiselectMock.mockResolvedValueOnce([]); await expect(componentEjectTemplates(undefined)).rejects.toMatchObject({ name: 'CliError', @@ -859,11 +894,11 @@ describe('componentEjectTemplates', () => { await componentEjectTemplates('twig', { dryRun: true }); expectNoWrites(); - expect(logMock).toHaveBeenCalledWith( - 'info', + expect(noteMock).toHaveBeenCalledWith( expect.stringMatching( /component\.twig \(would create\)[\s\S]*component\.scss \(conflict; a real run requires --force\)[\s\S]*No files were written or replaced\./, ), + 'Dry run: component eject-templates', ); }); @@ -876,9 +911,9 @@ describe('componentEjectTemplates', () => { }); expectNoWrites(); - expect(logMock).toHaveBeenCalledWith( - 'info', + expect(noteMock).toHaveBeenCalledWith( expect.stringContaining('(would replace)'), + 'Dry run: component eject-templates', ); }); diff --git a/src/handlers/componentEjectTemplates.ts b/src/handlers/componentEjectTemplates.ts index c39aa3b..1d7573f 100644 --- a/src/handlers/componentEjectTemplates.ts +++ b/src/handlers/componentEjectTemplates.ts @@ -1,6 +1,6 @@ import type { EjectComponentTemplatesHandlerOptions } from '@emulsify-cli/handlers'; -import { checkbox } from '@inquirer/prompts'; +import { multiselect } from '@clack/prompts'; import { randomUUID } from 'crypto'; import { constants as fsConstants, promises as fs } from 'fs'; import { basename, dirname, join } from 'path'; @@ -12,6 +12,7 @@ import { EMULSIFY_PROJECT_TEMPLATES_FOLDER, } from '../lib/constants.js'; import log from '../lib/log.js'; +import { note } from '../lib/session.js'; import findFileInCurrentPath from '../util/fs/findFileInCurrentPath.js'; import safeResolveWithin from '../util/fs/safeResolveWithin.js'; import { @@ -27,30 +28,30 @@ export const MISSING_TEMPLATE_TYPE_ERROR = export const CONFLICTING_TEMPLATE_TYPE_ERROR = 'Pass either the [type] positional argument or --all, not both.'; -const TYPE_CHOICES: { - name: string; +const TYPE_OPTIONS: { value: ComponentType; - description: string; + label: string; + hint: string; }[] = [ { - name: 'Twig', value: 'twig', - description: 'Twig markup, SCSS, YAML data, and a Storybook story', + label: 'Twig', + hint: 'Twig markup, SCSS, YAML data, and a Storybook story', }, { - name: 'Twig SDC', value: 'twig-sdc', - description: 'Drupal Single Directory Component templates', + label: 'Twig SDC', + hint: 'Drupal Single Directory Component templates', }, { - name: 'React', value: 'react', - description: 'React JSX, SCSS, and a standard Storybook story', + label: 'React', + hint: 'React JSX, SCSS, and a standard Storybook story', }, { - name: 'Web Component', value: 'web-component', - description: 'Custom element, SCSS, and an Emulsify Core story', + label: 'Web Component', + hint: 'Custom element, SCSS, and an Emulsify Core story', }, ]; @@ -135,7 +136,7 @@ function getDryRunAction(exists: boolean, force: boolean): string { return 'conflict; a real run requires --force'; } -function logDryRun( +function noteDryRun( types: ComponentType[], plan: InspectedPlanItem[], force: boolean, @@ -147,15 +148,14 @@ function logDryRun( ) .join('\n'); - log( - 'info', + note( [ - 'Dry run: component eject-templates', `Types: ${types.join(', ')}`, 'Template files:', destinations, 'No files were written or replaced.', ].join('\n'), + 'Dry run: component eject-templates', ); } @@ -427,7 +427,11 @@ async function executeTransaction( } } -/** Handler for `emulsify component eject-templates [type]`. */ +/** + * Handler for `emulsify component eject-templates [type]`. + * + * @returns summary shown on the closing line of the session. + */ export default async function componentEjectTemplates( type: string | void, { @@ -435,7 +439,7 @@ export default async function componentEjectTemplates( force = false, dryRun = false, }: EjectComponentTemplatesHandlerOptions = {}, -): Promise { +): Promise { const requestedType = type?.trim(); if (requestedType && all) { @@ -463,11 +467,10 @@ export default async function componentEjectTemplates( } else { selectedTypes = await runPrompt({ prompt: () => - checkbox({ + multiselect({ message: 'Which component template types should be ejected?', - choices: TYPE_CHOICES, - validate: (values) => - values.length > 0 || 'Select at least one component type.', + options: TYPE_OPTIONS, + required: false, }), nonInteractive: { error: MISSING_TEMPLATE_TYPE_ERROR }, }); @@ -483,8 +486,8 @@ export default async function componentEjectTemplates( const conflicts = inspectedPlan.filter(({ exists }) => exists); if (dryRun) { - logDryRun(canonicalTypes, inspectedPlan, force); - return; + noteDryRun(canonicalTypes, inspectedPlan, force); + return 'Dry run complete'; } if (conflicts.length > 0 && !force) { @@ -496,12 +499,11 @@ export default async function componentEjectTemplates( const paths = inspectedPlan .map(({ destination }) => ` - ${destination}`) .join('\n'); - log( - 'success', - `Ejected ${inspectedPlan.length} built-in component templates:\n${paths}`, - ); + note(paths, 'Ejected'); log( 'info', 'Edit these files to customize component create. Delete an override to restore its built-in template.', ); + + return `Ejected ${inspectedPlan.length} built-in component templates`; } diff --git a/src/handlers/componentInstall.test.ts b/src/handlers/componentInstall.test.ts index d23b79b..fe482f1 100644 --- a/src/handlers/componentInstall.test.ts +++ b/src/handlers/componentInstall.test.ts @@ -8,13 +8,27 @@ jest.mock('../util/cache/getJsonFromCachedFile', () => jest.fn()); jest.mock('../util/cache/cloneIntoCache', () => jest.fn()); jest.mock('../util/cache/copyItemFromCache', () => jest.fn()); jest.mock('../util/fs/findFileInCurrentPath', () => jest.fn()); -jest.mock('@inquirer/prompts'); +jest.mock('../lib/session', () => ({ note: jest.fn() })); +// The prompt library is ESM-only, so the mock is built from a factory rather +// than automocked from the real module. +jest.mock('@clack/prompts', () => { + const cancelSymbol = Symbol('clack:cancel'); + + return { + __esModule: true, + CANCEL_SYMBOL: cancelSymbol, + isCancel: (value: unknown) => value === cancelSymbol, + select: jest.fn(), + confirm: jest.fn(), + }; +}); import { pathExists } from 'fs-extra'; import { join, resolve } from 'path'; -import { confirm, select } from '@inquirer/prompts'; +import { confirm, select } from '@clack/prompts'; import type { EmulsifySystem } from '@emulsify-cli/config'; import log from '../lib/log.js'; +import { note } from '../lib/session.js'; import CliError from '../lib/CliError.js'; import { EMULSIFY_PROJECT_CONFIG_FILE, @@ -28,6 +42,7 @@ import findFileInCurrentPath from '../util/fs/findFileInCurrentPath.js'; import componentInstall from './componentInstall.js'; const logMock = log as jest.Mock; +const noteMock = note as jest.Mock; const getEmulsifyConfigMock = getEmulsifyConfig as jest.Mock; const getJsonFromCachedFileMock = getJsonFromCachedFile as jest.Mock; const cloneIntoCacheMock = cloneIntoCache as jest.Mock; @@ -265,26 +280,26 @@ describe('componentInstall', () => { await componentInstall(undefined, { force: true }); expect(selectMock).toHaveBeenCalledWith({ - message: 'Choose a component to install:', - choices: [ + message: 'Choose a component to install', + options: [ { - name: 'button', value: 'button', - description: undefined, + label: 'button', + hint: 'base', }, { - name: 'icon', value: 'icon', - description: undefined, + label: 'icon', + hint: 'base', }, { - name: 'card', value: 'card', - description: undefined, + label: 'card', + hint: 'base', }, { - name: 'Install all available components', value: expect.any(Symbol), + label: 'Install all available components', }, ], }); @@ -305,8 +320,8 @@ describe('componentInstall', () => { }); it('installs all components when the interactive all choice is selected', async () => { - selectMock.mockImplementationOnce(async ({ choices }) => { - return choices.at(-1).value; + selectMock.mockImplementationOnce(async ({ options }) => { + return options.at(-1).value; }); await componentInstall(undefined, {}); @@ -349,7 +364,9 @@ describe('componentInstall', () => { }); it('installs a component and its dependencies on the happy path', async () => { - await componentInstall('button', { force: true }); + await expect(componentInstall('button', { force: true })).resolves.toBe( + 'Installed 2 components', + ); expect(copyItemFromCacheMock).toHaveBeenNthCalledWith( 1, @@ -369,10 +386,8 @@ describe('componentInstall', () => { 'success', 'Success! The button component has been added to your project.', ); - expect(logMock).toHaveBeenCalledWith( - 'info', - 'The following dependencies were also installed:\n → icon', - ); + // The dependency list reads as a block rather than a log line. + expect(noteMock).toHaveBeenCalledWith(' → icon', 'Also installed'); }); it('requests a remote freshness check when refresh is enabled', async () => { @@ -384,7 +399,9 @@ describe('componentInstall', () => { }); it('previews a single component install without copying in dry-run mode', async () => { - await componentInstall('card', { dryRun: true }); + await expect(componentInstall('card', { dryRun: true })).resolves.toBe( + 'Dry run complete', + ); expect(confirmMock).not.toHaveBeenCalled(); expect(copyItemFromCacheMock).not.toHaveBeenCalled(); @@ -443,7 +460,9 @@ describe('componentInstall', () => { it('installs a component when no project config path is found for destination checks', async () => { findFileInCurrentPathMock.mockReturnValueOnce(undefined); - await componentInstall('card', {}); + await expect(componentInstall('card', {})).resolves.toBe( + 'Installed 1 component', + ); expect(copyItemFromCacheMock).toHaveBeenCalledWith( 'systems', @@ -462,7 +481,7 @@ describe('componentInstall', () => { expect(confirmMock).toHaveBeenCalledWith({ message: 'The component "button" already exists. Would you like to replace it?', - default: false, + initialValue: false, }); expect(copyItemFromCacheMock).toHaveBeenCalledWith( 'systems', @@ -499,7 +518,7 @@ describe('componentInstall', () => { expect(confirmMock).toHaveBeenCalledWith({ message: 'The component "icon" (required by "button") already exists. Would you like to replace it?', - default: false, + initialValue: false, }); expect(copyItemFromCacheMock).toHaveBeenNthCalledWith( 2, @@ -514,7 +533,9 @@ describe('componentInstall', () => { pathExistsMock.mockResolvedValue(true); confirmMock.mockResolvedValue(false); - await componentInstall('button', {}); + await expect(componentInstall('button', {})).resolves.toBe( + 'No components were installed', + ); expect(logMock).toHaveBeenCalledWith( 'info', diff --git a/src/handlers/componentInstall.ts b/src/handlers/componentInstall.ts index 15901e2..b278d32 100644 --- a/src/handlers/componentInstall.ts +++ b/src/handlers/componentInstall.ts @@ -1,6 +1,7 @@ import { pathExists } from 'fs-extra'; -import { confirm, select } from '@inquirer/prompts'; +import { confirm, select } from '@clack/prompts'; import log from '../lib/log.js'; +import { note } from '../lib/session.js'; import { EMULSIFY_PROJECT_CONFIG_FILE } from '../lib/constants.js'; import CliError from '../lib/CliError.js'; import type { InstallComponentHandlerOptions } from '@emulsify-cli/handlers'; @@ -111,6 +112,8 @@ function logComponentInstallDryRun( /** * Handler for the `component install` command. * + * @returns summary shown on the closing line of the session. + * * @throws {CliError} if a component name is missing and all components were not requested. * @throws {CliError} if the current project does not have a usable system and variant configuration. * @throws {CliError} if the requested component cannot be found. @@ -119,7 +122,7 @@ function logComponentInstallDryRun( export default async function componentInstall( name: string | void, { force, all, dryRun, refresh }: InstallComponentHandlerOptions, -): Promise { +): Promise { let selectedName = name; let installAll = all === true; const needsSelection = !selectedName?.trim() && !installAll; @@ -140,16 +143,18 @@ export default async function componentInstall( const selection = await runPrompt({ prompt: () => select({ - message: 'Choose a component to install:', - choices: [ + message: 'Choose a component to install', + options: [ + // The structure reads as a hint so the list stays scannable when a + // system offers many components. ...variantConf.components.map((component) => ({ - name: component.name, value: component.name, - description: component.description, + label: component.name, + hint: component.structure, })), { - name: 'Install all available components', value: INSTALL_ALL_COMPONENTS, + label: 'Install all available components', }, ], }), @@ -177,7 +182,7 @@ export default async function componentInstall( true, ); logComponentInstallDryRun('all components', [], plan); - return; + return 'Dry run complete'; } components.push( @@ -220,7 +225,7 @@ export default async function componentInstall( Boolean(force), ); logComponentInstallDryRun(rootComponentName, dependencies, plan); - return; + return 'Dry run complete'; } const projectConfigPath = findFileInCurrentPath( @@ -239,11 +244,11 @@ export default async function componentInstall( ? ` (required by "${rootComponentName}")` : ''; const overwriteMessage = `The component "${componentName}"${dependencyNote} already exists.`; - const result = await runPrompt({ + const result = await runPrompt({ prompt: () => confirm({ message: `${overwriteMessage} Would you like to replace it?`, - default: false, + initialValue: false, }), nonInteractive: { error: `${overwriteMessage} Pass --force to replace existing components in non-interactive mode.`, @@ -273,6 +278,7 @@ export default async function componentInstall( } } + const installed: string[] = []; const installedDeps: string[] = []; const failedDeps: string[] = []; const failureMessages: string[] = []; @@ -280,6 +286,7 @@ export default async function componentInstall( for (const [cname, isDependency, promise] of components) { try { await promise; + installed.push(cname); if (isDependency) { installedDeps.push(cname); } else { @@ -299,7 +306,7 @@ export default async function componentInstall( if (installedDeps.length > 0) { const depList = installedDeps.map((d) => ` → ${d}`).join('\n'); - log('info', `The following dependencies were also installed:\n${depList}`); + note(depList, 'Also installed'); } if (failedDeps.length > 0) { @@ -313,4 +320,12 @@ export default async function componentInstall( if (failureMessages.length > 0) { throw new CliError(failureMessages.join('\n')); } + + if (installed.length === 0) { + return 'No components were installed'; + } + + return `Installed ${installed.length} component${ + installed.length === 1 ? '' : 's' + }`; } diff --git a/src/handlers/componentList.test.ts b/src/handlers/componentList.test.ts index 32d2c20..44a81b7 100644 --- a/src/handlers/componentList.test.ts +++ b/src/handlers/componentList.test.ts @@ -3,19 +3,22 @@ */ jest.mock('../lib/log', () => jest.fn()); +jest.mock('../lib/session', () => ({ note: jest.fn() })); jest.mock('../util/project/getEmulsifyConfig', () => jest.fn()); jest.mock('../util/cache/getJsonFromCachedFile', () => jest.fn()); jest.mock('../util/cache/cloneIntoCache', () => jest.fn()); import type { EmulsifySystem } from '@emulsify-cli/config'; import log from '../lib/log.js'; +import { note } from '../lib/session.js'; import { EMULSIFY_SYSTEM_CONFIG_FILE } from '../lib/constants.js'; import getEmulsifyConfig from '../util/project/getEmulsifyConfig.js'; import getJsonFromCachedFile from '../util/cache/getJsonFromCachedFile.js'; import cloneIntoCache from '../util/cache/cloneIntoCache.js'; -import componentList from './componentList.js'; +import componentList, { formatComponentList } from './componentList.js'; const logMock = log as jest.Mock; +const noteMock = note as jest.Mock; const getEmulsifyConfigMock = getEmulsifyConfig as jest.Mock; const getJsonFromCachedFileMock = getJsonFromCachedFile as jest.Mock; const cloneIntoCacheMock = cloneIntoCache as jest.Mock; @@ -190,8 +193,23 @@ describe('componentList', () => { it('lists each component from the configured variant on the happy path', async () => { await componentList(); - expect(logMock).toHaveBeenCalledWith('info', 'base -> button'); - expect(logMock).toHaveBeenCalledWith('info', 'base -> card'); + expect(noteMock).toHaveBeenCalledTimes(1); + const [body, title] = noteMock.mock.calls[0]; + expect(title).toBe('Available components'); + expect(body).toContain('button'); + expect(body).toContain('card'); + }); + + it('summarizes what the variant offers', async () => { + await expect(componentList()).resolves.toBe( + '2 components across 1 structure', + ); + }); + + it('does not write one loose line per component', async () => { + await componentList(); + + expect(logMock).not.toHaveBeenCalled(); }); it('requests a remote freshness check when refresh is enabled', async () => { @@ -202,3 +220,40 @@ describe('componentList', () => { }); }); }); + +describe('formatComponentList', () => { + it('groups components by structure and counts the required ones', () => { + const block = formatComponentList([ + { name: 'buttons', structure: 'atoms', required: true }, + { name: 'tooltip', structure: 'atoms' }, + { name: 'card', structure: 'molecules', dependency: ['buttons', 'text'] }, + ]); + + expect(block).toBe( + [ + 'atoms \u00b7 2 components \u00b7 1 required', + ' buttons required', + ' tooltip', + '', + 'molecules \u00b7 1 component', + ' card needs buttons, text', + ].join('\n'), + ); + }); + + it('surfaces what each component drags in with it', () => { + const block = formatComponentList([ + { + name: 'accordion', + structure: 'molecules', + dependency: ['buttons', 'images', 'text', 'links', 'lists'], + }, + ]); + + expect(block).toContain('needs buttons, images, text, links, lists'); + }); + + it('reports an empty variant plainly', () => { + expect(formatComponentList([])).toBe('This variant offers no components.'); + }); +}); diff --git a/src/handlers/componentList.ts b/src/handlers/componentList.ts index 52ca68b..210b5b4 100644 --- a/src/handlers/componentList.ts +++ b/src/handlers/componentList.ts @@ -1,21 +1,114 @@ +import type { Components } from '@emulsify-cli/config'; import type { ListComponentHandlerOptions } from '@emulsify-cli/handlers'; -import log from '../lib/log.js'; +import { note } from '../lib/session.js'; import { withEmulsifySystem } from './hofs/withEmulsifySystem.js'; +/** + * Render a count with the right plural form. + * + * @param count number of things. + * @param singular singular noun. + * @returns the count followed by the correctly pluralized noun. + */ +function pluralize(count: number, singular: string): string { + return `${count} ${count === 1 ? singular : `${singular}s`}`; +} + +/** + * Group the variant's components by the structure they belong to. + * + * @param components every component the variant offers. + * @returns structure names mapped to their components, in declaration order. + */ +function groupByStructure(components: Components): Map { + const groups = new Map(); + + for (const component of components) { + const group = groups.get(component.structure); + + if (group) { + group.push(component); + continue; + } + + groups.set(component.structure, [component]); + } + + return groups; +} + +/** + * Render the variant's components as an aligned, grouped block. + * + * @remarks The system already records which components are required and what + * each one depends on. Showing both turns a flat list of names into something + * you can plan an install from. + * + * @param components every component the variant offers. + * @returns a multi-line block grouped by structure. + */ +export function formatComponentList(components: Components): string { + if (components.length === 0) { + return 'This variant offers no components.'; + } + + const nameWidth = Math.max(...components.map(({ name }) => name.length)); + const lines: string[] = []; + + for (const [structure, group] of groupByStructure(components)) { + const requiredCount = group.filter(({ required }) => required).length; + const heading = [ + structure, + pluralize(group.length, 'component'), + ...(requiredCount > 0 ? [`${requiredCount} required`] : []), + ].join(' · '); + + if (lines.length > 0) { + lines.push(''); + } + + lines.push(heading); + + for (const { name, required, dependency } of group) { + const notes = [ + ...(required ? ['required'] : []), + ...(dependency && dependency.length > 0 + ? [`needs ${dependency.join(', ')}`] + : []), + ]; + + lines.push( + ` ${name.padEnd(nameWidth)} ${notes.join(' · ')}`.trimEnd(), + ); + } + } + + return lines.join('\n'); +} + /** * Handler for the `component list` command. * + * @param options.refresh whether to check the system remote before reusing its cache entry. + * @returns a short summary of what the installed variant offers. * @throws {CliError} if the current project does not have a usable system and variant configuration. */ export default async function componentList({ refresh, -}: ListComponentHandlerOptions = {}): Promise { +}: ListComponentHandlerOptions = {}): Promise { // Load the configured system and variant before printing available components. const { variantConf } = await withEmulsifySystem('list components', { refresh, }); - variantConf.components.map(({ name, structure }) => { - log('info', `${structure} -> ${name}`); - }); + const { components } = variantConf; + + note(formatComponentList(components), 'Available components'); + + const structureCount = groupByStructure(components).size; + + return `${pluralize(components.length, 'component')} across ${pluralize( + structureCount, + 'structure', + )}`; } diff --git a/src/handlers/hofs/withProgressBar.test.ts b/src/handlers/hofs/withProgressBar.test.ts deleted file mode 100644 index 6414154..0000000 --- a/src/handlers/hofs/withProgressBar.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -class PB {} -jest.mock('progress', () => { - return PB; -}); -import withProgressBar from './withProgressBar.js'; - -describe('withProgressBar', () => { - it('creates a ProgressBar object, and passes it into the given handler fn', () => { - expect.assertions(2); - const handler = jest.fn().mockReturnValue('cookies'); - expect(withProgressBar(handler)).toEqual('cookies'); - expect(handler).toHaveBeenCalledWith(new PB()); - }); -}); diff --git a/src/handlers/hofs/withProgressBar.ts b/src/handlers/hofs/withProgressBar.ts deleted file mode 100644 index 8c91bb1..0000000 --- a/src/handlers/hofs/withProgressBar.ts +++ /dev/null @@ -1,24 +0,0 @@ -import ProgressBar from 'progress'; - -// In this case, we actually do not care what the args are, so any is fine. -export type HandlerWithProgress = ( - progress: InstanceType, -) => (...args: any[]) => Promise | void; - -/** - * HOF that passes a progress bar into a given handler fn. - * @param handler fn that, when called, returns a fn that will be executed by commander. - * @returns the function that handler returns. - */ -export default function withProgressBar( - handler: HandlerWithProgress, -): ReturnType { - const progress = new ProgressBar('[:bar] :percent :message', { - total: 100, - complete: '=', - incomplete: ' ', - width: 100, - }); - - return handler(progress); -} diff --git a/src/handlers/hofs/withSession.test.ts b/src/handlers/hofs/withSession.test.ts new file mode 100644 index 0000000..741125b --- /dev/null +++ b/src/handlers/hofs/withSession.test.ts @@ -0,0 +1,65 @@ +jest.mock('../../lib/session', () => ({ + startSession: jest.fn(), + finishSession: jest.fn(), +})); + +import { finishSession, startSession } from '../../lib/session.js'; +import withSession from './withSession.js'; + +const startSessionMock = startSession as jest.Mock; +const finishSessionMock = finishSession as jest.Mock; + +describe('withSession', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('frames the handler and passes every argument through', async () => { + const action = jest.fn().mockResolvedValue(undefined); + + await withSession('emulsify init', action)('cornflake', './themes', { + yes: true, + }); + + expect(startSessionMock).toHaveBeenCalledWith('emulsify init'); + expect(action).toHaveBeenCalledWith('cornflake', './themes', { + yes: true, + }); + expect(finishSessionMock).toHaveBeenCalledWith(undefined); + }); + + it('uses a returned string as the closing summary', async () => { + const action = jest.fn().mockResolvedValue('cornflake is ready'); + + await withSession('emulsify init', action)(); + + expect(finishSessionMock).toHaveBeenCalledWith('cornflake is ready'); + }); + + it('accepts a synchronous handler', async () => { + await withSession('emulsify cache clear', () => 'cache cleared')(); + + expect(finishSessionMock).toHaveBeenCalledWith('cache cleared'); + }); + + it('opens the session before the handler runs', async () => { + const action = jest.fn(() => { + expect(startSessionMock).toHaveBeenCalledTimes(1); + return undefined; + }); + + await withSession('emulsify system list', action)(); + + expect(action).toHaveBeenCalledTimes(1); + }); + + it('leaves the frame open when the handler fails so the failure reads inside it', async () => { + const failure = new Error('clone failed'); + const action = jest.fn().mockRejectedValue(failure); + + await expect(withSession('emulsify init', action)()).rejects.toBe(failure); + + expect(startSessionMock).toHaveBeenCalledTimes(1); + expect(finishSessionMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/handlers/hofs/withSession.ts b/src/handlers/hofs/withSession.ts new file mode 100644 index 0000000..f1871b9 --- /dev/null +++ b/src/handlers/hofs/withSession.ts @@ -0,0 +1,33 @@ +import { finishSession, startSession } from '../../lib/session.js'; + +export type SessionAction = ( + ...args: Args +) => Promise | string | void; + +/** + * HOF that frames a command handler as a session. + * + * The session opens before the handler runs and closes once it resolves, so + * every line the handler logs is threaded onto one gutter. A handler may return + * a string to set the closing summary. + * + * @remarks Failures are rethrown with the frame still open; the top-level + * command runner closes it as cancelled so the failure reads as part of the + * same session rather than as loose output after it. + * + * @param title short command label shown in the session header. + * @param action handler that will be executed by commander. + * @returns a commander action that runs the handler inside a session. + */ +export default function withSession( + title: string, + action: SessionAction, +): (...args: Args) => Promise { + return async (...args: Args): Promise => { + startSession(title); + + const summary = await action(...args); + + finishSession(typeof summary === 'string' ? summary : undefined); + }; +} diff --git a/src/handlers/init.test.ts b/src/handlers/init.test.ts index 4279b2f..6ef3688 100644 --- a/src/handlers/init.test.ts +++ b/src/handlers/init.test.ts @@ -4,14 +4,28 @@ jest.mock('../util/fs/loadJsonFile', () => jest.fn()); jest.mock('../util/fs/writeToJsonFile', () => jest.fn()); jest.mock('../util/fs/executeScript', () => jest.fn()); jest.mock('../util/project/installDependencies', () => jest.fn()); -jest.mock('@inquirer/prompts'); +jest.mock('../lib/session', () => ({ + note: jest.fn(), + spinner: jest.fn(), +})); +jest.mock('@clack/prompts', () => { + const CANCEL_SYMBOL = Symbol.for('clack:cancel'); + + return { + __esModule: true, + CANCEL_SYMBOL, + isCancel: (value: unknown) => value === CANCEL_SYMBOL, + text: jest.fn(), + select: jest.fn(), + }; +}); import fs from 'fs'; import { join, resolve } from 'path'; import { simpleGit as git } from 'simple-git'; import log from '../lib/log.js'; -import { input, select } from '@inquirer/prompts'; -import ProgressBar from 'progress'; +import { select, text } from '@clack/prompts'; +import { note, spinner } from '../lib/session.js'; import installDependencies from '../util/project/installDependencies.js'; import getPlatformInfo from '../util/platform/getPlatformInfo.js'; import loadJsonFile from '../util/fs/loadJsonFile.js'; @@ -55,16 +69,25 @@ const loadJsonFileMock = (loadJsonFile as jest.Mock).mockResolvedValue( const writeJsonFileMock = writeToJsonFile as jest.Mock; const installDependenciesMock = installDependencies as jest.Mock; const executeScriptMock = executeScript as jest.Mock; -const progressMock = { - tick: jest.fn(), +const spinnerMock = { + start: jest.fn(), + stop: jest.fn(), + message: jest.fn(), + cancel: jest.fn(), + error: jest.fn(), + clear: jest.fn(), + isCancelled: false, }; -const progress = progressMock as unknown as InstanceType; -const inputMock = input as jest.Mock; +const noteMock = note as jest.Mock; +const spinnerFactoryMock = (spinner as jest.Mock).mockReturnValue(spinnerMock); +const textMock = text as jest.Mock; const selectMock = select as jest.Mock; const originalStdinIsTTY = process.stdin.isTTY; const systemSelectionMessage = [ - 'Next, choose a component system:', - ' emulsify system install', + 'emulsify system install', + '', + 'Pick a component system to render with. Nothing renders until one', + 'is installed.', ].join('\n'); function setStdinIsTTY(value: boolean | undefined) { @@ -85,8 +108,13 @@ describe('init', () => { writeJsonFileMock.mockClear(); installDependenciesMock.mockClear(); executeScriptMock.mockClear(); - progressMock.tick.mockClear(); - inputMock.mockClear(); + spinnerFactoryMock.mockClear(); + spinnerMock.start.mockClear(); + spinnerMock.stop.mockClear(); + spinnerMock.message.mockClear(); + spinnerMock.error.mockClear(); + noteMock.mockClear(); + textMock.mockClear(); selectMock.mockClear(); setStdinIsTTY(false); }); @@ -96,7 +124,7 @@ describe('init', () => { }); it('should execute the returned function', async () => { - await expect(init(progress)()).rejects.toThrow( + await expect(init()).rejects.toThrow( 'Unable to determine the project name. Please provide a valid project name.', ); }); @@ -104,19 +132,20 @@ describe('init', () => { it('should prompt for the name if not provided in an interactive terminal', async () => { expect.assertions(2); setStdinIsTTY(true); - inputMock.mockResolvedValueOnce('cornflake'); + textMock.mockResolvedValueOnce('cornflake'); - await init(progress)(); - expect(input).toHaveBeenCalledTimes(1); - expect(input).toHaveBeenNthCalledWith(1, { - message: 'Project name:', - default: 'emulsifyTheme', + await init(); + expect(text).toHaveBeenCalledTimes(1); + expect(text).toHaveBeenNthCalledWith(1, { + message: 'Project name', + placeholder: 'emulsifyTheme', + defaultValue: 'emulsifyTheme', }); }); it('can detect the platform, and use information about the platform to autodetect the target directory and Emulsify starter', async () => { expect.assertions(4); - await init(progress)('cornflake'); + await init('cornflake'); expect(selectMock).not.toHaveBeenCalled(); expect(gitCloneMock).toHaveBeenCalledWith( 'https://github.com/emulsify-ds/emulsify-starter', @@ -156,7 +185,7 @@ describe('init', () => { }; loadJsonFileMock.mockResolvedValueOnce(starterConfig); - await init(progress)('cornflake'); + await init('cornflake'); expect(loadJsonFileMock).toHaveBeenCalledWith(defaultConfigPath); expect(writeJsonFileMock).toHaveBeenCalledWith(defaultConfigPath, { @@ -183,26 +212,22 @@ describe('init', () => { platformMajorVersion: 11, }); - await init(progress)('cornflake'); + await init('cornflake'); expect(gitCloneMock).toHaveBeenCalledWith( 'https://github.com/emulsify-ds/emulsify-drupal-starter', drupalTarget, { '--branch': 'main' }, ); - expect(logMock).toHaveBeenCalledWith( - 'warn', + // Guidance on a successful run is a note, never a warning. + expect(noteMock).toHaveBeenCalledWith( expect.stringContaining( 'composer require drupal/emulsify drupal/emulsify_tools', ), + 'Drupal also needs', ); - expect(logMock).toHaveBeenCalledWith( - 'warn', - expect.stringContaining( - 'drupal/emulsify as its base theme and emulsify_tools for Drupal integration', - ), - ); - expect(logMock).toHaveBeenCalledWith('info', systemSelectionMessage); + expect(noteMock).toHaveBeenCalledWith(systemSelectionMessage, 'Next'); + expect(logMock).not.toHaveBeenCalledWith('warn', expect.anything()); }); it('clones the WordPress starter into the detected themes directory when WordPress is auto-detected', async () => { @@ -213,7 +238,7 @@ describe('init', () => { emulsifyParentDirectory: wordpressThemesDirectory, }); - await init(progress)('My Theme'); + await init('My Theme'); expect(gitCloneMock).toHaveBeenCalledWith( 'https://github.com/emulsify-ds/emulsify-wordpress-starter', @@ -236,31 +261,51 @@ describe('init', () => { ); }); - it('uses the progress obj to display information on the init process', async () => { - expect.assertions(5); - await init(progress)('cornflake'); - expect(progress.tick).toHaveBeenNthCalledWith(1, 10, { - message: - 'using starter for none as the selected platform, validating config', - }); - expect(progress.tick).toHaveBeenNthCalledWith(2, 10, { - message: 'validation complete, cloning starter', - }); - expect(progress.tick).toHaveBeenNthCalledWith(3, 30, { - message: - 'starter cloned, installing dependencies (this will take a moment)', - }); - expect(progress.tick).toHaveBeenNthCalledWith(4, 40, { - message: 'dependencies installed, executing init script', - }); - expect(progress.tick).toHaveBeenNthCalledWith(5, 10, { - message: 'init script executed, initialization complete', - }); + it('reports each phase of the init process as it starts', async () => { + expect.assertions(4); + await init('cornflake'); + + expect(spinnerMock.start).toHaveBeenCalledWith( + 'Cloning https://github.com/emulsify-ds/emulsify-starter', + ); + expect(spinnerMock.message).toHaveBeenNthCalledWith( + 1, + 'Installing dependencies', + ); + expect(spinnerMock.message).toHaveBeenNthCalledWith( + 2, + 'Running the starter init hook', + ); + expect(spinnerMock.stop).toHaveBeenCalledWith( + `Created cornflake in ${defaultTarget}`, + ); + }); + + it('never starts the spinner before the target is reserved', async () => { + expect.assertions(2); + existsSyncMock.mockReturnValueOnce(true); + + await expect(init('cornflake')).rejects.toThrow( + 'The intended target is already occupied', + ); + expect(spinnerMock.start).not.toHaveBeenCalled(); + }); + + it('stops the spinner before reporting a failure', async () => { + expect.assertions(2); + installDependenciesMock.mockRejectedValueOnce(new Error('npm exploded')); + + await expect(init('cornflake')).rejects.toThrow( + 'Unable to initialize project while installing dependencies', + ); + expect(spinnerMock.error).toHaveBeenCalledWith( + 'Could not finish installing dependencies', + ); }); - it('can clone an Emulsify starter based on CLI input, and log a success message upon completion', async () => { - expect.assertions(3); - await init(progress)('cornflake', `${root}`, { + it('can clone an Emulsify starter based on CLI input, and summarize the result', async () => { + expect.assertions(2); + await init('cornflake', `${root}`, { starter: 'https://github.com/emulsify-ds/emulsify-starter', checkout: 'main', }); @@ -269,18 +314,15 @@ describe('init', () => { defaultTarget, { '--branch': 'main' }, ); - expect(logMock).toHaveBeenCalledTimes(2); - expect(logMock).toHaveBeenNthCalledWith( - 1, - 'success', - `Created an Emulsify project in ${defaultTarget}.`, + expect(spinnerMock.stop).toHaveBeenCalledWith( + `Created cornflake in ${defaultTarget}`, ); }); it('can clone an Emulsify starter without a provided checkout', async () => { expect.assertions(1); getPlatformInfoMock.mockReturnValueOnce(undefined); - await init(progress)('cornflake', `${root}`, { + await init('cornflake', `${root}`, { starter: 'https://github.com/emulsify-ds/emulsify-starter', platform: 'wordpress', }); @@ -295,7 +337,7 @@ describe('init', () => { expect.assertions(1); getPlatformInfoMock.mockReturnValueOnce(undefined); - await init(progress)('cornflake', `${root}`, { + await init('cornflake', `${root}`, { starter: 'git@github.com:example/custom-starter.git', platform: 'drupal', }); @@ -311,7 +353,7 @@ describe('init', () => { expect.assertions(2); getPlatformInfoMock.mockReturnValueOnce(undefined); - await init(progress)('cornflake', `${root}`, { + await init('cornflake', `${root}`, { platform: 'wordpress', }); @@ -336,13 +378,13 @@ describe('init', () => { expect.assertions(5); getPlatformInfoMock.mockReturnValueOnce(undefined); - await init(progress)(undefined, root, { + await init(undefined, root, { machineName: 'cornflake', starter: 'https://github.com/emulsify-ds/emulsify-starter', platform: 'drupal', }); - expect(inputMock).not.toHaveBeenCalled(); + expect(textMock).not.toHaveBeenCalled(); expect(selectMock).not.toHaveBeenCalled(); expect(gitCloneMock).toHaveBeenCalledWith( 'https://github.com/emulsify-ds/emulsify-starter', @@ -359,9 +401,9 @@ describe('init', () => { repository: 'https://github.com/emulsify-ds/emulsify-starter', }, }); - expect(logMock).not.toHaveBeenCalledWith( - 'warn', + expect(noteMock).not.toHaveBeenCalledWith( expect.stringContaining('composer require drupal/emulsify'), + expect.anything(), ); }); @@ -371,16 +413,20 @@ describe('init', () => { getPlatformInfoMock.mockReturnValueOnce(undefined); selectMock.mockResolvedValueOnce('none'); - await init(progress)('cornflake', root, { + await init('cornflake', root, { starter: 'https://github.com/emulsify-ds/emulsify-starter', }); - expect(inputMock).not.toHaveBeenCalled(); + expect(textMock).not.toHaveBeenCalled(); expect(selectMock).toHaveBeenCalledTimes(1); expect(selectMock).toHaveBeenNthCalledWith(1, { - message: 'Platform:', - choices: ['drupal', 'wordpress', 'none'], - default: 'drupal', + message: 'Platform', + options: [ + { value: 'drupal', label: 'Drupal', hint: 'Drupal 10 or 11 theme' }, + { value: 'wordpress', label: 'WordPress', hint: 'WordPress theme' }, + { value: 'none', label: 'None', hint: 'standalone front end' }, + ], + initialValue: 'drupal', }); expect(writeJsonFileMock).toHaveBeenCalledWith(defaultConfigPath, { project: { @@ -392,9 +438,9 @@ describe('init', () => { repository: 'https://github.com/emulsify-ds/emulsify-starter', }, }); - expect(logMock).not.toHaveBeenCalledWith( - 'warn', + expect(noteMock).not.toHaveBeenCalledWith( expect.stringContaining('composer require drupal/emulsify'), + expect.anything(), ); }); @@ -402,12 +448,12 @@ describe('init', () => { expect.assertions(4); getPlatformInfoMock.mockReturnValueOnce(undefined); - await init(progress)(undefined, undefined, { + await init(undefined, undefined, { starter: 'https://github.com/emulsify-ds/emulsify-starter', yes: true, }); - expect(inputMock).not.toHaveBeenCalled(); + expect(textMock).not.toHaveBeenCalled(); expect(selectMock).not.toHaveBeenCalled(); expect(gitCloneMock).toHaveBeenCalledWith( 'https://github.com/emulsify-ds/emulsify-starter', @@ -431,21 +477,21 @@ describe('init', () => { it('installs the project dependencies', async () => { expect.assertions(1); - await init(progress)('cornflake'); + await init('cornflake'); expect(installDependencies).toHaveBeenCalledWith(defaultTarget); }); it('executes the init script within the Emulsify starter, if it exists', async () => { expect.assertions(1); existsSyncMock.mockReturnValueOnce(false).mockReturnValueOnce(true); - await init(progress)('cornflake'); + await init('cornflake'); expect(executeScript).toHaveBeenCalledWith(defaultInitHookPath); }); it('throws if no valid platform name is detectable', async () => { expect.assertions(1); getPlatformInfoMock.mockReturnValueOnce(undefined); - await expect(init(progress)('cornflake')).rejects.toThrow( + await expect(init('cornflake')).rejects.toThrow( 'Unable to determine which platform you are installing Emulsify within. Please specify a platform (such as "drupal" or "wordpress") by passing a -p or --platform flag with your init command.', ); }); @@ -453,7 +499,7 @@ describe('init', () => { it('reports the clone phase and removes a partial target when cloning fails', async () => { gitCloneMock.mockRejectedValueOnce(new Error('Does not exist!')); - await expect(init(progress)('cornflake')).rejects.toThrow( + await expect(init('cornflake')).rejects.toThrow( `Unable to initialize project while cloning the starter: Error: Does not exist!. Removed the incomplete target "${defaultTarget}".`, ); expect(rmMock).toHaveBeenCalledWith(defaultTarget, { @@ -467,7 +513,7 @@ describe('init', () => { new Error('Invalid JSON in project.emulsify.json'), ); - await expect(init(progress)('cornflake')).rejects.toThrow( + await expect(init('cornflake')).rejects.toThrow( `Unable to initialize project while reading the starter project configuration: Error: Invalid JSON in project.emulsify.json. Removed the incomplete target "${defaultTarget}".`, ); expect(rmMock).toHaveBeenCalledWith(defaultTarget, { @@ -479,7 +525,7 @@ describe('init', () => { it('reports the configuration-write phase and removes the incomplete target', async () => { writeJsonFileMock.mockRejectedValueOnce(new Error('config write failed')); - await expect(init(progress)('cornflake')).rejects.toThrow( + await expect(init('cornflake')).rejects.toThrow( `Unable to initialize project while writing the project configuration: Error: config write failed. Removed the incomplete target "${defaultTarget}".`, ); expect(rmMock).toHaveBeenCalledWith(defaultTarget, { @@ -493,7 +539,7 @@ describe('init', () => { new Error('npm install failed'), ); - await expect(init(progress)('cornflake')).rejects.toThrow( + await expect(init('cornflake')).rejects.toThrow( `Unable to initialize project while installing dependencies: Error: npm install failed. Removed the incomplete target "${defaultTarget}".`, ); expect(rmMock).toHaveBeenCalledWith(defaultTarget, { @@ -506,7 +552,7 @@ describe('init', () => { existsSyncMock.mockReturnValueOnce(false).mockReturnValueOnce(true); executeScriptMock.mockRejectedValueOnce(new Error('hook failed')); - await expect(init(progress)('cornflake')).rejects.toThrow( + await expect(init('cornflake')).rejects.toThrow( `Unable to initialize project while executing the starter init hook: Error: hook failed. Removed the incomplete target "${defaultTarget}".`, ); expect(executeScriptMock).toHaveBeenCalledWith(defaultInitHookPath); @@ -522,7 +568,7 @@ describe('init', () => { ); rmMock.mockRejectedValueOnce(new Error('EACCES')); - await expect(init(progress)('cornflake')).rejects.toThrow( + await expect(init('cornflake')).rejects.toThrow( `Unable to initialize project while installing dependencies: Error: npm install failed. Automatic cleanup of the incomplete target "${defaultTarget}" also failed: Error: EACCES. Remove it manually before retrying.`, ); expect(rmMock).toHaveBeenCalledWith(defaultTarget, { @@ -534,7 +580,7 @@ describe('init', () => { it('rolls back the target when removing starter Git metadata fails', async () => { rmMock.mockRejectedValueOnce(new Error('Git metadata is locked')); - await expect(init(progress)('cornflake')).rejects.toThrow( + await expect(init('cornflake')).rejects.toThrow( `Unable to initialize project while removing the starter Git metadata: Error: Git metadata is locked. Removed the incomplete target "${defaultTarget}".`, ); expect(rmMock).toHaveBeenNthCalledWith(1, defaultGitPath, { @@ -547,13 +593,11 @@ describe('init', () => { }); it('does not roll back a completed project when success logging fails', async () => { - logMock.mockImplementationOnce(() => { + noteMock.mockImplementationOnce(() => { throw new Error('terminal output failed'); }); - await expect(init(progress)('cornflake')).rejects.toThrow( - 'terminal output failed', - ); + await expect(init('cornflake')).rejects.toThrow('terminal output failed'); expect(rmMock).toHaveBeenCalledTimes(1); expect(rmMock).toHaveBeenCalledWith(defaultGitPath, { recursive: true }); expect(rmMock).not.toHaveBeenCalledWith(defaultTarget, { @@ -567,7 +611,7 @@ describe('init', () => { getPlatformInfoMock.mockReturnValueOnce({ name: 'drupal', }); - await expect(init(progress)('cornflake')).rejects.toThrow( + await expect(init('cornflake')).rejects.toThrow( 'Unable to find a directory to put Emulsify in. Please specify a directory using the "path" argument: emulsify init myTheme ./themes', ); }); @@ -577,7 +621,7 @@ describe('init', () => { getPlatformInfoMock.mockReturnValueOnce({ name: 'invalid', }); - await expect(init(progress)('cornflake', root)).rejects.toThrow( + await expect(init('cornflake', root)).rejects.toThrow( 'Unsupported platform "invalid". Supported platforms are "none", "drupal", and "wordpress".', ); }); @@ -585,7 +629,7 @@ describe('init', () => { it('throws if the target directory already exists', async () => { expect.assertions(4); existsSyncMock.mockReturnValueOnce(true); - await expect(init(progress)('cornflake')).rejects.toThrow( + await expect(init('cornflake')).rejects.toThrow( `The intended target is already occupied: ${defaultTarget}`, ); expect(mkdirMock).not.toHaveBeenCalled(); @@ -598,7 +642,7 @@ describe('init', () => { Object.assign(new Error('already exists'), { code: 'EEXIST' }), ); - await expect(init(progress)('cornflake')).rejects.toThrow( + await expect(init('cornflake')).rejects.toThrow( `The intended target is already occupied: ${defaultTarget}`, ); expect(gitCloneMock).not.toHaveBeenCalled(); @@ -610,7 +654,7 @@ describe('init', () => { Object.assign(new Error('permission denied'), { code: 'EACCES' }), ); - await expect(init(progress)('cornflake')).rejects.toThrow( + await expect(init('cornflake')).rejects.toThrow( `Unable to initialize project while creating the target directory "${defaultTarget}": Error: permission denied`, ); expect(gitCloneMock).not.toHaveBeenCalled(); @@ -620,30 +664,36 @@ describe('init', () => { it('should prompt for all info if name is missing', async () => { setStdinIsTTY(true); getPlatformInfoMock.mockReturnValueOnce(undefined); - inputMock.mockResolvedValueOnce('new-project').mockResolvedValueOnce(root); + textMock.mockResolvedValueOnce('new-project').mockResolvedValueOnce(root); selectMock.mockResolvedValueOnce('drupal'); - await init(progress)(); + await init(); - expect(input).toHaveBeenCalledTimes(2); - expect(input).toHaveBeenNthCalledWith(1, { - message: 'Project name:', - default: 'emulsifyTheme', + expect(text).toHaveBeenCalledTimes(2); + expect(text).toHaveBeenNthCalledWith(1, { + message: 'Project name', + placeholder: 'emulsifyTheme', + defaultValue: 'emulsifyTheme', }); - expect(input).toHaveBeenNthCalledWith(2, { - message: 'Target directory:', - default: './', + expect(text).toHaveBeenNthCalledWith(2, { + message: 'Target directory', + placeholder: './', + defaultValue: './', }); expect(select).toHaveBeenCalledTimes(1); expect(select).toHaveBeenNthCalledWith(1, { - message: 'Platform:', - choices: ['drupal', 'wordpress', 'none'], - default: 'drupal', + message: 'Platform', + options: [ + { value: 'drupal', label: 'Drupal', hint: 'Drupal 10 or 11 theme' }, + { value: 'wordpress', label: 'WordPress', hint: 'WordPress theme' }, + { value: 'none', label: 'None', hint: 'standalone front end' }, + ], + initialValue: 'drupal', }); expect(gitCloneMock).toHaveBeenCalled(); - expect(logMock).not.toHaveBeenCalledWith( - 'warn', + expect(noteMock).not.toHaveBeenCalledWith( expect.stringContaining('composer require drupal/emulsify'), + expect.anything(), ); }); }); diff --git a/src/handlers/init.ts b/src/handlers/init.ts index 9bb5ff8..d908b65 100644 --- a/src/handlers/init.ts +++ b/src/handlers/init.ts @@ -1,8 +1,7 @@ import { join } from 'path'; import { existsSync, promises as fs } from 'fs'; import { simpleGit } from 'simple-git'; -import ProgressBar from 'progress'; -import { input, select } from '@inquirer/prompts'; +import { select, text } from '@clack/prompts'; import type { EmulsifyProjectConfiguration, @@ -24,6 +23,7 @@ import executeScript from '../util/fs/executeScript.js'; import getInitSuccessMessageForPlatform from '../util/platform/getInitSuccessMessageForPlatform.js'; import log from '../lib/log.js'; import CliError from '../lib/CliError.js'; +import { note, spinner } from '../lib/session.js'; import { isPlatform } from '../util/platform/platformCompatibility.js'; import { runPrompt } from '../util/prompt/index.js'; @@ -33,10 +33,14 @@ export const DIRECTORY = 1; const DEFAULT_PROJECT_NAME = 'emulsifyTheme'; const DEFAULT_PLATFORM: Platform = 'drupal'; const PLATFORM_CHOICES = [ - 'drupal', - 'wordpress', - 'none', -] as const satisfies readonly Platform[]; + { value: 'drupal', label: 'Drupal', hint: 'Drupal 10 or 11 theme' }, + { value: 'wordpress', label: 'WordPress', hint: 'WordPress theme' }, + { value: 'none', label: 'None', hint: 'standalone front end' }, +] as const satisfies readonly { + value: Platform; + label: string; + hint: string; +}[]; type InitializationPhase = | 'cloning the starter' @@ -89,229 +93,235 @@ async function rollbackFailedInitialization( * * @throws {CliError} if required project information cannot be determined or initialization fails. */ -export default function init(progress: InstanceType) { - return async ( - name?: string, - targetDirectory?: string, - options?: InitHandlerOptions, - ): Promise => { - // Load information about the project and platform. - const { name: autoPlatformName, emulsifyParentDirectory } = - (await getPlatformInfo()) || {}; - const isDetectedDrupalProject = autoPlatformName === 'drupal'; - const acceptDefaults = options?.yes === true; - - // Prompts are skipped in non-TTY runs; --yes accepts prompt defaults and - // explicit flags/arguments always take precedence. - let projectName = name || options?.machineName; - if (!projectName) { - projectName = await runPrompt({ - prompt: () => - input({ - message: 'Project name:', - default: DEFAULT_PROJECT_NAME, - }), - nonInteractive: { - error: - 'Unable to determine the project name. Please provide a valid project name.', - }, - accept: { - when: acceptDefaults, - value: DEFAULT_PROJECT_NAME, - }, - }); - } +export default async function init( + name?: string, + targetDirectory?: string, + options?: InitHandlerOptions, +): Promise { + // Load information about the project and platform. + const { name: autoPlatformName, emulsifyParentDirectory } = + (await getPlatformInfo()) || {}; + const isDetectedDrupalProject = autoPlatformName === 'drupal'; + const acceptDefaults = options?.yes === true; + + // Prompts are skipped in non-TTY runs; --yes accepts prompt defaults and + // explicit flags/arguments always take precedence. + let projectName = name || options?.machineName; + if (!projectName) { + projectName = await runPrompt({ + prompt: () => + text({ + message: 'Project name', + placeholder: DEFAULT_PROJECT_NAME, + defaultValue: DEFAULT_PROJECT_NAME, + }), + nonInteractive: { + error: + 'Unable to determine the project name. Please provide a valid project name.', + }, + accept: { + when: acceptDefaults, + value: DEFAULT_PROJECT_NAME, + }, + }); + } - if (!projectName) { - throw new CliError( - 'Unable to determine the project name. Please provide a valid project name.', - ); - } + if (!projectName) { + throw new CliError( + 'Unable to determine the project name. Please provide a valid project name.', + ); + } - let targetParent = targetDirectory || emulsifyParentDirectory; - if (!targetParent) { - targetParent = await runPrompt({ - prompt: () => - input({ - message: 'Target directory:', - default: './', - }), - // Preserve the existing error ordering: platform validation occurs - // before the missing target is reported below. - nonInteractive: { value: undefined }, - accept: { when: acceptDefaults, value: './' }, - }); - } + let targetParent = targetDirectory || emulsifyParentDirectory; + if (!targetParent) { + targetParent = await runPrompt({ + prompt: () => + text({ + message: 'Target directory', + placeholder: './', + defaultValue: './', + }), + // Preserve the existing error ordering: platform validation occurs + // before the missing target is reported below. + nonInteractive: { value: undefined }, + accept: { when: acceptDefaults, value: './' }, + }); + } - // If no platform name is given, and none can be detected, exit and error. - const requestedPlatformName = options?.platform || autoPlatformName; - let platformName = isPlatform(requestedPlatformName) - ? requestedPlatformName - : undefined; - if (requestedPlatformName && !platformName) { - throw new CliError( - `Unsupported platform "${requestedPlatformName}". Supported platforms are "none", "drupal", and "wordpress".`, - ); - } - if (!platformName) { - platformName = await runPrompt({ - prompt: () => - select({ - message: 'Platform:', - choices: PLATFORM_CHOICES, - default: DEFAULT_PLATFORM, - }), - nonInteractive: { - error: - 'Unable to determine which platform you are installing Emulsify within. Please specify a platform (such as "drupal" or "wordpress") by passing a -p or --platform flag with your init command.', - }, - accept: { when: acceptDefaults, value: DEFAULT_PLATFORM }, - }); - } + // If no platform name is given, and none can be detected, exit and error. + const requestedPlatformName = options?.platform || autoPlatformName; + let platformName = isPlatform(requestedPlatformName) + ? requestedPlatformName + : undefined; + if (requestedPlatformName && !platformName) { + throw new CliError( + `Unsupported platform "${requestedPlatformName}". Supported platforms are "none", "drupal", and "wordpress".`, + ); + } + if (!platformName) { + platformName = await runPrompt({ + prompt: () => + select({ + message: 'Platform', + options: [...PLATFORM_CHOICES], + initialValue: DEFAULT_PLATFORM, + }), + nonInteractive: { + error: + 'Unable to determine which platform you are installing Emulsify within. Please specify a platform (such as "drupal" or "wordpress") by passing a -p or --platform flag with your init command.', + }, + accept: { when: acceptDefaults, value: DEFAULT_PLATFORM }, + }); + } - if (!platformName) { - throw new CliError( - 'Unable to determine which platform you are installing Emulsify within. Please specify a platform (such as "drupal" or "wordpress") by passing a -p or --platform flag with your init command.', - ); - } + if (!platformName) { + throw new CliError( + 'Unable to determine which platform you are installing Emulsify within. Please specify a platform (such as "drupal" or "wordpress") by passing a -p or --platform flag with your init command.', + ); + } - progress.tick(10, { - message: `using starter for ${platformName} as the selected platform, validating config`, - }); + // Choose a folder name. If no machineName is given, create one using the project name. + const machineName = + options?.machineName || strToMachineName(projectName, platformName); - // Choose a folder name. If no machineName is given, create one using the project name. - const machineName = - options?.machineName || strToMachineName(projectName, platformName); + // Collection information about the starter kit, such as the target directory, + // starter repository, and checkout version. + const starters = getAvailableStarters(); + const starter = starters.find((s) => s.platform === platformName); - // Collection information about the starter kit, such as the target directory, - // starter repository, and checkout version. - const starters = getAvailableStarters(); - const starter = starters.find((s) => s.platform === platformName); + const target = targetParent ? join(targetParent, machineName) : undefined; - const target = targetParent ? join(targetParent, machineName) : undefined; + const repository = options?.starter || starter?.repository; + const checkout = + options?.checkout || (options?.starter ? undefined : starter?.checkout); - const repository = options?.starter || starter?.repository; - const checkout = - options?.checkout || (options?.starter ? undefined : starter?.checkout); + if (!target) { + throw new CliError( + 'Unable to find a directory to put Emulsify in. Please specify a directory using the "path" argument: emulsify init myTheme ./themes', + ); + } - if (!target) { - throw new CliError( - 'Unable to find a directory to put Emulsify in. Please specify a directory using the "path" argument: emulsify init myTheme ./themes', - ); - } + if (!repository) { + throw new CliError( + `Unable to find an Emulsify starter for your project. Please specify one using the --starter flag: emulsify init myTheme --starter ${ + getAvailableStarters()[0].repository + }`, + ); + } - if (!repository) { - throw new CliError( - `Unable to find an Emulsify starter for your project. Please specify one using the --starter flag: emulsify init myTheme --starter ${ - getAvailableStarters()[0].repository - }`, - ); - } + if (existsSync(target)) { + throw new CliError(`The intended target is already occupied: ${target}`); + } - if (existsSync(target)) { + // Reserve the target atomically before cloning so rollback only ever + // removes a directory created by this command run. Git can clone into an + // existing empty directory. + try { + await fs.mkdir(target); + } catch (error) { + if (isAlreadyExistsError(error)) { throw new CliError(`The intended target is already occupied: ${target}`); } - // Reserve the target atomically before cloning so rollback only ever - // removes a directory created by this command run. Git can clone into an - // existing empty directory. - try { - await fs.mkdir(target); - } catch (error) { - if (isAlreadyExistsError(error)) { - throw new CliError( - `The intended target is already occupied: ${target}`, - ); - } - - throw new CliError( - `Unable to initialize project while creating the target directory "${target}": ${String(error)}`, - ); - } + throw new CliError( + `Unable to initialize project while creating the target directory "${target}": ${String(error)}`, + ); + } + + let phase: InitializationPhase = 'cloning the starter'; - let phase: InitializationPhase = 'cloning the starter'; + // The spinner only runs once every value is validated and the target is + // reserved, so no failure above this point can leave it spinning. + const progress = spinner(); - try { - progress.tick(10, { message: 'validation complete, cloning starter' }); + try { + progress.start(`Cloning ${repository}`); + + // Clone the Emulsify starter into the target directory, and checkout + // the correct tag/branch/commit. + await git.clone( + repository, + target, + checkout + ? { + '--branch': checkout, + } + : {}, + ); - // Clone the Emulsify starter into the target directory, and checkout - // the correct tag/branch/commit. - await git.clone( + // Preserve starter-provided settings while replacing the values that + // describe this concrete generated project. + phase = 'reading the starter project configuration'; + const configPath = join(target, EMULSIFY_PROJECT_CONFIG_FILE); + const starterConfig = + await loadJsonFile>(configPath); + + phase = 'writing the project configuration'; + await writeToJsonFile(configPath, { + ...starterConfig, + project: { + ...starterConfig?.project, + platform: platformName, + name: projectName, + machineName, + }, + starter: { + ...starterConfig?.starter, repository, - target, - checkout - ? { - '--branch': checkout, - } - : {}, - ); - - // Preserve starter-provided settings while replacing the values that - // describe this concrete generated project. - phase = 'reading the starter project configuration'; - const configPath = join(target, EMULSIFY_PROJECT_CONFIG_FILE); - const starterConfig = - await loadJsonFile>(configPath); - - phase = 'writing the project configuration'; - await writeToJsonFile(configPath, { - ...starterConfig, - project: { - ...starterConfig?.project, - platform: platformName, - name: projectName, - machineName, - }, - starter: { - ...starterConfig?.starter, - repository, - }, - }); - - progress.tick(30, { - message: - 'starter cloned, installing dependencies (this will take a moment)', - }); - - // Install project dependencies. - phase = 'installing dependencies'; - await installDependencies(target); - - progress.tick(40, { - message: 'dependencies installed, executing init script', - }); - - // Execute the init script, if one exists. - const initPath = join( - target, - EMULSIFY_PROJECT_HOOK_FOLDER, - EMULSIFY_PROJECT_HOOK_INIT, - ); - if (existsSync(initPath)) { - phase = 'executing the starter init hook'; - await executeScript(initPath); - } - - // Remove the .git directory, as this is a starter kit. This step - // should happen after dependencies are installed, and init scripts are - // executed, otherwise git-reliant dev deps in the starter may error out. - phase = 'removing the starter Git metadata'; - await fs.rm(join(target, '.git'), { recursive: true }); - } catch (e) { - throw await rollbackFailedInitialization(target, phase, e); + }, + }); + + progress.message('Installing dependencies'); + + // Install project dependencies. + phase = 'installing dependencies'; + await installDependencies(target); + + progress.message('Running the starter init hook'); + + // Execute the init script, if one exists. + const initPath = join( + target, + EMULSIFY_PROJECT_HOOK_FOLDER, + EMULSIFY_PROJECT_HOOK_INIT, + ); + if (existsSync(initPath)) { + phase = 'executing the starter init hook'; + await executeScript(initPath); } - // The filesystem transaction is complete. Keep display-only work outside - // the rollback boundary so a terminal/logging failure cannot delete a - // successfully initialized project. - progress.tick(10, { - message: 'init script executed, initialization complete', - }); + // Remove the .git directory, as this is a starter kit. This step + // should happen after dependencies are installed, and init scripts are + // executed, otherwise git-reliant dev deps in the starter may error out. + phase = 'removing the starter Git metadata'; + await fs.rm(join(target, '.git'), { recursive: true }); + } catch (e) { + // Stop the spinner before rollback so the failure is not reported beneath + // an indicator that is still spinning. + progress.error(`Could not finish ${phase}`); + throw await rollbackFailedInitialization(target, phase, e); + } - log('success', `Created an Emulsify project in ${target}.`); - getInitSuccessMessageForPlatform(platformName, target, { + // The filesystem transaction is complete. Keep display-only work outside + // the rollback boundary so a terminal/logging failure cannot delete a + // successfully initialized project. + progress.stop(`Created ${machineName} in ${target}`); + + for (const { method, message, title } of getInitSuccessMessageForPlatform( + platformName, + target, + { includeDrupalInstallReminder: isDetectedDrupalProject && platformName === 'drupal', - }).map(({ method, message }) => log(method, message)); - }; + }, + )) { + if (title) { + note(message, title); + continue; + } + + log(method, message); + } + + return `${machineName} is ready`; } diff --git a/src/handlers/systemCreate.test.ts b/src/handlers/systemCreate.test.ts index 4c3b1ff..f9f93c3 100644 --- a/src/handlers/systemCreate.test.ts +++ b/src/handlers/systemCreate.test.ts @@ -5,11 +5,44 @@ jest.mock('../lib/log', () => jest.fn()); jest.mock('../util/fs/writeToJsonFile', () => jest.fn()); jest.mock('../util/system/validateSystemConfig', () => jest.fn()); -jest.mock('@inquirer/prompts'); +jest.mock('@clack/prompts', () => { + const CANCEL_SYMBOL = Symbol.for('clack:cancel'); + + return { + __esModule: true, + CANCEL_SYMBOL, + isCancel: (value: unknown) => value === CANCEL_SYMBOL, + text: jest.fn(), + multiselect: jest.fn(), + confirm: jest.fn(), + note: jest.fn(), + intro: jest.fn(), + outro: jest.fn(), + cancel: jest.fn(), + spinner: jest.fn(() => ({ + start: jest.fn(), + stop: jest.fn(), + message: jest.fn(), + cancel: jest.fn(), + error: jest.fn(), + clear: jest.fn(), + isCancelled: false, + })), + log: { + message: jest.fn(), + info: jest.fn(), + success: jest.fn(), + step: jest.fn(), + warn: jest.fn(), + warning: jest.fn(), + error: jest.fn(), + }, + }; +}); import type { CreateSystemHandlerOptions } from '@emulsify-cli/handlers'; -import { checkbox, confirm, input } from '@inquirer/prompts'; +import { confirm, multiselect, text } from '@clack/prompts'; import { existsSync, promises as fs } from 'fs'; import { dirname, join, resolve } from 'path'; import { simpleGit } from 'simple-git'; @@ -24,8 +57,8 @@ import buildSystemScaffold, { import validateSystemConfig from '../util/system/validateSystemConfig.js'; import systemCreate, { normalizeSystemName } from './systemCreate.js'; -const inputMock = input as jest.Mock; -const checkboxMock = checkbox as jest.Mock; +const textMock = text as jest.Mock; +const multiselectMock = multiselect as jest.Mock; const confirmMock = confirm as jest.Mock; const existsSyncMock = existsSync as jest.Mock; const mkdirMock = fs.mkdir as jest.Mock; @@ -121,8 +154,8 @@ describe('systemCreate', () => { git: true, }); - expect(inputMock).not.toHaveBeenCalled(); - expect(checkboxMock).not.toHaveBeenCalled(); + expect(textMock).not.toHaveBeenCalled(); + expect(multiselectMock).not.toHaveBeenCalled(); expect(confirmMock).not.toHaveBeenCalled(); expect(existsSyncMock).toHaveBeenCalledTimes(1); expect(existsSyncMock).toHaveBeenCalledWith(target); @@ -162,39 +195,33 @@ describe('systemCreate', () => { expect(gitInitMock).toHaveBeenCalledWith(false, { '--initial-branch': 'main', }); - expect(logMock).toHaveBeenNthCalledWith( - 1, - 'success', - `Created the acme-system system in ${target}.`, - ); - expect(logMock).toHaveBeenNthCalledWith( - 2, + expect(logMock).toHaveBeenCalledWith( 'info', 'Git was initialized on branch main. Review the generated metadata, then commit the scaffold before installing it.', ); - expect(logMock).toHaveBeenCalledTimes(2); + expect(logMock).toHaveBeenCalledTimes(1); }); it('prompts for missing values in order and uses the selected values', async () => { setStdinIsTTY(true); - inputMock + textMock .mockResolvedValueOnce('Fancy_System') .mockResolvedValueOnce('/interactive-systems'); - checkboxMock.mockResolvedValueOnce(['drupal', 'wordpress']); + multiselectMock.mockResolvedValueOnce(['drupal', 'wordpress']); confirmMock.mockResolvedValueOnce(true); await systemCreate(undefined); - const namePrompt = inputMock.mock.calls[0][0]; - const directoryPrompt = inputMock.mock.calls[1][0]; - const platformPrompt = checkboxMock.mock.calls[0][0]; + const namePrompt = textMock.mock.calls[0][0]; + const directoryPrompt = textMock.mock.calls[1][0]; + const platformPrompt = multiselectMock.mock.calls[0][0]; - expect(inputMock).toHaveBeenCalledTimes(2); + expect(textMock).toHaveBeenCalledTimes(2); expect(namePrompt).toMatchObject({ - message: 'System name:', - default: 'custom-system', + message: 'System name', + defaultValue: 'custom-system', }); - expect(namePrompt.validate('ValidSystem')).toBe(true); + expect(namePrompt.validate('ValidSystem')).toBeUndefined(); expect(namePrompt.validate('!!')).toBe( 'System name must contain at least three letters or numbers. Pass the [name] positional argument or use --yes for the default.', ); @@ -206,43 +233,36 @@ describe('systemCreate', () => { }), ).toBe('unexpected validator failure'); expect(directoryPrompt).toMatchObject({ - message: 'Target directory:', - default: './', + message: 'Target directory', + defaultValue: './', }); - expect(directoryPrompt.validate('/tmp/systems')).toBe(true); + expect(directoryPrompt.validate('/tmp/systems')).toBeUndefined(); expect(directoryPrompt.validate(' ')).toBe( 'Target directory cannot be empty.', ); - expect(checkboxMock).toHaveBeenCalledTimes(1); + expect(multiselectMock).toHaveBeenCalledTimes(1); expect(platformPrompt).toMatchObject({ - message: 'Platform targets:', - choices: [ - { - name: 'Generic / no platform', - value: 'none', - checked: true, - }, - { name: 'Drupal', value: 'drupal' }, - { name: 'WordPress', value: 'wordpress' }, + message: 'Platform targets', + options: [ + { label: 'Generic / no platform', value: 'none' }, + { label: 'Drupal', value: 'drupal' }, + { label: 'WordPress', value: 'wordpress' }, ], + initialValues: ['none'], }); - expect(platformPrompt.validate(['drupal'])).toBe(true); - expect(platformPrompt.validate([])).toBe( - 'Select at least one platform target.', - ); expect(confirmMock).toHaveBeenCalledTimes(1); expect(confirmMock).toHaveBeenCalledWith({ message: 'Initialize a Git repository?', - default: true, + initialValue: true, }); - expect(inputMock.mock.invocationCallOrder[0]).toBeLessThan( - inputMock.mock.invocationCallOrder[1], + expect(textMock.mock.invocationCallOrder[0]).toBeLessThan( + textMock.mock.invocationCallOrder[1], ); - expect(inputMock.mock.invocationCallOrder[1]).toBeLessThan( - checkboxMock.mock.invocationCallOrder[0], + expect(textMock.mock.invocationCallOrder[1]).toBeLessThan( + multiselectMock.mock.invocationCallOrder[0], ); - expect(checkboxMock.mock.invocationCallOrder[0]).toBeLessThan( + expect(multiselectMock.mock.invocationCallOrder[0]).toBeLessThan( confirmMock.mock.invocationCallOrder[0], ); @@ -274,8 +294,8 @@ describe('systemCreate', () => { await systemCreate(undefined, { yes: true }); - expect(inputMock).not.toHaveBeenCalled(); - expect(checkboxMock).not.toHaveBeenCalled(); + expect(textMock).not.toHaveBeenCalled(); + expect(multiselectMock).not.toHaveBeenCalled(); expect(confirmMock).not.toHaveBeenCalled(); expect(existsSyncMock).toHaveBeenCalledWith(target); expect(writeToJsonFileMock).toHaveBeenCalledWith( @@ -356,8 +376,8 @@ describe('systemCreate', () => { exitCode: 1, }); - expect(inputMock).not.toHaveBeenCalled(); - expect(checkboxMock).not.toHaveBeenCalled(); + expect(textMock).not.toHaveBeenCalled(); + expect(multiselectMock).not.toHaveBeenCalled(); expect(confirmMock).not.toHaveBeenCalled(); expect(existsSyncMock).not.toHaveBeenCalled(); expect(validateSystemConfigMock).not.toHaveBeenCalled(); @@ -619,15 +639,13 @@ describe('systemCreate', () => { it('skips Git and only logs creation when --no-git is selected', async () => { const target = join(parentDirectory, 'acme-system'); - await systemCreate('acme-system', explicitOptions); + await expect(systemCreate('acme-system', explicitOptions)).resolves.toBe( + `Created the acme-system system in ${target}.`, + ); expect(simpleGitMock).not.toHaveBeenCalled(); expect(gitInitMock).not.toHaveBeenCalled(); - expect(logMock).toHaveBeenCalledTimes(1); - expect(logMock).toHaveBeenCalledWith( - 'success', - `Created the acme-system system in ${target}.`, - ); + expect(logMock).not.toHaveBeenCalled(); }); it('wraps Git initialization failure and does not log success', async () => { diff --git a/src/handlers/systemCreate.ts b/src/handlers/systemCreate.ts index e898cd2..bc9e3f6 100644 --- a/src/handlers/systemCreate.ts +++ b/src/handlers/systemCreate.ts @@ -2,7 +2,7 @@ import type { Platform, PlatformExpression } from '@emulsify-cli/config'; import type { CreateSystemHandlerOptions } from '@emulsify-cli/handlers'; import type { ErrorObject } from 'ajv'; -import { checkbox, confirm, input } from '@inquirer/prompts'; +import { confirm, multiselect, text } from '@clack/prompts'; import { existsSync, promises as fs } from 'fs'; import { dirname, join, resolve } from 'path'; import { simpleGit } from 'simple-git'; @@ -22,16 +22,11 @@ const DEFAULT_SYSTEM_NAME = 'custom-system'; const DEFAULT_TARGET_DIRECTORY = './'; const DEFAULT_PLATFORM: Platform = 'none'; -const PLATFORM_CHOICES: { name: string; value: Platform; checked?: boolean }[] = - [ - { - name: 'Generic / no platform', - value: 'none', - checked: true, - }, - { name: 'Drupal', value: 'drupal' }, - { name: 'WordPress', value: 'wordpress' }, - ]; +const PLATFORM_OPTIONS: { value: Platform; label: string }[] = [ + { value: 'none', label: 'Generic / no platform' }, + { value: 'drupal', label: 'Drupal' }, + { value: 'wordpress', label: 'WordPress' }, +]; /** * Convert a human-readable system name into its repository/config identity. @@ -51,10 +46,14 @@ export function normalizeSystemName(name: string): string { return machineName; } -function validatePromptedSystemName(name: string): true | string { +function validatePromptedSystemName( + name: string | undefined, +): string | undefined { try { - normalizeSystemName(name); - return true; + // An empty submission resolves to the prompt's default value, so the + // validator judges the same name the prompt will return. + normalizeSystemName(name || DEFAULT_SYSTEM_NAME); + return undefined; } catch (error) { return error instanceof Error ? error.message : String(error); } @@ -157,21 +156,24 @@ async function writeExclusiveSystemFile({ /** * Handler for `emulsify system create [name]`. + * + * @returns summary shown on the closing line of the session. */ export default async function systemCreate( name: string | void, options: CreateSystemHandlerOptions = {}, -): Promise { +): Promise { const acceptDefaults = options.yes === true; const dryRun = options.dryRun === true; let requestedName = name?.trim(); if (!requestedName) { - requestedName = await runPrompt({ + requestedName = await runPrompt({ prompt: () => - input({ - message: 'System name:', - default: DEFAULT_SYSTEM_NAME, + text({ + message: 'System name', + placeholder: DEFAULT_SYSTEM_NAME, + defaultValue: DEFAULT_SYSTEM_NAME, validate: validatePromptedSystemName, }), nonInteractive: { @@ -185,13 +187,18 @@ export default async function systemCreate( let targetParent = options.directory?.trim(); if (!targetParent) { - targetParent = await runPrompt({ + targetParent = await runPrompt({ prompt: () => - input({ - message: 'Target directory:', - default: DEFAULT_TARGET_DIRECTORY, + text({ + message: 'Target directory', + placeholder: DEFAULT_TARGET_DIRECTORY, + defaultValue: DEFAULT_TARGET_DIRECTORY, + // An empty submission resolves to the default directory, so only a + // value the person typed can be rejected as blank. validate: (value) => - value.trim().length > 0 || 'Target directory cannot be empty.', + !value || value.trim().length > 0 + ? undefined + : 'Target directory cannot be empty.', }), nonInteractive: { error: @@ -207,11 +214,11 @@ export default async function systemCreate( if (!platform) { const platforms = await runPrompt({ prompt: () => - checkbox({ - message: 'Platform targets:', - choices: PLATFORM_CHOICES, - validate: (values) => - values.length > 0 || 'Select at least one platform target.', + multiselect({ + message: 'Platform targets', + options: PLATFORM_OPTIONS, + initialValues: [DEFAULT_PLATFORM], + required: false, }), nonInteractive: { error: @@ -224,11 +231,11 @@ export default async function systemCreate( let initializeGit = options.git; if (initializeGit === undefined) { - initializeGit = await runPrompt({ + initializeGit = await runPrompt({ prompt: () => confirm({ message: 'Initialize a Git repository?', - default: true, + initialValue: true, }), nonInteractive: { error: @@ -281,7 +288,7 @@ export default async function systemCreate( initializeGit, plannedDestinations, ); - return; + return 'Dry run complete'; } try { @@ -318,11 +325,12 @@ export default async function systemCreate( ); } - log('success', `Created the ${systemName} system in ${target}.`); if (initializeGit) { log( 'info', 'Git was initialized on branch main. Review the generated metadata, then commit the scaffold before installing it.', ); } + + return `Created the ${systemName} system in ${target}.`; } diff --git a/src/handlers/systemDetach.test.ts b/src/handlers/systemDetach.test.ts index 4858db0..2c768ba 100644 --- a/src/handlers/systemDetach.test.ts +++ b/src/handlers/systemDetach.test.ts @@ -5,9 +5,20 @@ jest.mock('../lib/log', () => jest.fn()); jest.mock('../util/project/getEmulsifyConfig', () => jest.fn()); jest.mock('../util/project/unsetEmulsifyConfig', () => jest.fn()); -jest.mock('@inquirer/prompts'); +// The prompt library ships ESM only, so a factory keeps it out of the CommonJS +// test runtime while still exposing the cancellation helper prompts rely on. +jest.mock('@clack/prompts', () => { + const cancelSymbol = Symbol('clack:cancel'); + + return { + __esModule: true, + CANCEL_SYMBOL: cancelSymbol, + isCancel: (value: unknown) => value === cancelSymbol, + confirm: jest.fn(), + }; +}); -import { confirm } from '@inquirer/prompts'; +import { confirm } from '@clack/prompts'; import log from '../lib/log.js'; import getEmulsifyConfig from '../util/project/getEmulsifyConfig.js'; @@ -72,7 +83,7 @@ describe('systemDetach', () => { setStdinIsTTY(true); confirmMock.mockResolvedValueOnce(true); - await systemDetach(); + const summary = await systemDetach(); expect(confirmMock).toHaveBeenCalledTimes(1); expect(confirmMock).toHaveBeenCalledWith( @@ -82,8 +93,7 @@ describe('systemDetach', () => { ); expect(unsetEmulsifyConfigMock).toHaveBeenCalledTimes(1); expect(unsetEmulsifyConfigMock).toHaveBeenCalledWith('system', 'variant'); - expect(logMock).toHaveBeenCalledWith( - 'success', + expect(summary).toBe( 'Detached the fixture-system system at main. All component files were left in place.', ); expect(logMock).toHaveBeenCalledWith( @@ -95,12 +105,11 @@ describe('systemDetach', () => { it('uses --yes in a non-interactive terminal without opening a prompt', async () => { setStdinIsTTY(undefined); - await systemDetach({ yes: true }); + const summary = await systemDetach({ yes: true }); expect(confirmMock).not.toHaveBeenCalled(); expect(unsetEmulsifyConfigMock).toHaveBeenCalledWith('system', 'variant'); - expect(logMock).toHaveBeenCalledWith( - 'success', + expect(summary).toBe( 'Detached the fixture-system system at main. All component files were left in place.', ); }); @@ -139,15 +148,12 @@ describe('systemDetach', () => { setStdinIsTTY(true); confirmMock.mockResolvedValueOnce(false); - await systemDetach(); + await expect(systemDetach()).resolves.toBe( + 'System detach cancelled. No project files were changed.', + ); expect(confirmMock).toHaveBeenCalledTimes(1); expectNoConfigMutation(); - expect(logMock).toHaveBeenCalledTimes(1); - expect(logMock).toHaveBeenCalledWith( - 'info', - 'System detach cancelled. No project files were changed.', - ); }); it('fails before prompting or writing in a non-interactive terminal without --yes', async () => { @@ -193,10 +199,7 @@ describe('systemDetach', () => { }, }); - await systemDetach({ yes: true }); - - expect(logMock).toHaveBeenCalledWith( - 'success', + await expect(systemDetach({ yes: true })).resolves.toBe( 'Detached the configured component system at main. All component files were left in place.', ); }, diff --git a/src/handlers/systemDetach.ts b/src/handlers/systemDetach.ts index 01664ce..d814435 100644 --- a/src/handlers/systemDetach.ts +++ b/src/handlers/systemDetach.ts @@ -1,6 +1,6 @@ import type { DetachSystemHandlerOptions } from '@emulsify-cli/handlers'; -import { confirm } from '@inquirer/prompts'; +import { confirm } from '@clack/prompts'; import CliError from '../lib/CliError.js'; import log from '../lib/log.js'; @@ -10,6 +10,8 @@ import unsetEmulsifyConfig from '../util/project/unsetEmulsifyConfig.js'; import { runPrompt } from '../util/prompt/index.js'; const CONFIGURED_SYSTEM_LABEL = 'configured component system'; +const DECLINED_SUMMARY = + 'System detach cancelled. No project files were changed.'; function getSystemLabel(repository: string): string { try { @@ -22,10 +24,12 @@ function getSystemLabel(repository: string): string { /** * Detach the configured component system without changing component files. + * + * @returns summary shown on the closing line of the session. */ export default async function systemDetach({ yes = false, -}: DetachSystemHandlerOptions = {}): Promise { +}: DetachSystemHandlerOptions = {}): Promise { const projectConfig = await getEmulsifyConfig(); if (!projectConfig) { throw new CliError( @@ -41,11 +45,11 @@ export default async function systemDetach({ const systemLabel = getSystemLabel(projectConfig.system.repository); const systemReference = `${systemLabel} at ${projectConfig.system.checkout}`; - const confirmed = await runPrompt({ + const confirmed = await runPrompt({ prompt: () => confirm({ message: `Detach the ${systemReference} from this project? Component files will be left in place.`, - default: false, + initialValue: false, }), nonInteractive: { error: @@ -55,8 +59,7 @@ export default async function systemDetach({ }); if (!confirmed) { - log('info', 'System detach cancelled. No project files were changed.'); - return; + return DECLINED_SUMMARY; } try { @@ -67,12 +70,10 @@ export default async function systemDetach({ ); } - log( - 'success', - `Detached the ${systemReference}. All component files were left in place.`, - ); log( 'info', 'Next: run "emulsify system create" to scaffold your own system repository, then replace its example content with the components preserved in this project.', ); + + return `Detached the ${systemReference}. All component files were left in place.`; } diff --git a/src/handlers/systemInstall.test.ts b/src/handlers/systemInstall.test.ts index ef91307..3159a67 100644 --- a/src/handlers/systemInstall.test.ts +++ b/src/handlers/systemInstall.test.ts @@ -18,14 +18,27 @@ jest.mock('../util/project/setEmulsifyConfig', () => jest.fn()); jest.mock('../util/project/getEmulsifyConfig', () => jest.fn()); jest.mock('../util/fs/findFileInCurrentPath', () => jest.fn()); jest.mock('../util/fs/executeScript', () => jest.fn()); -jest.mock('@inquirer/prompts'); +jest.mock('../lib/session', () => ({ + __esModule: true, + note: jest.fn(), +})); +// The prompt library ships ESM only, so a factory keeps it out of the CommonJS +// test runtime while still exposing the cancellation helper prompts rely on. +jest.mock('@clack/prompts', () => ({ + __esModule: true, + confirm: jest.fn(), + select: jest.fn(), + text: jest.fn(), + isCancel: (value: unknown) => typeof value === 'symbol', +})); import fs from 'fs'; import { join, resolve } from 'path'; import { pathToFileURL } from 'url'; import type { EmulsifySystem, EmulsifyVariant } from '@emulsify-cli/config'; -import { confirm, input, select, Separator } from '@inquirer/prompts'; +import { confirm, select, text } from '@clack/prompts'; import log from '../lib/log.js'; +import { note } from '../lib/session.js'; import { EMULSIFY_PROJECT_CONFIG_FILE, EMULSIFY_PROJECT_HOOK_FOLDER, @@ -62,10 +75,10 @@ const getEmulsifyConfigMock = getEmulsifyConfig as jest.Mock; const findFileInCurrentPathMock = findFileInCurrentPath as jest.Mock; const executeScriptMock = executeScript as jest.Mock; const existsSyncMock = fs.existsSync as jest.Mock; -const confirmMock = confirm as jest.Mock; -const inputMock = input as jest.Mock; -const selectMock = select as jest.Mock; -const separatorMock = Separator as unknown as jest.Mock; +const noteMock = note as jest.Mock; +const confirmMock = confirm as unknown as jest.Mock; +const textMock = text as unknown as jest.Mock; +const selectMock = select as unknown as jest.Mock; const originalStdinIsTTY = process.stdin.isTTY; const projectRoot = resolve('/project'); const projectConfigPath = join(projectRoot, EMULSIFY_PROJECT_CONFIG_FILE); @@ -151,14 +164,8 @@ const builtInSource = { const customSource = { kind: 'custom' as const }; const cancelSource = { kind: 'cancel' as const }; -function wizardHeader(step: number, total?: number): string { - return `${'Install a component system'.padEnd(60)}${ - total ? `Step ${step} of ${total}` : `Step ${step}` - }`; -} - -function formatChoice(label: string, description: string): string { - return `${label.padEnd(22)}${description}`; +function wizardStep(step: number, total?: number): string { + return total ? `Step ${step} of ${total}` : `Step ${step}`; } function queueBuiltInWizard({ @@ -356,7 +363,7 @@ describe('systemInstall', () => { beforeEach(() => { jest.clearAllMocks(); confirmMock.mockReset(); - inputMock.mockReset(); + textMock.mockReset(); selectMock.mockReset(); setStdinIsTTY(false); // The handler clones systems through a higher-order cache helper. @@ -402,53 +409,44 @@ describe('systemInstall', () => { ); }); - it('renders the step-one catalog, separator, and Cancel choice', async () => { + it('renders the step-one catalog and Cancel choice', async () => { setStdinIsTTY(true); selectMock.mockResolvedValueOnce(cancelSource); - await systemInstall(undefined, {}); + await expect(systemInstall(undefined, {})).resolves.toBe( + 'System install cancelled.', + ); expect(selectMock).toHaveBeenCalledTimes(1); expect(selectMock).toHaveBeenCalledWith({ message: 'Which system?', - choices: [ + options: [ { - name: formatChoice( - 'Compound', - 'Accessible, tested components. Drupal, WordPress, plain.', - ), value: builtInSource, - short: 'Compound', + label: 'Compound', + hint: 'Accessible, tested components. Drupal, WordPress, plain.', }, { - name: formatChoice( - 'Emulsify UI Kit', - 'Broader design-system starter kit.', - ), value: { kind: 'built-in', reference: availableSystems[1], }, - short: 'Emulsify UI Kit', + label: 'Emulsify UI Kit', + hint: 'Broader design-system starter kit.', }, { - name: formatChoice( - 'Bring your own', - 'Install from a git repository you control.', - ), value: customSource, - short: 'Bring your own', + label: 'Bring your own', + hint: 'Install from a git repository you control.', }, - expect.any(Separator), { - name: 'Cancel', value: cancelSource, + label: 'Cancel', + hint: 'Leave this project unchanged.', }, ], }); - expect(separatorMock).toHaveBeenCalledWith('────────────'); - expect(logMock).toHaveBeenCalledWith('info', wizardHeader(1)); - expect(logMock).toHaveBeenCalledWith('info', 'System install cancelled.'); + expect(logMock).toHaveBeenCalledWith('info', wizardStep(1)); expect(cloneIntoCacheMock).not.toHaveBeenCalled(); expect(setEmulsifyConfigMock).not.toHaveBeenCalled(); }); @@ -478,28 +476,30 @@ describe('systemInstall', () => { setStdinIsTTY(true); queueBuiltInWizard(); - await systemInstall(undefined, {}); + await expect(systemInstall(undefined, {})).resolves.toBe( + 'Compound installed \u00b7 drupal component set', + ); expect(selectMock).toHaveBeenCalledTimes(3); expect(selectMock).toHaveBeenNthCalledWith(3, { message: 'How much do you want to install?', - choices: [ + options: [ { - name: formatChoice('Essentials only', '1 required component'), value: false, - short: 'Essentials only', + label: 'Essentials only', + hint: '1 required component', }, { - name: formatChoice('Everything', '2 components'), value: true, - short: 'Everything', + label: 'Everything', + hint: '2 components', }, ], - default: false, + initialValue: false, }); expect(confirmMock).toHaveBeenCalledWith({ message: 'Install now?', - default: true, + initialValue: true, }); expect(cloneIntoCacheMock).toHaveBeenCalledWith('systems', ['compound'], { refresh: true, @@ -529,42 +529,35 @@ describe('systemInstall', () => { 'info', 'Loading Compound from github.com/emulsify-ds/compound. This may take a moment…', ); - expect(logMock).toHaveBeenCalledWith('info', 'Loaded Compound · v1.0.0.'); - expect(logMock).toHaveBeenCalledWith('info', wizardHeader(1)); - expect(logMock).toHaveBeenCalledWith('info', wizardHeader(2, 4)); - expect(logMock).toHaveBeenCalledWith('info', wizardHeader(3, 4)); - expect(logMock).toHaveBeenCalledWith('info', wizardHeader(4, 4)); - expect(logMock).toHaveBeenCalledWith( - 'info', - `\n System Compound · v1.0.0 + expect(logMock).toHaveBeenCalledWith('info', wizardStep(1)); + expect(logMock).toHaveBeenCalledWith('info', wizardStep(2, 4)); + expect(logMock).toHaveBeenCalledWith('info', wizardStep(3, 4)); + expect(logMock).toHaveBeenCalledWith('info', wizardStep(4, 4)); + expect(noteMock).toHaveBeenCalledWith( + ` System Compound · v1.0.0 Source github.com/emulsify-ds/compound Component set Drupal Scope Essentials only Will install 1 component → components/00-base/ 1 asset folder → assets/fonts/ - 1 asset file → styles/tokens.css\n`, + 1 asset file → styles/tokens.css`, + 'Review', ); expect(confirmMock.mock.invocationCallOrder[0]).toBeLessThan( setEmulsifyConfigMock.mock.invocationCallOrder[0], ); - expect(logMock).toHaveBeenCalledWith( - 'success', - 'Successfully installed the Compound system using the Drupal component set.', - ); }); it('declines the final review without mutating project files', async () => { setStdinIsTTY(true); queueBuiltInWizard({ confirmed: false }); - await systemInstall(undefined, {}); + await expect(systemInstall(undefined, {})).resolves.toBe( + 'System install cancelled. No project files were changed.', + ); expect(cloneIntoCacheMock).toHaveBeenCalled(); expect(getJsonFromCachedFileMock).toHaveBeenCalled(); - expect(logMock).toHaveBeenCalledWith( - 'info', - 'System install cancelled. No project files were changed.', - ); expect(setEmulsifyConfigMock).not.toHaveBeenCalled(); expect(installComponentFromCacheMock).not.toHaveBeenCalled(); expect(installGeneralAssetsFromCacheMock).not.toHaveBeenCalled(); @@ -578,7 +571,8 @@ describe('systemInstall', () => { await systemInstall(undefined, { yes: true }); expect(confirmMock).not.toHaveBeenCalled(); - expect(logMock).toHaveBeenCalledWith('info', wizardHeader(4, 4)); + expect(logMock).toHaveBeenCalledWith('info', wizardStep(4, 4)); + expect(noteMock).toHaveBeenCalledWith(expect.any(String), 'Review'); expect(setEmulsifyConfigMock).toHaveBeenCalled(); expect(installComponentFromCacheMock).toHaveBeenCalledWith( system, @@ -601,34 +595,36 @@ describe('systemInstall', () => { .mockResolvedValueOnce(customSource) .mockResolvedValueOnce(0) .mockResolvedValueOnce(true); - inputMock + textMock .mockResolvedValueOnce(` ${repository} `) .mockResolvedValueOnce(' release '); confirmMock.mockResolvedValueOnce(true); - await systemInstall(undefined, {}); + await expect(systemInstall(undefined, {})).resolves.toBe( + 'Custom System installed \u00b7 drupal component set', + ); - expect(inputMock).toHaveBeenNthCalledWith(1, { - message: 'Repository URL or local path:', + expect(textMock).toHaveBeenNthCalledWith(1, { + message: 'Repository URL or local path', validate: expect.any(Function), }); - expect(inputMock).toHaveBeenNthCalledWith(2, { - message: 'Checkout (branch, tag, or commit):', + expect(textMock).toHaveBeenNthCalledWith(2, { + message: 'Checkout (branch, tag, or commit)', validate: expect.any(Function), }); - const repositoryValidator = inputMock.mock.calls[0][0].validate; + const repositoryValidator = textMock.mock.calls[0][0].validate; expect( repositoryValidator('https://github.com/example/custom-system.git'), - ).toBe(true); + ).toBeUndefined(); expect( repositoryValidator('https://github.com/example/custom-system'), ).toBe('The repository URL must end in .git.'); expect(repositoryValidator('https://github.com/example/.git')).toBe( 'Enter a Git repository with a recognizable name.', ); - const checkoutValidator = inputMock.mock.calls[1][0].validate; + const checkoutValidator = textMock.mock.calls[1][0].validate; expect(checkoutValidator(' ')).toBe('Enter a branch, tag, or commit.'); - expect(checkoutValidator('main')).toBe(true); + expect(checkoutValidator('main')).toBeUndefined(); expect(cloneIntoCacheMock).toHaveBeenCalledWith( 'systems', ['custom-system'], @@ -646,27 +642,23 @@ describe('systemInstall', () => { 'card', true, ); - expect(logMock).toHaveBeenCalledWith('info', wizardHeader(1)); - expect(logMock).toHaveBeenCalledWith('info', wizardHeader(2, 6)); - expect(logMock).toHaveBeenCalledWith('info', wizardHeader(3, 6)); - expect(logMock).toHaveBeenCalledWith('info', wizardHeader(4, 6)); - expect(logMock).toHaveBeenCalledWith('info', wizardHeader(5, 6)); - expect(logMock).toHaveBeenCalledWith('info', wizardHeader(6, 6)); + expect(logMock).toHaveBeenCalledWith('info', wizardStep(1)); + expect(logMock).toHaveBeenCalledWith('info', wizardStep(2, 6)); + expect(logMock).toHaveBeenCalledWith('info', wizardStep(3, 6)); + expect(logMock).toHaveBeenCalledWith('info', wizardStep(4, 6)); + expect(logMock).toHaveBeenCalledWith('info', wizardStep(5, 6)); + expect(logMock).toHaveBeenCalledWith('info', wizardStep(6, 6)); expect(logMock).toHaveBeenCalledWith( 'info', 'Loading the component system from github.com/example/custom-system. This may take a moment…', ); - expect(logMock).toHaveBeenCalledWith( - 'info', - 'Loaded Custom System · release.', - ); - expect(logMock).toHaveBeenCalledWith( - 'info', - `\n System Custom System · release + expect(noteMock).toHaveBeenCalledWith( + ` System Custom System · release Source github.com/example/custom-system Component set Drupal Scope Everything - Will install 2 components → components/00-base/\n`, + Will install 2 components → components/00-base/`, + 'Review', ); }); @@ -678,7 +670,7 @@ describe('systemInstall', () => { }); setStdinIsTTY(true); selectMock.mockResolvedValueOnce(customSource); - inputMock.mockResolvedValueOnce(repository).mockResolvedValueOnce('main'); + textMock.mockResolvedValueOnce(repository).mockResolvedValueOnce('main'); await expect(systemInstall(undefined, {})).rejects.toThrow( 'The repository was cached as "custom-system", but system.emulsify.json declares the system name "declared-system". These names must match so files can be installed safely. Rename the repository or update the system name, then retry.', @@ -725,29 +717,29 @@ describe('systemInstall', () => { expect(selectMock).toHaveBeenNthCalledWith(2, { message: 'Which component set?', - choices: [ + options: [ { - name: 'Drupal (drupal) — Recommended · 2 components, 1 required component', value: 2, - short: 'Drupal', + label: 'Drupal (drupal)', + hint: 'Recommended · 2 components, 1 required component', }, { - name: 'Drupal and WordPress (drupal || wordpress) · 2 components, 1 required component', value: 1, - short: 'Drupal and WordPress', + label: 'Drupal and WordPress (drupal || wordpress)', + hint: '2 components, 1 required component', }, { - name: 'Platform-neutral (none) · 2 components, 1 required component', value: 0, - short: 'Platform-neutral', + label: 'Platform-neutral (none)', + hint: '2 components, 1 required component', }, { - name: 'WordPress (wordpress) · 2 components, 1 required component', value: 3, - short: 'WordPress', + label: 'WordPress (wordpress)', + hint: '2 components, 1 required component', }, ], - default: 2, + initialValue: 2, }); expect(setEmulsifyConfigMock).not.toHaveBeenCalled(); }); @@ -813,14 +805,14 @@ describe('systemInstall', () => { 'A custom repository is required in non-interactive mode. Pass --repository .', ); - expect(inputMock).not.toHaveBeenCalled(); + expect(textMock).not.toHaveBeenCalled(); expect(cloneIntoCacheMock).not.toHaveBeenCalled(); }); it('rejects the custom checkout prompt after stdin stops being interactive', async () => { setStdinIsTTY(true); selectMock.mockResolvedValueOnce(customSource); - inputMock.mockImplementationOnce(async () => { + textMock.mockImplementationOnce(async () => { setStdinIsTTY(false); return 'https://github.com/example/custom-system.git'; }); @@ -829,7 +821,7 @@ describe('systemInstall', () => { 'A custom checkout is required in non-interactive mode. Pass --checkout .', ); - expect(inputMock).toHaveBeenCalledTimes(1); + expect(textMock).toHaveBeenCalledTimes(1); expect(cloneIntoCacheMock).not.toHaveBeenCalled(); }); @@ -929,7 +921,7 @@ describe('systemInstall', () => { expect(selectMock).toHaveBeenCalledTimes(1); expect(confirmMock).not.toHaveBeenCalled(); - expect(logMock).toHaveBeenCalledWith('info', wizardHeader(2, 2)); + expect(logMock).toHaveBeenCalledWith('info', wizardStep(2, 2)); expect(installComponentFromCacheMock).toHaveBeenCalledTimes(2); expect(setEmulsifyConfigMock).toHaveBeenCalled(); }); @@ -1046,7 +1038,9 @@ describe('systemInstall', () => { }); it('installs the required components and global assets on the happy path', async () => { - await systemInstall('compound', {}); + await expect(systemInstall('compound', {})).resolves.toBe( + 'Compound installed \u00b7 drupal component set', + ); expect(setEmulsifyConfigMock).toHaveBeenCalledWith({ system: { @@ -1069,10 +1063,6 @@ describe('systemInstall', () => { variant, ); expect(executeScriptMock).toHaveBeenCalledWith(systemInstallHookPath); - expect(logMock).toHaveBeenCalledWith( - 'success', - 'Successfully installed the compound system using the drupal variant.', - ); }); it('installs optional dependencies of required components', async () => { @@ -1125,11 +1115,17 @@ describe('systemInstall', () => { }); it('uses an explicit variant on the happy path', async () => { - await systemInstall('compound', { variant: 'drupal' }); + await expect( + systemInstall('compound', { variant: 'drupal' }), + ).resolves.toBe('Compound installed \u00b7 drupal component set'); - expect(logMock).toHaveBeenCalledWith( - 'success', - 'Successfully installed the compound system using the drupal variant.', + expect(setEmulsifyConfigMock).toHaveBeenCalledWith( + expect.objectContaining({ + variant: { + platform: 'drupal', + structureImplementations: variant.structureImplementations, + }, + }), ); }); @@ -1209,7 +1205,9 @@ describe('systemInstall', () => { variants: [expressionVariant], }); - await systemInstall('compound', {}); + await expect(systemInstall('compound', {})).resolves.toBe( + 'Compound installed \u00b7 drupal || wordpress component set', + ); expect(setEmulsifyConfigMock).toHaveBeenCalledWith({ system: { @@ -1221,10 +1219,6 @@ describe('systemInstall', () => { structureImplementations: variant.structureImplementations, }, }); - expect(logMock).toHaveBeenCalledWith( - 'success', - 'Successfully installed the compound system using the drupal || wordpress variant.', - ); }); it('installs a shared system variant for a wordpress project', async () => { @@ -1401,19 +1395,19 @@ describe('systemInstall', () => { expect(selectMock).toHaveBeenCalledWith({ message: 'Which Compound component set should be used?', - choices: [ + options: [ { - name: 'Drupal (drupal) — Recommended · 2 components, 1 required component', value: 0, - short: 'Drupal', + label: 'Drupal (drupal)', + hint: 'Recommended · 2 components, 1 required component', }, { - name: 'WordPress (wordpress) — Recommended · 2 components, 1 required component', value: 1, - short: 'WordPress', + label: 'WordPress (wordpress)', + hint: 'Recommended · 2 components, 1 required component', }, ], - default: 0, + initialValue: 0, }); expect(setEmulsifyConfigMock).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/src/handlers/systemInstall.ts b/src/handlers/systemInstall.ts index 29ac0f7..827a2d4 100644 --- a/src/handlers/systemInstall.ts +++ b/src/handlers/systemInstall.ts @@ -9,7 +9,7 @@ import type { EmulsifySystemReference } from '@emulsify-cli/internal'; import { dirname, join } from 'path'; import { existsSync } from 'fs'; -import { confirm, input, select, Separator } from '@inquirer/prompts'; +import { confirm, select, text } from '@clack/prompts'; import { EMULSIFY_PROJECT_CONFIG_FILE, EMULSIFY_SYSTEM_CONFIG_FILE, @@ -18,6 +18,7 @@ import { } from '../lib/constants.js'; import log from '../lib/log.js'; import CliError from '../lib/CliError.js'; +import { note } from '../lib/session.js'; import getAvailableSystems from '../util/system/getAvailableSystems.js'; import getGitRepoNameFromUrl from '../util/getGitRepoNameFromUrl.js'; import cloneIntoCache from '../util/cache/cloneIntoCache.js'; @@ -49,7 +50,9 @@ const MISSING_SYSTEM_SOURCE_ERROR = 'No component system source was provided. Pass a built-in system name as the positional argument, or pass both --repository and --checkout .'; const INVALID_SYSTEM_SOURCE_ERROR = 'Unable to resolve the requested component system source. Pass a valid built-in system name as the positional argument, or pass both --repository and --checkout .'; -const WIZARD_TITLE = 'Install a component system'; +const CANCELLED_SUMMARY = 'System install cancelled.'; +const DECLINED_SUMMARY = + 'System install cancelled. No project files were changed.'; type BuiltInSourceChoice = { kind: 'built-in'; @@ -67,17 +70,12 @@ type CancelSourceChoice = { type SystemSourceChoice = BuiltInSourceChoice | CustomSourceChoice | CancelSourceChoice; -function formatWizardHeader(step: number, total?: number): string { - const progress = total ? `Step ${step} of ${total}` : `Step ${step}`; - return `${WIZARD_TITLE.padEnd(60)}${progress}`; +function formatWizardStep(step: number, total?: number): string { + return total ? `Step ${step} of ${total}` : `Step ${step}`; } function showWizardStep(step: number, total?: number): void { - log('info', formatWizardHeader(step, total)); -} - -function formatChoice(label: string, description: string): string { - return `${label.padEnd(22)}${description}`; + log('info', formatWizardStep(step, total)); } function pluralize(count: number, singular: string, plural = `${singular}s`) { @@ -131,10 +129,12 @@ function formatRepositorySource(repository: string): string { return repository.replace(/\.git\/?$/, ''); } -function validateRepositoryInput(repository: string): true | string { +function validateRepositoryInput( + repository: string | undefined, +): string | undefined { try { - return getGitRepoNameFromUrl(repository.trim()) - ? true + return getGitRepoNameFromUrl((repository || '').trim()) + ? undefined : 'Enter a Git repository with a recognizable name.'; } catch (error) { return error instanceof Error ? error.message : String(error); @@ -210,24 +210,21 @@ async function promptForSystemInstallChoice(): Promise< showWizardStep(1); return select({ message: 'Which system?', - choices: [ + options: [ ...availableSystems.map((reference) => ({ - name: formatChoice(reference.label, reference.description), value: { kind: 'built-in', reference } as BuiltInSourceChoice, - short: reference.label, + label: reference.label, + hint: reference.description, })), { - name: formatChoice( - 'Bring your own', - 'Install from a git repository you control.', - ), value: { kind: 'custom' } as CustomSourceChoice, - short: 'Bring your own', + label: 'Bring your own', + hint: 'Install from a git repository you control.', }, - new Separator('────────────'), { - name: 'Cancel', value: { kind: 'cancel' } as CancelSourceChoice, + label: 'Cancel', + hint: 'Leave this project unchanged.', }, ], }); @@ -236,7 +233,6 @@ async function promptForSystemInstallChoice(): Promise< }); if (selectedSource.kind === 'cancel') { - log('info', 'System install cancelled.'); return; } @@ -247,11 +243,11 @@ async function promptForCustomRepository( step: number, total: number, ): Promise { - return runPrompt({ + return runPrompt({ prompt: () => { showWizardStep(step, total); - return input({ - message: 'Repository URL or local path:', + return text({ + message: 'Repository URL or local path', validate: validateRepositoryInput, }); }, @@ -266,13 +262,15 @@ async function promptForCustomCheckout( step: number, total: number, ): Promise { - return runPrompt({ + return runPrompt({ prompt: () => { showWizardStep(step, total); - return input({ - message: 'Checkout (branch, tag, or commit):', + return text({ + message: 'Checkout (branch, tag, or commit)', validate: (value) => - value.trim().length > 0 || 'Enter a branch, tag, or commit.', + (value || '').trim().length > 0 + ? undefined + : 'Enter a branch, tag, or commit.', }); }, nonInteractive: { @@ -336,7 +334,7 @@ async function promptForVariantChoice( recommendedVariants.has(variant), )?.index; - const selectedIndex = await runPrompt({ + const selectedIndex = await runPrompt({ prompt: () => { if (wizardStep) { showWizardStep(wizardStep.step, wizardStep.total); @@ -345,23 +343,23 @@ async function promptForVariantChoice( message: wizardStep ? 'Which component set?' : `Which ${formatSystemLabel(systemName)} component set should be used?`, - choices: rankedVariants.map(({ variant, index }) => { + options: rankedVariants.map(({ variant, index }) => { const componentCount = variant.components.length; const requiredCount = variant.components.filter( ({ required }) => required === true, ).length; - const counts = ` · ${pluralize(componentCount, 'component')}, ${pluralize(requiredCount, 'required component')}`; + const counts = `${pluralize(componentCount, 'component')}, ${pluralize(requiredCount, 'required component')}`; const recommended = recommendedVariants.has(variant) - ? ' — Recommended' + ? 'Recommended · ' : ''; return { - name: `${formatPlatformLabel(variant.platform)} (${variant.platform})${recommended}${counts}`, value: index, - short: formatPlatformLabel(variant.platform), + label: `${formatPlatformLabel(variant.platform)} (${variant.platform})`, + hint: `${recommended}${counts}`, }; }), - default: defaultChoice, + initialValue: defaultChoice, }); }, nonInteractive: { error: nonInteractiveError }, @@ -432,30 +430,24 @@ async function promptForInstallScope( ({ required }) => required === true, ).length; - return runPrompt({ + return runPrompt({ prompt: () => { showWizardStep(step, total); return select({ message: 'How much do you want to install?', - choices: [ + options: [ { - name: formatChoice( - 'Essentials only', - pluralize(requiredComponentCount, 'required component'), - ), value: false, - short: 'Essentials only', + label: 'Essentials only', + hint: pluralize(requiredComponentCount, 'required component'), }, { - name: formatChoice( - 'Everything', - pluralize(variant.components.length, 'component'), - ), value: true, - short: 'Everything', + label: 'Everything', + hint: pluralize(variant.components.length, 'component'), }, ], - default: false, + initialValue: false, }); }, nonInteractive: { @@ -521,13 +513,13 @@ async function promptForInstallConfirmation( accept: boolean, ): Promise { showWizardStep(step, total); - log('info', `\n${review}\n`); + note(review, 'Review'); - return runPrompt({ + return runPrompt({ prompt: () => confirm({ message: 'Install now?', - default: true, + initialValue: true, }), nonInteractive: { error: @@ -548,12 +540,14 @@ async function promptForInstallConfirmation( * @param options.repository optional string containing a git URL to a repository containing the system that should be installed. * @param options.checkout optional string containing the commit/branch/tag of the system that should be used. * + * @returns string summarizing the outcome for the session's closing line. + * * @throws {CliError} if the project cannot install the requested system. */ export default async function systemInstall( name: string | void, options: InstallSystemHandlerOptions, -): Promise { +): Promise { // Attempt to load emulsify config. If none is found, this is not an Emulsify project. const projectConfig = await getEmulsifyConfig(); if (!projectConfig) { @@ -577,7 +571,7 @@ export default async function systemInstall( if (guidedInstall) { const source = await promptForSystemInstallChoice(); if (!source) { - return; + return CANCELLED_SUMMARY; } if (source.kind === 'built-in') { @@ -681,9 +675,6 @@ export default async function systemInstall( } systemLabel ||= formatSystemLabel(systemConf.name); - if (guidedInstall) { - log('info', `Loaded ${systemLabel} · ${repo.checkout}.`); - } const projectPlatform = projectConfig.project.platform; if (!isPlatform(projectPlatform)) { @@ -757,8 +748,7 @@ export default async function systemInstall( options.yes === true, ); if (!confirmed) { - log('info', 'System install cancelled. No project files were changed.'); - return; + return DECLINED_SUMMARY; } } @@ -814,10 +804,7 @@ export default async function systemInstall( ); } - return log( - 'success', - guidedInstall - ? `Successfully installed the ${systemLabel} system using the ${formatPlatformLabel(variantConf.platform)} component set.` - : `Successfully installed the ${systemConf.name} system using the ${variantConf.platform} variant.`, - ); + // Name the component set that was installed: a system can offer several, and + // which one landed is the thing a person needs to know afterwards. + return `${systemLabel} installed · ${variantConf.platform} component set`; } diff --git a/src/handlers/systemList.test.ts b/src/handlers/systemList.test.ts index 9991721..477dc18 100644 --- a/src/handlers/systemList.test.ts +++ b/src/handlers/systemList.test.ts @@ -1,37 +1,66 @@ jest.mock('../lib/log', () => jest.fn()); +jest.mock('../lib/session', () => ({ note: jest.fn() })); import log from '../lib/log.js'; -import systemList from './systemList.js'; +import { note } from '../lib/session.js'; +import systemList, { formatSystemList } from './systemList.js'; import getAvailableSystems from '../util/system/getAvailableSystems.js'; const logMock = log as jest.Mock; +const noteMock = note as jest.Mock; describe('systemList', () => { beforeEach(() => { - logMock.mockClear(); + jest.clearAllMocks(); }); it('can list all available out-of-the-box systems', async () => { - expect.assertions(5); - const systems = await getAvailableSystems(); + expect.assertions(4); await systemList(); - const loggedMessages = logMock.mock.calls.map(([, message]) => message); - expect(logMock).toHaveBeenCalledTimes(systems.length); - expect(logMock).toHaveBeenNthCalledWith( - 1, - 'info', - 'compound - https://github.com/emulsify-ds/compound.git', + expect(noteMock).toHaveBeenCalledTimes(1); + const [body, title] = noteMock.mock.calls[0]; + + expect(title).toBe('Built-in systems'); + expect(body).toContain( + 'compound https://github.com/emulsify-ds/compound.git', ); - expect(logMock).toHaveBeenNthCalledWith( - 2, - 'info', - 'emulsify-ui-kit - https://github.com/emulsify-ds/emulsify-ui-kit.git', + expect(body).toContain( + 'emulsify-ui-kit https://github.com/emulsify-ds/emulsify-ui-kit.git', ); - expect(loggedMessages).toEqual([ - 'compound - https://github.com/emulsify-ds/compound.git', - 'emulsify-ui-kit - https://github.com/emulsify-ds/emulsify-ui-kit.git', - ]); - expect(new Set(loggedMessages).size).toBe(loggedMessages.length); + }); + + it('summarizes how many systems are available and what to do next', async () => { + const systems = await getAvailableSystems(); + + await expect(systemList()).resolves.toBe( + `${systems.length} systems \u00b7 install one with emulsify system install`, + ); + }); + + it('does not write one loose line per system', async () => { + await systemList(); + + expect(logMock).not.toHaveBeenCalled(); + }); +}); + +describe('formatSystemList', () => { + it('aligns repositories into a column', () => { + expect( + formatSystemList([ + { name: 'compound', repository: 'https://example.com/compound.git' }, + { name: 'ui', repository: 'https://example.com/ui.git' }, + ]), + ).toBe( + [ + 'compound https://example.com/compound.git', + 'ui https://example.com/ui.git', + ].join('\n'), + ); + }); + + it('reports an empty catalog plainly', () => { + expect(formatSystemList([])).toBe('No built-in systems are available.'); }); }); diff --git a/src/handlers/systemList.ts b/src/handlers/systemList.ts index 2a1f781..f09631c 100644 --- a/src/handlers/systemList.ts +++ b/src/handlers/systemList.ts @@ -1,11 +1,38 @@ -import log from '../lib/log.js'; +import { note } from '../lib/session.js'; import getAvailableSystems from '../util/system/getAvailableSystems.js'; +type AvailableSystem = { + name: string; + repository: string; +}; + +/** + * Render the built-in systems as an aligned block. + * + * @param systems systems available for installation. + * @returns a multi-line block with one system per line. + */ +export function formatSystemList(systems: AvailableSystem[]): string { + if (systems.length === 0) { + return 'No built-in systems are available.'; + } + + const nameWidth = Math.max(...systems.map(({ name }) => name.length)); + + return systems + .map(({ name, repository }) => `${name.padEnd(nameWidth)} ${repository}`) + .join('\n'); +} + /** * Handler for the `system list` command. + * + * @returns a short summary of how many systems are available. */ -export default async function systemList(): Promise { - (await getAvailableSystems()).map(({ name, repository }) => - log('info', `${name} - ${repository}`), - ); +export default async function systemList(): Promise { + const systems = await getAvailableSystems(); + + note(formatSystemList(systems), 'Built-in systems'); + + return `${systems.length} system${systems.length === 1 ? '' : 's'} · install one with emulsify system install`; } diff --git a/src/index.ts b/src/index.ts index 594c926..c79e15f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node import { program } from 'commander'; -import withProgressBar from './handlers/hofs/withProgressBar.js'; +import withSession from './handlers/hofs/withSession.js'; import init from './handlers/init.js'; import systemList from './handlers/systemList.js'; import systemInstall from './handlers/systemInstall.js'; @@ -15,17 +15,58 @@ import cacheClear from './handlers/cacheClear.js'; import CliError from './lib/CliError.js'; import log from './lib/log.js'; import getRootHelp from './lib/rootHelp.js'; -import getTerminalColors, { - terminalSupportsColor, -} from './lib/terminalColors.js'; -import { isExitPromptError } from './util/prompt/index.js'; +import getTerminalColors from './lib/terminalColors.js'; +import { cancelSession, isSessionOpen, startSession } from './lib/session.js'; +import { isPromptCancelledError } from './util/prompt/index.js'; import { createRequire } from 'module'; -import boxen from 'boxen'; const packageInfo = createRequire(import.meta.url)('../package.json'); +/** + * Render a Commander failure as a session so it matches every other message. + * + * @param text error text produced by Commander, including any suggestion. + */ +function renderCommanderError(text: string): void { + const [summary, ...hints] = text + .split('\n') + .map((line) => line.replace(/^error:\s*/i, '').trim()) + .filter(Boolean); + + if (!summary) { + return; + } + + startSession('emulsify'); + log('error', summary); + + for (const hint of hints) { + log('info', hint); + } + + cancelSession('Run emulsify --help for the full command list.'); +} + +/** + * Close a session that ended in cancellation or failure. + * + * @remarks A failure that happens before any session opened prints on its own + * and needs no closing line. + * + * @param message closing line shown beneath the reported failure. + */ +function closeFailedSession(message: string): void { + if (isSessionOpen()) { + cancelSession(message); + } +} + // Main program commands. -program.name('emulsify').enablePositionalOptions(); +program + .name('emulsify') + .enablePositionalOptions() + .showSuggestionAfterError() + .configureOutput({ outputError: renderCommanderError }); program .command('init [name] [path]') @@ -47,7 +88,7 @@ program '-y, --yes', 'Accept default init values for any missing options without prompting.', ) - .action(withProgressBar(init)); + .action(withSession('emulsify init', init)); program .command('audit [args...]') @@ -65,7 +106,7 @@ system .command('list') .description('List built-in systems available for installation') .alias('ls') - .action(systemList); + .action(withSession('emulsify system list', systemList)); system .command('create [name]') .description('Scaffold a standalone component-system repository') @@ -95,7 +136,7 @@ system '--dry-run', 'Preview the system scaffold without creating files or initializing Git.', ) - .action(systemCreate); + .action(withSession('emulsify system create', systemCreate)); system .command('install [name]') .description('Install a component system or open the guided installer') @@ -119,12 +160,12 @@ system '-y, --yes', 'Accept the final guided-install review without prompting.', ) - .action(systemInstall); + .action(withSession('emulsify system install', systemInstall)); system .command('detach') .description('Detach the configured system and keep project components') .option('-y, --yes', 'Detach without prompting for confirmation.') - .action(systemDetach); + .action(withSession('emulsify system detach', systemDetach)); // Component sub-commands. const component = program @@ -141,7 +182,7 @@ component 'Check the configured system remote before reusing its local cache entry.', ) .alias('ls') - .action(componentList); + .action(withSession('emulsify component list', componentList)); component .command('install [name]') .description('Install one component from the installed system and variant') @@ -159,7 +200,7 @@ component 'Check the configured system remote before reusing its local cache entry.', ) .alias('i') - .action(componentInstall); + .action(withSession('emulsify component install', componentInstall)); component .command('create [name]') .option( @@ -196,7 +237,7 @@ component ) .alias('c') .description('Generate a new local component in the current project') - .action(componentCreate); + .action(withSession('emulsify component create', componentCreate)); component .command('eject-templates [type]') .description('Write editable copies of the built-in component templates') @@ -209,7 +250,9 @@ component '--dry-run', 'Preview template destinations and conflicts without writing files.', ) - .action(componentEjectTemplates); + .action( + withSession('emulsify component eject-templates', componentEjectTemplates), + ); // Cache sub-commands. const cache = program @@ -219,33 +262,11 @@ cache .command('clear') .description('Remove all locally cached Emulsify repositories') .option('--dry-run', 'Report cache contents without removing files.') - .action(cacheClear); + .action(withSession('emulsify cache clear', cacheClear)); -/* - * Generate a styled version message using boxen and colorette. - * This displays the product name and version in a visually appealing format. - * - * ╭ Emulsify CLI ──────╮ - * | │ - * │ Version: 2.0.0 │ - * │ │ - * ╰────────────────────╯ - */ -const { cyan, green } = getTerminalColors(); -const title = cyan(packageInfo.productName); -const message = `Version: ${green(packageInfo.version)}`; +// The version is a value scripts read, so it is printed as one plain line. +program.version(packageInfo.version, '-V, --version', 'Print the CLI version'); -const boxedMessage = boxen(message, { - title: title, - borderStyle: 'round', - padding: 1, - margin: 1, - ...(terminalSupportsColor() - ? { backgroundColor: 'black', borderColor: 'blue' } - : {}), -}); - -program.version(boxedMessage); const rootHelpRequested = process.argv.length <= 2 || (process.argv.length === 3 && @@ -268,19 +289,22 @@ if (rootHelpRequested) { try { await program.parseAsync(process.argv); } catch (err) { - // Ctrl-C is an expected prompt cancellation, not a command failure. - if (isExitPromptError(err)) { - log('info', 'Cancelled.'); - process.exitCode = 130; + // Ctrl-C is an expected prompt cancellation, not a command failure. It closes + // the session on its own line rather than being reported as an error. + if (isPromptCancelledError(err)) { + closeFailedSession(err.message); + process.exitCode = err.exitCode; } // Expected CliError failures map their message and exitCode to the process; // unexpected failures still produce a message and a default non-zero exit. else if (err instanceof CliError) { log('error', err.message); + closeFailedSession('No changes were made.'); process.exitCode = err.exitCode; } else { const message = err instanceof Error ? err.message : String(err); log('error', message); + closeFailedSession('No changes were made.'); process.exitCode = 1; } } diff --git a/src/lib/log.test.ts b/src/lib/log.test.ts index bdf887f..b0b7c80 100644 --- a/src/lib/log.test.ts +++ b/src/lib/log.test.ts @@ -1,4 +1,8 @@ -import consolaGlobalInstance from 'consola'; +import { log as render } from '@clack/prompts'; + +import log from './log.js'; + +const renderMock = render as jest.Mocked; /** * Return the provided value unchanged. @@ -9,51 +13,52 @@ import consolaGlobalInstance from 'consola'; */ const identity = (value: T): T => value; -const consolaLogMock = jest - .spyOn(consolaGlobalInstance, 'log') - .mockImplementation(identity); -const consolaInfoMock = jest - .spyOn(consolaGlobalInstance, 'info') - .mockImplementation(identity); -const consolaErrorMock = jest - .spyOn(consolaGlobalInstance, 'error') - .mockImplementation(identity); -const consolaWarnMock = jest - .spyOn(consolaGlobalInstance, 'warn') - .mockImplementation(identity); const exitMock = jest .spyOn(global.process, 'exit') .mockImplementation(identity as () => never); -import log from './log.js'; +const originalStdoutIsTTY = process.stdout.isTTY; + +function setStdoutIsTTY(value: boolean | undefined): void { + Object.defineProperty(process.stdout, 'isTTY', { + value, + configurable: true, + }); +} describe('log', () => { beforeEach(() => { jest.clearAllMocks(); + setStdoutIsTTY(true); + }); + + afterAll(() => { + setStdoutIsTTY(originalStdoutIsTTY); }); it('can log info messages', () => { expect.assertions(1); log('info', 'information'); - expect(consolaInfoMock).toHaveBeenCalledTimes(1); + expect(renderMock.info).toHaveBeenCalledTimes(1); }); it('can log error messages', () => { expect.assertions(1); log('error', 'error message'); - expect(consolaErrorMock).toHaveBeenCalledTimes(1); + expect(renderMock.error).toHaveBeenCalledTimes(1); }); it('can log warning messages', () => { expect.assertions(1); log('warn', 'warn message'); - expect(consolaWarnMock).toHaveBeenCalledTimes(1); + expect(renderMock.warn).toHaveBeenCalledTimes(1); }); - it('can write other types of messages', () => { - expect.assertions(1); + it('marks success messages with their own symbol', () => { + expect.assertions(2); log('success', 'success message'); - expect(consolaLogMock).toHaveBeenCalledTimes(1); + expect(renderMock.success).toHaveBeenCalledTimes(1); + expect(renderMock.message).not.toHaveBeenCalled(); }); it('does not exit when logging', () => { @@ -64,11 +69,58 @@ describe('log', () => { it('can log debug messages', () => { log('debug', 'debug message'); - expect(consolaLogMock).toHaveBeenCalled(); + expect(renderMock.message).toHaveBeenCalled(); }); it('can log verbose messages', () => { log('verbose', 'verbose message'); - expect(consolaLogMock).toHaveBeenCalled(); + expect(renderMock.message).toHaveBeenCalled(); + }); + + it('writes diagnostics to stderr, not stdout', () => { + expect.assertions(2); + + log('error', 'error message'); + log('warn', 'warn message'); + + expect(renderMock.error).toHaveBeenCalledWith('error message', { + output: expect.anything(), + }); + expect(renderMock.warn).toHaveBeenCalledWith('warn message', { + output: expect.anything(), + }); + }); + + describe('outside a terminal', () => { + beforeEach(() => { + setStdoutIsTTY(false); + }); + + it('writes plain lines with no gutter, symbols, or color', () => { + const stdoutWrite = jest + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + + log('info', 'base -> button'); + + expect(stdoutWrite).toHaveBeenCalledWith('base -> button\n'); + expect(renderMock.info).not.toHaveBeenCalled(); + + stdoutWrite.mockRestore(); + }); + + it('still separates diagnostics onto stderr', () => { + const stderrWrite = jest + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + + log('error', 'clone failed'); + log('warn', 'deprecated flag'); + + expect(stderrWrite).toHaveBeenCalledWith('clone failed\n'); + expect(stderrWrite).toHaveBeenCalledWith('deprecated flag\n'); + + stderrWrite.mockRestore(); + }); }); }); diff --git a/src/lib/log.ts b/src/lib/log.ts index 5c74a3c..3e63ef6 100644 --- a/src/lib/log.ts +++ b/src/lib/log.ts @@ -3,56 +3,50 @@ * Exports methods that MUST be used when writing to the console. */ -import consolaGlobalInstance, { type ConsolaInstance } from 'consola'; -import getTerminalColors from './terminalColors.js'; +import { log as render } from '@clack/prompts'; +import { dim } from 'colorette'; +import { getDiagnosticOutput, isInteractiveOutput } from './session.js'; export type LogMethod = 'info' | 'error' | 'warn' | 'debug' | 'verbose' | 'success'; -const withColor = - (logger: ConsolaInstance['log']) => - (method: LogMethod, message: string): void => { - const { bold, cyan, dim, green, red, yellow } = getTerminalColors(); - const logMethodColorMap: { - [name in LogMethod]: (value: string) => string; - } = { - info: cyan, - error: (value: string) => bold(red(value)), - warn: (value: string) => bold(yellow(value)), - debug: dim, - verbose: dim, - success: green, - }; - - logger(logMethodColorMap[method](message)); - }; - /** * Lib function that allows for info, error, warn, debug, verbose, and success messages - * to be written to the console with consistent methods and colors. This function + * to be written to the console with consistent symbols and colors. This function * only writes log output and never exits the process. * + * @remarks Every message is drawn on the session gutter so a command reads as + * one continuous transcript. Warnings and errors are written to stderr; the rest + * go to stdout. + * * @param method method that should be used to log, such as 'error', or 'warn'. * @param message string containing message to be logged. */ export default function log(method: LogMethod, message: string): void { - // @TODO: add support for --verbose flag, and suppress verbose messages - // by default when --verbose is false or void. + // Piped or redirected output is read by scripts, so it gets plain lines with + // no gutter, no symbols, and no color. + if (!isInteractiveOutput()) { + const stream = + method === 'error' || method === 'warn' ? process.stderr : process.stdout; + + stream.write(`${message}\n`); + return; + } - // emit log message based off of method. switch (method) { case 'error': - withColor(consolaGlobalInstance.error)(method, message); - break; - case 'info': - withColor(consolaGlobalInstance.info)(method, message); - break; + render.error(message, { output: getDiagnosticOutput() }); + return; case 'warn': - withColor(consolaGlobalInstance.warn)(method, message); - break; + render.warn(message, { output: getDiagnosticOutput() }); + return; + case 'info': + render.info(message); + return; + case 'success': + render.success(message); + return; default: - withColor(consolaGlobalInstance.log)(method, message); + render.message(dim(message)); } - - return; } diff --git a/src/lib/session.test.ts b/src/lib/session.test.ts new file mode 100644 index 0000000..69d0448 --- /dev/null +++ b/src/lib/session.test.ts @@ -0,0 +1,209 @@ +import { + cancel as renderCancel, + intro as renderIntro, + note as renderNote, + outro as renderOutro, + spinner as createSpinner, +} from '@clack/prompts'; + +import { + cancelSession, + finishSession, + getDiagnosticOutput, + isSessionOpen, + note, + resetSessionForTesting, + spinner, + startSession, + stripAnsi, +} from './session.js'; + +const introMock = renderIntro as jest.Mock; +const outroMock = renderOutro as jest.Mock; +const cancelMock = renderCancel as jest.Mock; +const noteMock = renderNote as jest.Mock; +const spinnerMock = createSpinner as jest.Mock; + +describe('stripAnsi', () => { + it('removes color and cursor sequences', () => { + expect(stripAnsi('done')).toBe('done'); + expect(stripAnsi('redraw')).toBe('redraw'); + }); + + it('leaves plain text and box-drawing characters untouched', () => { + expect(stripAnsi('│ emulsify init')).toBe('│ emulsify init'); + }); +}); + +describe('getDiagnosticOutput', () => { + it('writes straight to an interactive stream', () => { + const stream = { isTTY: true, write: jest.fn() }; + + expect(getDiagnosticOutput(stream as unknown as NodeJS.WriteStream)).toBe( + stream, + ); + }); + + it('strips color when the stream is redirected to a file', () => { + const write = jest.fn(); + const stream = { isTTY: false, write }; + + const output = getDiagnosticOutput(stream as unknown as NodeJS.WriteStream); + output.write('failed'); + + // Redirecting stderr must not leave escape sequences in the file, even + // while stdout is still a terminal. + expect(write).toHaveBeenCalledWith('failed'); + }); +}); + +const originalStdoutIsTTY = process.stdout.isTTY; + +function setStdoutIsTTY(value: boolean | undefined): void { + Object.defineProperty(process.stdout, 'isTTY', { + value, + configurable: true, + }); +} + +describe('session framing', () => { + beforeEach(() => { + jest.clearAllMocks(); + resetSessionForTesting(); + setStdoutIsTTY(true); + }); + + afterAll(() => { + setStdoutIsTTY(originalStdoutIsTTY); + }); + + it('opens and closes exactly once', () => { + expect(isSessionOpen()).toBe(false); + + startSession('emulsify init'); + expect(isSessionOpen()).toBe(true); + expect(introMock).toHaveBeenCalledWith('emulsify init'); + + startSession('emulsify init'); + expect(introMock).toHaveBeenCalledTimes(1); + + finishSession('done'); + expect(isSessionOpen()).toBe(false); + expect(outroMock).toHaveBeenCalledWith('done'); + + finishSession('done again'); + expect(outroMock).toHaveBeenCalledTimes(1); + }); + + it('closes a cancelled session with its reason', () => { + startSession('emulsify system detach'); + cancelSession('Nothing was changed.'); + + expect(cancelMock).toHaveBeenCalledWith('Nothing was changed.'); + expect(isSessionOpen()).toBe(false); + }); + + it('ignores a cancellation when no session is open', () => { + cancelSession('Nothing was changed.'); + + expect(cancelMock).not.toHaveBeenCalled(); + }); + + it('renders a titled block', () => { + note('a\nb', 'Next'); + + expect(noteMock).toHaveBeenCalledWith('a\nb', 'Next'); + }); + + it('creates a spinner that reports elapsed time rather than a fake total', () => { + spinner(); + + expect(spinnerMock).toHaveBeenCalledWith({ indicator: 'timer' }); + }); + + describe('outside a terminal', () => { + beforeEach(() => { + setStdoutIsTTY(false); + }); + + it('draws no frame, but still reports the closing summary', () => { + const stdoutWrite = jest + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + + startSession('emulsify init'); + finishSession('cornflake is ready'); + + expect(introMock).not.toHaveBeenCalled(); + expect(outroMock).not.toHaveBeenCalled(); + expect(stdoutWrite).toHaveBeenCalledWith('cornflake is ready\n'); + + stdoutWrite.mockRestore(); + }); + + it('reports a cancellation on stderr without a frame', () => { + const stderrWrite = jest + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + + startSession('emulsify system detach'); + cancelSession('Nothing was changed.'); + + expect(cancelMock).not.toHaveBeenCalled(); + expect(stderrWrite).toHaveBeenCalledWith('Nothing was changed.\n'); + + stderrWrite.mockRestore(); + }); + + it('writes a block as plain lines', () => { + const stdoutWrite = jest + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + + note('a\nb', 'Next'); + + expect(noteMock).not.toHaveBeenCalled(); + expect(stdoutWrite).toHaveBeenCalledWith('Next\na\nb\n'); + + stdoutWrite.mockRestore(); + }); + + it('reports spinner phases as plain lines instead of animating', () => { + const stdoutWrite = jest + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + + const progress = spinner(); + progress.start('Cloning the starter'); + progress.message('Installing dependencies'); + progress.stop('Created cornflake'); + progress.clear(); + + expect(spinnerMock).not.toHaveBeenCalled(); + expect(progress.isCancelled).toBe(false); + expect(stdoutWrite).toHaveBeenCalledWith('Cloning the starter\n'); + expect(stdoutWrite).toHaveBeenCalledWith('Installing dependencies\n'); + expect(stdoutWrite).toHaveBeenCalledWith('Created cornflake\n'); + + stdoutWrite.mockRestore(); + }); + + it('sends spinner failures to stderr and skips empty phases', () => { + const stderrWrite = jest + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + + const progress = spinner(); + progress.error('Could not finish cloning the starter'); + progress.cancel('Cancelled'); + progress.stop(); + + expect(stderrWrite).toHaveBeenCalledWith( + 'Could not finish cloning the starter\n', + ); + expect(stderrWrite).toHaveBeenCalledWith('Cancelled\n'); + + stderrWrite.mockRestore(); + }); + }); +}); diff --git a/src/lib/session.ts b/src/lib/session.ts new file mode 100644 index 0000000..4290359 --- /dev/null +++ b/src/lib/session.ts @@ -0,0 +1,217 @@ +/** + * @file session.ts + * Shared session framing for every command that talks to a person. + * + * A session opens with a title, threads each step onto a single gutter, and + * closes with either a summary or a cancellation. All command output is drawn + * by one renderer so prompts, logs, and spinners share a visual language. + */ + +import { Writable } from 'node:stream'; +import { + cancel as renderCancel, + intro as renderIntro, + note as renderNote, + outro as renderOutro, + spinner as createSpinner, + type SpinnerResult, +} from '@clack/prompts'; + +/** + * Matches SGR and other ANSI escape sequences. + * + * @remarks Built from a string so the two control characters it matches stay + * written as escapes rather than being embedded literally in the source. + */ +const ANSI_PATTERN = new RegExp( + '[\\u001B\\u009B][[\\]()#;?]*(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nqry=><]', + 'g', +); + +let sessionIsOpen = false; + +/** + * Remove ANSI escape sequences from a string. + * + * @param value string that may contain escape sequences. + * @returns the string with every escape sequence removed. + */ +export function stripAnsi(value: string): string { + return value.replace(ANSI_PATTERN, ''); +} + +/** + * Wrap a stream so anything written to it is stripped of ANSI escapes first. + * + * @param target stream that should only ever receive plain text. + * @returns a writable that forwards plain text to the target. + */ +function createPlainTextStream(target: NodeJS.WritableStream): Writable { + return new Writable({ + write(chunk, _encoding, callback) { + target.write(stripAnsi(String(chunk))); + callback(); + }, + }); +} + +/** + * Resolve the stream diagnostics should be written to. + * + * @remarks Color is chosen from stdout by the renderer, but warnings and errors + * are written to stderr. When stderr is redirected to a file, escape sequences + * are removed so the file holds plain text even while stdout stays a terminal. + * + * @param stream stderr stream to write to. + * @returns stderr itself when it is a terminal, otherwise a plain-text wrapper. + */ +export function getDiagnosticOutput( + stream: NodeJS.WriteStream = process.stderr, +): Writable { + return stream.isTTY === true ? stream : createPlainTextStream(stream); +} + +/** + * Whether output is being drawn for a person at a terminal. + * + * @remarks Piped and redirected output is read by scripts and other commands, + * so it gets plain lines instead of a drawn session frame. + * + * @param stream stdout stream to test. + * @returns true when stdout is an interactive terminal. + */ +export function isInteractiveOutput( + stream: NodeJS.WriteStream = process.stdout, +): boolean { + return stream.isTTY === true; +} + +/** + * Whether a session frame is currently open. + * + * @returns true between `startSession` and `finishSession`/`cancelSession`. + */ +export function isSessionOpen(): boolean { + return sessionIsOpen; +} + +/** + * Open a session frame. + * + * @param title short command label shown in the session header. + */ +export function startSession(title: string): void { + if (sessionIsOpen) { + return; + } + + sessionIsOpen = true; + + if (isInteractiveOutput()) { + renderIntro(title); + } +} + +/** + * Close an open session frame with an optional summary. + * + * @param message summary shown on the closing line. + */ +export function finishSession(message?: string): void { + if (!sessionIsOpen) { + return; + } + + sessionIsOpen = false; + + if (isInteractiveOutput()) { + renderOutro(message); + return; + } + + if (message) { + process.stdout.write(`${message}\n`); + } +} + +/** + * Close an open session frame as cancelled or failed. + * + * @param message reason shown on the closing line. + */ +export function cancelSession(message: string): void { + if (!sessionIsOpen) { + return; + } + + sessionIsOpen = false; + + if (isInteractiveOutput()) { + renderCancel(message); + return; + } + + process.stderr.write(`${message}\n`); +} + +/** + * Render a bordered block of related lines inside the current session. + * + * @param message block body. + * @param title optional block heading. + */ +export function note(message: string, title?: string): void { + if (isInteractiveOutput()) { + renderNote(message, title); + return; + } + + process.stdout.write(`${title ? `${title}\n` : ''}${message}\n`); +} + +/** + * Write one plain progress line. + * + * @param stream stream to write to. + * @param message line to write, if any. + */ +function writeLine(stream: NodeJS.WriteStream, message?: string): void { + if (message) { + stream.write(`${message}\n`); + } +} + +/** + * Create a spinner bound to the current session. + * + * @remarks Long-running work reports which phase it is in rather than + * pretending to know how much of the total work is complete. Outside a + * terminal there is nothing to animate, so each phase is written as one plain + * line and the log stays readable in CI. + * + * @returns a spinner whose `message` updates the active phase. + */ +export function spinner(): SpinnerResult { + if (isInteractiveOutput()) { + return createSpinner({ indicator: 'timer' }); + } + + return { + start: (message) => writeLine(process.stdout, message), + stop: (message) => writeLine(process.stdout, message), + message: (message) => writeLine(process.stdout, message), + cancel: (message) => writeLine(process.stderr, message), + error: (message) => writeLine(process.stderr, message), + clear: () => undefined, + isCancelled: false, + }; +} + +/** + * Reset session state. + * + * @remarks Exported for tests; production code opens and closes sessions in pairs. + */ +export function resetSessionForTesting(): void { + sessionIsOpen = false; +} diff --git a/src/util/platform/getInitSuccessMessageForPlatform.test.ts b/src/util/platform/getInitSuccessMessageForPlatform.test.ts index 015532e..627f218 100644 --- a/src/util/platform/getInitSuccessMessageForPlatform.test.ts +++ b/src/util/platform/getInitSuccessMessageForPlatform.test.ts @@ -1,17 +1,18 @@ import getInitSuccessMessageForPlatform from './getInitSuccessMessageForPlatform.js'; const systemSelectionMessage = [ - 'Next, choose a component system:', - ' emulsify system install', + 'emulsify system install', + '', + 'Pick a component system to render with. Nothing renders until one', + 'is installed.', ].join('\n'); const drupalIntegrationMessage = [ - 'Detected a Drupal project.', - '', - 'Install the required Drupal packages with Composer:', - ' composer require drupal/emulsify drupal/emulsify_tools', - ' drush en emulsify_tools -y', + 'composer require drupal/emulsify drupal/emulsify_tools', + 'drush en emulsify_tools -y', '', - 'The generated Drupal starter uses drupal/emulsify as its base theme and emulsify_tools for Drupal integration, so both packages must exist in the Drupal codebase.', + 'The generated starter uses drupal/emulsify as its base theme and', + 'emulsify_tools for Drupal integration, so both packages must exist', + 'in the Drupal codebase.', ].join('\n'); describe('getInitSuccessMessageForPlatform', () => { @@ -23,12 +24,14 @@ describe('getInitSuccessMessageForPlatform', () => { }), ).toEqual([ { - method: 'warn', + method: 'info', message: drupalIntegrationMessage, + title: 'Drupal also needs', }, { method: 'info', message: systemSelectionMessage, + title: 'Next', }, ]); }); @@ -42,6 +45,7 @@ describe('getInitSuccessMessageForPlatform', () => { { method: 'info', message: systemSelectionMessage, + title: 'Next', }, ]); expect(messages).not.toContain('composer require drupal/'); @@ -72,6 +76,7 @@ describe('getInitSuccessMessageForPlatform', () => { { method: 'info', message: systemSelectionMessage, + title: 'Next', }, ]); expect(messages).not.toContain('Drupal integration module'); @@ -91,6 +96,15 @@ describe('getInitSuccessMessageForPlatform', () => { ); }); + it('never reports success-path guidance as a warning', () => { + expect.assertions(1); + const methods = getInitSuccessMessageForPlatform('drupal', '/directory', { + includeDrupalInstallReminder: true, + }).map(({ method }) => method); + + expect(methods).not.toContain('warn'); + }); + it('returns an empty array if the given platform does not correspond with any success messages', () => { expect.assertions(1); expect(getInitSuccessMessageForPlatform('java', '/directory')).toEqual([]); diff --git a/src/util/platform/getInitSuccessMessageForPlatform.ts b/src/util/platform/getInitSuccessMessageForPlatform.ts index 3253153..3265b99 100644 --- a/src/util/platform/getInitSuccessMessageForPlatform.ts +++ b/src/util/platform/getInitSuccessMessageForPlatform.ts @@ -1,55 +1,66 @@ import { LogMethod } from 'src/lib/log.js'; +const DRUPAL_INTEGRATION_TITLE = 'Drupal also needs'; const DRUPAL_INTEGRATION_MESSAGE = [ - 'Detected a Drupal project.', + 'composer require drupal/emulsify drupal/emulsify_tools', + 'drush en emulsify_tools -y', '', - 'Install the required Drupal packages with Composer:', - ' composer require drupal/emulsify drupal/emulsify_tools', - ' drush en emulsify_tools -y', - '', - 'The generated Drupal starter uses drupal/emulsify as its base theme and emulsify_tools for Drupal integration, so both packages must exist in the Drupal codebase.', + 'The generated starter uses drupal/emulsify as its base theme and', + 'emulsify_tools for Drupal integration, so both packages must exist', + 'in the Drupal codebase.', ].join('\n'); +const SYSTEM_SELECTION_TITLE = 'Next'; const SYSTEM_SELECTION_MESSAGE = [ - 'Next, choose a component system:', - ' emulsify system install', + 'emulsify system install', + '', + 'Pick a component system to render with. Nothing renders until one', + 'is installed.', ].join('\n'); +export type InitSuccessMessage = { + /** Log level used when the message is written as a single line. */ + method: LogMethod; + /** Message body. */ + message: string; + /** Heading shown when the message is rendered as a block. */ + title?: string; +}; + type InitSuccessMessageOptions = { includeDrupalInstallReminder?: boolean; }; /** - * Returns the init success log messages for a given platform. + * Returns the init success messages for a given platform. + * + * @remarks These describe what to do next on a run that succeeded, so they are + * written as guidance rather than as warnings. * * @param platform name of platform. * @param options.includeDrupalInstallReminder whether to include Composer package guidance for an auto-detected Drupal project. - * @returns array containing objects with a log method, and message. + * @returns array containing objects with a log method, message, and optional block title. */ export default function getInitSuccessMessageForPlatform( platform: string, _directory: string, options: InitSuccessMessageOptions = {}, -): { - method: LogMethod; - message: string; -}[] { +): InitSuccessMessage[] { if (platform === 'drupal') { - const messages: { - method: LogMethod; - message: string; - }[] = []; + const messages: InitSuccessMessage[] = []; if (options.includeDrupalInstallReminder) { messages.push({ - method: 'warn', + method: 'info', message: DRUPAL_INTEGRATION_MESSAGE, + title: DRUPAL_INTEGRATION_TITLE, }); } messages.push({ method: 'info', message: SYSTEM_SELECTION_MESSAGE, + title: SYSTEM_SELECTION_TITLE, }); return messages; @@ -60,6 +71,7 @@ export default function getInitSuccessMessageForPlatform( { method: 'info', message: SYSTEM_SELECTION_MESSAGE, + title: SYSTEM_SELECTION_TITLE, }, ]; } diff --git a/src/util/project/generateComponent.test.ts b/src/util/project/generateComponent.test.ts index cbe5788..9792cf3 100644 --- a/src/util/project/generateComponent.test.ts +++ b/src/util/project/generateComponent.test.ts @@ -11,16 +11,31 @@ jest.mock('fs-extra', () => ({ emptyDir: jest.fn(), remove: jest.fn(), })); -jest.mock('@inquirer/prompts'); +// The prompt library is ESM-only, so the mock is built from a factory rather +// than automocked from the real module. +jest.mock('@clack/prompts', () => { + const cancelSymbol = Symbol('clack:cancel'); + + return { + __esModule: true, + CANCEL_SYMBOL: cancelSymbol, + isCancel: (value: unknown) => value === cancelSymbol, + text: jest.fn(), + select: jest.fn(), + confirm: jest.fn(), + }; +}); +jest.mock('../../lib/session', () => ({ note: jest.fn() })); jest.mock('../../lib/log.js'); jest.mock('../fs/findFileInCurrentPath.js'); jest.mock('../fs/loadJsonFile.js'); -import { confirm, input, select } from '@inquirer/prompts'; +import { CANCEL_SYMBOL, confirm, select, text } from '@clack/prompts'; import { promises as fs } from 'fs'; import { join, normalize, resolve, sep } from 'path'; import { pathExists, remove } from 'fs-extra'; import log from '../../lib/log.js'; +import { note } from '../../lib/session.js'; import { EMULSIFY_PROJECT_CONFIG_FILE, EMULSIFY_PROJECT_TEMPLATES_FOLDER, @@ -32,6 +47,7 @@ import type { } from '@emulsify-cli/config'; import findFileInCurrentPath from '../fs/findFileInCurrentPath.js'; import loadJsonFile from '../fs/loadJsonFile.js'; +import { PromptCancelledError } from '../prompt/index.js'; const projectRoot = resolve( '/home/uname/Projects/cornflake/web/themes/custom/themename', @@ -83,9 +99,10 @@ const readFileMock = fs.readFile as jest.Mock; const writeFileMock = fs.writeFile as jest.Mock; const mkdirMock = fs.mkdir as jest.Mock; const loadJsonFileMock = loadJsonFile as jest.Mock; -const inputMock = input as jest.Mock; +const textMock = text as jest.Mock; const selectMock = select as jest.Mock; const confirmMock = confirm as jest.Mock; +const noteMock = note as jest.Mock; const originalStdinIsTTY = process.stdin.isTTY; function setStdinIsTTY(value: boolean | undefined) { @@ -137,7 +154,7 @@ describe('generateComponent', () => { loadJsonFileMock.mockResolvedValue({ dependencies: { '@emulsify/core': '^4.4.0' }, }); - inputMock.mockResolvedValue('cornflake-button'); + textMock.mockResolvedValue('cornflake-button'); }); afterAll(() => { @@ -197,25 +214,40 @@ describe('generateComponent', () => { expect(selectMock).toHaveBeenNthCalledWith( 1, expect.objectContaining({ - message: expect.stringContaining('Choose the component type:'), - choices: expect.arrayContaining([ - expect.objectContaining({ value: 'twig' }), - expect.objectContaining({ value: 'twig-sdc' }), - expect.objectContaining({ value: 'react' }), - expect.objectContaining({ value: 'web-component' }), + message: 'Choose the component type', + options: expect.arrayContaining([ + expect.objectContaining({ + value: 'twig', + label: 'Twig', + hint: expect.any(String), + }), + expect.objectContaining({ + value: 'twig-sdc', + label: 'Twig SDC', + hint: expect.any(String), + }), + expect.objectContaining({ + value: 'react', + label: 'React', + hint: expect.any(String), + }), + expect.objectContaining({ + value: 'web-component', + label: 'Web Component', + hint: expect.any(String), + }), ]), }), ); - expect(selectMock.mock.calls[0][0].choices).toHaveLength(4); + expect(selectMock.mock.calls[0][0].options).toHaveLength(4); expect(selectMock).toHaveBeenNthCalledWith( 2, expect.objectContaining({ - message: expect.stringContaining( - 'Choose a directory for the new component:', - ), + message: 'Choose a directory for the new component', + options: [{ value: 'base', label: 'base' }], }), ); - expect(inputMock).not.toHaveBeenCalled(); + expect(textMock).not.toHaveBeenCalled(); }); it('offers Twig and Twig SDC in a Drupal project without Core and explains the omission', async () => { @@ -228,7 +260,7 @@ describe('generateComponent', () => { }); expect( - selectMock.mock.calls[0][0].choices.map( + selectMock.mock.calls[0][0].options.map( ({ value }: { value: string }) => value, ), ).toEqual(['twig', 'twig-sdc']); @@ -238,7 +270,7 @@ describe('generateComponent', () => { 'React and Web Component are not shown because @emulsify/core is not declared', ), ); - expect(inputMock).not.toHaveBeenCalled(); + expect(textMock).not.toHaveBeenCalled(); }); it('offers Twig, React, and Web Component in a non-Drupal project with Core and explains the omission', async () => { @@ -254,7 +286,7 @@ describe('generateComponent', () => { }); expect( - selectMock.mock.calls[0][0].choices.map( + selectMock.mock.calls[0][0].options.map( ({ value }: { value: string }) => value, ), ).toEqual(['twig', 'react', 'web-component']); @@ -369,14 +401,14 @@ describe('generateComponent', () => { }); it('previews a Twig component without writing files in dry-run mode', async () => { - expect.assertions(6); + expect.assertions(7); setStdinIsTTY(false); pathExistsMock.mockImplementation((path) => { const value = String(path); return !isTemplatePath(value) && !value.endsWith(componentPath('card')); }); - await generateComponent(variant, projectConfig, 'card', { + const result = await generateComponent(variant, projectConfig, 'card', { directory: 'base', type: 'twig', dryRun: true, @@ -386,14 +418,15 @@ describe('generateComponent', () => { expect(removeMock).not.toHaveBeenCalled(); expect(mkdirMock).not.toHaveBeenCalled(); expect(writeFileMock).not.toHaveBeenCalled(); - expect(log).toHaveBeenCalledWith( - 'info', - expect.stringContaining('Dry run: component create "card"'), + expect(noteMock).toHaveBeenCalledWith( + expect.any(String), + 'Dry run: component create "card"', ); - expect(log).toHaveBeenCalledWith( - 'info', + expect(noteMock).toHaveBeenCalledWith( expect.stringContaining(componentPath('card', 'card.stories.js')), + expect.any(String), ); + expect(result).toBe('Dry run complete; no files were changed'); }); it('previews a Twig SDC component without writing files in dry-run mode', async () => { @@ -413,13 +446,13 @@ describe('generateComponent', () => { expect(removeMock).not.toHaveBeenCalled(); expect(mkdirMock).not.toHaveBeenCalled(); expect(writeFileMock).not.toHaveBeenCalled(); - expect(log).toHaveBeenCalledWith( - 'info', + expect(noteMock).toHaveBeenCalledWith( expect.stringContaining('Type: twig-sdc'), + expect.any(String), ); - expect(log).toHaveBeenCalledWith( - 'info', + expect(noteMock).toHaveBeenCalledWith( expect.stringContaining(componentPath('teaser', 'teaser.component.yml')), + expect.any(String), ); }); @@ -437,15 +470,15 @@ describe('generateComponent', () => { expect(confirm).not.toHaveBeenCalled(); expect(removeMock).not.toHaveBeenCalled(); expect(writeFileMock).not.toHaveBeenCalled(); - expect(log).toHaveBeenCalledWith( - 'info', + expect(noteMock).toHaveBeenCalledWith( expect.stringContaining('Destination exists: yes'), + expect.any(String), ); - expect(log).toHaveBeenCalledWith( - 'info', + expect(noteMock).toHaveBeenCalledWith( expect.stringContaining( 'Real run would: prompt before replacing the existing component directory', ), + expect.any(String), ); }); @@ -558,7 +591,7 @@ describe('generateComponent', () => { }); it('should cancel component creation if user declines overwrite', async () => { - expect.assertions(2); + expect.assertions(3); confirmMock.mockResolvedValueOnce(false); const result = await generateComponent(variant, projectConfig, 'link', { @@ -568,9 +601,12 @@ describe('generateComponent', () => { expect(confirm).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringContaining('already exists'), + initialValue: false, }), ); - expect(result).toBeUndefined(); // Returns early after logging cancellation + expect(writeFileMock).not.toHaveBeenCalled(); + // Returns early after logging cancellation, reporting the declined overwrite. + expect(result).toBe('Canceled; the existing link component was kept'); }); it('skips the overwrite confirm and replaces the component when yes is set', async () => { @@ -610,10 +646,10 @@ describe('generateComponent', () => { }); it('should continue creation if user confirms overwrite', async () => { - expect.assertions(2); + expect.assertions(3); confirmMock.mockResolvedValueOnce(true); - await generateComponent(variant, projectConfig, 'link', { + const result = await generateComponent(variant, projectConfig, 'link', { directory: 'base', type: 'twig', }); @@ -622,6 +658,20 @@ describe('generateComponent', () => { 'success', expect.stringContaining('Success!'), ); + expect(result).toBe('Created the link component in base'); + }); + + it('reports a cancelled type prompt as a prompt cancellation', async () => { + expect.assertions(2); + selectMock.mockResolvedValueOnce(CANCEL_SYMBOL); + + await expect( + generateComponent(variant, projectConfig, 'button', { + directory: 'base', + }), + ).rejects.toThrow(PromptCancelledError); + + expect(writeFileMock).not.toHaveBeenCalled(); }); it.each([ @@ -698,24 +748,24 @@ describe('generateComponent', () => { it('prompts with the derived web component tag and accepts a validated override', async () => { expect.assertions(5); pathExistsMock.mockResolvedValue(false); - inputMock.mockImplementationOnce( - async ({ default: defaultValue, validate }) => { - expect(defaultValue).toBe('cornflake-button'); - expect(validate('button')).toContain('contain a hyphen'); - expect(validate('custom-button')).toBe(true); - return ' custom-button '; - }, - ); + textMock.mockImplementationOnce(async ({ defaultValue, validate }) => { + expect(defaultValue).toBe('cornflake-button'); + expect(validate('button')).toContain('contain a hyphen'); + // Clack treats an undefined validation result as a valid value. + expect(validate('custom-button')).toBeUndefined(); + return ' custom-button '; + }); await generateComponent(variant, projectConfig, 'button', { directory: 'base', type: 'web-component', }); - expect(inputMock).toHaveBeenCalledWith( + expect(textMock).toHaveBeenCalledWith( expect.objectContaining({ - message: expect.stringContaining('Custom element tag name:'), - default: 'cornflake-button', + message: 'Custom element tag name', + placeholder: 'cornflake-button', + defaultValue: 'cornflake-button', validate: expect.any(Function), }), ); @@ -737,7 +787,7 @@ describe('generateComponent', () => { type: 'web-component', }); - expect(inputMock).not.toHaveBeenCalled(); + expect(textMock).not.toHaveBeenCalled(); expect(writeFileMock).toHaveBeenCalledWith( componentPath('featured-item', 'featured-item.js'), expect.stringContaining( @@ -761,7 +811,7 @@ describe('generateComponent', () => { tagName: ' valid-button ', }); - expect(inputMock).not.toHaveBeenCalled(); + expect(textMock).not.toHaveBeenCalled(); expect(writeFileMock).toHaveBeenCalledWith( componentPath('button', 'button.js'), expect.stringContaining( @@ -789,7 +839,7 @@ describe('generateComponent', () => { 'Invalid custom element tag name "button". Names must start with an ASCII lowercase letter', ); - expect(inputMock).not.toHaveBeenCalled(); + expect(textMock).not.toHaveBeenCalled(); expect(writeFileMock).not.toHaveBeenCalled(); }); @@ -831,7 +881,7 @@ describe('generateComponent', () => { 'Invalid custom element tag name "123theme-button". Names must start with an ASCII lowercase letter, contain a hyphen, use browser-supported custom-element name characters, and must not be a reserved name. Pass --tag-name to provide a valid custom element name.', ); - expect(inputMock).not.toHaveBeenCalled(); + expect(textMock).not.toHaveBeenCalled(); expect(writeFileMock).not.toHaveBeenCalled(); }); diff --git a/src/util/project/generateComponent.ts b/src/util/project/generateComponent.ts index f9450a9..45b3a84 100644 --- a/src/util/project/generateComponent.ts +++ b/src/util/project/generateComponent.ts @@ -4,13 +4,13 @@ import type { } from '@emulsify-cli/config'; import type { CreateComponentHandlerOptions } from '@emulsify-cli/handlers'; -import { confirm, input, select } from '@inquirer/prompts'; +import { confirm, select, text } from '@clack/prompts'; import { promises as fs } from 'fs'; import { dirname } from 'path'; import { pathExists, remove } from 'fs-extra'; import log from '../../lib/log.js'; -import getTerminalColors from '../../lib/terminalColors.js'; +import { note } from '../../lib/session.js'; import findFileInCurrentPath from '../fs/findFileInCurrentPath.js'; import safeResolveWithin from '../fs/safeResolveWithin.js'; import { EMULSIFY_PROJECT_CONFIG_FILE } from '../../lib/constants.js'; @@ -19,7 +19,11 @@ import { assertValidCustomElementTagName, deriveCustomElementTagName, } from '../deriveCustomElementTagName.js'; -import { isInteractiveTerminal, runPrompt } from '../prompt/index.js'; +import { + isInteractiveTerminal, + runPrompt, + type PromptResult, +} from '../prompt/index.js'; import { componentTypeFromLegacyFormat, getAvailableComponentTypes, @@ -66,9 +70,7 @@ function resolveProvidedComponentType( async function promptForComponentType( projectRoot: string, platform: EmulsifyProjectConfiguration['project']['platform'], - bold: (value: string) => string, - cyan: (value: string) => string, -): Promise { +): Promise> { const hasEmulsifyCore = await projectDeclaresEmulsifyCore(projectRoot); const availableTypes = getAvailableComponentTypes(platform, hasEmulsifyCore); @@ -98,19 +100,21 @@ async function promptForComponentType( }; return select({ - message: cyan('Choose the component type:'), - choices: availableTypes.map((type) => ({ - name: bold(TYPE_LABELS[type]), + message: 'Choose the component type', + options: availableTypes.map((type) => ({ value: type, - description: descriptions[type], + label: TYPE_LABELS[type], + hint: descriptions[type], })), }); } -function validateCustomElementTagName(value: string): true | string { +function validateCustomElementTagName( + value: string | undefined, +): string | undefined { try { - assertValidCustomElementTagName(value.trim()); - return true; + assertValidCustomElementTagName((value ?? '').trim()); + return undefined; } catch (error) { return (error as Error).message; } @@ -130,7 +134,7 @@ function validateCustomElementTagName(value: string): true | string { * @param options.force whether to replace existing components without prompting. * @param options.yes compatibility alias for options.force. * @param options.dryRun whether to preview generated files without changing the project. - * @returns + * @returns summary of what happened, suitable for the closing line of the session. Distinguishes a created component from a declined overwrite and from a dry run. * @throws {Error} if the component name is invalid, the current path is not within an Emulsify project, the requested structure is invalid, or required non-interactive options are missing. */ export default async function generateComponent( @@ -138,8 +142,7 @@ export default async function generateComponent( projectConfig: EmulsifyProjectConfiguration, componentName: string, options: CreateComponentHandlerOptions = {}, -): Promise { - const { bold, cyan, green, yellow } = getTerminalColors(); +): Promise { const { filename, className, camelName, pascalName, snakeName, humanName } = deriveComponentNames(componentName); const providedType = resolveProvidedComponentType(options); @@ -160,14 +163,9 @@ export default async function generateComponent( // the command never waits for input it cannot receive. const type = providedType ? providedType - : await runPrompt({ + : await runPrompt({ prompt: () => - promptForComponentType( - projectRoot, - projectConfig.project.platform, - bold, - cyan, - ), + promptForComponentType(projectRoot, projectConfig.project.platform), nonInteractive: { error: MISSING_COMPONENT_TYPE_ERROR }, }); @@ -198,11 +196,12 @@ export default async function generateComponent( projectConfig.project.machineName, ); tagName = ( - await runPrompt({ + await runPrompt({ prompt: () => - input({ - message: cyan('Custom element tag name:'), - default: derivedTagName, + text({ + message: 'Custom element tag name', + placeholder: derivedTagName, + defaultValue: derivedTagName, validate: validateCustomElementTagName, }), nonInteractive: { value: derivedTagName }, @@ -223,13 +222,13 @@ export default async function generateComponent( // Choose the component's parent structure within the given variant configuration. if (!directory) { - directory = await runPrompt({ + directory = await runPrompt({ prompt: () => select({ - message: cyan('Choose a directory for the new component:'), - choices: variant.structureImplementations.map((structure) => ({ - name: structure.name, + message: 'Choose a directory for the new component', + options: variant.structureImplementations.map((structure) => ({ value: structure.name, + label: structure.name, })), }), nonInteractive: { @@ -304,10 +303,8 @@ export default async function generateComponent( .map((filePath) => ` - ${filePath}`) .join('\n'); - return log( - 'info', + note( [ - `Dry run: component create "${filename}"`, `Type: ${type}`, `Directory: ${directory}`, `Structure path: ${structure.directory}`, @@ -319,7 +316,10 @@ export default async function generateComponent( generatedFiles, 'No files were written, removed, or created.', ].join('\n'), + `Dry run: component create "${filename}"`, ); + + return 'Dry run complete; no files were changed'; } if (!parentExists) { @@ -328,20 +328,20 @@ export default async function generateComponent( } if (componentExists) { - const shouldReplace = await runPrompt({ + const shouldReplace = await runPrompt({ prompt: () => confirm({ - message: yellow( - `The component "${humanName}" already exists in ${structure.directory}. Would you like to replace it?`, - ), - default: false, + message: `The component "${humanName}" already exists in ${structure.directory}. Would you like to replace it?`, + initialValue: false, }), nonInteractive: { value: false }, accept: { when: force, value: true }, }); if (!shouldReplace) { - return log('info', `Component creation canceled.`); + log('info', `Component creation canceled.`); + + return `Canceled; the existing ${componentName} component was kept`; } // Remove the existing component directory to ensure a clean start. @@ -366,8 +366,10 @@ export default async function generateComponent( await fs.writeFile(artifactDestination, templateFile); } - return log( + log( 'success', - `${bold(green('Success!'))} The ${bold(cyan(componentName))} component (${yellow(TYPE_LABELS[type].toUpperCase())}) has been created in ${bold(directory)}.`, + `Success! The ${componentName} component (${TYPE_LABELS[type].toUpperCase()}) has been created in ${directory}.`, ); + + return `Created the ${componentName} component in ${directory}`; } diff --git a/src/util/prompt/index.test.ts b/src/util/prompt/index.test.ts index 6577bed..8004208 100644 --- a/src/util/prompt/index.test.ts +++ b/src/util/prompt/index.test.ts @@ -1,9 +1,13 @@ +import { CANCEL_SYMBOL } from '@clack/prompts'; import CliError from '../../lib/CliError.js'; import { - isExitPromptError, + PROMPT_CANCELLED_EXIT_CODE, + PromptCancelledError, isInteractiveTerminal, + isPromptCancelledError, requireInteractiveTerminal, runPrompt, + unwrapPromptResult, } from './index.js'; const originalStdinIsTTY = process.stdin.isTTY; @@ -58,17 +62,41 @@ describe('prompt utilities', () => { }); }); - describe('isExitPromptError', () => { - it("recognizes an Error with Inquirer's cancellation name", () => { - const error = new Error('User force closed the prompt'); - error.name = 'ExitPromptError'; - - expect(isExitPromptError(error)).toBe(true); + describe('isPromptCancelledError', () => { + it('recognizes a cancellation raised by a prompt', () => { + expect(isPromptCancelledError(new PromptCancelledError())).toBe(true); }); it('rejects other errors and non-Error lookalikes', () => { - expect(isExitPromptError(new Error('different failure'))).toBe(false); - expect(isExitPromptError({ name: 'ExitPromptError' })).toBe(false); + expect(isPromptCancelledError(new Error('different failure'))).toBe( + false, + ); + expect(isPromptCancelledError(new CliError('different failure'))).toBe( + false, + ); + expect(isPromptCancelledError({ name: 'PromptCancelledError' })).toBe( + false, + ); + }); + + it('carries the conventional cancellation exit code', () => { + expect(new PromptCancelledError()).toMatchObject({ + exitCode: PROMPT_CANCELLED_EXIT_CODE, + }); + expect(PROMPT_CANCELLED_EXIT_CODE).toBe(130); + }); + }); + + describe('unwrapPromptResult', () => { + it('returns a submitted value unchanged', () => { + expect(unwrapPromptResult('chosen')).toBe('chosen'); + expect(unwrapPromptResult(false)).toBe(false); + }); + + it('converts the cancel sentinel into a cancellation error', () => { + expect(() => unwrapPromptResult(CANCEL_SYMBOL)).toThrow( + PromptCancelledError, + ); }); }); @@ -144,19 +172,29 @@ describe('prompt utilities', () => { expect(prompt).toHaveBeenCalledTimes(1); }); - it('preserves prompt cancellation errors for the top-level handler', async () => { + it('turns a cancelled prompt into a cancellation the top-level handler can report', async () => { setStdinIsTTY(true); - const cancellation = new Error('User force closed the prompt'); - cancellation.name = 'ExitPromptError'; await expect( runPrompt({ - prompt: async () => { - throw cancellation; - }, + prompt: async () => CANCEL_SYMBOL, nonInteractive: { error: 'Pass --value.' }, }), - ).rejects.toBe(cancellation); + ).rejects.toMatchObject({ + name: 'PromptCancelledError', + exitCode: PROMPT_CANCELLED_EXIT_CODE, + }); + }); + + it('turns a cancelled prompt into a cancellation even when a fallback exists', async () => { + setStdinIsTTY(true); + + await expect( + runPrompt({ + prompt: async () => CANCEL_SYMBOL, + nonInteractive: { value: false }, + }), + ).rejects.toBeInstanceOf(PromptCancelledError); }); }); }); diff --git a/src/util/prompt/index.ts b/src/util/prompt/index.ts index 0af3b76..df2d2b1 100644 --- a/src/util/prompt/index.ts +++ b/src/util/prompt/index.ts @@ -1,9 +1,36 @@ +import { CANCEL_SYMBOL, isCancel } from '@clack/prompts'; import CliError from '../../lib/CliError.js'; +/** + * Value a prompt resolves with, including the cancellation sentinel. + * + * @remarks Naming the sentinel concretely rather than as a bare `symbol` keeps + * inference working, so call sites do not have to restate their value type. + */ +export type PromptResult = T | typeof CANCEL_SYMBOL; + +/** Exit code reported when a person cancels a prompt. */ +export const PROMPT_CANCELLED_EXIT_CODE = 130; + +/** Message shown when a person cancels a prompt. */ +export const PROMPT_CANCELLED_MESSAGE = 'Cancelled. Nothing was changed.'; + +/** + * Error thrown when a person cancels a prompt with Ctrl-C or Escape. + * + * @param message user-facing cancellation message. + */ +export class PromptCancelledError extends CliError { + constructor(message: string = PROMPT_CANCELLED_MESSAGE) { + super(message, PROMPT_CANCELLED_EXIT_CODE); + this.name = 'PromptCancelledError'; + } +} + type NonInteractivePromptBehavior = { error: string } | { value: T }; export type RunPromptOptions = { - prompt: () => Promise; + prompt: () => Promise>; nonInteractive: NonInteractivePromptBehavior; accept?: { when: boolean; @@ -31,19 +58,41 @@ export function requireInteractiveTerminal(nonInteractiveError: string): void { } /** - * Identify the error Inquirer throws when a user cancels a prompt with Ctrl-C. + * Identify a cancellation raised by a prompt. + * + * @param error value thrown from a prompt or command handler. + * @returns true when the value represents a cancelled prompt. + */ +export function isPromptCancelledError( + error: unknown, +): error is PromptCancelledError { + return error instanceof PromptCancelledError; +} + +/** + * Convert a prompt result into a value, treating cancellation as an error. + * + * @remarks Prompts return a cancel symbol rather than throwing, so every prompt + * result passes through here to keep cancellation handling in one place. * - * @remarks `@inquirer/prompts` does not export this error class, so use its - * stable Error name without depending directly on Inquirer's internal package. + * @param result value returned by a prompt. + * @returns the prompt value. + * @throws {PromptCancelledError} when the person cancelled the prompt. */ -export function isExitPromptError(error: unknown): error is Error { - return error instanceof Error && error.name === 'ExitPromptError'; +export function unwrapPromptResult(result: PromptResult): T { + if (isCancel(result)) { + throw new PromptCancelledError(); + } + + return result as T; } /** * Run a prompt only when stdin is interactive, with explicit behavior for all * other environments. An accepted value (for example, an opt-in `--yes` * default) always takes precedence over terminal detection. + * + * @throws {PromptCancelledError} when the person cancelled the prompt. */ export async function runPrompt({ prompt, @@ -56,12 +105,12 @@ export async function runPrompt({ if ('error' in nonInteractive) { requireInteractiveTerminal(nonInteractive.error); - return prompt(); + return unwrapPromptResult(await prompt()); } if (!isInteractiveTerminal()) { return nonInteractive.value; } - return prompt(); + return unwrapPromptResult(await prompt()); } diff --git a/test/e2e/cli.test.mjs b/test/e2e/cli.test.mjs index 2b19720..5259f2d 100644 --- a/test/e2e/cli.test.mjs +++ b/test/e2e/cli.test.mjs @@ -367,11 +367,8 @@ describe('built Emulsify CLI', { concurrency: false }, () => { assert.equal(result.status, 0, commandFailure('--version', result)); assert.equal(result.stderr, ''); - assert.equal(result.stdout.includes('Emulsify CLI'), true); - assert.equal( - result.stdout.includes(`Version: ${packageInfo.version}`), - true, - ); + // The version is a value scripts read, so it prints as one plain line. + assert.equal(result.stdout.trim(), packageInfo.version); }); test('fails fast when component create has no name outside a TTY', () => { @@ -592,7 +589,7 @@ describe('built Emulsify CLI', { concurrency: false }, () => { ); assert.match( installResult.stdout, - /Successfully installed the round-trip-system system using the drupal \|\| wordpress variant/, + /Round Trip System installed\s+·\s+drupal \|\| wordpress component set/, ); const installedComponentRoot = join( @@ -812,7 +809,7 @@ describe('built Emulsify CLI', { concurrency: false }, () => { assert.equal(result.status, 0, commandFailure('init', result)); assert.equal(result.stderr.trim(), ''); - assert.match(result.stdout, /Created an Emulsify project/); + assert.match(result.stdout, /Created fixture-project in /); assert.deepEqual( JSON.parse( readFileSync(join(projectRoot, 'project.emulsify.json'), 'utf8'), @@ -855,7 +852,7 @@ describe('built Emulsify CLI', { concurrency: false }, () => { assert.equal(result.stderr, ''); assert.match( result.stdout, - /Successfully installed the fixture-system system using the wordpress variant/, + /Fixture System installed\s+·\s+wordpress component set/, ); const projectConfig = JSON.parse(