ninedeploy 0.18.26 → 0.18.27

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 (74) hide show
  1. package/dist/commands/databaseUpgrade.d.ts +25 -0
  2. package/dist/commands/databaseUpgrade.d.ts.map +1 -0
  3. package/dist/commands/databaseUpgrade.js +136 -0
  4. package/dist/commands/databaseUpgrade.js.map +1 -0
  5. package/dist/commands/importApply.d.ts +114 -0
  6. package/dist/commands/importApply.d.ts.map +1 -0
  7. package/dist/commands/importApply.js +403 -0
  8. package/dist/commands/importApply.js.map +1 -0
  9. package/dist/commands/importCutover.d.ts +95 -0
  10. package/dist/commands/importCutover.d.ts.map +1 -0
  11. package/dist/commands/importCutover.js +293 -0
  12. package/dist/commands/importCutover.js.map +1 -0
  13. package/dist/commands/importData.d.ts +60 -0
  14. package/dist/commands/importData.d.ts.map +1 -0
  15. package/dist/commands/importData.js +286 -0
  16. package/dist/commands/importData.js.map +1 -0
  17. package/dist/commands/importPlan.d.ts +32 -0
  18. package/dist/commands/importPlan.d.ts.map +1 -0
  19. package/dist/commands/importPlan.js +138 -0
  20. package/dist/commands/importPlan.js.map +1 -0
  21. package/dist/commands/importVolumes.d.ts +38 -0
  22. package/dist/commands/importVolumes.d.ts.map +1 -0
  23. package/dist/commands/importVolumes.js +161 -0
  24. package/dist/commands/importVolumes.js.map +1 -0
  25. package/dist/index.js +59 -0
  26. package/dist/index.js.map +1 -1
  27. package/dist/lib/importers/caprover.d.ts +13 -0
  28. package/dist/lib/importers/caprover.d.ts.map +1 -0
  29. package/dist/lib/importers/caprover.js +150 -0
  30. package/dist/lib/importers/caprover.js.map +1 -0
  31. package/dist/lib/importers/connections.d.ts +39 -0
  32. package/dist/lib/importers/connections.d.ts.map +1 -0
  33. package/dist/lib/importers/connections.js +119 -0
  34. package/dist/lib/importers/connections.js.map +1 -0
  35. package/dist/lib/importers/coolify.d.ts +10 -0
  36. package/dist/lib/importers/coolify.d.ts.map +1 -0
  37. package/dist/lib/importers/coolify.js +174 -0
  38. package/dist/lib/importers/coolify.js.map +1 -0
  39. package/dist/lib/importers/dokploy.d.ts +13 -0
  40. package/dist/lib/importers/dokploy.d.ts.map +1 -0
  41. package/dist/lib/importers/dokploy.js +183 -0
  42. package/dist/lib/importers/dokploy.js.map +1 -0
  43. package/dist/lib/importers/heroku.d.ts +50 -0
  44. package/dist/lib/importers/heroku.d.ts.map +1 -0
  45. package/dist/lib/importers/heroku.js +159 -0
  46. package/dist/lib/importers/heroku.js.map +1 -0
  47. package/dist/lib/importers/plan.d.ts +137 -0
  48. package/dist/lib/importers/plan.d.ts.map +1 -0
  49. package/dist/lib/importers/plan.js +110 -0
  50. package/dist/lib/importers/plan.js.map +1 -0
  51. package/dist/lib/importers/shell.d.ts +24 -0
  52. package/dist/lib/importers/shell.d.ts.map +1 -0
  53. package/dist/lib/importers/shell.js +45 -0
  54. package/dist/lib/importers/shell.js.map +1 -0
  55. package/dist/lib/importers/sources.d.ts +26 -0
  56. package/dist/lib/importers/sources.d.ts.map +1 -0
  57. package/dist/lib/importers/sources.js +100 -0
  58. package/dist/lib/importers/sources.js.map +1 -0
  59. package/package.json +3 -3
  60. package/src/commands/databaseUpgrade.ts +124 -0
  61. package/src/commands/importApply.ts +445 -0
  62. package/src/commands/importCutover.ts +329 -0
  63. package/src/commands/importData.ts +302 -0
  64. package/src/commands/importPlan.ts +143 -0
  65. package/src/commands/importVolumes.ts +168 -0
  66. package/src/index.ts +61 -0
  67. package/src/lib/importers/caprover.ts +141 -0
  68. package/src/lib/importers/connections.ts +133 -0
  69. package/src/lib/importers/coolify.ts +164 -0
  70. package/src/lib/importers/dokploy.ts +167 -0
  71. package/src/lib/importers/heroku.ts +168 -0
  72. package/src/lib/importers/plan.ts +209 -0
  73. package/src/lib/importers/shell.ts +56 -0
  74. package/src/lib/importers/sources.ts +96 -0
