shyp 0.1.1 → 0.1.5

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 CHANGED
@@ -45,6 +45,48 @@
45
45
  npm install -g shyp
46
46
  ```
47
47
 
48
+ ## Before You Deploy
49
+
50
+ Before adding your first app, complete this checklist:
51
+
52
+ ### 1. DNS Configuration
53
+ Point your domain to your server:
54
+ - Create an **A record** pointing `yourdomain.com` → `your-server-ip`
55
+ - Optional: Add a **www** subdomain if needed
56
+
57
+ ### 2. Email Forwarding
58
+ Set up email forwarding for SSL certificate notifications:
59
+ - Create `contact@yourdomain.com` forwarding to your real email
60
+ - This is used by Let's Encrypt for certificate expiry warnings
61
+
62
+ ### 3. GitHub SSH Key
63
+ Ensure your server can pull from GitHub:
64
+ ```bash
65
+ # Generate a deploy key (if you haven't already)
66
+ ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_myapp -N ""
67
+
68
+ # Add the public key to your repo
69
+ cat ~/.ssh/id_ed25519_myapp.pub
70
+ # → GitHub repo → Settings → Deploy keys → Add deploy key
71
+ ```
72
+
73
+ ### 4. Webhook Secret (for auto-deploy)
74
+ Generate a shared secret for GitHub webhooks:
75
+ ```bash
76
+ # Generate a random secret
77
+ openssl rand -hex 32
78
+
79
+ # Set it on your server
80
+ export SHYP_WEBHOOK_SECRET=your-generated-secret
81
+ ```
82
+
83
+ Then configure the webhook in GitHub:
84
+ 1. Go to your repo → **Settings** → **Webhooks** → **Add webhook**
85
+ 2. **Payload URL:** `http://your-server-ip:9000/`
86
+ 3. **Content type:** `application/json`
87
+ 4. **Secret:** Your `SHYP_WEBHOOK_SECRET`
88
+ 5. **Events:** Just the push event
89
+
48
90
  ## Quick Start
49
91
 
