genbox 1.0.99 → 1.0.101

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.
@@ -36,8 +36,9 @@ async function cleanupOrphanedSshConfigs(options = {}) {
36
36
  }
37
37
  throw error;
38
38
  }
39
- // Get names of existing genboxes (both running and terminated but recent)
40
- const existingNames = new Set(existingGenboxes.map(g => g.name));
39
+ // Keep SSH configs for any non-terminated genbox (running, error, provisioning, stopped)
40
+ const activeGenboxes = existingGenboxes.filter(g => g.status !== 'terminated');
41
+ const existingNames = new Set(activeGenboxes.map(g => g.name));
41
42
  // Find orphaned entries (SSH configs for genboxes that don't exist anymore)
42
43
  const orphaned = sshEntries.filter(name => !existingNames.has(name));
43
44
  if (orphaned.length === 0) {
@@ -92,13 +92,18 @@ exports.extendCommand = new commander_1.Command('extend')
92
92
  spinner.stop();
93
93
  if (result.success) {
94
94
  console.log(chalk_1.default.green(`✓ ${result.message}`));
95
- if (result.currentHourEnd) {
96
- const endTime = new Date(result.currentHourEnd);
97
- const minutesUntil = Math.ceil((endTime.getTime() - Date.now()) / (60 * 1000));
98
- console.log(chalk_1.default.dim(` Current hour ends in: ${minutesUntil} minutes`));
95
+ if (result.protectedUntil && result.autoDestroyEnabled) {
96
+ const protectedTime = new Date(result.protectedUntil);
97
+ const minutesUntil = Math.ceil((protectedTime.getTime() - Date.now()) / (60 * 1000));
98
+ const hoursUntil = Math.floor(minutesUntil / 60);
99
+ const remainingMins = minutesUntil % 60;
100
+ const timeStr = hoursUntil > 0
101
+ ? `${hoursUntil}h ${remainingMins}m`
102
+ : `${minutesUntil} min`;
103
+ console.log(chalk_1.default.dim(` Protected from auto-destroy for: ${timeStr}`));
99
104
  }
100
105
  console.log(chalk_1.default.dim(` Auto-destroy: ${result.autoDestroyEnabled ? 'enabled' : 'disabled'}`));
101
- console.log(chalk_1.default.dim(` Note: Credits charged at the end of each hour`));
106
+ console.log(chalk_1.default.dim(` Note: Credits charged at the end of each billing hour`));
102
107
  }
103
108
  else {
104
109
  console.error(chalk_1.default.red(`Error: ${result.message}`));
@@ -337,3 +337,136 @@ exports.profilesCommand
337
337
  console.error(chalk_1.default.red(`Error: ${error.message}`));
338
338
  }
339
339
  });
