ninedeploy 0.18.25 → 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,209 @@
1
+ /**
2
+ * A1 step 1 (docs/plans/COMPETITIVE_ROADMAP_2026-11.md): a read-only import
3
+ * plan. An adapter reads another platform's API (Coolify, Dokploy, CapRover)
4
+ * and describes, per item, what NineDeploy would create, what it cannot carry,
5
+ * and what needs a person. Nothing is created and the source is not changed.
6
+ *
7
+ * Secret hygiene is part of the type: a plan carries environment variable
8
+ * NAMES only, never values, passwords, tokens or keys, and every URL has its
9
+ * userinfo removed before it enters the plan. Step 2 (`import apply`, in
10
+ * commands/importApply.ts) takes values only from .env files the person passes.
11
+ */
12
+ import type { CreateService } from '@ninedeploy/schemas';
13
+
14
+ export type SourceKind = 'coolify' | 'dokploy' | 'caprover' | 'heroku';
15
+
16
+ /** GET a JSON path on the source panel (auth already applied). */
17
+ export type SourceGet = (path: string) => Promise<unknown>;
18
+
19
+ export type IssueLevel = 'unsupported' | 'manual' | 'info';
20
+ export interface PlanIssue {
21
+ level: IssueLevel;
22
+ /** The source option or field the issue is about, e.g. `build_pack`. */
23
+ option: string;
24
+ detail: string;
25
+ }
26
+
27
+ /** The createService fields a step-2 apply would send (subset, never secrets). */
28
+ export type ServiceCreateFields = Pick<CreateService, 'name' | 'type'> &
29
+ Partial<Pick<CreateService, 'image' | 'repoUrl' | 'branch' | 'port' | 'publishedPort' | 'replicas' | 'cpuLimitMilli' | 'memLimitMb' | 'healthPath' | 'volumeMount'>> & {
30
+ build?: { buildPack?: NonNullable<CreateService['build']>['buildPack']; baseDir?: string; dockerfilePath?: string; outputDir?: string; startCmd?: string };
31
+ };
32
+
33
+ /**
34
+ * A domain of a service. `sourceDefault` marks a host the source generated
35
+ * for itself (CapRover's <app>.<root domain>, Coolify's sslip.io, Dokploy's
36
+ * traefik.me): it stays with the source and is never added to NineDeploy.
37
+ * Optional and additive, so planVersion stays 1.
38
+ */
39
+ export interface PlanDomain {
40
+ host: string;
41
+ https: boolean;
42
+ sourceDefault?: true;
43
+ }
44
+
45
+ /** Wildcard-DNS hosts the sources generate (they resolve to an IP in the name, or to the source). */
46
+ export function isGeneratedHost(host: string): boolean {
47
+ return /\.(sslip\.io|nip\.io|traefik\.me)$/i.test(host);
48
+ }
49
+
50
+ export function planDomain(host: string, https: boolean, sourceDefault = isGeneratedHost(host)): PlanDomain {
51
+ return sourceDefault ? { host, https, sourceDefault: true } : { host, https };
52
+ }
53
+
54
+ export interface PlanService {
55
+ /** Stable reference in the source (uuid / id / appName). */
56
+ ref: string;
57
+ name: string;
58
+ kind: 'image' | 'git' | 'compose';
59
+ create: ServiceCreateFields;
60
+ domains: PlanDomain[];
61
+ /** Names only. `secret` marks values the source keeps hidden or flags as secret. */
62
+ env: Array<{ key: string }>;
63
+ volumes: Array<{ mountPath: string; source: 'volume' | 'bind' | 'file'; name?: string }>;
64
+ issues: PlanIssue[];
65
+ /**
66
+ * Where it runs on the source host, for the volume command of `import
67
+ * data` (same shape as PlanDatabase.runtime). Optional and additive.
68
+ */
69
+ runtime?: PlanRuntime;
70
+ /** Settings apply sets right after create (an update; not create fields). Optional and additive. */
71
+ settings?: { healthCheck?: 'running' };
72
+ /** Databases of the plan to attach under a variable (Heroku add-ons). Optional and additive. */
73
+ attachments?: Array<{ database: string; envAlias: string }>;
74
+ }
75
+
76
+ /**
77
+ * Where an item runs on the source: a container, a Swarm service, or (Heroku
78
+ * add-ons) the Heroku app that owns it. Every reader switches on `kind`.
79
+ */
80
+ export type PlanRuntime = { kind: 'container' | 'swarm-service' | 'heroku-app'; name: string };
81
+
82
+ export type PlanEngine = 'postgres' | 'mysql' | 'mariadb' | 'mongo' | 'redis' | 'keydb' | 'dragonfly' | 'clickhouse';
83
+ export interface PlanDatabase {
84
+ ref: string;
85
+ name: string;
86
+ /** NineDeploy engine, or null when NineDeploy has no managed equivalent. */
87
+ engine: PlanEngine | null;
88
+ /** The source image (the version lives in its tag), when the source says. */
89
+ image: string | null;
90
+ /**
91
+ * Where it runs on the source host, for the dump commands of `import data`:
92
+ * a plain container name, or a Swarm service whose task container is found
93
+ * by its com.docker.swarm.service.name label. Optional and additive.
94
+ */
95
+ runtime?: PlanRuntime;
96
+ issues: PlanIssue[];
97
+ }
98
+
99
+ export interface ImportPlan {
100
+ planVersion: 1;
101
+ source: { kind: SourceKind; url: string; version: string | null; scope: string };
102
+ generatedAt: string;
103
+ services: PlanService[];
104
+ databases: PlanDatabase[];
105
+ /** Plan-wide notes (scope limits, what step 1 does not do). */
106
+ notes: string[];
107
+ }
108
+
109
+ /** Always present: what this plan is and is not. */
110
+ export const PLAN_NOTES = [
111
+ 'Read-only: nothing was created here and nothing was changed on the source.',
112
+ 'Environment variables are listed by name only. Values, passwords, tokens and keys are never written to a plan.',
113
+ 'Database contents and volume data are not moved by this step.',
114
+ ];
115
+
116
+ /** Remove `user:password@` from a URL; non-URLs pass through unchanged. */
117
+ export function stripUserinfo(raw: string): string {
118
+ // Only `scheme://` strings are URLs here: `user:secret@host:repo` would
119
+ // otherwise parse as scheme "user:" and keep its password.
120
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) {
121
+ try {
122
+ const u = new URL(raw);
123
+ if (!u.username && !u.password) return raw;
124
+ u.username = '';
125
+ u.password = '';
126
+ return u.toString();
127
+ } catch {
128
+ /* fall through to the textual rule */
129
+ }
130
+ }
131
+ // scp-style remotes: `git@host:owner/repo.git` (no password) stays as it is;
132
+ // `user:secret@host…` loses its credentials.
133
+ return raw.replace(/^[^@/\s]+:[^@/\s]*@/, '');
134
+ }
135
+
136
+ /** `KEY=VALUE` lines (Dokploy's env blob) → names only. Comments and blanks skipped. */
137
+ export function envNames(blob: string | null | undefined): Array<{ key: string }> {
138
+ const out: Array<{ key: string }> = [];
139
+ for (const line of (blob ?? '').split(/\r?\n/)) {
140
+ const t = line.trim();
141
+ if (!t || t.startsWith('#')) continue;
142
+ const eq = t.indexOf('=');
143
+ const key = (eq === -1 ? t : t.slice(0, eq)).replace(/^export\s+/, '').trim();
144
+ if (/^[A-Za-z_][A-Za-z0-9_.-]*$/.test(key)) out.push({ key });
145
+ }
146
+ return out;
147
+ }
148
+
149
+ /** Each URL or bare host in a list → `{host, https}` (Coolify's comma-separated `fqdn`). */
150
+ export function domainsFrom(list: string | null | undefined): PlanDomain[] {
151
+ const out: PlanDomain[] = [];
152
+ for (const part of (list ?? '').split(',')) {
153
+ const t = part.trim();
154
+ if (!t) continue;
155
+ try {
156
+ const u = new URL(/^[a-z]+:\/\//i.test(t) ? t : `http://${t}`);
157
+ if (u.hostname) out.push(planDomain(u.hostname, u.protocol === 'https:'));
158
+ } catch {
159
+ /* not a host */
160
+ }
161
+ }
162
+ return out;
163
+ }
164
+
165
+ /** "512M" / "2g" / "1024" (bytes or MB per source) → megabytes, or undefined. */
166
+ export function memToMb(raw: string | number | null | undefined, plainUnit: 'bytes' | 'mb' = 'bytes'): number | undefined {
167
+ if (raw == null || raw === '' || raw === '0' || raw === 0) return undefined;
168
+ const m = /^(\d+(?:\.\d+)?)\s*([kmgt]?)b?$/i.exec(String(raw).trim());
169
+ if (!m) return undefined;
170
+ const n = Number(m[1]);
171
+ const unit = (m[2] ?? '').toLowerCase();
172
+ const mb = unit === 'k' ? n / 1024 : unit === 'm' ? n : unit === 'g' ? n * 1024 : unit === 't' ? n * 1024 * 1024 : plainUnit === 'mb' ? n : n / (1024 * 1024);
173
+ return mb >= 1 ? Math.round(mb) : undefined;
174
+ }
175
+
176
+ /** "1.5" cores → 1500 millicores, or undefined. */
177
+ export function cpusToMilli(raw: string | number | null | undefined): number | undefined {
178
+ const n = Number(raw);
179
+ return Number.isFinite(n) && n > 0 ? Math.round(n * 1000) : undefined;
180
+ }
181
+
182
+ /** NineDeploy accepts 1..10 replicas. */
183
+ export function replicasFor(raw: unknown, issues: PlanIssue[]): number | undefined {
184
+ const n = Number(raw);
185
+ if (!Number.isInteger(n) || n <= 1) return undefined;
186
+ if (n > 10) {
187
+ issues.push({ level: 'manual', option: 'replicas', detail: `${n} replicas in the source; NineDeploy runs at most 10 per service, so the plan uses 10.` });
188
+ return 10;
189
+ }
190
+ return n;
191
+ }
192
+
193
+ /** The first volume becomes `volumeMount`; any others are listed for a person. */
194
+ export function firstVolumeMount(volumes: PlanService['volumes'], issues: PlanIssue[]): string | undefined {
195
+ const named = volumes.filter((v) => v.source !== 'file');
196
+ if (named.length > 1) {
197
+ issues.push({ level: 'manual', option: 'volumes', detail: `${named.length} volumes; a new service gets one at create time (${named[0]!.mountPath}). Attach the others under Volumes & Storage.` });
198
+ }
199
+ for (const f of volumes.filter((v) => v.source === 'file')) {
200
+ issues.push({ level: 'manual', option: 'file mount', detail: `File mount ${f.mountPath}: recreate it under Service → File mounts (its content is not read).` });
201
+ }
202
+ return named[0]?.mountPath;
203
+ }
204
+
205
+ export const DATA_NOT_MOVED: PlanIssue = { level: 'manual', option: 'data', detail: 'Data is not moved by this step: create the database, then restore a dump into it (step 3).' };
206
+
207
+ export function emptyPlan(kind: SourceKind, url: string, version: string | null, scope: string): ImportPlan {
208
+ return { planVersion: 1, source: { kind, url: stripUserinfo(url), version, scope }, generatedAt: new Date().toISOString(), services: [], databases: [], notes: [...PLAN_NOTES] };
209
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Pieces of the commands `import data` prints for the source host. The names
3
+ * in them come from the source platform's API, and the person pastes the
4
+ * command into a shell, so a name only goes in when it is a plain Docker
5
+ * name; anything else becomes a placeholder to fill in by hand.
6
+ */
7
+
8
+ import type { PlanRuntime } from './plan.js';
9
+
10
+ /** A Docker container or service name: no spaces, quotes, `$`, `;` or other shell syntax. */
11
+ const DOCKER_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,254}$/;
12
+
13
+ export const isPlainName = (name: string): boolean => DOCKER_NAME.test(name);
14
+
15
+ /** Single-quote for POSIX sh. */
16
+ export const shellQuote = (s: string): string => `'${s.replace(/'/g, `'\\''`)}'`;
17
+
18
+ /** A file name made of safe characters only. */
19
+ export function fileBase(name: string): string {
20
+ const safe = name.replace(/[^A-Za-z0-9_.-]/g, '_').replace(/^[.-]+/, '_');
21
+ return safe || 'import';
22
+ }
23
+
24
+ /**
25
+ * The container to reach on the source host: a container name, or the newest
26
+ * task container of a Swarm service (`all` also finds stopped ones). A
27
+ * missing or unusual name gives `<container>` and a note saying so.
28
+ */
29
+ export function sourceTarget(
30
+ runtime: PlanRuntime | undefined,
31
+ what: string,
32
+ all = false,
33
+ ): { target: string; note: string | null } {
34
+ if (runtime && isPlainName(runtime.name)) {
35
+ switch (runtime.kind) {
36
+ case 'container':
37
+ return { target: runtime.name, note: null };
38
+ case 'swarm-service':
39
+ return { target: `$(docker ps -${all ? 'a' : ''}q --filter label=com.docker.swarm.service.name=${runtime.name} | head -n 1)`, note: null };
40
+ case 'heroku-app':
41
+ // A Heroku app has no container on a host you run docker on.
42
+ return { target: '<container>', note: `Replace <container> with ${what}; Heroku runs it, so there is no docker host to read it from.` };
43
+ default: {
44
+ const never: never = runtime.kind;
45
+ return never;
46
+ }
47
+ }
48
+ }
49
+ const why = runtime ? ` (its name on the source, ${JSON.stringify(runtime.name)}, is not a plain Docker name, so it is not put in a shell command)` : '';
50
+ return { target: '<container>', note: `Replace <container> with ${what} on the source host (docker ps${all ? ' -a' : ''})${why}.` };
51
+ }
52
+
53
+ /** `<name>=<file>` for a --dump / --volume flag, quoted when the name is not plain. */
54
+ export function flagArg(name: string, file: string): string {
55
+ return isPlainName(name) ? `${name}=${file}` : shellQuote(`${name}=${file}`);
56
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * HTTP clients for the import sources (A1 step 1). Native fetch only. The
3
+ * credentials come from the caller (an env var or a hidden prompt, never
4
+ * argv) and never appear in an error message.
5
+ */
6
+ import type { SourceGet } from './plan.js';
7
+ import { stripUserinfo } from './plan.js';
8
+
9
+ export class SourceError extends Error {}
10
+ /** CapRover asked for a two-factor code (status 1114). */
11
+ export class OtpRequiredError extends SourceError {}
12
+
13
+ type Fetch = typeof fetch;
14
+
15
+ /** `https://host[:port][/prefix]` without a trailing slash or userinfo. */
16
+ export function normaliseBase(raw: string): string {
17
+ let u: URL;
18
+ try {
19
+ u = new URL(raw);
20
+ } catch {
21
+ throw new SourceError(`Not a URL: ${stripUserinfo(raw)}`);
22
+ }
23
+ if (u.protocol !== 'https:' && u.protocol !== 'http:') throw new SourceError('The source URL must start with https:// (or http://).');
24
+ return stripUserinfo(`${u.origin}${u.pathname}`).replace(/\/+$/, '');
25
+ }
26
+
27
+ async function body(res: Response): Promise<unknown> {
28
+ const text = await res.text();
29
+ if (!text) return null;
30
+ try {
31
+ return JSON.parse(text);
32
+ } catch {
33
+ return text;
34
+ }
35
+ }
36
+
37
+ function httpError(res: Response, what: string, hint = ''): SourceError {
38
+ return new SourceError(`${what} answered HTTP ${res.status}${hint ? `: ${hint}` : ''}`);
39
+ }
40
+
41
+ /** Coolify: `Authorization: Bearer <token>`; a plain `read` token is enough. */
42
+ export function coolifySource(base: string, token: string, f: Fetch = fetch): SourceGet {
43
+ return async (path) => {
44
+ const res = await f(`${base}${path}`, { headers: { authorization: `Bearer ${token}`, accept: 'application/json' } });
45
+ const data = await body(res);
46
+ if (res.ok) return data;
47
+ const message = typeof data === 'object' && data && 'message' in data ? String((data as { message: unknown }).message) : '';
48
+ if (res.status === 403 && /api is disabled/i.test(message)) throw httpError(res, 'Coolify', 'the API is off. Turn it on under Settings → Advanced → API access.');
49
+ if (res.status === 401) throw httpError(res, 'Coolify', 'the token was refused (create one under Keys & Tokens → API tokens, with "read").');
50
+ if (res.status === 403) throw httpError(res, 'Coolify', message || 'the token lacks access (IP allow-list, scope or ability).');
51
+ throw httpError(res, `Coolify ${path}`, message);
52
+ };
53
+ }
54
+
55
+ /** Dokploy: `x-api-key`, tRPC procedures as `GET /api/<procedure>?<input>`. */
56
+ export function dokploySource(base: string, apiKey: string, f: Fetch = fetch): SourceGet {
57
+ return async (path) => {
58
+ const res = await f(`${base}${path}`, { headers: { 'x-api-key': apiKey, accept: 'application/json' } });
59
+ const data = await body(res);
60
+ if (res.ok) return data;
61
+ // Dokploy answers a plain 401 for a wrong key and for one past its rate limit alike (checked live on v0.31.0).
62
+ if (res.status === 401) throw httpError(res, 'Dokploy', 'the API key was refused: it is wrong, belongs to another organization, or has used up its rate limit. A Dokploy key allows 10 requests a day unless it was created with rate limiting off, and a plan makes 2 plus one per application, compose stack and database. Create one under Profile → API keys with rate limiting off.');
63
+ const message = typeof data === 'object' && data && 'message' in data ? String((data as { message: unknown }).message) : '';
64
+ throw httpError(res, `Dokploy ${path.split('?')[0]}`, message);
65
+ };
66
+ }
67
+
68
+ interface CaproverEnvelope { status?: number; description?: string; data?: unknown }
69
+
70
+ /**
71
+ * CapRover: sign in once (never retried: each wrong password starts a 30 s
72
+ * back-off), then send `x-captain-auth`. Errors arrive with HTTP 200 and a
73
+ * non-100 `status` in the envelope.
74
+ */
75
+ export async function caproverSource(base: string, password: string, otp: string | undefined, f: Fetch = fetch): Promise<SourceGet> {
76
+ const res = await f(`${base}/api/v2/login/`, {
77
+ method: 'POST',
78
+ headers: { 'content-type': 'application/json', 'x-namespace': 'captain' },
79
+ body: JSON.stringify(otp ? { password, otpToken: otp } : { password }),
80
+ });
81
+ if (!res.ok) throw httpError(res, 'CapRover sign-in');
82
+ const env = (await body(res)) as CaproverEnvelope | null;
83
+ if (env?.status === 1114) throw new OtpRequiredError('CapRover asks for a two-factor code.');
84
+ if (env?.status === 1113) throw new SourceError('CapRover is pausing sign-ins after a wrong password; wait 30 seconds and try again.');
85
+ if (env?.status === 1105) throw new SourceError('CapRover refused the password (or the two-factor code).');
86
+ const token = (env?.data as { token?: unknown } | undefined)?.token;
87
+ if (env?.status !== 100 || typeof token !== 'string') throw new SourceError(`CapRover sign-in failed (status ${env?.status ?? '?'}).`);
88
+ return async (path) => {
89
+ const r = await f(`${base}${path}`, { headers: { 'x-captain-auth': token, 'x-namespace': 'captain', accept: 'application/json' } });
90
+ if (!r.ok) throw httpError(r, `CapRover ${path}`);
91
+ const e = (await body(r)) as CaproverEnvelope | null;
92
+ if (e?.status === 1106) throw new SourceError('CapRover no longer accepts the session (it restarted, or the password changed); run the import again.');
93
+ if (e?.status !== 100) throw new SourceError(`CapRover ${path} failed (status ${e?.status ?? '?'}${e?.description ? `: ${e.description}` : ''}).`);
94
+ return e.data;
95
+ };
96
+ }