From 905e608d9533cd3f5fab7ff0c6f4148fb61d2c2c Mon Sep 17 00:00:00 2001 From: nbogie Date: Tue, 16 Dec 2025 18:18:09 +0000 Subject: [PATCH 1/3] add tooling to allow tsc to type-check the JS of create-p5 project itself Includes a vs-code task (ctrl-shift-b to trigger it) which type-checks all files and populates problems window tsconfig excludes templates from type-checking for now, this tooling is is intended to check the heart of the check-js tool itself for inconsistencies. --- .vscode/tasks.json | 19 ++++++++++++++++++ package-lock.json | 49 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 7 ++++++- tsconfig.json | 25 +++++++++++++++++++++++ 4 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 .vscode/tasks.json create mode 100644 tsconfig.json diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..284aae6 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,19 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "tsc: watch", + "type": "shell", + "command": "npm run type-check", + "group": { + "kind": "build", + "isDefault": true + }, + "presentation": { + "reveal": "never", + "panel": "dedicated" + }, + "problemMatcher": "$tsc" + } + ] +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index e3b5cde..b0aec2a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,11 @@ "create-p5js": "index.js" }, "devDependencies": { + "@types/degit": "^2.8.6", + "@types/minimist": "^1.2.5", + "@types/node": "^25.0.2", "@vitest/coverage-v8": "^1.0.0", + "typescript": "^5.9.3", "vitest": "^1.0.0" }, "engines": { @@ -887,6 +891,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/degit": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/@types/degit/-/degit-2.8.6.tgz", + "integrity": "sha512-y0M7sqzsnHB6cvAeTCBPrCQNQiZe8U4qdzf8uBVmOWYap5MMTN/gB2iEqrIqFiYcsyvP74GnGD5tgsHttielFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -894,6 +905,23 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.0.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.2.tgz", + "integrity": "sha512-gWEkeiyYE4vqjON/+Obqcoeffmk0NF15WSBwSs7zwVA2bAbTaE0SJ7P0WNGoJn8uE7fiaV5a7dKYIJriEqOrmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, "node_modules/@vitest/coverage-v8": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.6.1.tgz", @@ -2196,6 +2224,20 @@ "node": ">=4" } }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/ufo": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", @@ -2209,6 +2251,13 @@ "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==", "license": "ISC" }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, "node_modules/unique-names-generator": { "version": "4.7.1", "resolved": "https://registry.npmjs.org/unique-names-generator/-/unique-names-generator-4.7.1.tgz", diff --git a/package.json b/package.json index 408219f..9d66bdd 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,8 @@ "scripts": { "test": "vitest run", "test:coverage": "vitest --coverage --run", - "run": "node ./index.js" + "run": "node ./index.js", + "type-check": "tsc" }, "dependencies": { "@clack/prompts": "^0.11.0", @@ -40,7 +41,11 @@ "unique-names-generator": "^4.7.1" }, "devDependencies": { + "@types/degit": "^2.8.6", + "@types/minimist": "^1.2.5", + "@types/node": "^25.0.2", "@vitest/coverage-v8": "^1.0.0", + "typescript": "^5.9.3", "vitest": "^1.0.0" } } diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a8d9d37 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,25 @@ +{ + // Visit https://aka.ms/tsconfig to read more about this file + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "noEmit": true, + + "module": "nodenext", + "target": "es2024", + "lib": ["es2024", "DOM"], + "types": ["node"], + + // Style Options + // "noImplicitReturns": true, + // "noImplicitOverride": true, + // "noUnusedLocals": true, + // "noUnusedParameters": true, + // "noFallthroughCasesInSwitch": true, + // "noPropertyAccessFromIndexSignature": true, + // Recommended Options + "strict": true, + "skipLibCheck": true, + }, + "exclude": ["templates"] +} From dadb86d0a5a65c3e899fcb27551e9a6c4f9e9aa4 Mon Sep 17 00:00:00 2001 From: nbogie Date: Tue, 16 Dec 2025 18:18:38 +0000 Subject: [PATCH 2/3] moved tasks.json into the correct .vscode/ and rename the task --- .vscode/tasks.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 284aae6..cd736f6 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -2,7 +2,7 @@ "version": "2.0.0", "tasks": [ { - "label": "tsc: watch", + "label": "tsc: type-check", "type": "shell", "command": "npm run type-check", "group": { From 14c63fc23641e652aeafa4210e72a889dd57988c Mon Sep 17 00:00:00 2001 From: nbogie Date: Tue, 16 Dec 2025 22:00:34 +0000 Subject: [PATCH 3/3] make create-p5 type-check internally --- locales/en/prompts.json | 4 +- src/config.js | 74 ++++++++++++++++++++++--- src/exceptionUtils.js | 78 ++++++++++++++++++++++++++ src/git.js | 4 +- src/htmlManager.js | 33 +++++++++-- src/i18n/index.js | 7 ++- src/operations/scaffold.js | 111 +++++++++++++++++++++++++++++-------- src/operations/update.js | 15 +++-- src/types.js | 14 +++++ src/ui/display.js | 17 ++++-- src/ui/prompts.js | 39 +++++++++---- src/utils.js | 40 ++++++++++--- src/version.js | 53 ++++++++++++++++-- tests/config.test.js | 6 +- tsconfig.json | 3 +- 15 files changed, 417 insertions(+), 81 deletions(-) create mode 100644 src/exceptionUtils.js create mode 100644 src/types.js diff --git a/locales/en/prompts.json b/locales/en/prompts.json index 4a065c2..09151a3 100644 --- a/locales/en/prompts.json +++ b/locales/en/prompts.json @@ -50,5 +50,7 @@ "prompt.update.deleteLib.message": "Delete the local lib/ directory?", - "prompt.cancel.sketchCreation": "You cancelled the sketch creation" + "prompt.cancel.sketchCreation": "You cancelled the sketch creation", + + "prompt.cancel.sketchUpdate": "You cancelled the sketch update" } diff --git a/src/config.js b/src/config.js index b88e2c6..8b57f18 100644 --- a/src/config.js +++ b/src/config.js @@ -5,27 +5,84 @@ import fs from 'fs/promises'; import path from 'path'; import { readJSON, writeJSON, fileExists } from './utils.js'; +import { messageFromErrorOrUndefined } from './exceptionUtils.js'; +/** + * @typedef {import('./types.js').Language} Language +*/ +/** + * @typedef {import('./types.js').P5Mode} P5Mode +*/ +/** + * @typedef {import('./types.js').DeliveryMode} DeliveryMode +*/ +/** + * @typedef {import('./types.js').SetupType} SetupType +*/ + + + +/** + * @param {any} candidate + * @returns {candidate is DeliveryMode} + */ +export function isValidDeliveryMode(candidate){ + return (candidate==="cdn" || candidate==="local"); +} + +/** + * @param {any} candidate + * @returns {candidate is P5Mode} + */ +export function isValidP5Mode(candidate){ + return (candidate==="global" || candidate==="instance"); +} + +/** + * @param {any} candidate + * @returns {candidate is Language} + */ +export function isValidLanguage(candidate){ + return (candidate==="javascript" || candidate==="typescript"); +} + +/** + * @typedef {Object} ProjectConfig + * @property {string} version + * @property {DeliveryMode} mode + * @property {Language?} language + * @property {P5Mode?} p5Mode + * @property {string|null} typeDefsVersion + * @property {string=} template + * @property {string} lastUpdated + + * + */ /** * Creates a new .p5-config.json file with project metadata * * @param {string} configPath - The path where the config file should be created * @param {Object} options - Configuration options * @param {string} options.version - The p5.js version used - * @param {string} [options.mode='cdn'] - Delivery mode: "cdn" or "local" - * @param {string} [options.language] - Programming language: "javascript" or "typescript" - * @param {string} [options.p5Mode] - p5.js mode: "global" or "instance" + * @param {DeliveryMode} [options.mode='cdn'] - Delivery mode: "cdn" or "local" + * @param {Language} [options.language] - Programming language: "javascript" or "typescript" + * @param {P5Mode} [options.p5Mode] - p5.js mode: "global" or "instance" + * @param {string} [options.template] - template * @param {string|null} [options.typeDefsVersion=null] - Version of TypeScript definitions installed * @returns {Promise} */ export async function createConfig(configPath, options) { + /** + * @type {ProjectConfig} + */ const config = { version: options.version, mode: options.mode || 'cdn', language: options.language || null, p5Mode: options.p5Mode || null, - typeDefsVersion: options.typeDefsVersion || null, - lastUpdated: new Date().toISOString() + typeDefsVersion: options.typeDefsVersion || null, + lastUpdated: new Date().toISOString(), + template: options.template }; await writeJSON(configPath, config); @@ -35,7 +92,7 @@ export async function createConfig(configPath, options) { * Reads an existing .p5-config.json file * * @param {string} configPath - The path to the config file - * @returns {Promise} The configuration object with {version, mode, language, p5Mode, typeDefsVersion, lastUpdated} or null if file doesn't exist + * @returns {Promise} The configuration object with {version, mode, language, p5Mode, typeDefsVersion, lastUpdated} or null if file doesn't exist */ export async function readConfig(configPath) { return await readJSON(configPath); @@ -80,9 +137,10 @@ export async function migrateConfigIfNeeded(projectDir) { await fs.rename(oldConfigPath, newConfigPath); return { migrated: true, error: null }; } catch (err) { + return { - migrated: false, - error: `error.migration.renameFailed: ${err.message}` + migrated: false, + error: `error.migration.renameFailed: ${messageFromErrorOrUndefined(err)}` }; } } diff --git a/src/exceptionUtils.js b/src/exceptionUtils.js new file mode 100644 index 0000000..0439456 --- /dev/null +++ b/src/exceptionUtils.js @@ -0,0 +1,78 @@ + +/** + * Type guard to check if an unknown value is an Error object. + * @param {unknown} error + * @returns {error is Error} + */ +export function isError(error) { + // Check if it's an instance of Error and also check for common object-ness + // in case the error came from a different environment/iframe. + return error instanceof Error && typeof error.message === 'string'; +} + +/** + * @typedef {object} FetchErrorCandidate + * @property {string} message - The error message string. + * @property {string} [code] - An optional error code string (e.g., 'ENOTFOUND'). + */ + +/** + * Type guard to check if an unknown value is an object that is likely a Node.js-style error + * with at least a 'message' property. Allows us to safely check if the object has a .code property later without checking again if it IS an object. + * * @param {unknown} error - The value caught by the catch block. + * @returns {error is FetchErrorCandidate} True if the error has the expected structure. + */ +export function isFetchErrorCandidate(error) { + if (typeof error !== 'object' || error === null) { + return false; + } + /** @type {any} */ + const e = error; + + // Check if 'message' property exists and is a string. + return typeof e.message === 'string'; +} + + +/** + * Type guard to check if an unknown value is an object with a string "message" property. + * * @param {unknown} error - an error (likely from a catch block) + * @returns {error is Record<"message", string>} True if the error has the expected structure. + */ +export function hasMessageStringProperty(error) { + /**@type {any} */ + const e = error; + return typeof e.message === "string"; +} + +/** + * @typedef {object} LoggingError + * @property {string} message + * @property {string | undefined} stack - The stack trace, which may be present but is sometimes undefined. + */ +/** + * Type guard to check if an unknown value is an object with a message and stack property. + * @param {unknown} error - The value caught by the catch block. + * @returns {error is LoggingError} True if the error has the required structure. + */ +export function isLoggingError(error) { + // 1. Basic checks for object type and null + if (typeof error !== 'object' || error === null) { + return false; + } + /** @type {any} */ + const e = error; + const hasMessage = typeof e.message === 'string'; + const hasStackProperty = (typeof e.stack === 'string' || typeof e.stack === 'undefined'); + return hasMessage && hasStackProperty; +} + +/** + * @param {unknown} error - likely from a "catch" + */ +export function messageFromErrorOrUndefined(error) { + if (hasMessageStringProperty(error)){ + return error.message; + } + return undefined; +} diff --git a/src/git.js b/src/git.js index dd32380..2533a81 100644 --- a/src/git.js +++ b/src/git.js @@ -34,7 +34,7 @@ export async function initGit(projectDir) { } // Run git init - await new Promise((resolve, reject) => { + await /** @type {Promise} */(new Promise((resolve, reject) => { const git = spawn('git', ['init'], { cwd: projectDir }); git.on('error', (error) => { @@ -48,7 +48,7 @@ export async function initGit(projectDir) { reject(new Error(`git init exited with code ${code}`)); } }); - }); + })); // Create .gitignore file await createGitignore(projectDir); diff --git a/src/htmlManager.js b/src/htmlManager.js index a861c04..1e4595a 100644 --- a/src/htmlManager.js +++ b/src/htmlManager.js @@ -1,5 +1,9 @@ import { parseHTML } from 'linkedom'; +/** + * @typedef {import('./types.js').DeliveryMode} DeliveryMode +*/ + const P5_PATTERNS = [ /^https?:\/\/cdn\.jsdelivr\.net\/npm\/p5@([^/]+)\/lib\/p5\.(min\.)?js$/, /^https?:\/\/cdnjs\.cloudflare\.com\/ajax\/libs\/p5\.js\/([^/]+)\/p5\.(?:min\.)?js$/, @@ -38,7 +42,7 @@ class HTMLManager { * @returns {{scriptNode: Element, version: string, isMinified: boolean, cdnProvider: string}|null} */ findP5Script() { - const scripts = this.document.querySelectorAll('script'); + const scripts = Array.from(this.document.querySelectorAll('script')); for (const script of scripts) { const src = script.getAttribute('src') || ''; @@ -67,7 +71,7 @@ class HTMLManager { * 3. Insert script into * * @param {string} version - p5.js version to reference - * @param {string} [mode='cdn'] - Delivery mode: 'cdn' or 'local' + * @param {DeliveryMode} [mode='cdn'] - Delivery mode: 'cdn' or 'local' * @param {Object} [preferences={}] - Optional preferences: { isMinified: boolean, cdnProvider: string } * @returns {boolean} True if the document was modified, false otherwise */ @@ -85,7 +89,7 @@ class HTMLManager { // Replace marker if present const marker = this._findMarker(); - if (marker) { + if (marker && marker.parentNode) { const script = this.document.createElement('script'); const newURL = buildScriptURL(version, mode, preferences); script.setAttribute('src', newURL); @@ -111,12 +115,16 @@ class HTMLManager { const head = this.document.head; if (!head) return null; - const findCommentInNode = (node) => { + /** + * @param {ChildNode} node + * @returns {ChildNode|null} + */ + function findCommentInNode(node){ if (node.nodeType === 8) { - if (node.textContent.trim() === 'P5JS_SCRIPT_TAG') return node; + if (node.textContent && node.textContent.trim() === 'P5JS_SCRIPT_TAG') return node; } - for (const child of node.childNodes || []) { + for (const child of Array.from(node.childNodes) || []) { const found = findCommentInNode(child); if (found) return found; } @@ -127,6 +135,10 @@ class HTMLManager { return findCommentInNode(head); } + + /** + * @param {string} url + */ _detectCDN(url) { if (/cdn\.jsdelivr\.net/.test(url)) return 'jsdelivr'; if (/cdnjs\.cloudflare\.com/.test(url)) return 'cdnjs'; @@ -135,6 +147,12 @@ class HTMLManager { } } +/** + * @param {string} version + * @param {string} mode + * @param {{isMinified?: boolean, cdnProvider?: string | undefined}} preferences + * @returns {string} + */ function buildScriptURL(version, mode, preferences = {}) { const file = preferences.isMinified ? 'p5.min.js' : 'p5.js'; @@ -156,6 +174,9 @@ function buildScriptURL(version, mode, preferences = {}) { /** * Legacy helper that mirrors previous API: injectP5Script(htmlString, version, mode) * Internally uses HTMLManager for DOM operations. + * @param {string} htmlString + * @param {string} version + * @param {DeliveryMode} mode */ export function injectP5Script(htmlString, version, mode = 'cdn') { const mgr = new HTMLManager(htmlString); diff --git a/src/i18n/index.js b/src/i18n/index.js index 0949870..ba618d3 100644 --- a/src/i18n/index.js +++ b/src/i18n/index.js @@ -9,6 +9,7 @@ import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; +import { messageFromErrorOrUndefined } from '../exceptionUtils.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -22,7 +23,7 @@ let currentLocale = 'en'; /** * Load all translation files for a given locale * @param {string} locale - Locale code (e.g., 'en', 'fr', 'es') - * @returns {Record} All messages for this locale + * @returns {Record | {}} All messages for this locale */ function loadMessages(locale) { const localeDir = path.join(__dirname, '..', '..', 'locales', locale); @@ -48,7 +49,7 @@ function loadMessages(locale) { const json = JSON.parse(content); Object.assign(result, json); } catch (error) { - console.error(`Failed to load ${filePath}:`, error.message); + console.error(`Failed to load ${filePath}:`, messageFromErrorOrUndefined(error)); } } @@ -121,7 +122,7 @@ export function detectLocale() { // Parse formats like: en_US.UTF-8 => en, fr_FR => fr, pt-BR => pt-BR const match = env.match(/^([a-z]{2})([_-][A-Z]{2})?/i); - if (match) { + if (match && match[1]) { return match[1].toLowerCase(); // Return just language code (en, fr, es, etc.) } diff --git a/src/operations/scaffold.js b/src/operations/scaffold.js index eb679d5..19b822f 100644 --- a/src/operations/scaffold.js +++ b/src/operations/scaffold.js @@ -16,19 +16,55 @@ import * as display from '../ui/display.js'; import * as prompts from '../ui/prompts.js'; // Business utilities -import { copyTemplateFiles, validateProjectName, directoryExists, validateMode, validateVersion, validateLanguage, validateP5Mode, validateSetupType, getTemplateName, generateProjectName, isRemoteTemplateSpec } from '../utils.js'; +import { copyTemplateFiles, validateProjectName, directoryExists, validateMode, validateVersion, validateLanguage, validateP5Mode, validateSetupType, getTemplateName, generateProjectName, isRemoteTemplateSpec, getValidSetupTypes } from '../utils.js'; import { fetchVersions, downloadP5Files, downloadTypeDefinitions } from '../version.js'; import { injectP5Script } from '../htmlManager.js'; -import { createConfig } from '../config.js'; +import { createConfig, isValidDeliveryMode, isValidLanguage, isValidP5Mode } from '../config.js'; import { initGit, addLibToGitignore } from '../git.js'; import { normalizeTemplateSpec, fetchTemplate } from '../templateFetcher.js'; +import { hasMessageStringProperty, isFetchErrorCandidate, isLoggingError, messageFromErrorOrUndefined } from '../exceptionUtils.js'; + + + +/** + * @typedef {import('../types.js').Language} Language +*/ + +/** + * @typedef {import('../types.js').P5Mode} P5Mode +*/ +/** + * @typedef {import('../types.js').DeliveryMode} DeliveryMode +*/ +/** + * @typedef {import('../types.js').SetupType} SetupType +*/ + + const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +// {_:any[], verbose: boolean?, yes:boolean?, language:string ?, "p5-mode":string ?, version:string?, mode:string?, type: string?, "include-prerelease":boolean? } +/** + * @typedef {object} CliArgs + * @property {any[]} _ + * @property {boolean} [verbose] + * @property {boolean} [yes] + * @property {string} [language] + * @property {string} [p5-mode] + * @property {string} [version] + * @property {string} [mode] + * @property {string} [type] + * @property {boolean} [types] + * @property {string} [template] + * @property {string} [git] + * @property {boolean} [include-prerelease] + */ + /** * Main scaffolding function - * @param {Object} args - Parsed command line arguments + * @param {CliArgs} args - Parsed command line arguments * @returns {Promise} */ export async function scaffold(args) { @@ -54,23 +90,27 @@ export async function scaffold(args) { } // Determine setup type (allow override via --type flag) + /** + * @type {SetupType} + */ let setupType = 'standard'; const hasConfigFlags = args.language || args['p5-mode'] || args.version || args.mode; if (args.type) { - const typeError = validateSetupType(args.type); - if (typeError) { + if (!validateSetupType(args.type)){ display.error('error.invalidSetupType'); - display.message(typeError); + display.message(`Invalid setup type: ${args.type}. Must be one of: ${getValidSetupTypes().join(', ')}`); process.exit(1); } setupType = args.type; } else if (!args.yes && !hasConfigFlags) { // Interactive mode without config flags: ask for setup type - setupType = await prompts.promptSetupType(); - if (prompts.isCancel(setupType)) { + const setupTypeReply = await prompts.promptSetupType(); + if (prompts.isCancel(setupTypeReply)) { display.cancel('prompt.cancel.sketchCreation'); + return; } + setupType = setupTypeReply; } else if (hasConfigFlags) { // If user provided config flags, they clearly want to customize setupType = 'custom'; @@ -137,7 +177,9 @@ export async function scaffold(args) { s.stop('spinner.failedVersions'); display.message(''); display.error('error.fetchVersions.failed'); - display.message(error.message); + if (hasMessageStringProperty(error)){ + display.message(error.message); + } display.message(''); display.info('error.fetchVersions.troubleshooting'); display.info('error.fetchVersions.step1'); @@ -154,7 +196,9 @@ export async function scaffold(args) { } catch (error) { display.message(''); display.error('error.fetchVersions.failed'); - display.message(error.message); + if (hasMessageStringProperty(error)){ + display.message(error.message); + } display.message(''); display.info('error.fetchVersions.troubleshooting'); display.info('error.fetchVersions.step1'); @@ -211,11 +255,11 @@ export async function scaffold(args) { display.info('note.verbose.targetPath', { path: targetPath }); } try { - await fetchTemplate(args.template, targetPath, { verbose: args.verbose }); + await fetchTemplate(args.template, targetPath, { verbose: args.verbose ?? false }); if (copySpinner) copySpinner.stop('spinner.fetchedRemoteTemplate'); } catch (err) { if (copySpinner) copySpinner.stop('spinner.failedRemoteTemplate'); - throw new Error(t('error.fetchTemplate', { template: args.template, error: err.message })); + throw new Error(t('error.fetchTemplate', { template: args.template, error: messageFromErrorOrUndefined(err) })); } // Success! Community templates are used as-is, no modifications @@ -244,13 +288,16 @@ export async function scaffold(args) { selectedVersion = await prompts.promptVersion(versions, latest); if (prompts.isCancel(selectedVersion)) { display.cancel('prompt.cancel.sketchCreation'); + return; } } // Built-in templates: determine delivery mode (flag, default, or prompt) + /** @type {DeliveryMode} */ let selectedMode; - if (args.mode) { - selectedMode = args.mode; + const modeFromArgs = args.mode; + if (isValidDeliveryMode(modeFromArgs)) { + selectedMode = modeFromArgs; display.success('info.usingMode', { mode: selectedMode }); } else if (setupType === 'basic' || setupType === 'standard') { // Use default (cdn) for basic and standard setups @@ -258,16 +305,22 @@ export async function scaffold(args) { display.success('info.defaultMode'); } else { // Interactive customization mode (custom) - selectedMode = await prompts.promptMode(); - if (prompts.isCancel(selectedMode)) { + const selectedModeResponse = await prompts.promptMode(); + if (prompts.isCancel(selectedModeResponse)) { display.cancel('prompt.cancel.sketchCreation'); + return; } + selectedMode = selectedModeResponse; } // Determine language and p5Mode for built-in templates - let selectedLanguage, selectedP5Mode; + /** @type {Language} */ + let selectedLanguage; + + /** @type {P5Mode} */ + let selectedP5Mode; - if (args.language && args['p5-mode']) { + if (args.language && args['p5-mode'] && isValidLanguage(args.language) && isValidP5Mode(args['p5-mode'])) { // Non-interactive with flags selectedLanguage = args.language; selectedP5Mode = args['p5-mode']; @@ -281,7 +334,8 @@ export async function scaffold(args) { // Interactive customization mode (custom): prompt for language and mode const choices = await prompts.promptLanguageAndMode(); if (prompts.isCancel(choices)) { - display.cancel('prompt.cancel.sketchCreation'); + display.cancel('prompt.cancel.sketchCreation'); + return; } [selectedLanguage, selectedP5Mode] = choices; } @@ -360,7 +414,9 @@ export async function scaffold(args) { await addLibToGitignore(targetPath); } catch (error) { display.error('error.fetchVersions.failed'); - display.message(error.message); + if (hasMessageStringProperty(error)){ + display.message(error.message); + } display.message(''); display.info('error.cleanup'); await fs.rm(targetPath, { recursive: true, force: true }); @@ -389,11 +445,13 @@ export async function scaffold(args) { const typesSpinner = display.spinner('spinner.downloadingTypes'); typeDefsVersion = await downloadTypeDefinitions(selectedVersion, typesPath, typesSpinner, templateMode); } else { - typeDefsVersion = await downloadTypeDefinitions(selectedVersion, typesPath, null, templateMode); + typeDefsVersion = await downloadTypeDefinitions(selectedVersion, typesPath, undefined, templateMode); } } catch (error) { display.warn('error.fetchVersions.failed'); - display.message(error.message); + if (hasMessageStringProperty(error)){ + display.message(error.message); + } display.info('info.continueWithoutTypes'); // Don't fail the entire operation if type definitions fail typeDefsVersion = null; @@ -478,11 +536,14 @@ export async function scaffold(args) { display.note(gitTipsLines, 'note.gitTips.title'); } } catch (error) { + if (!isFetchErrorCandidate(error)){ + throw new Error("unknown object was thrown: ", {cause:error}) + } display.message(''); display.error('error.fetchVersions.failed'); display.message(error.message); - if (args.verbose) { + if (args.verbose && isLoggingError(error) && error.stack) { display.message(''); display.info('info.stackTrace'); display.message(error.stack); @@ -497,7 +558,9 @@ export async function scaffold(args) { } } catch (cleanupError) { display.warn('error.cleanup'); - display.message(cleanupError.message); + if (hasMessageStringProperty(cleanupError)){ + display.message(cleanupError.message); + } } const helpLines = [ diff --git a/src/operations/update.js b/src/operations/update.js index a8e4499..5af0da9 100644 --- a/src/operations/update.js +++ b/src/operations/update.js @@ -91,8 +91,9 @@ export async function update(projectDir = process.cwd()) { * Updates the p5.js version in an existing project * Handles both CDN and local delivery modes * @param {string} projectDir - The directory of the project to update - * @param {Object} config - Current project configuration from p5-config.json + * @param {import('../config.js').ProjectConfig} config - Current project configuration from p5-config.json * @param {Object} [options={}] - Update options + * @param {boolean} [options.verbose=false] * @param {boolean} [options.includePrerelease=false] - Whether to include pre-release versions * @returns {Promise} */ @@ -107,8 +108,14 @@ async function updateVersion(projectDir, config, options = {}) { } // Let user select new version - const newVersion = await prompts.promptVersion(versions, latest); + const newVersionResponse = await prompts.promptVersion(versions, latest); + if (prompts.isCancel(newVersionResponse)){ + display.cancel('prompt.cancel.sketchUpdate'); + return; + } + + const newVersion = newVersionResponse; if (newVersion === config.version) { display.info('info.update.sameVersion'); return; @@ -141,7 +148,7 @@ async function updateVersion(projectDir, config, options = {}) { // Update TypeScript definitions const typesPath = path.join(projectDir, 'types'); await createDirectory(typesPath); - const typeDefsVersion = await downloadTypeDefinitions(newVersion, typesPath, null, config.template, config.version); + const typeDefsVersion = await downloadTypeDefinitions(newVersion, typesPath, undefined, config.template, config.version); if (verbose && typeDefsVersion) { display.success('info.update.updatedTypes', { version: typeDefsVersion }); } @@ -172,7 +179,7 @@ async function updateVersion(projectDir, config, options = {}) { /** * Switches delivery mode between CDN and local * @param {string} projectDir - The directory of the project to update - * @param {Object} config - Current project configuration from p5-config.json + * @param {import('../config.js').ProjectConfig} config - Current project configuration from p5-config.json * @param {Object} [options={}] - Update options * @param {boolean} [options.verbose=false] - Whether to show verbose output * @returns {Promise} diff --git a/src/types.js b/src/types.js new file mode 100644 index 0000000..97baa57 --- /dev/null +++ b/src/types.js @@ -0,0 +1,14 @@ + +/** + * @typedef {"cdn"|"local"} DeliveryMode + */ +/** + * @typedef {'basic' | 'standard' | 'custom'} SetupType + */ +/** + * @typedef {"javascript"|"typescript"} Language + */ +/** + * @typedef {"global"|"instance"} P5Mode + */ + diff --git a/src/ui/display.js b/src/ui/display.js index 8dfe5de..9ecb180 100644 --- a/src/ui/display.js +++ b/src/ui/display.js @@ -151,11 +151,17 @@ export function note(lineKeys, titleKey, vars = {}) { p.note(content, title); } + +/** + * @typedef {object} SpinnerControl + * @property {function(string, Record=): void} message + * @property {function(string, Record=): void} stop + */ /** * Create and manage a spinner * @param {string} key - Translation key for initial message * @param {Record} [vars] - Variables for interpolation - * @returns {Object} Spinner object with message(key, vars) and stop(key, vars) methods + * @returns {SpinnerControl} Spinner object with message(key, vars) and stop(key, vars) methods, for managing the control's lifecycle. */ export function spinner(key, vars) { if (shouldSuppress('spinner')) { @@ -170,10 +176,11 @@ export function spinner(key, vars) { // Wrap methods to use translation keys const originalMessage = s.message.bind(s); const originalStop = s.stop.bind(s); - - s.message = (key, vars) => originalMessage(t(key, vars)); - s.stop = (key, vars) => originalStop(t(key, vars)); - + //@ts-ignore not getting into this for now + s.message = /** @type {SpinnerControl["message"]} */ (key, vars) => originalMessage(t(key, vars)); + //@ts-ignore not getting into this for now + s.stop = /** @type {SpinnerControl["stop"]} */ (key, vars) => originalStop(t(key, vars)); + //@ts-ignore return s; } diff --git a/src/ui/prompts.js b/src/ui/prompts.js index c5bfc15..b4646eb 100644 --- a/src/ui/prompts.js +++ b/src/ui/prompts.js @@ -8,10 +8,27 @@ import * as p from '@clack/prompts'; import { t } from '../i18n/index.js'; import { isValidPathName } from '../utils.js'; + + +/** + * @typedef {import('../types.js').Language} Language +*/ +/** + * @typedef {import('../types.js').P5Mode} P5Mode +*/ +/** + * @typedef {import('../types.js').DeliveryMode} DeliveryMode +*/ +/** + * @typedef {import('../types.js').SetupType} SetupType +*/ + + + /** * Check if user cancelled a prompt * @param {any} value - The prompt response value - * @returns {boolean} True if cancelled + * @returns {value is symbol} True if cancelled */ export function isCancel(value) { return p.isCancel(value); @@ -20,7 +37,7 @@ export function isCancel(value) { /** * Prompt for project path * @param {string} initialValue - Initial/default value - * @returns {Promise} User's input + * @returns {Promise} User's input */ export async function promptProjectPath(initialValue) { return await p.text({ @@ -51,7 +68,7 @@ export async function promptProjectPath(initialValue) { /** * Prompt for setup type selection - * @returns {Promise} Selected setup type ('basic', 'standard', or 'custom') + * @returns {Promise} Selected setup type ('basic', 'standard', or 'custom') */ export async function promptSetupType() { const result = await p.select({ @@ -79,7 +96,7 @@ export async function promptSetupType() { /** * Prompt for language selection - * @returns {Promise} Selected language ('javascript' or 'typescript') + * @returns {Promise} Selected language ('javascript' or 'typescript') */ export async function promptLanguage() { return await p.select({ @@ -101,7 +118,7 @@ export async function promptLanguage() { /** * Prompt for p5.js mode selection - * @returns {Promise} Selected mode ('global' or 'instance') + * @returns {Promise} Selected mode ('global' or 'instance') */ export async function promptP5Mode() { return await p.select({ @@ -123,11 +140,11 @@ export async function promptP5Mode() { /** * Prompt for language and mode selection using two separate prompts - * @returns {Promise} Array of selected values: ['javascript'|'typescript', 'global'|'instance'] + * @returns {Promise} Either the cancel signal or a tuple of selected values: ['javascript'|'typescript', 'global'|'instance'] */ export async function promptLanguageAndMode() { const language = await promptLanguage(); - if (isCancel(language)) { + if (isCancel(language)) { return language; // Return the cancel symbol } @@ -143,7 +160,7 @@ export async function promptLanguageAndMode() { * Prompt for version selection * @param {string[]} versions - Available versions * @param {string} latest - Latest version - * @returns {Promise} Selected version + * @returns {Promise} Selected version or symbol if cancelled */ export async function promptVersion(versions, latest) { return await p.select({ @@ -158,7 +175,7 @@ export async function promptVersion(versions, latest) { /** * Prompt for delivery mode selection - * @returns {Promise} Selected mode ('cdn' or 'local') + * @returns {Promise} Selected mode ('cdn' or 'local') or symbol if cancelled */ export async function promptMode() { return await p.select({ @@ -180,7 +197,7 @@ export async function promptMode() { /** * Prompt for update action selection - * @returns {Promise} Selected action ('version', 'mode', or 'cancel') + * @returns {Promise} Selected action ('version', 'mode', or 'cancel') or symbol if cancelled */ export async function promptUpdateAction() { return await p.select({ @@ -207,7 +224,7 @@ export async function promptUpdateAction() { /** * Confirm deletion of lib directory - * @returns {Promise} User's confirmation + * @returns {Promise} User's confirmation or symbol if cancelled */ export async function confirmDeleteLib() { return await p.confirm({ diff --git a/src/utils.js b/src/utils.js index cb01edc..9cbadd9 100644 --- a/src/utils.js +++ b/src/utils.js @@ -6,6 +6,21 @@ import fs from 'fs/promises'; import path from 'path'; import { uniqueNamesGenerator, adjectives, colors, animals } from 'unique-names-generator'; +/** + * @typedef {import('./types.js').Language} Language +*/ +/** + * @typedef {import('./types.js').P5Mode} P5Mode +*/ +/** + * @typedef {import('./types.js').DeliveryMode} DeliveryMode +*/ +/** + * @typedef {import('./types.js').SetupType} SetupType +*/ + + + /** * Copies all files from a template directory to a target directory. * Creates the target directory if it doesn't exist. @@ -270,16 +285,21 @@ export function validateP5Mode(mode) { * Validates setup type selections * * @param {string} type - Setup type to validate - * @returns {string|null} Error message if invalid, null otherwise + * @returns {type is SetupType} */ export function validateSetupType(type) { - const validTypes = ['basic', 'standard', 'custom']; - if (!validTypes.includes(type)) { - return `Invalid setup type: ${type}. Must be one of: ${validTypes.join(', ')}`; - } - return null; + return (/** @type {string[]} */(getValidSetupTypes())).includes(type); } + +/** + * @returns {SetupType[]} + */ +export function getValidSetupTypes(){ + /**@type {SetupType[]} */ + const validTypes = ['basic', 'standard', 'custom']; + return validTypes; +} /** * Determines template directory name from language and mode * @param {string} language - 'javascript' or 'typescript' @@ -310,13 +330,13 @@ export function validateVersion(version, availableVersions, latest) { return null; } - /** * Generates a random, memorable project name using adjectives and animals * @returns {string} A random project name (e.g., 'brave-elephant', 'blue-tiger') */ export function generateProjectName() { const useColor = Math.random() < 0.5; + /**@type {import('unique-names-generator').Config} */ const config = { dictionaries: useColor ? [colors, animals] : [adjectives, animals], separator: '-', @@ -353,7 +373,11 @@ export function hasValidEnding(trimmedPath) { * @returns {boolean} True if valid, false otherwise */ export function isNotReservedName(trimmedPath) { - return !/^(con|prn|aux|nul|com[0-9]|lpt[0-9])$/i.test(trimmedPath.split('.')[0]); + const firstPart = trimmedPath.split('.')[0]; + if (!firstPart){ + return true; //it's falsy, but it's not reserved! + } + return !/^(con|prn|aux|nul|com[0-9]|lpt[0-9])$/i.test(firstPart); } /** diff --git a/src/version.js b/src/version.js index d88c39c..ac517f4 100644 --- a/src/version.js +++ b/src/version.js @@ -1,5 +1,6 @@ import { writeFile } from './utils.js'; import { t } from './i18n/index.js'; +import { isFetchErrorCandidate } from './exceptionUtils.js'; /** * Checks if a version string is a stable release (semver compliant: X.Y.Z) @@ -10,6 +11,16 @@ export function isStableVersion(version) { return /^\d+\.\d+\.\d+$/.test(version); } +/** + * @typedef {[string, string, string, string, string | undefined]} VersionMatchArray + * The structure of the match array: + * 0: Full match (e.g., "1.2.3-beta") + * 1: Major (e.g., "1") + * 2: Minor (e.g., "2") + * 3: Patch (e.g., "3") + * 4: Prerelease (e.g., "beta" or undefined) + */ + /** * Parses a semantic version string into its components * @param {string} version - Version string to parse (e.g., "1.9.0" or "2.1.0-rc.1") @@ -17,9 +28,10 @@ export function isStableVersion(version) { */ export function parseVersion(version) { const match = version.match(/^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/); - if (!match) { + if (!isVersionMatch(match)) { throw new Error(`Invalid semver version: ${version}`); } + return { major: parseInt(match[1], 10), minor: parseInt(match[2], 10), @@ -28,6 +40,17 @@ export function parseVersion(version) { }; } +/** + * Type guard to assert that the match result is a non-null array with the expected length (5 elements, including capture group 0). + * @param {RegExpMatchArray | null} match + * @returns {match is VersionMatchArray} + */ +function isVersionMatch(match) { + // Check for null and ensure the array has the expected length of 5. + // (Full match + 3 main groups + 1 optional group = 5) + return match !== null && match.length === 5; +} + /** * Determines whether to use @types/p5 or bundled types based on p5.js version @@ -90,6 +113,10 @@ export async function fetchVersions(includePrerelease = false) { return { latest, versions }; } catch (error) { + if (!isFetchErrorCandidate(error) ){ + throw new Error('An unknown object was thrown:', {cause: error}); + } + if (error.message.includes('fetch failed') || error.code === 'ENOTFOUND') { throw new Error('Unable to reach jsdelivr CDN API. Please check your internet connection and try again.'); } @@ -97,15 +124,16 @@ export async function fetchVersions(includePrerelease = false) { } } + /** * Downloads p5.js files for local mode from jsdelivr CDN * @param {string} version - The p5.js version to download * @param {string} targetDir - The directory path where files should be saved - * @param {Object} [spinner] - Optional spinner object with stop() method for progress feedback + * @param {import('./ui/display.js').SpinnerControl} [spinner] - Optional spinner object with stop() method for progress feedback * @returns {Promise} * @throws {Error} If download fails or files cannot be written */ -export async function downloadP5Files(version, targetDir, spinner = null) { +export async function downloadP5Files(version, targetDir, spinner = undefined) { const cdnBase = 'https://cdn.jsdelivr.net/npm'; // Download both regular and minified versions @@ -117,7 +145,13 @@ export async function downloadP5Files(version, targetDir, spinner = null) { try { for (const file of files) { if (spinner) { - spinner.message(t('spinner.downloadingP5File', { filename: file.name })); + // spinner.message( + // t('spinner.downloadingP5File', { filename: file.name } + // )); + + spinner.message( + "potato" + ); } const response = await fetch(file.url); @@ -135,6 +169,9 @@ export async function downloadP5Files(version, targetDir, spinner = null) { spinner.stop(t('spinner.downloadedP5')); } } catch (error) { + if (!isFetchErrorCandidate(error)){ + throw new Error('An unknown object was thrown:', {cause: error}); + } if (spinner) { spinner.stop(t('spinner.failedP5')); } @@ -155,13 +192,13 @@ export async function downloadP5Files(version, targetDir, spinner = null) { * For global-mode sketches, downloads both global.d.ts and main definition file. * @param {string} p5Version - The p5.js version to download type definitions for * @param {string} targetDir - The directory path where type definitions should be saved - * @param {Object} [spinner] - Optional spinner object with stop() method for progress feedback + * @param {import('./ui/display.js').SpinnerControl} [spinner] - Optional spinner object with stop() method for progress feedback * @param {string} [template] - The template being used ('instance', 'basic', 'typescript', 'empty') * @param {string} [previousVersion] - Optional previous p5.js version (for detecting major version changes) * @returns {Promise} The actual types version used * @throws {Error} If download fails */ -export async function downloadTypeDefinitions(p5Version, targetDir, spinner = null, template = null, previousVersion = null) { +export async function downloadTypeDefinitions(p5Version, targetDir, spinner = undefined, template = undefined, previousVersion = undefined) { const cdnBase = 'https://cdn.jsdelivr.net/npm'; const isInstanceMode = template === 'instance'; @@ -250,6 +287,10 @@ export async function downloadTypeDefinitions(p5Version, targetDir, spinner = nu return typesVersion; } } catch (error) { + if (!isFetchErrorCandidate(error)){ + throw new Error("An unknown error object was thrown", {cause: error}); + } + if (spinner) { spinner.stop(t('spinner.failedTypes')); } diff --git a/tests/config.test.js b/tests/config.test.js index 1049183..2074aff 100644 --- a/tests/config.test.js +++ b/tests/config.test.js @@ -19,8 +19,10 @@ describe('ConfigManager', () => { const cfg = await readConfig(configPath); expect(cfg).not.toBeNull(); - expect(cfg.version).toBe('1.9.0'); - expect(cfg.mode).toBe('cdn'); + if (cfg){ + expect(cfg.version).toBe('1.9.0'); + expect(cfg.mode).toBe('cdn'); + } // cleanup await fs.rm(tmpDir, { recursive: true, force: true }); diff --git a/tsconfig.json b/tsconfig.json index a8d9d37..85c9c31 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,5 +21,6 @@ "strict": true, "skipLibCheck": true, }, - "exclude": ["templates"] + //TODO: bring tests back in to the type-checking + "exclude": ["templates", "tests"] }