@y4wee/nupo 0.1.0

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.
Files changed (63) hide show
  1. package/dist/App.d.ts +8 -0
  2. package/dist/App.js +109 -0
  3. package/dist/__tests__/checks.test.d.ts +1 -0
  4. package/dist/__tests__/checks.test.js +68 -0
  5. package/dist/__tests__/config.test.d.ts +1 -0
  6. package/dist/__tests__/config.test.js +61 -0
  7. package/dist/components/ConfirmExit.d.ts +9 -0
  8. package/dist/components/ConfirmExit.js +12 -0
  9. package/dist/components/ErrorPanel.d.ts +7 -0
  10. package/dist/components/ErrorPanel.js +12 -0
  11. package/dist/components/Header.d.ts +10 -0
  12. package/dist/components/Header.js +17 -0
  13. package/dist/components/LeftPanel.d.ts +9 -0
  14. package/dist/components/LeftPanel.js +31 -0
  15. package/dist/components/OptionsPanel.d.ts +11 -0
  16. package/dist/components/OptionsPanel.js +18 -0
  17. package/dist/components/PathInput.d.ts +10 -0
  18. package/dist/components/PathInput.js +133 -0
  19. package/dist/components/ProgressBar.d.ts +5 -0
  20. package/dist/components/ProgressBar.js +21 -0
  21. package/dist/components/StepsPanel.d.ts +8 -0
  22. package/dist/components/StepsPanel.js +24 -0
  23. package/dist/hooks/useConfig.d.ts +8 -0
  24. package/dist/hooks/useConfig.js +26 -0
  25. package/dist/hooks/useTerminalSize.d.ts +4 -0
  26. package/dist/hooks/useTerminalSize.js +18 -0
  27. package/dist/index.d.ts +2 -0
  28. package/dist/index.js +169 -0
  29. package/dist/screens/ConfigScreen.d.ts +10 -0
  30. package/dist/screens/ConfigScreen.js +182 -0
  31. package/dist/screens/ConfigureServiceScreen.d.ts +11 -0
  32. package/dist/screens/ConfigureServiceScreen.js +499 -0
  33. package/dist/screens/HomeScreen.d.ts +14 -0
  34. package/dist/screens/HomeScreen.js +24 -0
  35. package/dist/screens/IdeScreen.d.ts +9 -0
  36. package/dist/screens/IdeScreen.js +101 -0
  37. package/dist/screens/InitScreen.d.ts +9 -0
  38. package/dist/screens/InitScreen.js +182 -0
  39. package/dist/screens/InstallVersionScreen.d.ts +10 -0
  40. package/dist/screens/InstallVersionScreen.js +495 -0
  41. package/dist/screens/OdooScreen.d.ts +13 -0
  42. package/dist/screens/OdooScreen.js +76 -0
  43. package/dist/screens/OdooServiceScreen.d.ts +10 -0
  44. package/dist/screens/OdooServiceScreen.js +51 -0
  45. package/dist/screens/StartServiceScreen.d.ts +12 -0
  46. package/dist/screens/StartServiceScreen.js +386 -0
  47. package/dist/screens/UpgradeVersionScreen.d.ts +9 -0
  48. package/dist/screens/UpgradeVersionScreen.js +259 -0
  49. package/dist/services/checks.d.ts +8 -0
  50. package/dist/services/checks.js +48 -0
  51. package/dist/services/config.d.ts +11 -0
  52. package/dist/services/config.js +146 -0
  53. package/dist/services/git.d.ts +35 -0
  54. package/dist/services/git.js +173 -0
  55. package/dist/services/ide.d.ts +10 -0
  56. package/dist/services/ide.js +126 -0
  57. package/dist/services/python.d.ts +14 -0
  58. package/dist/services/python.js +81 -0
  59. package/dist/services/system.d.ts +2 -0
  60. package/dist/services/system.js +22 -0
  61. package/dist/types/index.d.ts +82 -0
  62. package/dist/types/index.js +26 -0
  63. package/package.json +37 -0