340
+ // Subcommand: profiles edit [name]
341
+ exports.profilesCommand
342
+ .command('edit [name]')
343
+ .description('Interactively edit a profile')
344
+ .action(async (name) => {
345
+ try {
346
+ const configLoader = new config_loader_1.ConfigLoader();
347
+ const loadResult = await configLoader.load();
348
+ if (!loadResult.config) {
349
+ console.log(chalk_1.default.red('Not a genbox project'));
350
+ console.log(chalk_1.default.dim('No genbox.yaml found in current directory.'));
351
+ return;
352
+ }
353
+ const configVersion = (0, schema_v4_1.getConfigVersion)(loadResult.config);
354
+ if (configVersion === 'invalid') {
355
+ console.log(chalk_1.default.red('Unknown config version'));
356
+ return;
357
+ }
358
+ const config = loadResult.config;
359
+ const profiles = configLoader.listProfiles(config);
360
+ if (profiles.length === 0) {
361
+ console.log(chalk_1.default.yellow('No profiles defined'));
362
+ console.log(chalk_1.default.dim('Run "genbox profiles create" to create a profile first'));
363
+ return;
364
+ }
365
+ // If no name provided, let user select from list
366
+ let profileName = name;
367
+ if (!profileName) {
368
+ const profileChoices = profiles.map(p => ({
369
+ name: `${p.name}${p.description ? ` - ${p.description}` : ''}`,
370
+ value: p.name,
371
+ }));
372
+ profileName = await prompts.select({
373
+ message: 'Select profile to edit:',
374
+ choices: profileChoices,
375
+ });
376
+ }
377
+ // Check if profile exists
378
+ if (!config.profiles?.[profileName]) {
379
+ console.log(chalk_1.default.red(`Profile '${profileName}' not found in genbox.yaml`));
380
+ return;
381
+ }
382
+ const existingProfile = config.profiles[profileName];
383
+ console.log(chalk_1.default.blue(`\nEditing Profile: ${profileName}\n`));
384
+ // Description
385
+ const description = await prompts.input({
386
+ message: 'Description:',
387
+ default: existingProfile.description || '',
388
+ });
389
+ // Apps selection
390
+ const appChoices = Object.entries(config.apps)
391
+ .filter(([_, app]) => app.type !== 'library')
392
+ .map(([appName, app]) => ({
393
+ name: `${appName} (${app.type})`,
394
+ value: appName,
395
+ checked: existingProfile.apps?.includes(appName) ?? false,
396
+ }));
397
+ const selectedApps = await prompts.checkbox({
398
+ message: 'Select apps to include:',
399
+ choices: appChoices,
400
+ });
401
+ // Size
402
+ const currentSize = existingProfile.size || 'medium';
403
+ const size = await prompts.select({
404
+ message: 'Server size:',
405
+ choices: [
406
+ { name: 'Small', value: 'small' },
407
+ { name: 'Medium', value: 'medium' },
408
+ { name: 'Large', value: 'large' },
409
+ { name: 'XL', value: 'xl' },
410
+ ],
411
+ default: currentSize,
412
+ });
413
+ // Connect to environment
414
+ const currentConnection = existingProfile.default_connection || 'local';
415
+ const connectTo = await prompts.select({
416
+ message: 'How should dependencies be resolved?',
417
+ choices: [
418
+ { name: 'Include locally', value: 'local' },
419
+ { name: 'Connect to staging', value: 'staging' },
420
+ { name: 'Connect to production', value: 'production' },
421
+ ],
422
+ default: currentConnection,
423
+ });
424
+ // Database mode - determine current setting
425
+ let currentDbMode = 'none';
426
+ if (existingProfile.database) {
427
+ if (existingProfile.database.mode === 'local') {
428
+ currentDbMode = 'local';
429
+ }
430
+ else if (existingProfile.database.mode === 'copy') {
431
+ currentDbMode = `copy-${existingProfile.database.source || 'staging'}`;
432
+ }
433
+ else if (existingProfile.database.mode === 'remote') {
434
+ currentDbMode = `remote-${existingProfile.database.source || 'staging'}`;
435
+ }
436
+ }
437
+ const dbMode = await prompts.select({
438
+ message: 'Database mode:',
439
+ choices: [
440
+ { name: 'None', value: 'none' },
441
+ { name: 'Fresh (empty)', value: 'local' },
442
+ { name: 'Copy from staging', value: 'copy-staging' },
443
+ { name: 'Connect to staging', value: 'remote-staging' },
444
+ ],
445
+ default: currentDbMode,
446
+ });
447
+ // Build updated profile
448
+ const updatedProfile = {
449
+ description: description || undefined,
450
+ size: size,
451
+ apps: selectedApps,
452
+ default_connection: connectTo !== 'local' ? connectTo : undefined,
453
+ database: dbMode !== 'none' ? {
454
+ mode: dbMode.startsWith('copy') ? 'copy' : dbMode.startsWith('remote') ? 'remote' : 'local',
455
+ source: dbMode.includes('staging') ? 'staging' : dbMode.includes('production') ? 'production' : undefined,
456
+ } : undefined,
457
+ };
458
+ // Update and save
459
+ config.profiles[profileName] = updatedProfile;
460
+ const configPath = configLoader.getConfigPath();
461
+ const yamlContent = yaml.dump(config, { lineWidth: 120, noRefs: true });
462
+ fs.writeFileSync(configPath, yamlContent);
463
+ console.log(chalk_1.default.green(`\n✔ Profile '${profileName}' updated in genbox.yaml`));
464
+ }
465
+ catch (error) {
466
+ if (error.name === 'ExitPromptError') {
467
+ console.log(chalk_1.default.dim('\nCancelled.'));
468
+ return;
469
+ }
470
+ console.error(chalk_1.default.red(`Error: ${error.message}`));
471
+ }
472
+ });
@@ -0,0 +1,424 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.restartCommand = void 0;
40
+ const commander_1 = require("commander");
41
+ const chalk_1 = __importDefault(require("chalk"));
42
+ const ora_1 = __importDefault(require("ora"));
43
+ const api_1 = require("../api");
44
+ const genbox_selector_1 = require("../genbox-selector");
45
+ const child_process_1 = require("child_process");
46
+ const os = __importStar(require("os"));
47
+ const path = __importStar(require("path"));
48
+ const fs = __importStar(require("fs"));
49
+ function getPrivateSshKey() {
50
+ const home = os.homedir();
51
+ const potentialKeys = [
52
+ path.join(home, '.ssh', 'id_rsa'),
53
+ path.join(home, '.ssh', 'id_ed25519'),
54
+ ];
55
+ for (const keyPath of potentialKeys) {
56
+ if (fs.existsSync(keyPath)) {
57
+ return keyPath;
58
+ }
59
+ }
60
+ throw new Error('No SSH private key found in ~/.ssh/');
61
+ }
62
+ function sshExec(ip, keyPath, command, timeoutSecs = 30) {
63
+ const sshOpts = `-i ${keyPath} -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=10`;
64
+ try {
65
+ const result = (0, child_process_1.execSync)(`ssh ${sshOpts} dev@${ip} "${command}"`, {
66
+ encoding: 'utf8',
67
+ timeout: timeoutSecs * 1000,
68
+ stdio: ['pipe', 'pipe', 'pipe'],
69
+ });
70
+ return { stdout: result.trim(), success: true };
71
+ }
72
+ catch (error) {
73
+ const output = error.stdout?.toString().trim() || error.stderr?.toString().trim() || '';
74
+ return { stdout: output, success: false };
75
+ }
76
+ }
77
+ exports.restartCommand = new commander_1.Command('restart')
78
+ .description('Restart apps and services in a genbox')
79
+ .argument('[name]', 'Name of the Genbox (optional - will auto-select or prompt)')
80
+ .option('-a, --all', 'Select from all genboxes (not just current project)')
81
+ .option('--pm2', 'Restart only PM2 processes')
82
+ .option('--docker', 'Restart only Docker containers')
83
+ .action(async (name, options) => {
84
+ try {
85
+ // Select genbox
86
+ const { genbox: target, cancelled } = await (0, genbox_selector_1.selectGenbox)(name, {
87
+ all: options.all,
88
+ selectMessage: 'Select a genbox to restart services:',
89
+ });
90
+ if (cancelled) {
91
+ console.log(chalk_1.default.dim('Cancelled.'));
92
+ return;
93
+ }
94
+ if (!target) {
95
+ return;
96
+ }
97
+ if (target.status !== 'running') {
98
+ console.error(chalk_1.default.red(`Error: Genbox '${target.name}' is not running (status: ${target.status})`));
99
+ return;
100
+ }
101
+ if (!target.ipAddress) {
102
+ console.error(chalk_1.default.red(`Error: Genbox '${target.name}' has no IP address`));
103
+ return;
104
+ }
105
+ // Get SSH key
106
+ let keyPath;
107
+ try {
108
+ keyPath = getPrivateSshKey();
109
+ }
110
+ catch (error) {
111
+ console.error(chalk_1.default.red(error.message));
112
+ return;
113
+ }
114
+ const restartPm2 = !options.docker || options.pm2;
115
+ const restartDocker = !options.pm2 || options.docker;
116
+ // Get app configurations from genbox
117
+ const appConfigs = target.appConfigs || [];
118
+ const workspace = target.workspace || 'app';
119
+ // Determine PM2 apps: explicitly set as pm2, OR frontend apps without docker config
120
+ const pm2Apps = appConfigs.filter(app => app.runner === 'pm2' ||
121
+ (!app.runner && !app.docker && (app.type === 'frontend' || ['admin', 'web', 'web-new'].includes(app.name))));
122
+ const dockerApps = appConfigs.filter(app => app.runner === 'docker' ||
123
+ (!app.runner && app.docker) ||
124
+ (!app.runner && app.type === 'backend'));
125
+ console.log(chalk_1.default.blue(`Restarting services on ${target.name}...`));
126
+ if (appConfigs.length > 0) {
127
+ console.log(chalk_1.default.dim(` Apps: ${appConfigs.map(a => a.name).join(', ')}`));
128
+ console.log(chalk_1.default.dim(` PM2 apps: ${pm2Apps.length > 0 ? pm2Apps.map(a => a.name).join(', ') : 'none detected'}`));
129
+ console.log(chalk_1.default.dim(` Docker apps: ${dockerApps.length > 0 ? dockerApps.map(a => a.name).join(', ') : 'none detected'}`));
130
+ }
131
+ console.log('');
132
+ // Start/Restart PM2 processes
133
+ if (restartPm2) {
134
+ const pm2Spinner = (0, ora_1.default)('Checking PM2 processes...').start();
135
+ // First check if PM2 has any processes
136
+ const pm2Check = sshExec(target.ipAddress, keyPath, 'source ~/.nvm/nvm.sh 2>/dev/null; pm2 jlist 2>/dev/null || echo "[]"', 15);
137
+ try {
138
+ const processes = JSON.parse(pm2Check.stdout || '[]');
139
+ if (processes.length === 0 && pm2Apps.length > 0) {
140
+ // No processes running but we have PM2 apps configured - start them
141
+ pm2Spinner.text = `Starting ${pm2Apps.length} PM2 app${pm2Apps.length > 1 ? 's' : ''} from config...`;
142
+ let startedCount = 0;
143
+ const startedNames = [];
144
+ for (const app of pm2Apps) {
145
+ // Try to resolve the correct path - check multiple possible locations
146
+ let appPath = app.path.startsWith('/') ? app.path : `~/${app.path}`;
147
+ // Check if path exists, if not try common variations
148
+ const pathCheck = sshExec(target.ipAddress, keyPath, `test -d ${appPath} && echo "found" || (test -d ~/${workspace}/${app.name} && echo "workspace" || (test -d ~/app/${app.name} && echo "app" || echo "notfound"))`, 5);
149
+ if (pathCheck.stdout === 'workspace') {
150
+ appPath = `~/${workspace}/${app.name}`;
151
+ }
152
+ else if (pathCheck.stdout === 'app') {
153
+ appPath = `~/app/${app.name}`;
154
+ }
155
+ else if (pathCheck.stdout === 'notfound') {
156
+ // Try to find the app directory
157
+ const findResult = sshExec(target.ipAddress, keyPath, `find ~ -maxdepth 3 -type d -name "${app.name}" 2>/dev/null | head -1`, 10);
158
+ if (findResult.stdout && findResult.stdout.trim()) {
159
+ appPath = findResult.stdout.trim();
160
+ }
161
+ }
162
+ const startCmd = app.commands?.dev || app.commands?.start || 'npm run dev';
163
+ // For frontend apps with a configured port, add port argument
164
+ // This is needed for frameworks like Next.js that accept -p/--port
165
+ const portArg = app.port ? ` -- -p ${app.port}` : '';
166
+ // Start the app with PM2
167
+ // Parse the start command - if it's "npm run X", use pm2 start npm -- run X
168
+ // If it's just a script name like "dev", treat it as npm run <script>
169
+ let pm2Cmd;
170
+ if (startCmd.startsWith('npm run ')) {
171
+ const script = startCmd.replace('npm run ', '');
172
+ pm2Cmd = `pm2 start npm --name "${app.name}" -- run ${script}${portArg}`;
173
+ }
174
+ else if (startCmd.startsWith('pnpm ')) {
175
+ const script = startCmd.replace('pnpm ', '');
176
+ pm2Cmd = `pm2 start pnpm --name "${app.name}" -- ${script}${portArg}`;
177
+ }
178
+ else if (startCmd.startsWith('yarn ')) {
179
+ const script = startCmd.replace('yarn ', '');
180
+ pm2Cmd = `pm2 start yarn --name "${app.name}" -- ${script}${portArg}`;
181
+ }
182
+ else if (startCmd.startsWith('bun ')) {
183
+ const script = startCmd.replace('bun ', '');
184
+ pm2Cmd = `pm2 start bun --name "${app.name}" -- ${script}${portArg}`;
185
+ }
186
+ else if (/^[a-z0-9:-]+$/.test(startCmd) && !startCmd.includes('/')) {
187
+ // Single word like "dev", "start", "serve" - treat as npm script name
188
+ pm2Cmd = `pm2 start npm --name "${app.name}" -- run ${startCmd}${portArg}`;
189
+ }
190
+ else {
191
+ // Assume it's a direct script/file path
192
+ pm2Cmd = `pm2 start "${startCmd}" --name "${app.name}"`;
193
+ }
194
+ pm2Spinner.text = `Starting ${app.name}...`;
195
+ const fullCmd = `source ~/.nvm/nvm.sh 2>/dev/null; cd ${appPath} && ${pm2Cmd} 2>&1`;
196
+ const startResult = sshExec(target.ipAddress, keyPath, fullCmd, 60);
197
+ // Check for success indicators in PM2 output
198
+ const isSuccess = startResult.success ||
199
+ startResult.stdout.includes('Process successfully started') ||
200
+ startResult.stdout.includes('[PM2] Done') ||
201
+ startResult.stdout.includes('online');
202
+ if (isSuccess) {
203
+ startedCount++;
204
+ startedNames.push(app.name);
205
+ }
206
+ }
207
+ if (startedCount > 0) {
208
+ pm2Spinner.succeed(chalk_1.default.green(`Started ${startedCount} PM2 process${startedCount > 1 ? 'es' : ''}`));
209
+ console.log(chalk_1.default.dim(` Processes: ${startedNames.join(', ')}`));
210
+ }
211
+ else {
212
+ pm2Spinner.warn(chalk_1.default.yellow('Failed to start PM2 processes'));
213
+ }
214
+ }
215
+ else if (processes.length === 0) {
216
+ // No processes and no PM2 apps configured - try ecosystem file fallback
217
+ pm2Spinner.text = 'No PM2 processes running, checking for ecosystem file...';
218
+ // Search in common locations
219
+ const ecosystemCheck = sshExec(target.ipAddress, keyPath, 'find ~ -maxdepth 3 -name "ecosystem.config.*" -o -name "pm2.config.js" 2>/dev/null | head -1', 10);
220
+ if (ecosystemCheck.stdout && ecosystemCheck.stdout.trim()) {
221
+ const ecosystemFile = ecosystemCheck.stdout.trim();
222
+ const ecosystemDir = ecosystemFile.substring(0, ecosystemFile.lastIndexOf('/'));
223
+ pm2Spinner.text = `Starting PM2 from ${ecosystemFile.split('/').pop()}...`;
224
+ const startResult = sshExec(target.ipAddress, keyPath, `source ~/.nvm/nvm.sh 2>/dev/null; cd ${ecosystemDir} && pm2 start ${ecosystemFile} 2>&1`, 60);
225
+ if (startResult.success) {
226
+ const newCheck = sshExec(target.ipAddress, keyPath, 'source ~/.nvm/nvm.sh 2>/dev/null; pm2 jlist 2>/dev/null || echo "[]"', 10);
227
+ const newProcesses = JSON.parse(newCheck.stdout || '[]');
228
+ pm2Spinner.succeed(chalk_1.default.green(`Started ${newProcesses.length} PM2 process${newProcesses.length > 1 ? 'es' : ''}`));
229
+ if (newProcesses.length > 0) {
230
+ const names = newProcesses.map((p) => p.name).join(', ');
231
+ console.log(chalk_1.default.dim(` Processes: ${names}`));
232
+ }
233
+ }
234
+ else {
235
+ pm2Spinner.warn(chalk_1.default.yellow('PM2 start completed with warnings'));
236
+ }
237
+ }
238
+ else {
239
+ pm2Spinner.info(chalk_1.default.dim('No PM2 processes or ecosystem file found'));
240
+ }
241
+ }
242
+ else {
243
+ // Processes running - check if we need to reconfigure (e.g., port changes)
244
+ // If we have PM2 apps with port configs, delete and recreate to ensure correct config
245
+ const appsWithPorts = pm2Apps.filter(app => app.port);
246
+ if (appsWithPorts.length > 0) {
247
+ pm2Spinner.text = 'Reconfiguring PM2 processes with correct ports...';
248
+ // Delete all PM2 processes first
249
+ sshExec(target.ipAddress, keyPath, 'source ~/.nvm/nvm.sh 2>/dev/null; pm2 delete all 2>/dev/null', 15);
250
+ // Start them fresh with correct config
251
+ let startedCount = 0;
252
+ const startedNames = [];
253
+ for (const app of pm2Apps) {
254
+ let appPath = app.path.startsWith('/') ? app.path : `~/${app.path}`;
255
+ // Check if path exists, if not try common variations
256
+ const pathCheck = sshExec(target.ipAddress, keyPath, `test -d ${appPath} && echo "found" || (test -d ~/${workspace}/${app.name} && echo "workspace" || (test -d ~/app/${app.name} && echo "app" || echo "notfound"))`, 5);
257
+ if (pathCheck.stdout === 'workspace') {
258
+ appPath = `~/${workspace}/${app.name}`;
259
+ }
260
+ else if (pathCheck.stdout === 'app') {
261
+ appPath = `~/app/${app.name}`;
262
+ }
263
+ else if (pathCheck.stdout === 'notfound') {
264
+ const findResult = sshExec(target.ipAddress, keyPath, `find ~ -maxdepth 3 -type d -name "${app.name}" 2>/dev/null | head -1`, 10);
265
+ if (findResult.stdout && findResult.stdout.trim()) {
266
+ appPath = findResult.stdout.trim();
267
+ }
268
+ }
269
+ const startCmd = app.commands?.dev || app.commands?.start || 'npm run dev';
270
+ const portArg = app.port ? ` -- -p ${app.port}` : '';
271
+ let pm2Cmd;
272
+ if (startCmd.startsWith('npm run ')) {
273
+ const script = startCmd.replace('npm run ', '');
274
+ pm2Cmd = `pm2 start npm --name "${app.name}" -- run ${script}${portArg}`;
275
+ }
276
+ else if (startCmd.startsWith('pnpm ')) {
277
+ const script = startCmd.replace('pnpm ', '');
278
+ pm2Cmd = `pm2 start pnpm --name "${app.name}" -- ${script}${portArg}`;
279
+ }
280
+ else if (/^[a-z0-9:-]+$/.test(startCmd) && !startCmd.includes('/')) {
281
+ pm2Cmd = `pm2 start npm --name "${app.name}" -- run ${startCmd}${portArg}`;
282
+ }
283
+ else {
284
+ pm2Cmd = `pm2 start "${startCmd}" --name "${app.name}"`;
285
+ }
286
+ pm2Spinner.text = `Starting ${app.name}${app.port ? ` on port ${app.port}` : ''}...`;
287
+ const startResult = sshExec(target.ipAddress, keyPath, `source ~/.nvm/nvm.sh 2>/dev/null; cd ${appPath} && ${pm2Cmd} 2>&1`, 60);
288
+ const isSuccess = startResult.success ||
289
+ startResult.stdout.includes('Process successfully started') ||
290
+ startResult.stdout.includes('[PM2] Done') ||
291
+ startResult.stdout.includes('online');
292
+ if (isSuccess) {
293
+ startedCount++;
294
+ startedNames.push(app.name);
295
+ }
296
+ }
297
+ if (startedCount > 0) {
298
+ pm2Spinner.succeed(chalk_1.default.green(`Restarted ${startedCount} PM2 process${startedCount > 1 ? 'es' : ''} with correct ports`));
299
+ console.log(chalk_1.default.dim(` Processes: ${startedNames.join(', ')}`));
300
+ }
301
+ else {
302
+ pm2Spinner.warn(chalk_1.default.yellow('Failed to restart PM2 processes'));
303
+ }
304
+ }
305
+ else {
306
+ // No port configs, simple restart is fine
307
+ pm2Spinner.text = 'Restarting PM2 processes...';
308
+ const pm2Result = sshExec(target.ipAddress, keyPath, 'source ~/.nvm/nvm.sh 2>/dev/null; pm2 restart all 2>&1', 30);
309
+ if (pm2Result.success) {
310
+ pm2Spinner.succeed(chalk_1.default.green(`Restarted ${processes.length} PM2 process${processes.length > 1 ? 'es' : ''}`));
311
+ const names = processes.map((p) => p.name).join(', ');
312
+ console.log(chalk_1.default.dim(` Processes: ${names}`));
313
+ }
314
+ else {
315
+ pm2Spinner.warn(chalk_1.default.yellow('PM2 restart completed with warnings'));
316
+ }
317
+ }
318
+ }
319
+ }
320
+ catch {
321
+ pm2Spinner.info(chalk_1.default.dim('No PM2 processes found'));
322
+ }
323
+ }
324
+ // Start/Restart Docker containers
325
+ if (restartDocker) {
326
+ const dockerSpinner = (0, ora_1.default)('Checking Docker containers...').start();
327
+ // Get list of running containers
328
+ const dockerList = sshExec(target.ipAddress, keyPath, 'docker ps --format "{{.Names}}" 2>/dev/null', 15);
329
+ let containers = dockerList.stdout.split('\n').filter(c => c.trim());
330
+ if (containers.length === 0) {
331
+ // No containers running - find docker-compose file
332
+ dockerSpinner.text = 'No containers running, looking for docker-compose...';
333
+ // First check if we have docker apps configured with known paths
334
+ let composeDir = '';
335
+ if (dockerApps.length > 0) {
336
+ // Use the first docker app's path to find docker-compose
337
+ const firstDockerApp = dockerApps[0];
338
+ const appPath = firstDockerApp.path.startsWith('/') ? firstDockerApp.path : `~/${firstDockerApp.path}`;
339
+ // Check for docker-compose in the app's directory
340
+ const composeCheck = sshExec(target.ipAddress, keyPath, `ls ${appPath}/docker-compose.yml ${appPath}/docker-compose.yaml 2>/dev/null | head -1`, 5);
341
+ if (composeCheck.stdout && composeCheck.stdout.trim()) {
342
+ composeDir = appPath;
343
+ }
344
+ }
345
+ // If not found via app config, search common locations
346
+ if (!composeDir) {
347
+ const findCompose = sshExec(target.ipAddress, keyPath, 'find ~ -maxdepth 4 \\( -name "docker-compose.yml" -o -name "docker-compose.yaml" \\) ! -path "*/security/*" 2>/dev/null | head -1', 10);
348
+ if (findCompose.stdout && findCompose.stdout.trim()) {
349
+ const composePath = findCompose.stdout.trim();
350
+ composeDir = composePath.substring(0, composePath.lastIndexOf('/'));
351
+ }
352
+ }
353
+ if (composeDir) {
354
+ dockerSpinner.text = `Starting Docker services from ${composeDir.split('/').pop()}...`;
355
+ const startResult = sshExec(target.ipAddress, keyPath, `cd ${composeDir} && (docker compose up -d 2>&1 || docker-compose up -d 2>&1)`, 180);
356
+ // Check what containers are now running
357
+ const newList = sshExec(target.ipAddress, keyPath, 'docker ps --format "{{.Names}}" 2>/dev/null', 15);
358
+ containers = newList.stdout.split('\n').filter(c => c.trim());
359
+ if (containers.length > 0) {
360
+ dockerSpinner.succeed(chalk_1.default.green(`Started ${containers.length} Docker container${containers.length > 1 ? 's' : ''}`));
361
+ console.log(chalk_1.default.dim(` Containers: ${containers.slice(0, 5).join(', ')}${containers.length > 5 ? ` (+${containers.length - 5} more)` : ''}`));
362
+ }
363
+ else {
364
+ dockerSpinner.warn(chalk_1.default.yellow('docker-compose up completed but no containers running'));
365
+ if (startResult.stdout) {
366
+ const lines = startResult.stdout.split('\n').slice(-3);
367
+ lines.forEach(line => {
368
+ if (line.trim())
369
+ console.log(chalk_1.default.dim(` ${line}`));
370
+ });
371
+ }
372
+ }
373
+ }
374
+ else {
375
+ dockerSpinner.info(chalk_1.default.dim('No Docker containers or docker-compose file found'));
376
+ }
377
+ }
378
+ else {
379
+ // Containers are running - restart them
380
+ dockerSpinner.text = 'Restarting Docker containers...';
381
+ // Find the docker-compose directory for running containers
382
+ const findCompose = sshExec(target.ipAddress, keyPath, 'find ~ -maxdepth 4 \\( -name "docker-compose.yml" -o -name "docker-compose.yaml" \\) ! -path "*/security/*" 2>/dev/null | head -1', 10);
383
+ if (findCompose.stdout && findCompose.stdout.trim()) {
384
+ const composePath = findCompose.stdout.trim();
385
+ const composeDir = composePath.substring(0, composePath.lastIndexOf('/'));
386
+ const composeResult = sshExec(target.ipAddress, keyPath, `cd ${composeDir} && (docker compose restart 2>&1 || docker-compose restart 2>&1)`, 120);
387
+ if (composeResult.success) {
388
+ dockerSpinner.succeed(chalk_1.default.green(`Restarted ${containers.length} Docker container${containers.length > 1 ? 's' : ''} (docker-compose)`));
389
+ }
390
+ else {
391
+ dockerSpinner.warn(chalk_1.default.yellow('Docker restart completed with warnings'));
392
+ }
393
+ }
394
+ else {
395
+ // Restart containers individually
396
+ const dockerResult = sshExec(target.ipAddress, keyPath, `docker restart ${containers.join(' ')} 2>&1`, 120);
397
+ if (dockerResult.success) {
398
+ dockerSpinner.succeed(chalk_1.default.green(`Restarted ${containers.length} Docker container${containers.length > 1 ? 's' : ''}`));
399
+ }
400
+ else {
401
+ dockerSpinner.warn(chalk_1.default.yellow('Docker restart completed with warnings'));
402
+ }
403
+ }
404
+ console.log(chalk_1.default.dim(` Containers: ${containers.slice(0, 5).join(', ')}${containers.length > 5 ? ` (+${containers.length - 5} more)` : ''}`));
405
+ }
406
+ }
407
+ console.log('');
408
+ console.log(chalk_1.default.green('✓ Restart complete'));
409
+ // Show health check tip
410
+ console.log(chalk_1.default.dim(' Run `gb status` to check service health'));
411
+ }
412
+ catch (error) {
413
+ if (error.name === 'ExitPromptError' || error.message?.includes('force closed')) {
414
+ console.log('');
415
+ console.log(chalk_1.default.dim('Cancelled.'));
416
+ return;
417
+ }
418
+ if (error instanceof api_1.AuthenticationError) {
419
+ (0, api_1.handleApiError)(error);
420
+ return;
421
+ }
422
+ console.error(chalk_1.default.red(`Error: ${error.message}`));
423
+ }
424
+ });
@@ -84,6 +84,17 @@ function formatDuration(secs) {
84
84
  const remainingSecs = secs % 60;
85
85
  return `${mins}m ${remainingSecs}s`;
86
86
  }