50
92
  ```bash
@@ -93,7 +135,7 @@ landing-page ● online 3003 45MB 12d 3h example.com
93
135
  | `shyp status` | Show status of all apps |
94
136
  | `shyp deploy <name>` | Deploy an app |
95
137
  | `shyp add <name>` | Add a new app configuration |
96
- | `shyp sync` | Apply configs to PM2 + Nginx |
138
+ | `shyp sync` | Sync configs, provision SSL certs, reload Nginx |
97
139
  | `shyp ports` | Show port allocations |
98
140
  | `shyp logs <name>` | View deployment logs |
99
141
  | `shyp doctor` | Check system health |
@@ -209,6 +251,7 @@ shyp deploy new-project
209
251
  ## Links
210
252
 
211
253
  - **Website:** [shyp.now](https://shyp.now)
254
+ - **Documentation:** [shyp.now/docs](https://shyp.now/docs)
212
255
  - **GitHub:** [github.com/shypd/shyp](https://github.com/shypd/shyp)
213
256
  - **Issues:** [github.com/shypd/shyp/issues](https://github.com/shypd/shyp/issues)
214
257
  - **npm:** [npmjs.com/package/shyp](https://www.npmjs.com/package/shyp)
package/dist/cli.js CHANGED
@@ -4,7 +4,7 @@ import { statusCommand, deployCommand, portsCommand, doctorCommand, initCommand,
4
4
  program
5
5
  .name('shyp')
6
6
  .description('Zero friction deployment for Node.js apps')
7
- .version('0.1.1');
7
+ .version('0.1.4');
8
8
  // shyp status
9
9
  program
10
10
  .command('status')
@@ -40,7 +40,7 @@ program
40
40
  // shyp sync
41
41
  program
42
42
  .command('sync')
43
- .description('Apply all configs to PM2 and Nginx')
43
+ .description('Sync configs, provision SSL certs, reload Nginx')
44
44
  .option('-n, --dry-run', 'Show what would be done without making changes')
45
45
  .action(syncCommand);
46
46
  // shyp logs <name>
@@ -2,9 +2,124 @@ import { writeFile } from 'fs/promises';
2
2
  import { existsSync } from 'fs';
3
3
  import { stringify as yamlStringify } from 'yaml';
4
4
  import chalk from 'chalk';
5
+ import { input, select, confirm } from '@inquirer/prompts';
5
6
  import { isInitialized, getAppConfigPath } from '../lib/config.js';
6
7
  import { allocatePort } from '../lib/state.js';
7
8
  import { log } from '../utils/logger.js';
9
+ function showPrerequisites() {
10
+ console.log();
11
+ console.log(chalk.bold.cyan('Before You Deploy'));
12
+ console.log(chalk.dim('─'.repeat(60)));
13
+ console.log();
14
+ console.log(chalk.yellow('Complete this checklist before adding your app:'));
15
+ console.log();
16
+ // 1. DNS
17
+ console.log(chalk.bold('1. DNS Configuration'));
18
+ console.log(chalk.dim(' Point your domain to your server\'s IP address:'));
19
+ console.log();
20
+ console.log(chalk.dim(' Go to your domain registrar (Namecheap, Cloudflare, etc.)'));
21
+ console.log(chalk.dim(' → DNS settings → Add record:'));
22
+ console.log(chalk.white(' Type: A'));
23
+ console.log(chalk.white(' Host: @ (or leave blank for root domain)'));
24
+ console.log(chalk.white(' Value: YOUR_SERVER_IP'));
25
+ console.log(chalk.white(' TTL: Automatic'));
26
+ console.log();
27
+ // 2. Email
28
+ console.log(chalk.bold('2. Email Forwarding (for SSL notifications)'));
29
+ console.log(chalk.dim(' Let\'s Encrypt sends certificate expiry warnings to contact@yourdomain.com'));
30
+ console.log();
31
+ console.log(chalk.dim(' Option A: Domain registrar email forwarding'));
32
+ console.log(chalk.dim(' → Email settings → Add forwarder:'));
33
+ console.log(chalk.white(' contact@yourdomain.com → your-real-email@gmail.com'));
34
+ console.log();
35
+ console.log(chalk.dim(' Option B: Use a different email in the wizard below'));
36
+ console.log();
37
+ // 3. SSH Key
38
+ console.log(chalk.bold('3. GitHub Deploy Key'));
39
+ console.log(chalk.dim(' Your server needs permission to pull from your GitHub repo.'));
40
+ console.log();
41
+ console.log(chalk.dim(' On your server, run:'));
42
+ console.log(chalk.white(' ssh-keygen -t ed25519 -f ~/.ssh/deploy_key -N ""'));
43
+ console.log(chalk.white(' cat ~/.ssh/deploy_key.pub'));
44
+ console.log();
45
+ console.log(chalk.dim(' Copy the public key, then in GitHub:'));
46
+ console.log(chalk.dim(' → Your repo → Settings → Deploy keys → Add deploy key'));
47
+ console.log(chalk.white(' Title: "My Server"'));
48
+ console.log(chalk.white(' Key: (paste the public key)'));
49
+ console.log(chalk.white(' Allow write access: No (leave unchecked)'));
50
+ console.log();
51
+ // 4. Webhook
52
+ console.log(chalk.bold('4. GitHub Webhook (optional - for auto-deploy on push)'));
53
+ console.log(chalk.dim(' Automatically deploy when you push to GitHub.'));
54
+ console.log();
55
+ console.log(chalk.dim(' First, generate a secret on your server:'));
56
+ console.log(chalk.white(' openssl rand -hex 32'));
57
+ console.log(chalk.dim(' Save this secret! Add it to your server environment:'));
58
+ console.log(chalk.white(' export SHYP_WEBHOOK_SECRET=your-generated-secret'));
59
+ console.log();
60
+ console.log(chalk.dim(' Then in GitHub:'));
61
+ console.log(chalk.dim(' → Your repo → Settings → Webhooks → Add webhook'));
62
+ console.log(chalk.white(' Payload URL: http://YOUR_SERVER_IP:9000/'));
63
+ console.log(chalk.white(' Content type: application/json'));
64
+ console.log(chalk.white(' Secret: (paste your SHYP_WEBHOOK_SECRET)'));
65
+ console.log(chalk.white(' Events: Just the push event'));
66
+ console.log(chalk.white(' Active: Yes'));
67
+ console.log();
68
+ console.log(chalk.dim(' After setup, run: shyp start'));
69
+ console.log();
70
+ }
71
+ async function runWizard(name) {
72
+ showPrerequisites();
73
+ const proceed = await confirm({
74
+ message: 'Have you completed the prerequisites above?',
75
+ default: true
76
+ });
77
+ if (!proceed) {
78
+ console.log();
79
+ log.info('Complete the prerequisites first, then run shyp add again');
80
+ process.exit(0);
81
+ }
82
+ console.log();
83
+ console.log(chalk.bold.cyan(`Adding new app: ${name}`));
84
+ console.log(chalk.dim('─'.repeat(50)));
85
+ console.log();
86
+ // Repository URL
87
+ const repo = await input({
88
+ message: 'GitHub repository URL:',
89
+ default: `git@github.com:YOUR_ORG/${name}.git`,
90
+ validate: (value) => {
91
+ if (!value.includes('github.com') && !value.startsWith('git@')) {
92
+ return 'Please enter a valid GitHub repository URL';
93
+ }
94
+ return true;
95
+ }
96
+ });
97
+ // Domain
98
+ const domain = await input({
99
+ message: 'Domain name (leave empty for no domain):',
100
+ default: ''
101
+ });
102
+ // Email for SSL (only if domain provided)
103
+ let email;
104
+ if (domain) {
105
+ email = await input({
106
+ message: 'Email for SSL certificates:',
107
+ default: `contact@${domain}`
108
+ });
109
+ }
110
+ // App type
111
+ const type = await select({
112
+ message: 'Application type:',
113
+ choices: [
114
+ { name: 'Next.js', value: 'nextjs', description: 'Next.js application with npm start' },
115
+ { name: 'Node.js', value: 'node', description: 'Standard Node.js application' },
116
+ { name: 'Static', value: 'static', description: 'Static files served by Nginx' },
117
+ { name: 'Script', value: 'script', description: 'Custom deploy script' }
118
+ ],
119
+ default: 'nextjs'
120
+ });
121
+ return { repo, domain: domain || undefined, email, type };
122
+ }
8
123
  export async function addCommand(name, options) {
9
124
  log.banner();
10
125
  if (!isInitialized()) {
@@ -17,20 +132,37 @@ export async function addCommand(name, options) {
17
132
  log.dim(`Config: ${configPath}`);
18
133
  process.exit(1);
19
134
  }
135
+ // Check if we should run the wizard (no options provided)
136
+ const hasOptions = options.repo || options.domain || options.type || options.port;
137
+ let repo = options.repo;
138
+ let domain = options.domain;
139
+ let type = options.type || 'nextjs';
140
+ let email;
141
+ if (!hasOptions) {
142
+ // Run interactive wizard
143
+ const wizardResult = await runWizard(name);
144
+ repo = wizardResult.repo;
145
+ domain = wizardResult.domain;
146
+ type = wizardResult.type;
147
+ email = wizardResult.email;
148
+ }
20
149
  // Allocate port if not specified
21
150
  const port = options.port || await allocatePort(name, 'standard');
22
151
  // Build config
23
152
  const config = {
24
153
  name,
25
154
  description: `${name} application`,
26
- repo: options.repo || `git@github.com:YOUR_ORG/${name}.git`,
155
+ repo: repo || `git@github.com:YOUR_ORG/${name}.git`,
27
156
  branch: 'main',
28
157
  path: `/var/www/${name}`,
29
- type: options.type || 'nextjs',
158
+ type,
30
159
  port,
31
160
  };
32
- if (options.domain) {
33
- config.domain = options.domain;
161
+ if (domain) {
162
+ config.domain = domain;
163
+ }
164
+ if (email) {
165
+ config.ssl = { email };
34
166
  }
35
167
  config.build = {
36
168
  command: 'npm ci && npm run build',
@@ -52,6 +184,7 @@ export async function addCommand(name, options) {
52
184
  // Write config file
53
185
  const yaml = yamlStringify(config);
54
186
  await writeFile(configPath, yaml);
187
+ console.log();
55
188
  log.success(`Created app config: ${name}`);
56
189
  console.log();
57
190
  console.log(chalk.dim('Config file:'));
@@ -61,8 +194,8 @@ export async function addCommand(name, options) {
61
194
  console.log(chalk.cyan(yaml));
62
195
  console.log();
63
196
  log.info('Next steps:');
64
- console.log(chalk.dim(` 1. Edit the config: nano ${configPath}`));
65
- console.log(chalk.dim(` 2. Apply changes: shyp sync`));
66
- console.log(chalk.dim(` 3. Deploy: shyp deploy ${name}`));
197
+ console.log(chalk.dim(` 1. Review the config: nano ${configPath}`));
198
+ console.log(chalk.dim(` 2. Apply changes: shyp sync`));
199
+ console.log(chalk.dim(` 3. Deploy: shyp deploy ${name}`));
67
200
  console.log();
68
201
  }
@@ -2,6 +2,7 @@ import chalk from 'chalk';
2
2
  import { loadAppConfigs, loadEngineConfigs, isInitialized } from '../lib/config.js';
3
3
  import { listProcesses, formatMemory, formatUptime } from '../lib/pm2.js';
4
4
  import { loadPortAllocations, loadDeployments } from '../lib/state.js';
5
+ import { getCertInfo, formatCertStatus } from '../lib/ssl.js';
5
6
  import { log } from '../utils/logger.js';
6
7
  export async function statusCommand() {
7
8
  log.banner();
@@ -22,11 +23,29 @@ export async function statusCommand() {
22
23
  for (const p of processes) {
23
24
  processMap.set(p.name, p);
24
25
  }
26
+ // Collect all domains for cert checking
27
+ const domains = [];
28
+ for (const [, config] of apps) {
29
+ if (config.domain)
30
+ domains.push(config.domain);
31
+ }
32
+ for (const [, config] of engines) {
33
+ for (const [, mod] of Object.entries(config.modules)) {
34
+ if (mod.domain)
35
+ domains.push(mod.domain);
36
+ }
37
+ }
38
+ // Get cert info for all domains
39
+ const certMap = new Map();
40
+ for (const domain of domains) {
41
+ const info = await getCertInfo(domain);
42
+ certMap.set(domain, info);
43
+ }
25
44
  // Display apps
26
45
  if (apps.size > 0) {
27
46
  console.log(chalk.bold.white('\nApps'));
28
- console.log(chalk.dim('─'.repeat(70)));
29
- console.log(chalk.dim('NAME'.padEnd(20)), chalk.dim('STATUS'.padEnd(12)), chalk.dim('PORT'.padEnd(8)), chalk.dim('MEMORY'.padEnd(10)), chalk.dim('UPTIME'.padEnd(12)), chalk.dim('DOMAIN'));
47
+ console.log(chalk.dim('─'.repeat(80)));
48
+ console.log(chalk.dim('NAME'.padEnd(18)), chalk.dim('STATUS'.padEnd(12)), chalk.dim('PORT'.padEnd(6)), chalk.dim('MEM'.padEnd(8)), chalk.dim('UPTIME'.padEnd(10)), chalk.dim('SSL'.padEnd(6)), chalk.dim('DOMAIN'));
30
49
  for (const [name, config] of apps) {
31
50
  const pm2Name = config.pm2?.name || name;
32
51
  const proc = processMap.get(pm2Name);
@@ -34,20 +53,26 @@ export async function statusCommand() {
34
53
  const status = proc?.status || 'stopped';
35
54
  const statusColor = status === 'online' ? chalk.green : chalk.red;
36
55
  const statusIcon = status === 'online' ? '●' : '○';
37
- console.log(chalk.white(name.padEnd(20)), statusColor(`${statusIcon} ${status}`.padEnd(12)), chalk.cyan(String(port).padEnd(8)), chalk.dim((proc ? formatMemory(proc.memory) : '-').padEnd(10)), chalk.dim((proc ? formatUptime(proc.uptime) : '-').padEnd(12)), chalk.yellow(config.domain || '-'));
56
+ // Get cert status
57
+ const certInfo = config.domain ? certMap.get(config.domain) : null;
58
+ const cert = certInfo ? formatCertStatus(certInfo) : { text: '-', color: 'dim' };
59
+ const certColor = cert.color === 'green' ? chalk.green :
60
+ cert.color === 'yellow' ? chalk.yellow :
61
+ cert.color === 'red' ? chalk.red : chalk.dim;
62
+ console.log(chalk.white(name.padEnd(18)), statusColor(`${statusIcon} ${status}`.padEnd(12)), chalk.cyan(String(port).padEnd(6)), chalk.dim((proc ? formatMemory(proc.memory) : '-').padEnd(8)), chalk.dim((proc ? formatUptime(proc.uptime) : '-').padEnd(10)), certColor(cert.text.padEnd(6)), chalk.yellow(config.domain || '-'));
38
63
  }
39
64
  }
40
65
  // Display engines
41
66
  if (engines.size > 0) {
42
67
  console.log(chalk.bold.white('\nEngines'));
43
- console.log(chalk.dim('─'.repeat(70)));
68
+ console.log(chalk.dim('─'.repeat(80)));
44
69
  for (const [name, config] of engines) {
45
70
  const pm2Name = config.server.pm2?.name || name;
46
71
  const proc = processMap.get(pm2Name);
47
72
  const status = proc?.status || 'stopped';
48
73
  const statusColor = status === 'online' ? chalk.green : chalk.red;
49
74
  const statusIcon = status === 'online' ? '●' : '○';
50
- console.log(chalk.white(name.padEnd(20)), statusColor(`${statusIcon} ${status}`.padEnd(12)), chalk.dim('Engine'), chalk.dim(proc ? formatMemory(proc.memory) : ''));
75
+ console.log(chalk.white(name.padEnd(18)), statusColor(`${statusIcon} ${status}`.padEnd(12)), chalk.dim('Engine'), chalk.dim(proc ? formatMemory(proc.memory) : ''));
51
76
  // Display modules
52
77
  const modules = Object.entries(config.modules);
53
78
  if (modules.length > 0) {
@@ -58,15 +83,20 @@ export async function statusCommand() {
58
83
  const moduleStatus = moduleProc?.status || (status === 'online' ? 'managed' : 'stopped');
59
84
  const moduleStatusColor = moduleStatus === 'online' || moduleStatus === 'managed' ? chalk.green : chalk.red;
60
85
  const moduleIcon = moduleStatus === 'online' || moduleStatus === 'managed' ? '●' : '○';
61
- console.log(chalk.dim(' └─'), chalk.white(moduleName.padEnd(16)), moduleStatusColor(`${moduleIcon} ${moduleStatus}`.padEnd(12)), chalk.cyan(String(moduleConfig.port).padEnd(8)), chalk.yellow(moduleConfig.domain || '-'));
86
+ // Get cert status for module
87
+ const modCertInfo = moduleConfig.domain ? certMap.get(moduleConfig.domain) : null;
88
+ const modCert = modCertInfo ? formatCertStatus(modCertInfo) : { text: '-', color: 'dim' };
89
+ const modCertColor = modCert.color === 'green' ? chalk.green :
90
+ modCert.color === 'yellow' ? chalk.yellow :
91
+ modCert.color === 'red' ? chalk.red : chalk.dim;
92
+ console.log(chalk.dim(' └─'), chalk.white(moduleName.padEnd(14)), moduleStatusColor(`${moduleIcon} ${moduleStatus}`.padEnd(12)), chalk.cyan(String(moduleConfig.port).padEnd(6)), modCertColor(modCert.text.padEnd(6)), chalk.yellow(moduleConfig.domain || '-'));
62
93
  }
63
94
  }
64
95
  }
65
96
  }
66
97
  if (apps.size === 0 && engines.size === 0) {
67
98
  log.dim('\nNo apps or engines configured.');
68
- log.dim('Add an app: shyp add <name>');
69
- log.dim('Import apps: shyp import');
99
+ log.dim('Add an app: shyp add <name>');
70
100
  }
71
101
  console.log();
72
102
  }
@@ -1,6 +1,7 @@
1
- import { loadAppConfigs, loadEngineConfigs, isInitialized } from '../lib/config.js';
1
+ import { loadAppConfigs, loadEngineConfigs, loadGlobalConfig, isInitialized } from '../lib/config.js';
2
2
  import { allocatePort } from '../lib/state.js';
3
3
  import { generateNginxConfig, generateModuleConfig, writeNginxConfig, enableNginxConfig, testNginxConfig, reloadNginx, } from '../lib/nginx.js';
4
+ import { getCertInfo, obtainCert, isCertbotAvailable } from '../lib/ssl.js';
4
5
  import { log } from '../utils/logger.js';
5
6
  import { createSpinner } from '../utils/spinner.js';
6
7
  export async function syncCommand(options) {
@@ -14,12 +15,17 @@ export async function syncCommand(options) {
14
15
  log.info('Dry run mode - no changes will be made');
15
16
  console.log();
16
17
  }
17
- const [apps, engines] = await Promise.all([
18
+ const [apps, engines, globalConfig] = await Promise.all([
18
19
  loadAppConfigs(),
19
20
  loadEngineConfigs(),
21
+ loadGlobalConfig(),
20
22
  ]);
23
+ // Get SSL email from config (fallback to contact@domain per-domain)
24
+ const sslEmail = globalConfig?.server?.ssl?.email;
21
25
  // Track what we're doing
22
26
  const actions = [];
27
+ // Collect all domains for SSL
28
+ const domains = [];
23
29
  // Allocate ports for apps that don't have them
24
30
  log.info('Checking port allocations...');
25
31
  for (const [name, config] of apps) {
@@ -28,6 +34,42 @@ export async function syncCommand(options) {
28
34
  config.port = port;
29
35
  actions.push(`Allocated port ${port} for ${name}`);
30
36
  }
37
+ if (config.domain) {
38
+ domains.push(config.domain);
39
+ }
40
+ }
41
+ // Collect engine module domains
42
+ for (const [, engine] of engines) {
43
+ for (const [, moduleConfig] of Object.entries(engine.modules)) {
44
+ if (moduleConfig.domain) {
45
+ domains.push(moduleConfig.domain);
46
+ }
47
+ }
48
+ }
49
+ // Provision SSL certs for domains that need them
50
+ if (domains.length > 0 && !dryRun) {
51
+ log.info('Checking SSL certificates...');
52
+ const hasCertbot = await isCertbotAvailable();
53
+ if (hasCertbot) {
54
+ for (const domain of domains) {
55
+ const certInfo = await getCertInfo(domain);
56
+ if (!certInfo.exists) {
57
+ const spinner = createSpinner(`Obtaining cert for ${domain}...`).start();
58
+ const result = await obtainCert(domain, sslEmail);
59
+ if (result.success) {
60
+ spinner.succeed(`Obtained cert for ${domain}`);
61
+ actions.push(`Obtained SSL cert for ${domain}`);
62
+ }
63
+ else {
64
+ spinner.fail(`Failed to obtain cert for ${domain}`);
65
+ log.dim(` ${result.error}`);
66
+ }
67
+ }
68
+ }
69
+ }
70
+ else {
71
+ log.dim(' certbot not installed - skipping SSL provisioning');
72
+ }
31
73
  }
32
74
  // Generate nginx configs for apps with domains
33
75
  log.info('Generating nginx configs...');
@@ -4,3 +4,4 @@ export * from './pm2.js';
4
4
  export * from './git.js';
5
5
  export * from './deploy.js';
6
6
  export * from './nginx.js';
7
+ export * from './ssl.js';
package/dist/lib/index.js CHANGED
@@ -4,3 +4,4 @@ export * from './pm2.js';
4
4
  export * from './git.js';
5
5
  export * from './deploy.js';
6
6
  export * from './nginx.js';
7
+ export * from './ssl.js';
@@ -0,0 +1,22 @@
1
+ export interface CertInfo {
2
+ domain: string;
3
+ exists: boolean;
4
+ expiresAt?: Date;
5
+ daysRemaining?: number;
6
+ issuer?: string;
7
+ }
8
+ export declare function getCertInfo(domain: string): Promise<CertInfo>;
9
+ export declare function getAllCerts(): Promise<CertInfo[]>;
10
+ export declare function formatCertStatus(info: CertInfo): {
11
+ text: string;
12
+ color: 'green' | 'yellow' | 'red' | 'dim';
13
+ };
14
+ export declare function isCertbotAvailable(): Promise<boolean>;
15
+ export declare function obtainCert(domain: string, emailOverride?: string): Promise<{
16
+ success: boolean;
17
+ error?: string;
18
+ }>;
19
+ export declare function ensureCerts(domains: string[]): Promise<Map<string, {
20
+ success: boolean;
21
+ error?: string;
22
+ }>>;
@@ -0,0 +1,115 @@
1
+ import { execa } from 'execa';
2
+ import { existsSync } from 'fs';
3
+ const LETSENCRYPT_LIVE = '/etc/letsencrypt/live';
4
+ export async function getCertInfo(domain) {
5
+ const certPath = `${LETSENCRYPT_LIVE}/${domain}/fullchain.pem`;
6
+ if (!existsSync(certPath)) {
7
+ return { domain, exists: false };
8
+ }
9
+ try {
10
+ // Use openssl to read cert expiry
11
+ const { stdout } = await execa('openssl', [
12
+ 'x509', '-enddate', '-noout', '-in', certPath
13
+ ]);
14
+ // Parse "notAfter=Mon Dec 27 00:00:00 2025 GMT"
15
+ const match = stdout.match(/notAfter=(.+)/);
16
+ if (match) {
17
+ const expiresAt = new Date(match[1]);
18
+ const now = new Date();
19
+ const daysRemaining = Math.floor((expiresAt.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
20
+ return {
21
+ domain,
22
+ exists: true,
23
+ expiresAt,
24
+ daysRemaining,
25
+ };
26
+ }
27
+ }
28
+ catch {
29
+ // Cert exists but couldn't read it (permission issue)
30
+ return { domain, exists: true };
31
+ }
32
+ return { domain, exists: true };
33
+ }
34
+ export async function getAllCerts() {
35
+ const certs = [];
36
+ try {
37
+ const { stdout } = await execa('ls', [LETSENCRYPT_LIVE]);
38
+ const domains = stdout.split('\n').filter(d => d && !d.startsWith('README'));
39
+ for (const domain of domains) {
40
+ const info = await getCertInfo(domain);
41
+ certs.push(info);
42
+ }
43
+ }
44
+ catch {
45
+ // No certs or can't read directory
46
+ }
47
+ return certs.sort((a, b) => (a.daysRemaining || 999) - (b.daysRemaining || 999));
48
+ }
49
+ export function formatCertStatus(info) {
50
+ if (!info.exists) {
51
+ return { text: 'no cert', color: 'red' };
52
+ }
53
+ if (info.daysRemaining === undefined) {
54
+ return { text: 'unknown', color: 'dim' };
55
+ }
56
+ if (info.daysRemaining <= 7) {
57
+ return { text: `${info.daysRemaining}d`, color: 'red' };
58
+ }
59
+ if (info.daysRemaining <= 30) {
60
+ return { text: `${info.daysRemaining}d`, color: 'yellow' };
61
+ }
62
+ return { text: `${info.daysRemaining}d`, color: 'green' };
63
+ }
64
+ export async function isCertbotAvailable() {
65
+ try {
66
+ await execa('which', ['certbot']);
67
+ return true;
68
+ }
69
+ catch {
70
+ return false;
71
+ }
72
+ }
73
+ export async function obtainCert(domain, emailOverride) {
74
+ const certPath = `${LETSENCRYPT_LIVE}/${domain}/fullchain.pem`;
75
+ // Already have cert
76
+ if (existsSync(certPath)) {
77
+ return { success: true };
78
+ }
79
+ // Get email from domain (contact@domain.com)
80
+ const email = emailOverride || `contact@${domain}`;
81
+ try {
82
+ await execa('certbot', [
83
+ 'certonly',
84
+ '--nginx',
85
+ '--non-interactive',
86
+ '--agree-tos',
87
+ '--no-eff-email',
88
+ '--email', email,
89
+ '-d', domain,
90
+ ], { stdio: 'inherit' });
91
+ return { success: true };
92
+ }
93
+ catch (error) {
94
+ return {
95
+ success: false,
96
+ error: error instanceof Error ? error.message : 'Failed to obtain certificate'
97
+ };
98
+ }
99
+ }
100
+ export async function ensureCerts(domains) {
101
+ const results = new Map();
102
+ if (!await isCertbotAvailable()) {
103
+ for (const domain of domains) {
104
+ results.set(domain, { success: false, error: 'certbot not installed' });
105
+ }
106
+ return results;
107
+ }
108
+ for (const domain of domains) {
109
+ if (!domain)
110
+ continue;
111
+ const result = await obtainCert(domain);
112
+ results.set(domain, result);
113
+ }
114
+ return results;
115
+ }
@@ -11,15 +11,15 @@ export declare const PortRangeSchema: z.ZodObject<{
11
11
  }>;
12
12
  export declare const SSLConfigSchema: z.ZodObject<{
13
13
  enabled: z.ZodDefault<z.ZodBoolean>;
14
- email: z.ZodString;
14
+ email: z.ZodOptional<z.ZodString>;
15
15
  auto_renew: z.ZodDefault<z.ZodBoolean>;
16
16
  }, "strip", z.ZodTypeAny, {
17
17
  enabled: boolean;
18
- email: string;
19
18
  auto_renew: boolean;
19
+ email?: string | undefined;
20
20
  }, {
21
- email: string;
22
21
  enabled?: boolean | undefined;
22
+ email?: string | undefined;
23
23
  auto_renew?: boolean | undefined;
24
24
  }>;
25
25
  export declare const DefaultsSchema: z.ZodObject<{
@@ -127,15 +127,15 @@ export declare const ServerConfigSchema: z.ZodObject<{
127
127
  }>>;
128
128
  ssl: z.ZodOptional<z.ZodObject<{
129
129
  enabled: z.ZodDefault<z.ZodBoolean>;
130
- email: z.ZodString;
130
+ email: z.ZodOptional<z.ZodString>;
131
131
  auto_renew: z.ZodDefault<z.ZodBoolean>;
132
132
  }, "strip", z.ZodTypeAny, {
133
133
  enabled: boolean;
134
- email: string;
135
134
  auto_renew: boolean;
135
+ email?: string | undefined;
136
136
  }, {
137
- email: string;
138
137
  enabled?: boolean | undefined;
138
+ email?: string | undefined;
139
139
  auto_renew?: boolean | undefined;
140
140
  }>>;
141
141
  defaults: z.ZodOptional<z.ZodObject<{
@@ -176,8 +176,8 @@ export declare const ServerConfigSchema: z.ZodObject<{
176
176
  } | undefined;
177
177
  ssl?: {
178
178
  enabled: boolean;
179
- email: string;
180
179
  auto_renew: boolean;
180
+ email?: string | undefined;
181
181
  } | undefined;
182
182
  defaults?: {
183
183
  instances: number;
@@ -204,8 +204,8 @@ export declare const ServerConfigSchema: z.ZodObject<{
204
204
  } | undefined;
205
205
  } | undefined;
206
206
  ssl?: {
207
- email: string;
208
207
  enabled?: boolean | undefined;
208
+ email?: string | undefined;
209
209
  auto_renew?: boolean | undefined;
210
210
  } | undefined;
211
211
  defaults?: {
@@ -281,15 +281,15 @@ export declare const GlobalConfigSchema: z.ZodObject<{
281
281
  }>>;
282
282
  ssl: z.ZodOptional<z.ZodObject<{
283
283
  enabled: z.ZodDefault<z.ZodBoolean>;
284
- email: z.ZodString;
284
+ email: z.ZodOptional<z.ZodString>;
285
285
  auto_renew: z.ZodDefault<z.ZodBoolean>;
286
286
  }, "strip", z.ZodTypeAny, {
287
287
  enabled: boolean;
288
- email: string;
289
288
  auto_renew: boolean;
289
+ email?: string | undefined;
290
290
  }, {
291
- email: string;
292
291
  enabled?: boolean | undefined;
292
+ email?: string | undefined;
293
293
  auto_renew?: boolean | undefined;
294
294
  }>>;
295
295
  defaults: z.ZodOptional<z.ZodObject<{
@@ -330,8 +330,8 @@ export declare const GlobalConfigSchema: z.ZodObject<{
330
330
  } | undefined;
331
331
  ssl?: {
332
332
  enabled: boolean;
333
- email: string;
334
333
  auto_renew: boolean;
334
+ email?: string | undefined;
335
335
  } | undefined;
336
336
  defaults?: {
337
337
  instances: number;
@@ -358,8 +358,8 @@ export declare const GlobalConfigSchema: z.ZodObject<{
358
358
  } | undefined;
359
359
  } | undefined;
360
360
  ssl?: {
361
- email: string;
362
361
  enabled?: boolean | undefined;
362
+ email?: string | undefined;
363
363
  auto_renew?: boolean | undefined;
364
364
  } | undefined;
365
365
  defaults?: {
@@ -431,8 +431,8 @@ export declare const GlobalConfigSchema: z.ZodObject<{
431
431
  } | undefined;
432
432
  ssl?: {
433
433
  enabled: boolean;
434
- email: string;
435
434
  auto_renew: boolean;
435
+ email?: string | undefined;
436
436
  } | undefined;
437
437
  defaults?: {
438
438
  instances: number;
@@ -475,8 +475,8 @@ export declare const GlobalConfigSchema: z.ZodObject<{
475
475
  } | undefined;
476
476
  } | undefined;
477
477
  ssl?: {
478
- email: string;
479
478
  enabled?: boolean | undefined;
479
+ email?: string | undefined;
480
480
  auto_renew?: boolean | undefined;
481
481
  } | undefined;
482
482
  defaults?: {
@@ -8,7 +8,7 @@ export const PortRangeSchema = z.object({
8
8
  // SSL configuration
9
9
  export const SSLConfigSchema = z.object({
10
10
  enabled: z.boolean().default(true),
11
- email: z.string().email(),
11
+ email: z.string().email().optional(), // Falls back to contact@{domain}
12
12
  auto_renew: z.boolean().default(true),
13
13
  });
14
14
  // Default values
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shyp",
3
- "version": "0.1.1",
3
+ "version": "0.1.5",
4
4
  "description": "Zero friction deployment for Node.js apps",
5
5
  "author": "shypd",
6
6
  "license": "MIT",
@@ -42,6 +42,7 @@
42
42
  "node": ">=20.0.0"
43
43
  },
44
44
  "dependencies": {
45
+ "@inquirer/prompts": "^7.2.1",
45
46
  "chalk": "^5.4.1",
46
47
  "commander": "^13.0.0",
47
48
  "execa": "^9.5.2",