@simplens/onboard 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 +214 -0
- package/dist/__tests__/errors.test.d.ts +2 -0
- package/dist/__tests__/errors.test.d.ts.map +1 -0
- package/dist/__tests__/errors.test.js +125 -0
- package/dist/__tests__/errors.test.js.map +1 -0
- package/dist/__tests__/utils.test.d.ts +2 -0
- package/dist/__tests__/utils.test.d.ts.map +1 -0
- package/dist/__tests__/utils.test.js +105 -0
- package/dist/__tests__/utils.test.js.map +1 -0
- package/dist/__tests__/validators.test.d.ts +2 -0
- package/dist/__tests__/validators.test.d.ts.map +1 -0
- package/dist/__tests__/validators.test.js +148 -0
- package/dist/__tests__/validators.test.js.map +1 -0
- package/dist/config/constants.d.ts +69 -0
- package/dist/config/constants.d.ts.map +1 -0
- package/dist/config/constants.js +79 -0
- package/dist/config/constants.js.map +1 -0
- package/dist/config/index.d.ts +2 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +2 -0
- package/dist/config/index.js.map +1 -0
- package/dist/env-config.d.ts +33 -0
- package/dist/env-config.d.ts.map +1 -0
- package/dist/env-config.js +285 -0
- package/dist/env-config.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +153 -0
- package/dist/index.js.map +1 -0
- package/dist/infra.d.ts +31 -0
- package/dist/infra.d.ts.map +1 -0
- package/dist/infra.js +267 -0
- package/dist/infra.js.map +1 -0
- package/dist/plugins.d.ts +35 -0
- package/dist/plugins.d.ts.map +1 -0
- package/dist/plugins.js +164 -0
- package/dist/plugins.js.map +1 -0
- package/dist/services.d.ts +52 -0
- package/dist/services.d.ts.map +1 -0
- package/dist/services.js +158 -0
- package/dist/services.js.map +1 -0
- package/dist/templates.d.ts +3 -0
- package/dist/templates.d.ts.map +1 -0
- package/dist/templates.js +202 -0
- package/dist/templates.js.map +1 -0
- package/dist/types/domain.d.ts +119 -0
- package/dist/types/domain.d.ts.map +1 -0
- package/dist/types/domain.js +5 -0
- package/dist/types/domain.js.map +1 -0
- package/dist/types/errors.d.ts +69 -0
- package/dist/types/errors.d.ts.map +1 -0
- package/dist/types/errors.js +129 -0
- package/dist/types/errors.js.map +1 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +3 -0
- package/dist/types/index.js.map +1 -0
- package/dist/utils/index.d.ts +2 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +2 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/logger.d.ts +54 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +92 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils.d.ts +32 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +79 -0
- package/dist/utils.js.map +1 -0
- package/dist/validators.d.ts +93 -0
- package/dist/validators.d.ts.map +1 -0
- package/dist/validators.js +180 -0
- package/dist/validators.js.map +1 -0
- package/package.json +45 -0
- package/src/__tests__/errors.test.ts +187 -0
- package/src/__tests__/utils.test.ts +142 -0
- package/src/__tests__/validators.test.ts +195 -0
- package/src/config/constants.ts +86 -0
- package/src/config/index.ts +1 -0
- package/src/env-config.ts +320 -0
- package/src/index.ts +203 -0
- package/src/infra.ts +300 -0
- package/src/plugins.ts +190 -0
- package/src/services.ts +190 -0
- package/src/templates.ts +203 -0
- package/src/types/domain.ts +127 -0
- package/src/types/errors.ts +173 -0
- package/src/types/index.ts +2 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/logger.ts +118 -0
- package/src/utils.ts +105 -0
- package/src/validators.ts +192 -0
- package/tsconfig.json +19 -0
- package/vitest.config.ts +20 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import inquirer from 'inquirer';
|
|
6
|
+
import { displayBanner, logSuccess, logError, logInfo, initLogger, logDebug } from './utils.js';
|
|
7
|
+
import { validatePrerequisites } from './validators.js';
|
|
8
|
+
import {
|
|
9
|
+
promptInfraServices,
|
|
10
|
+
generateInfraCompose,
|
|
11
|
+
writeAppCompose,
|
|
12
|
+
} from './infra.js';
|
|
13
|
+
import {
|
|
14
|
+
promptEnvVariables,
|
|
15
|
+
generateEnvFile,
|
|
16
|
+
appendPluginEnv,
|
|
17
|
+
} from './env-config.js';
|
|
18
|
+
import {
|
|
19
|
+
fetchAvailablePlugins,
|
|
20
|
+
promptPluginSelection,
|
|
21
|
+
generatePluginConfig,
|
|
22
|
+
parseConfigCredentials,
|
|
23
|
+
promptPluginCredentials,
|
|
24
|
+
} from './plugins.js';
|
|
25
|
+
import {
|
|
26
|
+
promptStartServices,
|
|
27
|
+
startInfraServices,
|
|
28
|
+
waitForInfraHealth,
|
|
29
|
+
startAppServices,
|
|
30
|
+
displayServiceStatus,
|
|
31
|
+
} from './services.js';
|
|
32
|
+
|
|
33
|
+
const program = new Command();
|
|
34
|
+
|
|
35
|
+
program
|
|
36
|
+
.name('@simplens/onboard')
|
|
37
|
+
.description('A CLI tool to setup a SimpleNS instance on your machine/server')
|
|
38
|
+
.version('1.0.0')
|
|
39
|
+
.option('--infra', 'Setup infrastructure services (MongoDB, Kafka, Redis, etc.)')
|
|
40
|
+
.option('--env <mode>', 'Environment setup mode: "default" or "interactive"')
|
|
41
|
+
.option('--dir <path>', 'Target directory for setup')
|
|
42
|
+
.parse(process.argv);
|
|
43
|
+
|
|
44
|
+
const options = program.opts();
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Prompt for setup options if not provided via CLI args
|
|
48
|
+
*/
|
|
49
|
+
async function promptSetupOptions(): Promise<{
|
|
50
|
+
infra: boolean;
|
|
51
|
+
envMode: 'default' | 'interactive';
|
|
52
|
+
targetDir: string;
|
|
53
|
+
}> {
|
|
54
|
+
const answers = await inquirer.prompt([
|
|
55
|
+
{
|
|
56
|
+
type: 'confirm',
|
|
57
|
+
name: 'infra',
|
|
58
|
+
message: 'Do you want to setup infrastructure services (MongoDB, Kafka, Redis, etc.)?',
|
|
59
|
+
default: true,
|
|
60
|
+
when: () => options.infra === undefined,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
type: 'list',
|
|
64
|
+
name: 'envMode',
|
|
65
|
+
message: 'Select environment configuration mode:',
|
|
66
|
+
choices: [
|
|
67
|
+
{ name: 'Default (use preset values, prompt only for critical)', value: 'default' },
|
|
68
|
+
{ name: 'Interactive (prompt for all variables)', value: 'interactive' },
|
|
69
|
+
],
|
|
70
|
+
default: 'default',
|
|
71
|
+
when: () => !options.env,
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
type: 'input',
|
|
75
|
+
name: 'targetDir',
|
|
76
|
+
message: 'Target directory for setup:',
|
|
77
|
+
default: process.cwd(),
|
|
78
|
+
when: () => !options.dir,
|
|
79
|
+
},
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
infra: options.infra !== undefined ? options.infra : answers.infra,
|
|
84
|
+
envMode: options.env || answers.envMode || 'default',
|
|
85
|
+
targetDir: options.dir || answers.targetDir || process.cwd(),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Main onboarding workflow
|
|
91
|
+
*/
|
|
92
|
+
async function main() {
|
|
93
|
+
try {
|
|
94
|
+
// Display banner
|
|
95
|
+
displayBanner();
|
|
96
|
+
|
|
97
|
+
// Initialize logger based on CLI flags
|
|
98
|
+
const opts = program.opts();
|
|
99
|
+
initLogger({
|
|
100
|
+
verbose: opts.verbose || false,
|
|
101
|
+
debug: opts.debug || false,
|
|
102
|
+
logFile: opts.debug ? path.join(process.cwd(), 'onboard-debug.log') : undefined,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
logDebug('Logger initialized');
|
|
106
|
+
logDebug(`CLI options: ${JSON.stringify(opts)}`);
|
|
107
|
+
|
|
108
|
+
// Prompt for setup options if not provided
|
|
109
|
+
const setupOptions = await promptSetupOptions();
|
|
110
|
+
|
|
111
|
+
// Get target directory
|
|
112
|
+
const targetDir = path.resolve(setupOptions.targetDir);
|
|
113
|
+
logInfo(`Target directory: ${targetDir}`);
|
|
114
|
+
logDebug(`Resolved target directory: ${targetDir}`);
|
|
115
|
+
|
|
116
|
+
// Step 1: Validate prerequisites
|
|
117
|
+
await validatePrerequisites();
|
|
118
|
+
|
|
119
|
+
// Step 2: Infrastructure setup (if --infra flag is provided)
|
|
120
|
+
let selectedInfraServices: string[] = [];
|
|
121
|
+
let infraHost = 'host.docker.internal';
|
|
122
|
+
|
|
123
|
+
if (setupOptions.infra) {
|
|
124
|
+
logInfo('\n🏗️ Infrastructure Setup\n');
|
|
125
|
+
selectedInfraServices = await promptInfraServices();
|
|
126
|
+
infraHost = await generateInfraCompose(targetDir, selectedInfraServices);
|
|
127
|
+
} else {
|
|
128
|
+
logInfo('\n⏭️ Skipping infrastructure setup (use --infra to enable)');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Always write app docker-compose
|
|
132
|
+
logInfo('\n📦 Application Services Setup\n');
|
|
133
|
+
await writeAppCompose(targetDir);
|
|
134
|
+
|
|
135
|
+
// Step 3: Environment configuration
|
|
136
|
+
logInfo('\n⚙️ Environment Configuration\n');
|
|
137
|
+
const envMode = setupOptions.envMode;
|
|
138
|
+
const envVars = await promptEnvVariables(envMode, selectedInfraServices, infraHost);
|
|
139
|
+
await generateEnvFile(targetDir, envVars);
|
|
140
|
+
|
|
141
|
+
// Step 4: Plugin installation
|
|
142
|
+
logInfo('\n🔌 Plugin Installation\n');
|
|
143
|
+
const availablePlugins = await fetchAvailablePlugins();
|
|
144
|
+
const selectedPlugins = await promptPluginSelection(availablePlugins);
|
|
145
|
+
|
|
146
|
+
if (selectedPlugins.length > 0) {
|
|
147
|
+
await generatePluginConfig(targetDir, selectedPlugins);
|
|
148
|
+
|
|
149
|
+
// Extract and prompt for plugin credentials
|
|
150
|
+
const configPath = path.join(targetDir, 'simplens.config.yaml');
|
|
151
|
+
const credentialKeys = await parseConfigCredentials(configPath);
|
|
152
|
+
|
|
153
|
+
if (credentialKeys.length > 0) {
|
|
154
|
+
const pluginCreds = await promptPluginCredentials(credentialKeys);
|
|
155
|
+
await appendPluginEnv(targetDir, pluginCreds);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Step 5: Service orchestration
|
|
160
|
+
logInfo('\n🚀 Service Orchestration\n');
|
|
161
|
+
const shouldStart = await promptStartServices();
|
|
162
|
+
|
|
163
|
+
if (shouldStart) {
|
|
164
|
+
// Start infra services first (if --infra was used)
|
|
165
|
+
if (setupOptions.infra && selectedInfraServices.length > 0) {
|
|
166
|
+
await startInfraServices(targetDir);
|
|
167
|
+
await waitForInfraHealth(targetDir);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Start app services
|
|
171
|
+
await startAppServices(targetDir);
|
|
172
|
+
|
|
173
|
+
// Display service status
|
|
174
|
+
await displayServiceStatus();
|
|
175
|
+
} else {
|
|
176
|
+
logInfo('Services not started. You can start them later with:');
|
|
177
|
+
if (setupOptions.infra) {
|
|
178
|
+
console.log(' docker-compose -f docker-compose.infra.yaml up -d');
|
|
179
|
+
}
|
|
180
|
+
console.log(' docker-compose up -d\n');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Final success message
|
|
184
|
+
logSuccess('\n🎉 SimpleNS onboarding completed successfully!\n');
|
|
185
|
+
|
|
186
|
+
} catch (error: any) {
|
|
187
|
+
// Import at top of file
|
|
188
|
+
const { formatErrorForUser } = await import('./types/errors.js');
|
|
189
|
+
|
|
190
|
+
console.log('\n' + formatErrorForUser(error));
|
|
191
|
+
|
|
192
|
+
// Log full error to stderr for debugging
|
|
193
|
+
if (process.env.DEBUG) {
|
|
194
|
+
console.error('\nFull error details:');
|
|
195
|
+
console.error(error);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
process.exit(1);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Run main function
|
|
203
|
+
main();
|
package/src/infra.ts
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import inquirer from 'inquirer';
|
|
2
|
+
import { detectOS } from './validators.js';
|
|
3
|
+
import { INFRA_COMPOSE_TEMPLATE, APP_COMPOSE_TEMPLATE } from './templates.js';
|
|
4
|
+
import { writeFile, logInfo, logSuccess, logWarning } from './utils.js';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import type { InfraService } from './types/domain.js';
|
|
7
|
+
|
|
8
|
+
const INFRA_SERVICES: InfraService[] = [
|
|
9
|
+
{ name: 'MongoDB (Database)', value: 'mongo', checked: true },
|
|
10
|
+
{ name: 'Kafka (Message Queue)', value: 'kafka', checked: true },
|
|
11
|
+
{ name: 'Kafka UI (Dashboard)', value: 'kafka-ui', checked: true },
|
|
12
|
+
{ name: 'Redis (Cache)', value: 'redis', checked: true },
|
|
13
|
+
{ name: 'Loki (Log Aggregation)', value: 'loki', checked: false },
|
|
14
|
+
{ name: 'Grafana (Observability Dashboard)', value: 'grafana', checked: false },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Prompts user to select which infrastructure services to deploy.
|
|
19
|
+
* Services include MongoDB, Kafka, Redis, Loki, and Grafana.
|
|
20
|
+
*
|
|
21
|
+
* @returns Array of selected service IDs (e.g., ['mongo', 'kafka', 'redis'])
|
|
22
|
+
* @throws Error if no services are selected
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```ts
|
|
26
|
+
* const services = await promptInfraServices();
|
|
27
|
+
* // Returns: ['mongo', 'kafka', 'kafka-ui', 'redis']
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export async function promptInfraServices(): Promise<string[]> {
|
|
31
|
+
const answer = await inquirer.prompt<{ services: string[] }>([
|
|
32
|
+
{
|
|
33
|
+
type: 'checkbox',
|
|
34
|
+
name: 'services',
|
|
35
|
+
message: 'Select infrastructure services to run:',
|
|
36
|
+
choices: INFRA_SERVICES,
|
|
37
|
+
validate: (input: string[]) => {
|
|
38
|
+
if (input.length === 0) {
|
|
39
|
+
return 'Please select at least one service';
|
|
40
|
+
}
|
|
41
|
+
return true;
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
return answer.services;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Get infrastructure host based on OS
|
|
51
|
+
*/
|
|
52
|
+
export function getInfraHost(): string {
|
|
53
|
+
// const os = detectOS();
|
|
54
|
+
|
|
55
|
+
// if (os === 'linux') {
|
|
56
|
+
// logWarning('Linux detected: host.docker.internal does not work by default.');
|
|
57
|
+
|
|
58
|
+
// const answer = await inquirer.prompt<{ hostChoice: string; customHost?: string }>([
|
|
59
|
+
// {
|
|
60
|
+
// type: 'list',
|
|
61
|
+
// name: 'hostChoice',
|
|
62
|
+
// message: 'Select host configuration:',
|
|
63
|
+
// choices: [
|
|
64
|
+
// { name: 'Use Docker bridge IP (172.17.0.1)', value: '172.17.0.1' },
|
|
65
|
+
// { name: 'Enter custom IP/hostname', value: 'custom' },
|
|
66
|
+
// ],
|
|
67
|
+
// },
|
|
68
|
+
// {
|
|
69
|
+
// type: 'input',
|
|
70
|
+
// name: 'customHost',
|
|
71
|
+
// message: 'Enter your machine IP or hostname:',
|
|
72
|
+
// when: (answers) => answers.hostChoice === 'custom',
|
|
73
|
+
// validate: (input: string) => {
|
|
74
|
+
// if (!input || input.trim().length === 0) {
|
|
75
|
+
// return 'Please enter a valid IP or hostname';
|
|
76
|
+
// }
|
|
77
|
+
// return true;
|
|
78
|
+
// },
|
|
79
|
+
// },
|
|
80
|
+
// ]);
|
|
81
|
+
|
|
82
|
+
// return answer.hostChoice === 'custom' ? answer.customHost! : answer.hostChoice;
|
|
83
|
+
// }
|
|
84
|
+
|
|
85
|
+
// For Windows, linux and macOS, use host.docker.internal
|
|
86
|
+
return 'host.docker.internal';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Service chunk definitions - each service as a complete block
|
|
91
|
+
*/
|
|
92
|
+
const SERVICE_CHUNKS: Record<string, string> = {
|
|
93
|
+
'mongo': ` mongo:
|
|
94
|
+
image: mongo:7.0
|
|
95
|
+
container_name: mongo
|
|
96
|
+
command: [ "--replSet", "rs0", "--bind_ip_all", "--port", "27017" ]
|
|
97
|
+
ports:
|
|
98
|
+
- 27017:27017
|
|
99
|
+
extra_hosts:
|
|
100
|
+
- "host.docker.internal:host-gateway"
|
|
101
|
+
healthcheck:
|
|
102
|
+
test: echo "try { rs.status() } catch (err) { rs.initiate({_id:'rs0',members:[{_id:0,host:'{{INFRA_HOST}}:27017'}]}) }" | mongosh --port 27017 --quiet
|
|
103
|
+
interval: 5s
|
|
104
|
+
timeout: 30s
|
|
105
|
+
start_period: 0s
|
|
106
|
+
start_interval: 1s
|
|
107
|
+
retries: 30
|
|
108
|
+
volumes:
|
|
109
|
+
- "mongo_data:/data/db"
|
|
110
|
+
- "mongo_config:/data/configdb"`,
|
|
111
|
+
|
|
112
|
+
'kafka': ` kafka:
|
|
113
|
+
image: apache/kafka-native
|
|
114
|
+
container_name: kafka
|
|
115
|
+
ports:
|
|
116
|
+
- "9092:9092"
|
|
117
|
+
environment:
|
|
118
|
+
# Configure listeners for both docker and host communication
|
|
119
|
+
KAFKA_LISTENERS: CONTROLLER://localhost:9091,HOST://0.0.0.0:9092,DOCKER://0.0.0.0:9093
|
|
120
|
+
KAFKA_ADVERTISED_LISTENERS: HOST://{{INFRA_HOST}}:9092,DOCKER://kafka:9093
|
|
121
|
+
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,DOCKER:PLAINTEXT,HOST:PLAINTEXT
|
|
122
|
+
|
|
123
|
+
# Settings required for KRaft mode
|
|
124
|
+
KAFKA_NODE_ID: 1
|
|
125
|
+
KAFKA_PROCESS_ROLES: broker,controller
|
|
126
|
+
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
|
|
127
|
+
KAFKA_CONTROLLER_QUORUM_VOTERS: 1@localhost:9091
|
|
128
|
+
|
|
129
|
+
# Listener to use for broker-to-broker communication
|
|
130
|
+
KAFKA_INTER_BROKER_LISTENER_NAME: DOCKER
|
|
131
|
+
|
|
132
|
+
# Required for a single node cluster
|
|
133
|
+
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
|
|
134
|
+
|
|
135
|
+
# Disable auto-topic creation - API server will create topics with correct partitions
|
|
136
|
+
KAFKA_AUTO_CREATE_TOPICS_ENABLE: "false"
|
|
137
|
+
volumes:
|
|
138
|
+
- "kafka_data:/var/lib/kafka/data"`,
|
|
139
|
+
|
|
140
|
+
'kafka-ui': ` kafka-ui:
|
|
141
|
+
image: kafbat/kafka-ui:main
|
|
142
|
+
container_name: kafka-ui
|
|
143
|
+
ports:
|
|
144
|
+
- 8080:8080
|
|
145
|
+
environment:
|
|
146
|
+
DYNAMIC_CONFIG_ENABLED: "true"
|
|
147
|
+
KAFKA_CLUSTERS_0_NAME: local
|
|
148
|
+
KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:9093
|
|
149
|
+
depends_on:
|
|
150
|
+
- kafka`,
|
|
151
|
+
|
|
152
|
+
'redis': ` redis:
|
|
153
|
+
image: redis:7-alpine
|
|
154
|
+
container_name: redis
|
|
155
|
+
ports:
|
|
156
|
+
- "6379:6379"
|
|
157
|
+
command: redis-server --appendonly yes
|
|
158
|
+
volumes:
|
|
159
|
+
- "redis_data:/data"
|
|
160
|
+
healthcheck:
|
|
161
|
+
test: [ "CMD", "redis-cli", "ping" ]
|
|
162
|
+
interval: 5s
|
|
163
|
+
timeout: 3s
|
|
164
|
+
retries: 5`,
|
|
165
|
+
|
|
166
|
+
'loki': ` loki:
|
|
167
|
+
image: grafana/loki:2.9.0
|
|
168
|
+
container_name: loki
|
|
169
|
+
ports:
|
|
170
|
+
- "3100:3100"
|
|
171
|
+
command: -config.file=/etc/loki/local-config.yaml
|
|
172
|
+
volumes:
|
|
173
|
+
- "loki_data:/loki"
|
|
174
|
+
healthcheck:
|
|
175
|
+
test: [ "CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:3100/ready || exit 1" ]
|
|
176
|
+
interval: 10s
|
|
177
|
+
timeout: 5s
|
|
178
|
+
retries: 5`,
|
|
179
|
+
|
|
180
|
+
'grafana': ` grafana:
|
|
181
|
+
image: grafana/grafana:10.2.0
|
|
182
|
+
container_name: grafana
|
|
183
|
+
ports:
|
|
184
|
+
- "3001:3000"
|
|
185
|
+
environment:
|
|
186
|
+
- GF_PATHS_PROVISIONING=/etc/grafana/provisioning
|
|
187
|
+
- GF_AUTH_ANONYMOUS_ENABLED=true
|
|
188
|
+
- GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
|
|
189
|
+
- GF_SECURITY_ADMIN_PASSWORD=admin
|
|
190
|
+
volumes:
|
|
191
|
+
- "grafana_data:/var/lib/grafana"
|
|
192
|
+
depends_on:
|
|
193
|
+
loki:
|
|
194
|
+
condition: service_healthy`,
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Service-to-volumes mapping
|
|
199
|
+
*/
|
|
200
|
+
const SERVICE_VOLUMES: Record<string, string[]> = {
|
|
201
|
+
'mongo': ['mongo_data', 'mongo_config'],
|
|
202
|
+
'kafka': ['kafka_data'],
|
|
203
|
+
'kafka-ui': [],
|
|
204
|
+
'redis': ['redis_data'],
|
|
205
|
+
'loki': ['loki_data'],
|
|
206
|
+
'grafana': ['grafana_data'],
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Build docker-compose content from selected services
|
|
211
|
+
*/
|
|
212
|
+
function buildInfraCompose(selectedServices: string[]): string {
|
|
213
|
+
// Header
|
|
214
|
+
const header = `# ============================================
|
|
215
|
+
# INFRA_HOST: Set this in .env to your machine's IP/hostname when running
|
|
216
|
+
# infrastructure on a separate system from application services.
|
|
217
|
+
# Default: host.docker.internal (for same-system deployment)
|
|
218
|
+
# ============================================
|
|
219
|
+
|
|
220
|
+
services:
|
|
221
|
+
# ============================================
|
|
222
|
+
# Infrastructure Services
|
|
223
|
+
# ============================================`;
|
|
224
|
+
|
|
225
|
+
// Assemble selected service chunks
|
|
226
|
+
const serviceBlocks: string[] = [];
|
|
227
|
+
for (const service of selectedServices) {
|
|
228
|
+
if (SERVICE_CHUNKS[service]) {
|
|
229
|
+
serviceBlocks.push(SERVICE_CHUNKS[service]);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Collect volumes for selected services
|
|
234
|
+
const volumeSet = new Set<string>();
|
|
235
|
+
for (const service of selectedServices) {
|
|
236
|
+
const volumes = SERVICE_VOLUMES[service] || [];
|
|
237
|
+
volumes.forEach(v => volumeSet.add(v));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Build volumes section
|
|
241
|
+
const volumeLines: string[] = ['', 'volumes:'];
|
|
242
|
+
for (const volume of Array.from(volumeSet).sort()) {
|
|
243
|
+
volumeLines.push(` ${volume}:`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Build networks section with custom default network name
|
|
247
|
+
const networkLines: string[] = ['', 'networks:', ' default:', ' name: simplens'];
|
|
248
|
+
|
|
249
|
+
// Combine all parts
|
|
250
|
+
return [
|
|
251
|
+
header,
|
|
252
|
+
serviceBlocks.join('\n\n'),
|
|
253
|
+
volumeLines.join('\n'),
|
|
254
|
+
networkLines.join('\n'),
|
|
255
|
+
].join('\n');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Replace INFRA_HOST placeholder in template
|
|
260
|
+
*/
|
|
261
|
+
export function replaceInfraHost(template: string, infraHost: string): string {
|
|
262
|
+
return template.replace(/\{\{INFRA_HOST\}\}/g, infraHost);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Generate and write docker-compose.infra.yaml
|
|
267
|
+
*/
|
|
268
|
+
export async function generateInfraCompose(
|
|
269
|
+
targetDir: string,
|
|
270
|
+
selectedServices: string[]
|
|
271
|
+
): Promise<string> {
|
|
272
|
+
logInfo('Generating docker-compose.infra.yaml...');
|
|
273
|
+
|
|
274
|
+
// Get infrastructure host
|
|
275
|
+
const infraHost = getInfraHost();
|
|
276
|
+
logSuccess(`Using infrastructure host: ${infraHost}`);
|
|
277
|
+
|
|
278
|
+
// Build compose content from service chunks
|
|
279
|
+
let infraContent = buildInfraCompose(selectedServices);
|
|
280
|
+
|
|
281
|
+
// Replace host placeholder
|
|
282
|
+
infraContent = replaceInfraHost(infraContent, infraHost);
|
|
283
|
+
|
|
284
|
+
// Write infrastructure compose file
|
|
285
|
+
const infraPath = path.join(targetDir, 'docker-compose.infra.yaml');
|
|
286
|
+
await writeFile(infraPath, infraContent);
|
|
287
|
+
logSuccess('Generated docker-compose.infra.yaml');
|
|
288
|
+
|
|
289
|
+
// Return infraHost for env configuration
|
|
290
|
+
return infraHost;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Write app docker-compose.yaml
|
|
295
|
+
*/
|
|
296
|
+
export async function writeAppCompose(targetDir: string): Promise<void> {
|
|
297
|
+
const appPath = path.join(targetDir, 'docker-compose.yaml');
|
|
298
|
+
await writeFile(appPath, APP_COMPOSE_TEMPLATE);
|
|
299
|
+
logSuccess('Generated docker-compose.yaml');
|
|
300
|
+
}
|
package/src/plugins.ts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import inquirer from 'inquirer';
|
|
2
|
+
import { execa } from 'execa';
|
|
3
|
+
import yaml from 'js-yaml';
|
|
4
|
+
import { readFile, logInfo, logSuccess, logError, logWarning } from './utils.js';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import type { PluginInfo, SimplensConfig } from './types/domain.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Fetches available SimpleNS plugins using the config-gen CLI tool.
|
|
10
|
+
* Falls back to default plugins if fetching fails.
|
|
11
|
+
*
|
|
12
|
+
* @returns Array of available plugin information
|
|
13
|
+
*
|
|
14
|
+
* @remarks
|
|
15
|
+
* Uses `npx @simplens/config-gen list --official` to fetch plugins.
|
|
16
|
+
* Default fallback plugins: mock, nodemailer-gmail, resend
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```ts
|
|
20
|
+
* const plugins = await fetchAvailablePlugins();
|
|
21
|
+
* // Returns: [{ package: '@simplens/mock', name: 'Mock Provider', ... }, ...]
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export async function fetchAvailablePlugins(): Promise<PluginInfo[]> {
|
|
25
|
+
logInfo('Fetching available plugins...');
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
// Execute config-gen list command
|
|
29
|
+
const { stdout } = await execa('npx', ['@simplens/config-gen', 'list', '--official'], {
|
|
30
|
+
stdio: 'pipe',
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Parse output to extract plugins
|
|
34
|
+
// Expected format: " @simplens/package-name Plugin Name"
|
|
35
|
+
const plugins: PluginInfo[] = [];
|
|
36
|
+
const lines = stdout.split('\n');
|
|
37
|
+
|
|
38
|
+
for (const line of lines) {
|
|
39
|
+
// Match plugin lines (starts with @simplens/)
|
|
40
|
+
const match = line.match(/^\s+(@simplens\/[\w-]+)\s+(.+)$/);
|
|
41
|
+
if (match) {
|
|
42
|
+
const [, packageName, rest] = match;
|
|
43
|
+
// Extract name and description
|
|
44
|
+
const parts = rest.split(/\s{2,}/); // Split by multiple spaces
|
|
45
|
+
plugins.push({
|
|
46
|
+
package: packageName.trim(),
|
|
47
|
+
name: parts[0]?.trim() || packageName,
|
|
48
|
+
description: parts[1]?.trim() || '',
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
logSuccess(`Found ${plugins.length} available plugins`);
|
|
54
|
+
return plugins;
|
|
55
|
+
} catch (error: any) {
|
|
56
|
+
logWarning('Could not fetch plugins list. Using defaults.');
|
|
57
|
+
// Return default plugins as fallback
|
|
58
|
+
return [
|
|
59
|
+
{ package: '@simplens/mock', name: 'Mock Provider', description: 'Mock notification provider for testing' },
|
|
60
|
+
{ package: '@simplens/nodemailer-gmail', name: 'Gmail', description: 'Send emails via Gmail' },
|
|
61
|
+
{ package: '@simplens/resend', name: 'Resend', description: 'Send emails via Resend' },
|
|
62
|
+
];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Prompt user to select plugins
|
|
68
|
+
*/
|
|
69
|
+
export async function promptPluginSelection(availablePlugins: PluginInfo[]): Promise<string[]> {
|
|
70
|
+
if (availablePlugins.length === 0) {
|
|
71
|
+
logWarning('No plugins available to select.');
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const answer = await inquirer.prompt<{ plugins: string[] }>([
|
|
76
|
+
{
|
|
77
|
+
type: 'checkbox',
|
|
78
|
+
name: 'plugins',
|
|
79
|
+
message: 'Select plugins to install:',
|
|
80
|
+
choices: availablePlugins.map(p => ({
|
|
81
|
+
name: `${p.name} (${p.package}) - ${p.description}`,
|
|
82
|
+
value: p.package,
|
|
83
|
+
checked: p.package === '@simplens/mock', // Mock checked by default
|
|
84
|
+
})),
|
|
85
|
+
},
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
return answer.plugins;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Generate plugin configuration using config-gen
|
|
93
|
+
*/
|
|
94
|
+
export async function generatePluginConfig(
|
|
95
|
+
targetDir: string,
|
|
96
|
+
selectedPlugins: string[]
|
|
97
|
+
): Promise<void> {
|
|
98
|
+
if (selectedPlugins.length === 0) {
|
|
99
|
+
logInfo('No plugins selected, skipping config generation.');
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
logInfo(`Generating configuration for ${selectedPlugins.length} plugin(s)...`);
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const configPath = path.join(targetDir, 'simplens.config.yaml');
|
|
107
|
+
|
|
108
|
+
// Execute config-gen for all selected plugins
|
|
109
|
+
// Use relative path to avoid WSL path issues when npx runs Windows binaries
|
|
110
|
+
await execa(
|
|
111
|
+
'npx',
|
|
112
|
+
['@simplens/config-gen', 'gen', ...selectedPlugins, '-o', 'simplens.config.yaml'],
|
|
113
|
+
{ cwd: targetDir, stdio: 'inherit' }
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
logSuccess('Generated simplens.config.yaml');
|
|
117
|
+
} catch (error: any) {
|
|
118
|
+
logError('Failed to generate plugin configuration');
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Parse simplens.config.yaml to extract credential keys
|
|
125
|
+
*/
|
|
126
|
+
export async function parseConfigCredentials(configPath: string): Promise<string[]> {
|
|
127
|
+
try {
|
|
128
|
+
const content = await readFile(configPath);
|
|
129
|
+
const config: any = yaml.load(content);
|
|
130
|
+
|
|
131
|
+
const credentialKeys = new Set<string>();
|
|
132
|
+
|
|
133
|
+
// Extract credential keys from providers (ONLY from credentials, not optionalConfig)
|
|
134
|
+
if (config.providers && Array.isArray(config.providers)) {
|
|
135
|
+
for (const provider of config.providers) {
|
|
136
|
+
if (provider.credentials && typeof provider.credentials === 'object') {
|
|
137
|
+
for (const [key, value] of Object.entries(provider.credentials)) {
|
|
138
|
+
// Extract env var name from ${ENV_VAR} format
|
|
139
|
+
if (typeof value === 'string' && value.startsWith('${') && value.endsWith('}')) {
|
|
140
|
+
const envVar = value.slice(2, -1);
|
|
141
|
+
credentialKeys.add(envVar);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// NOTE: We intentionally skip optionalConfig - those are optional!
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return Array.from(credentialKeys);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
logWarning('Could not parse config file for credentials');
|
|
152
|
+
return [];
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Prompt for plugin-specific credentials
|
|
158
|
+
*/
|
|
159
|
+
export async function promptPluginCredentials(credentialKeys: string[]): Promise<Map<string, string>> {
|
|
160
|
+
if (credentialKeys.length === 0) {
|
|
161
|
+
logInfo('No plugin credentials required.');
|
|
162
|
+
return new Map();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
logInfo('Configuring plugin credentials...');
|
|
166
|
+
|
|
167
|
+
const result = new Map<string, string>();
|
|
168
|
+
|
|
169
|
+
for (const key of credentialKeys) {
|
|
170
|
+
const answer = await inquirer.prompt<{ value: string }>([
|
|
171
|
+
{
|
|
172
|
+
type: key.toLowerCase().includes('password') || key.toLowerCase().includes('key')
|
|
173
|
+
? 'password'
|
|
174
|
+
: 'input',
|
|
175
|
+
name: 'value',
|
|
176
|
+
message: `${key}:`,
|
|
177
|
+
validate: (input: string) => {
|
|
178
|
+
if (!input || input.trim().length === 0) {
|
|
179
|
+
return `${key} is required`;
|
|
180
|
+
}
|
|
181
|
+
return true;
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
]);
|
|
185
|
+
result.set(key, answer.value);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
logSuccess('Plugin credentials configured');
|
|
189
|
+
return result;
|
|
190
|
+
}
|