ninedeploy 0.18.26 → 0.18.28

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 +27 -0
  2. package/dist/commands/databaseUpgrade.d.ts.map +1 -0
  3. package/dist/commands/databaseUpgrade.js +151 -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 +144 -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,445 @@
1
+ /**
2
+ * `ninedeploy import apply <plan.json>`
3
+ *
4
+ * A1 step 2 (docs/plans/COMPETITIVE_ROADMAP_2026-11.md): create what a saved
5
+ * import plan describes, on the panel this CLI is signed in to. It reads the
6
+ * plan file, never the source, so it needs no source credential.
7
+ *
8
+ * - It creates each service that has something to deploy from (an image or a
9
+ * repository), and each database that has a NineDeploy engine, empty.
10
+ * - It never deploys and never adds a domain. Adding a domain writes the proxy
11
+ * config, so a certificate would be requested while DNS still points at
12
+ * the source; with the Cloudflare integration on, a DNS record would be
13
+ * written too. Domains move at the cutover (step 3).
14
+ * - A value in such a file that still names a source database (a copied
15
+ * connection string) gets the new database attached under that variable
16
+ * when the panel's string replaces it exactly; the rest are listed for a
17
+ * person (lib/importers/connections.ts).
18
+ * - Environment values come only from a file the person passes
19
+ * (--dotenv <service>=<path>), through the panel's .env import. The plan
20
+ * holds names only, so a variable without a value is reported, not created
21
+ * empty. The flag is not called --env-file: Node (checked on v26.13) reads
22
+ * --env-file from anywhere in argv, even after the script name, and exits
23
+ * before the CLI starts.
24
+ * - A receipt is kept next to the plan. Each item is written as pending
25
+ * before its create call and as done after it, so a run that stops can be
26
+ * repeated. Creates are sent with reuseExisting, which hands back the
27
+ * caller's own idle service or database of the same definition instead of
28
+ * failing on the name. Done items are never sent again.
29
+ *
30
+ * The receipt holds ids, states and notes, never a value.
31
+ */
32
+ import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
33
+ import { getClient, type NineDeployClient } from '../client.js';
34
+ import { loadConfig } from '../config.js';
35
+ import { c, error, header, info, success } from '../lib/format.js';
36
+ import { type ConnectionHit, findConnections } from '../lib/importers/connections.js';
37
+ import type { ImportPlan, PlanEngine, PlanIssue, PlanService } from '../lib/importers/plan.js';
38
+
39
+ type CreateServiceInput = Parameters<NineDeployClient['services']['create']>[0];
40
+ type CreateDatabaseInput = Parameters<NineDeployClient['databases']['create']>[0];
41
+
42
+ /** The panel calls apply makes; NineDeployClient satisfies it. */
43
+ export interface ApplyClient {
44
+ services: {
45
+ create: (input: CreateServiceInput) => Promise<{ id: number }>;
46
+ /** Settings set right after create (the plan's `settings`, e.g. a worker's `running` health check). */
47
+ update: (id: number, input: { healthCheck?: 'running' }) => Promise<unknown>;
48
+ };
49
+ databases: { create: (input: CreateDatabaseInput) => Promise<{ id: number }> };
50
+ env: { import: (serviceId: number, content: string) => Promise<{ imported: number; skipped: number; errors: Array<{ line: number; message: string }> }> };
51
+ attachments: {
52
+ list: (serviceId: number) => Promise<Array<{ id: number; databaseId: number; envAlias: string }>>;
53
+ create: (serviceId: number, input: { databaseId: number; envAlias?: string; reuseExisting?: boolean }) => Promise<{ id: number; databaseId: number; envAlias: string }>;
54
+ };
55
+ }
56
+
57
+ export type ReceiptState = 'pending' | 'done' | 'skipped' | 'failed';
58
+ export interface ReceiptItem {
59
+ kind: 'service' | 'database' | 'env' | 'data' | 'domain' | 'attach' | 'volume';
60
+ ref: string;
61
+ name: string;
62
+ state: ReceiptState;
63
+ /** The NineDeploy id (the service's for an env item, the database's for a data item, the domain's for a domain item). */
64
+ id?: number;
65
+ /** Data and volume items (`import data`): the panel's import id, and the file's sha256 and size. */
66
+ importId?: number;
67
+ sha256?: string;
68
+ bytes?: number;
69
+ at: string;
70
+ notes: string[];
71
+ }
72
+ export interface ImportReceipt {
73
+ receiptVersion: 1;
74
+ panel: string;
75
+ source: { kind: string; url: string };
76
+ items: Record<string, ReceiptItem>;
77
+ }
78
+
79
+ export interface ApplyOptions {
80
+ /** Tag every created service and database with this project. */
81
+ project?: number;
82
+ /** Service name → .env file content. */
83
+ envFiles?: Record<string, string>;
84
+ }
85
+ export interface ApplyDeps {
86
+ client: ApplyClient;
87
+ panel: string;
88
+ receipt: ImportReceipt | null;
89
+ save: (receipt: ImportReceipt) => void;
90
+ now?: () => Date;
91
+ }
92
+
93
+ /** Accept only a plan this CLI wrote (planVersion 1) with the parts apply reads. */
94
+ export function validatePlan(raw: unknown): ImportPlan {
95
+ const p = raw as Partial<ImportPlan> | null;
96
+ if (!p || typeof p !== 'object' || p.planVersion !== 1) {
97
+ throw new Error('Not an import plan this CLI can apply (planVersion 1). Make one with `ninedeploy import plan <source> --url … --out plan.json`.');
98
+ }
99
+ if (!p.source || typeof p.source.url !== 'string' || !Array.isArray(p.services) || !Array.isArray(p.databases)) {
100
+ throw new Error('The plan file is incomplete: it needs source, services and databases.');
101
+ }
102
+ for (const s of p.services) {
103
+ if (!s || typeof s.ref !== 'string' || typeof s.name !== 'string' || !s.create || typeof s.create !== 'object' || !Array.isArray(s.env) || !Array.isArray(s.domains) || !Array.isArray(s.issues)) {
104
+ throw new Error('A service in the plan is incomplete (ref, name, create, env, domains and issues are required).');
105
+ }
106
+ }
107
+ for (const d of p.databases) {
108
+ if (!d || typeof d.ref !== 'string' || typeof d.name !== 'string' || !Array.isArray(d.issues)) {
109
+ throw new Error('A database in the plan is incomplete (ref, name and issues are required).');
110
+ }
111
+ }
112
+ return p as ImportPlan;
113
+ }
114
+
115
+ /** The image repository NineDeploy runs for each engine (engine/database.ts). */
116
+ const ENGINE_REPOS: Record<PlanEngine, string> = {
117
+ postgres: 'postgres', mysql: 'mysql', mariadb: 'mariadb', mongo: 'mongo', redis: 'redis',
118
+ keydb: 'eqalpha/keydb', dragonfly: 'ghcr.io/dragonflydb/dragonfly', clickhouse: 'clickhouse/clickhouse-server',
119
+ };
120
+
121
+ /**
122
+ * The source's version, when its image is the same repository NineDeploy runs
123
+ * and the tag is a version (starts with a digit). Otherwise undefined, and
124
+ * the panel picks its default.
125
+ */
126
+ export function databaseVersion(engine: PlanEngine, image: string | null): string | undefined {
127
+ if (!image) return undefined;
128
+ const ref = image.split('@')[0]!;
129
+ const colon = ref.lastIndexOf(':');
130
+ if (colon < 0 || ref.slice(colon).includes('/')) return undefined;
131
+ const repo = ref.slice(0, colon).replace(/^docker\.io\//, '').replace(/^library\//, '');
132
+ const tag = ref.slice(colon + 1);
133
+ if (repo !== ENGINE_REPOS[engine] || !/^[0-9][A-Za-z0-9_.-]*$/.test(tag)) return undefined;
134
+ return tag;
135
+ }
136
+
137
+ /** Variable names a .env file defines (values are never looked at). */
138
+ export function envFileKeys(content: string): string[] {
139
+ const keys: string[] = [];
140
+ for (const line of content.split(/\r?\n/)) {
141
+ const m = /^[ \t]*(?:export[ \t]+)?([A-Za-z_][A-Za-z0-9_]*)[ \t]*=/.exec(line);
142
+ if (m) keys.push(m[1]!);
143
+ }
144
+ return keys;
145
+ }
146
+
147
+ const hasSource = (s: PlanService): boolean => Boolean(s.create.image || s.create.repoUrl);
148
+ const issueNotes = (issues: PlanIssue[], skip: string[] = []): string[] =>
149
+ issues.filter((i) => i.level !== 'info' && !skip.includes(i.option)).map((i) => `${i.level === 'unsupported' ? 'Not carried' : 'To do'}: ${i.detail}`);
150
+ /** Which domains move at the cutover, and which stay with the source (its own generated hosts). */
151
+ function domainNotes(s: PlanService): string[] {
152
+ const moving = s.domains.filter((d) => !d.sourceDefault).map((d) => d.host);
153
+ const staying = s.domains.filter((d) => d.sourceDefault).map((d) => d.host);
154
+ return [
155
+ ...(moving.length ? [`Domains wait for the cutover (step 3): ${moving.join(', ')}.`] : []),
156
+ ...(staying.length ? [`The source's own host stays with it: ${staying.join(', ')}.`] : []),
157
+ ];
158
+ }
159
+ const message = (err: unknown): string => (err instanceof Error ? err.message : String(err));
160
+
161
+ /** Create what the plan describes, recording every step in the receipt. */
162
+ export async function applyPlan(plan: ImportPlan, opts: ApplyOptions, deps: ApplyDeps): Promise<ImportReceipt> {
163
+ const now = deps.now ?? (() => new Date());
164
+ const receipt: ImportReceipt = deps.receipt ?? { receiptVersion: 1, panel: deps.panel, source: { kind: plan.source.kind, url: plan.source.url }, items: {} };
165
+ if (receipt.receiptVersion !== 1 || !receipt.items || typeof receipt.items !== 'object') throw new Error('The receipt file is not one this CLI wrote (receiptVersion 1).');
166
+ if (receipt.panel !== deps.panel) {
167
+ throw new Error(`The receipt is for ${receipt.panel}, but the CLI is signed in to ${deps.panel}. Sign in to that panel, or pass another --receipt for this one.`);
168
+ }
169
+ if (receipt.source.kind !== plan.source.kind || receipt.source.url !== plan.source.url) {
170
+ throw new Error(`The receipt is for a plan of ${receipt.source.kind} at ${receipt.source.url}, not ${plan.source.kind} at ${plan.source.url}. Pass another --receipt.`);
171
+ }
172
+ const envFiles = opts.envFiles ?? {};
173
+ const unknown = Object.keys(envFiles).filter((name) => !plan.services.some((s) => s.name === name));
174
+ if (unknown.length) throw new Error(`--dotenv names no service in the plan: ${unknown.join(', ')}.`);
175
+
176
+ const put = (key: string, item: Omit<ReceiptItem, 'at'>): void => {
177
+ receipt.items[key] = { ...item, at: now().toISOString() };
178
+ deps.save(receipt);
179
+ };
180
+
181
+ for (const s of plan.services) {
182
+ const key = `service:${s.ref}`;
183
+ const base = { kind: 'service' as const, ref: s.ref, name: s.name };
184
+ if (receipt.items[key]?.state !== 'done') {
185
+ const notes = issueNotes(s.issues);
186
+ if (!hasSource(s)) {
187
+ put(key, { ...base, state: 'skipped', notes: ['Nothing to create it from: the plan has no image and no repository for it.', ...notes] });
188
+ continue;
189
+ }
190
+ put(key, { ...base, state: 'pending', notes: [] });
191
+ // Only the panel call is caught: a receipt that cannot be written stops the run.
192
+ let created: { id: number };
193
+ try {
194
+ created = await deps.client.services.create({ ...s.create, reuseExisting: true, ...(opts.project ? { tagProjectIds: [opts.project] } : {}) } as CreateServiceInput);
195
+ // Before done: a run that stops here creates (reuses) and sets it again.
196
+ if (s.settings?.healthCheck) await deps.client.services.update(created.id, { healthCheck: s.settings.healthCheck });
197
+ } catch (err) {
198
+ put(key, { ...base, state: 'failed', notes: [message(err)] });
199
+ continue;
200
+ }
201
+ const domains = domainNotes(s);
202
+ const settings = s.settings?.healthCheck ? [`Health check: ${s.settings.healthCheck} (the process answers no HTTP).`] : [];
203
+ put(key, { ...base, state: 'done', id: created.id, notes: ['Created, not deployed.', ...settings, ...domains, ...notes] });
204
+ }
205
+ const serviceId = receipt.items[key]!.id!;
206
+
207
+ const envKey = `env:${s.ref}`;
208
+ const file = envFiles[s.name];
209
+ if (receipt.items[envKey]?.state === 'done' || (s.env.length === 0 && file === undefined)) continue;
210
+ const envBase = { kind: 'env' as const, ref: s.ref, name: s.name, id: serviceId };
211
+ if (file === undefined) {
212
+ put(envKey, { ...envBase, state: 'skipped', notes: [`${s.env.length} variable(s) need a value: ${s.env.map((e) => e.key).join(', ')}. Pass --dotenv ${s.name}=<path>.`] });
213
+ continue;
214
+ }
215
+ put(envKey, { ...envBase, state: 'pending', notes: [] });
216
+ let result: Awaited<ReturnType<ApplyClient['env']['import']>>;
217
+ try {
218
+ result = await deps.client.env.import(serviceId, file);
219
+ } catch (err) {
220
+ put(envKey, { ...envBase, state: 'failed', notes: [message(err)] });
221
+ continue;
222
+ }
223
+ {
224
+ const defined = new Set(envFileKeys(file));
225
+ const missing = s.env.map((e) => e.key).filter((k) => !defined.has(k));
226
+ const notes = [`${result.imported} imported, ${result.skipped} skipped.`];
227
+ // Line numbers only: the panel's message could quote the line.
228
+ if (result.errors.length) notes.push(`The panel refused line(s) ${result.errors.map((e) => e.line).join(', ')} of the file.`);
229
+ if (missing.length) notes.push(`In the plan but not in the file: ${missing.join(', ')}.`);
230
+ put(envKey, { ...envBase, state: result.errors.length ? 'failed' : 'done', notes });
231
+ }
232
+ }
233
+
234
+ for (const d of plan.databases) {
235
+ const key = `database:${d.ref}`;
236
+ if (receipt.items[key]?.state === 'done') continue;
237
+ const base = { kind: 'database' as const, ref: d.ref, name: d.name };
238
+ if (!d.engine) {
239
+ put(key, { ...base, state: 'skipped', notes: ['NineDeploy has no managed equivalent.', ...issueNotes(d.issues, ['data'])] });
240
+ continue;
241
+ }
242
+ const version = databaseVersion(d.engine, d.image);
243
+ put(key, { ...base, state: 'pending', notes: [] });
244
+ let created: { id: number };
245
+ try {
246
+ created = await deps.client.databases.create({
247
+ name: d.name, engine: d.engine, reuseExisting: true,
248
+ ...(version ? { version } : {}),
249
+ ...(opts.project ? { projectId: opts.project } : {}),
250
+ });
251
+ } catch (err) {
252
+ put(key, { ...base, state: 'failed', notes: [message(err)] });
253
+ continue;
254
+ }
255
+ {
256
+ const versionNote = version
257
+ ? `Version ${version}, as in the source.`
258
+ : `NineDeploy's default ${d.engine} version${d.image ? ` (the source runs ${d.image}; check they are compatible before restoring)` : ''}.`;
259
+ put(key, { ...base, state: 'done', id: created.id, notes: ['Created empty: the data moves in step 3.', versionNote, ...issueNotes(d.issues, ['data'])] });
260
+ }
261
+ }
262
+
263
+ // Attachments: the plan's own (Heroku add-ons, set as DATABASE_URL /
264
+ // REDIS_URL there) and connection strings in the .env files that still name
265
+ // the source's databases, attached under that variable when the panel's
266
+ // string replaces them exactly; the rest are listed for a person.
267
+ for (const s of plan.services) {
268
+ const file = envFiles[s.name];
269
+ const svc = receipt.items[`service:${s.ref}`];
270
+ if (svc?.state !== 'done' || svc.id === undefined) continue;
271
+ const wishes: Array<ConnectionHit & { addon?: true }> = [];
272
+ for (const a of s.attachments ?? []) {
273
+ const db = plan.databases.find((d) => d.ref === a.database);
274
+ if (db && !wishes.some((w) => w.key === a.envAlias)) wishes.push({ key: a.envAlias, database: db, host: '', action: 'attach', reason: '', addon: true });
275
+ }
276
+ if (file !== undefined) for (const h of findConnections(plan, file)) if (!wishes.some((w) => w.key === h.key)) wishes.push(h);
277
+ let current: Array<{ id: number; databaseId: number; envAlias: string }> | null = null;
278
+ for (const h of wishes) {
279
+ const key = `attach:${s.ref}:${h.key}`;
280
+ if (receipt.items[key]?.state === 'done') continue;
281
+ const base = { kind: 'attach' as const, ref: `${s.ref}:${h.key}`, name: `${h.key} of ${s.name}` };
282
+ const what = h.addon ? `the ${h.database!.name} add-on` : h.database ? `the source's database ${h.database.name} (${h.host})` : `the old platform (${h.host})`;
283
+ if (h.action === 'manual') {
284
+ put(key, { ...base, state: 'skipped', notes: [`${h.key} names ${what}: ${h.reason}.`] });
285
+ continue;
286
+ }
287
+ const db = h.database!;
288
+ const made = receipt.items[`database:${db.ref}`];
289
+ if (made?.state !== 'done' || made.id === undefined) {
290
+ put(key, { ...base, state: 'skipped', notes: [`${h.key} names ${what}, which is not created here yet; run again once it is.`] });
291
+ continue;
292
+ }
293
+ try {
294
+ current ??= await deps.client.attachments.list(svc.id);
295
+ } catch (err) {
296
+ put(key, { ...base, state: 'failed', notes: [message(err)] });
297
+ continue;
298
+ }
299
+ const same = current.find((a) => a.envAlias === h.key);
300
+ if (same && same.databaseId !== made.id) {
301
+ put(key, { ...base, state: 'failed', notes: [`${h.key} is already set by another attached database (#${same.databaseId}); detach it, or edit the variable by hand.`] });
302
+ continue;
303
+ }
304
+ // The panel attaches a database to a service once, under one variable
305
+ // (reuseExisting hands back that one whatever its alias), so a second
306
+ // variable naming the same database (DATABASE_URL and DIRECT_URL) is
307
+ // left to a person rather than recorded as attached.
308
+ const onlyOne = (alias: string) =>
309
+ put(key, { ...base, state: 'skipped', notes: [`${h.key} names ${what}, but ${db.name} is already attached as ${alias}, and the panel gives a database one variable per service. Set ${h.key} by hand (Databases → ${db.name} → Connection).`] });
310
+ const other = same ? undefined : current.find((a) => a.databaseId === made.id);
311
+ if (other) {
312
+ onlyOne(other.envAlias);
313
+ continue;
314
+ }
315
+ let id: number;
316
+ if (same) id = same.id;
317
+ else {
318
+ put(key, { ...base, state: 'pending', notes: [] });
319
+ let got: { id: number; databaseId: number; envAlias: string };
320
+ try {
321
+ got = await deps.client.attachments.create(svc.id, { databaseId: made.id, envAlias: h.key, reuseExisting: true });
322
+ } catch (err) {
323
+ put(key, { ...base, state: 'failed', notes: [message(err)] });
324
+ continue;
325
+ }
326
+ if (got.envAlias !== h.key) {
327
+ onlyOne(got.envAlias);
328
+ continue;
329
+ }
330
+ current.push(got);
331
+ id = got.id;
332
+ }
333
+ put(key, {
334
+ ...base, state: 'done', id,
335
+ notes: [h.addon
336
+ ? `Attached ${db.name} as ${h.key}, as Heroku sets it for the add-on. It is set at every deploy.`
337
+ : `Attached ${db.name} as ${h.key} (the copied value named ${h.host}). The service's own ${h.key} still shows the old value; the attachment replaces it at every deploy.`],
338
+ });
339
+ }
340
+ }
341
+ return receipt;
342
+ }
343
+
344
+ const MARK: Record<ReceiptState, string> = { done: '✓', skipped: '–', failed: '✗', pending: '…' };
345
+
346
+ /** The receipt, item by item, in plan order. */
347
+ export function renderReceipt(receipt: ImportReceipt): string {
348
+ const out: string[] = [];
349
+ for (const item of Object.values(receipt.items)) {
350
+ const id = item.id !== undefined && item.kind !== 'env' && item.kind !== 'data' && item.kind !== 'volume' ? ` #${item.id}` : '';
351
+ const label = item.kind === 'env' ? `env of ${item.name}` : item.kind === 'data' ? `data of ${item.name}` : item.kind === 'volume' ? `volume of ${item.name}` : `${item.kind} ${item.name}`;
352
+ out.push(` ${MARK[item.state]} ${label}${id}: ${item.state}`);
353
+ for (const n of item.notes) out.push(` ${n}`);
354
+ }
355
+ return out.join('\n');
356
+ }
357
+
358
+ /** What --yes would do, without calling the panel. */
359
+ export function renderApplyPreview(plan: ImportPlan, receipt: ImportReceipt | null, envFiles: Record<string, string>): string {
360
+ const done = (key: string) => receipt?.items[key]?.state === 'done';
361
+ const out: string[] = ['Services (created, not deployed):'];
362
+ for (const s of plan.services) {
363
+ if (done(`service:${s.ref}`)) out.push(` ✓ ${s.name}: already created (#${receipt!.items[`service:${s.ref}`]!.id})`);
364
+ else if (!hasSource(s)) out.push(` – ${s.name}: skipped, no image and no repository`);
365
+ else out.push(` + ${s.name}: ${s.create.image ? `image ${s.create.image}` : `git ${s.create.repoUrl}${s.create.branch ? `@${s.create.branch}` : ''}`}`);
366
+ if (s.env.length) out.push(` env: ${envFiles[s.name] !== undefined ? 'from the file passed' : `${s.env.length} name(s), no values (pass --dotenv ${s.name}=<path>)`}`);
367
+ for (const n of domainNotes(s)) out.push(` ${n}`);
368
+ }
369
+ out.push('Databases (created empty):');
370
+ for (const d of plan.databases) {
371
+ if (done(`database:${d.ref}`)) out.push(` ✓ ${d.name}: already created (#${receipt!.items[`database:${d.ref}`]!.id})`);
372
+ else if (!d.engine) out.push(` – ${d.name}: skipped, no managed equivalent`);
373
+ else out.push(` + ${d.name}: ${d.engine}${databaseVersion(d.engine, d.image) ? ` ${databaseVersion(d.engine, d.image)}` : ' (default version)'}`);
374
+ }
375
+ const links = plan.services.flatMap((s) => {
376
+ const file = envFiles[s.name];
377
+ const own = (s.attachments ?? []).map((a) => a.envAlias);
378
+ return file === undefined ? [] : findConnections(plan, file).filter((h) => !own.includes(h.key)).map((h) => ({ s, h }));
379
+ });
380
+ const addons = plan.services.flatMap((s) => (s.attachments ?? []).map((a) => ({ s, a })));
381
+ if (addons.length) {
382
+ out.push('Attachments (add-ons):');
383
+ for (const { s, a } of addons) out.push(` + ${a.envAlias} of ${s.name}: attach ${a.database}`);
384
+ }
385
+ if (links.length) {
386
+ out.push('Connection strings that name the source:');
387
+ for (const { s, h } of links) {
388
+ out.push(h.action === 'attach'
389
+ ? ` + ${h.key} of ${s.name}: attach ${h.database!.name} under ${h.key} (the copied value names ${h.host})`
390
+ : ` ! ${h.key} of ${s.name} names ${h.database ? `the source's database ${h.database.name}` : 'the old platform'} (${h.host}); change it by hand: ${h.reason}`);
391
+ }
392
+ }
393
+ return out.join('\n');
394
+ }
395
+
396
+ /** Write the receipt atomically (temp file, then rename). */
397
+ export function writeReceipt(path: string, receipt: ImportReceipt): void {
398
+ const tmp = `${path}.tmp`;
399
+ writeFileSync(tmp, `${JSON.stringify(receipt, null, 2)}\n`);
400
+ renameSync(tmp, path);
401
+ }
402
+
403
+ export interface ImportApplyOpts {
404
+ receipt?: string;
405
+ project?: string;
406
+ dotenv?: string[];
407
+ yes?: boolean;
408
+ }
409
+
410
+ export async function importApplyAction(planPath: string, opts: ImportApplyOpts, deps: { client?: ApplyClient; panel?: string } = {}): Promise<void> {
411
+ try {
412
+ const plan = validatePlan(JSON.parse(readFileSync(planPath, 'utf8')));
413
+ const receiptPath = opts.receipt ?? `${planPath.replace(/\.json$/i, '')}.receipt.json`;
414
+ let project: number | undefined;
415
+ if (opts.project !== undefined) {
416
+ project = Number(opts.project);
417
+ if (!Number.isInteger(project) || project <= 0) throw new Error(`--project takes a project id, got "${opts.project}".`);
418
+ }
419
+ const envFiles: Record<string, string> = {};
420
+ for (const spec of opts.dotenv ?? []) {
421
+ const eq = spec.indexOf('=');
422
+ if (eq <= 0 || eq === spec.length - 1) throw new Error(`--dotenv takes <service>=<path>, got "${spec}".`);
423
+ envFiles[spec.slice(0, eq)] = readFileSync(spec.slice(eq + 1), 'utf8');
424
+ }
425
+ const existing = existsSync(receiptPath) ? (JSON.parse(readFileSync(receiptPath, 'utf8')) as ImportReceipt) : null;
426
+ const panel = (deps.panel ?? loadConfig().baseUrl).replace(/\/+$/, '');
427
+
428
+ if (!opts.yes) {
429
+ header(`Import into ${panel}`);
430
+ console.log(renderApplyPreview(plan, existing, envFiles));
431
+ info(`${c.dim('Nothing was created.')} Repeat with --yes to create these. Nothing is deployed and no domain is added.`);
432
+ return;
433
+ }
434
+ const receipt = await applyPlan(plan, { project, envFiles }, {
435
+ client: deps.client ?? getClient(), panel, receipt: existing, save: (r) => writeReceipt(receiptPath, r),
436
+ });
437
+ header(`Import into ${panel}`);
438
+ console.log(renderReceipt(receipt));
439
+ const failed = Object.values(receipt.items).filter((i) => i.state === 'failed').length;
440
+ if (failed) error(`${failed} item(s) failed. Fix the cause and run the same command again: done items are not repeated. Receipt: ${receiptPath}`);
441
+ else success(`Done. Receipt: ${receiptPath}. Nothing was deployed; domains and data move in step 3.`);
442
+ } catch (err) {
443
+ error(message(err));
444
+ }
445
+ }