@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.
- package/dist/App.d.ts +8 -0
- package/dist/App.js +109 -0
- package/dist/__tests__/checks.test.d.ts +1 -0
- package/dist/__tests__/checks.test.js +68 -0
- package/dist/__tests__/config.test.d.ts +1 -0
- package/dist/__tests__/config.test.js +61 -0
- package/dist/components/ConfirmExit.d.ts +9 -0
- package/dist/components/ConfirmExit.js +12 -0
- package/dist/components/ErrorPanel.d.ts +7 -0
- package/dist/components/ErrorPanel.js +12 -0
- package/dist/components/Header.d.ts +10 -0
- package/dist/components/Header.js +17 -0
- package/dist/components/LeftPanel.d.ts +9 -0
- package/dist/components/LeftPanel.js +31 -0
- package/dist/components/OptionsPanel.d.ts +11 -0
- package/dist/components/OptionsPanel.js +18 -0
- package/dist/components/PathInput.d.ts +10 -0
- package/dist/components/PathInput.js +133 -0
- package/dist/components/ProgressBar.d.ts +5 -0
- package/dist/components/ProgressBar.js +21 -0
- package/dist/components/StepsPanel.d.ts +8 -0
- package/dist/components/StepsPanel.js +24 -0
- package/dist/hooks/useConfig.d.ts +8 -0
- package/dist/hooks/useConfig.js +26 -0
- package/dist/hooks/useTerminalSize.d.ts +4 -0
- package/dist/hooks/useTerminalSize.js +18 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +169 -0
- package/dist/screens/ConfigScreen.d.ts +10 -0
- package/dist/screens/ConfigScreen.js +182 -0
- package/dist/screens/ConfigureServiceScreen.d.ts +11 -0
- package/dist/screens/ConfigureServiceScreen.js +499 -0
- package/dist/screens/HomeScreen.d.ts +14 -0
- package/dist/screens/HomeScreen.js +24 -0
- package/dist/screens/IdeScreen.d.ts +9 -0
- package/dist/screens/IdeScreen.js +101 -0
- package/dist/screens/InitScreen.d.ts +9 -0
- package/dist/screens/InitScreen.js +182 -0
- package/dist/screens/InstallVersionScreen.d.ts +10 -0
- package/dist/screens/InstallVersionScreen.js +495 -0
- package/dist/screens/OdooScreen.d.ts +13 -0
- package/dist/screens/OdooScreen.js +76 -0
- package/dist/screens/OdooServiceScreen.d.ts +10 -0
- package/dist/screens/OdooServiceScreen.js +51 -0
- package/dist/screens/StartServiceScreen.d.ts +12 -0
- package/dist/screens/StartServiceScreen.js +386 -0
- package/dist/screens/UpgradeVersionScreen.d.ts +9 -0
- package/dist/screens/UpgradeVersionScreen.js +259 -0
- package/dist/services/checks.d.ts +8 -0
- package/dist/services/checks.js +48 -0
- package/dist/services/config.d.ts +11 -0
- package/dist/services/config.js +146 -0
- package/dist/services/git.d.ts +35 -0
- package/dist/services/git.js +173 -0
- package/dist/services/ide.d.ts +10 -0
- package/dist/services/ide.js +126 -0
- package/dist/services/python.d.ts +14 -0
- package/dist/services/python.js +81 -0
- package/dist/services/system.d.ts +2 -0
- package/dist/services/system.js +22 -0
- package/dist/types/index.d.ts +82 -0
- package/dist/types/index.js +26 -0
- package/package.json +37 -0
package/dist/App.d.ts
ADDED
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,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,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,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
|
+
}
|