ninedeploy 0.10.5 → 0.16.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.
Files changed (149) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +1 -0
  3. package/dist/client.d.ts +34 -0
  4. package/dist/client.d.ts.map +1 -1
  5. package/dist/client.js +81 -22
  6. package/dist/client.js.map +1 -1
  7. package/dist/commands/backupPolicy.d.ts +29 -0
  8. package/dist/commands/backupPolicy.d.ts.map +1 -0
  9. package/dist/commands/backupPolicy.js +111 -0
  10. package/dist/commands/backupPolicy.js.map +1 -0
  11. package/dist/commands/certificates.d.ts +18 -7
  12. package/dist/commands/certificates.d.ts.map +1 -1
  13. package/dist/commands/certificates.js +121 -1
  14. package/dist/commands/certificates.js.map +1 -1
  15. package/dist/commands/databaseAccess.d.ts +37 -0
  16. package/dist/commands/databaseAccess.d.ts.map +1 -0
  17. package/dist/commands/databaseAccess.js +306 -0
  18. package/dist/commands/databaseAccess.js.map +1 -0
  19. package/dist/commands/deploy.d.ts.map +1 -1
  20. package/dist/commands/deploy.js +28 -7
  21. package/dist/commands/deploy.js.map +1 -1
  22. package/dist/commands/githubApps.d.ts +40 -0
  23. package/dist/commands/githubApps.d.ts.map +1 -0
  24. package/dist/commands/githubApps.js +278 -0
  25. package/dist/commands/githubApps.js.map +1 -0
  26. package/dist/commands/housekeeping.d.ts +7 -0
  27. package/dist/commands/housekeeping.d.ts.map +1 -1
  28. package/dist/commands/housekeeping.js +16 -0
  29. package/dist/commands/housekeeping.js.map +1 -1
  30. package/dist/commands/logs.d.ts.map +1 -1
  31. package/dist/commands/logs.js +17 -2
  32. package/dist/commands/logs.js.map +1 -1
  33. package/dist/commands/manage.d.ts +26 -10
  34. package/dist/commands/manage.d.ts.map +1 -1
  35. package/dist/commands/manage.js +219 -65
  36. package/dist/commands/manage.js.map +1 -1
  37. package/dist/commands/manifest.d.ts.map +1 -1
  38. package/dist/commands/manifest.js +9 -1
  39. package/dist/commands/manifest.js.map +1 -1
  40. package/dist/commands/misc.d.ts +4 -2
  41. package/dist/commands/misc.d.ts.map +1 -1
  42. package/dist/commands/misc.js +25 -11
  43. package/dist/commands/misc.js.map +1 -1
  44. package/dist/commands/multiNode.d.ts +55 -0
  45. package/dist/commands/multiNode.d.ts.map +1 -0
  46. package/dist/commands/multiNode.js +438 -0
  47. package/dist/commands/multiNode.js.map +1 -0
  48. package/dist/commands/notifications.d.ts +1 -1
  49. package/dist/commands/operations.d.ts +89 -0
  50. package/dist/commands/operations.d.ts.map +1 -0
  51. package/dist/commands/operations.js +468 -0
  52. package/dist/commands/operations.js.map +1 -0
  53. package/dist/commands/panelBackup.d.ts +28 -0
  54. package/dist/commands/panelBackup.d.ts.map +1 -0
  55. package/dist/commands/panelBackup.js +153 -0
  56. package/dist/commands/panelBackup.js.map +1 -0
  57. package/dist/commands/plugins.d.ts +1 -1
  58. package/dist/commands/plugins.d.ts.map +1 -1
  59. package/dist/commands/plugins.js +7 -3
  60. package/dist/commands/plugins.js.map +1 -1
  61. package/dist/commands/proxyConfig.d.ts +16 -0
  62. package/dist/commands/proxyConfig.d.ts.map +1 -0
  63. package/dist/commands/proxyConfig.js +122 -0
  64. package/dist/commands/proxyConfig.js.map +1 -0
  65. package/dist/commands/secretProviders.d.ts +59 -0
  66. package/dist/commands/secretProviders.d.ts.map +1 -0
  67. package/dist/commands/secretProviders.js +212 -0
  68. package/dist/commands/secretProviders.js.map +1 -0
  69. package/dist/commands/services.d.ts.map +1 -1
  70. package/dist/commands/services.js +3 -5
  71. package/dist/commands/services.js.map +1 -1
  72. package/dist/commands/setup.d.ts +3 -1
  73. package/dist/commands/setup.d.ts.map +1 -1
  74. package/dist/commands/setup.js +19 -3
  75. package/dist/commands/setup.js.map +1 -1
  76. package/dist/commands/sources.d.ts +10 -3
  77. package/dist/commands/sources.d.ts.map +1 -1
  78. package/dist/commands/sources.js +116 -18
  79. package/dist/commands/sources.js.map +1 -1
  80. package/dist/commands/templates.d.ts.map +1 -1
  81. package/dist/commands/templates.js +8 -1
  82. package/dist/commands/templates.js.map +1 -1
  83. package/dist/config.d.ts.map +1 -1
  84. package/dist/config.js +2 -1
  85. package/dist/config.js.map +1 -1
  86. package/dist/index.js +427 -23
  87. package/dist/index.js.map +1 -1
  88. package/dist/lib/format.d.ts.map +1 -1
  89. package/dist/lib/format.js +5 -4
  90. package/dist/lib/format.js.map +1 -1
  91. package/dist/lib/panelBackupFile.d.ts +3 -0
  92. package/dist/lib/panelBackupFile.d.ts.map +1 -0
  93. package/dist/lib/panelBackupFile.js +71 -0
  94. package/dist/lib/panelBackupFile.js.map +1 -0
  95. package/dist/lib/serverRunner.d.ts +6 -0
  96. package/dist/lib/serverRunner.d.ts.map +1 -1
  97. package/dist/lib/serverRunner.js +18 -3
  98. package/dist/lib/serverRunner.js.map +1 -1
  99. package/dist/prompts.d.ts.map +1 -1
  100. package/dist/prompts.js +9 -2
  101. package/dist/prompts.js.map +1 -1
  102. package/package.json +6 -5
  103. package/src/client.ts +137 -0
  104. package/src/commands/backupPolicy.ts +123 -0
  105. package/src/commands/branding.ts +83 -0
  106. package/src/commands/buildCache.ts +72 -0
  107. package/src/commands/certificates.ts +208 -0
  108. package/src/commands/communityTemplates.ts +97 -0
  109. package/src/commands/configCenter.ts +71 -0
  110. package/src/commands/configPresets.ts +215 -0
  111. package/src/commands/databaseAccess.ts +333 -0
  112. package/src/commands/demo.ts +22 -0
  113. package/src/commands/deploy.ts +284 -0
  114. package/src/commands/doctor.ts +170 -0
  115. package/src/commands/domains.ts +170 -0
  116. package/src/commands/egress.ts +135 -0
  117. package/src/commands/emailTemplates.ts +112 -0
  118. package/src/commands/firewall.ts +82 -0
  119. package/src/commands/githubApps.ts +292 -0
  120. package/src/commands/housekeeping.ts +106 -0
  121. package/src/commands/login.ts +58 -0
  122. package/src/commands/logs.ts +91 -0
  123. package/src/commands/manage.ts +714 -0
  124. package/src/commands/manifest.ts +314 -0
  125. package/src/commands/metrics.ts +69 -0
  126. package/src/commands/misc.ts +307 -0
  127. package/src/commands/multiNode.ts +442 -0
  128. package/src/commands/notifications.ts +174 -0
  129. package/src/commands/operations.ts +519 -0
  130. package/src/commands/panelBackup.ts +167 -0
  131. package/src/commands/pgbouncer.ts +65 -0
  132. package/src/commands/plugins.ts +123 -0
  133. package/src/commands/proxyConfig.ts +117 -0
  134. package/src/commands/secretProviders.ts +228 -0
  135. package/src/commands/server.ts +126 -0
  136. package/src/commands/services.ts +249 -0
  137. package/src/commands/setup.ts +94 -0
  138. package/src/commands/sources.ts +317 -0
  139. package/src/commands/sso.ts +106 -0
  140. package/src/commands/templates.ts +158 -0
  141. package/src/commands/token.ts +66 -0
  142. package/src/commands/webhooks.ts +93 -0
  143. package/src/commands/workspaces.ts +63 -0
  144. package/src/config.ts +53 -0
  145. package/src/index.ts +1273 -0
  146. package/src/lib/format.ts +182 -0
  147. package/src/lib/panelBackupFile.ts +67 -0
  148. package/src/lib/serverRunner.ts +223 -0
  149. package/src/prompts.ts +238 -0
