@bastani/atomic 0.5.3-0 → 0.5.3-1

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": "@bastani/atomic",
3
- "version": "0.5.3-0",
3
+ "version": "0.5.3-1",
4
4
  "description": "Configuration management CLI and SDK for coding agents",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -44,107 +44,7 @@ import {
44
44
  applyManagedOnboardingFiles,
45
45
  hasProjectOnboardingFiles,
46
46
  } from "./onboarding.ts";
47
- import { supportsTrueColor, supports256Color, supportsColor } from "@/services/system/detect.ts";
48
-
49
- const ATOMIC_BLOCK_LOGO = [
50
- "█▀▀█ ▀▀█▀▀ █▀▀█ █▀▄▀█ ▀█▀ █▀▀",
51
- "█▄▄█ █ █ █ █ ▀ █ █ █ ",
52
- "▀ ▀ ▀ ▀▀▀▀ ▀ ▀ ▀▀▀ ▀▀▀",
53
- ];
54
-
55
- // Catppuccin-inspired gradient (dark terminal)
56
- const GRADIENT_DARK = [
57
- "#f5e0dc", "#f2cdcd", "#f5c2e7", "#cba6f7",
58
- "#b4befe", "#89b4fa", "#74c7ec", "#89dceb", "#94e2d5",
59
- ];
60
-
61
- // Catppuccin-inspired gradient (light terminal)
62
- const GRADIENT_LIGHT = [
63
- "#dc8a78", "#dd7878", "#ea76cb", "#8839ef",
64
- "#7287fd", "#1e66f5", "#209fb5", "#04a5e5", "#179299",
65
- ];
66
-
67
- // 256-color approximation of the gradient
68
- const GRADIENT_256 = [224, 218, 219, 183, 147, 111, 117, 159, 115];
69
-
70
- function hexToRgb(hex: string): [number, number, number] {
71
- const h = hex.replace("#", "");
72
- return [
73
- parseInt(h.substring(0, 2), 16),
74
- parseInt(h.substring(2, 4), 16),
75
- parseInt(h.substring(4, 6), 16),
76
- ];
77
- }
78
-
79
- function interpolateHex(gradient: string[], t: number): [number, number, number] {
80
- const pos = Math.max(0, Math.min(1, t)) * (gradient.length - 1);
81
- const lo = Math.floor(pos);
82
- const hi = Math.min(lo + 1, gradient.length - 1);
83
- const frac = pos - lo;
84
- const [r1, g1, b1] = hexToRgb(gradient[lo]!);
85
- const [r2, g2, b2] = hexToRgb(gradient[hi]!);
86
- return [
87
- Math.round(r1 + (r2 - r1) * frac),
88
- Math.round(g1 + (g2 - g1) * frac),
89
- Math.round(b1 + (b2 - b1) * frac),
90
- ];
91
- }
92
-
93
- function interpolate256(gradient: number[], t: number): number {
94
- const pos = Math.max(0, Math.min(1, t)) * (gradient.length - 1);
95
- const lo = Math.floor(pos);
96
- return gradient[lo]!;
97
- }
98
-
99
- function colorizeLineTrueColor(line: string, gradient: string[]): string {
100
- let out = "";
101
- const len = line.length;
102
- for (let i = 0; i < len; i++) {
103
- const ch = line[i]!;
104
- if (ch === " ") {
105
- out += ch;
106
- continue;
107
- }
108
- const [r, g, b] = interpolateHex(gradient, len > 1 ? i / (len - 1) : 0);
109
- out += `\x1b[38;2;${r};${g};${b}m${ch}`;
110
- }
111
- return out + "\x1b[0m";
112
- }
113
-
114
- function colorizeLine256(line: string, gradient: number[]): string {
115
- let out = "";
116
- const len = line.length;
117
- for (let i = 0; i < len; i++) {
118
- const ch = line[i]!;
119
- if (ch === " ") {
120
- out += ch;
121
- continue;
122
- }
123
- const code = interpolate256(gradient, len > 1 ? i / (len - 1) : 0);
124
- out += `\x1b[38;5;${code}m${ch}`;
125
- }
126
- return out + "\x1b[0m";
127
- }
128
-
129
- function displayBlockBanner(): void {
130
- const isDark = !(process.env.COLORFGBG ?? "").startsWith("0;");
131
- const truecolor = supportsTrueColor();
132
- const color256 = supports256Color();
133
- const hasColor = supportsColor();
134
-
135
- console.log();
136
- for (const line of ATOMIC_BLOCK_LOGO) {
137
- if (truecolor) {
138
- const gradient = isDark ? GRADIENT_DARK : GRADIENT_LIGHT;
139
- console.log(` ${colorizeLineTrueColor(line, gradient)}`);
140
- } else if (color256 && hasColor) {
141
- console.log(` ${colorizeLine256(line, GRADIENT_256)}`);
142
- } else {
143
- console.log(` ${line}`);
144
- }
145
- }
146
- console.log();
147
- }
47
+ import { displayBlockBanner } from "@/theme/logo.ts";
148
48
 