package/dist/App.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ import React from 'react';
2
+ import { CliStartArgs } from './types/index.js';
3
+ interface AppProps {
4
+ onExit: () => void;
5
+ startupArgs?: CliStartArgs;
6
+ }
7
+ export declare function App({ onExit, startupArgs }: AppProps): React.JSX.Element;
8
+ export {};
package/dist/App.js ADDED
@@ -0,0 +1,109 @@
1
+ import React, { useState, useMemo, useCallback, useEffect } from 'react';
2
+ import { Box, Text, useInput } from 'ink';
3
+ import { getPrimaryColor, getSecondaryColor, getTextColor, getCursorColor } from './types/index.js';
4
+ import { useConfig } from './hooks/useConfig.js';
5
+ import { useTerminalSize } from './hooks/useTerminalSize.js';
6
+ import { Header } from './components/Header.js';
7
+ import { ConfirmExit } from './components/ConfirmExit.js';
8
+ import { HomeScreen } from './screens/HomeScreen.js';
9
+ import { InitScreen } from './screens/InitScreen.js';
10
+ import { OdooScreen } from './screens/OdooScreen.js';
11
+ import { ConfigScreen } from './screens/ConfigScreen.js';
12
+ import { IdeScreen } from './screens/IdeScreen.js';
13
+ export function App({ onExit, startupArgs }) {
14
+ const { columns, rows } = useTerminalSize();
15
+ const { config, loading, refresh } = useConfig();
16
+ const [currentScreen, setCurrentScreen] = useState('home');
17
+ const [confirmExit, setConfirmExit] = useState(false);
18
+ const [confirmSelected, setConfirmSelected] = useState(1);
19
+ const [serviceRunning, setServiceRunning] = useState(false);
20
+ const [activeService, setActiveService] = useState(null);
21
+ // Reposition to top-left whenever the box height changes (service start/stop)
22
+ useEffect(() => {
23
+ process.stdout.write('\x1B[2J\x1B[H');
24
+ }, [serviceRunning]);
25
+ // Auto-navigate to odoo screen when CLI start args are present
26
+ useEffect(() => {
27
+ if (!loading && config?.initiated && startupArgs) {
28
+ setCurrentScreen('odoo');
29
+ }
30
+ }, [loading, config?.initiated, startupArgs]);
31
+ const primaryColor = getPrimaryColor(config);
32
+ const secondaryColor = getSecondaryColor(config);
33
+ const textColor = getTextColor(config);
34
+ const cursorColor = getCursorColor(config);
35
+ const options = useMemo(() => [
36
+ {
37
+ id: 'init',
38
+ label: 'Initialisation',
39
+ description: "Configure l'environnement nupo : vérifie Python, pip et le chemin vers le dépôt Odoo.",
40
+ screen: 'init',
41
+ visible: !config?.initiated,
42
+ },
43
+ {
44
+ id: 'odoo',
45
+ label: 'Odoo',
46
+ description: 'Accédez aux outils Odoo : démarrage, migration, gestion des modules.',
47
+ screen: 'odoo',
48
+ visible: config?.initiated === true,
49
+ },
50
+ {
51
+ id: 'ide',
52
+ label: 'IDE',
53
+ description: 'Ouvrir une version Odoo dans VS Code avec la configuration de débogage.',
54
+ screen: 'ide',
55
+ visible: config?.initiated === true,
56
+ },
57
+ {
58
+ id: 'config',
59
+ label: 'Paramètres',
60
+ description: 'Changer la configuration nupo : modifiez les paramètres de votre environnement.',
61
+ screen: 'config',
62
+ visible: config?.initiated === true,
63
+ },
64
+ ].filter(o => o.visible), [config]);
65
+ // Global input: handles Ctrl+C and confirm-exit dialog on all screens
66
+ useInput((char, key) => {
67
+ if (confirmExit) {
68
+ if (key.leftArrow)
69
+ setConfirmSelected(0);
70
+ if (key.rightArrow)
71
+ setConfirmSelected(1);
72
+ if (key.return)
73
+ confirmSelected === 0 ? onExit() : setConfirmExit(false);
74
+ if (key.escape || char === 'n') {
75
+ setConfirmExit(false);
76
+ setConfirmSelected(1);
77
+ }
78
+ if (char === 'o' || char === 'y')
79
+ onExit();
80
+ if (key.ctrl && char === 'c')
81
+ onExit();
82
+ return;
83
+ }
84
+ if (key.ctrl && char === 'c' && !serviceRunning) {
85
+ setConfirmExit(true);
86
+ setConfirmSelected(1);
87
+ }
88
+ });
89
+ const handleInitComplete = useCallback(() => {
90
+ void refresh();
91
+ setCurrentScreen('home');
92
+ }, [refresh]);
93
+ const termWidth = columns - 2;
94
+ const leftWidth = Math.floor(termWidth * 0.33);
95
+ if (loading) {
96
+ return (React.createElement(Box, { borderStyle: "round", borderColor: primaryColor, flexDirection: "column", width: termWidth },
97
+ React.createElement(Header, { activeService: activeService, serviceRunning: serviceRunning, primaryColor: primaryColor, secondaryColor: secondaryColor }),
98
+ React.createElement(Box, { paddingX: 3, paddingY: 2 },
99
+ React.createElement(Text, { color: textColor, dimColor: true }, "Chargement\u2026"))));
100
+ }
101
+ return (React.createElement(Box, { borderStyle: "round", borderColor: primaryColor, flexDirection: "column", width: termWidth, height: serviceRunning ? rows : undefined },
102
+ React.createElement(Header, { activeService: activeService, serviceRunning: serviceRunning, primaryColor: primaryColor, secondaryColor: secondaryColor }),
103
+ currentScreen === 'home' && (React.createElement(HomeScreen, { leftWidth: leftWidth, options: options, isActive: !confirmExit, primaryColor: primaryColor, secondaryColor: secondaryColor, textColor: textColor, cursorColor: cursorColor, onNavigate: screen => setCurrentScreen(screen) })),
104
+ currentScreen === 'init' && (React.createElement(InitScreen, { config: config, leftWidth: leftWidth, onComplete: handleInitComplete })),
105
+ currentScreen === 'odoo' && config && (React.createElement(OdooScreen, { leftWidth: leftWidth, config: config, onBack: () => setCurrentScreen('home'), onConfigChange: () => void refresh(), onServiceRunning: svc => { setServiceRunning(true); setActiveService(svc); }, onServiceStopped: () => { setServiceRunning(false); setActiveService(null); }, autoStart: startupArgs })),
106
+ currentScreen === 'ide' && config && (React.createElement(IdeScreen, { config: config, leftWidth: leftWidth, onBack: () => setCurrentScreen('home') })),
107
+ currentScreen === 'config' && config && (React.createElement(ConfigScreen, { config: config, leftWidth: leftWidth, onBack: () => setCurrentScreen('home'), onSaved: () => { void refresh(); setCurrentScreen('home'); } })),
108
+ React.createElement(ConfirmExit, { visible: confirmExit, selected: confirmSelected, textColor: textColor, cursorColor: cursorColor })));
109
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,68 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ vi.mock('node:child_process');
3
+ import { execFile } from 'node:child_process';
4
+ import { checkPython, checkPip } from '../services/checks.js';
5
+ const mockExecFile = vi.mocked(execFile);
6
+ function makeSuccess(stdout) {
7
+ return (_cmd, _args, cb) => {
8
+ cb(null, stdout, '');
9
+ return {};
10
+ };
11
+ }
12
+ function makeError(message) {
13
+ return (_cmd, _args, cb) => {
14
+ const err = Object.assign(new Error(message), { code: 'ENOENT' });
15
+ cb(err, '', '');
16
+ return {};
17
+ };
18
+ }
19
+ beforeEach(() => {
20
+ vi.clearAllMocks();
21
+ });
22
+ describe('checkPython', () => {
23
+ it('returns ok=true with version when python3 succeeds', async () => {
24
+ mockExecFile.mockImplementationOnce(makeSuccess('Python 3.10.12'));
25
+ const result = await checkPython();
26
+ expect(result.ok).toBe(true);
27
+ expect(result.version).toBe('3.10.12');
28
+ });
29
+ it('falls back to python when python3 is not found', async () => {
30
+ mockExecFile
31
+ .mockImplementationOnce(makeError('spawn python3 ENOENT'))
32
+ .mockImplementationOnce(makeSuccess('Python 3.8.0'));
33
+ const result = await checkPython();
34
+ expect(result.ok).toBe(true);
35
+ expect(result.version).toBe('3.8.0');
36
+ });
37
+ it('returns ok=false when neither python3 nor python is found', async () => {
38
+ mockExecFile
39
+ .mockImplementationOnce(makeError('spawn python3 ENOENT'))
40
+ .mockImplementationOnce(makeError('spawn python ENOENT'));
41
+ const result = await checkPython();
42
+ expect(result.ok).toBe(false);
43
+ expect(result.error).toBeDefined();
44
+ });
45
+ });
46
+ describe('checkPip', () => {
47
+ it('returns ok=true with version when pip3 succeeds', async () => {
48
+ mockExecFile.mockImplementationOnce(makeSuccess('pip 23.0.1 from /usr/lib/python3/dist-packages/pip (python 3.11)'));
49
+ const result = await checkPip();
50
+ expect(result.ok).toBe(true);
51
+ expect(result.version).toBe('23.0.1');
52
+ });
53
+ it('falls back to pip when pip3 is not found', async () => {
54
+ mockExecFile
55
+ .mockImplementationOnce(makeError('spawn pip3 ENOENT'))
56
+ .mockImplementationOnce(makeSuccess('pip 22.0.4 from /usr/local/lib (python 3.9)'));
57
+ const result = await checkPip();
58
+ expect(result.ok).toBe(true);
59
+ expect(result.version).toBe('22.0.4');
60
+ });
61
+ it('returns ok=false when neither pip3 nor pip is found', async () => {
62
+ mockExecFile
63
+ .mockImplementationOnce(makeError('spawn pip3 ENOENT'))
64
+ .mockImplementationOnce(makeError('spawn pip ENOENT'));
65
+ const result = await checkPip();
66
+ expect(result.ok).toBe(false);
67
+ });
68
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,61 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdtemp, rm, readFile } from 'fs/promises';
3
+ import { join } from 'path';
4
+ import { tmpdir } from 'os';
5
+ import { configExists, readConfig, writeConfig, patchConfig, getConfigPath, } from '../services/config.js';
6
+ import { DEFAULT_CONFIG } from '../types/index.js';
7
+ let tmpDir;
8
+ const BASE_CONFIG = {
9
+ initiated: false,
10
+ python_installed: false,
11
+ pip_installed: false,
12
+ odoo_path_repo: '',
13
+ odoo_versions: {},
14
+ };
15
+ describe('config service', () => {
16
+ beforeEach(async () => {
17
+ tmpDir = await mkdtemp(join(tmpdir(), 'nupo-test-'));
18
+ process.env['NUPO_CONFIG_DIR'] = tmpDir;
19
+ });
20
+ afterEach(async () => {
21
+ delete process.env['NUPO_CONFIG_DIR'];
22
+ await rm(tmpDir, { recursive: true, force: true });
23
+ });
24
+ it('configExists: false when file is absent', async () => {
25
+ expect(await configExists()).toBe(false);
26
+ });
27
+ it('configExists: true after writeConfig', async () => {
28
+ await writeConfig({ ...BASE_CONFIG, initiated: true, python_installed: true, pip_installed: true, odoo_path_repo: '/tmp/odoo' });
29
+ expect(await configExists()).toBe(true);
30
+ });
31
+ it('readConfig: returns DEFAULT_CONFIG when file is absent', async () => {
32
+ const cfg = await readConfig();
33
+ expect(cfg).toEqual(DEFAULT_CONFIG);
34
+ });
35
+ it('readConfig: parses existing JSON', async () => {
36
+ const saved = {
37
+ initiated: true,
38
+ python_installed: true,
39
+ pip_installed: false,
40
+ odoo_path_repo: '/home/user/odoo',
41
+ odoo_versions: { '17.0': { branch: '17.0', path: '/home/user/odoo/17.0' } },
42
+ };
43
+ await writeConfig(saved);
44
+ const cfg = await readConfig();
45
+ expect(cfg).toEqual(saved);
46
+ });
47
+ it('writeConfig: creates directory if absent and writes valid JSON', async () => {
48
+ await writeConfig(BASE_CONFIG);
49
+ const raw = await readFile(getConfigPath(), 'utf-8');
50
+ expect(JSON.parse(raw)).toEqual(BASE_CONFIG);
51
+ });
52
+ it('patchConfig: merges without overwriting other keys', async () => {
53
+ await writeConfig({ ...BASE_CONFIG, python_installed: true });
54
+ await patchConfig({ pip_installed: true });
55
+ const cfg = await readConfig();
56
+ expect(cfg.python_installed).toBe(true);
57
+ expect(cfg.pip_installed).toBe(true);
58
+ expect(cfg.initiated).toBe(false);
59
+ expect(cfg.odoo_versions).toEqual({});
60
+ });
61
+ });
@@ -0,0 +1,9 @@
1
+ import React from 'react';
2
+ interface ConfirmExitProps {
3
+ visible: boolean;
4
+ selected: number;
5
+ textColor?: string;
6
+ cursorColor?: string;
7
+ }
8
+ export declare function ConfirmExit({ visible, selected, textColor, cursorColor }: ConfirmExitProps): React.JSX.Element | null;
9
+ export {};
@@ -0,0 +1,12 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+ export function ConfirmExit({ visible, selected, textColor = '#848484', cursorColor = 'cyan' }) {
4
+ if (!visible)
5
+ return null;
6
+ return (React.createElement(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 2, paddingY: 0, marginX: 2, marginBottom: 1, flexDirection: "row", justifyContent: "space-between", alignItems: "center" },
7
+ React.createElement(Text, { color: "yellow" }, "Voulez-vous vraiment quitter nupo ?"),
8
+ React.createElement(Box, { flexDirection: "row", gap: 2 },
9
+ React.createElement(Text, { color: selected === 0 ? 'black' : 'white', backgroundColor: selected === 0 ? 'yellow' : undefined, bold: selected === 0 }, ' Oui '),
10
+ React.createElement(Text, { color: selected === 1 ? 'black' : 'white', backgroundColor: selected === 1 ? cursorColor : undefined, bold: selected === 1 }, ' Non ')),
11
+ React.createElement(Text, { color: textColor, dimColor: true }, '◀▶ choisir · ↵ confirmer')));
12
+ }
@@ -0,0 +1,7 @@
1
+ import React from 'react';
2
+ import { AnyStep } from '../types/index.js';
3
+ interface ErrorPanelProps {
4
+ steps: AnyStep[];
5
+ }
6
+ export declare function ErrorPanel({ steps }: ErrorPanelProps): React.JSX.Element | null;
7
+ export {};
@@ -0,0 +1,12 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+ export function ErrorPanel({ steps }) {
4
+ const errorStep = steps.find(s => s.status === 'error');
5
+ if (!errorStep)
6
+ return null;
7
+ return (React.createElement(Box, { borderStyle: "single", borderColor: "red", paddingX: 2, paddingY: 0, borderTop: true, borderBottom: false, borderLeft: false, borderRight: false },
8
+ React.createElement(Text, { color: "red" },
9
+ '✗ ',
10
+ errorStep.label,
11
+ errorStep.errorMessage ? ` : ${errorStep.errorMessage}` : '')));
12
+ }
@@ -0,0 +1,10 @@
1
+ import React from 'react';
2
+ import { OdooServiceConfig } from '../types/index.js';
3
+ interface HeaderProps {
4
+ activeService?: OdooServiceConfig | null;
5
+ serviceRunning?: boolean;
6
+ primaryColor?: string;
7
+ secondaryColor?: string;
8
+ }
9
+ export declare function Header({ activeService, serviceRunning, primaryColor, secondaryColor }: HeaderProps): React.JSX.Element;
10
+ export {};
@@ -0,0 +1,17 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import { createRequire } from 'module';
4
+ const _require = createRequire(import.meta.url);
5
+ const { version } = _require('../../package.json');
6
+ export function Header({ activeService, serviceRunning, primaryColor = '#9F0C58', secondaryColor = '#E79439' }) {
7
+ return (React.createElement(Box, { paddingX: 1, borderStyle: "single", borderColor: "gray", borderTop: false, borderLeft: false, borderRight: false, borderBottom: true, flexDirection: "row", gap: 1 },
8
+ React.createElement(Text, { color: primaryColor, bold: true }, "nupO"),
9
+ React.createElement(Text, { color: secondaryColor, dimColor: true },
10
+ "v",
11
+ version),
12
+ activeService && (React.createElement(React.Fragment, null,
13
+ React.createElement(Text, { color: "white", dimColor: true }, "\u00B7"),
14
+ React.createElement(Text, { color: "yellow", bold: true }, activeService.name),
15
+ React.createElement(Text, { color: "white", dimColor: true }, activeService.branch),
16
+ React.createElement(Text, { color: serviceRunning ? 'green' : 'red' }, serviceRunning ? '● en cours' : '■ arrêté')))));
17
+ }
@@ -0,0 +1,9 @@
1
+ import React from "react";
2
+ interface LeftPanelProps {
3
+ width: number;
4
+ serviceLabel?: string;
5
+ primaryColor?: string;
6
+ textColor?: string;
7
+ }
8
+ export declare function LeftPanel({ width, serviceLabel, primaryColor, textColor }: LeftPanelProps): React.JSX.Element;
9
+ export {};
@@ -0,0 +1,31 @@
1
+ import React, { useState, useEffect } from "react";
2
+ import { Box, Text } from "ink";
3
+ const LOGO = `
4
+ ███
5
+ █ █
6
+ █ █
7
+ █ █ █
8
+ █ █
9
+ █ █
10
+ ███
11
+ `;
12
+ const WELCOME = "Bienvenue\nNuprodien";
13
+ export function LeftPanel({ width, serviceLabel, primaryColor = '#9F0C58', textColor = '#848484' }) {
14
+ const [typedText, setTypedText] = useState("");
15
+ useEffect(() => {
16
+ let i = 0;
17
+ const id = setInterval(() => {
18
+ i++;
19
+ setTypedText(WELCOME.slice(0, i));
20
+ if (i >= WELCOME.length)
21
+ clearInterval(id);
22
+ }, 60);
23
+ return () => clearInterval(id);
24
+ }, []);
25
+ return (React.createElement(Box, { width: width, flexGrow: 0, flexShrink: 0, flexDirection: "column", paddingX: 2, paddingY: 2, gap: 2, borderStyle: "single", borderColor: "gray", borderTop: false, borderBottom: false, borderLeft: false, borderRight: true },
26
+ React.createElement(Box, { flexDirection: "column", alignItems: "center" },
27
+ React.createElement(Text, { color: primaryColor }, LOGO)),
28
+ serviceLabel && (React.createElement(Box, { flexDirection: "column", gap: 0 },
29
+ React.createElement(Text, { color: textColor, dimColor: true }, "Service"),
30
+ React.createElement(Text, { color: "yellow", bold: true }, serviceLabel)))));
31
+ }
@@ -0,0 +1,11 @@
1
+ import React from 'react';
2
+ import { MenuOption } from '../types/index.js';
3
+ interface OptionsPanelProps {
4
+ options: MenuOption[];
5
+ selected: number;
6
+ secondaryColor?: string;
7
+ textColor?: string;
8
+ cursorColor?: string;
9
+ }
10
+ export declare function OptionsPanel({ options, selected, secondaryColor, textColor, cursorColor }: OptionsPanelProps): React.JSX.Element;
11
+ export {};
@@ -0,0 +1,18 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+ export function OptionsPanel({ options, selected, secondaryColor, textColor = '#848484', cursorColor = 'cyan' }) {
4
+ const current = options[selected];
5
+ if (options.length === 0) {
6
+ return (React.createElement(Box, { flexGrow: 1, flexDirection: "column", paddingX: 3, paddingY: 2 },
7
+ React.createElement(Text, { color: textColor, dimColor: true }, "Aucune option disponible.")));
8
+ }
9
+ return (React.createElement(Box, { flexGrow: 1, flexDirection: "column", paddingX: 3, paddingY: 2, gap: 1 },
10
+ React.createElement(Box, { flexDirection: "column", gap: 0 }, options.map((opt, i) => {
11
+ const isSelected = i === selected;
12
+ return (React.createElement(Text, { key: opt.id, color: isSelected ? 'black' : 'white', backgroundColor: isSelected ? cursorColor : undefined, bold: isSelected }, ` ${isSelected ? '▶' : ' '} ${opt.label}`));
13
+ })),
14
+ React.createElement(Box, null,
15
+ React.createElement(Text, { color: textColor, dimColor: true }, '↑↓ naviguer · ↵ sélectionner')),
16
+ React.createElement(Box, { borderStyle: "round", borderColor: textColor, paddingX: 1, paddingY: 0 },
17
+ React.createElement(Text, { color: textColor, wrap: "wrap" }, current?.description ?? ''))));
18
+ }
@@ -0,0 +1,10 @@
1
+ import React from 'react';
2
+ export interface PathInputProps {
3
+ value: string;
4
+ onChange: (value: string) => void;
5
+ onSubmit: (value: string) => void;
6
+ placeholder?: string;
7
+ focus?: boolean;
8
+ textColor?: string;
9
+ }
10
+ export declare function PathInput({ value, onChange, onSubmit, placeholder, focus, textColor, }: PathInputProps): React.JSX.Element;
@@ -0,0 +1,133 @@
1
+ import React, { useState, useRef } from 'react';
2
+ import { Box, Text, useInput } from 'ink';
3
+ import TextInput from 'ink-text-input';
4
+ import { readdir, stat } from 'fs/promises';
5
+ import { join } from 'path';
6
+ import { homedir } from 'os';
7
+ const MAX_VISIBLE = 6;
8
+ function expandHome(p) {
9
+ if (p === '~' || p.startsWith('~/'))
10
+ return homedir() + p.slice(1);
11
+ return p;
12
+ }
13
+ function commonPrefix(strs) {
14
+ if (!strs.length)
15
+ return '';
16
+ let pre = strs[0];
17
+ for (const s of strs.slice(1)) {
18
+ while (!s.startsWith(pre))
19
+ pre = pre.slice(0, -1);
20
+ }
21
+ return pre;
22
+ }
23
+ async function computeCompletions(input) {
24
+ const expanded = expandHome(input);
25
+ let searchDir;
26
+ let partial;
27
+ let prefix; // part of original input kept verbatim before the partial token
28
+ if (expanded.endsWith('/')) {
29
+ searchDir = expanded || '.';
30
+ partial = '';
31
+ prefix = input;
32
+ }
33
+ else {
34
+ const lastSlash = expanded.lastIndexOf('/');
35
+ if (lastSlash === -1) {
36
+ searchDir = process.cwd();
37
+ partial = expanded;
38
+ prefix = '';
39
+ }
40
+ else {
41
+ searchDir = expanded.slice(0, lastSlash + 1);
42
+ partial = expanded.slice(lastSlash + 1);
43
+ const origSlash = input.lastIndexOf('/');
44
+ prefix = origSlash === -1 ? '' : input.slice(0, origSlash + 1);
45
+ }
46
+ }
47
+ try {
48
+ const entries = await readdir(searchDir || '.');
49
+ const matches = entries.filter(e => e.startsWith(partial));
50
+ const results = [];
51
+ for (const match of matches) {
52
+ let suffix = '';
53
+ try {
54
+ const s = await stat(join(searchDir || '.', match));
55
+ if (s.isDirectory())
56
+ suffix = '/';
57
+ }
58
+ catch { }
59
+ results.push(prefix + match + suffix);
60
+ }
61
+ return results.sort();
62
+ }
63
+ catch {
64
+ return [];
65
+ }
66
+ }
67
+ export function PathInput({ value, onChange, onSubmit, placeholder, focus = true, textColor = '#848484', }) {
68
+ const [completions, setCompletions] = useState(null);
69
+ // Refs for fresh values inside async callback
70
+ const genRef = useRef(0);
71
+ const valueRef = useRef(value);
72
+ const completionsRef = useRef(completions);
73
+ const onChangeRef = useRef(onChange);
74
+ valueRef.current = value;
75
+ completionsRef.current = completions;
76
+ onChangeRef.current = onChange;
77
+ const cancelCompletions = () => {
78
+ genRef.current++;
79
+ setCompletions(null);
80
+ };
81
+ useInput((_char, key) => {
82
+ if (!key.tab) {
83
+ cancelCompletions();
84
+ return;
85
+ }
86
+ const gen = ++genRef.current;
87
+ const cur = completionsRef.current;
88
+ // Already have completions: cycle to next
89
+ if (cur && cur.items.length > 0) {
90
+ const next = (cur.index + 1) % cur.items.length;
91
+ setCompletions({ ...cur, index: next });
92
+ onChangeRef.current(cur.items[next]);
93
+ return;
94
+ }
95
+ // Compute new completions
96
+ void computeCompletions(valueRef.current).then(items => {
97
+ if (gen !== genRef.current)
98
+ return; // cancelled by user input
99
+ if (items.length === 0)
100
+ return;
101
+ if (items.length === 1) {
102
+ if (items[0] !== valueRef.current)
103
+ onChangeRef.current(items[0]);
104
+ setCompletions({ items, index: 0 });
105
+ return;
106
+ }
107
+ // Multiple: complete to common prefix first, then cycle on next TABs
108
+ const cp = commonPrefix(items);
109
+ if (cp && cp !== valueRef.current) {
110
+ onChangeRef.current(cp);
111
+ setCompletions({ items, index: -1 });
112
+ }
113
+ else {
114
+ // Already at common prefix: start cycling
115
+ onChangeRef.current(items[0]);
116
+ setCompletions({ items, index: 0 });
117
+ }
118
+ });
119
+ }, { isActive: focus });
120
+ const showList = completions !== null && completions.items.length > 1;
121
+ return (React.createElement(Box, { flexDirection: "column" },
122
+ React.createElement(TextInput, { value: value, onChange: v => {
123
+ cancelCompletions();
124
+ onChange(v);
125
+ }, onSubmit: onSubmit, placeholder: placeholder, focus: focus }),
126
+ showList && (React.createElement(Box, { flexDirection: "column", paddingLeft: 2, marginTop: 0 },
127
+ completions.items.slice(0, MAX_VISIBLE).map((item, i) => {
128
+ const active = i === completions.index;
129
+ return (React.createElement(Text, { key: item, color: active ? 'cyan' : 'gray', bold: active, dimColor: !active }, active ? `▶ ${item}` : ` ${item}`));
130
+ }),
131
+ completions.items.length > MAX_VISIBLE && (React.createElement(Text, { color: textColor, dimColor: true }, ` … ${completions.items.length - MAX_VISIBLE} autre(s)`)),
132
+ React.createElement(Text, { color: textColor, dimColor: true }, ' ↹ suivant · ↵ valider')))));
133
+ }
@@ -0,0 +1,5 @@
1
+ import React from 'react';
2
+ export declare function ProgressBar({ percent, textColor }: {
3
+ percent: number;
4
+ textColor?: string;
5
+ }): React.JSX.Element;
@@ -0,0 +1,21 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { Box, Text } from 'ink';
3
+ const BAR_WIDTH = 50;
4
+ export function ProgressBar({ percent, textColor = '#848484' }) {
5
+ const [frame, setFrame] = useState(0);
6
+ useEffect(() => {
7
+ const id = setInterval(() => setFrame(f => f + 1), 150);
8
+ return () => clearInterval(id);
9
+ }, []);
10
+ const pos = Math.min(Math.round((percent / 100) * (BAR_WIDTH - 1)), BAR_WIDTH - 1);
11
+ const pacman = frame % 2 === 0 ? 'ᗧ' : '○';
12
+ const eaten = pos;
13
+ const remaining = BAR_WIDTH - 1 - pos;
14
+ return (React.createElement(Box, null,
15
+ React.createElement(Text, null,
16
+ React.createElement(Text, { color: "yellow" },
17
+ '─'.repeat(eaten),
18
+ pacman),
19
+ React.createElement(Text, { color: textColor, dimColor: true }, '·'.repeat(remaining)),
20
+ ` ${String(percent).padStart(3)}%`)));
21
+ }
@@ -0,0 +1,8 @@
1
+ import React from 'react';
2
+ import { AnyStep } from '../types/index.js';
3
+ interface StepsPanelProps {
4
+ steps: AnyStep[];
5
+ textColor?: string;
6
+ }
7
+ export declare function StepsPanel({ steps, textColor }: StepsPanelProps): React.JSX.Element | null;
8
+ export {};
@@ -0,0 +1,24 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+ const STATUS_ICON = {
4
+ pending: '○',
5
+ running: '⟳',
6
+ success: '✓',
7
+ error: '✗',
8
+ };
9
+ const STATUS_COLOR = {
10
+ pending: 'gray',
11
+ running: 'yellow',
12
+ success: 'green',
13
+ error: 'red',
14
+ };
15
+ export function StepsPanel({ steps, textColor = '#848484' }) {
16
+ if (steps.length === 0)
17
+ return null;
18
+ return (React.createElement(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", borderTop: true, borderBottom: false, borderLeft: false, borderRight: false, paddingX: 2, paddingY: 0 }, steps.map(step => (React.createElement(Box, { key: step.id, flexDirection: "row", gap: 1 },
19
+ React.createElement(Text, { color: STATUS_COLOR[step.status] }, STATUS_ICON[step.status]),
20
+ React.createElement(Text, { color: "white" }, step.label),
21
+ step.status === 'success' && step.errorMessage && (React.createElement(Text, { color: textColor, dimColor: true },
22
+ ' ',
23
+ step.errorMessage)))))));
24
+ }
@@ -0,0 +1,8 @@
1
+ import { NupoConfig } from '../types/index.js';
2
+ export interface UseConfigResult {
3
+ config: NupoConfig | null;
4
+ loading: boolean;
5
+ patch: (partial: Partial<NupoConfig>) => Promise<void>;
6
+ refresh: () => Promise<void>;
7
+ }
8
+ export declare function useConfig(): UseConfigResult;
@@ -0,0 +1,26 @@
1
+ import { useState, useEffect, useCallback } from 'react';
2
+ import { readConfig, patchConfig as patchConfigService, configExists, } from '../services/config.js';
3
+ export function useConfig() {
4
+ const [config, setConfig] = useState(null);
5
+ const [loading, setLoading] = useState(true);
6
+ const load = useCallback(async () => {
7
+ setLoading(true);
8
+ const exists = await configExists();
9
+ if (exists) {
10
+ const c = await readConfig();
11
+ setConfig(c);
12
+ }
13
+ else {
14
+ setConfig(null);
15
+ }
16
+ setLoading(false);
17
+ }, []);
18
+ useEffect(() => {
19
+ void load();
20
+ }, [load]);
21
+ const patch = useCallback(async (partial) => {
22
+ await patchConfigService(partial);
23
+ await load();
24
+ }, [load]);
25
+ return { config, loading, patch, refresh: load };
26
+ }