shell-logo 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shell-logo",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "Render colorful ASCII art logos in the terminal from a config file",
6
6
  "license": "MIT",
package/src/config.js CHANGED
@@ -1,22 +1,30 @@
1
+ /**
2
+ * Config loading with XDG-first resolution and legacy migration.
3
+ *
4
+ * Load order:
5
+ * 1. XDG path (~/.config/shell-logo/folders/<hash>/config.json)
6
+ * 2. Legacy path (.shell-logo.json in the working directory)
7
+ *
8
+ * If a legacy config is found but no XDG config exists, the legacy config
9
+ * is automatically copied to XDG (the legacy file is left untouched).
10
+ */
11
+
1
12
  import { readFileSync } from 'node:fs';
2
- import { join } from 'node:path';
3
13
  import chalk from 'chalk';
14
+ import { configPath, legacyConfigPath } from './paths.js';
15
+ import { writeConfig } from './generate.js';
4
16
 
5
17
  export const DEFAULTS = {
6
18
  colors: ['#ff6b6b', '#feca57', '#48dbfb'],
7
19
  font: 'Standard',
8
20
  };
9
21
 
10
- export function tryLoadConfig() {
11
- const configPath = join(process.cwd(), '.shell-logo.json');
12
-
13
- let raw;
14
- try {
15
- raw = readFileSync(configPath, 'utf-8');
16
- } catch {
17
- return null;
18
- }
19
-
22
+ /**
23
+ * Parse a JSON string and validate it as a shell-logo config.
24
+ * Requires a non-empty `text` field. Colors must be an array of 2+ if present.
25
+ * Returns a normalized config object, or null if invalid.
26
+ */
27
+ function parseAndValidate(raw) {
20
28
  let config;
21
29
  try {
22
30
  config = JSON.parse(raw);
@@ -41,51 +49,60 @@ export function tryLoadConfig() {
41
49
  };
42
50
  }
43
51
 
44
- export function loadConfig() {
45
- const configPath = join(process.cwd(), '.shell-logo.json');
46
-
47
- let raw;
52
+ /**
53
+ * Try to load config for `cwd`. Returns the parsed config or null.
54
+ * Checks XDG first, then falls back to legacy .shell-logo.json.
55
+ * Auto-migrates legacy configs to XDG on first load.
56
+ */
57
+ export function tryLoadConfig(cwd) {
58
+ // 1. Try XDG path
48
59
  try {
49
- raw = readFileSync(configPath, 'utf-8');
60
+ const raw = readFileSync(configPath(cwd), 'utf-8');
61
+ const config = parseAndValidate(raw);
62
+ if (config) return config;
50
63
  } catch {
51
- console.error(
52
- chalk.red('Error: ') +
53
- 'No .shell-logo.json found in the current directory.\n\n' +
54
- 'Create one with at least:\n\n' +
55
- ' { "text": "HELLO" }\n\n' +
56
- 'Or copy an example:\n\n' +
57
- ' cp node_modules/shell-logo/examples/.shell-logo.json .'
58
- );
59
- process.exit(1);
64
+ // Not found or unreadable — fall through to legacy
60
65
  }
61
66
 
62
- let config;
67
+ // 2. Try legacy .shell-logo.json in the working directory
68
+ let raw;
63
69
  try {
64
- config = JSON.parse(raw);
70
+ raw = readFileSync(legacyConfigPath(cwd), 'utf-8');
65
71
  } catch {
66
- console.error(chalk.red('Error: ') + '.shell-logo.json contains invalid JSON.');
67
- process.exit(1);
72
+ return null;
68
73
  }
69
74
 
70
- if (!config.text || typeof config.text !== 'string' || config.text.trim() === '') {
71
- console.error(
72
- chalk.red('Error: ') + '"text" is required and must be a non-empty string in .shell-logo.json.'
75
+ const config = parseAndValidate(raw);
76
+ if (!config) return null;
77
+
78
+ // 3. Auto-migrate legacy config to XDG (legacy file is left untouched)
79
+ try {
80
+ writeConfig(config, cwd);
81
+ const xdgPath = configPath(cwd);
82
+ console.log(
83
+ chalk.dim(`Migrated config from .shell-logo.json → ${xdgPath}`)
73
84
  );
74
- process.exit(1);
85
+ } catch {
86
+ // Migration failed — still usable from the legacy path
75
87
  }
76
88
 
77
- if (config.colors !== undefined) {
78
- if (!Array.isArray(config.colors) || config.colors.length < 2) {
79
- console.error(
80
- chalk.red('Error: ') + '"colors" must be an array of at least 2 color strings.'
81
- );
82
- process.exit(1);
83
- }
84
- }
89
+ return config;
90
+ }
85
91
 
86
- return {
87
- text: config.text.trim(),
88
- colors: config.colors ?? DEFAULTS.colors,
89
- font: config.font ?? DEFAULTS.font,
90
- };
92
+ /**
93
+ * Load config for `cwd`, or exit with an error if none is found.
94
+ * Same resolution as tryLoadConfig but treats missing config as fatal.
95
+ */
96
+ export function loadConfig(cwd) {
97
+ const config = tryLoadConfig(cwd);
98
+ if (config) return config;
99
+
100
+ const xdg = configPath(cwd);
101
+ console.error(
102
+ chalk.red('Error: ') +
103
+ `No config found for this folder.\n\n` +
104
+ `Expected at: ${xdg}\n\n` +
105
+ 'Run shell-logo and choose "Generate" to create one.'
106
+ );
107
+ process.exit(1);
91
108
  }
package/src/generate.js CHANGED
@@ -1,8 +1,46 @@
1
- import { writeFileSync } from 'node:fs';
2
- import { join } from 'node:path';
1
+ /**
2
+ * Config persistence writes config to the XDG folder structure.
3
+ *
4
+ * Uses atomic writes (write to tmp file, then rename) to prevent corruption
5
+ * if two terminals write to the same folder config simultaneously.
6
+ */
3
7
 
4
- export function writeConfig(config) {
5
- const configPath = join(process.cwd(), '.shell-logo.json');
6
- writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
7
- return configPath;
8
+ import { writeFileSync, renameSync, mkdirSync } from 'node:fs';
9
+ import { resolve, join, dirname } from 'node:path';
10
+ import { randomBytes } from 'node:crypto';
11
+ import { configPath, folderConfigDir, metaPath } from './paths.js';
12
+
13
+ /**
14
+ * Write data to a file atomically: write to a temp file in the same
15
+ * directory, then rename. This avoids partial reads if another process
16
+ * is reading the file at the same time.
17
+ */
18
+ function atomicWriteSync(targetPath, data) {
19
+ const dir = dirname(targetPath);
20
+ const tmpPath = join(dir, `.tmp.${randomBytes(6).toString('hex')}`);
21
+ writeFileSync(tmpPath, data);
22
+ renameSync(tmpPath, targetPath);
23
+ }
24
+
25
+ /**
26
+ * Persist a config object to the XDG folder for `cwd`.
27
+ * Creates the directory tree if it doesn't exist yet.
28
+ * Also writes a meta.json with the original folder path (best-effort).
29
+ * Returns the path the config was written to.
30
+ */
31
+ export function writeConfig(config, cwd = process.cwd()) {
32
+ const dir = folderConfigDir(cwd);
33
+ mkdirSync(dir, { recursive: true });
34
+
35
+ atomicWriteSync(configPath(cwd), JSON.stringify(config, null, 2) + '\n');
36
+
37
+ // meta.json stores the original folder path so humans can identify
38
+ // which hash directory belongs to which project.
39
+ try {
40
+ atomicWriteSync(metaPath(cwd), JSON.stringify({ path: resolve(cwd) }, null, 2) + '\n');
41
+ } catch {
42
+ // Best-effort — not critical if this fails
43
+ }
44
+
45
+ return configPath(cwd);
8
46
  }
package/src/index.js CHANGED
@@ -1,7 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { readFileSync, writeFileSync, existsSync } from 'node:fs';
4
- import { join } from 'node:path';
3
+ /**
4
+ * Entry point for shell-logo.
5
+ *
6
+ * 1. Run the interactive UI to get a config + session mode (persistent or temporary).
7
+ * 2. If the user chose "Generate", persist the new config to disk.
8
+ * 3. Start the fullscreen render loop with arrow-key theme/font cycling.
9
+ */
10
+
5
11
  import { runInteractiveUI } from './ui.js';
6
12
  import { writeConfig } from './generate.js';
7
13
  import { render } from './renderer.js';
@@ -10,56 +16,26 @@ import { THEMES, FONTS } from './themes.js';
10
16
  import chalk from 'chalk';
11
17
  import * as p from '@clack/prompts';
12
18
 
13
- const { action, config } = await runInteractiveUI();
19
+ const { action, config, persistent } = await runInteractiveUI();
14
20
 
15
21
  if (action === 'generate') {
16
22
  const s = p.spinner();
17
- s.start('Writing .shell-logo.json...');
23
+ s.start('Saving config...');
18
24
  writeConfig(config);
19
25
  s.stop('Config saved!');
20
-
21
- const isGitRepo = existsSync(join(process.cwd(), '.git'));
22
- const gitignorePath = join(process.cwd(), '.gitignore');
23
- let shouldPrompt = isGitRepo;
24
-
25
- if (existsSync(gitignorePath)) {
26
- const content = readFileSync(gitignorePath, 'utf-8');
27
- const lines = content.split('\n').map(l => l.trim());
28
- if (lines.includes('.shell-logo.json')) {
29
- shouldPrompt = false;
30
- }
31
- }
32
-
33
- if (shouldPrompt) {
34
- const addToGitignore = await p.select({
35
- message: 'Add .shell-logo.json to .gitignore?',
36
- options: [
37
- { value: true, label: 'Yes (recommended)' },
38
- { value: false, label: 'No' },
39
- ],
40
- });
41
-
42
- if (p.isCancel(addToGitignore)) {
43
- p.cancel('Cancelled.');
44
- process.exit(0);
45
- }
46
-
47
- if (addToGitignore) {
48
- if (existsSync(gitignorePath)) {
49
- const existing = readFileSync(gitignorePath, 'utf-8');
50
- const separator = existing.endsWith('\n') ? '' : '\n';
51
- writeFileSync(gitignorePath, existing + separator + '.shell-logo.json\n');
52
- } else {
53
- writeFileSync(gitignorePath, '.shell-logo.json\n');
54
- }
55
- p.log.success('.gitignore updated.');
56
- }
57
- }
58
26
  }
59
27
 
60
- startRenderLoop(config);
61
-
62
- function startRenderLoop(config) {
28
+ startRenderLoop(config, persistent);
29
+
30
+ /**
31
+ * Fullscreen render loop. Draws the logo centered in the terminal and
32
+ * listens for arrow keys to cycle themes (up/down) and fonts (left/right).
33
+ *
34
+ * @param {object} config - Logo config: { text, colors, font }
35
+ * @param {boolean} persistent - When true, arrow key changes are saved to disk.
36
+ * When false (temporary session), changes are in-memory only.
37
+ */
38
+ function startRenderLoop(config, persistent) {
63
39
  let resizeTimer;
64
40
 
65
41
  // Find the current theme index by matching colors
@@ -85,6 +61,7 @@ function startRenderLoop(config) {
85
61
  }
86
62
  }
87
63
 
64
+ /** Briefly show the status bar (theme name, font, controls) for 2 seconds. */
88
65
  function flashStatus() {
89
66
  showStatus = true;
90
67
  clearTimeout(statusTimer);
@@ -94,6 +71,7 @@ function startRenderLoop(config) {
94
71
  }, 2000);
95
72
  }
96
73
 
74
+ /** Debounce re-renders on terminal resize to avoid flickering. */
97
75
  function debouncedRender() {
98
76
  clearTimeout(resizeTimer);
99
77
  resizeTimer = setTimeout(renderLoop, 50);
@@ -140,7 +118,8 @@ function startRenderLoop(config) {
140
118
  } else {
141
119
  return;
142
120
  }
143
- writeConfig(config);
121
+ // Only persist theme/font changes in a persistent (non-temporary) session
122
+ if (persistent) writeConfig(config);
144
123
  flashStatus();
145
124
  renderLoop();
146
125
  }
package/src/paths.js ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Path resolution for XDG-based config storage.
3
+ *
4
+ * Configs are stored per-folder under:
5
+ * $XDG_CONFIG_HOME/shell-logo/folders/<hash>/config.json
6
+ *
7
+ * where <hash> is the first 16 hex chars of SHA-256(absolute CWD).
8
+ * Falls back to ~/.config/shell-logo/ when XDG_CONFIG_HOME is unset.
9
+ */
10
+
11
+ import { createHash } from 'node:crypto';
12
+ import { resolve, join } from 'node:path';
13
+ import { homedir } from 'node:os';
14
+
15
+ /** Root directory for all shell-logo config: $XDG_CONFIG_HOME/shell-logo/ */
16
+ export function configRoot() {
17
+ const base = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
18
+ return join(base, 'shell-logo');
19
+ }
20
+
21
+ /** Deterministic 16-char hex hash of the absolute path for `cwd`. */
22
+ export function folderHash(cwd = process.cwd()) {
23
+ return createHash('sha256').update(resolve(cwd)).digest('hex').slice(0, 16);
24
+ }
25
+
26
+ /** Per-folder config directory: <configRoot>/folders/<hash>/ */
27
+ export function folderConfigDir(cwd = process.cwd()) {
28
+ return join(configRoot(), 'folders', folderHash(cwd));
29
+ }
30
+
31
+ /** Path to the per-folder config file (text, colors, font). */
32
+ export function configPath(cwd = process.cwd()) {
33
+ return join(folderConfigDir(cwd), 'config.json');
34
+ }
35
+
36
+ /** Path to the per-folder meta file (stores original absolute path for debugging). */
37
+ export function metaPath(cwd = process.cwd()) {
38
+ return join(folderConfigDir(cwd), 'meta.json');
39
+ }
40
+
41
+ /** Path to the legacy config file (.shell-logo.json in the working directory). */
42
+ export function legacyConfigPath(cwd = process.cwd()) {
43
+ return join(resolve(cwd), '.shell-logo.json');
44
+ }
package/src/themes.js CHANGED
@@ -1,4 +1,8 @@
1
- export const FONTS = ['Standard', 'Big', 'ANSI Shadow', 'Slant', 'Small'];
1
+ export const FONTS = [
2
+ 'Standard', 'Big', 'ANSI Shadow', 'Slant', 'Small',
3
+ 'ANSI Regular', 'Bloody', 'DOS Rebel', 'Graffiti', 'Larry 3D',
4
+ 'Star Wars', 'Doh', 'Ghost', 'Fraktur', 'Fire Font-k',
5
+ ];
2
6
 
3
7
  export const THEMES = [
4
8
  { name: 'Sunset', colors: ['#ff6b6b', '#ff9f43', '#feca57'] },
@@ -9,4 +13,11 @@ export const THEMES = [
9
13
  { name: 'Fire', colors: ['#ff4757', '#ff6348', '#ffdd59'] },
10
14
  { name: 'Pastel', colors: ['#fd79a8', '#feca57', '#48dbfb'] },
11
15
  { name: 'Monochrome', colors: ['#ffffff', '#54a0ff', '#5f27cd'] },
16
+ { name: 'Aurora', colors: ['#00d2ff', '#3a7bd5', '#7b2ff7'] },
17
+ { name: 'Cherry', colors: ['#eb3349', '#f45c43', '#ff8a80'] },
18
+ { name: 'Cyberpunk', colors: ['#f72585', '#7209b7', '#4cc9f0'] },
19
+ { name: 'Arctic', colors: ['#e0eafc', '#cfdef3', '#74b9ff'] },
20
+ { name: 'Emerald', colors: ['#11998e', '#38ef7d', '#b8ff96'] },
21
+ { name: 'Midnight', colors: ['#0f0c29', '#302b63', '#24c6dc'] },
22
+ { name: 'Rose Gold', colors: ['#f4c4f3', '#fc5c7d', '#fda085'] },
12
23
  ];
package/src/ui.js CHANGED
@@ -1,3 +1,17 @@
1
+ /**
2
+ * Interactive CLI prompts for shell-logo.
3
+ *
4
+ * When a config already exists for the current folder, presents three options:
5
+ * - Run — display the logo, arrow key changes persist to disk
6
+ * - New session — display the logo, changes are in-memory only (temporary)
7
+ * - Generate — walk through the setup wizard to create a new config
8
+ *
9
+ * When no config exists, only the Generate option is shown.
10
+ *
11
+ * Returns { action, config, persistent } where `persistent` controls
12
+ * whether arrow key theme/font changes are saved back to disk.
13
+ */
14
+
1
15
  import * as p from '@clack/prompts';
2
16
  import chalk from 'chalk';
3
17
  import { tryLoadConfig } from './config.js';
@@ -27,8 +41,19 @@ const FONT_OPTIONS = [
27
41
  { value: 'ANSI Shadow', label: 'ANSI Shadow' },
28
42
  { value: 'Slant', label: 'Slant' },
29
43
  { value: 'Small', label: 'Small' },
44
+ { value: 'ANSI Regular', label: 'ANSI Regular' },
45
+ { value: 'Bloody', label: 'Bloody' },
46
+ { value: 'DOS Rebel', label: 'DOS Rebel' },
47
+ { value: 'Graffiti', label: 'Graffiti' },
48
+ { value: 'Larry 3D', label: 'Larry 3D' },
49
+ { value: 'Star Wars', label: 'Star Wars' },
50
+ { value: 'Doh', label: 'Doh' },
51
+ { value: 'Ghost', label: 'Ghost' },
52
+ { value: 'Fraktur', label: 'Fraktur' },
53
+ { value: 'Fire Font-k', label: 'Fire Font-k' },
30
54
  ];
31
55
 
56
+ /** Exit gracefully if the user presses Ctrl+C / Escape during a prompt. */
32
57
  function handleCancel(value) {
33
58
  if (p.isCancel(value)) {
34
59
  p.cancel('Cancelled.');
@@ -37,6 +62,7 @@ function handleCancel(value) {
37
62
  return value;
38
63
  }
39
64
 
65
+ /** Walk the user through the Generate wizard: text, colors, font. */
40
66
  async function promptGenerate() {
41
67
  const text = handleCancel(
42
68
  await p.text({
@@ -77,21 +103,53 @@ async function promptGenerate() {
77
103
  return {
78
104
  action: 'generate',
79
105
  config: { text: text.trim(), colors, font },
106
+ persistent: true,
80
107
  };
81
108
  }
82
109
 
110
+ /**
111
+ * Main entry point for the interactive CLI.
112
+ * Returns { action: 'generate'|'run', config, persistent }.
113
+ */
83
114
  export async function runInteractiveUI() {
84
115
  p.intro(chalk.bold('terminal-logo'));
85
116
 
86
- const hasConfig = !!tryLoadConfig();
117
+ const existingConfig = tryLoadConfig();
118
+
119
+ if (existingConfig) {
120
+ const action = handleCancel(
121
+ await p.select({
122
+ message: 'What would you like to do?',
123
+ initialValue: 'run',
124
+ options: [
125
+ { value: 'run', label: 'Run', hint: 'use saved config' },
126
+ { value: 'temp', label: 'New session', hint: "temporary, changes won't be saved" },
127
+ { value: 'generate', label: 'Generate', hint: 'create a new logo config' },
128
+ ],
129
+ })
130
+ );
131
+
132
+ if (action === 'generate') {
133
+ return promptGenerate();
134
+ }
135
+
136
+ if (action === 'temp') {
137
+ p.outro('Launching logo (temporary session)...');
138
+ return { action: 'run', config: existingConfig, persistent: false };
139
+ }
87
140
 
141
+ // action === 'run'
142
+ p.outro('Launching logo...');
143
+ return { action: 'run', config: existingConfig, persistent: true };
144
+ }
145
+
146
+ // No existing config — only offer Generate
88
147
  const action = handleCancel(
89
148
  await p.select({
90
149
  message: 'What would you like to do?',
91
- initialValue: hasConfig ? 'run' : 'generate',
150
+ initialValue: 'generate',
92
151
  options: [
93
- { value: 'generate', label: 'Generate', hint: 'create .shell-logo.json' },
94
- { value: 'run', label: 'Run', hint: 'display current logo' },
152
+ { value: 'generate', label: 'Generate', hint: 'create a new logo config' },
95
153
  ],
96
154
  })
97
155
  );
@@ -99,18 +157,4 @@ export async function runInteractiveUI() {
99
157
  if (action === 'generate') {
100
158
  return promptGenerate();
101
159
  }
102
-
103
- // action === 'run'
104
- const config = tryLoadConfig();
105
- if (!config) {
106
- p.log.error(
107
- 'No valid .shell-logo.json found in the current directory.\n' +
108
- ' Run again and choose "Generate" to create one.'
109
- );
110
- p.outro('Done');
111
- process.exit(1);
112
- }
113
-
114
- p.outro('Launching logo...');
115
- return { action: 'run', config };
116
160
  }