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.
- package/LICENSE +21 -21
- package/README.md +1 -0
- package/dist/client.d.ts +34 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +81 -22
- package/dist/client.js.map +1 -1
- package/dist/commands/backupPolicy.d.ts +29 -0
- package/dist/commands/backupPolicy.d.ts.map +1 -0
- package/dist/commands/backupPolicy.js +111 -0
- package/dist/commands/backupPolicy.js.map +1 -0
- package/dist/commands/certificates.d.ts +18 -7
- package/dist/commands/certificates.d.ts.map +1 -1
- package/dist/commands/certificates.js +121 -1
- package/dist/commands/certificates.js.map +1 -1
- package/dist/commands/databaseAccess.d.ts +37 -0
- package/dist/commands/databaseAccess.d.ts.map +1 -0
- package/dist/commands/databaseAccess.js +306 -0
- package/dist/commands/databaseAccess.js.map +1 -0
- package/dist/commands/deploy.d.ts.map +1 -1
- package/dist/commands/deploy.js +28 -7
- package/dist/commands/deploy.js.map +1 -1
- package/dist/commands/githubApps.d.ts +40 -0
- package/dist/commands/githubApps.d.ts.map +1 -0
- package/dist/commands/githubApps.js +278 -0
- package/dist/commands/githubApps.js.map +1 -0
- package/dist/commands/housekeeping.d.ts +7 -0
- package/dist/commands/housekeeping.d.ts.map +1 -1
- package/dist/commands/housekeeping.js +16 -0
- package/dist/commands/housekeeping.js.map +1 -1
- package/dist/commands/logs.d.ts.map +1 -1
- package/dist/commands/logs.js +17 -2
- package/dist/commands/logs.js.map +1 -1
- package/dist/commands/manage.d.ts +26 -10
- package/dist/commands/manage.d.ts.map +1 -1
- package/dist/commands/manage.js +219 -65
- package/dist/commands/manage.js.map +1 -1
- package/dist/commands/manifest.d.ts.map +1 -1
- package/dist/commands/manifest.js +9 -1
- package/dist/commands/manifest.js.map +1 -1
- package/dist/commands/misc.d.ts +4 -2
- package/dist/commands/misc.d.ts.map +1 -1
- package/dist/commands/misc.js +25 -11
- package/dist/commands/misc.js.map +1 -1
- package/dist/commands/multiNode.d.ts +55 -0
- package/dist/commands/multiNode.d.ts.map +1 -0
- package/dist/commands/multiNode.js +438 -0
- package/dist/commands/multiNode.js.map +1 -0
- package/dist/commands/notifications.d.ts +1 -1
- package/dist/commands/operations.d.ts +89 -0
- package/dist/commands/operations.d.ts.map +1 -0
- package/dist/commands/operations.js +468 -0
- package/dist/commands/operations.js.map +1 -0
- package/dist/commands/panelBackup.d.ts +28 -0
- package/dist/commands/panelBackup.d.ts.map +1 -0
- package/dist/commands/panelBackup.js +153 -0
- package/dist/commands/panelBackup.js.map +1 -0
- package/dist/commands/plugins.d.ts +1 -1
- package/dist/commands/plugins.d.ts.map +1 -1
- package/dist/commands/plugins.js +7 -3
- package/dist/commands/plugins.js.map +1 -1
- package/dist/commands/proxyConfig.d.ts +16 -0
- package/dist/commands/proxyConfig.d.ts.map +1 -0
- package/dist/commands/proxyConfig.js +122 -0
- package/dist/commands/proxyConfig.js.map +1 -0
- package/dist/commands/secretProviders.d.ts +59 -0
- package/dist/commands/secretProviders.d.ts.map +1 -0
- package/dist/commands/secretProviders.js +212 -0
- package/dist/commands/secretProviders.js.map +1 -0
- package/dist/commands/services.d.ts.map +1 -1
- package/dist/commands/services.js +3 -5
- package/dist/commands/services.js.map +1 -1
- package/dist/commands/setup.d.ts +3 -1
- package/dist/commands/setup.d.ts.map +1 -1
- package/dist/commands/setup.js +19 -3
- package/dist/commands/setup.js.map +1 -1
- package/dist/commands/sources.d.ts +10 -3
- package/dist/commands/sources.d.ts.map +1 -1
- package/dist/commands/sources.js +116 -18
- package/dist/commands/sources.js.map +1 -1
- package/dist/commands/templates.d.ts.map +1 -1
- package/dist/commands/templates.js +8 -1
- package/dist/commands/templates.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +2 -1
- package/dist/config.js.map +1 -1
- package/dist/index.js +427 -23
- package/dist/index.js.map +1 -1
- package/dist/lib/format.d.ts.map +1 -1
- package/dist/lib/format.js +5 -4
- package/dist/lib/format.js.map +1 -1
- package/dist/lib/panelBackupFile.d.ts +3 -0
- package/dist/lib/panelBackupFile.d.ts.map +1 -0
- package/dist/lib/panelBackupFile.js +71 -0
- package/dist/lib/panelBackupFile.js.map +1 -0
- package/dist/lib/serverRunner.d.ts +6 -0
- package/dist/lib/serverRunner.d.ts.map +1 -1
- package/dist/lib/serverRunner.js +18 -3
- package/dist/lib/serverRunner.js.map +1 -1
- package/dist/prompts.d.ts.map +1 -1
- package/dist/prompts.js +9 -2
- package/dist/prompts.js.map +1 -1
- package/package.json +6 -5
- package/src/client.ts +137 -0
- package/src/commands/backupPolicy.ts +123 -0
- package/src/commands/branding.ts +83 -0
- package/src/commands/buildCache.ts +72 -0
- package/src/commands/certificates.ts +208 -0
- package/src/commands/communityTemplates.ts +97 -0
- package/src/commands/configCenter.ts +71 -0
- package/src/commands/configPresets.ts +215 -0
- package/src/commands/databaseAccess.ts +333 -0
- package/src/commands/demo.ts +22 -0
- package/src/commands/deploy.ts +284 -0
- package/src/commands/doctor.ts +170 -0
- package/src/commands/domains.ts +170 -0
- package/src/commands/egress.ts +135 -0
- package/src/commands/emailTemplates.ts +112 -0
- package/src/commands/firewall.ts +82 -0
- package/src/commands/githubApps.ts +292 -0
- package/src/commands/housekeeping.ts +106 -0
- package/src/commands/login.ts +58 -0
- package/src/commands/logs.ts +91 -0
- package/src/commands/manage.ts +714 -0
- package/src/commands/manifest.ts +314 -0
- package/src/commands/metrics.ts +69 -0
- package/src/commands/misc.ts +307 -0
- package/src/commands/multiNode.ts +442 -0
- package/src/commands/notifications.ts +174 -0
- package/src/commands/operations.ts +519 -0
- package/src/commands/panelBackup.ts +167 -0
- package/src/commands/pgbouncer.ts +65 -0
- package/src/commands/plugins.ts +123 -0
- package/src/commands/proxyConfig.ts +117 -0
- package/src/commands/secretProviders.ts +228 -0
- package/src/commands/server.ts +126 -0
- package/src/commands/services.ts +249 -0
- package/src/commands/setup.ts +94 -0
- package/src/commands/sources.ts +317 -0
- package/src/commands/sso.ts +106 -0
- package/src/commands/templates.ts +158 -0
- package/src/commands/token.ts +66 -0
- package/src/commands/webhooks.ts +93 -0
- package/src/commands/workspaces.ts +63 -0
- package/src/config.ts +53 -0
- package/src/index.ts +1273 -0
- package/src/lib/format.ts +182 -0
- package/src/lib/panelBackupFile.ts +67 -0
- package/src/lib/serverRunner.ts +223 -0
- 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
|
+
}
|