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 +44 -1
- package/dist/cli.js +2 -2
- package/dist/commands/add.js +140 -7
- package/dist/commands/status.js +38 -8
- package/dist/commands/sync.js +44 -2
- package/dist/lib/index.d.ts +1 -0
- package/dist/lib/index.js +1 -0
- package/dist/lib/ssl.d.ts +22 -0
- package/dist/lib/ssl.js +115 -0
- package/dist/schemas/config.d.ts +15 -15
- package/dist/schemas/config.js +1 -1
- package/package.json +2 -1
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` |
|
|
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.
|
|
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('
|
|
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>
|
package/dist/commands/add.js
CHANGED
|
@@ -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:
|
|
155
|
+
repo: repo || `git@github.com:YOUR_ORG/${name}.git`,
|
|
27
156
|
branch: 'main',
|
|
28
157
|
path: `/var/www/${name}`,
|
|
29
|
-
type
|
|
158
|
+
type,
|
|
30
159
|
port,
|
|
31
160
|
};
|
|
32
|
-
if (
|
|
33
|
-
config.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.
|
|
65
|
-
console.log(chalk.dim(` 2. Apply changes:
|
|
66
|
-
console.log(chalk.dim(` 3. Deploy:
|
|
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
|
}
|
package/dist/commands/status.js
CHANGED
|
@@ -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(
|
|
29
|
-
console.log(chalk.dim('NAME'.padEnd(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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:
|
|
69
|
-
log.dim('Import apps: shyp import');
|
|
99
|
+
log.dim('Add an app: shyp add <name>');
|
|
70
100
|
}
|
|
71
101
|
console.log();
|
|
72
102
|
}
|
package/dist/commands/sync.js
CHANGED
|
@@ -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...');
|
package/dist/lib/index.d.ts
CHANGED
package/dist/lib/index.js
CHANGED
|
@@ -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
|
+
}>>;
|
package/dist/lib/ssl.js
ADDED
|
@@ -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
|
+
}
|
package/dist/schemas/config.d.ts
CHANGED
|
@@ -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?: {
|
package/dist/schemas/config.js
CHANGED
|
@@ -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.
|
|
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",
|