87
+ function renderBar(percent, width = 10) {
88
+ const filled = Math.round((percent / 100) * width);
89
+ const empty = width - filled;
90
+ const bar = '█'.repeat(filled) + '░'.repeat(empty);
91
+ // Color based on usage level
92
+ if (percent >= 90)
93
+ return chalk_1.default.red(bar);
94
+ if (percent >= 70)
95
+ return chalk_1.default.yellow(bar);
96
+ return chalk_1.default.green(bar);
97
+ }
87
98
  function getTimingBreakdown(ip, keyPath) {
88
99
  const timing = {
89
100
  sshReady: null,
@@ -294,6 +305,7 @@ exports.statusCommand = new commander_1.Command('status')
294
305
  currentHourEnd: target.currentHourEnd,
295
306
  lastActivityAt: target.lastActivityAt,
296
307
  autoDestroyOnInactivity: target.autoDestroyOnInactivity ?? true,
308
+ protectedUntil: target.protectedUntil,
297
309
  };
298
310
  const now = new Date();
299
311
  const minutesUntilBilling = billing.currentHourEnd
@@ -302,22 +314,90 @@ exports.statusCommand = new commander_1.Command('status')
302
314
  const minutesInactive = billing.lastActivityAt
303
315
  ? Math.floor((now.getTime() - new Date(billing.lastActivityAt).getTime()) / (60 * 1000))
304
316
  : 0;
317
+ // Format time remaining - show hours if > 60 min
318
+ const formatTimeRemaining = (mins) => {
319
+ if (mins >= 60) {
320
+ const hours = Math.floor(mins / 60);
321
+ const remainingMins = mins % 60;
322
+ return remainingMins > 0 ? `${hours}h ${remainingMins}m` : `${hours}h`;
323
+ }
324
+ return `${mins} min`;
325
+ };
305
326
  console.log(chalk_1.default.blue('[INFO] === Billing ==='));
306
327
  console.log(` Size: ${target.size} (${billing.creditsPerHour} credit${billing.creditsPerHour > 1 ? 's' : ''}/hr)`);
307
- console.log(` Current hour ends in: ${chalk_1.default.cyan(minutesUntilBilling + ' min')}`);
328
+ console.log(` Current hour ends in: ${chalk_1.default.cyan(formatTimeRemaining(minutesUntilBilling))}`);
308
329
  console.log(` Last activity: ${minutesInactive < 1 ? 'just now' : minutesInactive + ' min ago'}`);
309
330
  console.log(` Total: ${billing.totalHoursUsed} hour${billing.totalHoursUsed !== 1 ? 's' : ''}, ${billing.totalCreditsUsed} credit${billing.totalCreditsUsed !== 1 ? 's' : ''}`);
310
- if (billing.autoDestroyOnInactivity && billing.currentHourEnd) {
311
- // Auto-destroy happens at 58 min mark into billing hour if no activity after 50 min mark
312
- const currentHourEnd = new Date(billing.currentHourEnd);
313
- const currentHourStart = new Date(currentHourEnd.getTime() - 60 * 60 * 1000);
314
- const minutesIntoBillingHour = Math.floor((now.getTime() - currentHourStart.getTime()) / (60 * 1000));
315
- const minutesUntilDestroy = Math.max(0, 58 - minutesIntoBillingHour);
316
- if (minutesInactive >= 5) {
317
- console.log(chalk_1.default.yellow(` Auto-destroy in: ${minutesUntilDestroy} min (inactive)`));
331
+ // Auto-destroy protection and warning
332
+ if (billing.autoDestroyOnInactivity) {
333
+ // Check if protected from auto-destroy
334
+ if (billing.protectedUntil) {
335
+ const protectedTime = new Date(billing.protectedUntil);
336
+ const minutesUntilProtectionEnds = Math.ceil((protectedTime.getTime() - now.getTime()) / (60 * 1000));
337
+ if (minutesUntilProtectionEnds > 0) {
338
+ const timeStr = minutesUntilProtectionEnds >= 60
339
+ ? `${Math.floor(minutesUntilProtectionEnds / 60)}h ${minutesUntilProtectionEnds % 60}m`
340
+ : `${minutesUntilProtectionEnds} min`;
341
+ console.log(chalk_1.default.green(` Protected from auto-destroy for: ${timeStr}`));
342
+ }
318
343
  }
344
+ // Auto-destroy warning (only show if NOT protected and inactive)
345
+ if (billing.currentHourEnd) {
346
+ const isProtected = billing.protectedUntil && new Date(billing.protectedUntil).getTime() > now.getTime();
347
+ if (!isProtected) {
348
+ const currentHourEnd = new Date(billing.currentHourEnd);
349
+ const currentHourStart = new Date(currentHourEnd.getTime() - 60 * 60 * 1000);
350
+ const minutesIntoBillingHour = Math.floor((now.getTime() - currentHourStart.getTime()) / (60 * 1000));
351
+ const minutesUntilDestroy = Math.max(0, 58 - minutesIntoBillingHour);
352
+ if (minutesInactive >= 5) {
353
+ console.log(chalk_1.default.yellow(` Auto-destroy in: ${minutesUntilDestroy} min (inactive)`));
354
+ }
355
+ }
356
+ }
357
+ }
358
+ else {
359
+ console.log(chalk_1.default.dim(` Auto-destroy: disabled`));
319
360
  }
320
361
  console.log('');
362
+ // Show System Stats if available
363
+ const systemStats = target.systemStats;
364
+ if (systemStats && systemStats.updatedAt) {
365
+ const statsAge = Math.floor((now.getTime() - new Date(systemStats.updatedAt).getTime()) / 1000);
366
+ console.log(chalk_1.default.blue('[INFO] === System Stats ==='));
367
+ // Load average
368
+ if (systemStats.loadAvg && systemStats.loadAvg.length >= 3) {
369
+ const [load1, load5, load15] = systemStats.loadAvg;
370
+ console.log(` Load Avg: ${load1.toFixed(2)}, ${load5.toFixed(2)}, ${load15.toFixed(2)}`);
371
+ }
372
+ // Memory
373
+ if (systemStats.memoryUsagePercent !== undefined) {
374
+ const memUsed = systemStats.memoryUsedMb ? `${(systemStats.memoryUsedMb / 1024).toFixed(1)}G` : '?';
375
+ const memTotal = systemStats.memoryTotalMb ? `${(systemStats.memoryTotalMb / 1024).toFixed(1)}G` : '?';
376
+ const memBar = renderBar(systemStats.memoryUsagePercent);
377
+ console.log(` Memory: ${memBar} ${systemStats.memoryUsagePercent}% (${memUsed}/${memTotal})`);
378
+ }
379
+ // Disk
380
+ if (systemStats.diskUsagePercent !== undefined) {
381
+ const diskUsed = systemStats.diskUsedGb !== undefined ? `${systemStats.diskUsedGb}G` : '?';
382
+ const diskTotal = systemStats.diskTotalGb !== undefined ? `${systemStats.diskTotalGb}G` : '?';
383
+ const diskBar = renderBar(systemStats.diskUsagePercent);
384
+ console.log(` Disk: ${diskBar} ${systemStats.diskUsagePercent}% (${diskUsed}/${diskTotal})`);
385
+ }
386
+ // Uptime
387
+ if (systemStats.uptimeSeconds !== undefined) {
388
+ const days = Math.floor(systemStats.uptimeSeconds / 86400);
389
+ const hours = Math.floor((systemStats.uptimeSeconds % 86400) / 3600);
390
+ const mins = Math.floor((systemStats.uptimeSeconds % 3600) / 60);
391
+ const uptimeStr = days > 0 ? `${days}d ${hours}h ${mins}m` : hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
392
+ console.log(` Uptime: ${uptimeStr}`);
393
+ }
394
+ // Process count
395
+ if (systemStats.processCount !== undefined) {
396
+ console.log(` Processes: ${systemStats.processCount}`);
397
+ }
398
+ console.log(chalk_1.default.dim(` (updated ${statsAge < 60 ? 'just now' : Math.floor(statsAge / 60) + 'm ago'})`));
399
+ console.log('');
400
+ }
321
401
  // Show Docker containers status
322
402
  const dockerStatus = sshExec(target.ipAddress, keyPath, 'docker ps --format "{{.Names}}\\t{{.Status}}" 2>/dev/null', 10);
323
403
  if (dockerStatus && dockerStatus.trim()) {
package/dist/index.js CHANGED
@@ -1,32 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
- var __importDefault = (this && this.__importDefault) || function (mod) {
4
- return (mod && mod.__esModule) ? mod : { "default": mod };
5
- };
6
3
  Object.defineProperty(exports, "__esModule", { value: true });
7
4
  const commander_1 = require("commander");
8
- const chalk_1 = __importDefault(require("chalk"));
9
5
  const init_1 = require("./commands/init");
10
- const cleanup_ssh_1 = require("./commands/cleanup-ssh");
11
- /**
12
- * Auto-cleanup orphaned SSH configs on startup (non-blocking, silent)
13
- * This runs in the background without affecting CLI startup time
14
- */
15
- function autoCleanupSshConfigs() {
16
- // Run cleanup in background - don't await, don't block startup
17
- (0, cleanup_ssh_1.cleanupOrphanedSshConfigs)({ silent: true })
18
- .then(removed => {
19
- // Optionally log if GENBOX_DEBUG is set
20
- if (process.env.GENBOX_DEBUG && removed.length > 0) {
21
- console.error(chalk_1.default.dim(`[Auto-cleanup] Removed ${removed.length} orphaned SSH config(s): ${removed.join(', ')}`));
22
- }
23
- })
24
- .catch(() => {
25
- // Silently ignore errors - this is a background cleanup
26
- });
27
- }
28
- // Run auto-cleanup on startup (non-blocking)
29
- autoCleanupSshConfigs();
30
6
  const program = new commander_1.Command();
31
7
  // eslint-disable-next-line @typescript-eslint/no-var-requires
32
8
  const { version } = require('../package.json');
@@ -55,7 +31,8 @@ const migrate_1 = require("./commands/migrate");
55
31
  const ssh_setup_1 = require("./commands/ssh-setup");
56
32
  const rebuild_1 = require("./commands/rebuild");
57
33
  const extend_1 = require("./commands/extend");
58
- const cleanup_ssh_2 = require("./commands/cleanup-ssh");
34
+ const cleanup_ssh_1 = require("./commands/cleanup-ssh");
35
+ const restart_1 = require("./commands/restart");
59
36
  program
60
37
  .addCommand(init_1.initCommand)
61
38
  .addCommand(create_1.createCommand)
@@ -80,5 +57,6 @@ program
80
57
  .addCommand(ssh_setup_1.sshSetupCommand)
81
58
  .addCommand(rebuild_1.rebuildCommand)
82
59
  .addCommand(extend_1.extendCommand)
83
- .addCommand(cleanup_ssh_2.cleanupSshCommand);
60
+ .addCommand(cleanup_ssh_1.cleanupSshCommand)
61
+ .addCommand(restart_1.restartCommand);
84
62
  program.parse(process.argv);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genbox",
3
- "version": "1.0.99",
3
+ "version": "1.0.101",
4
4
  "description": "Genbox CLI - AI-Powered Development Environments",
5
5
  "main": "dist/index.js",
6
6
  "bin": {