package/src/client.ts ADDED
@@ -0,0 +1,137 @@
1
+ import { createClient, type NineDeployClient } from '@ninedeploy/sdk';
2
+ import { loadConfig, saveConfig } from './config.js';
3
+
4
+ export type { NineDeployClient };
5
+
6
+ /**
7
+ * In-flight dedup: when several commands race 401s (batch scripts), the
8
+ * refresh must happen ONCE — replaying the same refresh token twice would
9
+ * revoke it server-side and log the session out.
10
+ */
11
+ let refreshInflight: Promise<boolean> | null = null;
12
+
13
+ /**
14
+ * Endpoints that exchange credentials for tokens (or run before a session
15
+ * exists). A 401 there is a real answer — never refresh-and-retry it.
16
+ * r193: every `/auth/` path used to be skipped, so the authenticated ones
17
+ * (`/auth/me`, `/auth/tokens`, `/auth/sessions`, 2FA, passkey registration)
18
+ * failed permanently once the 15-minute access token expired.
19
+ * Keep byte-identical to apps/web/src/lib/api.ts — the web copy also exempts
20
+ * `oidc/<slug>/link` (an authenticated route); this copy had drifted and
21
+ * would have re-inherited the r193 failure the moment a CLI command wired
22
+ * auth.oidc.link().
23
+ */
24
+ export const NO_REFRESH_PATH =
25
+ /\/(?:v1\/setup|v1\/auth\/(?:login|refresh|register|logout|forgot-password|reset-password|status|oidc\/(?!providers(?:\/|$|\?)|[^/]+\/link(?:$|\?))|passkey\/login\/))/;
26
+
27
+ /**
28
+ * r550: join the configured base URL and an API path by string concatenation.
29
+ * `new URL('/v1/…', base)` resolves the path against the ORIGIN, silently
30
+ * dropping a sub-path prefix (`https://host/ninedeploy` → `https://host/v1/…`)
31
+ * — a panel published behind a path prefix answered every raw call with the
32
+ * front proxy's 404. The SDK has always concatenated; this keeps the raw
33
+ * paths (exports, imports, the log WebSocket) in line with it.
34
+ */
35
+ export function apiUrl(path: string, baseUrl: string = loadConfig().baseUrl): string {
36
+ return `${baseUrl.replace(/\/+$/, '')}${path}`;
37
+ }
38
+
39
+ async function refreshSession(baseUrl: string, refreshToken: string): Promise<boolean> {
40
+ try {
41
+ const res = await fetch(apiUrl('/v1/auth/refresh', baseUrl), {
42
+ method: 'POST',
43
+ headers: { 'Content-Type': 'application/json' },
44
+ body: JSON.stringify({ refreshToken }),
45
+ signal: AbortSignal.timeout(15_000),
46
+ });
47
+ if (!res.ok) return false;
48
+ const data = (await res.json()) as { tokens?: { accessToken?: string; refreshToken?: string } };
49
+ const tokens = data.tokens;
50
+ if (!tokens?.accessToken || !tokens.refreshToken) return false;
51
+ saveConfig({ ...loadConfig(), token: tokens.accessToken, refreshToken: tokens.refreshToken });
52
+ return true;
53
+ } catch {
54
+ return false;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Mint a fresh access token from the saved refresh token (single-flight,
60
+ * persisted). Resolves false when there is no refresh token or the server
61
+ * refused it — the caller then surfaces its original 401.
62
+ */
63
+ export async function refreshAccessToken(baseUrl: string = loadConfig().baseUrl): Promise<boolean> {
64
+ const refreshToken = loadConfig().refreshToken;
65
+ if (!refreshToken) return false;
66
+ refreshInflight ??= refreshSession(baseUrl, refreshToken).finally(() => {
67
+ refreshInflight = null;
68
+ });
69
+ return refreshInflight;
70
+ }
71
+
72
+ /** fetch() with the dashboard's 401 → refresh → retry-once semantics. */
73
+ async function fetchWithRefresh(baseUrl: string, input: string, init?: RequestInit): Promise<Response> {
74
+ const res = await fetch(input, init);
75
+ if (res.status !== 401) return res;
76
+ if (NO_REFRESH_PATH.test(input)) return res;
77
+ if (!(await refreshAccessToken(baseUrl))) return res;
78
+ const headers = new Headers(init?.headers);
79
+ const fresh = loadConfig().token;
80
+ if (fresh) headers.set('Authorization', `Bearer ${fresh}`);
81
+ return fetch(input, { ...init, headers });
82
+ }
83
+
84
+ /**
85
+ * r550: authenticated raw fetch for the endpoints the SDK cannot model
86
+ * (binary exports, the octet-stream import). These used to call fetch()
87
+ * directly with the saved bearer and no refresh: once the 15-minute access
88
+ * token expired, `system export` / `system import` / `services export` failed
89
+ * with a bare 401 while every SDK-backed command kept working. Same base-URL
90
+ * join and refresh-and-retry as getClient().
91
+ */
92
+ export async function authedFetch(path: string, init: RequestInit = {}): Promise<Response> {
93
+ const cfg = loadConfig();
94
+ const headers = new Headers(init.headers);
95
+ if (cfg.token) headers.set('Authorization', `Bearer ${cfg.token}`);
96
+ return fetchWithRefresh(cfg.baseUrl, apiUrl(path, cfg.baseUrl), { ...init, headers });
97
+ }
98
+
99
+ /**
100
+ * True when `token` is a JWT whose `exp` falls within `skewMs` from now.
101
+ * Opaque API tokens (and anything undecodable) report false — they carry no
102
+ * client-visible expiry, so the server's answer is the only authority.
103
+ */
104
+ export function tokenExpiresSoon(token: string | undefined, skewMs = 60_000, now = Date.now()): boolean {
105
+ if (!token) return false;
106
+ const part = token.split('.')[1];
107
+ if (!part) return false;
108
+ try {
109
+ const payload = JSON.parse(Buffer.from(part, 'base64url').toString('utf8')) as { exp?: unknown };
110
+ return typeof payload.exp === 'number' && payload.exp * 1000 - skewMs <= now;
111
+ } catch {
112
+ return false;
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Build an SDK client configured from the saved CLI config.
118
+ *
119
+ * The 401 wrapper mirrors the dashboard's `fetchWithRefresh`: the server's
120
+ * access token lives only ~15 minutes (NINEDEPLOY_JWT_ACCESS_TTL) and the
121
+ * CLI used to persist ONLY that token — every scripted/CI session died a
122
+ * quarter hour after login. On a 401 from any non-auth endpoint the saved
123
+ * refresh token mints a fresh pair (single-flight, persisted) and the request
124
+ * retries once with the new bearer. Auth endpoints manage their own tokens
125
+ * and must not loop.
126
+ */
127
+ export function getClient(): NineDeployClient {
128
+ const cfg = loadConfig();
129
+ return createClient({
130
+ baseUrl: cfg.baseUrl,
131
+ getToken: () => loadConfig().token,
132
+ // Volume-file uploads and log tailing can be slow; still bounded so a
133
+ // stalled backend cannot hang a CI runner forever.
134
+ timeoutMs: 120_000,
135
+ fetch: (input, init) => fetchWithRefresh(cfg.baseUrl, input, init),
136
+ });
137
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * `ninedeploy databases backup-policy get|set <dbId>` — 0.12 per-database
3
+ * backup policy: cron schedule, retention and destination. A database with
4
+ * no saved policy is on the built-in schedule (daily, 7 kept, active
5
+ * destination).
6
+ */
7
+ import type { BackupPolicy, BackupPolicyInput } from '@ninedeploy/sdk';
8
+ import type { NineDeployClient } from '../client.js';
9
+ import { c, error, header, info, spinner, success } from '../lib/format.js';
10
+
11
+ /** Canonical decimal ids only — the server's `parseId` rule (F537). */
12
+ const CANONICAL_ID = /^[1-9]\d*$/;
13
+ const num = (v: string, usage: string): number => {
14
+ const n = CANONICAL_ID.test(v) ? Number(v) : Number.NaN;
15
+ if (!Number.isSafeInteger(n)) {
16
+ error(usage);
17
+ throw new Error(usage);
18
+ }
19
+ return n;
20
+ };
21
+
22
+ /** The presets the web card offers. */
23
+ export const BACKUP_CRON_PRESETS: Record<string, string> = {
24
+ daily: '0 3 * * *',
25
+ '6h': '0 */6 * * *',
26
+ weekly: '0 3 * * 0',
27
+ };
28
+
29
+ function printPolicy(p: BackupPolicy): void {
30
+ header('Backup policy');
31
+ if (!p.configured) {
32
+ info(`Schedule: built-in (daily, ${p.retainCount} kept, active destination)`);
33
+ info(c.dim('No policy saved — `ninedeploy databases backup-policy set` creates one.'));
34
+ return;
35
+ }
36
+ info(`Enabled: ${p.enabled ? 'yes' : c.yellow('no — no scheduled backups')}`);
37
+ info(`Cron: ${p.cron}`);
38
+ info(`Keep: ${p.retainCount} local`);
39
+ info(`Remote keep: ${p.localOnly ? '—' : (p.retainRemoteCount ?? `same as local (${p.retainCount})`)}`);
40
+ info(`Destination: ${p.localOnly ? 'local only' : p.destinationId != null ? `#${p.destinationId}` : 'active destination'}`);
41
+ if (p.nextRunAt) info(`Next run: ${p.nextRunAt}`);
42
+ }
43
+
44
+ /** `ninedeploy databases backup-policy get <dbId>` */
45
+ export async function backupPolicyGet(client: NineDeployClient, idStr: string): Promise<void> {
46
+ const id = num(idStr, 'Usage: ninedeploy databases backup-policy get <dbId>');
47
+ try {
48
+ printPolicy(await spinner('Reading backup policy', () => client.backups.getPolicy(id)));
49
+ } catch (err) {
50
+ error(err instanceof Error ? err.message : String(err));
51
+ }
52
+ }
53
+
54
+ export interface BackupPolicySetOpts {
55
+ cron?: string;
56
+ preset?: string;
57
+ keep?: string;
58
+ keepRemote?: string;
59
+ destination?: string;
60
+ enable?: boolean;
61
+ disable?: boolean;
62
+ }
63
+
64
+ const intOpt = (v: string | undefined, flag: string): number | undefined => {
65
+ if (v === undefined) return undefined;
66
+ if (!/^\d+$/.test(v)) throw new Error(`${flag} must be a whole number`);
67
+ return Number(v);
68
+ };
69
+
70
+ /**
71
+ * Build the PUT body: the current policy (or the built-in defaults) with the
72
+ * given flags applied — PUT replaces, so unset flags keep today's values.
73
+ */
74
+ export function mergePolicyInput(current: BackupPolicy, opts: BackupPolicySetOpts): BackupPolicyInput {
75
+ if (opts.enable && opts.disable) throw new Error('Use either --enable or --disable, not both');
76
+ if (opts.cron && opts.preset) throw new Error('Use either --cron or --preset, not both');
77
+ let cron = opts.cron ?? current.cron ?? undefined;
78
+ if (opts.preset) {
79
+ cron = BACKUP_CRON_PRESETS[opts.preset];
80
+ if (!cron) throw new Error(`--preset must be one of: ${Object.keys(BACKUP_CRON_PRESETS).join(', ')}`);
81
+ }
82
+ if (!cron) throw new Error('No schedule yet: pass --cron "<5-field expression>" or --preset daily|6h|weekly');
83
+ let destinationId = current.destinationId;
84
+ let localOnly = current.localOnly;
85
+ if (opts.destination !== undefined) {
86
+ const d = opts.destination.trim().toLowerCase();
87
+ if (d === 'local') {
88
+ localOnly = true;
89
+ destinationId = null;
90
+ } else if (d === 'active' || d === 'default') {
91
+ localOnly = false;
92
+ destinationId = null;
93
+ } else if (CANONICAL_ID.test(d)) {
94
+ localOnly = false;
95
+ destinationId = Number(d);
96
+ } else {
97
+ throw new Error('--destination must be a destination id, "active" or "local"');
98
+ }
99
+ }
100
+ const keepRemote = intOpt(opts.keepRemote, '--keep-remote');
101
+ return {
102
+ enabled: opts.disable ? false : opts.enable ? true : current.enabled,
103
+ cron,
104
+ retainCount: intOpt(opts.keep, '--keep') ?? current.retainCount,
105
+ retainRemoteCount: localOnly ? null : (keepRemote ?? current.retainRemoteCount),
106
+ destinationId,
107
+ localOnly,
108
+ };
109
+ }
110
+
111
+ /** `ninedeploy databases backup-policy set <dbId> [--cron|--preset] [--keep] [--keep-remote] [--destination] [--enable|--disable]` */
112
+ export async function backupPolicySet(client: NineDeployClient, idStr: string, opts: BackupPolicySetOpts): Promise<void> {
113
+ const id = num(idStr, 'Usage: ninedeploy databases backup-policy set <dbId> [options]');
114
+ try {
115
+ const current = await client.backups.getPolicy(id);
116
+ const input = mergePolicyInput(current, opts);
117
+ const saved = await spinner('Saving backup policy', () => client.backups.setPolicy(id, input));
118
+ success('Backup policy saved.');
119
+ printPolicy(saved);
120
+ } catch (err) {
121
+ error(err instanceof Error ? err.message : String(err));
122
+ }
123
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * `ninedeploy branding {get,set}` — Sprint 4, Gap G-30.
3
+ *
4
+ * Operator-side wrapper around the four branding fields the panel
5
+ * can override: `logoUrl`, `primaryColor`, `supportEmail`,
6
+ * `footerHtml`. The CLI is the canonical way to set them when
7
+ * provisioning a fresh instance (the panel is the read path; the
8
+ * CLI is the write path operators actually use at the terminal).
9
+ */
10
+ import type { NineDeployClient } from '../client.js';
11
+ import { error, header, info, success } from '../lib/format.js';
12
+
13
+ export interface BrandingStatus {
14
+ logoUrl: string | null;
15
+ primaryColor: string | null;
16
+ supportEmail: string | null;
17
+ footerHtml: string | null;
18
+ }
19
+
20
+ export interface BrandingSetOptions {
21
+ logoUrl?: string;
22
+ primaryColor?: string;
23
+ supportEmail?: string;
24
+ footerHtml?: string;
25
+ }
26
+
27
+ // ── `ninedeploy branding get` ─────────────────────────────────────────────
28
+
29
+ /** Pure entry point — same shape used by the CLI action and the unit test. */
30
+ export async function brandingGet(client: NineDeployClient): Promise<BrandingStatus> {
31
+ return await client.branding.get();
32
+ }
33
+
34
+ export async function brandingGetAction(client: NineDeployClient): Promise<void> {
35
+ header('Branding');
36
+ let result: BrandingStatus;
37
+ try {
38
+ result = await brandingGet(client);
39
+ } catch (err) {
40
+ error(err instanceof Error ? err.message : String(err));
41
+ process.exitCode = 1;
42
+ return;
43
+ }
44
+ info(`logoUrl: ${result.logoUrl ?? '(default)'}`);
45
+ info(`primaryColor: ${result.primaryColor ?? '(default)'}`);
46
+ info(`supportEmail: ${result.supportEmail ?? '(default)'}`);
47
+ info(`footerHtml: ${result.footerHtml ? `${result.footerHtml.length} chars` : '(default)'}`);
48
+ }
49
+
50
+ // ── `ninedeploy branding set` ─────────────────────────────────────────────
51
+
52
+ /** Pure entry point — same shape used by the CLI action and the unit test. */
53
+ export async function brandingSet(
54
+ client: NineDeployClient,
55
+ opts: BrandingSetOptions = {},
56
+ ): Promise<{ ok: boolean }> {
57
+ if (
58
+ opts.logoUrl === undefined &&
59
+ opts.primaryColor === undefined &&
60
+ opts.supportEmail === undefined &&
61
+ opts.footerHtml === undefined
62
+ ) {
63
+ throw new Error('At least one of --logo-url, --primary-color, --support-email, --footer-html is required');
64
+ }
65
+ return await client.branding.set(opts);
66
+ }
67
+
68
+ export async function brandingSetAction(
69
+ client: NineDeployClient,
70
+ opts: BrandingSetOptions = {},
71
+ ): Promise<void> {
72
+ header('Branding set');
73
+ let result: Awaited<ReturnType<typeof brandingSet>>;
74
+ try {
75
+ result = await brandingSet(client, opts);
76
+ } catch (err) {
77
+ error(err instanceof Error ? err.message : String(err));
78
+ process.exitCode = 1;
79
+ return;
80
+ }
81
+ success(`Branding updated (${Object.keys(opts).filter((k) => opts[k as keyof BrandingSetOptions] !== undefined).join(', ')})`);
82
+ void result;
83
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * `ninedeploy build-cache stats` — Sprint 3, Gap G-01, PR-A.
3
+ *
4
+ * Operator-side view of the build cache plugin. The plugin is the
5
+ * source of truth (it owns the schema and the cache registration);
6
+ * this module is just a thin CLI surface that calls the same HTTP
7
+ * endpoint the panel will use, so the values an operator sees here
8
+ * are the same values the deploy pipeline will read.
9
+ */
10
+ import type { NineDeployClient } from '../client.js';
11
+ import { error, header, info, success } from '../lib/format.js';
12
+
13
+ export interface BuildCacheBackendStats {
14
+ name: string;
15
+ entries: number;
16
+ totalBytes: number;
17
+ hits: number;
18
+ misses: number;
19
+ stores: number;
20
+ evictions: number;
21
+ }
22
+
23
+ export interface BuildCacheStats {
24
+ backends: BuildCacheBackendStats[];
25
+ totals: Omit<BuildCacheBackendStats, 'name'>;
26
+ }
27
+
28
+ // ── `ninedeploy build-cache stats` ────────────────────────────────────────
29
+
30
+ /** Pure entry point — same shape used by the CLI action and the unit test. */
31
+ export async function buildCacheStats(client: NineDeployClient): Promise<BuildCacheStats> {
32
+ return await client.buildCache.stats();
33
+ }
34
+
35
+ export async function buildCacheStatsAction(client: NineDeployClient): Promise<void> {
36
+ header('Build cache stats');
37
+ let result: BuildCacheStats;
38
+ try {
39
+ result = await buildCacheStats(client);
40
+ } catch (err) {
41
+ error(err instanceof Error ? err.message : String(err));
42
+ process.exitCode = 1;
43
+ return;
44
+ }
45
+ if (result.backends.length === 0) {
46
+ info('No build-cache backends are registered. PR-A ships the inline driver; install one via Settings → Plugins.');
47
+ return;
48
+ }
49
+ for (const b of result.backends) {
50
+ info(`Backend: ${b.name}`);
51
+ info(` entries: ${b.entries}`);
52
+ info(` bytes: ${b.totalBytes}`);
53
+ info(` hits: ${b.hits}`);
54
+ info(` misses: ${b.misses}`);
55
+ info(` stores: ${b.stores}`);
56
+ info(` evictions: ${b.evictions}`);
57
+ }
58
+ info('Totals:');
59
+ info(` entries: ${result.totals.entries}`);
60
+ info(` bytes: ${result.totals.totalBytes}`);
61
+ info(` hits: ${result.totals.hits}`);
62
+ info(` misses: ${result.totals.misses}`);
63
+ info(` stores: ${result.totals.stores}`);
64
+ info(` evictions: ${result.totals.evictions}`);
65
+ success(`Done. Hit rate: ${hitRate(result.totals)}%`);
66
+ }
67
+
68
+ function hitRate(t: { hits: number; misses: number }): string {
69
+ const total = t.hits + t.misses;
70
+ if (total === 0) return '0.0';
71
+ return ((t.hits / total) * 100).toFixed(1);
72
+ }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * `ninedeploy certificates {list,expiring}` — G-15 cert
3
+ * inventory CLI. The server-side surface is in
4
+ * `lib/certificateInventory.ts`; the CLI is a thin
5
+ * renderer around `client.traefik.{certificateInventory,
6
+ * expiringCertificates}`.
7
+ */
8
+ import { readFileSync } from 'node:fs';
9
+ import type { CustomCertificateSaved } from '@ninedeploy/sdk';
10
+ import type { NineDeployClient } from '../client.js';
11
+ import { prompt } from '../prompts.js';
12
+ import { c, error, header, info, kv, spinner, success, table } from '../lib/format.js';
13
+ import { plain } from './sources.js';
14
+
15
+ const num = (v: string, usage: string): number => {
16
+ const n = Number(v);
17
+ if (Number.isNaN(n)) {
18
+ error(usage);
19
+ throw new Error(usage);
20
+ }
21
+ return n;
22
+ };
23
+
24
+ const STATUS_COLOR: Record<string, (s: string) => string> = {
25
+ valid: c.green,
26
+ 'expiring-soon': c.yellow,
27
+ expired: c.red,
28
+ unknown: c.dim,
29
+ };
30
+
31
+ export async function certificatesList(
32
+ client: NineDeployClient,
33
+ opts: { threshold?: string } = {},
34
+ ): Promise<void> {
35
+ const threshold = opts.threshold ? num(opts.threshold, 'Usage: --threshold <days>') : 30;
36
+ const report = await spinner('Reading inventory', () =>
37
+ client.traefik.certificateInventory({ threshold }),
38
+ );
39
+ header('Certificate inventory');
40
+ info(`Total: ${report.summary.total}`);
41
+ info(`Valid: ${c.green(String(report.summary.valid))}`);
42
+ info(`Expiring: ${c.yellow(String(report.summary.expiringSoon))} (within ${report.summary.expiringThresholdDays}d)`);
43
+ if (report.summary.expired > 0) {
44
+ info(`Expired: ${c.red(String(report.summary.expired))}`);
45
+ }
46
+ info(`Fetched: ${new Date(report.summary.fetchedAt).toLocaleString()}`);
47
+ if (report.certificates.length === 0) {
48
+ info('No certificates registered yet.');
49
+ return;
50
+ }
51
+ console.log();
52
+ table(
53
+ report.certificates.map((cert) => ({
54
+ host: cert.host,
55
+ status: (STATUS_COLOR[cert.status] ?? c.dim)(cert.status),
56
+ daysToExpiry: cert.daysToExpiry != null ? String(cert.daysToExpiry) : c.dim('—'),
57
+ expiresAt: cert.notAfter ? new Date(cert.notAfter).toLocaleString() : c.dim('—'),
58
+ autoRenew: cert.autoRenew ? 'yes' : c.dim('no'),
59
+ })),
60
+ ['host', 'status', 'daysToExpiry', 'expiresAt', 'autoRenew'],
61
+ );
62
+ }
63
+
64
+ export async function certificatesExpiring(
65
+ client: NineDeployClient,
66
+ opts: { days?: string } = {},
67
+ ): Promise<void> {
68
+ const days = opts.days ? num(opts.days, 'Usage: --days <days>') : 30;
69
+ const res = await spinner('Reading inventory', () =>
70
+ client.traefik.expiringCertificates({ days }),
71
+ );
72
+ header(`Certificates expiring within ${days} days`);
73
+ info(`Count: ${res.count}`);
74
+ if (res.count === 0) {
75
+ info('(none)');
76
+ return;
77
+ }
78
+ console.log();
79
+ table(
80
+ res.certificates.map((cert) => ({
81
+ host: cert.host,
82
+ status: (STATUS_COLOR[cert.status] ?? c.dim)(cert.status),
83
+ daysToExpiry: cert.daysToExpiry != null ? String(cert.daysToExpiry) : c.dim('—'),
84
+ expiresAt: cert.notAfter ? new Date(cert.notAfter).toLocaleString() : c.dim('—'),
85
+ })),
86
+ ['host', 'status', 'daysToExpiry', 'expiresAt'],
87
+ );
88
+ }
89
+
90
+ // ── Custom certificates (0.14) ─────────────────────────────────────────────
91
+ // `ninedeploy certificates custom list|upload|replace|delete` — operator-only
92
+ // uploaded certificates (`/v1/traefik/certificates/custom`). The PEM files are
93
+ // read from paths; no response carries a private key. Names, hostnames,
94
+ // subjects and issuers come from the certificate itself, so they go through
95
+ // the F1011 sanitiser.
96
+
97
+ const certMessage = (err: unknown): string => plain(err instanceof Error ? err.message : String(err));
98
+
99
+ function certId(raw: string | undefined): number {
100
+ const t = (raw ?? '').trim();
101
+ return /^[1-9]\d*$/.test(t) && Number.isSafeInteger(Number(t)) ? Number(t) : 0;
102
+ }
103
+
104
+ function readPemPair(opts: { cert?: string; key?: string }, usage: string): { certPem: string; keyPem: string } | null {
105
+ if (!opts.cert || !opts.key) {
106
+ error(usage);
107
+ return null;
108
+ }
109
+ try {
110
+ return { certPem: readFileSync(opts.cert, 'utf8').trim(), keyPem: readFileSync(opts.key, 'utf8').trim() };
111
+ } catch (err) {
112
+ error(`Could not read the PEM files: ${certMessage(err)}`);
113
+ return null;
114
+ }
115
+ }
116
+
117
+ function printSaved(cert: CustomCertificateSaved): void {
118
+ kv('Hostnames', cert.hostnames.map(plain).join(', '));
119
+ kv('Expires', new Date(cert.notAfter).toLocaleString());
120
+ kv('Covers', cert.coveredDomains.length > 0 ? cert.coveredDomains.map((d) => plain(d.hostname)).join(', ') : c.gray('no domains yet'));
121
+ for (const w of cert.warnings) console.log(` ${c.yellow('!')} ${c.yellow(plain(w))}`);
122
+ }
123
+
124
+ /** `ninedeploy certificates custom list` */
125
+ export async function certificatesCustomList(client: NineDeployClient): Promise<void> {
126
+ try {
127
+ const certs = await spinner('Reading uploaded certificates', () => client.traefik.customCertificates.list());
128
+ header('Uploaded certificates');
129
+ if (certs.length === 0) {
130
+ info('No certificates uploaded. Add one with `ninedeploy certificates custom upload --name <n> --cert <path> --key <path>`.');
131
+ return;
132
+ }
133
+ table(
134
+ certs.map((cert) => ({
135
+ id: cert.id,
136
+ name: plain(cert.name),
137
+ hostnames: cert.hostnames.map(plain).join(', '),
138
+ expires: cert.expired ? c.red(`expired ${new Date(cert.notAfter).toLocaleDateString()}`) : new Date(cert.notAfter).toLocaleDateString(),
139
+ issuer: cert.issuer ? plain(cert.issuer) : c.dim('—'),
140
+ covers: cert.coveredDomains.length > 0 ? cert.coveredDomains.map((d) => plain(d.hostname)).join(', ') : c.dim('—'),
141
+ })),
142
+ ['id', 'name', 'hostnames', 'expires', 'issuer', 'covers'],
143
+ );
144
+ } catch (err) {
145
+ error(certMessage(err));
146
+ }
147
+ }
148
+
149
+ /** `ninedeploy certificates custom upload --name <n> --cert <path> --key <path>` */
150
+ export async function certificatesCustomUpload(
151
+ client: NineDeployClient,
152
+ opts: { name?: string; cert?: string; key?: string } = {},
153
+ ): Promise<void> {
154
+ const usage = 'Usage: ninedeploy certificates custom upload --name <name> --cert <chain.pem> --key <key.pem>';
155
+ const name = opts.name?.trim();
156
+ if (!name) return error(usage);
157
+ const pem = readPemPair(opts, usage);
158
+ if (!pem) return;
159
+ try {
160
+ const cert = await spinner('Verifying and uploading', () => client.traefik.customCertificates.upload({ name, ...pem }));
161
+ success(`Certificate "${plain(cert.name)}" uploaded (id: ${cert.id})`);
162
+ printSaved(cert);
163
+ } catch (err) {
164
+ error(certMessage(err));
165
+ }
166
+ }
167
+
168
+ /** `ninedeploy certificates custom replace <id> --cert <path> --key <path> [--name <n>]` */
169
+ export async function certificatesCustomReplace(
170
+ client: NineDeployClient,
171
+ idArg: string,
172
+ opts: { name?: string; cert?: string; key?: string } = {},
173
+ ): Promise<void> {
174
+ const usage = 'Usage: ninedeploy certificates custom replace <id> --cert <chain.pem> --key <key.pem> [--name <name>]';
175
+ const id = certId(idArg);
176
+ if (!id) return error(usage);
177
+ const pem = readPemPair(opts, usage);
178
+ if (!pem) return;
179
+ const name = opts.name?.trim();
180
+ try {
181
+ const cert = await spinner('Verifying and replacing', () =>
182
+ client.traefik.customCertificates.replace(id, { ...(name ? { name } : {}), ...pem }),
183
+ );
184
+ success(`Certificate #${id} replaced`);
185
+ printSaved(cert);
186
+ } catch (err) {
187
+ error(certMessage(err));
188
+ }
189
+ }
190
+
191
+ /** `ninedeploy certificates custom delete <id> [--yes]` */
192
+ export async function certificatesCustomDelete(client: NineDeployClient, idArg: string, opts: { yes?: boolean } = {}): Promise<void> {
193
+ const id = certId(idArg);
194
+ if (!id) return error('Usage: ninedeploy certificates custom delete <id> [--yes]');
195
+ if (!opts.yes) {
196
+ const confirm = await prompt(`Type "delete" to remove certificate #${id} (covered domains fall back to ACME)`, '');
197
+ if (confirm.trim() !== 'delete') {
198
+ info('Aborted.');
199
+ return;
200
+ }
201
+ }
202
+ try {
203
+ await spinner('Deleting the certificate', () => client.traefik.customCertificates.delete(id));
204
+ success(`Certificate #${id} deleted`);
205
+ } catch (err) {
206
+ error(certMessage(err));
207
+ }
208
+ }