certops 0.0.1
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/dist/api.js +27 -0
- package/dist/client.js +21 -0
- package/dist/commands/download.js +87 -0
- package/dist/commands/list.js +40 -0
- package/dist/commands/service/configure.js +67 -0
- package/dist/commands/service/control.js +86 -0
- package/dist/commands/service/daemon.js +35 -0
- package/dist/commands/service/index.js +34 -0
- package/dist/commands/service/install.js +132 -0
- package/dist/commands/service/renew.js +89 -0
- package/dist/config.js +30 -0
- package/dist/files.js +43 -0
- package/dist/hooks.js +58 -0
- package/dist/index.js +25 -0
- package/dist/state.js +25 -0
- package/package.json +33 -0
package/dist/api.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export function createApiClient(options) {
|
|
2
|
+
const base = (options.apiUrl ?? 'https://ssl-manager.dcom.at').replace(/\/$/, '');
|
|
3
|
+
async function request(path) {
|
|
4
|
+
const res = await fetch(`${base}/api/cli${path}`, {
|
|
5
|
+
headers: {
|
|
6
|
+
'Authorization': `Bearer ${options.apiKey}`,
|
|
7
|
+
'Content-Type': 'application/json',
|
|
8
|
+
},
|
|
9
|
+
});
|
|
10
|
+
const body = (await res.json());
|
|
11
|
+
if (!res.ok || 'errors' in body) {
|
|
12
|
+
const errBody = body;
|
|
13
|
+
const msg = errBody.errors?.[0]?.message ?? `HTTP ${res.status}`;
|
|
14
|
+
throw new Error(msg);
|
|
15
|
+
}
|
|
16
|
+
return body.data;
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
async listCerts() {
|
|
20
|
+
const data = await request('/certs');
|
|
21
|
+
return data.certs;
|
|
22
|
+
},
|
|
23
|
+
async downloadCert(id) {
|
|
24
|
+
return request(`/certs/${id}/download`);
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import input from '@inquirer/input';
|
|
2
|
+
import { readConfig } from './config.js';
|
|
3
|
+
import { createApiClient } from './api.js';
|
|
4
|
+
export async function getConfiguredClient() {
|
|
5
|
+
let apiKey = process.env['SSL_PILOT_API_KEY'];
|
|
6
|
+
if (!apiKey) {
|
|
7
|
+
if (!process.stdin.isTTY) {
|
|
8
|
+
process.stderr.write('Error: SSL_PILOT_API_KEY is not set and no TTY is available for prompt.\n');
|
|
9
|
+
process.stderr.write(' export SSL_PILOT_API_KEY=\'sslpilot_...\'\n\n');
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
12
|
+
apiKey = await input({
|
|
13
|
+
message: 'API key (sslpilot_...):',
|
|
14
|
+
validate: (v) => v.startsWith('sslpilot_') && v.length > 10
|
|
15
|
+
? true
|
|
16
|
+
: 'Key must start with sslpilot_',
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
const config = await readConfig();
|
|
20
|
+
return createApiClient({ apiKey, apiUrl: config.apiUrl });
|
|
21
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import select from '@inquirer/select';
|
|
3
|
+
import {} from '../api.js';
|
|
4
|
+
import { getConfiguredClient } from '../client.js';
|
|
5
|
+
import { saveCert } from '../files.js';
|
|
6
|
+
async function performDownload(cert, client) {
|
|
7
|
+
process.stdout.write(`\nDownloading ${cert.certName}…\n`);
|
|
8
|
+
const files = await client.downloadCert(cert._id);
|
|
9
|
+
const { certPath, keyPath } = await saveCert(files.certName, files.certPem, files.keyPem);
|
|
10
|
+
process.stdout.write(` ✓ Certificate : ${certPath}\n`);
|
|
11
|
+
process.stdout.write(` ✓ Private key : ${keyPath}\n`);
|
|
12
|
+
if (files.expiryDate) {
|
|
13
|
+
process.stdout.write(` ✓ Expires : ${new Date(files.expiryDate).toLocaleDateString()}\n`);
|
|
14
|
+
}
|
|
15
|
+
process.stdout.write('\n');
|
|
16
|
+
}
|
|
17
|
+
export const downloadCommand = new Command('download')
|
|
18
|
+
.description('Download a certificate to /etc/certops/certs/<domain>/ — requires sudo')
|
|
19
|
+
.argument('[certName]', 'Certificate name, e.g. *.example.com (omit to pick interactively)')
|
|
20
|
+
.option('-i, --id <id>', 'Download by certificate ID')
|
|
21
|
+
.addHelpText('after', `
|
|
22
|
+
Examples:
|
|
23
|
+
sudo certops download Interactive picker
|
|
24
|
+
sudo certops download '*.example.com' By domain name
|
|
25
|
+
sudo certops download --id 6643abc... By certificate ID
|
|
26
|
+
`)
|
|
27
|
+
.action(async (certName, opts) => {
|
|
28
|
+
const DOWNLOADABLE = new Set(['active', 'renewing']);
|
|
29
|
+
try {
|
|
30
|
+
const client = await getConfiguredClient();
|
|
31
|
+
if (opts.id) {
|
|
32
|
+
const certs = await client.listCerts();
|
|
33
|
+
const cert = certs.find(c => c._id === opts.id);
|
|
34
|
+
if (!cert) {
|
|
35
|
+
process.stderr.write(`\nError: no certificate found with ID "${opts.id}"\n\n`);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
await performDownload(cert, client);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const certs = await client.listCerts();
|
|
42
|
+
if (certs.length === 0) {
|
|
43
|
+
process.stdout.write('\nNo certificates found in your account.\n\n');
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (certName) {
|
|
47
|
+
const match = certs.find(c => c.certName === certName);
|
|
48
|
+
if (!match) {
|
|
49
|
+
process.stderr.write(`\nError: no certificate found for "${certName}"\n`);
|
|
50
|
+
process.stderr.write('Run "sp list" to see available certificates.\n\n');
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
if (!DOWNLOADABLE.has(match.status)) {
|
|
54
|
+
process.stderr.write(`\nError: "${certName}" is not available for download (status: ${match.status})\n\n`);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
await performDownload(match, client);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const active = certs.filter(c => DOWNLOADABLE.has(c.status));
|
|
61
|
+
if (active.length === 0) {
|
|
62
|
+
process.stdout.write('\nNo active certificates available.\n\n');
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const chosen = await select({
|
|
66
|
+
message: 'Select a certificate to download:',
|
|
67
|
+
choices: active.map(c => {
|
|
68
|
+
const expiry = c.expiryDate
|
|
69
|
+
? `expires ${new Date(c.expiryDate).toLocaleDateString()}`
|
|
70
|
+
: 'no expiry';
|
|
71
|
+
const renewingTag = c.status === 'renewing' ? ' [renewing]' : '';
|
|
72
|
+
const tag = c.certType === 'wildcard' ? ' [wildcard]' : c.certType === 'apex' ? ' [apex]' : '';
|
|
73
|
+
return {
|
|
74
|
+
name: `${c.certName}${tag}${renewingTag} — ${expiry}`,
|
|
75
|
+
value: c,
|
|
76
|
+
};
|
|
77
|
+
}),
|
|
78
|
+
});
|
|
79
|
+
await performDownload(chosen, client);
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
if (err.name === 'ExitPromptError')
|
|
83
|
+
process.exit(0);
|
|
84
|
+
process.stderr.write(`\nError: ${err.message}\n\n`);
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { getConfiguredClient } from '../client.js';
|
|
3
|
+
export const listCommand = new Command('list')
|
|
4
|
+
.description('List all certificates in your organisation')
|
|
5
|
+
.action(async () => {
|
|
6
|
+
try {
|
|
7
|
+
const client = await getConfiguredClient();
|
|
8
|
+
const certs = await client.listCerts();
|
|
9
|
+
if (certs.length === 0) {
|
|
10
|
+
process.stdout.write('\nNo certificates found in your account.\n\n');
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
const active = certs.filter(c => c.status === 'active');
|
|
14
|
+
const inactive = certs.filter(c => c.status !== 'active');
|
|
15
|
+
const maxLen = Math.max(...certs.map(c => c.certName.length), 10);
|
|
16
|
+
const pad = (s, n) => s.padEnd(n);
|
|
17
|
+
const typeTag = (type) => type === 'wildcard' ? '[wildcard]' : type === 'apex' ? '[apex] ' : '[single] ';
|
|
18
|
+
process.stdout.write(`\nFound ${certs.length} certificate(s)\n`);
|
|
19
|
+
if (active.length > 0) {
|
|
20
|
+
process.stdout.write(`\nACTIVE (${active.length})\n`);
|
|
21
|
+
for (const c of active) {
|
|
22
|
+
const expiry = c.expiryDate
|
|
23
|
+
? `expires ${new Date(c.expiryDate).toLocaleDateString()}`
|
|
24
|
+
: 'no expiry info';
|
|
25
|
+
process.stdout.write(` ${pad(c.certName, maxLen)} ${typeTag(c.certType)} ${expiry}\n`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (inactive.length > 0) {
|
|
29
|
+
process.stdout.write(`\nOTHER (${inactive.length})\n`);
|
|
30
|
+
for (const c of inactive) {
|
|
31
|
+
process.stdout.write(` ${pad(c.certName, maxLen)} ${typeTag(c.certType)} ${c.status}\n`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
process.stdout.write('\n');
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
process.stderr.write(`\nError: ${err.message}\n\n`);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import input from '@inquirer/input';
|
|
3
|
+
import { spawnSync } from 'child_process';
|
|
4
|
+
import { readConfig, writeConfig } from '../../config.js';
|
|
5
|
+
export const configureCommand = new Command('configure')
|
|
6
|
+
.description('Update service config (watch domains, check interval) and restart')
|
|
7
|
+
.action(async () => {
|
|
8
|
+
try {
|
|
9
|
+
if (process.getuid?.() !== 0) {
|
|
10
|
+
console.error('Error: certops service configure must run as root.');
|
|
11
|
+
console.error('\n sudo sp service configure\n');
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
const existing = await readConfig();
|
|
15
|
+
console.log('\nSSL Pilot — Update Service Config\n');
|
|
16
|
+
console.log(' (API key is stored in the systemd unit, not changed here)\n');
|
|
17
|
+
const intervalStr = await input({
|
|
18
|
+
message: 'Check interval (hours):',
|
|
19
|
+
default: String(existing.checkIntervalHours),
|
|
20
|
+
validate: (v) => Number.isInteger(Number(v)) && Number(v) > 0 ? true : 'Must be a positive integer',
|
|
21
|
+
});
|
|
22
|
+
const thresholdStr = await input({
|
|
23
|
+
message: 'Download cert when expiry within (days):',
|
|
24
|
+
default: String(existing.renewalThresholdDays),
|
|
25
|
+
validate: (v) => Number.isInteger(Number(v)) && Number(v) > 0 ? true : 'Must be a positive integer',
|
|
26
|
+
});
|
|
27
|
+
const retriesStr = await input({
|
|
28
|
+
message: 'Max download retries per cert:',
|
|
29
|
+
default: String(existing.maxDownloadRetries),
|
|
30
|
+
validate: (v) => Number.isInteger(Number(v)) && Number(v) >= 0 ? true : 'Must be a non-negative integer',
|
|
31
|
+
});
|
|
32
|
+
const domainsRaw = await input({
|
|
33
|
+
message: 'Watch specific domains (comma-separated, blank = all active):',
|
|
34
|
+
default: existing.watchDomains.join(', '),
|
|
35
|
+
});
|
|
36
|
+
const watchDomains = domainsRaw
|
|
37
|
+
.split(',')
|
|
38
|
+
.map(d => d.trim())
|
|
39
|
+
.filter(Boolean);
|
|
40
|
+
await writeConfig({
|
|
41
|
+
...existing,
|
|
42
|
+
checkIntervalHours: Number(intervalStr),
|
|
43
|
+
renewalThresholdDays: Number(thresholdStr),
|
|
44
|
+
maxDownloadRetries: Number(retriesStr),
|
|
45
|
+
watchDomains,
|
|
46
|
+
});
|
|
47
|
+
console.log('\n✓ Config updated at /etc/certops/config.json');
|
|
48
|
+
const result = spawnSync('systemctl', ['restart', 'certops'], {
|
|
49
|
+
stdio: ['inherit', 'inherit', 'pipe'],
|
|
50
|
+
encoding: 'utf8',
|
|
51
|
+
});
|
|
52
|
+
if (result.status !== 0) {
|
|
53
|
+
if (result.stderr?.trim())
|
|
54
|
+
process.stderr.write(result.stderr + '\n');
|
|
55
|
+
process.stderr.write('\nWarning: config saved but service restart failed.\n');
|
|
56
|
+
process.stderr.write(' Run: sudo sp service start\n\n');
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
console.log('✓ Service restarted with new config.\n');
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
if (err.name === 'ExitPromptError')
|
|
63
|
+
process.exit(0);
|
|
64
|
+
console.error(`\nError: ${err.message}`);
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { spawnSync } from 'child_process';
|
|
3
|
+
import { unlink } from 'fs/promises';
|
|
4
|
+
import { resolveApiKey, runRenewalCycle } from './renew.js';
|
|
5
|
+
const UNIT = 'certops';
|
|
6
|
+
const UNIT_PATH = '/etc/systemd/system/certops.service';
|
|
7
|
+
/**
|
|
8
|
+
* Runs systemctl with stdout inherited (visible) and stderr captured.
|
|
9
|
+
* Stderr is only printed if the command fails, so noisy shim warnings
|
|
10
|
+
* from Python-based systemctl stubs don't pollute normal output.
|
|
11
|
+
*/
|
|
12
|
+
function systemctl(args, exitOnFail = true) {
|
|
13
|
+
const result = spawnSync('systemctl', args, {
|
|
14
|
+
stdio: ['inherit', 'inherit', 'pipe'],
|
|
15
|
+
encoding: 'utf8',
|
|
16
|
+
});
|
|
17
|
+
if (result.status !== 0) {
|
|
18
|
+
if (result.stderr?.trim())
|
|
19
|
+
process.stderr.write(result.stderr + '\n');
|
|
20
|
+
if (exitOnFail)
|
|
21
|
+
process.exit(1);
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
function requireRoot() {
|
|
27
|
+
if (process.getuid?.() !== 0) {
|
|
28
|
+
process.stderr.write('\nError: this command requires root. Use sudo.\n\n');
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export const startCommand = new Command('start')
|
|
33
|
+
.description('Start the SSL Pilot service')
|
|
34
|
+
.action(() => { requireRoot(); systemctl(['start', UNIT]); });
|
|
35
|
+
export const stopCommand = new Command('stop')
|
|
36
|
+
.description('Stop the SSL Pilot service')
|
|
37
|
+
.action(() => { requireRoot(); systemctl(['stop', UNIT]); });
|
|
38
|
+
export const restartCommand = new Command('restart')
|
|
39
|
+
.description('Restart the service — picks up config.json changes immediately')
|
|
40
|
+
.action(() => { requireRoot(); systemctl(['restart', UNIT]); });
|
|
41
|
+
export const statusCommand = new Command('status')
|
|
42
|
+
.description('Show SSL Pilot service status')
|
|
43
|
+
.action(() => { systemctl(['status', UNIT]); });
|
|
44
|
+
export const checkCommand = new Command('check')
|
|
45
|
+
.description('Run one renewal check cycle immediately (for testing)')
|
|
46
|
+
.action(async () => {
|
|
47
|
+
requireRoot();
|
|
48
|
+
const apiKey = await resolveApiKey();
|
|
49
|
+
if (!apiKey) {
|
|
50
|
+
process.stderr.write('\nError: SSL_PILOT_API_KEY not found in environment or service unit.\n');
|
|
51
|
+
process.stderr.write(' Set the env var or run: sudo sp service install\n\n');
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
const log = (msg) => process.stdout.write(` ${msg}\n`);
|
|
55
|
+
process.stdout.write('\nRunning renewal check…\n\n');
|
|
56
|
+
try {
|
|
57
|
+
const result = await runRenewalCycle(apiKey, log);
|
|
58
|
+
process.stdout.write(`\n Done — checked: ${result.checked}, downloaded: ${result.downloaded}, errors: ${result.errors}\n\n`);
|
|
59
|
+
if (result.errors > 0)
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
process.stderr.write(`\nError: ${err.message}\n\n`);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
export const uninstallServiceCommand = new Command('uninstall')
|
|
68
|
+
.description('Stop, disable, and remove the SSL Pilot service unit')
|
|
69
|
+
.action(async () => {
|
|
70
|
+
requireRoot();
|
|
71
|
+
process.stdout.write('\nRemoving SSL Pilot service…\n\n');
|
|
72
|
+
systemctl(['stop', UNIT], false);
|
|
73
|
+
systemctl(['disable', UNIT], false);
|
|
74
|
+
try {
|
|
75
|
+
await unlink(UNIT_PATH);
|
|
76
|
+
process.stdout.write(` ✓ Removed ${UNIT_PATH}\n`);
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
if (err.code !== 'ENOENT')
|
|
80
|
+
throw err;
|
|
81
|
+
}
|
|
82
|
+
systemctl(['daemon-reload'], false);
|
|
83
|
+
process.stdout.write('\n ✓ Service uninstalled.\n');
|
|
84
|
+
process.stdout.write(' /etc/certops/ (config, state, certs, hooks) was NOT removed.\n');
|
|
85
|
+
process.stdout.write(' Run "sudo sp service install" to reinstall.\n\n');
|
|
86
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { readConfig } from '../../config.js';
|
|
3
|
+
import { resolveApiKey, runRenewalCycle } from './renew.js';
|
|
4
|
+
function log(msg) {
|
|
5
|
+
process.stdout.write(`[${new Date().toISOString()}] ${msg}\n`);
|
|
6
|
+
}
|
|
7
|
+
function sleep(ms) {
|
|
8
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
9
|
+
}
|
|
10
|
+
async function tick() {
|
|
11
|
+
const apiKey = await resolveApiKey();
|
|
12
|
+
if (!apiKey) {
|
|
13
|
+
log('SSL_PILOT_API_KEY not available — skipping cycle.');
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
const result = await runRenewalCycle(apiKey, log);
|
|
18
|
+
log(`Cycle complete — checked: ${result.checked}, downloaded: ${result.downloaded}, errors: ${result.errors}`);
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
log(`Cycle failed: ${err.message}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export const daemonCommand = new Command('run')
|
|
25
|
+
.description('Run the monitoring daemon (managed by systemd)')
|
|
26
|
+
.action(async () => {
|
|
27
|
+
log('SSL Pilot daemon starting.');
|
|
28
|
+
process.on('SIGTERM', () => { log('SIGTERM — shutting down.'); process.exit(0); });
|
|
29
|
+
process.on('SIGINT', () => { log('SIGINT — shutting down.'); process.exit(0); });
|
|
30
|
+
while (true) {
|
|
31
|
+
await tick();
|
|
32
|
+
const config = await readConfig();
|
|
33
|
+
await sleep(config.checkIntervalHours * 60 * 60 * 1000);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { installCommand } from './install.js';
|
|
3
|
+
import { configureCommand } from './configure.js';
|
|
4
|
+
import { daemonCommand } from './daemon.js';
|
|
5
|
+
import { startCommand, stopCommand, restartCommand, statusCommand, checkCommand, uninstallServiceCommand } from './control.js';
|
|
6
|
+
export const serviceCommand = new Command('service')
|
|
7
|
+
.description('Manage the SSL Pilot auto-renewal background service')
|
|
8
|
+
.addHelpText('after', `
|
|
9
|
+
Commands:
|
|
10
|
+
install Interactive setup — writes config, hooks, and installs systemd unit
|
|
11
|
+
configure Update watch domains, interval, thresholds — restarts service
|
|
12
|
+
start Start the service (requires sudo)
|
|
13
|
+
stop Stop the service (requires sudo)
|
|
14
|
+
restart Restart — picks up /etc/certops/config.json changes (requires sudo)
|
|
15
|
+
status Show service status
|
|
16
|
+
check Run one renewal cycle immediately (requires sudo)
|
|
17
|
+
uninstall Remove the systemd unit (config and certs are kept) (requires sudo)
|
|
18
|
+
|
|
19
|
+
Examples:
|
|
20
|
+
sudo certops service install
|
|
21
|
+
sudo certops service configure
|
|
22
|
+
sudo certops service restart
|
|
23
|
+
certops service status
|
|
24
|
+
journalctl -u certops -f
|
|
25
|
+
`);
|
|
26
|
+
serviceCommand.addCommand(installCommand);
|
|
27
|
+
serviceCommand.addCommand(configureCommand);
|
|
28
|
+
serviceCommand.addCommand(startCommand);
|
|
29
|
+
serviceCommand.addCommand(stopCommand);
|
|
30
|
+
serviceCommand.addCommand(restartCommand);
|
|
31
|
+
serviceCommand.addCommand(statusCommand);
|
|
32
|
+
serviceCommand.addCommand(checkCommand);
|
|
33
|
+
serviceCommand.addCommand(uninstallServiceCommand);
|
|
34
|
+
serviceCommand.addCommand(daemonCommand, { hidden: true });
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import input from '@inquirer/input';
|
|
3
|
+
import { writeFile, mkdir } from 'fs/promises';
|
|
4
|
+
import { spawnSync } from 'child_process';
|
|
5
|
+
import { readConfig, writeConfig, CERTS_DIR, HOOKS_DIR } from '../../config.js';
|
|
6
|
+
import { domainHookPath, GLOBAL_HOOK } from '../../hooks.js';
|
|
7
|
+
const UNIT_PATH = '/etc/systemd/system/certops.service';
|
|
8
|
+
function buildUnitContent(apiKey) {
|
|
9
|
+
return `\
|
|
10
|
+
[Unit]
|
|
11
|
+
Description=SSL Pilot Certificate Monitor
|
|
12
|
+
After=network-online.target
|
|
13
|
+
Wants=network-online.target
|
|
14
|
+
|
|
15
|
+
[Service]
|
|
16
|
+
Type=simple
|
|
17
|
+
Environment=SSL_PILOT_API_KEY=${apiKey}
|
|
18
|
+
ExecStart=/usr/local/bin/certops service run
|
|
19
|
+
Restart=on-failure
|
|
20
|
+
RestartSec=30
|
|
21
|
+
StandardOutput=journal
|
|
22
|
+
StandardError=journal
|
|
23
|
+
SyslogIdentifier=certops
|
|
24
|
+
|
|
25
|
+
[Install]
|
|
26
|
+
WantedBy=multi-user.target
|
|
27
|
+
`;
|
|
28
|
+
}
|
|
29
|
+
const HOOK_TEMPLATE = `#!/usr/bin/env bash
|
|
30
|
+
# Available env vars:
|
|
31
|
+
# SSL_PILOT_CERT_NAME — certificate name (e.g. *.example.com)
|
|
32
|
+
# SSL_PILOT_DOMAIN — domain without wildcard (e.g. example.com)
|
|
33
|
+
# SSL_PILOT_CERT_PATH — path to certificate.crt
|
|
34
|
+
# SSL_PILOT_KEY_PATH — path to private.key
|
|
35
|
+
|
|
36
|
+
# Example: reload nginx after cert update
|
|
37
|
+
# systemctl reload nginx
|
|
38
|
+
`;
|
|
39
|
+
export const installCommand = new Command('install')
|
|
40
|
+
.description('Install and configure the SSL Pilot background service')
|
|
41
|
+
.action(async () => {
|
|
42
|
+
try {
|
|
43
|
+
if (process.getuid?.() !== 0) {
|
|
44
|
+
console.error('Error: certops service install must run as root.');
|
|
45
|
+
console.error('\n sudo -E sp service install\n');
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
console.log('\nSSL Pilot Service Setup\n');
|
|
49
|
+
const existing = await readConfig();
|
|
50
|
+
// API key — env takes precedence, otherwise prompt
|
|
51
|
+
const apiKey = process.env['SSL_PILOT_API_KEY'] ?? await input({
|
|
52
|
+
message: 'SSL Pilot API key (sslpilot_...):',
|
|
53
|
+
validate: (v) => v.startsWith('sslpilot_') && v.length > 10
|
|
54
|
+
? true
|
|
55
|
+
: 'Key must start with sslpilot_',
|
|
56
|
+
});
|
|
57
|
+
const apiUrl = await input({
|
|
58
|
+
message: 'API URL (leave blank for default https://ssl-manager.dcom.at):',
|
|
59
|
+
default: existing.apiUrl ?? '',
|
|
60
|
+
});
|
|
61
|
+
const intervalStr = await input({
|
|
62
|
+
message: 'Check interval (hours):',
|
|
63
|
+
default: String(existing.checkIntervalHours),
|
|
64
|
+
validate: (v) => Number.isInteger(Number(v)) && Number(v) > 0 ? true : 'Must be a positive integer',
|
|
65
|
+
});
|
|
66
|
+
const domainsRaw = await input({
|
|
67
|
+
message: 'Watch specific domains (comma-separated, blank = all active):',
|
|
68
|
+
default: existing.watchDomains.join(', '),
|
|
69
|
+
});
|
|
70
|
+
const watchDomains = domainsRaw
|
|
71
|
+
.split(',')
|
|
72
|
+
.map(d => d.trim())
|
|
73
|
+
.filter(Boolean);
|
|
74
|
+
const config = {
|
|
75
|
+
apiUrl: apiUrl.trim() || undefined,
|
|
76
|
+
renewalThresholdDays: existing.renewalThresholdDays,
|
|
77
|
+
checkIntervalHours: Number(intervalStr),
|
|
78
|
+
watchDomains,
|
|
79
|
+
maxDownloadRetries: existing.maxDownloadRetries,
|
|
80
|
+
};
|
|
81
|
+
// Write config + create directories
|
|
82
|
+
await writeConfig(config);
|
|
83
|
+
await mkdir(CERTS_DIR, { recursive: true });
|
|
84
|
+
await mkdir(HOOKS_DIR, { recursive: true });
|
|
85
|
+
console.log('\n✓ Config written to /etc/certops/config.json');
|
|
86
|
+
// Create hook stubs only if they don't exist yet
|
|
87
|
+
for (const domain of watchDomains) {
|
|
88
|
+
const hookFile = domainHookPath(domain);
|
|
89
|
+
try {
|
|
90
|
+
await writeFile(hookFile, HOOK_TEMPLATE, { flag: 'wx', mode: 0o755 });
|
|
91
|
+
console.log(`✓ Hook stub created: ${hookFile}`);
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
if (err.code !== 'EEXIST')
|
|
95
|
+
throw err;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
await writeFile(GLOBAL_HOOK, HOOK_TEMPLATE, { flag: 'wx', mode: 0o755 });
|
|
100
|
+
console.log(`✓ Global hook stub created: ${GLOBAL_HOOK}`);
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
if (err.code !== 'EEXIST')
|
|
104
|
+
throw err;
|
|
105
|
+
}
|
|
106
|
+
// Write systemd unit
|
|
107
|
+
await writeFile(UNIT_PATH, buildUnitContent(apiKey), { encoding: 'utf8', mode: 0o600 });
|
|
108
|
+
console.log(`✓ Systemd unit written to ${UNIT_PATH}`);
|
|
109
|
+
for (const args of [
|
|
110
|
+
['daemon-reload'],
|
|
111
|
+
['enable', 'certops'],
|
|
112
|
+
['restart', 'certops'],
|
|
113
|
+
]) {
|
|
114
|
+
const r = spawnSync('systemctl', args, { stdio: ['inherit', 'inherit', 'pipe'], encoding: 'utf8' });
|
|
115
|
+
if (r.status !== 0) {
|
|
116
|
+
if (r.stderr?.trim())
|
|
117
|
+
process.stderr.write(r.stderr + '\n');
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
console.log('\n✓ Service enabled and started.\n');
|
|
122
|
+
console.log(' Edit hooks in : /etc/certops/hooks/');
|
|
123
|
+
console.log(' Check status : certops service status');
|
|
124
|
+
console.log(' Follow logs : journalctl -u certops -f\n');
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
if (err.name === 'ExitPromptError')
|
|
128
|
+
process.exit(0);
|
|
129
|
+
console.error(`\nError: ${err.message}`);
|
|
130
|
+
process.exit(1);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { createApiClient } from '../../api.js';
|
|
2
|
+
import { saveCert } from '../../files.js';
|
|
3
|
+
import { readConfig } from '../../config.js';
|
|
4
|
+
import { readState, updateCertState } from '../../state.js';
|
|
5
|
+
import { runHooks } from '../../hooks.js';
|
|
6
|
+
const BASE_RETRY_DELAY_MS = 2_000; // 2 s → 4 s → 8 s …
|
|
7
|
+
function sleep(ms) {
|
|
8
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
9
|
+
}
|
|
10
|
+
export async function resolveApiKey() {
|
|
11
|
+
if (process.env['SSL_PILOT_API_KEY'])
|
|
12
|
+
return process.env['SSL_PILOT_API_KEY'];
|
|
13
|
+
try {
|
|
14
|
+
const { execSync } = await import('child_process');
|
|
15
|
+
const out = execSync('systemctl show certops -p Environment --value', { encoding: 'utf8' });
|
|
16
|
+
const match = out.match(/SSL_PILOT_API_KEY=(\S+)/);
|
|
17
|
+
return match?.[1] ?? null;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export async function runRenewalCycle(apiKey, log) {
|
|
24
|
+
const config = await readConfig();
|
|
25
|
+
const client = createApiClient({ apiKey, apiUrl: config.apiUrl });
|
|
26
|
+
const allCerts = await client.listCerts();
|
|
27
|
+
const DOWNLOADABLE = new Set(['active', 'renewing']);
|
|
28
|
+
const certs = config.watchDomains.length > 0
|
|
29
|
+
? allCerts.filter(c => config.watchDomains.includes(c.certName))
|
|
30
|
+
: allCerts.filter(c => DOWNLOADABLE.has(c.status));
|
|
31
|
+
const state = await readState();
|
|
32
|
+
const thresholdMs = config.renewalThresholdDays * 24 * 60 * 60 * 1000;
|
|
33
|
+
const maxRetries = Math.max(0, config.maxDownloadRetries);
|
|
34
|
+
const now = Date.now();
|
|
35
|
+
let downloaded = 0;
|
|
36
|
+
let errors = 0;
|
|
37
|
+
for (const cert of certs) {
|
|
38
|
+
if (!DOWNLOADABLE.has(cert.status)) {
|
|
39
|
+
log(`Skip ${cert.certName} — ${cert.status}`);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const local = state[cert.certName];
|
|
43
|
+
const parsed = cert.expiryDate ? new Date(cert.expiryDate).getTime() : 0;
|
|
44
|
+
const certExpiry = Number.isFinite(parsed) ? parsed : 0;
|
|
45
|
+
const neverDownloaded = !local;
|
|
46
|
+
const expiryChanged = !neverDownloaded && local.expiryDate !== cert.expiryDate;
|
|
47
|
+
const expiringInTime = certExpiry > 0 && certExpiry - now <= thresholdMs;
|
|
48
|
+
if (!neverDownloaded && !expiryChanged && !expiringInTime) {
|
|
49
|
+
const daysLeft = certExpiry ? Math.ceil((certExpiry - now) / 86_400_000) : 0;
|
|
50
|
+
log(`OK ${cert.certName} — ${daysLeft}d until expiry`);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const reason = neverDownloaded ? 'first download' : expiryChanged ? 'cert renewed' : 'expiring soon';
|
|
54
|
+
log(`Downloading ${cert.certName} (${reason})…`);
|
|
55
|
+
let lastError;
|
|
56
|
+
let attempt = 0;
|
|
57
|
+
while (attempt <= maxRetries) {
|
|
58
|
+
if (attempt > 0) {
|
|
59
|
+
const delay = BASE_RETRY_DELAY_MS * Math.pow(2, attempt - 1);
|
|
60
|
+
log(` retry ${attempt}/${maxRetries} in ${delay / 1000}s…`);
|
|
61
|
+
await sleep(delay);
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
const files = await client.downloadCert(cert._id);
|
|
65
|
+
const { certPath, keyPath } = await saveCert(files.certName, files.certPem, files.keyPem);
|
|
66
|
+
log(` cert : ${certPath}`);
|
|
67
|
+
log(` key : ${keyPath}`);
|
|
68
|
+
await updateCertState(cert.certName, files.expiryDate ?? cert.expiryDate ?? '');
|
|
69
|
+
await runHooks(cert.certName, {
|
|
70
|
+
SSL_PILOT_DOMAIN: cert.certName.replace(/^\*\./, ''),
|
|
71
|
+
SSL_PILOT_CERT_PATH: certPath,
|
|
72
|
+
SSL_PILOT_KEY_PATH: keyPath,
|
|
73
|
+
}, log);
|
|
74
|
+
downloaded++;
|
|
75
|
+
lastError = undefined;
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
80
|
+
attempt++;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (lastError) {
|
|
84
|
+
log(` error (gave up after ${maxRetries + 1} attempt${maxRetries !== 0 ? 's' : ''}): ${lastError.message}`);
|
|
85
|
+
errors++;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { checked: certs.length, downloaded, errors };
|
|
89
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from 'fs/promises';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
export const SSL_PILOT_DIR = '/etc/certops';
|
|
4
|
+
export const CERTS_DIR = join(SSL_PILOT_DIR, 'certs');
|
|
5
|
+
export const HOOKS_DIR = join(SSL_PILOT_DIR, 'hooks');
|
|
6
|
+
const CONFIG_PATH = join(SSL_PILOT_DIR, 'config.json');
|
|
7
|
+
const DEFAULTS = {
|
|
8
|
+
renewalThresholdDays: 5,
|
|
9
|
+
checkIntervalHours: 12,
|
|
10
|
+
watchDomains: [],
|
|
11
|
+
maxDownloadRetries: 3,
|
|
12
|
+
};
|
|
13
|
+
export async function readConfig() {
|
|
14
|
+
try {
|
|
15
|
+
const raw = await readFile(CONFIG_PATH, 'utf8');
|
|
16
|
+
return { ...DEFAULTS, ...JSON.parse(raw) };
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
if (err instanceof Error && err.code === 'ENOENT')
|
|
20
|
+
return { ...DEFAULTS };
|
|
21
|
+
throw err;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export async function writeConfig(config) {
|
|
25
|
+
await mkdir(SSL_PILOT_DIR, { recursive: true });
|
|
26
|
+
await writeFile(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n', {
|
|
27
|
+
encoding: 'utf8',
|
|
28
|
+
mode: 0o600,
|
|
29
|
+
});
|
|
30
|
+
}
|
package/dist/files.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { mkdir, writeFile, chmod } from 'fs/promises';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { CERTS_DIR } from './config.js';
|
|
4
|
+
function domainDir(certName) {
|
|
5
|
+
return join(CERTS_DIR, certName.replace(/^\*\./, ''));
|
|
6
|
+
}
|
|
7
|
+
export async function saveCert(certName, certPem, keyPem) {
|
|
8
|
+
const dir = domainDir(certName);
|
|
9
|
+
const certPath = join(dir, 'certificate.crt');
|
|
10
|
+
const keyPath = join(dir, 'private.key');
|
|
11
|
+
try {
|
|
12
|
+
await mkdir(dir, { recursive: true });
|
|
13
|
+
}
|
|
14
|
+
catch (err) {
|
|
15
|
+
fsError(err, certName, dir);
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
await writeFile(certPath, certPem, 'utf8');
|
|
19
|
+
await chmod(certPath, 0o644);
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
fsError(err, certName, certPath);
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
await writeFile(keyPath, keyPem, 'utf8');
|
|
26
|
+
await chmod(keyPath, 0o600);
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
fsError(err, certName, keyPath);
|
|
30
|
+
}
|
|
31
|
+
return { certPath, keyPath };
|
|
32
|
+
}
|
|
33
|
+
function fsError(err, certName, path) {
|
|
34
|
+
const e = err instanceof Error ? err : null;
|
|
35
|
+
if (e?.code === 'EACCES' || e?.code === 'EPERM') {
|
|
36
|
+
const name = certName.startsWith('*.') ? `'${certName}'` : certName;
|
|
37
|
+
process.stderr.write(`\nPermission denied: ${path}\n`);
|
|
38
|
+
process.stderr.write(`/etc/certops/certs requires root. Re-run:\n\n`);
|
|
39
|
+
process.stderr.write(` sudo sp download ${name}\n\n`);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
throw err;
|
|
43
|
+
}
|
package/dist/hooks.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { access } from 'fs/promises';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { HOOKS_DIR } from './config.js';
|
|
5
|
+
const GLOBAL_HOOK = join(HOOKS_DIR, 'global.sh');
|
|
6
|
+
function domainHookPath(certName) {
|
|
7
|
+
const safe = certName.replace(/^\*\./, 'wildcard.');
|
|
8
|
+
return join(HOOKS_DIR, `${safe}.sh`);
|
|
9
|
+
}
|
|
10
|
+
async function fileExists(path) {
|
|
11
|
+
try {
|
|
12
|
+
await access(path);
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
async function execHook(scriptPath, vars) {
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
const child = spawn('bash', [scriptPath], {
|
|
22
|
+
env: { ...process.env, ...vars },
|
|
23
|
+
stdio: 'inherit',
|
|
24
|
+
});
|
|
25
|
+
child.on('close', (code) => {
|
|
26
|
+
if (code === 0)
|
|
27
|
+
resolve();
|
|
28
|
+
else
|
|
29
|
+
reject(new Error(`Hook exited with code ${code}`));
|
|
30
|
+
});
|
|
31
|
+
child.on('error', reject);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
export async function runHooks(certName, vars, log) {
|
|
35
|
+
const domainHook = domainHookPath(certName);
|
|
36
|
+
const hookVars = { ...vars, SSL_PILOT_CERT_NAME: certName };
|
|
37
|
+
if (await fileExists(domainHook)) {
|
|
38
|
+
log(` Running domain hook: ${domainHook}`);
|
|
39
|
+
try {
|
|
40
|
+
await execHook(domainHook, hookVars);
|
|
41
|
+
log(' Domain hook OK');
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
log(` Domain hook failed: ${err.message}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (await fileExists(GLOBAL_HOOK)) {
|
|
48
|
+
log(` Running global hook: ${GLOBAL_HOOK}`);
|
|
49
|
+
try {
|
|
50
|
+
await execHook(GLOBAL_HOOK, hookVars);
|
|
51
|
+
log(' Global hook OK');
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
log(` Global hook failed: ${err.message}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export { domainHookPath, GLOBAL_HOOK };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { listCommand } from './commands/list.js';
|
|
4
|
+
import { downloadCommand } from './commands/download.js';
|
|
5
|
+
import { serviceCommand } from './commands/service/index.js';
|
|
6
|
+
const program = new Command()
|
|
7
|
+
.name('certops')
|
|
8
|
+
.description('CertOps CLI — manage and auto-renew your SSL certificates')
|
|
9
|
+
.version('1.0.0')
|
|
10
|
+
.addHelpText('after', `
|
|
11
|
+
Examples:
|
|
12
|
+
certops list List all certificates
|
|
13
|
+
sudo certops download Pick and download interactively
|
|
14
|
+
sudo certops download '*.example.com' Download a specific domain
|
|
15
|
+
sudo certops service install Set up the auto-renewal service
|
|
16
|
+
certops service status Check service status
|
|
17
|
+
|
|
18
|
+
Documentation:
|
|
19
|
+
https://github.com/Vimosoftdev/ssl-checker-web/blob/main/SETUP.md
|
|
20
|
+
`);
|
|
21
|
+
program.addCommand(listCommand);
|
|
22
|
+
program.addCommand(downloadCommand);
|
|
23
|
+
program.addCommand(serviceCommand);
|
|
24
|
+
program.action(() => program.outputHelp());
|
|
25
|
+
program.parse();
|
package/dist/state.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { readFile, writeFile, rename, mkdir } from 'fs/promises';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { randomBytes } from 'crypto';
|
|
4
|
+
import { SSL_PILOT_DIR } from './config.js';
|
|
5
|
+
const STATE_PATH = join(SSL_PILOT_DIR, 'state.json');
|
|
6
|
+
export async function readState() {
|
|
7
|
+
try {
|
|
8
|
+
const raw = await readFile(STATE_PATH, 'utf8');
|
|
9
|
+
return JSON.parse(raw);
|
|
10
|
+
}
|
|
11
|
+
catch (err) {
|
|
12
|
+
if (err instanceof Error && err.code === 'ENOENT')
|
|
13
|
+
return {};
|
|
14
|
+
throw err;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export async function updateCertState(certName, expiryDate) {
|
|
18
|
+
await mkdir(SSL_PILOT_DIR, { recursive: true });
|
|
19
|
+
const state = await readState();
|
|
20
|
+
state[certName] = { expiryDate, downloadedAt: new Date().toISOString() };
|
|
21
|
+
const content = JSON.stringify(state, null, 2) + '\n';
|
|
22
|
+
const tmp = join(SSL_PILOT_DIR, `.state-${randomBytes(4).toString('hex')}.tmp`);
|
|
23
|
+
await writeFile(tmp, content, { encoding: 'utf8', mode: 0o600 });
|
|
24
|
+
await rename(tmp, STATE_PATH);
|
|
25
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "certops",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "SSL Pilot CLI — download and manage your SSL certificates",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"files": [
|
|
7
|
+
"dist"
|
|
8
|
+
],
|
|
9
|
+
"bin": {
|
|
10
|
+
"certops": "./dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsc",
|
|
14
|
+
"dev": "tsx src/index.ts",
|
|
15
|
+
"typecheck": "tsc --noEmit"
|
|
16
|
+
},
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@inquirer/input": "^4.0.0",
|
|
22
|
+
"@inquirer/select": "^4.0.0",
|
|
23
|
+
"commander": "^12.0.0"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/node": "^20.0.0",
|
|
27
|
+
"tsx": "^4.7.1",
|
|
28
|
+
"typescript": "^5.8.3"
|
|
29
|
+
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=18"
|
|
32
|
+
}
|
|
33
|
+
}
|