envmgr-cli 1.0.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/README.md +89 -0
- package/dist/api/client.js +27 -0
- package/dist/api/service.js +64 -0
- package/dist/commands/config.js +17 -0
- package/dist/commands/configure.js +30 -0
- package/dist/commands/doctor.js +105 -0
- package/dist/commands/help.js +42 -0
- package/dist/commands/import.js +64 -0
- package/dist/commands/login.js +38 -0
- package/dist/commands/status.js +43 -0
- package/dist/commands/switch.js +59 -0
- package/dist/commands/sync.js +122 -0
- package/dist/config/config.js +40 -0
- package/dist/config/guard.js +8 -0
- package/dist/constants.js +1 -0
- package/dist/index.js +61 -0
- package/dist/ui/components/AddVarsFlow.js +88 -0
- package/dist/ui/components/App.js +110 -0
- package/dist/ui/components/ConfigureForm.js +80 -0
- package/dist/ui/components/CreateEnvFlow.js +58 -0
- package/dist/ui/components/Dashboard.js +18 -0
- package/dist/ui/components/EnvironmentPicker.js +33 -0
- package/dist/ui/components/Header.js +12 -0
- package/dist/ui/components/LinkFlow.js +160 -0
- package/dist/ui/components/LoginForm.js +48 -0
- package/dist/ui/components/Logout.js +16 -0
- package/dist/ui/components/ProjectPicker.js +44 -0
- package/dist/ui/components/PushFlow.js +83 -0
- package/dist/ui/components/Screen.js +6 -0
- package/dist/ui/components/Status.js +36 -0
- package/dist/ui/components/SyncFlow.js +83 -0
- package/dist/ui/index.js +28 -0
- package/package.json +57 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { fetchVariables } from '../api/service.js';
|
|
5
|
+
export async function sync(options = {}) {
|
|
6
|
+
try {
|
|
7
|
+
const configPath = path.join(process.cwd(), '.envmgr', 'config.json');
|
|
8
|
+
if (!fs.existsSync(configPath)) {
|
|
9
|
+
console.error(chalk.red('Error: No local configuration found. Please link a project first.'));
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
12
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
13
|
+
const filePath = path.join(process.cwd(), config.envFilePath);
|
|
14
|
+
if (options.dryRun) {
|
|
15
|
+
console.log(chalk.bold(`\nDry Run: Syncing ${chalk.cyan(config.projectName)} (${chalk.yellow(config.environmentName)}) to ${chalk.dim(config.envFilePath)}\n`));
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
console.log(chalk.bold(`\nSyncing ${chalk.cyan(config.projectName)} (${chalk.yellow(config.environmentName)}) to ${chalk.dim(config.envFilePath)}...`));
|
|
19
|
+
}
|
|
20
|
+
const { data: remoteVars } = await fetchVariables(config.environmentId);
|
|
21
|
+
const remoteMap = new Map();
|
|
22
|
+
remoteVars.forEach((v) => remoteMap.set(v.key, v.value));
|
|
23
|
+
const localMap = new Map();
|
|
24
|
+
if (fs.existsSync(filePath)) {
|
|
25
|
+
const localContent = fs.readFileSync(filePath, 'utf-8');
|
|
26
|
+
localContent.split('\n').forEach(line => {
|
|
27
|
+
const [key, ...rest] = line.split('=');
|
|
28
|
+
if (key && key.trim()) {
|
|
29
|
+
localMap.set(key.trim(), rest.join('=').trim());
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
const added = [];
|
|
34
|
+
const modified = [];
|
|
35
|
+
const removed = [];
|
|
36
|
+
const unchanged = [];
|
|
37
|
+
remoteMap.forEach((value, key) => {
|
|
38
|
+
if (!localMap.has(key)) {
|
|
39
|
+
added.push(key);
|
|
40
|
+
}
|
|
41
|
+
else if (localMap.get(key) !== value) {
|
|
42
|
+
modified.push(key);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
unchanged.push(key);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
localMap.forEach((_, key) => {
|
|
49
|
+
if (!remoteMap.has(key)) {
|
|
50
|
+
removed.push(key);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
if (options.dryRun) {
|
|
54
|
+
if (added.length === 0 && modified.length === 0 && removed.length === 0) {
|
|
55
|
+
console.log(chalk.green('No changes detected. Local file is up to date.'));
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
added.forEach(key => console.log(`${chalk.green('+')} ${key}`));
|
|
59
|
+
modified.forEach(key => console.log(`${chalk.yellow('~')} ${key}`));
|
|
60
|
+
removed.forEach(key => console.log(`${chalk.red('-')} ${key}`));
|
|
61
|
+
console.log(`\nSummary: ${chalk.green(added.length + ' added')}, ${chalk.yellow(modified.length + ' modified')}, ${chalk.red(removed.length + ' removed')}`);
|
|
62
|
+
console.log(chalk.dim('\nThis was a dry run. No files were modified.'));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
const resolvedMap = new Map();
|
|
67
|
+
const { select } = await import('@inquirer/prompts');
|
|
68
|
+
// Process remote variables
|
|
69
|
+
for (const [key, remoteValue] of remoteMap) {
|
|
70
|
+
const localValue = localMap.get(key);
|
|
71
|
+
if (localValue !== undefined && localValue !== remoteValue && process.stdout.isTTY) {
|
|
72
|
+
console.log(chalk.yellow(`\nConflict detected for ${chalk.bold(key)}`));
|
|
73
|
+
console.log(`${chalk.dim(' Local: ')} ${localValue}`);
|
|
74
|
+
console.log(`${chalk.dim(' Remote:')} ${remoteValue}`);
|
|
75
|
+
const choice = await select({
|
|
76
|
+
message: 'Action:',
|
|
77
|
+
choices: [
|
|
78
|
+
{ name: 'Use remote', value: 'remote' },
|
|
79
|
+
{ name: 'Keep local', value: 'local' },
|
|
80
|
+
{ name: 'Skip', value: 'skip' }
|
|
81
|
+
]
|
|
82
|
+
});
|
|
83
|
+
if (choice === 'remote')
|
|
84
|
+
resolvedMap.set(key, remoteValue);
|
|
85
|
+
else if (choice === 'local')
|
|
86
|
+
resolvedMap.set(key, localValue);
|
|
87
|
+
// skip = don't include in final map
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
// No conflict or non-interactive: use remote value
|
|
91
|
+
resolvedMap.set(key, remoteValue);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Handle local-only variables (optional: user might want to keep them)
|
|
95
|
+
const localOnly = Array.from(localMap.keys()).filter(key => !remoteMap.has(key));
|
|
96
|
+
for (const key of localOnly) {
|
|
97
|
+
if (process.stdout.isTTY) {
|
|
98
|
+
console.log(chalk.blue(`\nLocal-only variable found: ${chalk.bold(key)}`));
|
|
99
|
+
const choice = await select({
|
|
100
|
+
message: 'Action:',
|
|
101
|
+
choices: [
|
|
102
|
+
{ name: 'Remove (Sync with remote)', value: 'remove' },
|
|
103
|
+
{ name: 'Keep local-only', value: 'keep' }
|
|
104
|
+
]
|
|
105
|
+
});
|
|
106
|
+
if (choice === 'keep')
|
|
107
|
+
resolvedMap.set(key, localMap.get(key));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const content = Array.from(resolvedMap.entries())
|
|
111
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
112
|
+
.join('\n');
|
|
113
|
+
fs.writeFileSync(filePath, content);
|
|
114
|
+
console.log(chalk.green('\n✓ Sync complete!'));
|
|
115
|
+
}
|
|
116
|
+
console.log('');
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
console.error(chalk.red(`\nError: ${err.message}`));
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
const CONFIG_DIR = path.join(os.homedir(), ".envmgr");
|
|
5
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
|
|
6
|
+
function readConfig() {
|
|
7
|
+
if (!fs.existsSync(CONFIG_FILE))
|
|
8
|
+
return {};
|
|
9
|
+
return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8"));
|
|
10
|
+
}
|
|
11
|
+
function writeConfig(config) {
|
|
12
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
13
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
14
|
+
}
|
|
15
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
16
|
+
}
|
|
17
|
+
export function saveToken(token) {
|
|
18
|
+
const config = readConfig();
|
|
19
|
+
writeConfig({ ...config, token });
|
|
20
|
+
}
|
|
21
|
+
export function getToken() {
|
|
22
|
+
return readConfig().token ?? null;
|
|
23
|
+
}
|
|
24
|
+
export function clearToken() {
|
|
25
|
+
const config = readConfig();
|
|
26
|
+
delete config.token;
|
|
27
|
+
writeConfig(config);
|
|
28
|
+
}
|
|
29
|
+
export function setApiUrl(apiUrl) {
|
|
30
|
+
const config = readConfig();
|
|
31
|
+
writeConfig({ ...config, apiUrl });
|
|
32
|
+
}
|
|
33
|
+
export function getApiUrl() {
|
|
34
|
+
return readConfig().apiUrl ?? null;
|
|
35
|
+
}
|
|
36
|
+
export function clearAll() {
|
|
37
|
+
if (fs.existsSync(CONFIG_FILE)) {
|
|
38
|
+
fs.unlinkSync(CONFIG_FILE);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const DEFAULT_API_URL = "https://envmgr.vercel.app";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import "dotenv/config";
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { render } from 'ink';
|
|
5
|
+
import { App } from './ui/components/App.js';
|
|
6
|
+
async function main() {
|
|
7
|
+
const [command] = process.argv.slice(2);
|
|
8
|
+
const args = process.argv.slice(2);
|
|
9
|
+
const isJson = args.includes('--json');
|
|
10
|
+
const isDebug = args.includes('--debug') || process.env.ENVMGR_DEBUG === '1';
|
|
11
|
+
const isDryRun = args.includes('--dry-run');
|
|
12
|
+
if (isDebug) {
|
|
13
|
+
process.env.ENVMGR_DEBUG = '1';
|
|
14
|
+
}
|
|
15
|
+
const uiCommands = ['login', 'configure', 'link', 'sync', 'status', 'logout', 'create-env', 'push'];
|
|
16
|
+
// Command mapping for convenience
|
|
17
|
+
let activeCommand = command;
|
|
18
|
+
if (command === 'env' && args[1] === 'create')
|
|
19
|
+
activeCommand = 'create-env';
|
|
20
|
+
if (command === 'var' && args[1] === 'add') {
|
|
21
|
+
const { importVariables } = await import('./commands/import.js');
|
|
22
|
+
await importVariables();
|
|
23
|
+
process.exit(0);
|
|
24
|
+
}
|
|
25
|
+
const isHelp = args.includes('--help') || command === 'help';
|
|
26
|
+
if (isHelp) {
|
|
27
|
+
const { help } = await import('./commands/help.js');
|
|
28
|
+
help();
|
|
29
|
+
process.exit(0);
|
|
30
|
+
}
|
|
31
|
+
if (!activeCommand || uiCommands.includes(activeCommand)) {
|
|
32
|
+
if (activeCommand === 'status' && (isJson || !process.stdout.isTTY)) {
|
|
33
|
+
const { status } = await import('./commands/status.js');
|
|
34
|
+
await status({ json: isJson });
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
const shouldExit = !!activeCommand;
|
|
38
|
+
render(React.createElement(App, {
|
|
39
|
+
initialView: activeCommand || 'dashboard',
|
|
40
|
+
isDryRun,
|
|
41
|
+
shouldExit
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
else if (activeCommand === 'switch') {
|
|
46
|
+
const { handleSwitch } = await import('./commands/switch.js');
|
|
47
|
+
await handleSwitch(process.argv.slice(3));
|
|
48
|
+
}
|
|
49
|
+
else if (command === 'doctor') {
|
|
50
|
+
const { doctor } = await import('./commands/doctor.js');
|
|
51
|
+
await doctor();
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
console.log(`Unknown command: ${command}`);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
main().catch((err) => {
|
|
59
|
+
console.error("Unexpected error", err);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useEffect } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import TextInput from 'ink-text-input';
|
|
5
|
+
import Spinner from 'ink-spinner';
|
|
6
|
+
import SelectInput from 'ink-select-input';
|
|
7
|
+
import { Screen } from './Screen.js';
|
|
8
|
+
import { bulkCreateVariables } from '../../api/service.js';
|
|
9
|
+
import fs from 'fs';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
export const AddVarsFlow = ({ onCancel }) => {
|
|
12
|
+
const [step, setStep] = useState('mode');
|
|
13
|
+
const [inputContent, setInputContent] = useState('');
|
|
14
|
+
const [error, setError] = useState(null);
|
|
15
|
+
const [config, setConfig] = useState(null);
|
|
16
|
+
const [parsedVars, setParsedVars] = useState([]);
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
try {
|
|
19
|
+
const configPath = path.join(process.cwd(), '.envmgr', 'config.json');
|
|
20
|
+
if (fs.existsSync(configPath)) {
|
|
21
|
+
setConfig(JSON.parse(fs.readFileSync(configPath, 'utf-8')));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
setError('Failed to load project configuration');
|
|
26
|
+
setStep('error');
|
|
27
|
+
}
|
|
28
|
+
}, []);
|
|
29
|
+
useInput((input, key) => {
|
|
30
|
+
if (step === 'success' || (step === 'error' && key.escape)) {
|
|
31
|
+
onCancel();
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
const parseVariables = (text) => {
|
|
35
|
+
const lines = text.split('\n');
|
|
36
|
+
const vars = [];
|
|
37
|
+
lines.forEach(line => {
|
|
38
|
+
const trimmed = line.trim();
|
|
39
|
+
if (!trimmed || trimmed.startsWith('#'))
|
|
40
|
+
return;
|
|
41
|
+
// Handle KEY=VALUE or KEY: VALUE
|
|
42
|
+
let key = '', value = '';
|
|
43
|
+
if (trimmed.includes('=')) {
|
|
44
|
+
const [k, ...v] = trimmed.split('=');
|
|
45
|
+
key = k.trim();
|
|
46
|
+
value = v.join('=').trim();
|
|
47
|
+
}
|
|
48
|
+
else if (trimmed.includes(':')) {
|
|
49
|
+
const [k, ...v] = trimmed.split(':');
|
|
50
|
+
key = k.trim();
|
|
51
|
+
value = v.join(':').trim();
|
|
52
|
+
}
|
|
53
|
+
if (key) {
|
|
54
|
+
// Basic heuristic for secrets: if key contains SECRET, KEY, PASS, TOKEN, etc.
|
|
55
|
+
const isSecret = /SECRET|PASSWORD|TOKEN|KEY|AUTH|CREDENTIAL|PRIVATE/i.test(key);
|
|
56
|
+
vars.push({ key, value, isSecret });
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
return vars;
|
|
60
|
+
};
|
|
61
|
+
const handleSubmit = async () => {
|
|
62
|
+
const vars = parseVariables(inputContent);
|
|
63
|
+
if (vars.length === 0) {
|
|
64
|
+
setError('No variables found in input. Use KEY=VALUE format.');
|
|
65
|
+
setStep('error');
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
setParsedVars(vars);
|
|
69
|
+
setStep('submitting');
|
|
70
|
+
try {
|
|
71
|
+
await bulkCreateVariables(config.environmentId, vars);
|
|
72
|
+
setStep('success');
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
setError(err.message || 'Failed to add variables');
|
|
76
|
+
setStep('error');
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
return (_jsx(Screen, { children: _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { bold: true, color: "cyan", children: ["Add Variables to ", config?.environmentName] }), _jsxs(Text, { dimColor: true, children: ["Project: ", config?.projectName] }), _jsxs(Box, { marginTop: 1, children: [step === 'mode' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "Select input mode:" }), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: [
|
|
80
|
+
{ label: 'Paste multiple variables (KEY=VALUE)', value: 'paste' },
|
|
81
|
+
{ label: 'Cancel', value: 'cancel' }
|
|
82
|
+
], onSelect: (item) => {
|
|
83
|
+
if (item.value === 'paste')
|
|
84
|
+
setStep('paste');
|
|
85
|
+
else
|
|
86
|
+
onCancel();
|
|
87
|
+
} }) })] })), step === 'paste' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Paste your variables below and press Enter twice to confirm:" }), _jsx(Box, { borderStyle: "round", borderColor: "dim", paddingX: 1, marginTop: 1, children: _jsx(TextInput, { value: inputContent, onChange: setInputContent, onSubmit: handleSubmit, placeholder: "DB_URL=postgres://...\nAPI_KEY=sk_test_..." }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, italic: true, children: "Note: Secret status is automatically detected for keys like API_KEY, PASSWORD, etc." }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press Escape to cancel" }) })] })), step === 'submitting' && (_jsx(Box, { flexDirection: "column", children: _jsxs(Text, { color: "yellow", children: [_jsx(Spinner, { type: "dots" }), " Adding ", parsedVars.length, " variables..."] }) })), step === 'success' && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "green", bold: true, children: ["\u2713 ", parsedVars.length, " variables added successfully!"] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [parsedVars.slice(0, 5).map((v, i) => (_jsxs(Text, { dimColor: true, children: [" + ", v.key] }, i))), parsedVars.length > 5 && _jsxs(Text, { dimColor: true, children: [" ... and ", parsedVars.length - 5, " more"] })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, italic: true, children: "Press any key to return to menu" }) })] })), step === 'error' && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "red", children: ["Error: ", error] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, italic: true, children: "Press Escape to go back" }) })] }))] })] }) }));
|
|
88
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import React, { useState } from 'react';
|
|
3
|
+
import { useApp } from 'ink';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import { DEFAULT_API_URL } from '../../constants.js';
|
|
7
|
+
import { Dashboard } from './Dashboard.js';
|
|
8
|
+
import { LoginForm } from './LoginForm.js';
|
|
9
|
+
import { ConfigureForm } from './ConfigureForm.js';
|
|
10
|
+
import { Status } from './Status.js';
|
|
11
|
+
import { Logout } from './Logout.js';
|
|
12
|
+
import { LinkFlow } from './LinkFlow.js';
|
|
13
|
+
import { SyncFlow } from './SyncFlow.js';
|
|
14
|
+
import { CreateEnvFlow } from './CreateEnvFlow.js';
|
|
15
|
+
import { PushFlow } from './PushFlow.js';
|
|
16
|
+
export const App = ({ initialView = 'dashboard', isDryRun = false, shouldExit = false }) => {
|
|
17
|
+
const [view, setView] = useState(initialView);
|
|
18
|
+
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
|
19
|
+
const [isConfigured, setIsConfigured] = useState(false);
|
|
20
|
+
const [isLinked, setIsLinked] = useState(false);
|
|
21
|
+
const [localConfig, setLocalConfig] = useState(null);
|
|
22
|
+
const { exit } = useApp();
|
|
23
|
+
const handleCancel = () => {
|
|
24
|
+
if (shouldExit) {
|
|
25
|
+
exit();
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
setView('dashboard');
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
React.useEffect(() => {
|
|
32
|
+
async function checkStatus() {
|
|
33
|
+
const { getToken, getApiUrl } = await import('../../config/config.js');
|
|
34
|
+
setIsLoggedIn(!!getToken());
|
|
35
|
+
setIsConfigured(!!getApiUrl());
|
|
36
|
+
const configPath = path.join(process.cwd(), '.envmgr', 'config.json');
|
|
37
|
+
let linked = false;
|
|
38
|
+
if (fs.existsSync(configPath)) {
|
|
39
|
+
try {
|
|
40
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
41
|
+
setLocalConfig(config);
|
|
42
|
+
linked = !!config.projectId;
|
|
43
|
+
}
|
|
44
|
+
catch (e) {
|
|
45
|
+
console.error('Failed to parse config');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
setIsLinked(linked);
|
|
49
|
+
}
|
|
50
|
+
checkStatus();
|
|
51
|
+
}, [view]);
|
|
52
|
+
const handleAction = (item) => {
|
|
53
|
+
if (item.value === 'exit') {
|
|
54
|
+
exit();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
setView(item.value);
|
|
58
|
+
};
|
|
59
|
+
const handleLogin = async (email, pass) => {
|
|
60
|
+
const { requireApiConfig } = await import('../../config/guard.js');
|
|
61
|
+
const { saveToken } = await import('../../config/config.js');
|
|
62
|
+
const apiUrl = requireApiConfig();
|
|
63
|
+
const res = await fetch(`${apiUrl}/api/auth/login`, {
|
|
64
|
+
method: "POST",
|
|
65
|
+
headers: { "Content-Type": "application/json" },
|
|
66
|
+
body: JSON.stringify({
|
|
67
|
+
email,
|
|
68
|
+
password: pass,
|
|
69
|
+
client: "cli",
|
|
70
|
+
}),
|
|
71
|
+
});
|
|
72
|
+
const data = await res.json();
|
|
73
|
+
if (!res.ok) {
|
|
74
|
+
if (res.status === 401) {
|
|
75
|
+
throw new Error('Incorrect email or password. Please try again.');
|
|
76
|
+
}
|
|
77
|
+
throw new Error(data.message || 'Login failed');
|
|
78
|
+
}
|
|
79
|
+
saveToken(data.data.token);
|
|
80
|
+
// Wait a bit before returning to dashboard or exiting
|
|
81
|
+
setTimeout(() => handleCancel(), 2000);
|
|
82
|
+
};
|
|
83
|
+
const handleConfigure = async (apiUrl) => {
|
|
84
|
+
const { setApiUrl } = await import('../../config/config.js');
|
|
85
|
+
setApiUrl(apiUrl);
|
|
86
|
+
// Wait a bit before returning to dashboard or exiting
|
|
87
|
+
setTimeout(() => handleCancel(), 2000);
|
|
88
|
+
};
|
|
89
|
+
switch (view) {
|
|
90
|
+
case 'login':
|
|
91
|
+
return _jsx(LoginForm, { onSubmit: handleLogin, onCancel: handleCancel });
|
|
92
|
+
case 'configure':
|
|
93
|
+
return (_jsx(ConfigureForm, { onSubmit: handleConfigure, onCancel: handleCancel, defaultUrl: DEFAULT_API_URL, config: localConfig, isLoggedIn: isLoggedIn, onEditProject: () => setView('link') }));
|
|
94
|
+
case 'status':
|
|
95
|
+
return _jsx(Status, { onBack: handleCancel });
|
|
96
|
+
case 'logout':
|
|
97
|
+
return _jsx(Logout, { onComplete: handleCancel });
|
|
98
|
+
case 'link':
|
|
99
|
+
return _jsx(LinkFlow, { onCancel: handleCancel, isSwitching: isLinked, onSyncRequest: () => setView('sync') });
|
|
100
|
+
case 'sync':
|
|
101
|
+
return _jsx(SyncFlow, { onCancel: handleCancel, isDryRun: isDryRun });
|
|
102
|
+
case 'create-env':
|
|
103
|
+
return _jsx(CreateEnvFlow, { onCancel: handleCancel });
|
|
104
|
+
case 'push':
|
|
105
|
+
return _jsx(PushFlow, { onCancel: handleCancel });
|
|
106
|
+
case 'dashboard':
|
|
107
|
+
default:
|
|
108
|
+
return _jsx(Dashboard, { onSelect: handleAction, isLoggedIn: isLoggedIn, isConfigured: isConfigured, isLinked: isLinked });
|
|
109
|
+
}
|
|
110
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import TextInput from 'ink-text-input';
|
|
5
|
+
import SelectInput from 'ink-select-input';
|
|
6
|
+
import { Screen } from './Screen.js';
|
|
7
|
+
import fs from 'fs';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
export const ConfigureForm = ({ onSubmit, onCancel, defaultUrl, config, isLoggedIn, onEditProject }) => {
|
|
10
|
+
const [step, setStep] = useState((isLoggedIn && config) ? 'choice' : 'hosted-choice');
|
|
11
|
+
const [apiUrl, setApiUrl] = useState('');
|
|
12
|
+
const [targetFile, setTargetFile] = useState(config?.envFilePath || '.env.local');
|
|
13
|
+
const [error, setError] = useState(null);
|
|
14
|
+
useInput((input, key) => {
|
|
15
|
+
if (step === 'success') {
|
|
16
|
+
onCancel();
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
if (key.escape || input === '\u001b') {
|
|
20
|
+
onCancel();
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
const handleChoiceSelect = (item) => {
|
|
24
|
+
if (item.value === 'api')
|
|
25
|
+
setStep('hosted-choice');
|
|
26
|
+
else if (item.value === 'file')
|
|
27
|
+
setStep('edit-file');
|
|
28
|
+
else if (item.value === 'project')
|
|
29
|
+
onEditProject?.();
|
|
30
|
+
else
|
|
31
|
+
onCancel();
|
|
32
|
+
};
|
|
33
|
+
const handleHostedSelect = (item) => {
|
|
34
|
+
if (item.value === 'default') {
|
|
35
|
+
onSubmit(defaultUrl);
|
|
36
|
+
setStep('success');
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
setStep('custom-url');
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
const handleUrlSubmit = () => {
|
|
43
|
+
try {
|
|
44
|
+
new URL(apiUrl);
|
|
45
|
+
onSubmit(apiUrl);
|
|
46
|
+
setStep('success');
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
setError('Please enter a valid URL');
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
const handleFileSubmit = () => {
|
|
53
|
+
if (!targetFile) {
|
|
54
|
+
setError('Please enter a filename');
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const configPath = path.join(process.cwd(), '.envmgr', 'config.json');
|
|
59
|
+
const currentConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
60
|
+
currentConfig.envFilePath = targetFile;
|
|
61
|
+
fs.writeFileSync(configPath, JSON.stringify(currentConfig, null, 2));
|
|
62
|
+
setStep('success');
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
setError(err.message || 'Failed to update config');
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
if (step === 'success') {
|
|
69
|
+
return (_jsx(Screen, { children: _jsxs(Box, { flexDirection: "column", alignItems: "center", justifyContent: "center", flexGrow: 1, children: [_jsx(Text, { color: "green", bold: true, children: "\u2713 Configuration updated!" }), _jsx(Text, { dimColor: true, children: "Returning to menu..." })] }) }));
|
|
70
|
+
}
|
|
71
|
+
return (_jsx(Screen, { children: _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, color: "magenta", children: "Configure EnvMgr" }), step === 'choice' && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { children: "What would you like to configure?" }), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: [
|
|
72
|
+
{ label: 'API Server URL', value: 'api' },
|
|
73
|
+
{ label: 'Target File (.env)', value: 'file' },
|
|
74
|
+
{ label: 'Project & Environment', value: 'project' },
|
|
75
|
+
{ label: 'Back', value: 'back' }
|
|
76
|
+
], onSelect: handleChoiceSelect }) })] })), step === 'hosted-choice' && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { children: "Which EnvMgr instance are you using?" }), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: [
|
|
77
|
+
{ label: 'Cloud (envmgr.vercel.app)', value: 'default' },
|
|
78
|
+
{ label: 'Self-hosted Instance', value: 'custom' },
|
|
79
|
+
], onSelect: handleHostedSelect }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press Escape to go back" }) })] })), step === 'custom-url' && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "API URL:" }), _jsx(TextInput, { value: apiUrl, onChange: setApiUrl, onSubmit: handleUrlSubmit, placeholder: "https://api.your-instance.com" })] }), error && _jsx(Text, { color: "red", children: error }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press Escape to go back" }) })] })), step === 'edit-file' && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Target File Path:" }), _jsx(TextInput, { value: targetFile, onChange: setTargetFile, onSubmit: handleFileSubmit, placeholder: ".env.local" })] }), error && _jsx(Text, { color: "red", children: error }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press Enter to save \u2022 Escape to go back" }) })] }))] }) }));
|
|
80
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useEffect } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import TextInput from 'ink-text-input';
|
|
5
|
+
import Spinner from 'ink-spinner';
|
|
6
|
+
import { Screen } from './Screen.js';
|
|
7
|
+
import { ProjectPicker } from './ProjectPicker.js';
|
|
8
|
+
import { createEnvironment } from '../../api/service.js';
|
|
9
|
+
import fs from 'fs';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
export const CreateEnvFlow = ({ onCancel }) => {
|
|
12
|
+
const [step, setStep] = useState('project');
|
|
13
|
+
const [selectedProject, setSelectedProject] = useState(null);
|
|
14
|
+
const [envName, setEnvName] = useState('');
|
|
15
|
+
const [error, setError] = useState(null);
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
// Check if we already have a linked project to pre-select it
|
|
18
|
+
try {
|
|
19
|
+
const configPath = path.join(process.cwd(), '.envmgr', 'config.json');
|
|
20
|
+
if (fs.existsSync(configPath)) {
|
|
21
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
22
|
+
setSelectedProject({ id: config.projectId, name: config.projectName });
|
|
23
|
+
setStep('name');
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
// Ignore
|
|
28
|
+
}
|
|
29
|
+
}, []);
|
|
30
|
+
useInput((input, key) => {
|
|
31
|
+
if (step === 'success') {
|
|
32
|
+
onCancel();
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (key.escape && step !== 'submitting') {
|
|
36
|
+
onCancel();
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
// Fallback for some terminals where key.escape might not be detected
|
|
40
|
+
if (input === '\u001b' && step !== 'submitting') {
|
|
41
|
+
onCancel();
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
const handleCreate = async () => {
|
|
45
|
+
if (!selectedProject || !envName.trim())
|
|
46
|
+
return;
|
|
47
|
+
setStep('submitting');
|
|
48
|
+
try {
|
|
49
|
+
await createEnvironment(envName.trim(), selectedProject.id);
|
|
50
|
+
setStep('success');
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
setError(err.message || 'Failed to create environment');
|
|
54
|
+
setStep('error');
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
return (_jsx(Screen, { children: _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, color: "cyan", children: "Add New Environment" }), _jsxs(Box, { marginTop: 1, children: [step === 'project' && (_jsx(ProjectPicker, { onSelect: (p) => { setSelectedProject(p); setStep('name'); }, onCancel: onCancel })), step === 'name' && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["Project: ", _jsx(Text, { color: "green", children: selectedProject?.name })] }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { bold: true, children: "Environment Name: " }), _jsx(TextInput, { value: envName, onChange: setEnvName, onSubmit: handleCreate, placeholder: "e.g. staging, testing" })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, italic: true, children: "Press Enter to create \u2022 Esc to cancel" }) })] })), step === 'submitting' && (_jsxs(Text, { color: "yellow", children: [_jsx(Spinner, { type: "dots" }), " Creating environment..."] })), step === 'success' && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "green", bold: true, children: ["\u2713 Environment \"", envName, "\" created successfully!"] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, italic: true, children: "Press any key to return to menu" }) })] })), step === 'error' && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "red", children: ["Error: ", error] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, italic: true, children: "Press Escape to go back" }) })] }))] })] }) }));
|
|
58
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import SelectInput from 'ink-select-input';
|
|
4
|
+
import { Screen } from './Screen.js';
|
|
5
|
+
export const Dashboard = ({ onSelect, isLoggedIn, isConfigured, isLinked }) => {
|
|
6
|
+
const items = [
|
|
7
|
+
isLoggedIn && isLinked && { label: 'Sync Variables', value: 'sync' },
|
|
8
|
+
isLoggedIn && isLinked && { label: 'Push Variables', value: 'push' },
|
|
9
|
+
isLoggedIn && isLinked && { label: 'Add Environment', value: 'create-env' },
|
|
10
|
+
isLoggedIn && { label: isLinked ? 'Switch Environment' : 'Link Project', value: 'link' },
|
|
11
|
+
isLoggedIn && { label: 'Status', value: 'status' },
|
|
12
|
+
!isLoggedIn && isConfigured && { label: 'Login', value: 'login' },
|
|
13
|
+
{ label: 'Configure', value: 'configure' },
|
|
14
|
+
isLoggedIn && { label: 'Logout', value: 'logout' },
|
|
15
|
+
{ label: 'Exit', value: 'exit' },
|
|
16
|
+
].filter(Boolean);
|
|
17
|
+
return (_jsx(Screen, { children: _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, color: "yellow", children: "Main Menu" }), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: items, onSelect: onSelect }) })] }) }));
|
|
18
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useEffect } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import SelectInput from 'ink-select-input';
|
|
5
|
+
import Spinner from 'ink-spinner';
|
|
6
|
+
import { fetchEnvironments } from '../../api/service.js';
|
|
7
|
+
export const EnvironmentPicker = ({ projectId, projectName, onSelect, onCancel, activeId }) => {
|
|
8
|
+
const [envs, setEnvs] = useState([]);
|
|
9
|
+
const [loading, setLoading] = useState(true);
|
|
10
|
+
const [error, setError] = useState(null);
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
async function loadEnvs() {
|
|
13
|
+
try {
|
|
14
|
+
const res = await fetchEnvironments(projectId);
|
|
15
|
+
setEnvs(res.data.map((e) => ({
|
|
16
|
+
label: e.id === activeId ? `${e.name} (active)` : e.name,
|
|
17
|
+
value: e.id
|
|
18
|
+
})));
|
|
19
|
+
setLoading(false);
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
setError(err.message || 'Failed to fetch environments');
|
|
23
|
+
setLoading(false);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
loadEnvs();
|
|
27
|
+
}, [projectId, activeId]);
|
|
28
|
+
useInput((input, key) => {
|
|
29
|
+
if (key.escape)
|
|
30
|
+
onCancel();
|
|
31
|
+
});
|
|
32
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "Select Environment for " }), _jsx(Text, { bold: true, color: "yellow", children: projectName })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", minHeight: 8, children: loading ? (_jsxs(Text, { color: "yellow", children: [_jsx(Spinner, { type: "dots" }), " Loading environments..."] })) : error ? (_jsxs(Text, { color: "red", children: ["Error: ", error] })) : envs.length === 0 ? (_jsx(Text, { dimColor: true, children: "No environments found" })) : (_jsx(SelectInput, { items: envs, onSelect: (item) => onSelect({ id: item.value, name: item.label }) })) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Esc to go back" }) })] }));
|
|
33
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
const LOGO = `
|
|
4
|
+
███████╗███╗ ██╗██╗ ██╗███╗ ███╗ ██████╗ ██████╗
|
|
5
|
+
██╔════╝████╗ ██║██║ ██║████╗ ████║██╔════╝ ██╔══██╗
|
|
6
|
+
█████╗ ██╔██╗ ██║██║ ██║██╔████╔██║██║ ███╗██████╔╝
|
|
7
|
+
██╔══╝ ██║╚██╗██║╚██╗ ██╔╝██║╚██╔╝██║██║ ██║██╔══██╗
|
|
8
|
+
███████╗██║ ╚████║ ╚████╔╝ ██║ ╚═╝ ██║╚██████╔╝██║ ██║
|
|
9
|
+
╚══════╝╚═╝ ╚═══╝ ╚═══╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝`;
|
|
10
|
+
export const Header = () => {
|
|
11
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, paddingX: 1, children: [_jsx(Box, { children: _jsx(Text, { color: "#34B27B", bold: true, children: LOGO }) }), _jsxs(Box, { marginTop: 1, paddingX: 2, flexDirection: "row", justifyContent: "space-between", width: 65, children: [_jsx(Text, { dimColor: true, bold: true, children: "WORKSPACE CONSOLE" }), _jsx(Text, { dimColor: true, italic: true, children: "Unified Management \u2022 v1.0.0" })] }), _jsx(Box, { borderStyle: "single", borderTop: true, borderBottom: false, borderLeft: false, borderRight: false, borderColor: "gray", borderDimColor: true, marginTop: 1, width: 65 })] }));
|
|
12
|
+
};
|