149
49
  /**
150
50
  * Thrown when the user cancels an interactive prompt during init.
@@ -238,26 +238,20 @@ function createPainter(): Paint {
238
238
  * Layout:
239
239
  * N workflows
240
240
  *
241
- *
242
241
  * local (.atomic/workflows)
243
242
  *
244
243
  * Claude
245
- *
246
244
  * <name>
247
245
  * <name>
248
246
  *
249
247
  * OpenCode
250
- *
251
248
  * <name>
252
249
  *
253
- *
254
250
  * global (~/.atomic/workflows)
255
251
  *
256
252
  * Claude
257
- *
258
253
  * <name>
259
254
  *
260
- *
261
255
  * run: atomic workflow -n <name> -a <agent>
262
256
  */
263
257
  function renderWorkflowList(workflows: DiscoveredWorkflow[]): string {
@@ -309,15 +303,13 @@ function renderWorkflowList(workflows: DiscoveredWorkflow[]): string {
309
303
 
310
304
  // One stanza per source section, with nested provider sub-groups inside.
311
305
  // Rhythm:
312
- // 2 blanks before each source heading (major break)
313
- // 1 blank before each provider heading (tight, they're nested)
314
- // 1 blank before each provider's entries
306
+ // 1 blank before each source heading (section break)
307
+ // 1 blank before each provider heading (grouped with its entries)
315
308
  for (const source of SOURCE_ORDER) {
316
309
  const byAgent = bySource.get(source);
317
310
  if (!byAgent || byAgent.size === 0) continue;
318
311
 
319
- // Major break before the source section.
320
- lines.push("");
312
+ // Section break before the source section.
321
313
  lines.push("");
322
314
 
323
315
  // Source heading: bold semantic colour + dim inline directory hint.
@@ -338,7 +330,6 @@ function renderWorkflowList(workflows: DiscoveredWorkflow[]): string {
338
330
  lines.push(
339
331
  " " + paint("accent", AGENT_DISPLAY_NAMES[agent], { bold: true }),
340
332
  );
341
- lines.push("");
342
333
 
343
334
  for (const name of names) {
344
335
  lines.push(" " + paint("text", name));
@@ -346,8 +337,7 @@ function renderWorkflowList(workflows: DiscoveredWorkflow[]): string {
346
337
  }
347
338
  }
348
339
 
349
- // Footer — dim run hint, separated by the same major-break rhythm.
350
- lines.push("");
340
+ // Footer — dim run hint, separated by a section break.
351
341
  lines.push("");
352
342
  lines.push(
353
343
  " " + paint("dim", "run: atomic workflow -n <name> -a <agent>"),
@@ -455,7 +455,7 @@ async function initProviderClientAndSession(
455
455
  const { CopilotClient, approveAll } = await import("@github/copilot-sdk");
456
456
  const copilotClientOpts = clientOpts as StageClientOptions<"copilot">;
457
457
  const copilotSessionOpts = sessionOpts as StageSessionOptions<"copilot">;
458
- const client = new CopilotClient({ cliUrl: serverUrl, ...copilotClientOpts });
458
+ const client = new CopilotClient({ ...copilotClientOpts, cliUrl: serverUrl });
459
459
  await client.start();
460
460
  const session = await client.createSession({
461
461
  onPermissionRequest: approveAll,
@@ -468,7 +468,7 @@ async function initProviderClientAndSession(
468
468
  const { createOpencodeClient } = await import("@opencode-ai/sdk/v2");
469
469
  const ocClientOpts = clientOpts as StageClientOptions<"opencode">;
470
470
  const ocSessionOpts = sessionOpts as StageSessionOptions<"opencode">;
471
- const client = createOpencodeClient({ baseUrl: serverUrl, ...ocClientOpts });
471
+ const client = createOpencodeClient({ ...ocClientOpts, baseUrl: serverUrl });
472
472
  const sessionResult = await client.session.create(ocSessionOpts);
473
473
  await client.tui.selectSession({ sessionID: sessionResult.data!.id });
474
474
  return { client, session: sessionResult.data! };
@@ -10,21 +10,25 @@
10
10
  * Instead, we detect a fresh install or upgrade lazily on CLI startup by
11
11
  * comparing the bundled `VERSION` constant against a marker file at
12
12
  * `~/.atomic/.synced-version`. On a mismatch we run the same setup the
13
- * production bootstrap installers (`install.sh` / `install.ps1`) provide:
13
+ * production bootstrap installers (`install.sh` / `install.ps1`) provide,
14
+ * grouped into two parallel phases:
14
15
  *
15
- * 1. Node.js / npm (gates everything that needs npm/npx)
16
- * 2. tmux / psmux (terminal multiplexer for `chat` and `workflow`)
17
- * 3. @playwright/cli (global npm package used by skills)
18
- * 4. @llamaindex/liteparse (global npm package used by skills)
19
- * 5. global agent configs (~/.claude/agents, ~/.opencode/agents, ~/.copilot/agents, lsp-config.json)
20
- * 6. global workflows (~/.atomic/workflows/{hello,hello-parallel,ralph,...})
21
- * 7. global skills (npx skills add ...)
16
+ * Phase 1 (parallel no inter-dependencies):
17
+ * 1. Node.js / npm (installed via fnm, no system pkg-mgr)
18
+ * 2. tmux / psmux (terminal multiplexer for `chat` / `workflow`)
19
+ * 3. global agent configs (file copies no network)
22
20
  *
23
- * Each step runs sequentially. Failures are collected and reported as a
24
- * summary at the end, but never abort the run — partial setup matches the
25
- * production installer's "best-effort" semantics. The marker is written
26
- * after every run (success or partial) so we don't re-attempt the whole
27
- * setup on every subsequent launch.
21
+ * Phase 2 (parallel all need npm from Phase 1):
22
+ * 4. @playwright/cli (npm install -g)
23
+ * 5. @llamaindex/liteparse (npm install -g)
24
+ * 6. global skills (npx skills add ...)
25
+ *
26
+ * Steps within each phase run concurrently; phases run sequentially.
27
+ * Failures are collected and reported as a summary at the end, but never
28
+ * abort the run — partial setup matches the production installer's
29
+ * "best-effort" semantics. The marker is written after every run (success
30
+ * or partial) so we don't re-attempt the whole setup on every subsequent
31
+ * launch.
28
32
  */
29
33
 
30
34
  import { join } from "path";
@@ -40,6 +44,7 @@ import {
40
44
  import { installGlobalAgents } from "@/services/system/agents.ts";
41
45
  import { installGlobalSkills } from "@/services/system/skills.ts";
42
46
  import { runSteps, printSummary } from "@/services/system/install-ui.ts";
47
+ import { displayBlockBanner } from "@/theme/logo.ts";
43
48
 
44
49
  /** Path to the version marker. Honors ATOMIC_SETTINGS_HOME for tests. */
45
50
  function syncMarkerPath(): string {
@@ -88,26 +93,34 @@ export async function autoSyncIfStale(): Promise<void> {
88
93
  `\n ${COLORS.dim}Setting up atomic ${COLORS.reset}${COLORS.bold}v${VERSION}${COLORS.reset}${COLORS.dim}…${COLORS.reset}\n`,
89
94
  );
90
95
 
91
- // Ordering notes:
92
- // - Phase 1 (npm, tmux): core tools. npm comes first because
93
- // @playwright/cli, @llamaindex/liteparse, and `npx skills` all need it.
94
- // tmux/psmux is independent but installed sequentially to avoid
95
- // contention with system package-manager locks (apt-get, dnf, etc).
96
- // - Phase 2 (global npm packages): will fail loudly if Phase 1's npm
97
- // install failed, which is fine — the spinner summary records their
98
- // failure too.
99
- // - Phase 3 (bundled atomic content): file copies + npx skills install.
96
+ // Steps are split into two parallel phases:
97
+ //
98
+ // Phase 1 core tools + file copies (no inter-dependencies):
99
+ // npm is installed via fnm (not a system package manager), so it
100
+ // won't contend with tmux's apt-get/dnf install. Agent config
101
+ // copies are pure file I/O with no network or npm dependency.
102
+ //
103
+ // Phase 2 — npm-dependent tasks (run after Phase 1):
104
+ // @playwright/cli, @llamaindex/liteparse, and `npx skills` all
105
+ // need npm/npx. They install independent packages, so they can
106
+ // run concurrently.
100
107
  //
101
108
  // Each step's failure is caught inside `runSteps` (not thrown), so
102
109
  // subsequent steps still run even if one fails — matches install.sh's
103
110
  // best-effort contract.
104
111
  const results = await runSteps([
105
- { label: "Node.js / npm", fn: () => ensureNpmInstalled({ quiet: true }) },
106
- { label: "tmux / psmux", fn: () => ensureTmuxInstalled({ quiet: true }) },
107
- { label: "@playwright/cli", fn: upgradePlaywrightCli },
108
- { label: "@llamaindex/liteparse", fn: upgradeLiteparse },
109
- { label: "global agent configs", fn: installGlobalAgents },
110
- { label: "global skills", fn: installGlobalSkills },
112
+ // Phase 1 parallel
113
+ [
114
+ { label: "Node.js / npm", fn: () => ensureNpmInstalled({ quiet: true }) },
115
+ { label: "tmux / psmux", fn: () => ensureTmuxInstalled({ quiet: true }) },
116
+ { label: "global agent configs", fn: installGlobalAgents },
117
+ ],
118
+ // Phase 2 — parallel, after Phase 1
119
+ [
120
+ { label: "@playwright/cli", fn: upgradePlaywrightCli },
121
+ { label: "@llamaindex/liteparse", fn: upgradeLiteparse },
122
+ { label: "global skills", fn: installGlobalSkills },
123
+ ],
111
124
  ]);
112
125
 
113
126
  // Always write the marker — partial setup is the production-installer
@@ -115,7 +128,7 @@ export async function autoSyncIfStale(): Promise<void> {
115
128
  // @bastani/atomic` is the recovery path for a failed setup.
116
129
  await markSynced();
117
130
 
118
- console.log();
131
+ displayBlockBanner();
119
132
  printSummary(results);
120
133
 
121
134
  const failures = results.filter((r) => !r.ok);
@@ -124,6 +137,8 @@ export async function autoSyncIfStale(): Promise<void> {
124
137
  `\n ${COLORS.dim}Re-run \`bun install -g @bastani/atomic\` after resolving the issues to retry.${COLORS.reset}\n`,
125
138
  );
126
139
  } else {
127
- console.log();
140
+ console.log(
141
+ `\n ${COLORS.dim}Learn more at ${COLORS.reset}${COLORS.blue}https://deepwiki.com/flora131/atomic${COLORS.reset}\n`,
142
+ );
128
143
  }
129
144
  }
@@ -1,12 +1,21 @@
1
1
  /**
2
2
  * Progress UI primitives for the first-run install flow (auto-sync).
3
3
  *
4
- * Renders a single persistent line:
4
+ * Renders an OpenCode-inspired single-line progress bar:
5
5
  *
6
- * ⠋ [██████▒▒▒▒▒▒] 3/7 tmux / psmux
6
+ * ⠋ ■■■■■■■■■■■■■■■■■■・・・・・・・・・・・・ 50% tmux / psmux
7
+ *
8
+ * where the braille spinner is provided by `@clack/prompts` and the bar
9
+ * uses Catppuccin Mocha accent colors (Blue for progress, Green for
10
+ * success, Red for error) with true-color → 256-color → basic ANSI
11
+ * fallback.
12
+ *
13
+ * Steps are grouped into **phases**. Steps within a phase run in parallel
14
+ * (via `Promise.all`); phases themselves run sequentially so later phases
15
+ * can depend on earlier ones (e.g. npm must be available before
16
+ * `npm install -g` tasks). The progress bar advances and the label
17
+ * updates in real-time as individual steps complete within a phase.
7
18
  *
8
- * where the braille spinner animation is provided by `@clack/prompts`
9
- * and the bracketed bar tracks overall step progress (completed / total).
10
19
  * A final summary (✓/✗ per step) is printed after all steps finish, and
11
20
  * any captured stderr/stdout from a failed step is shown beneath it.
12
21
  *
@@ -16,16 +25,20 @@
16
25
 
17
26
  import { spinner } from "@clack/prompts";
18
27
  import { COLORS } from "@/theme/colors.ts";
28
+ import {
29
+ supportsTrueColor,
30
+ supports256Color,
31
+ } from "@/services/system/detect.ts";
19
32
 
20
- const BAR_WIDTH = 18;
21
- const BAR_FILLED = "";
22
- const BAR_EMPTY = "";
33
+ const BAR_WIDTH = 30;
34
+ const BAR_FILLED = "";
35
+ const BAR_EMPTY = "";
23
36
 
24
37
  /**
25
- * Semantic bar states:
26
- * progress → blue (Catppuccin accent; "in flight")
27
- * success → green (universal "completed")
28
- * error → red (universal "failed")
38
+ * Semantic bar states mapped to Catppuccin Mocha colors:
39
+ * progress → Blue #89b4fa (accent; "in flight")
40
+ * success → Green #a6e3a1 (universal "completed")
41
+ * error → Red #f38ba8 (universal "failed")
29
42
  *
30
43
  * The empty track stays dim regardless — only the filled portion carries
31
44
  * the status signal, which keeps the bar legible while still telegraphing
@@ -34,6 +47,28 @@ const BAR_EMPTY = "░";
34
47
  type BarState = "progress" | "success" | "error";
35
48
 
36
49
  function fillColor(state: BarState): string {
50
+ if (supportsTrueColor()) {
51
+ switch (state) {
52
+ case "success":
53
+ return "\x1b[38;2;166;227;161m"; // Catppuccin Green #a6e3a1
54
+ case "error":
55
+ return "\x1b[38;2;243;139;168m"; // Catppuccin Red #f38ba8
56
+ case "progress":
57
+ default:
58
+ return "\x1b[38;2;137;180;250m"; // Catppuccin Blue #89b4fa
59
+ }
60
+ }
61
+ if (supports256Color()) {
62
+ switch (state) {
63
+ case "success":
64
+ return "\x1b[38;5;150m";
65
+ case "error":
66
+ return "\x1b[38;5;211m";
67
+ case "progress":
68
+ default:
69
+ return "\x1b[38;5;111m";
70
+ }
71
+ }
37
72
  switch (state) {
38
73
  case "success":
39
74
  return COLORS.green;
@@ -45,7 +80,7 @@ function fillColor(state: BarState): string {
45
80
  }
46
81
  }
47
82
 
48
- /** Render a bracketed step-progress bar. `completed` is capped at `total`. */
83
+ /** Render a progress bar: colored filled + dim empty ・ */
49
84
  function renderBar(
50
85
  completed: number,
51
86
  total: number,
@@ -56,7 +91,6 @@ function renderBar(
56
91
  const filled = Math.round(ratio * BAR_WIDTH);
57
92
  const empty = BAR_WIDTH - filled;
58
93
  return (
59
- COLORS.bold +
60
94
  fillColor(state) +
61
95
  BAR_FILLED.repeat(filled) +
62
96
  COLORS.reset +
@@ -73,8 +107,12 @@ function formatLine(
73
107
  state: BarState = "progress",
74
108
  ): string {
75
109
  const bar = renderBar(completed, total, state);
76
- const counter = `${COLORS.dim}${completed}/${total}${COLORS.reset}`;
77
- return `${bar} ${counter} ${label}`;
110
+ const safeTotal = Math.max(1, total);
111
+ const pct = Math.round(
112
+ Math.max(0, Math.min(1, completed / safeTotal)) * 100,
113
+ );
114
+ const percent = `${COLORS.dim}${String(pct).padStart(3)}%${COLORS.reset}`;
115
+ return `${bar} ${percent} ${label}`;
78
116
  }
79
117
 
80
118
  export interface StepResult {
@@ -84,48 +122,80 @@ export interface StepResult {
84
122
  error?: string;
85
123
  }
86
124
 
125
+ export interface Step {
126
+ label: string;
127
+ fn: () => Promise<unknown>;
128
+ }
129
+
130
+ /** A phase is a group of steps that run in parallel. */
131
+ export type Phase = Step[];
132
+
87
133
  /**
88
- * Runs a sequence of async steps with a single persistent spinner line
89
- * showing stepped progress. Each step's failure is collected rather than
90
- * thrown, mirroring auto-sync's "best-effort" contract.
134
+ * Runs phases of async steps with a single persistent spinner line
135
+ * showing stepped progress. Steps within each phase run in parallel;
136
+ * phases run sequentially so later phases can depend on earlier ones.
91
137
  *
92
- * Returns the per-step results in submission order so the caller can
93
- * render a summary.
138
+ * Each step's failure is collected rather than thrown, mirroring
139
+ * auto-sync's "best-effort" contract.
140
+ *
141
+ * Returns the per-step results in phase/submission order so the caller
142
+ * can render a summary.
94
143
  */
95
- export async function runSteps(
96
- steps: Array<{ label: string; fn: () => Promise<unknown> }>,
97
- ): Promise<StepResult[]> {
98
- const total = steps.length;
144
+ export async function runSteps(phases: Phase[]): Promise<StepResult[]> {
145
+ const total = phases.reduce((n, phase) => n + phase.length, 0);
99
146
  const results: StepResult[] = [];
100
147
  const s = spinner();
148
+ let completed = 0;
101
149
 
102
150
  // Start with 0/total so the user sees the bar immediately.
103
- s.start(formatLine(0, total, steps[0]?.label ?? ""));
104
-
105
- for (let i = 0; i < steps.length; i++) {
106
- const step = steps[i]!;
107
- s.message(formatLine(i, total, step.label));
108
-
109
- try {
110
- await step.fn();
111
- results.push({ label: step.label, ok: true });
112
- } catch (error) {
113
- const message = error instanceof Error ? error.message : String(error);
114
- results.push({ label: step.label, ok: false, error: message });
115
- }
151
+ s.start(formatLine(0, total, phases[0]?.[0]?.label ?? ""));
152
+
153
+ for (const phase of phases) {
154
+ // Show all in-flight labels for this phase.
155
+ const inFlight = new Set(phase.map((step) => step.label));
156
+ s.message(formatLine(completed, total, [...inFlight].join(", ")));
157
+
158
+ // Run every step in this phase concurrently.
159
+ const phaseResults = await Promise.all(
160
+ phase.map(async (step): Promise<StepResult> => {
161
+ try {
162
+ await step.fn();
163
+ completed++;
164
+ inFlight.delete(step.label);
165
+ if (inFlight.size > 0) {
166
+ s.message(
167
+ formatLine(completed, total, [...inFlight].join(", ")),
168
+ );
169
+ }
170
+ return { label: step.label, ok: true };
171
+ } catch (error) {
172
+ const message =
173
+ error instanceof Error ? error.message : String(error);
174
+ completed++;
175
+ inFlight.delete(step.label);
176
+ if (inFlight.size > 0) {
177
+ s.message(
178
+ formatLine(completed, total, [...inFlight].join(", ")),
179
+ );
180
+ }
181
+ return { label: step.label, ok: false, error: message };
182
+ }
183
+ }),
184
+ );
185
+
186
+ results.push(...phaseResults);
116
187
  }
117
188
 
118
- // Stop with a filled bar + final label so the user sees we reached the
119
- // end rather than leaving the last in-flight step pinned. Bar color
120
- // flips to the universal status colors: green when every step
121
- // succeeded, red when any step failed. The per-step ✓/✗ rows printed
122
- // afterwards provide the detailed breakdown.
189
+ // Stop with a filled bar + final label. Bar color flips to the
190
+ // universal status colors: green when every step succeeded, red when
191
+ // any step failed.
123
192
  const okCount = results.filter((r) => r.ok).length;
124
193
  const allOk = okCount === total;
194
+ const finalState: BarState = allOk ? "success" : "error";
125
195
  const finalLabel = allOk
126
- ? `${COLORS.green}Setup complete${COLORS.reset}`
127
- : `${COLORS.red}Setup finished with errors${COLORS.reset}`;
128
- s.stop(formatLine(total, total, finalLabel, allOk ? "success" : "error"));
196
+ ? `${fillColor("success")}Setup complete${COLORS.reset}`
197
+ : `${fillColor("error")}Setup finished with errors${COLORS.reset}`;
198
+ s.stop(formatLine(total, total, finalLabel, finalState));
129
199
 
130
200
  return results;
131
201
  }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Atomic ASCII-art logo and Catppuccin gradient colorization.
3
+ *
4
+ * Shared between the init banner and the post-install completion screen.
5
+ */
6
+
7
+ import {
8
+ supportsTrueColor,
9
+ supports256Color,
10
+ supportsColor,
11
+ } from "@/services/system/detect.ts";
12
+
13
+ export const ATOMIC_BLOCK_LOGO = [
14
+ "█▀▀█ ▀▀█▀▀ █▀▀█ █▀▄▀█ ▀█▀ █▀▀",
15
+ "█▄▄█ █ █ █ █ ▀ █ █ █ ",
16
+ "▀ ▀ ▀ ▀▀▀▀ ▀ ▀ ▀▀▀ ▀▀▀",
17
+ ];
18
+
19
+ /** Catppuccin-inspired gradient (dark terminal). */
20
+ export const GRADIENT_DARK = [
21
+ "#f5e0dc", "#f2cdcd", "#f5c2e7", "#cba6f7",
22
+ "#b4befe", "#89b4fa", "#74c7ec", "#89dceb", "#94e2d5",
23
+ ];
24
+
25
+ /** Catppuccin-inspired gradient (light terminal). */
26
+ export const GRADIENT_LIGHT = [
27
+ "#dc8a78", "#dd7878", "#ea76cb", "#8839ef",
28
+ "#7287fd", "#1e66f5", "#209fb5", "#04a5e5", "#179299",
29
+ ];
30
+
31
+ /** 256-color approximation of the gradient. */
32
+ export const GRADIENT_256 = [224, 218, 219, 183, 147, 111, 117, 159, 115];
33
+
34
+ function hexToRgb(hex: string): [number, number, number] {
35
+ const h = hex.replace("#", "");
36
+ return [
37
+ parseInt(h.substring(0, 2), 16),
38
+ parseInt(h.substring(2, 4), 16),
39
+ parseInt(h.substring(4, 6), 16),
40
+ ];
41
+ }
42
+
43
+ function interpolateHex(gradient: string[], t: number): [number, number, number] {
44
+ const pos = Math.max(0, Math.min(1, t)) * (gradient.length - 1);
45
+ const lo = Math.floor(pos);
46
+ const hi = Math.min(lo + 1, gradient.length - 1);
47
+ const frac = pos - lo;
48
+ const [r1, g1, b1] = hexToRgb(gradient[lo]!);
49
+ const [r2, g2, b2] = hexToRgb(gradient[hi]!);
50
+ return [
51
+ Math.round(r1 + (r2 - r1) * frac),
52
+ Math.round(g1 + (g2 - g1) * frac),
53
+ Math.round(b1 + (b2 - b1) * frac),
54
+ ];
55
+ }
56
+
57
+ function interpolate256(gradient: number[], t: number): number {
58
+ const pos = Math.max(0, Math.min(1, t)) * (gradient.length - 1);
59
+ const lo = Math.floor(pos);
60
+ return gradient[lo]!;
61
+ }
62
+
63
+ export function colorizeLineTrueColor(line: string, gradient: string[]): string {
64
+ let out = "";
65
+ const len = line.length;
66
+ for (let i = 0; i < len; i++) {
67
+ const ch = line[i]!;
68
+ if (ch === " ") {
69
+ out += ch;
70
+ continue;
71
+ }
72
+ const [r, g, b] = interpolateHex(gradient, len > 1 ? i / (len - 1) : 0);
73
+ out += `\x1b[38;2;${r};${g};${b}m${ch}`;
74
+ }
75
+ return out + "\x1b[0m";
76
+ }
77
+
78
+ export function colorizeLine256(line: string, gradient: number[]): string {
79
+ let out = "";
80
+ const len = line.length;
81
+ for (let i = 0; i < len; i++) {
82
+ const ch = line[i]!;
83
+ if (ch === " ") {
84
+ out += ch;
85
+ continue;
86
+ }
87
+ const code = interpolate256(gradient, len > 1 ? i / (len - 1) : 0);
88
+ out += `\x1b[38;5;${code}m${ch}`;
89
+ }
90
+ return out + "\x1b[0m";
91
+ }
92
+
93
+ /** Print the Atomic block logo with Catppuccin gradient colorization. */
94
+ export function displayBlockBanner(): void {
95
+ const isDark = !(process.env.COLORFGBG ?? "").startsWith("0;");
96
+ const truecolor = supportsTrueColor();
97
+ const color256 = supports256Color();
98
+ const hasColor = supportsColor();
99
+
100
+ console.log();
101
+ for (const line of ATOMIC_BLOCK_LOGO) {
102
+ if (truecolor) {
103
+ const gradient = isDark ? GRADIENT_DARK : GRADIENT_LIGHT;
104
+ console.log(` ${colorizeLineTrueColor(line, gradient)}`);
105
+ } else if (color256 && hasColor) {
106
+ console.log(` ${colorizeLine256(line, GRADIENT_256)}`);
107
+ } else {
108
+ console.log(` ${line}`);
109
+ }
110
+ }
111
+ console.log();
112
+ }