chore: updated CLI
3e2df695
4 file(s) · +468 −52
| 12 | 12 | ||
| 13 | 13 | The goal of Norns is to provide the Ethereum ecosystem a set of simple yet powerful web components for building decentralized applications. The advantage we have today is that we've experienced good DX from modern frameworks, so we have the ability to build components that feel familiar to devs building UIs for smart contracts. We will start small but slowly grow the offering as we get a better feel for what devs need; check out the [Roadmap](#roadmap) for more information. |
|
| 14 | 14 | ||
| 15 | - | ## Developer Setup |
|
| 15 | + | ## Local Development Setup |
|
| 16 | 16 | ||
| 17 | 17 | 1. Clone and install dependencies with [Bun](https://bun.sh) |
|
| 18 | 18 | ||
| 41 | 41 | **CLI** |
|
| 42 | 42 | ||
| 43 | 43 | - [x] Implement `norns.json` initialization |
|
| 44 | - | - [ ] Improve styles and UX of commands and help menus |
|
| 44 | + | - [x] Improve styles and UX of commands and help menus |
|
| 45 | 45 | ||
| 46 | 46 | **Components** |
|
| 47 | 47 | ||
| 9 | 9 | ||
| 10 | 10 | import { fileURLToPath } from "url"; |
|
| 11 | 11 | import { dirname } from "path"; |
|
| 12 | + | import * as colors from "./utils/colors.js"; |
|
| 13 | + | import yoctoSpinner from "./utils/spinner.js"; |
|
| 12 | 14 | ||
| 13 | 15 | const __filename = fileURLToPath(import.meta.url); |
|
| 14 | 16 | ||
| 32 | 34 | const configContent = await readFile(CONFIG_FILE, "utf8"); |
|
| 33 | 35 | return JSON.parse(configContent); |
|
| 34 | 36 | } catch (error) { |
|
| 35 | - | console.error(`✗ Failed to load ${CONFIG_FILE}:`, error); |
|
| 37 | + | console.error(colors.red(`✗ Failed to load ${CONFIG_FILE}:`), error); |
|
| 36 | 38 | return null; |
|
| 37 | 39 | } |
|
| 38 | 40 | } |
|
| 39 | 41 | ||
| 40 | 42 | async function saveConfig(config: NornsConfig): Promise<void> { |
|
| 43 | + | const spinner = yoctoSpinner({ text: `Saving ${CONFIG_FILE}` }).start(); |
|
| 41 | 44 | try { |
|
| 42 | 45 | await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), "utf8"); |
|
| 46 | + | spinner.success(`Saved ${CONFIG_FILE}`); |
|
| 43 | 47 | } catch (error) { |
|
| 44 | - | console.error(`✗ Failed to save ${CONFIG_FILE}:`, error); |
|
| 48 | + | spinner.error(`Failed to save ${CONFIG_FILE}`); |
|
| 49 | + | console.error(error); |
|
| 45 | 50 | throw error; |
|
| 46 | 51 | } |
|
| 47 | 52 | } |
|
| 68 | 73 | } |
|
| 69 | 74 | ||
| 70 | 75 | async function init() { |
|
| 71 | - | console.log("⚡ Initializing norns project..."); |
|
| 76 | + | console.log(colors.yellow("∅ Initializing norns project...")); |
|
| 72 | 77 | ||
| 73 | 78 | // Check if components.json already exists |
|
| 74 | 79 | if (existsSync(CONFIG_FILE)) { |
|
| 75 | - | console.log(`▸ ${CONFIG_FILE} already exists`); |
|
| 80 | + | console.log(colors.blue(`▸ ${CONFIG_FILE} already exists`)); |
|
| 76 | 81 | const overwrite = await promptUser( |
|
| 77 | 82 | "Would you like to overwrite it? (y/N)", |
|
| 78 | 83 | "n", |
|
| 79 | 84 | ); |
|
| 80 | 85 | if (overwrite.toLowerCase() !== "y" && overwrite.toLowerCase() !== "yes") { |
|
| 81 | - | console.log("✗ Initialization cancelled"); |
|
| 86 | + | console.log(colors.red("✗ Initialization cancelled")); |
|
| 82 | 87 | return; |
|
| 83 | 88 | } |
|
| 84 | 89 | } |
|
| 85 | 90 | ||
| 86 | - | console.log("\n▸ Setting up your components configuration...\n"); |
|
| 91 | + | console.log(colors.blue("\n▸ Setting up your components configuration...\n")); |
|
| 87 | 92 | ||
| 88 | 93 | // Get component directory path |
|
| 89 | 94 | const componentsPath = await promptUser( |
|
| 98 | 103 | ||
| 99 | 104 | // Create components directory if it doesn't exist |
|
| 100 | 105 | if (!existsSync(componentsPath)) { |
|
| 106 | + | const dirSpinner = yoctoSpinner({ |
|
| 107 | + | text: `Creating ${componentsPath} directory`, |
|
| 108 | + | }).start(); |
|
| 101 | 109 | await mkdir(componentsPath, { recursive: true }); |
|
| 102 | - | console.log(`✓ Created ${componentsPath} directory`); |
|
| 110 | + | dirSpinner.success(`Created ${componentsPath} directory`); |
|
| 103 | 111 | } |
|
| 104 | 112 | ||
| 105 | 113 | // Save the configuration |
|
| 106 | 114 | await saveConfig(config); |
|
| 107 | - | console.log(`✓ Created ${CONFIG_FILE}`); |
|
| 108 | 115 | ||
| 109 | 116 | console.log( |
|
| 110 | - | "\n✓ norns project initialized! You can now add components with:", |
|
| 117 | + | colors.green( |
|
| 118 | + | "\n✓ norns project initialized! You can now add components with:", |
|
| 119 | + | ), |
|
| 111 | 120 | ); |
|
| 112 | - | console.log(" npx norns@latest add <component-name>"); |
|
| 113 | - | console.log(`\n▸ Components will be installed to: ${componentsPath}`); |
|
| 121 | + | console.log(colors.cyan(" npx norns@latest add <component-name>")); |
|
| 122 | + | console.log( |
|
| 123 | + | colors.blue(`\n▸ Components will be installed to: ${componentsPath}`), |
|
| 124 | + | ); |
|
| 114 | 125 | } |
|
| 115 | 126 | ||
| 116 | 127 | async function addComponent(componentName: string | undefined) { |
|
| 117 | 128 | if (!componentName) { |
|
| 118 | - | console.error("✗ Please specify a component name"); |
|
| 119 | - | console.log("Usage: npx norns@latest add <component-name>"); |
|
| 129 | + | console.error(colors.red("✗ Please specify a component name")); |
|
| 130 | + | console.log(colors.cyan("Usage: npx norns@latest add <component-name>")); |
|
| 120 | 131 | process.exit(1); |
|
| 121 | 132 | } |
|
| 122 | 133 | ||
| 123 | - | console.log(`▸ Adding component: ${componentName}`); |
|
| 134 | + | console.log(colors.blue(`▸ Adding component: ${componentName}`)); |
|
| 124 | 135 | ||
| 125 | 136 | // Load configuration |
|
| 126 | 137 | let config = await loadConfig(); |
|
| 127 | 138 | ||
| 128 | 139 | // If no config exists, ask user to run init first or use defaults |
|
| 129 | 140 | if (!config) { |
|
| 130 | - | console.log("▸ No norns.json found."); |
|
| 141 | + | console.log(colors.blue("▸ No norns.json found.")); |
|
| 131 | 142 | const shouldInit = await promptUser( |
|
| 132 | 143 | "Would you like to run 'norns init' first? (Y/n)", |
|
| 133 | 144 | "y", |
|
| 141 | 152 | await init(); |
|
| 142 | 153 | config = await loadConfig(); |
|
| 143 | 154 | } else { |
|
| 144 | - | console.log("▸ Using default configuration..."); |
|
| 155 | + | console.log(colors.blue("▸ Using default configuration...")); |
|
| 145 | 156 | config = DEFAULT_CONFIG; |
|
| 146 | 157 | } |
|
| 147 | 158 | } |
|
| 148 | 159 | ||
| 149 | 160 | if (!config) { |
|
| 150 | - | console.error("✗ Failed to initialize configuration"); |
|
| 161 | + | console.error(colors.red("✗ Failed to initialize configuration")); |
|
| 151 | 162 | process.exit(1); |
|
| 152 | 163 | } |
|
| 153 | 164 | ||
| 155 | 166 | ||
| 156 | 167 | // Create components directory if it doesn't exist |
|
| 157 | 168 | if (!existsSync(componentsDir)) { |
|
| 158 | - | console.log( |
|
| 159 | - | `▸ Components directory doesn't exist. Creating ${componentsDir}...`, |
|
| 160 | - | ); |
|
| 169 | + | const dirSpinner = yoctoSpinner({ |
|
| 170 | + | text: `Creating ${componentsDir} directory`, |
|
| 171 | + | }).start(); |
|
| 161 | 172 | await mkdir(componentsDir, { recursive: true }); |
|
| 173 | + | dirSpinner.success(`Created ${componentsDir} directory`); |
|
| 162 | 174 | } |
|
| 163 | 175 | ||
| 164 | 176 | try { |
|
| 165 | 177 | const sourceComponentPath = join(COMPONENTS_DIR, `${componentName}.js`); |
|
| 166 | 178 | ||
| 167 | 179 | if (!existsSync(sourceComponentPath)) { |
|
| 168 | - | console.error(`✗ Component '${componentName}' not found`); |
|
| 169 | - | console.log("Available components:"); |
|
| 170 | - | console.log(" - connect-wallet"); |
|
| 171 | - | console.log(" - contract-call"); |
|
| 180 | + | console.error(colors.red(`✗ Component '${componentName}' not found`)); |
|
| 181 | + | console.log(colors.blue("Available components:")); |
|
| 182 | + | console.log(colors.cyan(" - connect-wallet")); |
|
| 183 | + | console.log(colors.cyan(" - contract-call")); |
|
| 172 | 184 | process.exit(1); |
|
| 173 | 185 | } |
|
| 186 | + | ||
| 187 | + | const installSpinner = yoctoSpinner({ |
|
| 188 | + | text: `Installing ${componentName} component`, |
|
| 189 | + | }).start(); |
|
| 174 | 190 | ||
| 175 | 191 | const componentCode = await readFile(sourceComponentPath, "utf8"); |
|
| 176 | 192 | const componentPath = join(componentsDir, `${componentName}.js`); |
|
| 177 | 193 | ||
| 178 | 194 | await writeFile(componentPath, componentCode, "utf8"); |
|
| 179 | 195 | ||
| 180 | - | console.log(`✓ Added ${componentName} to ${componentPath}`); |
|
| 181 | - | console.log(`▸ You can now use it in your HTML:`); |
|
| 182 | - | console.log(` <script src="components/${componentName}.js"></script>`); |
|
| 183 | - | console.log(` <${componentName}></${componentName}>`); |
|
| 196 | + | installSpinner.success(`Added ${componentName} to ${componentPath}`); |
|
| 197 | + | console.log(colors.blue(`▸ You can now use it in your HTML:`)); |
|
| 198 | + | console.log( |
|
| 199 | + | colors.cyan(` <script src="components/${componentName}.js"></script>`), |
|
| 200 | + | ); |
|
| 201 | + | console.log(colors.cyan(` <${componentName}></${componentName}>`)); |
|
| 184 | 202 | } catch (error) { |
|
| 185 | - | console.error(`✗ Failed to add component: ${error}`); |
|
| 203 | + | console.error(colors.red(`✗ Failed to add component: ${error}`)); |
|
| 186 | 204 | process.exit(1); |
|
| 187 | 205 | } |
|
| 188 | 206 | } |
|
| 189 | 207 | ||
| 190 | 208 | function showHelp() { |
|
| 191 | - | console.log(` |
|
| 192 | - | ⚡ norns - Web Component Library CLI |
|
| 209 | + | console.log( |
|
| 210 | + | colors.yellow(` |
|
| 193 | 211 | ||
| 194 | - | Usage: |
|
| 195 | - | npx norns@latest init Initialize a new norns project with norns.json |
|
| 196 | - | npx norns@latest add <component-name> Add a component to your project |
|
| 197 | - | npx norns@latest --help Show this help message |
|
| 212 | + | @ |
|
| 213 | + | @@@@ |
|
| 214 | + | @@@@ @@@@@@@@@ |
|
| 215 | + | @@@@ @@@ @@@@@@ |
|
| 216 | + | @@@@ @@@@ |
|
| 217 | + | @@ @@@@ @@@@ |
|
| 218 | + | @@@@ @@@@ @@@ |
|
| 219 | + | @@@@@@@ @@@@ @@@@ @@@ |
|
| 220 | + | @@@@@@@@@@@ @@@@ @@@ @@@@ |
|
| 221 | + | @@@@ @@@@ @@@@ |
|
| 222 | + | @@@ @@@@ @ @@@@ @ |
|
| 223 | + | @@@ @@@@ @@@@ @@@@ @@@@ @@@ |
|
| 224 | + | @@@ @@@@ @@@@ @@@@ @@@@ @@@@ |
|
| 225 | + | @@@ @@@@ @@@@ @@@@ @@@@ @@@@ |
|
| 226 | + | @@@@ @@@@ @@@@ |
|
| 227 | + | @@@@ @@@@ @@@@ |
|
| 228 | + | @@@@ @@@@ @@@@ @@@@ @@@@ @@@ |
|
| 229 | + | @@@@ @@@@ @@@@ @@@@ @@@@ @@@ |
|
| 230 | + | @@@ @@@@ @@@@ @@@@ @@@@ @@@ |
|
| 231 | + | @ @@@@ @ @@@@ @@@ |
|
| 232 | + | @@@@ @@@@ @@@@ |
|
| 233 | + | @@@@ @@@ @@@@ @@@@@@@@@@@ |
|
| 234 | + | @@@ @@@@ @@@@ @@@@@@ |
|
| 235 | + | @@@ @@@@ @@@@ |
|
| 236 | + | @@@@ @@@@ @@ |
|
| 237 | + | @@@@ @@@@ |
|
| 238 | + | @@@@@ @@@ @@@@ |
|
| 239 | + | @@@@@@@@@ @@@@ |
|
| 240 | + | @@@@ |
|
| 241 | + | @ |
|
| 242 | + | `), |
|
| 243 | + | ); |
|
| 244 | + | console.log( |
|
| 245 | + | colors.yellow(`\n∅ norns - web components for decentralized applications`), |
|
| 246 | + | colors.cyan("\nhttps://github.com/stevedylandev/norns\n"), |
|
| 247 | + | ); |
|
| 248 | + | console.log(colors.bold("Usage:")); |
|
| 249 | + | console.log( |
|
| 250 | + | colors.cyan( |
|
| 251 | + | " npx norns@latest init Initialize a new norns project with norns.json", |
|
| 252 | + | ), |
|
| 253 | + | ); |
|
| 254 | + | console.log( |
|
| 255 | + | colors.cyan( |
|
| 256 | + | " npx norns@latest add <component-name> Add a component to your project", |
|
| 257 | + | ), |
|
| 258 | + | ); |
|
| 259 | + | console.log( |
|
| 260 | + | colors.cyan( |
|
| 261 | + | " npx norns@latest --help Show this help message\n", |
|
| 262 | + | ), |
|
| 263 | + | ); |
|
| 198 | 264 | ||
| 199 | - | Examples: |
|
| 200 | - | npx norns@latest init |
|
| 201 | - | npx norns@latest add connect-wallet |
|
| 265 | + | console.log(colors.bold("Examples:")); |
|
| 266 | + | console.log(colors.green(" npx norns@latest init")); |
|
| 267 | + | console.log(colors.green(" npx norns@latest add connect-wallet\n")); |
|
| 202 | 268 | ||
| 203 | - | The init command will: |
|
| 204 | - | - Create a norns.json configuration file |
|
| 205 | - | - Set up your preferred component installation directory |
|
| 206 | - | - Create necessary directories |
|
| 269 | + | console.log(colors.bold("The init command will:")); |
|
| 270 | + | console.log(colors.blue(" - Create a norns.json configuration file")); |
|
| 271 | + | console.log( |
|
| 272 | + | colors.blue(" - Set up your preferred component installation directory"), |
|
| 273 | + | ); |
|
| 274 | + | console.log(colors.blue(" - Create necessary directories\n")); |
|
| 207 | 275 | ||
| 208 | - | Available Components: |
|
| 209 | - | - connect-wallet A Web3 wallet connection component |
|
| 210 | - | - contract-call A Web3 contract interaction component |
|
| 276 | + | console.log(colors.bold("Available Components:")); |
|
| 277 | + | console.log( |
|
| 278 | + | colors.cyan(" - connect-wallet A Web3 wallet connection component"), |
|
| 279 | + | ); |
|
| 280 | + | console.log( |
|
| 281 | + | colors.cyan( |
|
| 282 | + | " - contract-call A Web3 contract interaction component\n", |
|
| 283 | + | ), |
|
| 284 | + | ); |
|
| 211 | 285 | ||
| 212 | - | Configuration: |
|
| 213 | - | The norns.json file controls where components are installed. |
|
| 214 | - | You can customize the installation directory during init or edit the file directly. |
|
| 215 | - | `); |
|
| 286 | + | console.log(colors.bold("Configuration:")); |
|
| 287 | + | console.log( |
|
| 288 | + | colors.blue( |
|
| 289 | + | " The norns.json file controls where components are installed.", |
|
| 290 | + | ), |
|
| 291 | + | ); |
|
| 292 | + | console.log( |
|
| 293 | + | colors.blue( |
|
| 294 | + | " You can customize the installation directory during init or edit the file directly.", |
|
| 295 | + | ), |
|
| 296 | + | ); |
|
| 216 | 297 | } |
|
| 217 | 298 | ||
| 218 | 299 | // Parse command line arguments using Node's parseArgs |
|
| 247 | 328 | if (!command) { |
|
| 248 | 329 | showHelp(); |
|
| 249 | 330 | } else { |
|
| 250 | - | console.error(`✗ Unknown command: ${command}`); |
|
| 331 | + | console.error(colors.red(`✗ Unknown command: ${command}`)); |
|
| 251 | 332 | showHelp(); |
|
| 252 | 333 | process.exit(1); |
|
| 253 | 334 | } |
|
| 1 | + | import tty from "node:tty"; |
|
| 2 | + | ||
| 3 | + | // eslint-disable-next-line no-warning-comments |
|
| 4 | + | // TODO: Use a better method when it's added to Node.js (https://github.com/nodejs/node/pull/40240) |
|
| 5 | + | // Lots of optionals here to support Deno. |
|
| 6 | + | const hasColors = tty?.WriteStream?.prototype?.hasColors?.() ?? false; |
|
| 7 | + | ||
| 8 | + | const format = (open, close) => { |
|
| 9 | + | if (!hasColors) { |
|
| 10 | + | return (input) => input; |
|
| 11 | + | } |
|
| 12 | + | ||
| 13 | + | const openCode = `\u001B[${open}m`; |
|
| 14 | + | const closeCode = `\u001B[${close}m`; |
|
| 15 | + | ||
| 16 | + | return (input) => { |
|
| 17 | + | const string = input + ""; // eslint-disable-line no-implicit-coercion -- This is faster. |
|
| 18 | + | let index = string.indexOf(closeCode); |
|
| 19 | + | ||
| 20 | + | if (index === -1) { |
|
| 21 | + | // Note: Intentionally not using string interpolation for performance reasons. |
|
| 22 | + | return openCode + string + closeCode; |
|
| 23 | + | } |
|
| 24 | + | ||
| 25 | + | // Handle nested colors. |
|
| 26 | + | ||
| 27 | + | // We could have done this, but it's too slow (as of Node.js 22). |
|
| 28 | + | // return openCode + string.replaceAll(closeCode, (close === 22 ? closeCode : '') + openCode) + closeCode; |
|
| 29 | + | ||
| 30 | + | let result = openCode; |
|
| 31 | + | let lastIndex = 0; |
|
| 32 | + | ||
| 33 | + | // SGR 22 resets both bold (1) and dim (2). When we encounter a nested |
|
| 34 | + | // close for styles that use 22, we need to re-open the outer style. |
|
| 35 | + | const reopenOnNestedClose = close === 22; |
|
| 36 | + | const replaceCode = (reopenOnNestedClose ? closeCode : "") + openCode; |
|
| 37 | + | ||
| 38 | + | while (index !== -1) { |
|
| 39 | + | result += string.slice(lastIndex, index) + replaceCode; |
|
| 40 | + | lastIndex = index + closeCode.length; |
|
| 41 | + | index = string.indexOf(closeCode, lastIndex); |
|
| 42 | + | } |
|
| 43 | + | ||
| 44 | + | result += string.slice(lastIndex) + closeCode; |
|
| 45 | + | ||
| 46 | + | return result; |
|
| 47 | + | }; |
|
| 48 | + | }; |
|
| 49 | + | ||
| 50 | + | export const reset = format(0, 0); |
|
| 51 | + | export const bold = format(1, 22); |
|
| 52 | + | export const dim = format(2, 22); |
|
| 53 | + | export const italic = format(3, 23); |
|
| 54 | + | export const underline = format(4, 24); |
|
| 55 | + | export const overline = format(53, 55); |
|
| 56 | + | export const inverse = format(7, 27); |
|
| 57 | + | export const hidden = format(8, 28); |
|
| 58 | + | export const strikethrough = format(9, 29); |
|
| 59 | + | ||
| 60 | + | export const black = format(30, 39); |
|
| 61 | + | export const red = format(31, 39); |
|
| 62 | + | export const green = format(32, 39); |
|
| 63 | + | export const yellow = format(33, 39); |
|
| 64 | + | export const blue = format(34, 39); |
|
| 65 | + | export const magenta = format(35, 39); |
|
| 66 | + | export const cyan = format(36, 39); |
|
| 67 | + | export const white = format(37, 39); |
|
| 68 | + | export const gray = format(90, 39); |
|
| 69 | + | ||
| 70 | + | export const bgBlack = format(40, 49); |
|
| 71 | + | export const bgRed = format(41, 49); |
|
| 72 | + | export const bgGreen = format(42, 49); |
|
| 73 | + | export const bgYellow = format(43, 49); |
|
| 74 | + | export const bgBlue = format(44, 49); |
|
| 75 | + | export const bgMagenta = format(45, 49); |
|
| 76 | + | export const bgCyan = format(46, 49); |
|
| 77 | + | export const bgWhite = format(47, 49); |
|
| 78 | + | export const bgGray = format(100, 49); |
|
| 79 | + | ||
| 80 | + | export const redBright = format(91, 39); |
|
| 81 | + | export const greenBright = format(92, 39); |
|
| 82 | + | export const yellowBright = format(93, 39); |
|
| 83 | + | export const blueBright = format(94, 39); |
|
| 84 | + | export const magentaBright = format(95, 39); |
|
| 85 | + | export const cyanBright = format(96, 39); |
|
| 86 | + | export const whiteBright = format(97, 39); |
|
| 87 | + | ||
| 88 | + | export const bgRedBright = format(101, 49); |
|
| 89 | + | export const bgGreenBright = format(102, 49); |
|
| 90 | + | export const bgYellowBright = format(103, 49); |
|
| 91 | + | export const bgBlueBright = format(104, 49); |
|
| 92 | + | export const bgMagentaBright = format(105, 49); |
|
| 93 | + | export const bgCyanBright = format(106, 49); |
|
| 94 | + | export const bgWhiteBright = format(107, 49); |
| 1 | + | // https://github.com/sindresorhus/yocto-spinner/blob/main/index.js |
|
| 2 | + | import process from "node:process"; |
|
| 3 | + | import { stripVTControlCharacters } from "node:util"; |
|
| 4 | + | import * as yoctocolors from "./colors"; |
|
| 5 | + | ||
| 6 | + | const isUnicodeSupported = |
|
| 7 | + | process.platform !== "win32" || |
|
| 8 | + | Boolean(process.env.WT_SESSION) || // Windows Terminal |
|
| 9 | + | process.env.TERM_PROGRAM === "vscode"; |
|
| 10 | + | ||
| 11 | + | const isInteractive = (stream) => |
|
| 12 | + | Boolean( |
|
| 13 | + | stream.isTTY && process.env.TERM !== "dumb" && !("CI" in process.env), |
|
| 14 | + | ); |
|
| 15 | + | ||
| 16 | + | const infoSymbol = yoctocolors.blue(isUnicodeSupported ? "ℹ" : "i"); |
|
| 17 | + | const successSymbol = yoctocolors.green(isUnicodeSupported ? "✔" : "√"); |
|
| 18 | + | const warningSymbol = yoctocolors.yellow(isUnicodeSupported ? "⚠" : "‼"); |
|
| 19 | + | const errorSymbol = yoctocolors.red(isUnicodeSupported ? "✖" : "×"); |
|
| 20 | + | ||
| 21 | + | const defaultSpinner = { |
|
| 22 | + | frames: isUnicodeSupported |
|
| 23 | + | ? ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] |
|
| 24 | + | : ["-", "\\", "|", "/"], |
|
| 25 | + | interval: 80, |
|
| 26 | + | }; |
|
| 27 | + | ||
| 28 | + | class YoctoSpinner { |
|
| 29 | + | #frames; |
|
| 30 | + | #interval; |
|
| 31 | + | #currentFrame = -1; |
|
| 32 | + | #timer; |
|
| 33 | + | #text; |
|
| 34 | + | #stream; |
|
| 35 | + | #color; |
|
| 36 | + | #lines = 0; |
|
| 37 | + | #exitHandlerBound; |
|
| 38 | + | #isInteractive; |
|
| 39 | + | #lastSpinnerFrameTime = 0; |
|
| 40 | + | #isSpinning = false; |
|
| 41 | + | ||
| 42 | + | constructor(options = {}) { |
|
| 43 | + | const spinner = options.spinner ?? defaultSpinner; |
|
| 44 | + | this.#frames = spinner.frames; |
|
| 45 | + | this.#interval = spinner.interval; |
|
| 46 | + | this.#text = options.text ?? ""; |
|
| 47 | + | this.#stream = options.stream ?? process.stderr; |
|
| 48 | + | this.#color = options.color ?? "cyan"; |
|
| 49 | + | this.#isInteractive = isInteractive(this.#stream); |
|
| 50 | + | this.#exitHandlerBound = this.#exitHandler.bind(this); |
|
| 51 | + | } |
|
| 52 | + | ||
| 53 | + | start(text) { |
|
| 54 | + | if (text) { |
|
| 55 | + | this.#text = text; |
|
| 56 | + | } |
|
| 57 | + | ||
| 58 | + | if (this.isSpinning) { |
|
| 59 | + | return this; |
|
| 60 | + | } |
|
| 61 | + | ||
| 62 | + | this.#isSpinning = true; |
|
| 63 | + | this.#hideCursor(); |
|
| 64 | + | this.#render(); |
|
| 65 | + | this.#subscribeToProcessEvents(); |
|
| 66 | + | ||
| 67 | + | // Only start the timer in interactive mode |
|
| 68 | + | if (this.#isInteractive) { |
|
| 69 | + | this.#timer = setInterval(() => { |
|
| 70 | + | this.#render(); |
|
| 71 | + | }, this.#interval); |
|
| 72 | + | } |
|
| 73 | + | ||
| 74 | + | return this; |
|
| 75 | + | } |
|
| 76 | + | ||
| 77 | + | stop(finalText) { |
|
| 78 | + | if (!this.isSpinning) { |
|
| 79 | + | return this; |
|
| 80 | + | } |
|
| 81 | + | ||
| 82 | + | this.#isSpinning = false; |
|
| 83 | + | if (this.#timer) { |
|
| 84 | + | clearInterval(this.#timer); |
|
| 85 | + | this.#timer = undefined; |
|
| 86 | + | } |
|
| 87 | + | ||
| 88 | + | this.#showCursor(); |
|
| 89 | + | this.clear(); |
|
| 90 | + | this.#unsubscribeFromProcessEvents(); |
|
| 91 | + | ||
| 92 | + | if (finalText) { |
|
| 93 | + | this.#stream.write(`${finalText}\n`); |
|
| 94 | + | } |
|
| 95 | + | ||
| 96 | + | return this; |
|
| 97 | + | } |
|
| 98 | + | ||
| 99 | + | #symbolStop(symbol, text) { |
|
| 100 | + | return this.stop(`${symbol} ${text ?? this.#text}`); |
|
| 101 | + | } |
|
| 102 | + | ||
| 103 | + | success(text) { |
|
| 104 | + | return this.#symbolStop(successSymbol, text); |
|
| 105 | + | } |
|
| 106 | + | ||
| 107 | + | error(text) { |
|
| 108 | + | return this.#symbolStop(errorSymbol, text); |
|
| 109 | + | } |
|
| 110 | + | ||
| 111 | + | warning(text) { |
|
| 112 | + | return this.#symbolStop(warningSymbol, text); |
|
| 113 | + | } |
|
| 114 | + | ||
| 115 | + | info(text) { |
|
| 116 | + | return this.#symbolStop(infoSymbol, text); |
|
| 117 | + | } |
|
| 118 | + | ||
| 119 | + | get isSpinning() { |
|
| 120 | + | return this.#isSpinning; |
|
| 121 | + | } |
|
| 122 | + | ||
| 123 | + | get text() { |
|
| 124 | + | return this.#text; |
|
| 125 | + | } |
|
| 126 | + | ||
| 127 | + | set text(value) { |
|
| 128 | + | this.#text = value ?? ""; |
|
| 129 | + | this.#render(); |
|
| 130 | + | } |
|
| 131 | + | ||
| 132 | + | get color() { |
|
| 133 | + | return this.#color; |
|
| 134 | + | } |
|
| 135 | + | ||
| 136 | + | set color(value) { |
|
| 137 | + | this.#color = value; |
|
| 138 | + | this.#render(); |
|
| 139 | + | } |
|
| 140 | + | ||
| 141 | + | clear() { |
|
| 142 | + | if (!this.#isInteractive) { |
|
| 143 | + | return this; |
|
| 144 | + | } |
|
| 145 | + | ||
| 146 | + | this.#stream.cursorTo(0); |
|
| 147 | + | ||
| 148 | + | for (let index = 0; index < this.#lines; index++) { |
|
| 149 | + | if (index > 0) { |
|
| 150 | + | this.#stream.moveCursor(0, -1); |
|
| 151 | + | } |
|
| 152 | + | ||
| 153 | + | this.#stream.clearLine(1); |
|
| 154 | + | } |
|
| 155 | + | ||
| 156 | + | this.#lines = 0; |
|
| 157 | + | ||
| 158 | + | return this; |
|
| 159 | + | } |
|
| 160 | + | ||
| 161 | + | #render() { |
|
| 162 | + | // Ensure we only update the spinner frame at the wanted interval, |
|
| 163 | + | // even if the frame method is called more often. |
|
| 164 | + | const now = Date.now(); |
|
| 165 | + | if ( |
|
| 166 | + | this.#currentFrame === -1 || |
|
| 167 | + | now - this.#lastSpinnerFrameTime >= this.#interval |
|
| 168 | + | ) { |
|
| 169 | + | this.#currentFrame = ++this.#currentFrame % this.#frames.length; |
|
| 170 | + | this.#lastSpinnerFrameTime = now; |
|
| 171 | + | } |
|
| 172 | + | ||
| 173 | + | const applyColor = yoctocolors[this.#color] ?? yoctocolors.cyan; |
|
| 174 | + | const frame = this.#frames[this.#currentFrame]; |
|
| 175 | + | let string = `${applyColor(frame)} ${this.#text}`; |
|
| 176 | + | ||
| 177 | + | if (!this.#isInteractive) { |
|
| 178 | + | string += "\n"; |
|
| 179 | + | } |
|
| 180 | + | ||
| 181 | + | this.clear(); |
|
| 182 | + | this.#write(string); |
|
| 183 | + | ||
| 184 | + | if (this.#isInteractive) { |
|
| 185 | + | this.#lines = this.#lineCount(string); |
|
| 186 | + | } |
|
| 187 | + | } |
|
| 188 | + | ||
| 189 | + | #write(text) { |
|
| 190 | + | this.#stream.write(text); |
|
| 191 | + | } |
|
| 192 | + | ||
| 193 | + | #lineCount(text) { |
|
| 194 | + | const width = this.#stream.columns ?? 80; |
|
| 195 | + | const lines = stripVTControlCharacters(text).split("\n"); |
|
| 196 | + | ||
| 197 | + | let lineCount = 0; |
|
| 198 | + | for (const line of lines) { |
|
| 199 | + | lineCount += Math.max(1, Math.ceil(line.length / width)); |
|
| 200 | + | } |
|
| 201 | + | ||
| 202 | + | return lineCount; |
|
| 203 | + | } |
|
| 204 | + | ||
| 205 | + | #hideCursor() { |
|
| 206 | + | if (this.#isInteractive) { |
|
| 207 | + | this.#write("\u001B[?25l"); |
|
| 208 | + | } |
|
| 209 | + | } |
|
| 210 | + | ||
| 211 | + | #showCursor() { |
|
| 212 | + | if (this.#isInteractive) { |
|
| 213 | + | this.#write("\u001B[?25h"); |
|
| 214 | + | } |
|
| 215 | + | } |
|
| 216 | + | ||
| 217 | + | #subscribeToProcessEvents() { |
|
| 218 | + | process.once("SIGINT", this.#exitHandlerBound); |
|
| 219 | + | process.once("SIGTERM", this.#exitHandlerBound); |
|
| 220 | + | } |
|
| 221 | + | ||
| 222 | + | #unsubscribeFromProcessEvents() { |
|
| 223 | + | process.off("SIGINT", this.#exitHandlerBound); |
|
| 224 | + | process.off("SIGTERM", this.#exitHandlerBound); |
|
| 225 | + | } |
|
| 226 | + | ||
| 227 | + | #exitHandler(signal) { |
|
| 228 | + | if (this.isSpinning) { |
|
| 229 | + | this.stop(); |
|
| 230 | + | } |
|
| 231 | + | ||
| 232 | + | // SIGINT: 128 + 2 |
|
| 233 | + | // SIGTERM: 128 + 15 |
|
| 234 | + | const exitCode = signal === "SIGINT" ? 130 : signal === "SIGTERM" ? 143 : 1; |
|
| 235 | + | process.exit(exitCode); |
|
| 236 | + | } |
|
| 237 | + | } |
|
| 238 | + | ||
| 239 | + | export default function yoctoSpinner(options) { |
|
| 240 | + | return new YoctoSpinner(options); |
|
| 241 | + | } |