certops 1.0.15 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/api.js CHANGED
@@ -1,3 +1,9 @@
1
+ const REQUEST_TIMEOUT_MS = 30_000;
2
+ function fetchWithTimeout(url, init) {
3
+ const controller = new AbortController();
4
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
5
+ return fetch(url, { ...init, signal: controller.signal }).finally(() => clearTimeout(timer));
6
+ }
1
7
  export function createApiClient(options) {
2
8
  const base = (options.apiUrl ?? 'https://ssl-manager.dcom.at').replace(/\/$/, '');
3
9
  const baseHeaders = {
@@ -20,7 +26,7 @@ export function createApiClient(options) {
20
26
  throw new Error(msg);
21
27
  }
22
28
  async function request(path) {
23
- const res = await fetch(`${base}/api/cli${path}`, {
29
+ const res = await fetchWithTimeout(`${base}/api/cli${path}`, {
24
30
  headers: { ...baseHeaders, 'Content-Type': 'application/json' },
25
31
  });
26
32
  const body = (await res.json());
@@ -31,7 +37,7 @@ export function createApiClient(options) {
31
37
  return body.data;
32
38
  }
33
39
  async function requestBinary(path) {
34
- const res = await fetch(`${base}/api/cli${path}`, {
40
+ const res = await fetchWithTimeout(`${base}/api/cli${path}`, {
35
41
  headers: baseHeaders,
36
42
  });
37
43
  if (!res.ok) {
@@ -3,10 +3,14 @@ import select from '@inquirer/select';
3
3
  import {} from '../api.js';
4
4
  import { getConfiguredClient } from '../client.js';
5
5
  import { saveZip } from '../files.js';
6
+ import { updateCertState } from '../state.js';
7
+ import { createLogger, logError } from '../logger.js';
8
+ const log = createLogger('download');
6
9
  async function performDownload(cert, client) {
7
10
  process.stdout.write(`\nDownloading ${cert.certName}…\n`);
8
11
  const zipBuffer = await client.downloadCert(cert._id);
9
12
  const paths = await saveZip(cert.certName, zipBuffer);
13
+ await updateCertState(cert.certName, cert.expiryDate ?? '');
10
14
  process.stdout.write(` ✓ Fullchain : ${paths.fullchainPath}\n`);
11
15
  process.stdout.write(` ✓ Certificate : ${paths.certPath}\n`);
12
16
  process.stdout.write(` ✓ Chain : ${paths.chainPath}\n`);
@@ -15,6 +19,8 @@ async function performDownload(cert, client) {
15
19
  process.stdout.write(` ✓ Expires : ${new Date(cert.expiryDate).toLocaleDateString()}\n`);
16
20
  }
17
21
  process.stdout.write('\n');
22
+ const expiry = cert.expiryDate ? ` expires ${new Date(cert.expiryDate).toLocaleDateString()}` : '';
23
+ log(`Downloaded ${cert.certName}${expiry} → ${paths.fullchainPath}`);
18
24
  }
19
25
  export const downloadCommand = new Command('download')
20
26
  .description('Download a certificate to /etc/certops/certs/<domain>/ — requires sudo')
@@ -81,7 +87,9 @@ Examples:
81
87
  catch (err) {
82
88
  if (err.name === 'ExitPromptError')
83
89
  process.exit(0);
84
- process.stderr.write(`\nError: ${err.message}\n\n`);
90
+ const msg = err.message;
91
+ process.stderr.write(`\nError: ${msg}\n\n`);
92
+ logError(msg);
85
93
  process.exit(1);
86
94
  }
87
95
  });
@@ -1,18 +1,19 @@
1
1
  import { Command } from 'commander';
2
2
  import input from '@inquirer/input';
3
3
  import { spawnSync } from 'child_process';
4
- import { readConfig, writeConfig } from '../../config.js';
4
+ import { readConfig, writeConfig, CERTOPS_DIR } from '../../config.js';
5
+ import { BRAND } from '../../brand.js';
5
6
  export const configureCommand = new Command('configure')
6
7
  .description('Update service config (watch domains, check interval) and restart')
7
8
  .action(async () => {
8
9
  try {
9
10
  if (process.getuid?.() !== 0) {
10
- console.error('Error: certops service configure must run as root.');
11
- console.error('\n sudo sp service configure\n');
11
+ console.error(`Error: ${BRAND.binName} service configure must run as root.`);
12
+ console.error(`\n sudo ${BRAND.binName} service configure\n`);
12
13
  process.exit(1);
13
14
  }
14
15
  const existing = await readConfig();
15
- console.log('\nCertOps — Update Service Config\n');
16
+ console.log(`\n${BRAND.displayName} — Update Service Config\n`);
16
17
  console.log(' (API key is stored in the systemd unit, not changed here)\n');
17
18
  const intervalStr = await input({
18
19
  message: 'Check interval (hours):',
@@ -44,8 +45,8 @@ export const configureCommand = new Command('configure')
44
45
  maxDownloadRetries: Number(retriesStr),
45
46
  watchDomains,
46
47
  });
47
- console.log('\n✓ Config updated at /etc/certops/config.json');
48
- const result = spawnSync('systemctl', ['restart', 'certops'], {
48
+ console.log(`\n✓ Config updated at ${CERTOPS_DIR}/config.json`);
49
+ const result = spawnSync('systemctl', ['restart', BRAND.serviceUnit], {
49
50
  stdio: ['inherit', 'inherit', 'pipe'],
50
51
  encoding: 'utf8',
51
52
  });
@@ -53,7 +54,7 @@ export const configureCommand = new Command('configure')
53
54
  if (result.stderr?.trim())
54
55
  process.stderr.write(result.stderr + '\n');
55
56
  process.stderr.write('\nWarning: config saved but service restart failed.\n');
56
- process.stderr.write(' Run: sudo sp service start\n\n');
57
+ process.stderr.write(` Run: sudo ${BRAND.binName} service start\n\n`);
57
58
  process.exit(1);
58
59
  }
59
60
  console.log('✓ Service restarted with new config.\n');
@@ -4,6 +4,7 @@ import { unlink } from 'fs/promises';
4
4
  import { resolveApiKey, runRenewalCycle } from './renew.js';
5
5
  import { BRAND } from '../../brand.js';
6
6
  import { CERTOPS_DIR } from '../../config.js';
7
+ import { createLogger } from '../../logger.js';
7
8
  const UNIT = BRAND.serviceUnit;
8
9
  const UNIT_PATH = `/etc/systemd/system/${BRAND.serviceUnit}.service`;
9
10
  function systemctl(args, exitOnFail = true) {
@@ -49,7 +50,7 @@ export const checkCommand = new Command('check')
49
50
  process.stderr.write(` Set the env var or run: sudo ${BRAND.binName} service install\n\n`);
50
51
  process.exit(1);
51
52
  }
52
- const log = (msg) => process.stdout.write(` ${msg}\n`);
53
+ const log = createLogger();
53
54
  process.stdout.write('\nRunning renewal check…\n\n');
54
55
  try {
55
56
  const result = await runRenewalCycle(apiKey, log);
@@ -2,9 +2,8 @@ import { Command } from 'commander';
2
2
  import { readConfig } from '../../config.js';
3
3
  import { resolveApiKey, runRenewalCycle } from './renew.js';
4
4
  import { BRAND } from '../../brand.js';
5
- function log(msg) {
6
- process.stdout.write(`[${new Date().toISOString()}] ${msg}\n`);
7
- }
5
+ import { createLogger } from '../../logger.js';
6
+ const log = createLogger();
8
7
  function sleep(ms) {
9
8
  return new Promise(resolve => setTimeout(resolve, ms));
10
9
  }
@@ -7,7 +7,13 @@ import { domainHookPath, GLOBAL_HOOK } from '../../hooks.js';
7
7
  import { BRAND } from '../../brand.js';
8
8
  const UNIT_PATH = `/etc/systemd/system/${BRAND.serviceUnit}.service`;
9
9
  const API_KEY_ENV = `${BRAND.envPrefix}_API_KEY`;
10
+ function resolveBinPath() {
11
+ // process.argv[1] is the actual path of the running certops script
12
+ return process.argv[1] ?? `/usr/local/bin/${BRAND.binName}`;
13
+ }
10
14
  function buildUnitContent(apiKey) {
15
+ const binPath = resolveBinPath();
16
+ const nodePath = process.execPath;
11
17
  return `\
12
18
  [Unit]
13
19
  Description=${BRAND.displayName} Certificate Monitor
@@ -17,7 +23,7 @@ Wants=network-online.target
17
23
  [Service]
18
24
  Type=simple
19
25
  Environment=${API_KEY_ENV}=${apiKey}
20
- ExecStart=/usr/local/bin/${BRAND.binName} service run
26
+ ExecStart=${nodePath} ${binPath} service run
21
27
  Restart=on-failure
22
28
  RestartSec=30
23
29
  StandardOutput=journal
@@ -57,10 +63,6 @@ export const installCommand = new Command('install')
57
63
  ? true
58
64
  : `Key must start with ${BRAND.keyPrefix}`,
59
65
  });
60
- const apiUrl = await input({
61
- message: `API URL (leave blank for default ${BRAND.apiUrl}):`,
62
- default: existing.apiUrl ?? '',
63
- });
64
66
  const intervalStr = await input({
65
67
  message: 'Check interval (hours):',
66
68
  default: String(existing.checkIntervalHours),
@@ -75,7 +77,7 @@ export const installCommand = new Command('install')
75
77
  .map(d => d.trim())
76
78
  .filter(Boolean);
77
79
  const config = {
78
- apiUrl: apiUrl.trim() || undefined,
80
+ apiUrl: existing.apiUrl,
79
81
  renewalThresholdDays: existing.renewalThresholdDays,
80
82
  checkIntervalHours: Number(intervalStr),
81
83
  watchDomains,
package/dist/config.js CHANGED
@@ -3,6 +3,8 @@ import { join } from 'path';
3
3
  import { BRAND } from './brand.js';
4
4
  export const CERTOPS_DIR = BRAND.configDir;
5
5
  export const HOOKS_DIR = join(CERTOPS_DIR, 'hooks');
6
+ export const LOG_DIR = `/var/log/${BRAND.name}`;
7
+ export const LOG_FILE = join(LOG_DIR, `${BRAND.name}.log`);
6
8
  const CONFIG_PATH = join(CERTOPS_DIR, 'config.json');
7
9
  const DEFAULTS = {
8
10
  renewalThresholdDays: 5,
package/dist/files.js CHANGED
@@ -2,6 +2,7 @@ import { mkdir, writeFile, chmod } from 'fs/promises';
2
2
  import { join } from 'path';
3
3
  import { unzipSync } from 'fflate';
4
4
  import { CERTOPS_DIR } from './config.js';
5
+ import { BRAND } from './brand.js';
5
6
  export async function saveZip(certName, zipBuffer) {
6
7
  const zipDirName = certName.replace(/^\*\./, 'wildcard.');
7
8
  const dir = join(CERTOPS_DIR, 'certs', zipDirName);
@@ -45,8 +46,8 @@ function fsError(err, certName, path) {
45
46
  const e = err instanceof Error ? err : null;
46
47
  if (e?.code === 'EACCES' || e?.code === 'EPERM') {
47
48
  process.stderr.write(`\nPermission denied: ${path}\n`);
48
- process.stderr.write(`/etc/certops requires root. Re-run:\n\n`);
49
- process.stderr.write(` sudo certops download '${certName}'\n\n`);
49
+ process.stderr.write(`${CERTOPS_DIR} requires root. Re-run:\n\n`);
50
+ process.stderr.write(` sudo ${BRAND.binName} download '${certName}'\n\n`);
50
51
  process.exit(1);
51
52
  }
52
53
  throw err;
package/dist/hooks.js CHANGED
@@ -17,19 +17,25 @@ async function fileExists(path) {
17
17
  return false;
18
18
  }
19
19
  }
20
+ const HOOK_TIMEOUT_MS = 60_000;
20
21
  async function execHook(scriptPath, vars) {
21
22
  return new Promise((resolve, reject) => {
22
23
  const child = spawn('bash', [scriptPath], {
23
24
  env: { ...process.env, ...vars },
24
25
  stdio: 'inherit',
25
26
  });
27
+ const timer = setTimeout(() => {
28
+ child.kill('SIGTERM');
29
+ reject(new Error(`Hook timed out after ${HOOK_TIMEOUT_MS / 1000}s`));
30
+ }, HOOK_TIMEOUT_MS);
26
31
  child.on('close', (code) => {
32
+ clearTimeout(timer);
27
33
  if (code === 0)
28
34
  resolve();
29
35
  else
30
36
  reject(new Error(`Hook exited with code ${code}`));
31
37
  });
32
- child.on('error', reject);
38
+ child.on('error', (err) => { clearTimeout(timer); reject(err); });
33
39
  });
34
40
  }
35
41
  export async function runHooks(certName, vars, log) {
package/dist/logger.js ADDED
@@ -0,0 +1,41 @@
1
+ import { appendFile, mkdir } from 'fs/promises';
2
+ import { LOG_DIR, LOG_FILE } from './config.js';
3
+ let logDirReady = false;
4
+ async function ensureLogDir() {
5
+ if (logDirReady)
6
+ return;
7
+ try {
8
+ await mkdir(LOG_DIR, { recursive: true });
9
+ logDirReady = true;
10
+ }
11
+ catch {
12
+ // silently fail — never crash the daemon over a logging error
13
+ }
14
+ }
15
+ async function writeToFile(line) {
16
+ await ensureLogDir();
17
+ if (!logDirReady)
18
+ return;
19
+ try {
20
+ await appendFile(LOG_FILE, line + '\n', { encoding: 'utf8', mode: 0o640 });
21
+ }
22
+ catch {
23
+ // silently fail
24
+ }
25
+ }
26
+ function timestamp() {
27
+ return new Date().toISOString();
28
+ }
29
+ export function createLogger(tag) {
30
+ const prefix = tag ? `[${tag}] ` : '';
31
+ return (msg) => {
32
+ const line = `[${timestamp()}] ${prefix}${msg}`;
33
+ process.stdout.write(line + '\n');
34
+ void writeToFile(line);
35
+ };
36
+ }
37
+ export function logError(msg) {
38
+ const line = `[${timestamp()}] [ERROR] ${msg}`;
39
+ process.stderr.write(line + '\n');
40
+ void writeToFile(line);
41
+ }
package/dist/version.js CHANGED
@@ -1,18 +1,19 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
- function isPackageJson(value) {
5
- return (typeof value === 'object' &&
6
- value !== null &&
7
- 'version' in value &&
8
- typeof value.version === 'string');
9
- }
10
4
  function readCliVersion() {
11
- const dir = dirname(fileURLToPath(import.meta.url));
12
- const path = join(dir, '..', 'package.json');
13
- const raw = JSON.parse(readFileSync(path, 'utf8'));
14
- if (!isPackageJson(raw))
15
- throw new Error('package.json is missing a valid version field');
16
- return raw.version;
5
+ // Injected at compile time by Bun build --define
6
+ if (process.env.CLI_VERSION)
7
+ return process.env.CLI_VERSION;
8
+ // JS mode (npm package with dist/): read from package.json
9
+ try {
10
+ const dir = dirname(fileURLToPath(import.meta.url));
11
+ const path = join(dir, '..', 'package.json');
12
+ const raw = JSON.parse(readFileSync(path, 'utf8'));
13
+ return raw.version;
14
+ }
15
+ catch {
16
+ return '0.0.0';
17
+ }
17
18
  }
18
19
  export const CLI_VERSION = readCliVersion();
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "certops",
3
- "version": "1.0.15",
3
+ "version": "1.1.0",
4
4
  "description": "SSL Pilot CLI — download and manage your SSL certificates",
5
5
  "type": "module",
6
6
  "files": [
7
- "dist"
7
+ "dist",
8
+ "scripts"
8
9
  ],
9
10
  "bin": {
10
11
  "certops": "./dist/index.js"
@@ -12,7 +13,9 @@
12
13
  "scripts": {
13
14
  "build": "tsc",
14
15
  "dev": "tsx src/index.ts",
15
- "typecheck": "tsc --noEmit"
16
+ "typecheck": "tsc --noEmit",
17
+ "postinstall": "node scripts/postinstall.cjs",
18
+ "preuninstall": "node scripts/preuninstall.cjs"
16
19
  },
17
20
  "publishConfig": {
18
21
  "access": "public"
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+ 'use strict'
3
+
4
+ const { createWriteStream, chmodSync, existsSync, unlinkSync } = require('fs')
5
+ const { get } = require('https')
6
+ const { join } = require('path')
7
+
8
+ const { name, version } = require('../package.json')
9
+ const REPO = 'Vimosoftdev/ssl-checker-web'
10
+ const BIN_NAME = 'certops'
11
+ const INSTALL_DIR = '/usr/local/bin'
12
+ const INSTALL_PATH = join(INSTALL_DIR, BIN_NAME)
13
+
14
+ const platformMap = { linux: 'linux', darwin: 'darwin' }
15
+ const archMap = { x64: 'x64', arm64: 'arm64' }
16
+ const plat = platformMap[process.platform]
17
+ const arc = archMap[process.arch]
18
+
19
+ if (!plat || !arc) {
20
+ console.warn(`[certops] Unsupported platform ${process.platform}-${process.arch}. Skipping binary install.`)
21
+ console.warn(`[certops] Install manually: https://github.com/${REPO}/releases/latest`)
22
+ process.exit(0)
23
+ }
24
+
25
+ const isRoot = typeof process.getuid === 'function' && process.getuid() === 0
26
+
27
+ if (!isRoot) {
28
+ console.warn(`[certops] Not running as root — binary not installed to ${INSTALL_PATH}.`)
29
+ console.warn(`[certops] To install system-wide: sudo npm install -g certops`)
30
+ console.warn(`[certops] Or: curl -fsSL https://github.com/${REPO}/releases/latest/download/install.sh | sudo bash`)
31
+ process.exit(0)
32
+ }
33
+
34
+ const assetName = `${BIN_NAME}-${plat}-${arc}`
35
+ const downloadUrl = `https://github.com/${REPO}/releases/download/v${version}/${assetName}`
36
+
37
+ console.log(`[certops] Installing v${version} binary for ${plat}-${arc}…`)
38
+ console.log(`[certops] Downloading ${downloadUrl}`)
39
+
40
+ downloadFile(downloadUrl, INSTALL_PATH)
41
+ .then(() => {
42
+ chmodSync(INSTALL_PATH, 0o755)
43
+ console.log(`[certops] ✓ Installed to ${INSTALL_PATH}`)
44
+ })
45
+ .catch(err => {
46
+ // clean up partial download
47
+ try { if (existsSync(INSTALL_PATH)) unlinkSync(INSTALL_PATH) } catch {}
48
+ console.error(`[certops] Binary install failed: ${err.message}`)
49
+ console.error(`[certops] Fallback: curl -fsSL https://github.com/${REPO}/releases/latest/download/install.sh | sudo bash`)
50
+ // non-fatal — npm install still succeeds
51
+ process.exit(0)
52
+ })
53
+
54
+ function downloadFile(url, dest) {
55
+ return new Promise((resolve, reject) => {
56
+ function fetch(url) {
57
+ get(url, res => {
58
+ if (res.statusCode === 301 || res.statusCode === 302) {
59
+ return fetch(res.headers.location)
60
+ }
61
+ if (res.statusCode !== 200) {
62
+ return reject(new Error(`HTTP ${res.statusCode} from ${url}`))
63
+ }
64
+ const file = createWriteStream(dest)
65
+ res.pipe(file)
66
+ file.on('finish', () => file.close(resolve))
67
+ file.on('error', err => { unlinkSync(dest); reject(err) })
68
+ }).on('error', reject)
69
+ }
70
+ fetch(url)
71
+ })
72
+ }
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+ 'use strict'
3
+
4
+ const { unlinkSync, existsSync } = require('fs')
5
+
6
+ const INSTALL_PATH = '/usr/local/bin/certops'
7
+
8
+ try {
9
+ if (existsSync(INSTALL_PATH)) {
10
+ unlinkSync(INSTALL_PATH)
11
+ console.log(`[certops] ✓ Removed binary from ${INSTALL_PATH}`)
12
+ }
13
+ } catch (err) {
14
+ console.warn(`[certops] Could not remove ${INSTALL_PATH}: ${err.message}`)
15
+ }