@@ -0,0 +1,143 @@
1
+ /**
2
+ * `ninedeploy import plan <coolify|dokploy|caprover> --url <source panel>`
3
+ *
4
+ * A1 step 1 (docs/plans/COMPETITIVE_ROADMAP_2026-11.md): read another
5
+ * platform's API from this machine and print what NineDeploy would create,
6
+ * what it cannot carry, and what needs a person. Nothing is created on
7
+ * NineDeploy and nothing changes on the source. The source credential is read
8
+ * from an environment variable or a hidden prompt, never from the command
9
+ * line, and stays on this machine; the plan holds variable names only.
10
+ */
11
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
12
+ import path from 'node:path';
13
+ import { planFromCaprover } from '../lib/importers/caprover.js';
14
+ import { planFromHeroku } from '../lib/importers/heroku.js';
15
+ import { planFromCoolify } from '../lib/importers/coolify.js';
16
+ import { planFromDokploy } from '../lib/importers/dokploy.js';
17
+ import type { ImportPlan, SourceGet, SourceKind } from '../lib/importers/plan.js';
18
+ import { caproverSource, coolifySource, dokploySource, normaliseBase, OtpRequiredError, SourceError } from '../lib/importers/sources.js';
19
+ import { c, error, header, info, success } from '../lib/format.js';
20
+ import { promptHidden } from '../prompts.js';
21
+
22
+ export const IMPORT_SOURCES: readonly SourceKind[] = ['coolify', 'dokploy', 'caprover', 'heroku'];
23
+
24
+ type PanelSource = Exclude<SourceKind, 'heroku'>;
25
+
26
+ /** Where each source's credential comes from, without the command line. Heroku is read from files and needs none. */
27
+ export const CREDENTIAL_ENV: Record<PanelSource, string> = {
28
+ coolify: 'NINEDEPLOY_IMPORT_TOKEN',
29
+ dokploy: 'NINEDEPLOY_IMPORT_TOKEN',
30
+ caprover: 'NINEDEPLOY_IMPORT_PASSWORD',
31
+ };
32
+ const PROMPT: Record<PanelSource, string> = {
33
+ coolify: 'Coolify API token (a "read" token is enough): ',
34
+ dokploy: 'Dokploy API key: ',
35
+ caprover: 'CapRover password: ',
36
+ };
37
+
38
+ export interface ImportPlanDeps {
39
+ /** Heroku: the checkout to read app.json / Procfile from, the branch, and the Heroku app name. */
40
+ heroku?: { dir?: string; branch?: string; app?: string };
41
+ /** Reads a file of the checkout, or undefined when it does not exist (tests replace it). */
42
+ readFile?: (file: string) => string | undefined;
43
+ env?: NodeJS.ProcessEnv;
44
+ ask?: (message: string) => Promise<string>;
45
+ fetch?: typeof fetch;
46
+ }
47
+
48
+ /** Build the plan (no output). Throws SourceError with a reason a person can act on. */
49
+ export async function buildImportPlan(kind: string, url: string, deps: ImportPlanDeps = {}): Promise<ImportPlan> {
50
+ if (!IMPORT_SOURCES.includes(kind as SourceKind)) throw new SourceError(`Unknown source "${kind}" (expected ${IMPORT_SOURCES.join(', ')}).`);
51
+ const source = kind as SourceKind;
52
+ if (source === 'heroku') {
53
+ // The repository URL stays whole (normaliseBase would cut its path).
54
+ const dir = path.resolve(deps.heroku?.dir ?? '.');
55
+ const read = deps.readFile ?? ((f: string) => (existsSync(f) ? readFileSync(f, 'utf8') : undefined));
56
+ try {
57
+ return planFromHeroku(
58
+ { appJson: read(path.join(dir, 'app.json')), procfile: read(path.join(dir, 'Procfile')), herokuYml: read(path.join(dir, 'heroku.yml')) !== undefined },
59
+ { repoUrl: url.trim(), branch: deps.heroku?.branch ?? 'main', app: deps.heroku?.app, dirName: path.basename(dir) },
60
+ );
61
+ } catch (err) {
62
+ throw new SourceError(err instanceof Error ? err.message : String(err));
63
+ }
64
+ }
65
+ const env = deps.env ?? process.env;
66
+ const ask = deps.ask ?? promptHidden;
67
+ const base = normaliseBase(url);
68
+ const credential = env[CREDENTIAL_ENV[source]]?.trim() || (await ask(PROMPT[source])).trim();
69
+ if (!credential) throw new SourceError(`No credential: set ${CREDENTIAL_ENV[source]} or type it at the prompt.`);
70
+ let get: SourceGet;
71
+ if (source === 'coolify') get = coolifySource(base, credential, deps.fetch);
72
+ else if (source === 'dokploy') get = dokploySource(base, credential, deps.fetch);
73
+ else {
74
+ const otpEnv = env['NINEDEPLOY_IMPORT_OTP']?.trim();
75
+ try {
76
+ get = await caproverSource(base, credential, otpEnv || undefined, deps.fetch);
77
+ } catch (err) {
78
+ if (!(err instanceof OtpRequiredError) || otpEnv) throw err;
79
+ const otp = (await ask('CapRover two-factor code: ')).trim();
80
+ get = await caproverSource(base, credential, otp, deps.fetch);
81
+ }
82
+ }
83
+ if (source === 'coolify') return planFromCoolify(get, base);
84
+ if (source === 'dokploy') return planFromDokploy(get, base);
85
+ return planFromCaprover(get, base);
86
+ }
87
+
88
+ /** The plan as text a person reads: per item, what would be created and what needs them. */
89
+ export function renderImportPlan(plan: ImportPlan): string {
90
+ const lines: string[] = [];
91
+ const s = plan.source;
92
+ lines.push(`Source: ${s.kind}${s.version ? ` ${s.version}` : ''} at ${s.url}`);
93
+ lines.push(`Scope: ${s.scope}`);
94
+ lines.push('');
95
+ lines.push(`Services (${plan.services.length})`);
96
+ for (const svc of plan.services) {
97
+ const what = svc.kind === 'image' ? `image ${svc.create.image ?? '(unknown)'}` : svc.kind === 'git' ? `git ${svc.create.repoUrl ?? '(no repository)'}${svc.create.branch ? `@${svc.create.branch}` : ''}` : 'compose stack';
98
+ lines.push(` • ${svc.name}: ${what}${svc.create.port ? `, port ${svc.create.port}` : ''}`);
99
+ if (svc.domains.length) lines.push(` domains: ${svc.domains.map((d) => `${d.https ? 'https' : 'http'}://${d.host}${d.sourceDefault ? " (the source's own host; stays)" : ''}`).join(', ')}`);
100
+ if (svc.env.length) lines.push(` env: ${svc.env.length} variable(s): ${svc.env.map((e) => e.key).join(', ')} (names only)`);
101
+ if (svc.volumes.length) lines.push(` volumes: ${svc.volumes.map((v) => v.mountPath).join(', ')}`);
102
+ if (svc.create.build?.startCmd) lines.push(` start: ${svc.create.build.startCmd}`);
103
+ if (svc.settings?.healthCheck) lines.push(` health check: ${svc.settings.healthCheck}`);
104
+ if (svc.attachments?.length) lines.push(` attaches: ${svc.attachments.map((a) => `${a.database} as ${a.envAlias}`).join(', ')}`);
105
+ for (const i of svc.issues) lines.push(` ${i.level === 'unsupported' ? '✗' : i.level === 'manual' ? '!' : '·'} ${i.option}: ${i.detail}`);
106
+ }
107
+ lines.push('');
108
+ lines.push(`Databases (${plan.databases.length})`);
109
+ for (const db of plan.databases) {
110
+ lines.push(` • ${db.name}: ${db.engine ?? 'no managed equivalent'}${db.image ? ` (${db.image})` : ''}`);
111
+ for (const i of db.issues) lines.push(` ${i.level === 'unsupported' ? '✗' : i.level === 'manual' ? '!' : '·'} ${i.option}: ${i.detail}`);
112
+ }
113
+ lines.push('');
114
+ for (const n of plan.notes) lines.push(`Note: ${n}`);
115
+ return lines.join('\n');
116
+ }
117
+
118
+ export async function importPlanAction(
119
+ kind: string,
120
+ opts: { url: string; json?: boolean; out?: string; dir?: string; branch?: string; app?: string },
121
+ deps: ImportPlanDeps = {},
122
+ ): Promise<void> {
123
+ let plan: ImportPlan;
124
+ try {
125
+ plan = await buildImportPlan(kind, opts.url, { ...deps, heroku: { dir: opts.dir, branch: opts.branch, app: opts.app } });
126
+ } catch (err) {
127
+ error(err instanceof Error ? err.message : String(err));
128
+ process.exitCode = 1;
129
+ return;
130
+ }
131
+ const json = `${JSON.stringify(plan, null, 2)}\n`;
132
+ if (opts.out) {
133
+ writeFileSync(opts.out, json);
134
+ success(`Plan written to ${opts.out}`);
135
+ }
136
+ if (opts.json) {
137
+ process.stdout.write(json);
138
+ return;
139
+ }
140
+ header(`Import plan: ${plan.services.length} service(s), ${plan.databases.length} database(s)`);
141
+ console.log(renderImportPlan(plan));
142
+ info(`${c.dim('Nothing was created or changed.')} Save it with --out plan.json, then preview step 2 with \`ninedeploy import apply plan.json\`.`);
143
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * `ninedeploy import data <plan> --volume <service>=<file.tar.gz>` (A1 step 3).
3
+ *
4
+ * Moves the contents of a service's data volume: the volume mounted at the
5
+ * path the service was created with (`create.volumeMount`), which NineDeploy
6
+ * keeps in `nd-svc-<slug>-data`.
7
+ *
8
+ * - The command printed for the source host mounts a named volume by its
9
+ * name, read-only (no container needed: a Swarm service scaled to 0 has
10
+ * none); a bind mount is read through the source container instead. It
11
+ * writes a gzipped tar with numeric owners, the format the panel restores.
12
+ * - The archive goes through the panel's chunked volume import with its
13
+ * sha256, which the panel checks before the volume is touched. The panel
14
+ * snapshots a volume that already exists, then swaps the archive in.
15
+ * - The receipt records the import id, sha256 and size. A stopped upload
16
+ * resumes, a volume already imported is not imported again.
17
+ */
18
+ import path from 'node:path';
19
+ import type { VolumeImport } from '@ninedeploy/schemas';
20
+ import type { NineDeployClient } from '../client.js';
21
+ import type { ImportPlan, PlanService } from '../lib/importers/plan.js';
22
+ import { fileBase, flagArg, isPlainName, shellQuote, sourceTarget } from '../lib/importers/shell.js';
23
+ import type { ImportReceipt } from './importApply.js';
24
+
25
+ /** The panel calls the volume half makes; NineDeployClient satisfies it. */
26
+ export interface VolumeClient {
27
+ volumeImports: {
28
+ importFile: NineDeployClient['volumeImports']['importFile'];
29
+ get: NineDeployClient['volumeImports']['get'];
30
+ wait: NineDeployClient['volumeImports']['wait'];
31
+ };
32
+ }
33
+
34
+ /** The helper image the panel itself tars volumes with (server lib/inventory.ts HELPER_IMAGE). */
35
+ const TAR_IMAGE = 'alpine:3.21';
36
+
37
+ /**
38
+ * The source-host command that archives a service's data volume, or why it
39
+ * cannot be printed. Null when the service has no data volume.
40
+ */
41
+ export function volumeCommand(s: PlanService): { command: string; file: string; notes: string[] } | { manual: string } | null {
42
+ const mount = s.create.volumeMount;
43
+ if (!mount) return null;
44
+ const v = s.volumes.find((x) => x.mountPath === mount);
45
+ if (v?.source === 'file') return { manual: `${mount} is a file mount on the source; recreate it under Service → File mounts` };
46
+ const notes: string[] = [];
47
+ const others = s.volumes.filter((x) => x.mountPath !== mount && x.source !== 'file');
48
+ if (others.length) notes.push(`Only ${mount} moves; copy ${others.map((x) => x.mountPath).join(', ')} by hand.`);
49
+ const file = `${fileBase(s.name)}.volume.tar.gz`;
50
+ // A named volume is mounted by its name: that needs no container, and a
51
+ // Swarm service scaled to 0 has none left (checked live on CapRover 1.16,
52
+ // which names the Docker volume exactly as the app's volumeName).
53
+ if (v?.source === 'volume' && v.name && isPlainName(v.name)) {
54
+ return { command: `docker run --rm -v ${v.name}:/v:ro ${TAR_IMAGE} tar -czf - --numeric-owner -C /v . > ${file}`, file, notes };
55
+ }
56
+ // A bind mount, or a volume whose name the plan does not hold: borrow the
57
+ // source container's mounts, which works only while it still exists.
58
+ const { target, note } = sourceTarget(s.runtime, `${s.name}'s container`, true);
59
+ if (note) notes.push(note);
60
+ notes.push('This reads the mount through the source container, so stop the app without removing its container (a Swarm service scaled to 0 has none left); or archive the host directory yourself with tar -czf - --numeric-owner -C <dir> .');
61
+ return {
62
+ command: `docker run --rm --volumes-from ${target}:ro ${TAR_IMAGE} tar -czf - --numeric-owner -C ${shellQuote(mount)} . > ${file}`,
63
+ file,
64
+ notes,
65
+ };
66
+ }
67
+
68
+ /** The preview lines for every service with a data volume. */
69
+ export function renderVolumePreview(plan: ImportPlan, receipt: ImportReceipt | null): string[] {
70
+ const out: string[] = [];
71
+ for (const s of plan.services) {
72
+ const cmd = volumeCommand(s);
73
+ if (!cmd) continue;
74
+ if (out.length === 0) out.push('', 'Volumes (the data volume of each service; stop the service on the source first):');
75
+ const created = receipt?.items[`service:${s.ref}`];
76
+ const done = receipt?.items[`volume:${s.ref}`];
77
+ if (!created || created.state !== 'done') out.push(` – ${s.name}: not created yet (run import apply first)`);
78
+ else if (done?.state === 'done') out.push(` ✓ ${s.name}: imported (#${done.importId}, sha256 ${done.sha256?.slice(0, 12)}…)`);
79
+ else if ('manual' in cmd) out.push(` – ${s.name}: ${cmd.manual}`);
80
+ else {
81
+ out.push(` + ${s.name} (${s.create.volumeMount}, NineDeploy service #${created.id}):`);
82
+ out.push(` ${cmd.command}`);
83
+ for (const n of cmd.notes) out.push(` ${n}`);
84
+ out.push(` then: --volume ${flagArg(s.name, cmd.file)}`);
85
+ }
86
+ }
87
+ return out;
88
+ }
89
+
90
+ export interface VolumeDeps {
91
+ client: VolumeClient;
92
+ receipt: ImportReceipt;
93
+ save: (receipt: ImportReceipt) => void;
94
+ hashFile: (file: string) => Promise<{ sha256: string; bytes: number }>;
95
+ open: (file: string) => (offset: number) => AsyncIterable<Uint8Array>;
96
+ now?: () => Date;
97
+ }
98
+
99
+ /** Check every archive, then import each, recording every step in the receipt. */
100
+ export async function importVolumes(plan: ImportPlan, archives: Record<string, string>, deps: VolumeDeps): Promise<ImportReceipt> {
101
+ const now = deps.now ?? (() => new Date());
102
+ const { receipt } = deps;
103
+ const jobs: Array<{ s: PlanService; serviceId: number; file: string; sha256: string; bytes: number }> = [];
104
+ for (const [name, file] of Object.entries(archives)) {
105
+ const s = plan.services.find((x) => x.name === name);
106
+ if (!s) throw new Error(`--volume names no service in the plan: ${name}.`);
107
+ const created = receipt.items[`service:${s.ref}`];
108
+ if (created?.state !== 'done' || created.id === undefined) throw new Error(`${name} has not been created yet: run \`ninedeploy import apply\` first.`);
109
+ if (!s.create.volumeMount) throw new Error(`${name} has no data volume in the plan (no volume mount), so there is nothing to import into.`);
110
+ const { sha256, bytes } = await deps.hashFile(file);
111
+ if (bytes === 0) throw new Error(`${file} is empty.`);
112
+ const prior = receipt.items[`volume:${s.ref}`];
113
+ if (prior?.sha256 !== undefined && prior.sha256 !== sha256 && (prior.state === 'done' || prior.importId !== undefined)) {
114
+ throw new Error(`${name} already has volume import #${prior.importId} recorded for a different archive. import data does not import twice; restore its pre-import snapshot or use the panel if you mean to.`);
115
+ }
116
+ jobs.push({ s, serviceId: created.id, file, sha256, bytes });
117
+ }
118
+
119
+ for (const job of jobs) {
120
+ const key = `volume:${job.s.ref}`;
121
+ const prior = receipt.items[key];
122
+ if (prior?.state === 'done') continue;
123
+ const base = { kind: 'volume' as const, ref: job.s.ref, name: job.s.name, id: job.serviceId, sha256: job.sha256, bytes: job.bytes };
124
+ const put = (state: 'pending' | 'done' | 'failed', notes: string[], importId?: number) => {
125
+ receipt.items[key] = { ...base, state, notes, ...(importId !== undefined ? { importId } : {}), at: now().toISOString() };
126
+ deps.save(receipt);
127
+ };
128
+ let resumeImportId = prior?.sha256 === job.sha256 ? prior.importId : undefined;
129
+ put('pending', [], resumeImportId);
130
+ let row: VolumeImport;
131
+ try {
132
+ const existing = resumeImportId === undefined ? undefined : await deps.client.volumeImports.get(job.serviceId, resumeImportId);
133
+ if (existing?.status === 'completed') row = existing;
134
+ else if (existing?.status === 'running') row = await deps.client.volumeImports.wait(job.serviceId, existing.id);
135
+ else {
136
+ if (existing && ['failed', 'cancelled', 'expired'].includes(existing.status)) {
137
+ resumeImportId = undefined;
138
+ put('pending', []);
139
+ }
140
+ row = await deps.client.volumeImports.importFile(job.serviceId, deps.open(job.file), {
141
+ sizeBytes: job.bytes,
142
+ sha256: job.sha256,
143
+ filename: path.basename(job.file),
144
+ ...(resumeImportId !== undefined ? { resumeImportId } : {}),
145
+ onCreated: (r) => put('pending', [], r.id),
146
+ });
147
+ row = await deps.client.volumeImports.wait(job.serviceId, row.id);
148
+ }
149
+ } catch (err) {
150
+ const status = (err as { status?: number }).status;
151
+ const message = status === 404 && receipt.items[key]?.importId === undefined
152
+ ? 'This panel has no volume import yet: update it, or copy the files by hand.'
153
+ : err instanceof Error ? err.message : String(err);
154
+ put('failed', [message], receipt.items[key]?.importId);
155
+ continue;
156
+ }
157
+ if (row.status === 'completed') {
158
+ put('done', [
159
+ `Imported into ${row.volumeName}: ${job.bytes} bytes, sha256 checked by the panel.`,
160
+ ...(row.safetyBackupId ? [`The volume's earlier contents are in volume backup #${row.safetyBackupId} (pre-import).`] : []),
161
+ ], row.id);
162
+ } else {
163
+ // The panel's message can quote file names from the archive, so the receipt keeps the status only.
164
+ put('failed', [`Volume import #${row.id} ended ${row.status}. Details: the service's volume imports on the panel.`], row.id);
165
+ }
166
+ }
167
+ return receipt;
168
+ }
package/src/index.ts CHANGED
@@ -38,6 +38,7 @@ import {
38
38
  } from './commands/notifications.js';
39
39
  import { databasePgbouncer } from './commands/pgbouncer.js';
40
40
  import { pitrBaseBackup, pitrDisable, pitrEnable, pitrRecover, pitrRecoveries, pitrStatus } from './commands/pitr.js';
41
+ import { upgradeFinalize, upgradeRollback, upgradeStart, upgradeStatus } from './commands/databaseUpgrade.js';
41
42
  import { backupPolicyGet, backupPolicySet } from './commands/backupPolicy.js';
42
43
  import { logsSearch } from './commands/logs.js';
43
44
  import {
@@ -128,6 +129,10 @@ import {
128
129
  import {
129
130
  egressClearAction, egressListAction, egressSetAction,
130
131
  } from './commands/egress.js';
132
+ import { importPlanAction } from './commands/importPlan.js';
133
+ import { importApplyAction } from './commands/importApply.js';
134
+ import { importDataAction } from './commands/importData.js';
135
+ import { type ImportCutoverOpts, importCutoverAction } from './commands/importCutover.js';
131
136
  import { ipsForgetAction, ipsListAction, ipsSetAction } from './commands/hostIps.js';
132
137
  import {
133
138
  ssoAddAction, ssoListAction, ssoRemoveAction,
@@ -358,6 +363,24 @@ databases
358
363
  databasePgbouncer(getClient(), dbId, action, opts),
359
364
  );
360
365
 
366
+ // ── Major-version upgrades (O2) ──────────────────────────────────────────
367
+ const upgradeCmd = databases
368
+ .command('upgrade')
369
+ .description('Move a PostgreSQL database to a newer major: dump, restore into a new volume, compare, switch (previous volume kept)');
370
+ upgradeCmd.command('status <dbId>').description('The running major, the majors it can move to, and the upgrade history')
371
+ .action((dbId: string) => upgradeStatus(getClient(), dbId));
372
+ upgradeCmd.command('start <dbId>').description('Upgrade to a newer major; the database stops until it is done (database admin)')
373
+ .requiredOption('--to <major>', 'Target PostgreSQL major, e.g. 18')
374
+ .option('-y, --yes', 'Skip the confirmation')
375
+ .option('--no-wait', 'Return once the upgrade is admitted')
376
+ .action((dbId: string, o: { to?: string; yes?: boolean; wait?: boolean }) => upgradeStart(getClient(), dbId, o));
377
+ upgradeCmd.command('rollback <dbId> <upgradeId>').description('Point the database back at the previous volume (database admin)')
378
+ .option('-y, --yes', 'Skip the confirmation')
379
+ .action((dbId: string, upgradeId: string, o: { yes?: boolean }) => upgradeRollback(getClient(), dbId, upgradeId, o));
380
+ upgradeCmd.command('finalize <dbId> <upgradeId>').description('Delete the volume the database no longer uses; no rollback after this (database admin)')
381
+ .option('-y, --yes', 'Skip the confirmation')
382
+ .action((dbId: string, upgradeId: string, o: { yes?: boolean }) => upgradeFinalize(getClient(), dbId, upgradeId, o));
383
+
361
384
  // ── PostgreSQL point-in-time recovery (R3) ───────────────────────────────
362
385
  const pitrCmd = databases
363
386
  .command('pitr')
@@ -695,6 +718,44 @@ egressCmd
695
718
  .option('--driver <name>', 'Target a specific driver (default: first registered)')
696
719
  .action((projectId: string, opts: { driver?: string }) => egressClearAction(getClient(), projectId, opts));
697
720
 
721
+ // ── Import from another platform (A1 step 1: read-only plan) ─────────────
722
+ const importCmd = program.command('import').description('Move from Coolify, Dokploy, CapRover or Heroku: plan (read-only), apply, data, cutover');
723
+ importCmd
724
+ .command('plan <source>')
725
+ .description('Read a coolify | dokploy | caprover panel, or a Heroku app\'s app.json and Procfile, and print what NineDeploy would create. Nothing is created or changed. The credential comes from NINEDEPLOY_IMPORT_TOKEN (Coolify, Dokploy) or NINEDEPLOY_IMPORT_PASSWORD (CapRover, plus NINEDEPLOY_IMPORT_OTP), or a hidden prompt; Heroku needs none.')
726
+ .requiredOption('--url <url>', 'The source panel (e.g. https://coolify.example.com), or for heroku the app\'s git repository')
727
+ .option('--dir <path>', 'heroku: a checkout of the repository holding app.json / Procfile (default: .)')
728
+ .option('--branch <branch>', 'heroku: the branch NineDeploy deploys (default: main)')
729
+ .option('--app <name>', 'heroku: the Heroku app name, for the pg:backups command')
730
+ .option('--json', 'Print the plan as JSON')
731
+ .option('--out <file>', 'Also write the plan (JSON, names only) to a file')
732
+ .action((source: string, opts: { url: string; json?: boolean; out?: string; dir?: string; branch?: string; app?: string }) => importPlanAction(source, opts));
733
+ importCmd
734
+ .command('apply <plan>')
735
+ .description('Create the services and databases of a saved plan (import plan --out) on this panel. Without --yes it only shows what it would create. Never deploys and never adds a domain; keeps a resumable receipt next to the plan.')
736
+ .option('--yes', 'Create them')
737
+ .option('--project <id>', 'Tag every created service and database with this project')
738
+ .option('--dotenv <service=path>', 'Environment values for a service, from a .env file (repeatable)', (v: string, prev: string[]) => [...prev, v], [] as string[])
739
+ .option('--receipt <file>', 'Receipt file (default: <plan>.receipt.json)')
740
+ .action((plan: string, opts: { yes?: boolean; project?: string; dotenv?: string[]; receipt?: string }) => importApplyAction(plan, opts));
741
+ importCmd
742
+ .command('data <plan>')
743
+ .description('Move database contents into the databases import apply created. Without --yes it prints the cutover order and the dump command to run on the source host for each database.')
744
+ .option('--dump <database=file>', 'A dump to import into that database (repeatable); its sha256 is checked by the panel', (v: string, prev: string[]) => [...prev, v], [] as string[])
745
+ .option('--volume <service=file>', 'A gzipped tar of that service\'s data volume (repeatable); restored after a snapshot of what is there', (v: string, prev: string[]) => [...prev, v], [] as string[])
746
+ .option('--yes', 'Import the dumps and archives')
747
+ .option('--receipt <file>', 'Receipt file (default: <plan>.receipt.json)')
748
+ .action((plan: string, opts: { dump?: string[]; volume?: string[]; yes?: boolean; receipt?: string }) => importDataAction(plan, opts));
749
+ importCmd
750
+ .command('cutover <plan>')
751
+ .description('Add the planned domains once each service runs a successful deploy and the hostname\'s DNS already points at this panel. Without --yes it only reports what is ready.')
752
+ .option('--yes', 'Add (or verify) the domains that pass')
753
+ .option('--expect <address>', 'The address DNS must point at (repeatable; default: Settings → DNS records)', (v: string, prev: string[]) => [...prev, v], [] as string[])
754
+ .option('--resolver <ip>', 'DNS server to query (default: the system\'s)')
755
+ .option('--skip-dns-check <host>', 'Skip the DNS check for a proxied host (repeatable; refused while the Cloudflare integration is on)', (v: string, prev: string[]) => [...prev, v], [] as string[])
756
+ .option('--receipt <file>', 'Receipt file (default: <plan>.receipt.json)')
757
+ .action((plan: string, opts: ImportCutoverOpts) => importCutoverAction(plan, opts));
758
+
698
759
  // ── SSO (G-22) ───────────────────────────────────────────────────────────
699
760
  const ssoCmd = program.command('sso').description('Manage OIDC / SAML SSO providers (G-22)');
700
761
  ssoCmd.command('list').description('List every configured provider').action(() => ssoListAction(getClient()));
@@ -0,0 +1,141 @@
1
+ /**
2
+ * CapRover (field names checked against v1.16.0, 74a61ff2): one call,
3
+ * `GET /api/v2/user/apps/appDefinitions/`, returns every app. The fetcher
4
+ * signs in, sends `x-captain-auth`, and unwraps CapRover's `{status, data}`
5
+ * envelope (errors arrive with HTTP 200 and a non-100 status).
6
+ *
7
+ * CapRover returns secrets in plain text in that list: env values, the git
8
+ * password and SSH key, webhook and deploy tokens, the HTTP-auth hash. The
9
+ * adapter copies none of them into the plan.
10
+ */
11
+ import {
12
+ DATA_NOT_MOVED, emptyPlan, firstVolumeMount, planDomain, replicasFor, stripUserinfo,
13
+ type ImportPlan, type PlanDatabase, type PlanEngine, type PlanIssue, type PlanService, type SourceGet,
14
+ } from './plan.js';
15
+
16
+ type Obj = Record<string, unknown>;
17
+ const str = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() ? v.trim() : undefined);
18
+ const list = (v: unknown): Obj[] => (Array.isArray(v) ? (v as Obj[]) : []);
19
+
20
+ /** One-click database apps carry the template name as a tag (OneClickAppDeploymentHelper). */
21
+ const ONE_CLICK_DATABASES: Record<string, PlanEngine> = {
22
+ postgres: 'postgres', mysql: 'mysql', mariadb: 'mariadb', mongodb: 'mongo', redis: 'redis', keydb: 'keydb', dragonfly: 'dragonfly', clickhouse: 'clickhouse',
23
+ };
24
+
25
+ /** The image CapRover runs for the deployed version, if it is not one it built itself. */
26
+ function deployedImage(app: Obj): { image?: string; builtByCaprover: boolean; neverDeployed?: boolean } {
27
+ const deployed = Number(app['deployedVersion']);
28
+ const version = list(app['versions']).find((v) => Number(v['version']) === deployed);
29
+ const image = str(version?.['deployedImageName']);
30
+ if (!image) return { builtByCaprover: true };
31
+ // A registered app runs CapRover's placeholder until its first deploy (checked live on v1.16.0).
32
+ if (/^caprover\/caprover-placeholder-app(:|$)/.test(image)) return { builtByCaprover: false, neverDeployed: true };
33
+ // Images CapRover builds are tagged img-captain-<app>, optionally in its own registry.
34
+ if (/(^|\/)img-captain-/.test(image)) return { builtByCaprover: true };
35
+ return { image, builtByCaprover: false };
36
+ }
37
+
38
+ function appItem(app: Obj, rootDomain: string): PlanService | null {
39
+ const name = String(app['appName']);
40
+ const issues: PlanIssue[] = [];
41
+ const repoInfo = (app['appPushWebhook'] as Obj | undefined)?.['repoInfo'] as Obj | undefined;
42
+ const repo = str(repoInfo?.['repo']);
43
+ const { image, builtByCaprover, neverDeployed } = deployedImage(app);
44
+ const base = { name, type: 'docker' as const };
45
+ let kind: PlanService['kind'];
46
+ let create: PlanService['create'];
47
+ if (repo) {
48
+ kind = 'git';
49
+ create = { ...base, repoUrl: stripUserinfo(repo), branch: str(repoInfo?.['branch']), build: { buildPack: 'auto' } };
50
+ const def = str(app['captainDefinitionRelativeFilePath']);
51
+ issues.push({ level: 'info', option: 'captain-definition', detail: `Built from ${def ?? './captain-definition'}; NineDeploy detects the build (a Dockerfile it references keeps working with Build pack: Dockerfile).` });
52
+ if (str(repoInfo?.['user']) || str(repoInfo?.['sshKey']) || str(repoInfo?.['password'])) {
53
+ issues.push({ level: 'manual', option: 'repoInfo', detail: 'Repository credentials do not transfer: reconnect the repository in NineDeploy.' });
54
+ }
55
+ } else if (image && !builtByCaprover) {
56
+ kind = 'image';
57
+ create = { ...base, image };
58
+ } else if (neverDeployed) {
59
+ kind = 'image';
60
+ create = base;
61
+ issues.push({ level: 'manual', option: 'deployedVersion', detail: 'Never deployed in CapRover (it still runs the placeholder app): set an image or a repository for it in NineDeploy.' });
62
+ } else {
63
+ kind = 'image';
64
+ create = base;
65
+ issues.push({ level: 'unsupported', option: 'deployedImageName', detail: 'Built by CapRover from an upload or the CLI, with no repository on record: its image lives in CapRover. Push the source to a repository, or the image to a registry, then import.' });
66
+ }
67
+
68
+ const port = Number(app['containerHttpPort']);
69
+ if (Number.isInteger(port) && port > 0) create.port = port;
70
+ const replicas = replicasFor(app['instanceCount'], issues);
71
+ if (replicas) create.replicas = replicas;
72
+ if (app['instanceCount'] === 0) issues.push({ level: 'info', option: 'instanceCount', detail: 'Stopped in CapRover (0 instances); NineDeploy runs one when it deploys the service.' });
73
+
74
+ const domains: PlanService['domains'] = [];
75
+ if (app['notExposeAsWebApp'] !== true) {
76
+ domains.push(planDomain(`${name}.${rootDomain}`, app['hasDefaultSubDomainSsl'] === true, true));
77
+ issues.push({ level: 'info', option: 'default subdomain', detail: `${name}.${rootDomain} is CapRover's own subdomain; it moves only if that DNS points at NineDeploy.` });
78
+ }
79
+ for (const d of list(app['customDomain'])) {
80
+ const host = str(d['publicDomain']);
81
+ if (host) domains.push(planDomain(host, d['hasSsl'] === true));
82
+ }
83
+
84
+ const volumes: PlanService['volumes'] = list(app['volumes'])
85
+ .map((v) => ({ mountPath: str(v['containerPath']) ?? '', source: (str(v['hostPath']) ? 'bind' : 'volume') as 'bind' | 'volume', name: str(v['volumeName']) }))
86
+ .filter((v) => v.mountPath);
87
+ const mount = firstVolumeMount(volumes, issues);
88
+ if (mount) create.volumeMount = mount;
89
+
90
+ const ports = list(app['ports']);
91
+ if (ports.length) issues.push({ level: 'manual', option: 'ports', detail: `${ports.length} host port mapping(s) (${ports.map((p) => `${p['hostPort']}:${p['containerPort']}`).join(', ')}); set Public Host Port or port mappings by hand.` });
92
+ if (app['httpAuth']) issues.push({ level: 'manual', option: 'httpAuth', detail: 'HTTP basic auth was on; protect the service with forward auth in NineDeploy (the password hash is not carried).' });
93
+ if (str(app['customNginxConfig'])) issues.push({ level: 'unsupported', option: 'customNginxConfig', detail: 'A custom nginx config has no equivalent; NineDeploy routes with Traefik (redirects, headers and middlewares are set per domain).' });
94
+ if (str(app['preDeployFunction'])) issues.push({ level: 'unsupported', option: 'preDeployFunction', detail: 'CapRover pre-deploy scripts have no equivalent.' });
95
+ if (str(app['serviceUpdateOverride'])) issues.push({ level: 'unsupported', option: 'serviceUpdateOverride', detail: 'Swarm service overrides are not carried.' });
96
+ if (str(app['nodeId'])) issues.push({ level: 'manual', option: 'nodeId', detail: 'Pinned to a CapRover node; choose a NineDeploy server for it.' });
97
+
98
+ return {
99
+ ref: name, name, kind, create, domains,
100
+ env: list(app['envVars']).map((e) => str(e['key'])).filter((k): k is string => !!k).map((key) => ({ key })),
101
+ volumes, issues,
102
+ // The Swarm service, as for one-click databases (srv-captain-- only for isLegacyAppName apps).
103
+ runtime: { kind: 'swarm-service', name: app['isLegacyAppName'] === true ? `srv-captain--${name}` : name },
104
+ };
105
+ }
106
+
107
+ /** A one-click database app is listed as a database, not as a service. */
108
+ function databaseOf(app: Obj): PlanDatabase | null {
109
+ for (const t of list(app['tags'])) {
110
+ const engine = ONE_CLICK_DATABASES[String(t['tagName'] ?? '').toLowerCase()];
111
+ if (!engine) continue;
112
+ const { image, neverDeployed } = deployedImage(app);
113
+ const issues: PlanIssue[] = [DATA_NOT_MOVED];
114
+ if (neverDeployed) issues.push({ level: 'info', option: 'deployedVersion', detail: 'Never deployed in CapRover, so the source has no engine version: choose one in NineDeploy.' });
115
+ if (list(app['ports']).length) issues.push({ level: 'manual', option: 'ports', detail: 'A host port was published; turn on public access in NineDeploy if you still need it.' });
116
+ // The app's Swarm service is named after it; apps from before the namespace change
117
+ // (isLegacyAppName) keep the srv-captain-- prefix. Checked live on v1.16.0.
118
+ const appName = String(app['appName']);
119
+ const service = app['isLegacyAppName'] === true ? `srv-captain--${appName}` : appName;
120
+ return { ref: appName, name: appName, engine, image: image ?? null, runtime: { kind: 'swarm-service', name: service }, issues };
121
+ }
122
+ return null;
123
+ }
124
+
125
+ export async function planFromCaprover(get: SourceGet, url: string): Promise<ImportPlan> {
126
+ const data = ((await get('/api/v2/user/apps/appDefinitions/')) ?? {}) as Obj;
127
+ const info = (await get('/api/v2/user/system/info/').catch(() => null)) as Obj | null;
128
+ const rootDomain = str(info?.['rootDomain']) ?? str(data['rootDomain']) ?? 'captain.localhost';
129
+ const versionInfo = (await get('/api/v2/user/system/versionInfo/').catch(() => null)) as Obj | null;
130
+ const plan = emptyPlan('caprover', url, str(versionInfo?.['currentVersion']) ?? null, 'every app on this CapRover instance');
131
+ for (const app of list(data['appDefinitions'])) {
132
+ if (!str(app['appName'])) continue;
133
+ const db = databaseOf(app);
134
+ if (db) plan.databases.push(db);
135
+ else {
136
+ const item = appItem(app, rootDomain);
137
+ if (item) plan.services.push(item);
138
+ }
139
+ }
140
+ return plan;
141
+ }