ninedeploy 0.10.6 → 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
@@ -0,0 +1,106 @@
1
+ /**
2
+ * `ninedeploy sso {list,add,remove}` — Sprint 5, Gap G-22.
3
+ *
4
+ * Operator-side wrapper for the SSO provider list. The CLI is the
5
+ * canonical write path; the panel is the read + login surface.
6
+ */
7
+ import type { NineDeployClient } from '../client.js';
8
+ import { error, header, info, success } from '../lib/format.js';
9
+
10
+ export interface SsoProviderListItem {
11
+ id: number;
12
+ type: 'oidc' | 'saml';
13
+ name: string;
14
+ createdAt: string;
15
+ }
16
+
17
+ // ── `ninedeploy sso list` ─────────────────────────────────────────────────
18
+
19
+ /** Pure entry point — same shape used by the CLI action and the unit test. */
20
+ export async function ssoList(client: NineDeployClient): Promise<{ providers: SsoProviderListItem[] }> {
21
+ return await client.sso.listProviders();
22
+ }
23
+
24
+ export async function ssoListAction(client: NineDeployClient): Promise<void> {
25
+ header('SSO providers');
26
+ let result: { providers: SsoProviderListItem[] };
27
+ try {
28
+ result = await ssoList(client);
29
+ } catch (err) {
30
+ error(err instanceof Error ? err.message : String(err));
31
+ process.exitCode = 1;
32
+ return;
33
+ }
34
+ if (result.providers.length === 0) {
35
+ info('No SSO providers configured. Run `ninedeploy sso add oidc --name <id> --issuer <url> --client-id <id> --client-secret <secret>`.');
36
+ return;
37
+ }
38
+ for (const p of result.providers) {
39
+ info(`#${p.id} ${p.name} (${p.type})`);
40
+ }
41
+ }
42
+
43
+ // ── `ninedeploy sso add` ──────────────────────────────────────────────────
44
+
45
+ /** Pure entry point — same shape used by the CLI action and the unit test. */
46
+ export async function ssoAdd(
47
+ client: NineDeployClient,
48
+ type: 'oidc' | 'saml',
49
+ name: string,
50
+ config: Record<string, unknown>,
51
+ ): Promise<{ ok: boolean; id?: number; error?: string }> {
52
+ return await client.sso.addProvider({ type, name, config });
53
+ }
54
+
55
+ export async function ssoAddAction(
56
+ client: NineDeployClient,
57
+ type: string,
58
+ name: string,
59
+ config: Record<string, unknown>,
60
+ ): Promise<void> {
61
+ if (type !== 'oidc' && type !== 'saml') {
62
+ error('Usage: ninedeploy sso add <oidc|saml> <name> --config-key value --config-key value ...');
63
+ process.exitCode = 1;
64
+ return;
65
+ }
66
+ let result: Awaited<ReturnType<typeof ssoAdd>>;
67
+ try {
68
+ result = await ssoAdd(client, type, name, config);
69
+ } catch (err) {
70
+ error(err instanceof Error ? err.message : String(err));
71
+ process.exitCode = 1;
72
+ return;
73
+ }
74
+ if (!result.ok) {
75
+ error(result.error ?? 'unknown error');
76
+ process.exitCode = 1;
77
+ return;
78
+ }
79
+ success(`Added SSO provider "${name}" (id=${result.id})`);
80
+ }
81
+
82
+ // ── `ninedeploy sso remove` ───────────────────────────────────────────────
83
+
84
+ /** Pure entry point — same shape used by the CLI action and the unit test. */
85
+ export async function ssoRemove(client: NineDeployClient, id: number): Promise<{ ok: boolean }> {
86
+ return await client.sso.removeProvider(id);
87
+ }
88
+
89
+ export async function ssoRemoveAction(client: NineDeployClient, idStr: string): Promise<void> {
90
+ const id = Number(idStr);
91
+ if (!Number.isFinite(id)) {
92
+ error('Usage: ninedeploy sso remove <id>');
93
+ process.exitCode = 1;
94
+ return;
95
+ }
96
+ let result: Awaited<ReturnType<typeof ssoRemove>>;
97
+ try {
98
+ result = await ssoRemove(client, id);
99
+ } catch (err) {
100
+ error(err instanceof Error ? err.message : String(err));
101
+ process.exitCode = 1;
102
+ return;
103
+ }
104
+ success(`Removed SSO provider #${id}`);
105
+ void result;
106
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * `ninedeploy templates {init}` — Sprint 1, Gap G-04, PR #4.
3
+ *
4
+ * `init <template-id>` asks the panel for one template, runs the shared
5
+ * `buildManifestFromTemplate` mapper (the same pure helper the server's
6
+ * `manifest-generator` plugin uses), and either prints the YAML to stdout
7
+ * or writes it to a `.ninedeploy` file in the current directory.
8
+ *
9
+ * The split is deliberate:
10
+ * - Pulling the template from the panel guarantees the operator sees the
11
+ * same port/volume/env the deploy will see, including any panel-side
12
+ * overrides (image tag pin, runtime version, etc.).
13
+ * - Building the manifest on the client keeps `ninedeploy` usable
14
+ * without a running panel: `ninedeploy templates init n8n` requires
15
+ * only an auth token, not a live deploy.
16
+ * - All mapping logic lives in `@ninedeploy/sdk` so the panel's
17
+ * `manifest-generator` plugin, this CLI command, and any future
18
+ * consumer share one implementation.
19
+ */
20
+ import { existsSync, writeFileSync } from 'node:fs';
21
+ import { resolve } from 'node:path';
22
+ import { buildManifestFromTemplate, formatManifestYaml, type TemplateRegistryEntry } from '@ninedeploy/sdk';
23
+ import type { NineDeployClient } from '../client.js';
24
+ import { error, header, info, success } from '../lib/format.js';
25
+
26
+ const MANIFEST_FILENAMES = ['.ninedeploy', '.ninedeploy.yml', '.ninedeploy.yaml'] as const;
27
+
28
+ function existingManifest(cwd: string): string | null {
29
+ for (const name of MANIFEST_FILENAMES) {
30
+ const candidate = resolve(cwd, name);
31
+ if (existsSync(candidate)) return candidate;
32
+ }
33
+ return null;
34
+ }
35
+
36
+ /**
37
+ * Pull a single template's registry entry from the panel. The response
38
+ * carries every field we need; anything beyond `TemplateRegistryEntry` is
39
+ * ignored. The cast is safe because the server's `Template` type is a
40
+ * superset of the SDK's `TemplateRegistryEntry` shape.
41
+ */
42
+ async function fetchTemplateEntry(
43
+ client: NineDeployClient,
44
+ templateId: string,
45
+ ): Promise<TemplateRegistryEntry> {
46
+ // The SDK exposes typed `client.templates.get(id)` returning `Template`.
47
+ // We rely on the cast here so a single helper definition lives in the
48
+ // SDK; if the server's response shape ever drifts, this is the seam.
49
+ const full = await (client as unknown as {
50
+ templates: { get: (id: string) => Promise<TemplateRegistryEntry> };
51
+ }).templates.get(templateId);
52
+ if (!full) throw new Error(`Panel returned no template for id "${templateId}"`);
53
+ return {
54
+ id: full.id,
55
+ name: full.name,
56
+ image: full.image,
57
+ port: full.port,
58
+ volumeMount: full.volumeMount,
59
+ env: full.env,
60
+ };
61
+ }
62
+
63
+ // ── `ninedeploy templates init <id>` ─────────────────────────────────────
64
+
65
+ export interface TemplatesInitOptions {
66
+ /** Default host to bake into the starter `routes[0].host`. */
67
+ host?: string;
68
+ /** Filename to write when `--write` is set; defaults to `.ninedeploy`. */
69
+ filename?: string;
70
+ /** When true, write to disk; when false (default), print to stdout. */
71
+ write?: boolean;
72
+ }
73
+
74
+ /**
75
+ * Pure entry point — same shape used by both the CLI action and the
76
+ * unit test. Given a client and a template id, return the rendered YAML.
77
+ * Does not touch the filesystem or stdin; the caller decides what to do
78
+ * with the result.
79
+ */
80
+ export async function renderTemplateManifest(
81
+ client: NineDeployClient,
82
+ templateId: string,
83
+ opts: TemplatesInitOptions = {},
84
+ ): Promise<{ yaml: string; entry: TemplateRegistryEntry }> {
85
+ const entry = await fetchTemplateEntry(client, templateId);
86
+ const host = opts.host?.trim() ?? '';
87
+ const manifest = buildManifestFromTemplate(entry, host);
88
+ // F929: the mapper always emits a starter route, but `routes[].host` is
89
+ // required (a hostname, min 3 chars) while `routes` itself is optional. With
90
+ // no --host, drop the route instead of writing `host: ""` that `manifest
91
+ // validate` and the deploy-time loader reject (mirrors the server's F336).
92
+ if (!host) delete manifest.routes;
93
+ const yaml = formatManifestYaml(manifest);
94
+ return { yaml, entry };
95
+ }
96
+
97
+ export async function templatesInit(
98
+ client: NineDeployClient,
99
+ templateId: string,
100
+ cwd: string = process.cwd(),
101
+ opts: TemplatesInitOptions = {},
102
+ ): Promise<void> {
103
+ header('Templates init');
104
+
105
+ if (!templateId) {
106
+ error('Usage: ninedeploy templates init <template-id>');
107
+ process.exitCode = 1;
108
+ return;
109
+ }
110
+
111
+ if (opts.write) {
112
+ const existing = existingManifest(cwd);
113
+ if (existing) {
114
+ error(`A manifest already exists at ${existing}. Refusing to overwrite.`);
115
+ process.exitCode = 1;
116
+ return;
117
+ }
118
+ }
119
+
120
+ let yaml: string;
121
+ let entry: TemplateRegistryEntry;
122
+ try {
123
+ ({ yaml, entry } = await renderTemplateManifest(client, templateId, opts));
124
+ } catch (err) {
125
+ error(
126
+ err instanceof Error
127
+ ? `Could not fetch template "${templateId}": ${err.message}`
128
+ : `Could not fetch template "${templateId}"`,
129
+ );
130
+ process.exitCode = 1;
131
+ return;
132
+ }
133
+
134
+ if (opts.write) {
135
+ const filename = opts.filename ?? '.ninedeploy';
136
+ const out = resolve(cwd, filename);
137
+ writeFileSync(out, yaml, 'utf8');
138
+ success(`Wrote ${filename}`);
139
+ // v8 ignore next -- the post-write banner lines are reached only
140
+ // when the user runs the action via the CLI; the only test that
141
+ // does that is the end-to-end smoke in test/index.test.ts, which v8
142
+ // attributes to the call site rather than this branch.
143
+ info(`Image: ${entry.image}`);
144
+ if (entry.port) info(`Port: ${entry.port}`);
145
+ if (entry.volumeMount) info(`Mount: ${entry.volumeMount}`);
146
+ info('Edit the file to match your project, then commit it.');
147
+ info('Run `ninedeploy manifest validate` to check it before pushing.');
148
+ } else {
149
+ // Print to stdout so the operator can pipe to `tee`, redirect into a
150
+ // file, or diff against an existing one. The header/footer banners
151
+ // are only useful interactively, so they go to stderr.
152
+ process.stdout.write(yaml);
153
+ if (!process.stdout.isTTY) {
154
+ // v8 ignore next -- see note above.
155
+ info(`(rendered from template "${entry.id}" — pipe to a file or diff with --write)`);
156
+ }
157
+ }
158
+ }
@@ -0,0 +1,66 @@
1
+ import { NineDeployError } from '@ninedeploy/sdk';
2
+ import { getClient } from '../client.js';
3
+ import { prompt } from '../prompts.js';
4
+
5
+ /**
6
+ * Normalise a typed scope answer. Accepts the legacy
7
+ * `read | write | operator` shorthand AND the fine-grained
8
+ * `nd://scope/(read|write|admin)/<resource>` form
9
+ * introduced in G-08. Anything unrecognised is dropped
10
+ * rather than silently widening the token. An empty
11
+ * result means "no scopes given": callers omit the field
12
+ * and the server applies its read-only default (the
13
+ * server refuses an explicit `[]`, which would be stored
14
+ * as a legacy unrestricted token).
15
+ */
16
+ export function parseScopes(answer: string): string[] {
17
+ const legacy = new Set(['read', 'write', 'operator']);
18
+ const resourceScope = /^nd:\/\/scope\/(read|write|admin)\/[a-z][a-z0-9_]{0,63}$/;
19
+ return answer
20
+ .split(',')
21
+ .map((s) => s.trim())
22
+ .filter((s) => legacy.has(s) || resourceScope.test(s));
23
+ }
24
+
25
+ /** `ninedeploy token create` — mint an API token (shown once). */
26
+ export async function tokenCreateAction(): Promise<void> {
27
+ const name = (await prompt('Token name', 'ci')) || 'ci';
28
+ // read = safe methods only · write = mutate as a non-operator ·
29
+ // operator = no extra restriction. Blank = read-only (server default).
30
+ // r194: the default must match the label — it was 'write', so pressing
31
+ // Enter at "blank = read" minted a write token.
32
+ const scopes = parseScopes(await prompt('Scopes (read,write,operator — blank = read)', 'read'));
33
+ try {
34
+ const created = await getClient().auth.tokens.create(scopes.length ? { name, scopes } : { name });
35
+ console.log(`✓ Token "${created.name}" created (id: ${created.id}).`);
36
+ console.log(` Scopes: ${created.scopes.length ? created.scopes.join(', ') : 'unrestricted (legacy)'}`);
37
+ console.log(' This token is shown ONCE — store it securely:');
38
+ console.log(` ${created.token}`);
39
+ console.log(' Use it as: Authorization: Bearer <token>');
40
+ } catch (err) {
41
+ console.error(
42
+ '✗ Could not create token:',
43
+ err instanceof NineDeployError ? err.message : err instanceof Error ? err.message : err,
44
+ );
45
+ process.exitCode = 1;
46
+ }
47
+ }
48
+
49
+ /** `ninedeploy token list` — list API tokens (without secrets). */
50
+ export async function tokenListAction(): Promise<void> {
51
+ const tokens = await getClient().auth.tokens.list();
52
+ if (tokens.length === 0) {
53
+ console.log('No API tokens.');
54
+ return;
55
+ }
56
+ console.table(
57
+ tokens.map((t) => ({
58
+ id: t.id,
59
+ name: t.name,
60
+ scopes: t.scopes.length ? t.scopes.join(',') : 'unrestricted',
61
+ lastUsed: t.lastUsedAt ?? 'never',
62
+ expires: t.expiresAt ?? 'never',
63
+ created: t.createdAt,
64
+ })),
65
+ );
66
+ }
@@ -0,0 +1,93 @@
1
+ import type { NineDeployClient } from '../client.js';
2
+ import { prompt } from '../prompts.js';
3
+ import { c, error, header, info, kv, spinner, success, table } from '../lib/format.js';
4
+
5
+ /** `ninedeploy webhooks list <serviceId>` */
6
+ export async function webhooksList(client: NineDeployClient, svcArg: string): Promise<void> {
7
+ const serviceId = Number(svcArg);
8
+ if (!serviceId) return error('Usage: ninedeploy webhooks list <serviceId>');
9
+ header(`Webhooks for service #${serviceId}`);
10
+ const rows = await spinner('Fetching', () => client.webhooks.list(serviceId));
11
+ if (rows.length === 0) {
12
+ info('No webhooks yet. Add one with `ninedeploy webhooks add`.');
13
+ return;
14
+ }
15
+ table(
16
+ rows.map((w) => ({
17
+ id: w.id,
18
+ branch: w.branch,
19
+ source: w.sourceId ?? c.gray('—'),
20
+ active: w.active ? c.green('✓') : c.red('✗'),
21
+ url: w.url,
22
+ })),
23
+ ['id', 'branch', 'source', 'active', 'url'],
24
+ );
25
+ }
26
+
27
+ /**
28
+ * `ninedeploy webhooks add <serviceId> [branch]`
29
+ *
30
+ * Returns the secret ONCE so the operator can paste it into GitHub as the
31
+ * webhook "Secret" value. The CLI never stores it; the panel only ever sees
32
+ * the HMAC digest.
33
+ */
34
+ export async function webhooksAdd(client: NineDeployClient, svcArg: string, branchArg?: string): Promise<void> {
35
+ const serviceId = Number(svcArg);
36
+ if (!serviceId) return error('Usage: ninedeploy webhooks add <serviceId> [branch]');
37
+ const branch = branchArg ?? (await prompt('Branch to deploy on push', 'main'));
38
+ if (!branch) return error('Branch is required');
39
+
40
+ const watchPaths = (await prompt('Watch paths (newline/comma-separated globs; empty = all files)', '')) || undefined;
41
+
42
+ const created = await spinner('Creating webhook', () =>
43
+ client.webhooks.create(serviceId, { branch, watchPaths }),
44
+ );
45
+ success(`Webhook #${created.id} created for branch "${created.branch}"`);
46
+ console.log();
47
+ console.log(` ${c.bold('URL')}`);
48
+ console.log(` ${c.cyan(created.url)}`);
49
+ console.log();
50
+ console.log(` ${c.bold('Secret (shown once — paste into GitHub):')}`);
51
+ console.log(` ${c.green(created.secret)}`);
52
+ console.log();
53
+ console.log(` ${c.gray('GitHub steps: Repo → Settings → Webhooks → Add')}`);
54
+ console.log(` ${c.gray(' • Payload URL : the URL above')}`);
55
+ console.log(` ${c.gray(' • Content type : application/json')}`);
56
+ console.log(` ${c.gray(' • Secret : the secret above')}`);
57
+ console.log(` ${c.gray(' • SSL : enabled (the panel runs behind HTTPS via Traefik)')}`);
58
+ console.log(` ${c.gray(' • Events : "Just the push event."')}`);
59
+ console.log();
60
+ info('Tip: set `NINEDEPLOY_PUBLIC_URL` on the server to a public HTTPS URL so the URL is reachable from GitHub.');
61
+ }
62
+
63
+ /** `ninedeploy webhooks remove <serviceId> <hookId>` */
64
+ export async function webhooksRemove(client: NineDeployClient, svcArg: string, hookArg: string): Promise<void> {
65
+ const serviceId = Number(svcArg);
66
+ const hookId = Number(hookArg);
67
+ if (!serviceId || !hookId) return error('Usage: ninedeploy webhooks remove <serviceId> <hookId>');
68
+ const confirm = await prompt(`Type "delete" to confirm removal of webhook #${hookId}`, '');
69
+ if (confirm.trim() !== 'delete') {
70
+ info('Aborted.');
71
+ return;
72
+ }
73
+ await spinner('Removing webhook', () => client.webhooks.remove(serviceId, hookId));
74
+ success(`Webhook #${hookId} removed.`);
75
+ }
76
+
77
+ /** `ninedeploy webhooks show <serviceId> <hookId>` */
78
+ export async function webhooksShow(client: NineDeployClient, svcArg: string, hookArg: string): Promise<void> {
79
+ const serviceId = Number(svcArg);
80
+ const hookId = Number(hookArg);
81
+ if (!serviceId || !hookId) return error('Usage: ninedeploy webhooks show <serviceId> <hookId>');
82
+ header(`Webhook #${hookId}`);
83
+ const rows = await spinner('Fetching', () => client.webhooks.list(serviceId));
84
+ const w = rows.find((x) => x.id === hookId);
85
+ if (!w) return error(`Webhook #${hookId} not found on service #${serviceId}`);
86
+ kv('ID', w.id);
87
+ kv('Branch', w.branch);
88
+ kv('Source', w.sourceId ?? c.gray('— (inherits service default)'));
89
+ kv('Active', w.active ? c.green('yes') : c.red('no'));
90
+ kv('Watch paths', w.watchPaths || c.gray('— (all files)'));
91
+ kv('URL', w.url);
92
+ kv('Created', w.createdAt);
93
+ }
@@ -0,0 +1,63 @@
1
+ import type { NineDeployClient } from '@ninedeploy/sdk';
2
+ import { spinner, table } from '../lib/format.js';
3
+
4
+ export async function workspacesList(client: NineDeployClient): Promise<void> {
5
+ const list = await spinner('Fetching workspaces', () => client.workspaces.list());
6
+ if (list.length === 0) {
7
+ console.log(' No workspaces found.');
8
+ return;
9
+ }
10
+ table(
11
+ list.map((w) => ({
12
+ id: w.id,
13
+ name: w.name,
14
+ slug: w.slug,
15
+ role: w.myRole,
16
+ members: w.memberCount,
17
+ projects: w.projectCount,
18
+ created: new Date(w.createdAt).toLocaleDateString(),
19
+ })),
20
+ ['id', 'name', 'slug', 'role', 'members', 'projects', 'created'],
21
+ );
22
+ }
23
+
24
+ export async function workspacesGet(client: NineDeployClient, id: string): Promise<void> {
25
+ const wsId = Number.parseInt(id, 10);
26
+ if (Number.isNaN(wsId)) {
27
+ throw new Error('Workspace ID must be an integer');
28
+ }
29
+ const ws = await spinner('Fetching workspace', () => client.workspaces.get(wsId));
30
+ console.log(`\n Workspace #${ws.id}: ${ws.name} (${ws.slug})`);
31
+ console.log(` Role: ${ws.myRole}`);
32
+ if (ws.description) console.log(` Description: ${ws.description}`);
33
+ console.log(` Created: ${new Date(ws.createdAt).toLocaleString()}`);
34
+ console.log('\n Members:');
35
+ table(
36
+ ws.members.map((m) => ({
37
+ userId: m.userId,
38
+ nameEmail: m.name ? `${m.name} <${m.email}>` : m.email,
39
+ role: m.role,
40
+ joined: new Date(m.createdAt).toLocaleDateString(),
41
+ })),
42
+ ['userId', 'nameEmail', 'role', 'joined'],
43
+ );
44
+ }
45
+
46
+ export async function workspacesCreate(client: NineDeployClient, name: string, opts: { description?: string }): Promise<void> {
47
+ const ws = await spinner('Creating workspace', () =>
48
+ client.workspaces.create({
49
+ name,
50
+ description: opts.description,
51
+ }),
52
+ );
53
+ console.log(` ✓ Workspace #${ws.id} created: ${ws.name} (${ws.slug})`);
54
+ }
55
+
56
+ export async function workspacesDelete(client: NineDeployClient, id: string): Promise<void> {
57
+ const wsId = Number.parseInt(id, 10);
58
+ if (Number.isNaN(wsId)) {
59
+ throw new Error('Workspace ID must be an integer');
60
+ }
61
+ await spinner('Deleting workspace', () => client.workspaces.delete(wsId));
62
+ console.log(` ✓ Workspace #${wsId} deleted.`);
63
+ }
package/src/config.ts ADDED
@@ -0,0 +1,53 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { randomUUID } from 'node:crypto';
4
+ import path from 'node:path';
5
+
6
+ const CONFIG_DIR = path.join(homedir(), '.ninedeploy');
7
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
8
+
9
+ export interface CliConfig {
10
+ baseUrl: string;
11
+ token?: string;
12
+ /** Long-lived token used to mint fresh access tokens (see client.ts). */
13
+ refreshToken?: string;
14
+ }
15
+
16
+ const DEFAULTS: CliConfig = { baseUrl: 'http://localhost:3000' };
17
+
18
+ export function loadConfig(): CliConfig {
19
+ try {
20
+ if (existsSync(CONFIG_FILE)) {
21
+ return { ...DEFAULTS, ...(JSON.parse(readFileSync(CONFIG_FILE, 'utf8')) as Partial<CliConfig>) };
22
+ }
23
+ } catch (err) {
24
+ // A torn/partial write used to fall back to defaults SILENTLY — the user
25
+ // was logged out with no hint the file was ever corrupt. Say so.
26
+ // eslint-disable-next-line no-console
27
+ console.error(`⚠ Could not read ${CONFIG_FILE} (${err instanceof Error ? err.message : String(err)}); using defaults.`);
28
+ }
29
+ return { ...DEFAULTS };
30
+ }
31
+
32
+ export function saveConfig(config: CliConfig): void {
33
+ mkdirSync(CONFIG_DIR, { recursive: true });
34
+ // Temp-file + rename: a crash mid-write must never leave a half-written
35
+ // config behind (the previous direct write could, logging the user out on
36
+ // the next command with no explanation).
37
+ const tmp = `${CONFIG_FILE}.${randomUUID()}.tmp`;
38
+ writeFileSync(tmp, JSON.stringify(config, null, 2), { mode: 0o600 });
39
+ try {
40
+ renameSync(tmp, CONFIG_FILE);
41
+ } catch (err) {
42
+ try { unlinkSync(tmp); } catch { /* already gone */ }
43
+ throw err;
44
+ }
45
+ // writeFileSync's mode only applies at CREATION — re-assert 0600 so a file
46
+ // that ever ended up group/world-readable (restore, copy, old version)
47
+ // holding the bearer token is tightened on every save.
48
+ try {
49
+ chmodSync(CONFIG_FILE, 0o600);
50
+ } catch {
51
+ /* best-effort on exotic filesystems */
52
+ }
53
+ }