omnideploy-cli 0.0.0-stage → 0.1.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/README.md +155 -2
- package/dist/main.js +2943 -0
- package/dist/main.js.map +1 -0
- package/package.json +67 -4
- package/schema.json +65 -0
package/dist/main.js
ADDED
|
@@ -0,0 +1,2943 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli/main.ts
|
|
4
|
+
import { resolve as resolve2 } from "path";
|
|
5
|
+
import { Command } from "commander";
|
|
6
|
+
|
|
7
|
+
// src/cli/commands/auth.ts
|
|
8
|
+
import * as p from "@clack/prompts";
|
|
9
|
+
import pc from "picocolors";
|
|
10
|
+
function registerAuth(program2, env2) {
|
|
11
|
+
program2.command("login").description("Save an API token for a provider").argument("<provider>").option("-t, --token <token>", "token value (otherwise prompted; prefer the env var in CI)").action(async (provider, opts) => {
|
|
12
|
+
const c = env2.container();
|
|
13
|
+
await c.login.execute(provider, opts.token);
|
|
14
|
+
p.log.success(`Logged in to ${c.providers.get(provider).displayName}.`);
|
|
15
|
+
});
|
|
16
|
+
program2.command("logout").description("Remove a saved provider token").argument("<provider>").action(async (provider) => {
|
|
17
|
+
const c = env2.container();
|
|
18
|
+
const removed = await c.logout.execute(provider);
|
|
19
|
+
const name = c.providers.get(provider).displayName;
|
|
20
|
+
if (removed) p.log.success(`Logged out of ${name}.`);
|
|
21
|
+
else p.log.info(`No saved token for ${name}.`);
|
|
22
|
+
p.log.message(pc.dim(`(Tokens set via ${c.providers.get(provider).capabilities.auth.envVar} are not affected.)`));
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// src/cli/commands/deploy.ts
|
|
27
|
+
import * as p3 from "@clack/prompts";
|
|
28
|
+
import pc3 from "picocolors";
|
|
29
|
+
|
|
30
|
+
// src/cli/presenters/format.ts
|
|
31
|
+
import * as p2 from "@clack/prompts";
|
|
32
|
+
import pc2 from "picocolors";
|
|
33
|
+
|
|
34
|
+
// src/domain/value-objects/Language.ts
|
|
35
|
+
var LANGUAGE_LABELS = {
|
|
36
|
+
node: "Node.js",
|
|
37
|
+
python: "Python",
|
|
38
|
+
java: "Java",
|
|
39
|
+
go: "Go",
|
|
40
|
+
rust: "Rust",
|
|
41
|
+
ruby: "Ruby",
|
|
42
|
+
php: "PHP",
|
|
43
|
+
dotnet: ".NET",
|
|
44
|
+
static: "Static site",
|
|
45
|
+
docker: "Docker"
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// src/domain/entities/ProjectProfile.ts
|
|
49
|
+
function describeProfile(p11) {
|
|
50
|
+
const parts = [
|
|
51
|
+
LANGUAGE_LABELS[p11.language] + (p11.runtimeVersion ? ` ${p11.runtimeVersion}` : ""),
|
|
52
|
+
p11.framework,
|
|
53
|
+
p11.kind,
|
|
54
|
+
p11.kind === "static" ? p11.outputDir ? `output: ${p11.outputDir}` : void 0 : `port ${p11.port}`
|
|
55
|
+
];
|
|
56
|
+
return parts.filter(Boolean).join(" \xB7 ");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// src/domain/entities/ProjectConfig.ts
|
|
60
|
+
var emptyConfig = () => ({ providers: {} });
|
|
61
|
+
function applyConfigOverrides(profile, config) {
|
|
62
|
+
if (!config) return profile;
|
|
63
|
+
return {
|
|
64
|
+
...profile,
|
|
65
|
+
buildCommand: config.build?.command ?? profile.buildCommand,
|
|
66
|
+
outputDir: config.build?.output ?? profile.outputDir,
|
|
67
|
+
startCommand: config.app?.start ?? profile.startCommand,
|
|
68
|
+
port: config.app?.port ?? profile.port
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// src/domain/errors/index.ts
|
|
73
|
+
var DomainError = class extends Error {
|
|
74
|
+
constructor(message, hint, code = "DOMAIN_ERROR") {
|
|
75
|
+
super(message);
|
|
76
|
+
this.hint = hint;
|
|
77
|
+
this.code = code;
|
|
78
|
+
this.name = new.target.name;
|
|
79
|
+
}
|
|
80
|
+
hint;
|
|
81
|
+
code;
|
|
82
|
+
};
|
|
83
|
+
var UnsupportedProjectError = class extends DomainError {
|
|
84
|
+
constructor(rootDir) {
|
|
85
|
+
super(
|
|
86
|
+
`Could not detect a supported project in ${rootDir}.`,
|
|
87
|
+
"Add a Dockerfile, or set build/app settings in omnideploy.json.",
|
|
88
|
+
"UNSUPPORTED_PROJECT"
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
var IncompatibleProviderError = class extends DomainError {
|
|
93
|
+
constructor(provider, reason, suggestion) {
|
|
94
|
+
super(`${provider}: ${reason}`, suggestion, "INCOMPATIBLE_PROVIDER");
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
var UnknownProviderError = class extends DomainError {
|
|
98
|
+
constructor(id, known) {
|
|
99
|
+
super(`Unknown provider "${id}".`, `Available providers: ${known.join(", ")}`, "UNKNOWN_PROVIDER");
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
var MissingCredentialError = class extends DomainError {
|
|
103
|
+
constructor(provider, envVar) {
|
|
104
|
+
super(
|
|
105
|
+
`No credentials found for ${provider}.`,
|
|
106
|
+
`Run \`omnideploy login ${provider}\` or set ${envVar}.`,
|
|
107
|
+
"MISSING_CREDENTIAL"
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
var MissingToolError = class extends DomainError {
|
|
112
|
+
constructor(bin, install) {
|
|
113
|
+
super(`Required tool "${bin}" is not installed.`, `Install it with: ${install}`, "MISSING_TOOL");
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
var ConfigError = class extends DomainError {
|
|
117
|
+
constructor(message, hint) {
|
|
118
|
+
super(message, hint, "CONFIG_ERROR");
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
var BuildError = class extends DomainError {
|
|
122
|
+
constructor(command, output) {
|
|
123
|
+
super(
|
|
124
|
+
`Build failed: \`${command}\``,
|
|
125
|
+
output ? `Last output:
|
|
126
|
+
${output}` : "Fix the build locally, or override it with build.command in omnideploy.json.",
|
|
127
|
+
"BUILD_FAILED"
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
var ProviderApiError = class extends DomainError {
|
|
132
|
+
constructor(provider, message, status, hint) {
|
|
133
|
+
super(`${provider} API error${status ? ` (${status})` : ""}: ${message}`, hint, "PROVIDER_API_ERROR");
|
|
134
|
+
this.status = status;
|
|
135
|
+
}
|
|
136
|
+
status;
|
|
137
|
+
};
|
|
138
|
+
var DeploymentFailedError = class extends DomainError {
|
|
139
|
+
constructor(provider, detail, hint) {
|
|
140
|
+
super(`${provider} deployment failed: ${detail}`, hint, "DEPLOYMENT_FAILED");
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
var GitError = class extends DomainError {
|
|
144
|
+
constructor(message, hint) {
|
|
145
|
+
super(message, hint, "GIT_ERROR");
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
var UserCancelledError = class extends DomainError {
|
|
149
|
+
constructor() {
|
|
150
|
+
super("Operation cancelled.", void 0, "CANCELLED");
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// src/domain/services/CompatibilityPolicy.ts
|
|
155
|
+
function checkCompatibility(profile, caps) {
|
|
156
|
+
if (profile.kind === "static") {
|
|
157
|
+
return caps.kinds.includes("static") ? { ok: true } : { ok: false, reason: "does not host static sites" };
|
|
158
|
+
}
|
|
159
|
+
if (profile.framework && caps.serverlessFrameworks?.includes(profile.framework)) {
|
|
160
|
+
return { ok: true };
|
|
161
|
+
}
|
|
162
|
+
if (!caps.kinds.includes("server")) {
|
|
163
|
+
const what = profile.framework ?? LANGUAGE_LABELS[profile.language];
|
|
164
|
+
return {
|
|
165
|
+
ok: false,
|
|
166
|
+
reason: !caps.kinds.includes("static") ? `cannot run ${what} apps` : caps.serverlessFrameworks?.length ? `only hosts static sites and ${listOf(caps.serverlessFrameworks)} apps, not ${what} servers` : `only hosts static sites, not ${what} servers`
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
if (caps.serverLanguages === "any") return { ok: true };
|
|
170
|
+
if (caps.serverLanguages.includes(profile.language)) return { ok: true };
|
|
171
|
+
return { ok: false, reason: `cannot run ${LANGUAGE_LABELS[profile.language]} server apps` };
|
|
172
|
+
}
|
|
173
|
+
function compatibleProviders(profile, providers, exclude) {
|
|
174
|
+
return providers.filter((p11) => p11.id !== exclude && checkCompatibility(profile, p11.capabilities).ok);
|
|
175
|
+
}
|
|
176
|
+
function suggestAlternatives(profile, providers, exclude) {
|
|
177
|
+
const alts = compatibleProviders(profile, providers, exclude).map((p11) => p11.displayName);
|
|
178
|
+
return alts.length > 0 ? `Deploy to ${listOf(alts)} instead.` : "No available provider can host this project yet.";
|
|
179
|
+
}
|
|
180
|
+
function listOf(items) {
|
|
181
|
+
return items.length <= 1 ? items[0] ?? "" : `${items.slice(0, -1).join(", ")} or ${items.at(-1)}`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// src/domain/services/naming.ts
|
|
185
|
+
function toServiceName(raw) {
|
|
186
|
+
const slug = raw.toLowerCase().replace(/^@[^/]+\//, "").replace(/[^a-z0-9-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 50);
|
|
187
|
+
return slug || "app";
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// src/domain/services/spa.ts
|
|
191
|
+
var SPA_FRAMEWORKS = /^(vite(-.+)?|create-react-app|angular)$/;
|
|
192
|
+
function isSinglePageApp(profile) {
|
|
193
|
+
return profile.kind === "static" && SPA_FRAMEWORKS.test(profile.framework ?? "");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// src/cli/presenters/format.ts
|
|
197
|
+
function table(headers, rows) {
|
|
198
|
+
const visible = (s) => s.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
199
|
+
const widths = headers.map((h, i) => Math.max(visible(h), ...rows.map((r) => visible(r[i] ?? ""))));
|
|
200
|
+
const line = (cells) => cells.map((c, i) => c + " ".repeat(widths[i] - visible(c))).join(" ").trimEnd();
|
|
201
|
+
return [pc2.bold(line(headers)), ...rows.map(line)].join("\n");
|
|
202
|
+
}
|
|
203
|
+
function presentError(error, debug) {
|
|
204
|
+
if (error instanceof UserCancelledError) {
|
|
205
|
+
p2.cancel("Cancelled.");
|
|
206
|
+
return 130;
|
|
207
|
+
}
|
|
208
|
+
if (error instanceof DomainError) {
|
|
209
|
+
p2.log.error(error.message);
|
|
210
|
+
if (error.hint) p2.log.message(`${pc2.cyan("Try:")} ${error.hint}`);
|
|
211
|
+
if (debug && error.stack) p2.log.message(pc2.dim(error.stack));
|
|
212
|
+
return 1;
|
|
213
|
+
}
|
|
214
|
+
p2.log.error(`Unexpected error: ${error instanceof Error ? error.message : String(error)}`);
|
|
215
|
+
if (debug && error instanceof Error && error.stack) p2.log.message(pc2.dim(error.stack));
|
|
216
|
+
else p2.log.message(pc2.dim("Re-run with --debug for details."));
|
|
217
|
+
return 1;
|
|
218
|
+
}
|
|
219
|
+
function describeError(error) {
|
|
220
|
+
if (error instanceof DomainError) return error.hint ? `${error.message}
|
|
221
|
+
${pc2.cyan("Try:")} ${error.hint}` : error.message;
|
|
222
|
+
return error instanceof Error ? error.message : String(error);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// src/cli/commands/deploy.ts
|
|
226
|
+
function registerDeploy(program2, env2) {
|
|
227
|
+
program2.command("deploy").description("Deploy to one or more providers (default: those in omnideploy.json)").argument("[providers...]", "e.g. vercel netlify github-pages railway render").option("--preview", "create a preview/draft deployment instead of production").option("--no-build", "skip the local build step").option("-d, --dir <path>", "deploy this directory as a static site").option("--dry-run", "show what would happen without deploying").option("-y, --yes", "answer yes to every prompt (CI mode)").option("-f, --force", "regenerate deployment files even if they exist").action(async (providers, opts) => {
|
|
228
|
+
const c = env2.container();
|
|
229
|
+
p3.intro(pc3.inverse(" omnideploy deploy "));
|
|
230
|
+
const report = await c.deploy.execute({
|
|
231
|
+
rootDir: env2.cwd(),
|
|
232
|
+
providerIds: providers,
|
|
233
|
+
production: !opts.preview,
|
|
234
|
+
skipBuild: !opts.build,
|
|
235
|
+
dir: opts.dir,
|
|
236
|
+
dryRun: opts.dryRun,
|
|
237
|
+
yes: opts.yes,
|
|
238
|
+
force: opts.force
|
|
239
|
+
});
|
|
240
|
+
if (report.dryRun) {
|
|
241
|
+
p3.outro("Dry run complete \u2014 nothing was deployed.");
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const rows = report.outcomes.map((o) => [
|
|
245
|
+
o.displayName,
|
|
246
|
+
o.ok ? o.deployment?.state === "ready" ? pc3.green("\u2714 live") : pc3.yellow("\u2026 building") : pc3.red("\u2716 failed"),
|
|
247
|
+
o.ok ? pc3.cyan(o.deployment?.url ?? "(no public URL)") : ""
|
|
248
|
+
]);
|
|
249
|
+
p3.note(table(["Provider", "Status", "URL"], rows), "Deployments");
|
|
250
|
+
for (const o of report.outcomes.filter((o2) => !o2.ok)) p3.log.error(`${o.displayName}: ${describeError(o.error)}`);
|
|
251
|
+
const failed = report.outcomes.filter((o) => !o.ok).length;
|
|
252
|
+
if (failed > 0) {
|
|
253
|
+
process.exitCode = 1;
|
|
254
|
+
p3.outro(pc3.red(`${failed} of ${report.outcomes.length} deployment(s) failed.`));
|
|
255
|
+
} else {
|
|
256
|
+
p3.outro(pc3.green("All deployments finished."));
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// src/cli/commands/doctor.ts
|
|
262
|
+
import * as p4 from "@clack/prompts";
|
|
263
|
+
import pc4 from "picocolors";
|
|
264
|
+
var ICON = { ok: pc4.green("\u2714"), warn: pc4.yellow("\u25B2"), fail: pc4.red("\u2716") };
|
|
265
|
+
function registerDoctor(program2, env2) {
|
|
266
|
+
program2.command("doctor").description("Check that everything needed to deploy this project is in place").action(async () => {
|
|
267
|
+
const c = env2.container();
|
|
268
|
+
p4.intro(pc4.inverse(" omnideploy doctor "));
|
|
269
|
+
const checks = await c.doctor.execute(env2.cwd(), { nodeVersion: process.version });
|
|
270
|
+
for (const ch of checks) {
|
|
271
|
+
p4.log.message(`${ICON[ch.status]} ${pc4.bold(ch.name)}: ${ch.detail}${ch.fix ? ch.fix.split("\n").map((f) => `
|
|
272
|
+
${pc4.cyan("Fix:")} ${f}`).join("") : ""}`);
|
|
273
|
+
}
|
|
274
|
+
const failed = checks.filter((ch) => ch.status === "fail").length;
|
|
275
|
+
const warned = checks.filter((ch) => ch.status === "warn").length;
|
|
276
|
+
if (failed) process.exitCode = 1;
|
|
277
|
+
p4.outro(
|
|
278
|
+
failed ? pc4.red(`${failed} problem(s) to fix before deploying.`) : warned ? pc4.yellow(`Ready, with ${warned} warning(s).`) : pc4.green("Everything looks good!")
|
|
279
|
+
);
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// src/cli/commands/generate.ts
|
|
284
|
+
import * as p5 from "@clack/prompts";
|
|
285
|
+
import pc5 from "picocolors";
|
|
286
|
+
function registerGenerate(program2, env2) {
|
|
287
|
+
program2.command("generate").alias("eject").description("Only write the deployment files (Dockerfile, render.yaml, ...) \u2014 deploy them however you like").argument("<providers...>", "providers to generate files for").option("-f, --force", "overwrite existing files").option("-y, --yes", "write without asking").option("--dry-run", "list the files without writing them").action(async (providerIds, opts) => {
|
|
288
|
+
const c = env2.container();
|
|
289
|
+
p5.intro(pc5.inverse(" omnideploy generate "));
|
|
290
|
+
const { profile, config } = await c.detect.execute(env2.cwd());
|
|
291
|
+
p5.log.success(`Detected ${describeProfile(profile)}`);
|
|
292
|
+
for (const id of providerIds) {
|
|
293
|
+
const provider = c.providers.get(id);
|
|
294
|
+
const compat = checkCompatibility(profile, provider.capabilities);
|
|
295
|
+
if (!compat.ok) throw new IncompatibleProviderError(provider.displayName, compat.reason, suggestAlternatives(profile, c.providers.all(), id));
|
|
296
|
+
}
|
|
297
|
+
const { planned, written } = await c.generate.execute({ profile, config, providerIds, ...opts });
|
|
298
|
+
const kept = planned.filter((a) => a.action === "keep").map((a) => a.path);
|
|
299
|
+
if (kept.length) p5.log.info(`Kept existing: ${kept.join(", ")} (use --force to regenerate)`);
|
|
300
|
+
p5.outro(written.length ? `Wrote ${written.length} file(s).` : opts.dryRun ? "Dry run \u2014 nothing written." : "Nothing to generate.");
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// src/cli/commands/init.ts
|
|
305
|
+
import * as p6 from "@clack/prompts";
|
|
306
|
+
import pc6 from "picocolors";
|
|
307
|
+
function registerInit(program2, env2) {
|
|
308
|
+
program2.command("init").description("Detect the project, pick providers, and generate deployment files + omnideploy.json").option("-p, --providers <ids>", "comma-separated providers (skips the prompt)").option("-y, --yes", "accept defaults and write files without asking").option("-f, --force", "overwrite existing deployment files").action(async (opts) => {
|
|
309
|
+
const c = env2.container();
|
|
310
|
+
p6.intro(pc6.inverse(" omnideploy init "));
|
|
311
|
+
const result = await c.init.execute({
|
|
312
|
+
rootDir: env2.cwd(),
|
|
313
|
+
providerIds: opts.providers?.split(",").map((s) => s.trim()).filter(Boolean),
|
|
314
|
+
yes: opts.yes,
|
|
315
|
+
force: opts.force
|
|
316
|
+
});
|
|
317
|
+
p6.outro(`Ready! Deploy with ${pc6.cyan(`omnideploy deploy${result.providerIds.length === 1 ? ` ${result.providerIds[0]}` : ""}`)}`);
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// src/cli/commands/providers.ts
|
|
322
|
+
import * as p7 from "@clack/prompts";
|
|
323
|
+
import pc7 from "picocolors";
|
|
324
|
+
function registerProviders(program2, env2) {
|
|
325
|
+
program2.command("providers").alias("ls").description("List providers, login state, required tools and compatibility with this project").action(async () => {
|
|
326
|
+
const c = env2.container();
|
|
327
|
+
let profile;
|
|
328
|
+
try {
|
|
329
|
+
profile = (await c.detect.execute(env2.cwd())).profile;
|
|
330
|
+
} catch {
|
|
331
|
+
}
|
|
332
|
+
const rows = await c.listProviders.execute(profile);
|
|
333
|
+
p7.log.message(
|
|
334
|
+
table(
|
|
335
|
+
["Provider", "Id", "Hosts", "Auth", "CLI", ...profile ? ["Fits project"] : []],
|
|
336
|
+
rows.map((r) => [
|
|
337
|
+
r.displayName,
|
|
338
|
+
pc7.dim(r.id),
|
|
339
|
+
r.hosts,
|
|
340
|
+
r.auth ? pc7.green(`\u2714 ${r.auth}`) : pc7.yellow("login needed"),
|
|
341
|
+
r.cli ? r.cli.installed ? pc7.green(`\u2714 ${r.cli.bin}`) : pc7.red(`\u2716 ${r.cli.bin}`) : pc7.dim("\u2014"),
|
|
342
|
+
...r.compatibility ? [r.compatibility.ok ? pc7.green("\u2714") : pc7.dim(`\u2716 ${r.compatibility.reason}`)] : []
|
|
343
|
+
])
|
|
344
|
+
)
|
|
345
|
+
);
|
|
346
|
+
if (rows.some((r) => !r.auth)) p7.log.message(pc7.dim("Log in with `omnideploy login <id>` or set the provider env var."));
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// src/cli/commands/status.ts
|
|
351
|
+
import * as p8 from "@clack/prompts";
|
|
352
|
+
import pc8 from "picocolors";
|
|
353
|
+
function registerStatus(program2, env2) {
|
|
354
|
+
program2.command("status").description("Show the latest (or a specific) deployment for a provider").argument("<provider>").argument("[deploymentId]").action(async (provider, id) => {
|
|
355
|
+
const c = env2.container();
|
|
356
|
+
const s = await c.status.execute(env2.cwd(), provider, id);
|
|
357
|
+
const color = s.state === "ready" ? pc8.green : s.state === "error" ? pc8.red : pc8.yellow;
|
|
358
|
+
p8.note(
|
|
359
|
+
[`State: ${color(s.state)}`, `ID: ${s.id}`, s.url && `URL: ${pc8.cyan(s.url)}`, s.createdAt && `Created: ${s.createdAt}`, s.message && `Info: ${s.message}`].filter(Boolean).join("\n"),
|
|
360
|
+
c.providers.get(provider).displayName
|
|
361
|
+
);
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// src/cli/container.ts
|
|
366
|
+
import { homedir } from "os";
|
|
367
|
+
import { join as join2 } from "path";
|
|
368
|
+
|
|
369
|
+
// src/application/services/ProviderRegistry.ts
|
|
370
|
+
var ProviderRegistry = class {
|
|
371
|
+
byId;
|
|
372
|
+
constructor(providers) {
|
|
373
|
+
this.byId = new Map(providers.map((p11) => [p11.id, p11]));
|
|
374
|
+
}
|
|
375
|
+
all() {
|
|
376
|
+
return [...this.byId.values()];
|
|
377
|
+
}
|
|
378
|
+
ids() {
|
|
379
|
+
return [...this.byId.keys()];
|
|
380
|
+
}
|
|
381
|
+
get(id) {
|
|
382
|
+
const p11 = this.byId.get(id);
|
|
383
|
+
if (!p11) throw new UnknownProviderError(id, this.ids());
|
|
384
|
+
return p11;
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
// src/application/services/CredentialResolver.ts
|
|
389
|
+
var CredentialResolver = class {
|
|
390
|
+
constructor(store, prompt) {
|
|
391
|
+
this.store = store;
|
|
392
|
+
this.prompt = prompt;
|
|
393
|
+
}
|
|
394
|
+
store;
|
|
395
|
+
prompt;
|
|
396
|
+
async resolve(provider, opts) {
|
|
397
|
+
const { envVar, tokenUrl } = provider.capabilities.auth;
|
|
398
|
+
const found = await this.store.get(provider.id, envVar);
|
|
399
|
+
if (found) return found.token;
|
|
400
|
+
if (!opts.allowPrompt || !this.prompt.interactive) {
|
|
401
|
+
throw new MissingCredentialError(provider.id, envVar);
|
|
402
|
+
}
|
|
403
|
+
this.prompt.note(`Create a token at:
|
|
404
|
+
${tokenUrl}
|
|
405
|
+
|
|
406
|
+
It will be saved for future deploys (or set ${envVar}).`, `${provider.displayName} login`);
|
|
407
|
+
const token = (await this.prompt.password(`Paste your ${provider.displayName} token`)).trim();
|
|
408
|
+
if (!token) throw new MissingCredentialError(provider.id, envVar);
|
|
409
|
+
await this.store.set(provider.id, token);
|
|
410
|
+
return token;
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
// src/application/services/serviceName.ts
|
|
415
|
+
function resolveServiceName(profile, config) {
|
|
416
|
+
const raw = config?.name ?? profile.meta?.name ?? profile.rootDir.split(/[\\/]/).filter(Boolean).pop() ?? "app";
|
|
417
|
+
return toServiceName(raw);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// src/application/use-cases/DetectProject.ts
|
|
421
|
+
var DetectProject = class {
|
|
422
|
+
constructor(detectors, configRepo) {
|
|
423
|
+
this.detectors = detectors;
|
|
424
|
+
this.configRepo = configRepo;
|
|
425
|
+
}
|
|
426
|
+
detectors;
|
|
427
|
+
configRepo;
|
|
428
|
+
async execute(rootDir) {
|
|
429
|
+
const [config, results] = await Promise.all([
|
|
430
|
+
this.configRepo.load(rootDir),
|
|
431
|
+
Promise.all(this.detectors.map((d) => d.detect(rootDir)))
|
|
432
|
+
]);
|
|
433
|
+
const candidates = results.filter((r) => r !== null).sort((a, b) => b.confidence - a.confidence);
|
|
434
|
+
const best = candidates[0];
|
|
435
|
+
if (!best) {
|
|
436
|
+
if (config?.app?.start || config?.build?.output) {
|
|
437
|
+
const profile = applyConfigOverrides(
|
|
438
|
+
{ rootDir, language: "docker", kind: config.app?.start ? "server" : "static", port: 8080, hasDockerfile: false },
|
|
439
|
+
config
|
|
440
|
+
);
|
|
441
|
+
return { profile, config, candidates };
|
|
442
|
+
}
|
|
443
|
+
throw new UnsupportedProjectError(rootDir);
|
|
444
|
+
}
|
|
445
|
+
return { profile: applyConfigOverrides(best.profile, config), config, candidates };
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
// src/application/use-cases/GenerateArtifacts.ts
|
|
450
|
+
var GenerateArtifacts = class {
|
|
451
|
+
constructor(generators, providers, fs, prompt, logger) {
|
|
452
|
+
this.generators = generators;
|
|
453
|
+
this.providers = providers;
|
|
454
|
+
this.fs = fs;
|
|
455
|
+
this.prompt = prompt;
|
|
456
|
+
this.logger = logger;
|
|
457
|
+
}
|
|
458
|
+
generators;
|
|
459
|
+
providers;
|
|
460
|
+
fs;
|
|
461
|
+
prompt;
|
|
462
|
+
logger;
|
|
463
|
+
async plan(req) {
|
|
464
|
+
const serviceName = resolveServiceName(req.profile, req.config);
|
|
465
|
+
const byId = /* @__PURE__ */ new Map();
|
|
466
|
+
for (const providerId of req.providerIds) {
|
|
467
|
+
const provider = this.providers.get(providerId);
|
|
468
|
+
for (const id of provider.requiredArtifacts(req.profile)) {
|
|
469
|
+
const existing = byId.get(id);
|
|
470
|
+
if (existing) {
|
|
471
|
+
existing.providers.push(providerId);
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
const generator = this.generators.find((g) => g.id === id && g.supports(req.profile));
|
|
475
|
+
const exists = await this.fs.exists(this.fs.join(req.profile.rootDir, generator?.path ?? String(id)));
|
|
476
|
+
if (!generator) {
|
|
477
|
+
if (exists) continue;
|
|
478
|
+
throw new DomainError(
|
|
479
|
+
`${provider.displayName} needs a ${id} for this ${req.profile.language} project, but omnideploy has no template for it.`,
|
|
480
|
+
id === "dockerfile" ? "Add a Dockerfile to the project root and run the command again." : void 0,
|
|
481
|
+
"NO_GENERATOR"
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
const artifact = generator.generate(req.profile, {
|
|
485
|
+
providerId,
|
|
486
|
+
providerOptions: req.config?.providers[providerId] ?? {},
|
|
487
|
+
serviceName,
|
|
488
|
+
healthCheckPath: req.config?.app?.healthCheckPath
|
|
489
|
+
});
|
|
490
|
+
byId.set(id, {
|
|
491
|
+
...artifact,
|
|
492
|
+
action: exists ? req.force ? "overwrite" : "keep" : "create",
|
|
493
|
+
providers: [providerId]
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
return [...byId.values()];
|
|
498
|
+
}
|
|
499
|
+
async execute(req) {
|
|
500
|
+
const planned = await this.plan(req);
|
|
501
|
+
const toWrite = planned.filter((a) => a.action !== "keep");
|
|
502
|
+
if (toWrite.length === 0) return { planned, written: [] };
|
|
503
|
+
this.prompt.note(
|
|
504
|
+
toWrite.map((a) => `${a.action === "create" ? "+" : "~"} ${a.path} \u2014 ${a.description} (for ${a.providers.join(", ")})`).join("\n"),
|
|
505
|
+
req.dryRun ? "Would generate" : "Deployment files to generate"
|
|
506
|
+
);
|
|
507
|
+
if (req.dryRun) return { planned, written: [] };
|
|
508
|
+
if (!req.yes) {
|
|
509
|
+
if (!this.prompt.interactive) {
|
|
510
|
+
throw new DomainError(
|
|
511
|
+
"Deployment files need to be generated, but the terminal is not interactive.",
|
|
512
|
+
"Re-run with --yes to accept, or run `omnideploy generate <provider>` first.",
|
|
513
|
+
"CONFIRMATION_REQUIRED"
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
const showPreview = await this.prompt.confirm("Preview file contents first?", false);
|
|
517
|
+
if (showPreview) for (const a of toWrite) this.prompt.note(a.contents, a.path);
|
|
518
|
+
if (!await this.prompt.confirm("Write these files?", true)) throw new UserCancelledError();
|
|
519
|
+
}
|
|
520
|
+
for (const a of toWrite) {
|
|
521
|
+
await this.fs.writeText(this.fs.join(req.profile.rootDir, a.path), a.contents);
|
|
522
|
+
this.logger.success(`${a.action === "create" ? "Created" : "Updated"} ${a.path}`);
|
|
523
|
+
}
|
|
524
|
+
return { planned, written: toWrite.map((a) => a.path) };
|
|
525
|
+
}
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
// src/application/use-cases/InitProject.ts
|
|
529
|
+
var InitProject = class {
|
|
530
|
+
constructor(detect, providers, generate, configRepo, prompt, logger) {
|
|
531
|
+
this.detect = detect;
|
|
532
|
+
this.providers = providers;
|
|
533
|
+
this.generate = generate;
|
|
534
|
+
this.configRepo = configRepo;
|
|
535
|
+
this.prompt = prompt;
|
|
536
|
+
this.logger = logger;
|
|
537
|
+
}
|
|
538
|
+
detect;
|
|
539
|
+
providers;
|
|
540
|
+
generate;
|
|
541
|
+
configRepo;
|
|
542
|
+
prompt;
|
|
543
|
+
logger;
|
|
544
|
+
async execute(req) {
|
|
545
|
+
const { profile, config: existing } = await this.detect.execute(req.rootDir);
|
|
546
|
+
this.logger.success(`Detected ${describeProfile(profile)}`);
|
|
547
|
+
const compatible = this.providers.all().filter((p11) => checkCompatibility(profile, p11.capabilities).ok);
|
|
548
|
+
const incompatible = this.providers.all().filter((p11) => !compatible.includes(p11));
|
|
549
|
+
if (compatible.length === 0) {
|
|
550
|
+
throw new DomainError("None of the available providers can host this project.", void 0, "NO_PROVIDER");
|
|
551
|
+
}
|
|
552
|
+
let providerIds = req.providerIds;
|
|
553
|
+
if (providerIds) {
|
|
554
|
+
for (const id of providerIds) {
|
|
555
|
+
const p11 = this.providers.get(id);
|
|
556
|
+
const c = checkCompatibility(profile, p11.capabilities);
|
|
557
|
+
if (!c.ok) this.logger.warn(`${p11.displayName} ${c.reason} \u2014 skipping.`);
|
|
558
|
+
}
|
|
559
|
+
providerIds = providerIds.filter((id) => compatible.some((p11) => p11.id === id));
|
|
560
|
+
} else if (req.yes || !this.prompt.interactive) {
|
|
561
|
+
providerIds = compatible.map((p11) => p11.id);
|
|
562
|
+
} else {
|
|
563
|
+
if (incompatible.length > 0) {
|
|
564
|
+
this.logger.info(
|
|
565
|
+
`Not offered: ${incompatible.map((p11) => `${p11.displayName} (${checkCompatibility(profile, p11.capabilities).reason})`).join("; ")}`
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
providerIds = await this.prompt.multiselect(
|
|
569
|
+
"Where do you want to deploy?",
|
|
570
|
+
compatible.map((p11) => ({ value: p11.id, label: p11.displayName, hint: p11.capabilities.kinds.join("/") })),
|
|
571
|
+
Object.keys(existing?.providers ?? {}).filter((id) => compatible.some((p11) => p11.id === id))
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
if (providerIds.length === 0) throw new DomainError("No providers selected.", void 0, "NO_PROVIDER");
|
|
575
|
+
const config = existing ?? emptyConfig();
|
|
576
|
+
config.name ??= resolveServiceName(profile, existing);
|
|
577
|
+
if (profile.kind === "static") {
|
|
578
|
+
config.build = { command: profile.buildCommand, output: profile.outputDir, ...config.build };
|
|
579
|
+
} else {
|
|
580
|
+
config.app = { port: profile.port, start: profile.startCommand, ...config.app };
|
|
581
|
+
}
|
|
582
|
+
for (const id of providerIds) config.providers[id] ??= {};
|
|
583
|
+
const { written } = await this.generate.execute({
|
|
584
|
+
profile,
|
|
585
|
+
config,
|
|
586
|
+
providerIds,
|
|
587
|
+
yes: req.yes,
|
|
588
|
+
force: req.force
|
|
589
|
+
});
|
|
590
|
+
await this.configRepo.save(req.rootDir, config);
|
|
591
|
+
this.logger.success(`Saved ${this.configRepo.fileName}`);
|
|
592
|
+
return { profile, config, providerIds, written };
|
|
593
|
+
}
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
// src/application/use-cases/BuildProject.ts
|
|
597
|
+
var BuildProject = class {
|
|
598
|
+
constructor(process2, fs, logger) {
|
|
599
|
+
this.process = process2;
|
|
600
|
+
this.fs = fs;
|
|
601
|
+
this.logger = logger;
|
|
602
|
+
}
|
|
603
|
+
process;
|
|
604
|
+
fs;
|
|
605
|
+
logger;
|
|
606
|
+
async execute(profile, opts) {
|
|
607
|
+
if (profile.kind !== "static") return profile.rootDir;
|
|
608
|
+
if (profile.buildCommand && !opts.skipBuild) {
|
|
609
|
+
opts.spinner?.start(`Building: ${profile.buildCommand}`);
|
|
610
|
+
const res = await this.process.shell(profile.buildCommand, { cwd: profile.rootDir, env: { CI: "1" } });
|
|
611
|
+
if (res.exitCode !== 0) {
|
|
612
|
+
opts.spinner?.stop("Build failed", false);
|
|
613
|
+
const tail = (res.stderr || res.stdout).trim().split("\n").slice(-15).join("\n");
|
|
614
|
+
throw new BuildError(profile.buildCommand, tail);
|
|
615
|
+
}
|
|
616
|
+
opts.spinner?.stop("Build complete");
|
|
617
|
+
}
|
|
618
|
+
const out = this.fs.resolve(profile.rootDir, profile.outputDir ?? ".");
|
|
619
|
+
if (!await this.fs.exists(out)) {
|
|
620
|
+
throw new DomainError(
|
|
621
|
+
`Build output directory "${profile.outputDir}" does not exist.`,
|
|
622
|
+
profile.buildCommand && opts.skipBuild ? "Run the build first, or drop --no-build." : "Set build.output in omnideploy.json to the folder your build produces.",
|
|
623
|
+
"MISSING_OUTPUT"
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
this.logger.debug(`Deploying files from ${out}`);
|
|
627
|
+
return out;
|
|
628
|
+
}
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
// src/application/use-cases/DeployProject.ts
|
|
632
|
+
var DeployProject = class {
|
|
633
|
+
constructor(detect, providers, generate, build, credentials, configRepo, git, prompt, logger) {
|
|
634
|
+
this.detect = detect;
|
|
635
|
+
this.providers = providers;
|
|
636
|
+
this.generate = generate;
|
|
637
|
+
this.build = build;
|
|
638
|
+
this.credentials = credentials;
|
|
639
|
+
this.configRepo = configRepo;
|
|
640
|
+
this.git = git;
|
|
641
|
+
this.prompt = prompt;
|
|
642
|
+
this.logger = logger;
|
|
643
|
+
}
|
|
644
|
+
detect;
|
|
645
|
+
providers;
|
|
646
|
+
generate;
|
|
647
|
+
build;
|
|
648
|
+
credentials;
|
|
649
|
+
configRepo;
|
|
650
|
+
git;
|
|
651
|
+
prompt;
|
|
652
|
+
logger;
|
|
653
|
+
async execute(req) {
|
|
654
|
+
const detected = await this.detect.execute(req.rootDir);
|
|
655
|
+
let profile = detected.profile;
|
|
656
|
+
if (req.dir) profile = { ...profile, kind: "static", outputDir: req.dir, buildCommand: req.skipBuild ? void 0 : profile.buildCommand };
|
|
657
|
+
const config = detected.config ?? { providers: {} };
|
|
658
|
+
this.logger.success(`Detected ${describeProfile(profile)}`);
|
|
659
|
+
const targets = await this.chooseProviders(req, profile, config);
|
|
660
|
+
for (const p11 of targets) {
|
|
661
|
+
const c = checkCompatibility(profile, p11.capabilities);
|
|
662
|
+
if (!c.ok) throw new IncompatibleProviderError(p11.displayName, c.reason, suggestAlternatives(profile, this.providers.all(), p11.id));
|
|
663
|
+
}
|
|
664
|
+
const { planned, written } = await this.generate.execute({
|
|
665
|
+
profile,
|
|
666
|
+
config,
|
|
667
|
+
providerIds: targets.map((p11) => p11.id),
|
|
668
|
+
yes: req.yes,
|
|
669
|
+
force: req.force,
|
|
670
|
+
dryRun: req.dryRun
|
|
671
|
+
});
|
|
672
|
+
if (planned.some((a) => a.id === "dockerfile")) profile = { ...profile, hasDockerfile: true };
|
|
673
|
+
const tokens = /* @__PURE__ */ new Map();
|
|
674
|
+
for (const p11 of targets) {
|
|
675
|
+
if (req.dryRun) {
|
|
676
|
+
await this.credentials.resolve(p11, { allowPrompt: false }).then(
|
|
677
|
+
(t) => tokens.set(p11.id, t),
|
|
678
|
+
() => this.logger.warn(`${p11.displayName}: no credentials (run \`omnideploy login ${p11.id}\`)`)
|
|
679
|
+
);
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
tokens.set(p11.id, await this.credentials.resolve(p11, { allowPrompt: !req.yes }));
|
|
683
|
+
await p11.validate({ profile, rootDir: req.rootDir, options: config.providers[p11.id] ?? {}, token: tokens.get(p11.id) });
|
|
684
|
+
}
|
|
685
|
+
const gitTargets = targets.filter((p11) => p11.capabilities.buildsFromGit);
|
|
686
|
+
if (gitTargets.length > 0 && !req.dryRun) await this.syncGitRemote(req, gitTargets, written);
|
|
687
|
+
if (req.dryRun) {
|
|
688
|
+
this.logger.info(`Dry run: would deploy to ${targets.map((p11) => p11.displayName).join(", ")}.`);
|
|
689
|
+
return { profile, dryRun: true, outcomes: [] };
|
|
690
|
+
}
|
|
691
|
+
const sourceDir = await this.build.execute(profile, { skipBuild: req.skipBuild, spinner: this.prompt.spinner() });
|
|
692
|
+
const serviceName = resolveServiceName(profile, config);
|
|
693
|
+
const outcomes = [];
|
|
694
|
+
for (const p11 of targets) {
|
|
695
|
+
const spinner2 = this.prompt.spinner();
|
|
696
|
+
spinner2.start(`Deploying to ${p11.displayName}`);
|
|
697
|
+
try {
|
|
698
|
+
const deployment = await p11.deploy({
|
|
699
|
+
profile,
|
|
700
|
+
options: config.providers[p11.id] ?? {},
|
|
701
|
+
token: tokens.get(p11.id),
|
|
702
|
+
sourceDir,
|
|
703
|
+
production: req.production,
|
|
704
|
+
serviceName,
|
|
705
|
+
logger: this.logger,
|
|
706
|
+
progress: (m) => spinner2.message(`${p11.displayName}: ${m}`)
|
|
707
|
+
});
|
|
708
|
+
spinner2.stop(`${p11.displayName}: ${deployment.url ?? "deployed"}`, true);
|
|
709
|
+
outcomes.push({ provider: p11.id, displayName: p11.displayName, ok: true, deployment });
|
|
710
|
+
config.providers[p11.id] = { ...config.providers[p11.id], ...deployment.configUpdates };
|
|
711
|
+
await this.configRepo.save(req.rootDir, config);
|
|
712
|
+
} catch (error) {
|
|
713
|
+
spinner2.stop(`${p11.displayName}: failed`, false);
|
|
714
|
+
outcomes.push({ provider: p11.id, displayName: p11.displayName, ok: false, error });
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
return { profile, dryRun: false, outcomes };
|
|
718
|
+
}
|
|
719
|
+
async chooseProviders(req, profile, config) {
|
|
720
|
+
let ids = req.providerIds?.length ? req.providerIds : Object.keys(config.providers);
|
|
721
|
+
if (ids.length === 0) {
|
|
722
|
+
const compatible = this.providers.all().filter((p11) => checkCompatibility(profile, p11.capabilities).ok);
|
|
723
|
+
if (!this.prompt.interactive || req.yes) {
|
|
724
|
+
throw new DomainError(
|
|
725
|
+
"No provider given.",
|
|
726
|
+
`Run \`omnideploy deploy <provider>\` (compatible: ${compatible.map((p11) => p11.id).join(", ")}) or \`omnideploy init\`.`,
|
|
727
|
+
"NO_PROVIDER"
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
ids = [
|
|
731
|
+
await this.prompt.select(
|
|
732
|
+
"Deploy to which provider?",
|
|
733
|
+
compatible.map((p11) => ({ value: p11.id, label: p11.displayName }))
|
|
734
|
+
)
|
|
735
|
+
];
|
|
736
|
+
}
|
|
737
|
+
return ids.map((id) => this.providers.get(id));
|
|
738
|
+
}
|
|
739
|
+
async syncGitRemote(req, targets, generated) {
|
|
740
|
+
const names = targets.map((p11) => p11.displayName).join(", ");
|
|
741
|
+
if (!await this.git.isRepo(req.rootDir) || !await this.git.remote(req.rootDir)) {
|
|
742
|
+
throw new DomainError(
|
|
743
|
+
`${names} builds from a git repository, but this project has no git remote.`,
|
|
744
|
+
"Push the project to GitHub/GitLab (git init && git remote add origin <url> && git push -u origin HEAD).",
|
|
745
|
+
"NO_GIT_REMOTE"
|
|
746
|
+
);
|
|
747
|
+
}
|
|
748
|
+
const dirty = await this.git.dirtyPaths(req.rootDir);
|
|
749
|
+
if (dirty.length === 0) return;
|
|
750
|
+
const generatedDirty = dirty.filter((d) => generated.includes(d) || ["Dockerfile", ".dockerignore", "render.yaml", "omnideploy.json"].includes(d));
|
|
751
|
+
const otherDirty = dirty.filter((d) => !generatedDirty.includes(d));
|
|
752
|
+
if (otherDirty.length > 0) {
|
|
753
|
+
this.logger.warn(`${otherDirty.length} other uncommitted change(s) will NOT be deployed by ${names} (it builds from the remote).`);
|
|
754
|
+
}
|
|
755
|
+
if (generatedDirty.length === 0) return;
|
|
756
|
+
const ok = req.yes || this.prompt.interactive && await this.prompt.confirm(`${names} builds from your git remote. Commit and push ${generatedDirty.join(", ")}?`, true);
|
|
757
|
+
if (!ok) {
|
|
758
|
+
if (!this.prompt.interactive && !req.yes) {
|
|
759
|
+
throw new DomainError("Deployment files must be committed and pushed first.", "Commit them yourself, or re-run with --yes.", "GIT_SYNC_REQUIRED");
|
|
760
|
+
}
|
|
761
|
+
throw new UserCancelledError();
|
|
762
|
+
}
|
|
763
|
+
await this.git.commit(req.rootDir, generatedDirty, "chore: add deployment configuration (omnideploy)");
|
|
764
|
+
await this.git.push(req.rootDir);
|
|
765
|
+
this.logger.success("Pushed deployment files");
|
|
766
|
+
}
|
|
767
|
+
};
|
|
768
|
+
|
|
769
|
+
// src/application/use-cases/ManageCredentials.ts
|
|
770
|
+
var LoginProvider = class {
|
|
771
|
+
constructor(providers, store, prompt) {
|
|
772
|
+
this.providers = providers;
|
|
773
|
+
this.store = store;
|
|
774
|
+
this.prompt = prompt;
|
|
775
|
+
}
|
|
776
|
+
providers;
|
|
777
|
+
store;
|
|
778
|
+
prompt;
|
|
779
|
+
async execute(providerId, token) {
|
|
780
|
+
const p11 = this.providers.get(providerId);
|
|
781
|
+
if (!token) {
|
|
782
|
+
if (!this.prompt.interactive) {
|
|
783
|
+
throw new DomainError("No token given.", `Pass --token, or set ${p11.capabilities.auth.envVar}.`, "MISSING_TOKEN");
|
|
784
|
+
}
|
|
785
|
+
this.prompt.note(`Create a token at:
|
|
786
|
+
${p11.capabilities.auth.tokenUrl}`, `${p11.displayName} login`);
|
|
787
|
+
token = await this.prompt.password(`Paste your ${p11.displayName} token`);
|
|
788
|
+
}
|
|
789
|
+
token = token.trim();
|
|
790
|
+
if (!token) throw new DomainError("Empty token.", void 0, "MISSING_TOKEN");
|
|
791
|
+
await this.store.set(p11.id, token);
|
|
792
|
+
}
|
|
793
|
+
};
|
|
794
|
+
var LogoutProvider = class {
|
|
795
|
+
constructor(providers, store) {
|
|
796
|
+
this.providers = providers;
|
|
797
|
+
this.store = store;
|
|
798
|
+
}
|
|
799
|
+
providers;
|
|
800
|
+
store;
|
|
801
|
+
async execute(providerId) {
|
|
802
|
+
return this.store.delete(this.providers.get(providerId).id);
|
|
803
|
+
}
|
|
804
|
+
};
|
|
805
|
+
|
|
806
|
+
// src/application/use-cases/ListProviders.ts
|
|
807
|
+
var ListProviders = class {
|
|
808
|
+
constructor(providers, store, process2) {
|
|
809
|
+
this.providers = providers;
|
|
810
|
+
this.store = store;
|
|
811
|
+
this.process = process2;
|
|
812
|
+
}
|
|
813
|
+
providers;
|
|
814
|
+
store;
|
|
815
|
+
process;
|
|
816
|
+
async execute(profile) {
|
|
817
|
+
return Promise.all(
|
|
818
|
+
this.providers.all().map(async (p11) => {
|
|
819
|
+
const cred = await this.store.get(p11.id, p11.capabilities.auth.envVar);
|
|
820
|
+
const req = p11.capabilities.requiredCli;
|
|
821
|
+
const c = profile ? checkCompatibility(profile, p11.capabilities) : void 0;
|
|
822
|
+
return {
|
|
823
|
+
id: p11.id,
|
|
824
|
+
displayName: p11.displayName,
|
|
825
|
+
hosts: p11.capabilities.kinds.join(", "),
|
|
826
|
+
auth: cred?.source ?? null,
|
|
827
|
+
cli: req ? { bin: req.bin, installed: await this.process.has(req.bin), install: req.install } : null,
|
|
828
|
+
compatibility: c && (c.ok ? { ok: true } : { ok: false, reason: c.reason })
|
|
829
|
+
};
|
|
830
|
+
})
|
|
831
|
+
);
|
|
832
|
+
}
|
|
833
|
+
};
|
|
834
|
+
|
|
835
|
+
// src/application/use-cases/DoctorCheck.ts
|
|
836
|
+
var DoctorCheck = class {
|
|
837
|
+
constructor(detect, listProviders, git) {
|
|
838
|
+
this.detect = detect;
|
|
839
|
+
this.listProviders = listProviders;
|
|
840
|
+
this.git = git;
|
|
841
|
+
}
|
|
842
|
+
detect;
|
|
843
|
+
listProviders;
|
|
844
|
+
git;
|
|
845
|
+
async execute(rootDir, runtime) {
|
|
846
|
+
const checks = [];
|
|
847
|
+
const major = Number(runtime.nodeVersion.replace(/^v/, "").split(".")[0]);
|
|
848
|
+
checks.push(
|
|
849
|
+
major >= 20 ? { name: "Node.js", status: "ok", detail: runtime.nodeVersion } : { name: "Node.js", status: "fail", detail: runtime.nodeVersion, fix: "Install Node.js 20 or newer." }
|
|
850
|
+
);
|
|
851
|
+
let profile;
|
|
852
|
+
let configured = [];
|
|
853
|
+
try {
|
|
854
|
+
const r = await this.detect.execute(rootDir);
|
|
855
|
+
profile = r.profile;
|
|
856
|
+
configured = Object.keys(r.config?.providers ?? {});
|
|
857
|
+
checks.push({ name: "Project", status: "ok", detail: describeProfile(r.profile) });
|
|
858
|
+
checks.push(
|
|
859
|
+
r.config ? { name: "Config", status: "ok", detail: `omnideploy.json (${configured.join(", ") || "no providers"})` } : { name: "Config", status: "warn", detail: "no omnideploy.json", fix: "Run `omnideploy init`." }
|
|
860
|
+
);
|
|
861
|
+
} catch (e) {
|
|
862
|
+
checks.push({
|
|
863
|
+
name: "Project",
|
|
864
|
+
status: "fail",
|
|
865
|
+
detail: e instanceof Error ? e.message : String(e),
|
|
866
|
+
fix: e instanceof DomainError ? e.hint : void 0
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
if (await this.git.isRepo(rootDir)) {
|
|
870
|
+
const remote = await this.git.remote(rootDir);
|
|
871
|
+
checks.push(
|
|
872
|
+
remote ? { name: "Git remote", status: "ok", detail: remote.url } : { name: "Git remote", status: "warn", detail: "none", fix: "Render and GitHub Pages need a pushed git remote (git remote add origin <url>)." }
|
|
873
|
+
);
|
|
874
|
+
} else {
|
|
875
|
+
checks.push({ name: "Git", status: "warn", detail: "not a git repository", fix: "Render and GitHub Pages need one: git init." });
|
|
876
|
+
}
|
|
877
|
+
for (const row of await this.listProviders.execute(profile)) {
|
|
878
|
+
const relevant = configured.length === 0 || configured.includes(row.id);
|
|
879
|
+
if (!relevant) continue;
|
|
880
|
+
const problems = [];
|
|
881
|
+
const fixes = [];
|
|
882
|
+
if (!row.auth) {
|
|
883
|
+
problems.push("not logged in");
|
|
884
|
+
fixes.push(`omnideploy login ${row.id}`);
|
|
885
|
+
}
|
|
886
|
+
if (row.cli && !row.cli.installed) {
|
|
887
|
+
problems.push(`${row.cli.bin} CLI missing`);
|
|
888
|
+
fixes.push(row.cli.install);
|
|
889
|
+
}
|
|
890
|
+
if (row.compatibility && !row.compatibility.ok) problems.push(row.compatibility.reason ?? "incompatible");
|
|
891
|
+
checks.push({
|
|
892
|
+
name: row.displayName,
|
|
893
|
+
status: problems.length === 0 ? "ok" : configured.includes(row.id) ? "fail" : "warn",
|
|
894
|
+
detail: problems.length === 0 ? `ready (token from ${row.auth})` : problems.join(", "),
|
|
895
|
+
fix: fixes.join("\n") || void 0
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
return checks;
|
|
899
|
+
}
|
|
900
|
+
};
|
|
901
|
+
|
|
902
|
+
// src/application/use-cases/GetDeploymentStatus.ts
|
|
903
|
+
var GetDeploymentStatus = class {
|
|
904
|
+
constructor(providers, credentials, configRepo) {
|
|
905
|
+
this.providers = providers;
|
|
906
|
+
this.credentials = credentials;
|
|
907
|
+
this.configRepo = configRepo;
|
|
908
|
+
}
|
|
909
|
+
providers;
|
|
910
|
+
credentials;
|
|
911
|
+
configRepo;
|
|
912
|
+
async execute(rootDir, providerId, deploymentId) {
|
|
913
|
+
const p11 = this.providers.get(providerId);
|
|
914
|
+
if (!p11.status) throw new DomainError(`${p11.displayName} does not support status checks yet.`, void 0, "UNSUPPORTED");
|
|
915
|
+
const config = await this.configRepo.load(rootDir);
|
|
916
|
+
const token = await this.credentials.resolve(p11, { allowPrompt: true });
|
|
917
|
+
return p11.status({ options: config?.providers[p11.id] ?? {}, token }, deploymentId);
|
|
918
|
+
}
|
|
919
|
+
};
|
|
920
|
+
|
|
921
|
+
// src/infrastructure/config/JsonConfigRepository.ts
|
|
922
|
+
import { z } from "zod";
|
|
923
|
+
var schema = z.object({
|
|
924
|
+
$schema: z.string().optional(),
|
|
925
|
+
name: z.string().min(1).optional(),
|
|
926
|
+
build: z.object({ command: z.string().optional(), output: z.string().optional() }).optional(),
|
|
927
|
+
app: z.object({
|
|
928
|
+
start: z.string().optional(),
|
|
929
|
+
port: z.number().int().min(1).max(65535).optional(),
|
|
930
|
+
healthCheckPath: z.string().startsWith("/").optional()
|
|
931
|
+
}).optional(),
|
|
932
|
+
providers: z.record(z.string(), z.record(z.string(), z.unknown())).default({})
|
|
933
|
+
});
|
|
934
|
+
var SCHEMA_URL = "https://unpkg.com/omnideploy/schema.json";
|
|
935
|
+
var JsonConfigRepository = class {
|
|
936
|
+
constructor(fs) {
|
|
937
|
+
this.fs = fs;
|
|
938
|
+
}
|
|
939
|
+
fs;
|
|
940
|
+
fileName = "omnideploy.json";
|
|
941
|
+
async load(rootDir) {
|
|
942
|
+
const raw = await this.fs.readTextIfExists(this.fs.join(rootDir, this.fileName));
|
|
943
|
+
if (raw === null) return null;
|
|
944
|
+
let json;
|
|
945
|
+
try {
|
|
946
|
+
json = JSON.parse(raw);
|
|
947
|
+
} catch (e) {
|
|
948
|
+
throw new ConfigError(`${this.fileName} is not valid JSON: ${e.message}`);
|
|
949
|
+
}
|
|
950
|
+
const parsed = schema.safeParse(json);
|
|
951
|
+
if (!parsed.success) {
|
|
952
|
+
const issues = parsed.error.issues.map((i) => ` \u2022 ${i.path.join(".") || "(root)"}: ${i.message}`).join("\n");
|
|
953
|
+
throw new ConfigError(`${this.fileName} is invalid:
|
|
954
|
+
${issues}`, "Fix the fields above or delete the file and run `omnideploy init`.");
|
|
955
|
+
}
|
|
956
|
+
const { $schema: _ignored, ...config } = parsed.data;
|
|
957
|
+
return config;
|
|
958
|
+
}
|
|
959
|
+
async save(rootDir, config) {
|
|
960
|
+
const { name, build, app, providers, ...rest } = config;
|
|
961
|
+
const ordered = { $schema: SCHEMA_URL, name, build, app, ...rest, providers };
|
|
962
|
+
await this.fs.writeText(this.fs.join(rootDir, this.fileName), JSON.stringify(stripUndefined(ordered), null, 2) + "\n");
|
|
963
|
+
}
|
|
964
|
+
};
|
|
965
|
+
function stripUndefined(value) {
|
|
966
|
+
return JSON.parse(JSON.stringify(value));
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// src/infrastructure/credentials/FileCredentialStore.ts
|
|
970
|
+
import { chmod, mkdir, readFile, writeFile } from "fs/promises";
|
|
971
|
+
import { dirname } from "path";
|
|
972
|
+
var FileCredentialStore = class {
|
|
973
|
+
constructor(filePath, env2 = process.env) {
|
|
974
|
+
this.filePath = filePath;
|
|
975
|
+
this.env = env2;
|
|
976
|
+
}
|
|
977
|
+
filePath;
|
|
978
|
+
env;
|
|
979
|
+
async get(providerId, envVar) {
|
|
980
|
+
const fromEnv = this.env[envVar]?.trim();
|
|
981
|
+
if (fromEnv) return { token: fromEnv, source: "env" };
|
|
982
|
+
const stored = (await this.read()).providers[providerId];
|
|
983
|
+
return stored ? { token: stored.token, source: "store" } : null;
|
|
984
|
+
}
|
|
985
|
+
async set(providerId, token) {
|
|
986
|
+
const data = await this.read();
|
|
987
|
+
data.providers[providerId] = { token, savedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
988
|
+
await this.write(data);
|
|
989
|
+
}
|
|
990
|
+
async delete(providerId) {
|
|
991
|
+
const data = await this.read();
|
|
992
|
+
if (!data.providers[providerId]) return false;
|
|
993
|
+
delete data.providers[providerId];
|
|
994
|
+
await this.write(data);
|
|
995
|
+
return true;
|
|
996
|
+
}
|
|
997
|
+
async read() {
|
|
998
|
+
try {
|
|
999
|
+
const parsed = JSON.parse(await readFile(this.filePath, "utf8"));
|
|
1000
|
+
return { providers: parsed.providers ?? {} };
|
|
1001
|
+
} catch {
|
|
1002
|
+
return { providers: {} };
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
async write(data) {
|
|
1006
|
+
await mkdir(dirname(this.filePath), { recursive: true, mode: 448 });
|
|
1007
|
+
await writeFile(this.filePath, JSON.stringify(data, null, 2) + "\n", { mode: 384 });
|
|
1008
|
+
await chmod(this.filePath, 384);
|
|
1009
|
+
}
|
|
1010
|
+
};
|
|
1011
|
+
|
|
1012
|
+
// src/infrastructure/detectors/DockerDetector.ts
|
|
1013
|
+
var DockerDetector = class {
|
|
1014
|
+
constructor(fs) {
|
|
1015
|
+
this.fs = fs;
|
|
1016
|
+
}
|
|
1017
|
+
fs;
|
|
1018
|
+
id = "docker";
|
|
1019
|
+
async detect(root) {
|
|
1020
|
+
const dockerfile = await this.fs.readTextIfExists(this.fs.join(root, "Dockerfile"));
|
|
1021
|
+
if (dockerfile === null) return null;
|
|
1022
|
+
const port = Number(dockerfile.match(/^\s*EXPOSE\s+(\d+)/im)?.[1] ?? 8080);
|
|
1023
|
+
return {
|
|
1024
|
+
profile: { rootDir: root, language: "docker", kind: "server", port, hasDockerfile: true },
|
|
1025
|
+
confidence: 20
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
};
|
|
1029
|
+
|
|
1030
|
+
// src/infrastructure/detectors/support.ts
|
|
1031
|
+
async function readJson(fs, path) {
|
|
1032
|
+
const raw = await fs.readTextIfExists(path);
|
|
1033
|
+
if (raw === null) return null;
|
|
1034
|
+
try {
|
|
1035
|
+
return JSON.parse(raw);
|
|
1036
|
+
} catch {
|
|
1037
|
+
return null;
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
async function firstExisting(fs, root, candidates) {
|
|
1041
|
+
for (const c of candidates) if (await fs.exists(fs.join(root, c))) return c;
|
|
1042
|
+
return void 0;
|
|
1043
|
+
}
|
|
1044
|
+
function hasDockerfile(fs, root) {
|
|
1045
|
+
return fs.exists(fs.join(root, "Dockerfile"));
|
|
1046
|
+
}
|
|
1047
|
+
function extractVersion(spec, parts = 2) {
|
|
1048
|
+
if (!spec) return void 0;
|
|
1049
|
+
const m = spec.match(/(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
|
|
1050
|
+
if (!m) return void 0;
|
|
1051
|
+
return [m[1], m[2], m[3]].slice(0, parts).filter((x) => x !== void 0).join(".");
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// src/infrastructure/detectors/DotnetDetector.ts
|
|
1055
|
+
var DotnetDetector = class {
|
|
1056
|
+
constructor(fs) {
|
|
1057
|
+
this.fs = fs;
|
|
1058
|
+
}
|
|
1059
|
+
fs;
|
|
1060
|
+
id = "dotnet";
|
|
1061
|
+
async detect(root) {
|
|
1062
|
+
const csproj = (await this.fs.list(root)).find((f) => f.endsWith(".csproj") || f.endsWith(".fsproj"));
|
|
1063
|
+
if (!csproj) return null;
|
|
1064
|
+
const xml = await this.fs.readText(this.fs.join(root, csproj));
|
|
1065
|
+
const assembly = xml.match(/<AssemblyName>([^<]+)</)?.[1] ?? csproj.replace(/\.[cf]sproj$/, "");
|
|
1066
|
+
const version = xml.match(/<TargetFramework>net(\d+\.\d+)</)?.[1] ?? "8.0";
|
|
1067
|
+
const web = /Sdk="Microsoft\.NET\.Sdk\.Web"/.test(xml);
|
|
1068
|
+
return {
|
|
1069
|
+
profile: {
|
|
1070
|
+
rootDir: root,
|
|
1071
|
+
language: "dotnet",
|
|
1072
|
+
framework: web ? "aspnetcore" : void 0,
|
|
1073
|
+
kind: "server",
|
|
1074
|
+
packageManager: "dotnet",
|
|
1075
|
+
runtimeVersion: version,
|
|
1076
|
+
buildCommand: "dotnet publish -c Release -o out",
|
|
1077
|
+
startCommand: `dotnet ${assembly}.dll`,
|
|
1078
|
+
port: 8080,
|
|
1079
|
+
hasDockerfile: await hasDockerfile(this.fs, root),
|
|
1080
|
+
meta: { name: assembly, assembly, projectFile: csproj }
|
|
1081
|
+
},
|
|
1082
|
+
confidence: web ? 85 : 70
|
|
1083
|
+
};
|
|
1084
|
+
}
|
|
1085
|
+
};
|
|
1086
|
+
|
|
1087
|
+
// src/infrastructure/detectors/GoDetector.ts
|
|
1088
|
+
var GoDetector = class {
|
|
1089
|
+
constructor(fs) {
|
|
1090
|
+
this.fs = fs;
|
|
1091
|
+
}
|
|
1092
|
+
fs;
|
|
1093
|
+
id = "go";
|
|
1094
|
+
async detect(root) {
|
|
1095
|
+
const gomod = await this.fs.readTextIfExists(this.fs.join(root, "go.mod"));
|
|
1096
|
+
if (gomod === null) return null;
|
|
1097
|
+
const module = gomod.match(/^module\s+(\S+)/m)?.[1] ?? "";
|
|
1098
|
+
const framework = [
|
|
1099
|
+
["github.com/gin-gonic/gin", "gin"],
|
|
1100
|
+
["github.com/labstack/echo", "echo"],
|
|
1101
|
+
["github.com/gofiber/fiber", "fiber"],
|
|
1102
|
+
["github.com/go-chi/chi", "chi"]
|
|
1103
|
+
].find(([mod]) => gomod.includes(mod))?.[1];
|
|
1104
|
+
let mainPath = ".";
|
|
1105
|
+
if (!await this.fs.exists(this.fs.join(root, "main.go"))) {
|
|
1106
|
+
const cmds = await this.fs.list(this.fs.join(root, "cmd"));
|
|
1107
|
+
for (const c of cmds) {
|
|
1108
|
+
if (await this.fs.exists(this.fs.join(root, "cmd", c, "main.go"))) {
|
|
1109
|
+
mainPath = `./cmd/${c}`;
|
|
1110
|
+
break;
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
return {
|
|
1115
|
+
profile: {
|
|
1116
|
+
rootDir: root,
|
|
1117
|
+
language: "go",
|
|
1118
|
+
framework,
|
|
1119
|
+
kind: "server",
|
|
1120
|
+
packageManager: "go",
|
|
1121
|
+
runtimeVersion: extractVersion(gomod.match(/^go\s+([\d.]+)/m)?.[1]) ?? "1.23",
|
|
1122
|
+
buildCommand: `go build -o app ${mainPath}`,
|
|
1123
|
+
startCommand: "./app",
|
|
1124
|
+
port: 8080,
|
|
1125
|
+
hasDockerfile: await hasDockerfile(this.fs, root),
|
|
1126
|
+
meta: { name: module.split("/").pop() ?? "", mainPath }
|
|
1127
|
+
},
|
|
1128
|
+
confidence: 85
|
|
1129
|
+
};
|
|
1130
|
+
}
|
|
1131
|
+
};
|
|
1132
|
+
|
|
1133
|
+
// src/infrastructure/detectors/JavaDetector.ts
|
|
1134
|
+
import { XMLParser } from "fast-xml-parser";
|
|
1135
|
+
var JVM_FRAMEWORKS = [
|
|
1136
|
+
[/spring-boot|org\.springframework\.boot/, "spring-boot"],
|
|
1137
|
+
[/io\.quarkus/, "quarkus"],
|
|
1138
|
+
[/io\.micronaut/, "micronaut"]
|
|
1139
|
+
];
|
|
1140
|
+
var PORT_PROPERTY = {
|
|
1141
|
+
"spring-boot": "server.port",
|
|
1142
|
+
quarkus: "quarkus.http.port",
|
|
1143
|
+
micronaut: "micronaut.server.port"
|
|
1144
|
+
};
|
|
1145
|
+
var JavaDetector = class {
|
|
1146
|
+
constructor(fs) {
|
|
1147
|
+
this.fs = fs;
|
|
1148
|
+
}
|
|
1149
|
+
fs;
|
|
1150
|
+
id = "java";
|
|
1151
|
+
async detect(root) {
|
|
1152
|
+
const read = (f) => this.fs.readTextIfExists(this.fs.join(root, f));
|
|
1153
|
+
const pom = await read("pom.xml");
|
|
1154
|
+
const gradle = await read("build.gradle.kts") ?? await read("build.gradle");
|
|
1155
|
+
if (pom === null && gradle === null) return null;
|
|
1156
|
+
const buildTool = pom !== null ? "maven" : "gradle";
|
|
1157
|
+
const source = pom ?? gradle;
|
|
1158
|
+
const framework = JVM_FRAMEWORKS.find(([re]) => re.test(source))?.[1];
|
|
1159
|
+
const hasWrapper = await this.fs.exists(this.fs.join(root, buildTool === "maven" ? "mvnw" : "gradlew"));
|
|
1160
|
+
let name = "";
|
|
1161
|
+
let javaVersion;
|
|
1162
|
+
if (pom !== null) {
|
|
1163
|
+
const project = new XMLParser({ ignoreAttributes: true }).parse(pom)?.project ?? {};
|
|
1164
|
+
name = String(project.artifactId ?? "");
|
|
1165
|
+
const props = project.properties ?? {};
|
|
1166
|
+
javaVersion = extractVersion(String(props["java.version"] ?? props["maven.compiler.release"] ?? props["maven.compiler.source"] ?? ""), 1);
|
|
1167
|
+
} else {
|
|
1168
|
+
javaVersion = extractVersion(gradle.match(/JavaLanguageVersion\.of\((\d+)\)/)?.[1], 1) ?? extractVersion(gradle.match(/VERSION_(\d+)/)?.[1], 1) ?? extractVersion(gradle.match(/sourceCompatibility\s*=\s*['"]?([\d.]+)/)?.[1], 1);
|
|
1169
|
+
const settings = await read("settings.gradle.kts") ?? await read("settings.gradle");
|
|
1170
|
+
name = settings?.match(/rootProject\.name\s*=\s*['"]([^'"]+)/)?.[1] ?? "";
|
|
1171
|
+
}
|
|
1172
|
+
if (javaVersion === "1") javaVersion = "8";
|
|
1173
|
+
const runtimeVersion = javaVersion ?? "21";
|
|
1174
|
+
const tool = buildTool === "maven" ? hasWrapper ? "./mvnw" : "mvn" : hasWrapper ? "./gradlew" : "gradle";
|
|
1175
|
+
const buildCommand = buildTool === "maven" ? `${tool} -B -DskipTests package` : `${tool} ${framework === "spring-boot" ? "bootJar" : "build"} -x test --no-daemon`;
|
|
1176
|
+
const portProp = framework ? PORT_PROPERTY[framework] : void 0;
|
|
1177
|
+
return {
|
|
1178
|
+
profile: {
|
|
1179
|
+
rootDir: root,
|
|
1180
|
+
language: "java",
|
|
1181
|
+
framework,
|
|
1182
|
+
kind: "server",
|
|
1183
|
+
runtimeVersion,
|
|
1184
|
+
packageManager: buildTool,
|
|
1185
|
+
buildCommand,
|
|
1186
|
+
startCommand: `java ${portProp ? `-D${portProp}=$PORT ` : ""}-jar app.jar`,
|
|
1187
|
+
port: 8080,
|
|
1188
|
+
hasDockerfile: await hasDockerfile(this.fs, root),
|
|
1189
|
+
meta: {
|
|
1190
|
+
name,
|
|
1191
|
+
buildTool,
|
|
1192
|
+
wrapper: String(hasWrapper),
|
|
1193
|
+
jarDir: buildTool === "maven" ? "target" : "build/libs",
|
|
1194
|
+
portProperty: portProp ?? "",
|
|
1195
|
+
quarkus: String(framework === "quarkus")
|
|
1196
|
+
}
|
|
1197
|
+
},
|
|
1198
|
+
confidence: 85
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
};
|
|
1202
|
+
|
|
1203
|
+
// src/infrastructure/detectors/NodeDetector.ts
|
|
1204
|
+
var NodeDetector = class {
|
|
1205
|
+
constructor(fs) {
|
|
1206
|
+
this.fs = fs;
|
|
1207
|
+
}
|
|
1208
|
+
fs;
|
|
1209
|
+
id = "node";
|
|
1210
|
+
async detect(root) {
|
|
1211
|
+
const pkg = await readJson(this.fs, this.fs.join(root, "package.json"));
|
|
1212
|
+
if (!pkg) return null;
|
|
1213
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
1214
|
+
const has = (name) => name in deps;
|
|
1215
|
+
const scripts = pkg.scripts ?? {};
|
|
1216
|
+
const pm = await this.packageManager(root);
|
|
1217
|
+
const run = (script) => pm === "npm" ? `npm run ${script}` : `${pm} run ${script}`;
|
|
1218
|
+
const install = { npm: "npm ci", pnpm: "pnpm install --frozen-lockfile", yarn: "yarn install --frozen-lockfile", bun: "bun install" }[pm];
|
|
1219
|
+
const nvmrc = await this.fs.readTextIfExists(this.fs.join(root, ".nvmrc"));
|
|
1220
|
+
const base = {
|
|
1221
|
+
rootDir: root,
|
|
1222
|
+
language: "node",
|
|
1223
|
+
kind: "server",
|
|
1224
|
+
packageManager: pm,
|
|
1225
|
+
runtimeVersion: extractVersion(pkg.engines?.node, 1) ?? extractVersion(nvmrc, 1) ?? "22",
|
|
1226
|
+
port: 3e3,
|
|
1227
|
+
hasDockerfile: await hasDockerfile(this.fs, root),
|
|
1228
|
+
meta: { name: pkg.name ?? "", install, lockfile: await this.lockfile(root) ?? "" }
|
|
1229
|
+
};
|
|
1230
|
+
const build = scripts.build ? run("build") : void 0;
|
|
1231
|
+
const start = scripts.start ? pm === "npm" ? "npm start" : `${pm} start` : void 0;
|
|
1232
|
+
const staticSite2 = (framework, outputDir) => ({
|
|
1233
|
+
profile: { ...base, framework, kind: "static", buildCommand: build, outputDir },
|
|
1234
|
+
confidence: 90
|
|
1235
|
+
});
|
|
1236
|
+
const server = (framework, startCommand, port = 3e3) => ({
|
|
1237
|
+
profile: { ...base, framework, kind: "server", buildCommand: build, startCommand, port },
|
|
1238
|
+
confidence: 90
|
|
1239
|
+
});
|
|
1240
|
+
if (has("next")) {
|
|
1241
|
+
const nextConfig = await this.readFirst(root, ["next.config.js", "next.config.mjs", "next.config.ts"]);
|
|
1242
|
+
if (nextConfig && /output\s*:\s*['"]export['"]/.test(nextConfig)) return staticSite2("nextjs", "out");
|
|
1243
|
+
return { profile: { ...base, framework: "nextjs", kind: "serverless", buildCommand: build, startCommand: start ?? "npx next start" }, confidence: 90 };
|
|
1244
|
+
}
|
|
1245
|
+
if (has("nuxt")) return server("nuxt", "node .output/server/index.mjs");
|
|
1246
|
+
if (has("@sveltejs/kit")) {
|
|
1247
|
+
return has("@sveltejs/adapter-static") ? staticSite2("sveltekit", "build") : server("sveltekit", "node build");
|
|
1248
|
+
}
|
|
1249
|
+
if (has("@remix-run/node") || has("@remix-run/serve")) return server("remix", start);
|
|
1250
|
+
if (has("astro")) return has("@astrojs/node") ? server("astro", "node ./dist/server/entry.mjs", 4321) : staticSite2("astro", "dist");
|
|
1251
|
+
if (has("gatsby")) return staticSite2("gatsby", "public");
|
|
1252
|
+
if (has("@angular/core")) return staticSite2("angular", `dist/${pkg.name ?? "app"}/browser`);
|
|
1253
|
+
if (has("react-scripts")) return staticSite2("create-react-app", "build");
|
|
1254
|
+
if (has("@nestjs/core")) return server("nestjs", scripts["start:prod"] ? run("start:prod") : "node dist/main");
|
|
1255
|
+
for (const fw of ["express", "fastify", "koa", "hono", "@hapi/hapi"]) {
|
|
1256
|
+
if (has(fw)) return server(fw.replace("@hapi/", ""), start ?? `node ${pkg.main ?? "index.js"}`);
|
|
1257
|
+
}
|
|
1258
|
+
if (has("vite")) {
|
|
1259
|
+
const viteFw = ["react", "vue", "svelte", "solid-js", "preact"].find(has);
|
|
1260
|
+
return staticSite2(viteFw ? `vite-${viteFw.replace("-js", "")}` : "vite", "dist");
|
|
1261
|
+
}
|
|
1262
|
+
if (start) return server(void 0, start);
|
|
1263
|
+
if (build) return { ...staticSite2("node-static", "dist"), confidence: 60 };
|
|
1264
|
+
return null;
|
|
1265
|
+
}
|
|
1266
|
+
async packageManager(root) {
|
|
1267
|
+
const lock = await this.lockfile(root);
|
|
1268
|
+
if (lock === "pnpm-lock.yaml") return "pnpm";
|
|
1269
|
+
if (lock === "yarn.lock") return "yarn";
|
|
1270
|
+
if (lock?.startsWith("bun.lock")) return "bun";
|
|
1271
|
+
return "npm";
|
|
1272
|
+
}
|
|
1273
|
+
lockfile(root) {
|
|
1274
|
+
return firstExisting(this.fs, root, ["pnpm-lock.yaml", "yarn.lock", "bun.lockb", "bun.lock", "package-lock.json"]);
|
|
1275
|
+
}
|
|
1276
|
+
async readFirst(root, files) {
|
|
1277
|
+
const f = await firstExisting(this.fs, root, files);
|
|
1278
|
+
return f ? this.fs.readText(this.fs.join(root, f)) : null;
|
|
1279
|
+
}
|
|
1280
|
+
};
|
|
1281
|
+
|
|
1282
|
+
// src/infrastructure/detectors/PhpDetector.ts
|
|
1283
|
+
var PhpDetector = class {
|
|
1284
|
+
constructor(fs) {
|
|
1285
|
+
this.fs = fs;
|
|
1286
|
+
}
|
|
1287
|
+
fs;
|
|
1288
|
+
id = "php";
|
|
1289
|
+
async detect(root) {
|
|
1290
|
+
const composer = await readJson(this.fs, this.fs.join(root, "composer.json"));
|
|
1291
|
+
const hasIndex = await this.fs.exists(this.fs.join(root, "index.php"));
|
|
1292
|
+
const hasPublicIndex = await this.fs.exists(this.fs.join(root, "public", "index.php"));
|
|
1293
|
+
if (!composer && !hasIndex && !hasPublicIndex) return null;
|
|
1294
|
+
const req = composer?.require ?? {};
|
|
1295
|
+
const framework = "laravel/framework" in req ? "laravel" : "symfony/framework-bundle" in req ? "symfony" : void 0;
|
|
1296
|
+
const docroot = hasPublicIndex || framework ? "public" : ".";
|
|
1297
|
+
return {
|
|
1298
|
+
profile: {
|
|
1299
|
+
rootDir: root,
|
|
1300
|
+
language: "php",
|
|
1301
|
+
framework,
|
|
1302
|
+
kind: "server",
|
|
1303
|
+
packageManager: composer ? "composer" : void 0,
|
|
1304
|
+
runtimeVersion: extractVersion(req.php) ?? "8.3",
|
|
1305
|
+
startCommand: framework === "laravel" ? "php artisan serve --host=0.0.0.0 --port=$PORT" : `php -S 0.0.0.0:$PORT -t ${docroot}`,
|
|
1306
|
+
port: 8080,
|
|
1307
|
+
hasDockerfile: await hasDockerfile(this.fs, root),
|
|
1308
|
+
meta: { name: composer?.name?.split("/").pop() ?? "", docroot, composer: String(Boolean(composer)) }
|
|
1309
|
+
},
|
|
1310
|
+
confidence: composer ? 80 : 50
|
|
1311
|
+
};
|
|
1312
|
+
}
|
|
1313
|
+
};
|
|
1314
|
+
|
|
1315
|
+
// src/infrastructure/detectors/PythonDetector.ts
|
|
1316
|
+
import { parse as parseToml } from "smol-toml";
|
|
1317
|
+
var PythonDetector = class {
|
|
1318
|
+
constructor(fs) {
|
|
1319
|
+
this.fs = fs;
|
|
1320
|
+
}
|
|
1321
|
+
fs;
|
|
1322
|
+
id = "python";
|
|
1323
|
+
async detect(root) {
|
|
1324
|
+
const read = (f) => this.fs.readTextIfExists(this.fs.join(root, f));
|
|
1325
|
+
const [requirements, pyprojectRaw, pipfile, hasManage] = await Promise.all([
|
|
1326
|
+
read("requirements.txt"),
|
|
1327
|
+
read("pyproject.toml"),
|
|
1328
|
+
read("Pipfile"),
|
|
1329
|
+
this.fs.exists(this.fs.join(root, "manage.py"))
|
|
1330
|
+
]);
|
|
1331
|
+
if (requirements === null && pyprojectRaw === null && pipfile === null && !hasManage) return null;
|
|
1332
|
+
let pyproject = {};
|
|
1333
|
+
try {
|
|
1334
|
+
pyproject = pyprojectRaw ? parseToml(pyprojectRaw) : {};
|
|
1335
|
+
} catch {
|
|
1336
|
+
}
|
|
1337
|
+
const depsText = [requirements, pyprojectRaw, pipfile].filter(Boolean).join("\n").toLowerCase();
|
|
1338
|
+
const has = (name2) => new RegExp(`(^|[\\s"'\\[,])${name2}([\\s"'<>=~!\\[,;]|$)`, "m").test(depsText);
|
|
1339
|
+
const packageManager = await this.fs.exists(this.fs.join(root, "uv.lock")) ? "uv" : await this.fs.exists(this.fs.join(root, "poetry.lock")) || pyproject.tool?.poetry ? "poetry" : "pip";
|
|
1340
|
+
const runtimeVersion = extractVersion(await read(".python-version")) ?? extractVersion((await read("runtime.txt"))?.replace("python-", "")) ?? extractVersion(pyproject.project?.["requires-python"]) ?? "3.12";
|
|
1341
|
+
const name = pyproject.project?.name ?? pyproject.tool?.poetry?.name ?? "";
|
|
1342
|
+
const base = {
|
|
1343
|
+
rootDir: root,
|
|
1344
|
+
language: "python",
|
|
1345
|
+
kind: "server",
|
|
1346
|
+
packageManager,
|
|
1347
|
+
runtimeVersion,
|
|
1348
|
+
hasDockerfile: await hasDockerfile(this.fs, root)
|
|
1349
|
+
};
|
|
1350
|
+
const requirementsMeta = { requirements: String(requirements !== null) };
|
|
1351
|
+
if (hasManage || has("django")) {
|
|
1352
|
+
const project = await this.findDjangoProject(root);
|
|
1353
|
+
return {
|
|
1354
|
+
profile: {
|
|
1355
|
+
...base,
|
|
1356
|
+
framework: "django",
|
|
1357
|
+
port: 8e3,
|
|
1358
|
+
startCommand: `gunicorn ${project}.wsgi:application --bind 0.0.0.0:$PORT`,
|
|
1359
|
+
buildCommand: "python manage.py collectstatic --noinput",
|
|
1360
|
+
meta: { ...requirementsMeta, name, extraPackages: has("gunicorn") ? "" : "gunicorn" }
|
|
1361
|
+
},
|
|
1362
|
+
confidence: 85
|
|
1363
|
+
};
|
|
1364
|
+
}
|
|
1365
|
+
if (has("fastapi")) {
|
|
1366
|
+
const mod = await this.findAppModule(root, "FastAPI(") ?? "main:app";
|
|
1367
|
+
return {
|
|
1368
|
+
profile: { ...base, framework: "fastapi", port: 8e3, startCommand: `uvicorn ${mod} --host 0.0.0.0 --port $PORT`, meta: { ...requirementsMeta, name, extraPackages: has("uvicorn") ? "" : "uvicorn" } },
|
|
1369
|
+
confidence: 85
|
|
1370
|
+
};
|
|
1371
|
+
}
|
|
1372
|
+
if (has("flask")) {
|
|
1373
|
+
const mod = await this.findAppModule(root, "Flask(") ?? "app:app";
|
|
1374
|
+
return {
|
|
1375
|
+
profile: { ...base, framework: "flask", port: 5e3, startCommand: `gunicorn ${mod} --bind 0.0.0.0:$PORT`, meta: { ...requirementsMeta, name, extraPackages: has("gunicorn") ? "" : "gunicorn" } },
|
|
1376
|
+
confidence: 85
|
|
1377
|
+
};
|
|
1378
|
+
}
|
|
1379
|
+
if (has("streamlit")) {
|
|
1380
|
+
const entry2 = await firstExisting(this.fs, root, ["streamlit_app.py", "app.py", "main.py"]) ?? "app.py";
|
|
1381
|
+
return {
|
|
1382
|
+
profile: { ...base, framework: "streamlit", port: 8501, startCommand: `streamlit run ${entry2} --server.port $PORT --server.address 0.0.0.0`, meta: { ...requirementsMeta, name } },
|
|
1383
|
+
confidence: 85
|
|
1384
|
+
};
|
|
1385
|
+
}
|
|
1386
|
+
const entry = await firstExisting(this.fs, root, ["main.py", "app.py", "server.py"]);
|
|
1387
|
+
return {
|
|
1388
|
+
profile: { ...base, port: 8e3, startCommand: entry ? `python ${entry}` : void 0, meta: { ...requirementsMeta, name } },
|
|
1389
|
+
confidence: 60
|
|
1390
|
+
};
|
|
1391
|
+
}
|
|
1392
|
+
async findDjangoProject(root) {
|
|
1393
|
+
for (const entry of await this.fs.list(root)) {
|
|
1394
|
+
if (await this.fs.exists(this.fs.join(root, entry, "wsgi.py"))) return entry;
|
|
1395
|
+
}
|
|
1396
|
+
return "config";
|
|
1397
|
+
}
|
|
1398
|
+
/** Finds "module:app" for the file that instantiates the framework app. */
|
|
1399
|
+
async findAppModule(root, marker) {
|
|
1400
|
+
for (const file of ["main.py", "app.py", "server.py", "app/main.py", "src/main.py", "api/main.py", "app/__init__.py"]) {
|
|
1401
|
+
const src = await this.fs.readTextIfExists(this.fs.join(root, file));
|
|
1402
|
+
if (!src?.includes(marker)) continue;
|
|
1403
|
+
const varName = src.match(new RegExp(`^(\\w+)\\s*=\\s*${marker.replace("(", "\\(")}`, "m"))?.[1] ?? "app";
|
|
1404
|
+
const mod = file.replace(/\/__init__\.py$/, "").replace(/\.py$/, "").replace(/\//g, ".");
|
|
1405
|
+
return `${mod}:${varName}`;
|
|
1406
|
+
}
|
|
1407
|
+
return void 0;
|
|
1408
|
+
}
|
|
1409
|
+
};
|
|
1410
|
+
|
|
1411
|
+
// src/infrastructure/detectors/RubyDetector.ts
|
|
1412
|
+
var RubyDetector = class {
|
|
1413
|
+
constructor(fs) {
|
|
1414
|
+
this.fs = fs;
|
|
1415
|
+
}
|
|
1416
|
+
fs;
|
|
1417
|
+
id = "ruby";
|
|
1418
|
+
async detect(root) {
|
|
1419
|
+
const gemfile = await this.fs.readTextIfExists(this.fs.join(root, "Gemfile"));
|
|
1420
|
+
if (gemfile === null) return null;
|
|
1421
|
+
const has = (gem) => new RegExp(`^\\s*gem\\s+['"]${gem}['"]`, "m").test(gemfile);
|
|
1422
|
+
const version = extractVersion(await this.fs.readTextIfExists(this.fs.join(root, ".ruby-version"))) ?? extractVersion(gemfile.match(/^\s*ruby\s+['"]([^'"]+)/m)?.[1]) ?? "3.3";
|
|
1423
|
+
const rails = has("rails");
|
|
1424
|
+
return {
|
|
1425
|
+
profile: {
|
|
1426
|
+
rootDir: root,
|
|
1427
|
+
language: "ruby",
|
|
1428
|
+
framework: rails ? "rails" : has("sinatra") ? "sinatra" : void 0,
|
|
1429
|
+
kind: "server",
|
|
1430
|
+
packageManager: "bundler",
|
|
1431
|
+
runtimeVersion: version,
|
|
1432
|
+
buildCommand: rails ? "bundle exec rails assets:precompile" : void 0,
|
|
1433
|
+
startCommand: rails ? "bundle exec rails server -b 0.0.0.0 -p $PORT" : "bundle exec rackup -o 0.0.0.0 -p $PORT",
|
|
1434
|
+
port: 3e3,
|
|
1435
|
+
hasDockerfile: await hasDockerfile(this.fs, root),
|
|
1436
|
+
meta: { name: "" }
|
|
1437
|
+
},
|
|
1438
|
+
confidence: 80
|
|
1439
|
+
};
|
|
1440
|
+
}
|
|
1441
|
+
};
|
|
1442
|
+
|
|
1443
|
+
// src/infrastructure/detectors/RustDetector.ts
|
|
1444
|
+
import { parse as parseToml2 } from "smol-toml";
|
|
1445
|
+
var RustDetector = class {
|
|
1446
|
+
constructor(fs) {
|
|
1447
|
+
this.fs = fs;
|
|
1448
|
+
}
|
|
1449
|
+
fs;
|
|
1450
|
+
id = "rust";
|
|
1451
|
+
async detect(root) {
|
|
1452
|
+
const raw = await this.fs.readTextIfExists(this.fs.join(root, "Cargo.toml"));
|
|
1453
|
+
if (raw === null) return null;
|
|
1454
|
+
let cargo = {};
|
|
1455
|
+
try {
|
|
1456
|
+
cargo = parseToml2(raw);
|
|
1457
|
+
} catch {
|
|
1458
|
+
}
|
|
1459
|
+
const deps = Object.keys(cargo.dependencies ?? {});
|
|
1460
|
+
const framework = ["actix-web", "axum", "rocket", "warp", "poem"].find((f) => deps.includes(f));
|
|
1461
|
+
const binName = cargo.bin?.[0]?.name ?? cargo.package?.name ?? "app";
|
|
1462
|
+
const toolchain = await this.fs.readTextIfExists(this.fs.join(root, "rust-toolchain")) ?? await this.fs.readTextIfExists(this.fs.join(root, "rust-toolchain.toml"));
|
|
1463
|
+
return {
|
|
1464
|
+
profile: {
|
|
1465
|
+
rootDir: root,
|
|
1466
|
+
language: "rust",
|
|
1467
|
+
framework,
|
|
1468
|
+
kind: "server",
|
|
1469
|
+
packageManager: "cargo",
|
|
1470
|
+
runtimeVersion: extractVersion(toolchain) ?? "1",
|
|
1471
|
+
buildCommand: "cargo build --release",
|
|
1472
|
+
startCommand: `./${binName}`,
|
|
1473
|
+
port: 8080,
|
|
1474
|
+
hasDockerfile: await hasDockerfile(this.fs, root),
|
|
1475
|
+
meta: { name: cargo.package?.name ?? "", binName }
|
|
1476
|
+
},
|
|
1477
|
+
confidence: framework ? 85 : 70
|
|
1478
|
+
};
|
|
1479
|
+
}
|
|
1480
|
+
};
|
|
1481
|
+
|
|
1482
|
+
// src/infrastructure/detectors/StaticDetector.ts
|
|
1483
|
+
var StaticDetector = class {
|
|
1484
|
+
constructor(fs) {
|
|
1485
|
+
this.fs = fs;
|
|
1486
|
+
}
|
|
1487
|
+
fs;
|
|
1488
|
+
id = "static";
|
|
1489
|
+
async detect(root) {
|
|
1490
|
+
for (const dir of [".", "public", "docs", "site"]) {
|
|
1491
|
+
if (await this.fs.exists(this.fs.join(root, dir, "index.html"))) {
|
|
1492
|
+
return {
|
|
1493
|
+
profile: {
|
|
1494
|
+
rootDir: root,
|
|
1495
|
+
language: "static",
|
|
1496
|
+
framework: "html",
|
|
1497
|
+
kind: "static",
|
|
1498
|
+
outputDir: dir,
|
|
1499
|
+
port: 8080,
|
|
1500
|
+
hasDockerfile: await hasDockerfile(this.fs, root)
|
|
1501
|
+
},
|
|
1502
|
+
confidence: 40
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
return null;
|
|
1507
|
+
}
|
|
1508
|
+
};
|
|
1509
|
+
|
|
1510
|
+
// src/infrastructure/detectors/index.ts
|
|
1511
|
+
function builtInDetectors(fs) {
|
|
1512
|
+
return [
|
|
1513
|
+
new NodeDetector(fs),
|
|
1514
|
+
new PythonDetector(fs),
|
|
1515
|
+
new JavaDetector(fs),
|
|
1516
|
+
new GoDetector(fs),
|
|
1517
|
+
new RustDetector(fs),
|
|
1518
|
+
new RubyDetector(fs),
|
|
1519
|
+
new PhpDetector(fs),
|
|
1520
|
+
new DotnetDetector(fs),
|
|
1521
|
+
new StaticDetector(fs),
|
|
1522
|
+
new DockerDetector(fs)
|
|
1523
|
+
];
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
// src/infrastructure/fs/NodeFileSystem.ts
|
|
1527
|
+
import { constants } from "fs";
|
|
1528
|
+
import { access, cp, mkdir as mkdir2, mkdtemp, readdir, readFile as readFile2, rm, writeFile as writeFile2 } from "fs/promises";
|
|
1529
|
+
import { tmpdir } from "os";
|
|
1530
|
+
import { dirname as dirname2, join, resolve } from "path";
|
|
1531
|
+
import fg from "fast-glob";
|
|
1532
|
+
import ignore from "ignore";
|
|
1533
|
+
var ALWAYS_IGNORED = ["**/node_modules/**", "**/.git/**", "**/.DS_Store"];
|
|
1534
|
+
var NodeFileSystem = class {
|
|
1535
|
+
async exists(path) {
|
|
1536
|
+
try {
|
|
1537
|
+
await access(path, constants.F_OK);
|
|
1538
|
+
return true;
|
|
1539
|
+
} catch {
|
|
1540
|
+
return false;
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
readText(path) {
|
|
1544
|
+
return readFile2(path, "utf8");
|
|
1545
|
+
}
|
|
1546
|
+
async readTextIfExists(path) {
|
|
1547
|
+
try {
|
|
1548
|
+
return await readFile2(path, "utf8");
|
|
1549
|
+
} catch (e) {
|
|
1550
|
+
if (e.code === "ENOENT" || e.code === "EISDIR") return null;
|
|
1551
|
+
throw e;
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
async readBytes(path) {
|
|
1555
|
+
return new Uint8Array(await readFile2(path));
|
|
1556
|
+
}
|
|
1557
|
+
async writeText(path, contents) {
|
|
1558
|
+
await mkdir2(dirname2(path), { recursive: true });
|
|
1559
|
+
await writeFile2(path, contents, "utf8");
|
|
1560
|
+
}
|
|
1561
|
+
async list(dir) {
|
|
1562
|
+
try {
|
|
1563
|
+
return await readdir(dir);
|
|
1564
|
+
} catch {
|
|
1565
|
+
return [];
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
async collectFiles(dir, opts = {}) {
|
|
1569
|
+
const files = await fg("**/*", {
|
|
1570
|
+
cwd: dir,
|
|
1571
|
+
dot: true,
|
|
1572
|
+
onlyFiles: true,
|
|
1573
|
+
followSymbolicLinks: false,
|
|
1574
|
+
ignore: [...ALWAYS_IGNORED, ...opts.ignore ?? []]
|
|
1575
|
+
});
|
|
1576
|
+
if (!opts.respectGitignore) return files.sort();
|
|
1577
|
+
const gitignore = await this.readTextIfExists(join(dir, ".gitignore"));
|
|
1578
|
+
if (!gitignore) return files.sort();
|
|
1579
|
+
const ig = ignore().add(gitignore);
|
|
1580
|
+
return files.filter((f) => !ig.ignores(f)).sort();
|
|
1581
|
+
}
|
|
1582
|
+
async copyDir(from, to) {
|
|
1583
|
+
await cp(from, to, {
|
|
1584
|
+
recursive: true,
|
|
1585
|
+
filter: (src) => !/[\\/](node_modules|\.git)([\\/]|$)/.test(src.slice(from.length))
|
|
1586
|
+
});
|
|
1587
|
+
}
|
|
1588
|
+
makeTempDir(prefix) {
|
|
1589
|
+
return mkdtemp(join(tmpdir(), `${prefix}-`));
|
|
1590
|
+
}
|
|
1591
|
+
async remove(path) {
|
|
1592
|
+
await rm(path, { recursive: true, force: true });
|
|
1593
|
+
}
|
|
1594
|
+
join(...parts) {
|
|
1595
|
+
return join(...parts);
|
|
1596
|
+
}
|
|
1597
|
+
resolve(...parts) {
|
|
1598
|
+
return resolve(...parts);
|
|
1599
|
+
}
|
|
1600
|
+
};
|
|
1601
|
+
|
|
1602
|
+
// src/infrastructure/generators/dockerfile/templates.ts
|
|
1603
|
+
var HEADER = "# syntax=docker/dockerfile:1\n# Generated by omnideploy \u2014 safe to edit; omnideploy will not overwrite it without --force.\n";
|
|
1604
|
+
var shCmd = (cmd) => `CMD ["sh", "-c", ${JSON.stringify(`exec ${cmd}`)}]`;
|
|
1605
|
+
var lines = (...l) => l.filter((x) => typeof x === "string").join("\n") + "\n";
|
|
1606
|
+
var nginxStage = (from, port) => lines(
|
|
1607
|
+
"FROM nginxinc/nginx-unprivileged:1.27-alpine",
|
|
1608
|
+
`ENV PORT=${port}`,
|
|
1609
|
+
"USER root",
|
|
1610
|
+
`RUN mkdir -p /etc/nginx/templates && printf 'server {\\n listen \${PORT};\\n root /usr/share/nginx/html;\\n location / { try_files $uri $uri/ /index.html; }\\n}\\n' > /etc/nginx/templates/default.conf.template`,
|
|
1611
|
+
"RUN rm -f /etc/nginx/conf.d/default.conf && chown -R 101:101 /etc/nginx/conf.d /etc/nginx/templates",
|
|
1612
|
+
`COPY --from=${from} --chown=101:101 /out /usr/share/nginx/html`,
|
|
1613
|
+
"USER 101",
|
|
1614
|
+
`EXPOSE ${port}`
|
|
1615
|
+
);
|
|
1616
|
+
function nodeImage(p11) {
|
|
1617
|
+
if (p11.packageManager === "bun") return { image: "oven/bun:1-alpine", user: "bun" };
|
|
1618
|
+
const setup = p11.packageManager === "pnpm" || p11.packageManager === "yarn" ? "RUN corepack enable" : void 0;
|
|
1619
|
+
return { image: `node:${p11.runtimeVersion ?? "22"}-alpine`, user: "node", setup };
|
|
1620
|
+
}
|
|
1621
|
+
function nodeInstall(p11) {
|
|
1622
|
+
const lock = p11.meta?.lockfile;
|
|
1623
|
+
const copy = `COPY package.json ${lock ? `${lock} ` : ""}./`;
|
|
1624
|
+
if (!lock) return { copy, run: p11.packageManager === "npm" || !p11.packageManager ? "npm install" : `${p11.packageManager} install` };
|
|
1625
|
+
return { copy, run: p11.meta?.install || "npm ci" };
|
|
1626
|
+
}
|
|
1627
|
+
var node = (p11) => {
|
|
1628
|
+
const { image, user, setup } = nodeImage(p11);
|
|
1629
|
+
const install = nodeInstall(p11);
|
|
1630
|
+
const build = lines(
|
|
1631
|
+
`FROM ${image} AS build`,
|
|
1632
|
+
"WORKDIR /app",
|
|
1633
|
+
setup,
|
|
1634
|
+
install.copy,
|
|
1635
|
+
`RUN ${install.run}`,
|
|
1636
|
+
"COPY . .",
|
|
1637
|
+
p11.buildCommand && `RUN ${p11.buildCommand}`
|
|
1638
|
+
);
|
|
1639
|
+
if (p11.kind === "static") {
|
|
1640
|
+
return HEADER + build + `RUN cp -r ${p11.outputDir ?? "dist"} /out
|
|
1641
|
+
|
|
1642
|
+
` + nginxStage("build", p11.port === 3e3 ? 8080 : p11.port);
|
|
1643
|
+
}
|
|
1644
|
+
return HEADER + build + "\n" + lines(
|
|
1645
|
+
`FROM ${image}`,
|
|
1646
|
+
"WORKDIR /app",
|
|
1647
|
+
setup,
|
|
1648
|
+
`ENV NODE_ENV=production PORT=${p11.port}`,
|
|
1649
|
+
`COPY --from=build --chown=${user}:${user} /app ./`,
|
|
1650
|
+
`USER ${user}`,
|
|
1651
|
+
`EXPOSE ${p11.port}`,
|
|
1652
|
+
shCmd(p11.startCommand ?? "npm start")
|
|
1653
|
+
);
|
|
1654
|
+
};
|
|
1655
|
+
var staticSite = (p11) => HEADER + lines("FROM busybox:1.36 AS files", "WORKDIR /src", "COPY . .", `RUN cp -r ${p11.outputDir ?? "."} /out`) + "\n" + nginxStage("files", 8080);
|
|
1656
|
+
var python = (p11) => {
|
|
1657
|
+
const v = p11.runtimeVersion ?? "3.12";
|
|
1658
|
+
const extra = p11.meta?.extraPackages ? ` ${p11.meta.extraPackages}` : "";
|
|
1659
|
+
let deps;
|
|
1660
|
+
if (p11.packageManager === "uv") {
|
|
1661
|
+
deps = lines("COPY pyproject.toml uv.lock ./", `RUN pip install uv && uv export --frozen --no-dev --no-hashes --no-emit-project -o requirements.txt && /venv/bin/pip install -r requirements.txt${extra}`);
|
|
1662
|
+
} else if (p11.packageManager === "poetry") {
|
|
1663
|
+
deps = lines(
|
|
1664
|
+
"COPY pyproject.toml poetry.lock* ./",
|
|
1665
|
+
`RUN pip install poetry poetry-plugin-export && poetry export --without-hashes --only main -f requirements.txt -o requirements.txt && /venv/bin/pip install -r requirements.txt${extra}`
|
|
1666
|
+
);
|
|
1667
|
+
} else {
|
|
1668
|
+
deps = p11.meta?.requirements === "true" ? lines("COPY requirements.txt ./", `RUN /venv/bin/pip install -r requirements.txt${extra}`) : lines("COPY . .", `RUN /venv/bin/pip install .${extra ? ` && /venv/bin/pip install${extra}` : ""}`);
|
|
1669
|
+
}
|
|
1670
|
+
return HEADER + lines(
|
|
1671
|
+
`FROM python:${v}-slim AS deps`,
|
|
1672
|
+
"ENV PIP_NO_CACHE_DIR=1 PIP_DISABLE_PIP_VERSION_CHECK=1",
|
|
1673
|
+
"WORKDIR /app",
|
|
1674
|
+
"RUN python -m venv /venv"
|
|
1675
|
+
) + deps + "\n" + lines(
|
|
1676
|
+
`FROM python:${v}-slim`,
|
|
1677
|
+
`ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PATH=/venv/bin:$PATH PORT=${p11.port}`,
|
|
1678
|
+
"RUN useradd --create-home --uid 10001 app",
|
|
1679
|
+
"WORKDIR /app",
|
|
1680
|
+
"COPY --from=deps /venv /venv",
|
|
1681
|
+
"COPY --chown=app:app . .",
|
|
1682
|
+
"USER app",
|
|
1683
|
+
p11.buildCommand && `RUN ${p11.buildCommand} || echo "omnideploy: build step '${p11.buildCommand}' skipped (needs runtime config)"`,
|
|
1684
|
+
`EXPOSE ${p11.port}`,
|
|
1685
|
+
shCmd(p11.startCommand ?? "python main.py")
|
|
1686
|
+
);
|
|
1687
|
+
};
|
|
1688
|
+
var java = (p11) => {
|
|
1689
|
+
const v = p11.runtimeVersion ?? "21";
|
|
1690
|
+
const maven = p11.meta?.buildTool !== "gradle";
|
|
1691
|
+
const quarkus = p11.meta?.quarkus === "true";
|
|
1692
|
+
const buildImage = maven ? `maven:3.9-eclipse-temurin-${v}` : `gradle:jdk${v}`;
|
|
1693
|
+
const build = maven ? "mvn -B -q -DskipTests package" : `gradle ${p11.framework === "spring-boot" ? "bootJar" : "build"} -x test --no-daemon -q`;
|
|
1694
|
+
const jarDir = p11.meta?.jarDir ?? (maven ? "target" : "build/libs");
|
|
1695
|
+
const portProp = p11.meta?.portProperty;
|
|
1696
|
+
const collect = quarkus ? `RUN cp -r ${jarDir}/quarkus-app /out` : `RUN mkdir /out && cp "$(ls ${jarDir}/*.jar | grep -v -e '-plain.jar$' -e '-sources.jar$' -e '-javadoc.jar$' -e '/original-' | head -n 1)" /out/app.jar`;
|
|
1697
|
+
const jar = quarkus ? "quarkus-run.jar" : "app.jar";
|
|
1698
|
+
return HEADER + lines(
|
|
1699
|
+
`FROM ${buildImage} AS build`,
|
|
1700
|
+
"WORKDIR /src",
|
|
1701
|
+
maven ? "COPY pom.xml ./" : "COPY build.gradle* settings.gradle* gradle.properties* ./",
|
|
1702
|
+
maven ? "RUN mvn -B -q dependency:go-offline || true" : "RUN gradle dependencies --no-daemon -q || true",
|
|
1703
|
+
"COPY . .",
|
|
1704
|
+
`RUN ${build}`,
|
|
1705
|
+
collect
|
|
1706
|
+
) + "\n" + lines(
|
|
1707
|
+
`FROM eclipse-temurin:${v}-jre`,
|
|
1708
|
+
"RUN useradd --system --create-home --uid 10001 app",
|
|
1709
|
+
"WORKDIR /app",
|
|
1710
|
+
"COPY --from=build --chown=app:app /out ./",
|
|
1711
|
+
"USER app",
|
|
1712
|
+
`ENV PORT=${p11.port} JAVA_OPTS="-XX:MaxRAMPercentage=75"`,
|
|
1713
|
+
`EXPOSE ${p11.port}`,
|
|
1714
|
+
shCmd(`java $JAVA_OPTS ${portProp ? `-D${portProp}=$PORT ` : ""}-jar ${jar}`)
|
|
1715
|
+
);
|
|
1716
|
+
};
|
|
1717
|
+
var go = (p11) => HEADER + lines(
|
|
1718
|
+
`FROM golang:${p11.runtimeVersion ?? "1.23"}-alpine AS build`,
|
|
1719
|
+
"WORKDIR /src",
|
|
1720
|
+
"COPY go.mod go.su[m] ./",
|
|
1721
|
+
"RUN go mod download",
|
|
1722
|
+
"COPY . .",
|
|
1723
|
+
`RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/app ${p11.meta?.mainPath ?? "."}`
|
|
1724
|
+
) + "\n" + lines(
|
|
1725
|
+
"FROM gcr.io/distroless/static-debian12:nonroot",
|
|
1726
|
+
"COPY --from=build /out/app /app",
|
|
1727
|
+
`ENV PORT=${p11.port}`,
|
|
1728
|
+
`EXPOSE ${p11.port}`,
|
|
1729
|
+
"USER nonroot:nonroot",
|
|
1730
|
+
'ENTRYPOINT ["/app"]'
|
|
1731
|
+
);
|
|
1732
|
+
var rust = (p11) => {
|
|
1733
|
+
const bin = p11.meta?.binName ?? "app";
|
|
1734
|
+
return HEADER + lines(`FROM rust:${p11.runtimeVersion ?? "1"}-slim AS build`, "WORKDIR /src", "COPY . .", `RUN cargo build --release --bin ${bin} && cp target/release/${bin} /out-app`) + "\n" + lines(
|
|
1735
|
+
"FROM debian:bookworm-slim",
|
|
1736
|
+
"RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/* \\",
|
|
1737
|
+
" && useradd --system --uid 10001 app",
|
|
1738
|
+
"COPY --from=build /out-app /usr/local/bin/app",
|
|
1739
|
+
`ENV PORT=${p11.port}`,
|
|
1740
|
+
"USER app",
|
|
1741
|
+
`EXPOSE ${p11.port}`,
|
|
1742
|
+
'CMD ["app"]'
|
|
1743
|
+
);
|
|
1744
|
+
};
|
|
1745
|
+
var ruby = (p11) => {
|
|
1746
|
+
const v = p11.runtimeVersion ?? "3.3";
|
|
1747
|
+
const rails = p11.framework === "rails";
|
|
1748
|
+
return HEADER + lines(
|
|
1749
|
+
`FROM ruby:${v}-slim AS build`,
|
|
1750
|
+
"RUN apt-get update && apt-get install -y --no-install-recommends build-essential git libpq-dev libyaml-dev pkg-config && rm -rf /var/lib/apt/lists/*",
|
|
1751
|
+
"WORKDIR /app",
|
|
1752
|
+
'ENV BUNDLE_WITHOUT="development:test" RAILS_ENV=production RACK_ENV=production',
|
|
1753
|
+
"COPY Gemfile Gemfile.loc[k] ./",
|
|
1754
|
+
"RUN bundle install --jobs 4",
|
|
1755
|
+
"COPY . .",
|
|
1756
|
+
rails && "RUN SECRET_KEY_BASE_DUMMY=1 bundle exec rails assets:precompile"
|
|
1757
|
+
) + "\n" + lines(
|
|
1758
|
+
`FROM ruby:${v}-slim`,
|
|
1759
|
+
"RUN apt-get update && apt-get install -y --no-install-recommends libpq5 libyaml-0-2 && rm -rf /var/lib/apt/lists/* \\",
|
|
1760
|
+
" && useradd --create-home --uid 10001 app",
|
|
1761
|
+
"WORKDIR /app",
|
|
1762
|
+
`ENV BUNDLE_WITHOUT="development:test" RAILS_ENV=production RACK_ENV=production RAILS_LOG_TO_STDOUT=1 RAILS_SERVE_STATIC_FILES=1 PORT=${p11.port}`,
|
|
1763
|
+
"COPY --from=build /usr/local/bundle /usr/local/bundle",
|
|
1764
|
+
"COPY --from=build --chown=app:app /app /app",
|
|
1765
|
+
"USER app",
|
|
1766
|
+
`EXPOSE ${p11.port}`,
|
|
1767
|
+
shCmd(p11.startCommand ?? "bundle exec rackup -o 0.0.0.0 -p $PORT")
|
|
1768
|
+
);
|
|
1769
|
+
};
|
|
1770
|
+
var php = (p11) => {
|
|
1771
|
+
const v = p11.runtimeVersion ?? "8.3";
|
|
1772
|
+
const composer = p11.meta?.composer === "true";
|
|
1773
|
+
return HEADER + (composer ? lines(
|
|
1774
|
+
"FROM composer:2 AS vendor",
|
|
1775
|
+
"WORKDIR /app",
|
|
1776
|
+
"COPY composer.json composer.loc[k] ./",
|
|
1777
|
+
"RUN composer install --no-dev --no-scripts --no-interaction --prefer-dist --ignore-platform-reqs --optimize-autoloader"
|
|
1778
|
+
) + "\n" : "") + lines(
|
|
1779
|
+
`FROM php:${v}-cli`,
|
|
1780
|
+
"RUN docker-php-ext-install pdo pdo_mysql opcache && useradd --create-home --uid 10001 app",
|
|
1781
|
+
"WORKDIR /app",
|
|
1782
|
+
composer && "COPY --from=vendor --chown=app:app /app/vendor ./vendor",
|
|
1783
|
+
"COPY --chown=app:app . .",
|
|
1784
|
+
"USER app",
|
|
1785
|
+
`ENV PORT=${p11.port}`,
|
|
1786
|
+
`EXPOSE ${p11.port}`,
|
|
1787
|
+
shCmd(p11.startCommand ?? `php -S 0.0.0.0:$PORT -t ${p11.meta?.docroot ?? "."}`)
|
|
1788
|
+
);
|
|
1789
|
+
};
|
|
1790
|
+
var dotnet = (p11) => {
|
|
1791
|
+
const v = p11.runtimeVersion ?? "8.0";
|
|
1792
|
+
const hasAppUser = Number(v.split(".")[0]) >= 8;
|
|
1793
|
+
return HEADER + lines(
|
|
1794
|
+
`FROM mcr.microsoft.com/dotnet/sdk:${v} AS build`,
|
|
1795
|
+
"WORKDIR /src",
|
|
1796
|
+
`COPY ${p11.meta?.projectFile ?? "*.csproj"} ./`,
|
|
1797
|
+
"RUN dotnet restore",
|
|
1798
|
+
"COPY . .",
|
|
1799
|
+
"RUN dotnet publish -c Release -o /out --no-restore"
|
|
1800
|
+
) + "\n" + lines(
|
|
1801
|
+
`FROM mcr.microsoft.com/dotnet/aspnet:${v}`,
|
|
1802
|
+
"WORKDIR /app",
|
|
1803
|
+
"COPY --from=build /out .",
|
|
1804
|
+
hasAppUser ? "USER app" : "RUN useradd --system --uid 10001 app\nUSER app",
|
|
1805
|
+
`ENV PORT=${p11.port}`,
|
|
1806
|
+
`EXPOSE ${p11.port}`,
|
|
1807
|
+
shCmd(`env ASPNETCORE_URLS=http://+:$PORT ${p11.startCommand ?? "dotnet app.dll"}`)
|
|
1808
|
+
);
|
|
1809
|
+
};
|
|
1810
|
+
var DOCKER_TEMPLATES = {
|
|
1811
|
+
node,
|
|
1812
|
+
static: staticSite,
|
|
1813
|
+
python,
|
|
1814
|
+
java,
|
|
1815
|
+
go,
|
|
1816
|
+
rust,
|
|
1817
|
+
ruby,
|
|
1818
|
+
php,
|
|
1819
|
+
dotnet
|
|
1820
|
+
};
|
|
1821
|
+
var IGNORES = {
|
|
1822
|
+
node: ["node_modules", "npm-debug.log*", ".next", ".nuxt", ".output", "coverage"],
|
|
1823
|
+
python: ["__pycache__", "*.pyc", ".venv", "venv", ".pytest_cache", ".mypy_cache"],
|
|
1824
|
+
java: ["target", "build", ".gradle", "*.iml", ".idea"],
|
|
1825
|
+
go: ["bin", "vendor"],
|
|
1826
|
+
rust: ["target"],
|
|
1827
|
+
ruby: ["log/*", "tmp/*", "vendor/bundle", ".bundle"],
|
|
1828
|
+
php: ["vendor", "storage/logs/*"],
|
|
1829
|
+
dotnet: ["bin", "obj", "out"]
|
|
1830
|
+
};
|
|
1831
|
+
function dockerignoreFor(p11) {
|
|
1832
|
+
return ["# Generated by omnideploy", ".git", ".gitignore", "Dockerfile", ".dockerignore", ".env", ".env.*", "*.log", ".DS_Store", ...IGNORES[p11.language] ?? []].join("\n") + "\n";
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
// src/infrastructure/generators/dockerfile/DockerfileGenerator.ts
|
|
1836
|
+
var DockerfileGenerator = class {
|
|
1837
|
+
id = "dockerfile";
|
|
1838
|
+
path = "Dockerfile";
|
|
1839
|
+
supports(profile) {
|
|
1840
|
+
return profile.language in DOCKER_TEMPLATES;
|
|
1841
|
+
}
|
|
1842
|
+
generate(profile) {
|
|
1843
|
+
const template = DOCKER_TEMPLATES[profile.language];
|
|
1844
|
+
return {
|
|
1845
|
+
id: this.id,
|
|
1846
|
+
path: this.path,
|
|
1847
|
+
contents: template(profile),
|
|
1848
|
+
description: `multi-stage ${LANGUAGE_LABELS[profile.language]}${profile.framework ? ` (${profile.framework})` : ""} image`
|
|
1849
|
+
};
|
|
1850
|
+
}
|
|
1851
|
+
};
|
|
1852
|
+
var DockerignoreGenerator = class {
|
|
1853
|
+
id = "dockerignore";
|
|
1854
|
+
path = ".dockerignore";
|
|
1855
|
+
supports() {
|
|
1856
|
+
return true;
|
|
1857
|
+
}
|
|
1858
|
+
generate(profile) {
|
|
1859
|
+
return { id: this.id, path: this.path, contents: dockerignoreFor(profile), description: "keeps the Docker build context small" };
|
|
1860
|
+
}
|
|
1861
|
+
};
|
|
1862
|
+
|
|
1863
|
+
// src/infrastructure/generators/provider-config/netlify.ts
|
|
1864
|
+
import { stringify } from "smol-toml";
|
|
1865
|
+
var NetlifyTomlGenerator = class {
|
|
1866
|
+
id = "netlify-toml";
|
|
1867
|
+
path = "netlify.toml";
|
|
1868
|
+
supports(profile) {
|
|
1869
|
+
return profile.kind === "static";
|
|
1870
|
+
}
|
|
1871
|
+
generate(p11) {
|
|
1872
|
+
const config = {
|
|
1873
|
+
build: { publish: p11.outputDir ?? ".", ...p11.buildCommand ? { command: p11.buildCommand } : {} }
|
|
1874
|
+
};
|
|
1875
|
+
if (p11.language === "node" && p11.runtimeVersion) config.build = { ...config.build, environment: { NODE_VERSION: p11.runtimeVersion } };
|
|
1876
|
+
if (isSinglePageApp(p11)) config.redirects = [{ from: "/*", to: "/index.html", status: 200 }];
|
|
1877
|
+
return {
|
|
1878
|
+
id: this.id,
|
|
1879
|
+
path: this.path,
|
|
1880
|
+
contents: "# Generated by omnideploy. https://docs.netlify.com/configure-builds/file-based-configuration/\n" + stringify(config) + "\n",
|
|
1881
|
+
description: "Netlify build settings" + (isSinglePageApp(p11) ? " + SPA redirect" : "")
|
|
1882
|
+
};
|
|
1883
|
+
}
|
|
1884
|
+
};
|
|
1885
|
+
|
|
1886
|
+
// src/infrastructure/generators/provider-config/railway.ts
|
|
1887
|
+
var RailwayJsonGenerator = class {
|
|
1888
|
+
id = "railway-json";
|
|
1889
|
+
path = "railway.json";
|
|
1890
|
+
supports() {
|
|
1891
|
+
return true;
|
|
1892
|
+
}
|
|
1893
|
+
generate(p11, opts) {
|
|
1894
|
+
const config = {
|
|
1895
|
+
$schema: "https://railway.com/railway.schema.json",
|
|
1896
|
+
build: { builder: "DOCKERFILE", dockerfilePath: "Dockerfile" },
|
|
1897
|
+
deploy: {
|
|
1898
|
+
healthcheckPath: opts.healthCheckPath ?? "/",
|
|
1899
|
+
healthcheckTimeout: 300,
|
|
1900
|
+
restartPolicyType: "ON_FAILURE",
|
|
1901
|
+
restartPolicyMaxRetries: 5
|
|
1902
|
+
}
|
|
1903
|
+
};
|
|
1904
|
+
return {
|
|
1905
|
+
id: this.id,
|
|
1906
|
+
path: this.path,
|
|
1907
|
+
contents: JSON.stringify(config, null, 2) + "\n",
|
|
1908
|
+
description: "Railway config (build from Dockerfile, health check, restart policy)"
|
|
1909
|
+
};
|
|
1910
|
+
}
|
|
1911
|
+
};
|
|
1912
|
+
|
|
1913
|
+
// src/infrastructure/generators/provider-config/render.ts
|
|
1914
|
+
import { stringify as stringify2 } from "yaml";
|
|
1915
|
+
var RENDER_NATIVE = { node: "node", python: "python", ruby: "ruby", go: "go" };
|
|
1916
|
+
function renderUsesDocker(profile) {
|
|
1917
|
+
return profile.kind !== "static" && (profile.hasDockerfile || !(profile.language in RENDER_NATIVE));
|
|
1918
|
+
}
|
|
1919
|
+
function renderNativeBuild(p11) {
|
|
1920
|
+
switch (p11.language) {
|
|
1921
|
+
case "node":
|
|
1922
|
+
return [p11.meta?.install || "npm install", p11.buildCommand].filter(Boolean).join(" && ");
|
|
1923
|
+
case "python": {
|
|
1924
|
+
const install = p11.packageManager === "poetry" ? "pip install poetry && poetry install --no-root --only main" : p11.packageManager === "uv" ? "pip install uv && uv sync --frozen --no-dev" : "pip install -r requirements.txt";
|
|
1925
|
+
return [install, p11.meta?.extraPackages && `pip install ${p11.meta.extraPackages}`, p11.buildCommand].filter(Boolean).join(" && ");
|
|
1926
|
+
}
|
|
1927
|
+
case "ruby":
|
|
1928
|
+
return ["bundle install", p11.buildCommand].filter(Boolean).join(" && ");
|
|
1929
|
+
case "go":
|
|
1930
|
+
return p11.buildCommand ?? "go build -o app .";
|
|
1931
|
+
default:
|
|
1932
|
+
return p11.buildCommand ?? "";
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
function renderRuntime(p11) {
|
|
1936
|
+
if (p11.kind === "static") return "static";
|
|
1937
|
+
return renderUsesDocker(p11) ? "docker" : RENDER_NATIVE[p11.language];
|
|
1938
|
+
}
|
|
1939
|
+
var RenderYamlGenerator = class {
|
|
1940
|
+
id = "render-yaml";
|
|
1941
|
+
path = "render.yaml";
|
|
1942
|
+
supports() {
|
|
1943
|
+
return true;
|
|
1944
|
+
}
|
|
1945
|
+
generate(p11, opts) {
|
|
1946
|
+
const runtime = renderRuntime(p11);
|
|
1947
|
+
const service = {
|
|
1948
|
+
type: "web",
|
|
1949
|
+
name: opts.providerOptions.serviceName ?? opts.serviceName,
|
|
1950
|
+
runtime,
|
|
1951
|
+
plan: opts.providerOptions.plan ?? "free",
|
|
1952
|
+
region: opts.providerOptions.region ?? "oregon"
|
|
1953
|
+
};
|
|
1954
|
+
if (runtime === "static") {
|
|
1955
|
+
if (p11.buildCommand) service.buildCommand = p11.buildCommand;
|
|
1956
|
+
service.staticPublishPath = `./${p11.outputDir ?? "."}`;
|
|
1957
|
+
delete service.plan;
|
|
1958
|
+
delete service.region;
|
|
1959
|
+
} else if (runtime === "docker") {
|
|
1960
|
+
service.dockerfilePath = "./Dockerfile";
|
|
1961
|
+
service.dockerContext = ".";
|
|
1962
|
+
} else {
|
|
1963
|
+
service.buildCommand = renderNativeBuild(p11);
|
|
1964
|
+
service.startCommand = p11.startCommand;
|
|
1965
|
+
}
|
|
1966
|
+
if (runtime !== "static") {
|
|
1967
|
+
service.healthCheckPath = opts.healthCheckPath ?? "/";
|
|
1968
|
+
service.autoDeploy = true;
|
|
1969
|
+
}
|
|
1970
|
+
if (p11.kind === "static" && /^(vite|create-react-app|angular)/.test(p11.framework ?? "")) {
|
|
1971
|
+
service.routes = [{ type: "rewrite", source: "/*", destination: "/index.html" }];
|
|
1972
|
+
}
|
|
1973
|
+
return {
|
|
1974
|
+
id: this.id,
|
|
1975
|
+
path: this.path,
|
|
1976
|
+
contents: "# Render Blueprint \u2014 generated by omnideploy. https://render.com/docs/blueprint-spec\n" + stringify2({ services: [service] }),
|
|
1977
|
+
description: `Render Blueprint (${runtime} runtime)`
|
|
1978
|
+
};
|
|
1979
|
+
}
|
|
1980
|
+
};
|
|
1981
|
+
|
|
1982
|
+
// src/infrastructure/generators/provider-config/vercel.ts
|
|
1983
|
+
var VercelJsonGenerator = class {
|
|
1984
|
+
id = "vercel-json";
|
|
1985
|
+
path = "vercel.json";
|
|
1986
|
+
supports(profile) {
|
|
1987
|
+
return isSinglePageApp(profile);
|
|
1988
|
+
}
|
|
1989
|
+
generate() {
|
|
1990
|
+
const config = {
|
|
1991
|
+
$schema: "https://openapi.vercel.sh/vercel.json",
|
|
1992
|
+
rewrites: [{ source: "/(.*)", destination: "/index.html" }]
|
|
1993
|
+
};
|
|
1994
|
+
return { id: this.id, path: this.path, contents: JSON.stringify(config, null, 2) + "\n", description: "SPA rewrite so client-side routes work on refresh" };
|
|
1995
|
+
}
|
|
1996
|
+
};
|
|
1997
|
+
|
|
1998
|
+
// src/infrastructure/generators/index.ts
|
|
1999
|
+
function builtInGenerators() {
|
|
2000
|
+
return [
|
|
2001
|
+
new DockerfileGenerator(),
|
|
2002
|
+
new DockerignoreGenerator(),
|
|
2003
|
+
new RenderYamlGenerator(),
|
|
2004
|
+
new RailwayJsonGenerator(),
|
|
2005
|
+
new VercelJsonGenerator(),
|
|
2006
|
+
new NetlifyTomlGenerator()
|
|
2007
|
+
];
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
// src/infrastructure/git/CliGit.ts
|
|
2011
|
+
function parseGitHubRemote(url) {
|
|
2012
|
+
const m = url.match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
|
|
2013
|
+
return m ? { owner: m[1], repo: m[2] } : void 0;
|
|
2014
|
+
}
|
|
2015
|
+
var CliGit = class {
|
|
2016
|
+
constructor(proc) {
|
|
2017
|
+
this.proc = proc;
|
|
2018
|
+
}
|
|
2019
|
+
proc;
|
|
2020
|
+
async git(dir, args, what) {
|
|
2021
|
+
const r = await this.proc.run("git", args, { cwd: dir });
|
|
2022
|
+
if (r.exitCode !== 0) throw new GitError(`git ${what} failed: ${(r.stderr || r.stdout).trim()}`);
|
|
2023
|
+
return r.stdout.trim();
|
|
2024
|
+
}
|
|
2025
|
+
async isRepo(dir) {
|
|
2026
|
+
const r = await this.proc.run("git", ["rev-parse", "--is-inside-work-tree"], { cwd: dir });
|
|
2027
|
+
return r.exitCode === 0 && r.stdout.trim() === "true";
|
|
2028
|
+
}
|
|
2029
|
+
async remote(dir, name = "origin") {
|
|
2030
|
+
const r = await this.proc.run("git", ["remote", "get-url", name], { cwd: dir });
|
|
2031
|
+
if (r.exitCode !== 0) return null;
|
|
2032
|
+
const url = r.stdout.trim();
|
|
2033
|
+
return { url, github: parseGitHubRemote(url) };
|
|
2034
|
+
}
|
|
2035
|
+
async currentBranch(dir) {
|
|
2036
|
+
const r = await this.proc.run("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: dir });
|
|
2037
|
+
return r.exitCode === 0 ? r.stdout.trim() : null;
|
|
2038
|
+
}
|
|
2039
|
+
async dirtyPaths(dir, paths = []) {
|
|
2040
|
+
const out = await this.git(dir, ["status", "--porcelain", "--untracked-files=all", "--", ...paths], "status");
|
|
2041
|
+
return out.split("\n").filter(Boolean).map((line) => line.slice(3).split(" -> ").pop().replace(/^"|"$/g, ""));
|
|
2042
|
+
}
|
|
2043
|
+
async commit(dir, paths, message) {
|
|
2044
|
+
await this.git(dir, ["add", "--", ...paths], "add");
|
|
2045
|
+
await this.git(dir, [...await this.identityArgs(dir), "commit", "-m", message, "--", ...paths], "commit");
|
|
2046
|
+
}
|
|
2047
|
+
async push(dir, remote = "origin", branch) {
|
|
2048
|
+
const target = branch ?? await this.currentBranch(dir) ?? "HEAD";
|
|
2049
|
+
await this.git(dir, ["push", "-u", remote, target], "push");
|
|
2050
|
+
}
|
|
2051
|
+
async publishDirectory(sourceDir, remoteUrl, branch, message) {
|
|
2052
|
+
await this.git(sourceDir, ["init", "-q"], "init");
|
|
2053
|
+
await this.git(sourceDir, ["checkout", "-q", "-b", branch], "checkout");
|
|
2054
|
+
await this.git(sourceDir, ["add", "-A"], "add");
|
|
2055
|
+
await this.git(sourceDir, [...await this.identityArgs(sourceDir), "commit", "-q", "-m", message], "commit");
|
|
2056
|
+
await this.git(sourceDir, ["push", "--force", remoteUrl, `HEAD:${branch}`], "push");
|
|
2057
|
+
}
|
|
2058
|
+
/** Lets commits succeed on machines without a configured git identity. */
|
|
2059
|
+
async identityArgs(dir) {
|
|
2060
|
+
const r = await this.proc.run("git", ["config", "user.email"], { cwd: dir });
|
|
2061
|
+
return r.exitCode === 0 && r.stdout.trim() ? [] : ["-c", "user.name=omnideploy", "-c", "user.email=omnideploy@users.noreply.github.com"];
|
|
2062
|
+
}
|
|
2063
|
+
};
|
|
2064
|
+
|
|
2065
|
+
// src/infrastructure/http/FetchHttpClient.ts
|
|
2066
|
+
var RETRYABLE = /* @__PURE__ */ new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
2067
|
+
var FetchHttpClient = class {
|
|
2068
|
+
retries;
|
|
2069
|
+
backoffMs;
|
|
2070
|
+
userAgent;
|
|
2071
|
+
fetchImpl;
|
|
2072
|
+
constructor(opts = {}) {
|
|
2073
|
+
this.retries = opts.retries ?? 3;
|
|
2074
|
+
this.backoffMs = opts.backoffMs ?? 500;
|
|
2075
|
+
this.userAgent = opts.userAgent ?? "omnideploy-cli";
|
|
2076
|
+
this.fetchImpl = opts.fetchImpl;
|
|
2077
|
+
}
|
|
2078
|
+
async request(req, opts = {}) {
|
|
2079
|
+
const url = new URL(req.url);
|
|
2080
|
+
for (const [k, v] of Object.entries(req.query ?? {})) if (v !== void 0) url.searchParams.set(k, String(v));
|
|
2081
|
+
const headers = { "user-agent": this.userAgent, accept: "application/json", ...req.headers };
|
|
2082
|
+
let body;
|
|
2083
|
+
if (req.json !== void 0) {
|
|
2084
|
+
headers["content-type"] ??= "application/json";
|
|
2085
|
+
body = JSON.stringify(req.json);
|
|
2086
|
+
} else if (req.body !== void 0) {
|
|
2087
|
+
body = req.body;
|
|
2088
|
+
}
|
|
2089
|
+
const provider = opts.provider ?? url.hostname;
|
|
2090
|
+
let lastError;
|
|
2091
|
+
for (let attempt = 0; attempt <= this.retries; attempt++) {
|
|
2092
|
+
if (attempt > 0) await sleep(this.backoffMs * 2 ** (attempt - 1));
|
|
2093
|
+
let res;
|
|
2094
|
+
try {
|
|
2095
|
+
res = await (this.fetchImpl ?? globalThis.fetch)(url, { method: req.method ?? "GET", headers, body });
|
|
2096
|
+
} catch (e) {
|
|
2097
|
+
lastError = e;
|
|
2098
|
+
continue;
|
|
2099
|
+
}
|
|
2100
|
+
const data = await parseBody(res);
|
|
2101
|
+
if (res.ok || opts.allowStatus?.includes(res.status)) {
|
|
2102
|
+
return { status: res.status, headers: Object.fromEntries(res.headers.entries()), data };
|
|
2103
|
+
}
|
|
2104
|
+
lastError = new ProviderApiError(provider, extractMessage(data, res.statusText), res.status, hintFor(res.status));
|
|
2105
|
+
if (!RETRYABLE.has(res.status)) break;
|
|
2106
|
+
}
|
|
2107
|
+
if (lastError instanceof ProviderApiError) throw lastError;
|
|
2108
|
+
throw new ProviderApiError(provider, `request to ${url.host} failed: ${lastError instanceof Error ? lastError.message : String(lastError)}`, void 0, "Check your network connection.");
|
|
2109
|
+
}
|
|
2110
|
+
};
|
|
2111
|
+
async function parseBody(res) {
|
|
2112
|
+
const text2 = await res.text();
|
|
2113
|
+
if (!text2) return null;
|
|
2114
|
+
try {
|
|
2115
|
+
return JSON.parse(text2);
|
|
2116
|
+
} catch {
|
|
2117
|
+
return text2;
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
function extractMessage(data, fallback) {
|
|
2121
|
+
if (typeof data === "string") return data.slice(0, 300) || fallback;
|
|
2122
|
+
if (data && typeof data === "object") {
|
|
2123
|
+
const d = data;
|
|
2124
|
+
const err = d.error;
|
|
2125
|
+
if (err && typeof err === "object" && typeof err.message === "string") {
|
|
2126
|
+
return err.message;
|
|
2127
|
+
}
|
|
2128
|
+
if (typeof err === "string") return err;
|
|
2129
|
+
if (typeof d.message === "string") return d.message;
|
|
2130
|
+
if (Array.isArray(d.errors) && d.errors.length) return JSON.stringify(d.errors).slice(0, 300);
|
|
2131
|
+
}
|
|
2132
|
+
return fallback || "unknown error";
|
|
2133
|
+
}
|
|
2134
|
+
function hintFor(status) {
|
|
2135
|
+
if (status === 401 || status === 403) return "Your token may be invalid or expired. Run `omnideploy login <provider>` again.";
|
|
2136
|
+
if (status === 404) return "The resource was not found \u2014 check the IDs in omnideploy.json.";
|
|
2137
|
+
if (status === 429) return "Rate limited by the provider. Wait a moment and retry.";
|
|
2138
|
+
return void 0;
|
|
2139
|
+
}
|
|
2140
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
2141
|
+
|
|
2142
|
+
// src/infrastructure/process/ExecaProcessRunner.ts
|
|
2143
|
+
import { execa } from "execa";
|
|
2144
|
+
var ExecaProcessRunner = class {
|
|
2145
|
+
async run(bin, args, opts = {}) {
|
|
2146
|
+
try {
|
|
2147
|
+
const r = await execa(bin, args, {
|
|
2148
|
+
cwd: opts.cwd,
|
|
2149
|
+
env: opts.env,
|
|
2150
|
+
input: opts.input,
|
|
2151
|
+
reject: false,
|
|
2152
|
+
stdio: opts.inherit ? "inherit" : "pipe"
|
|
2153
|
+
});
|
|
2154
|
+
return { exitCode: r.exitCode ?? 1, stdout: String(r.stdout ?? ""), stderr: String(r.stderr ?? "") };
|
|
2155
|
+
} catch (e) {
|
|
2156
|
+
return { exitCode: 127, stdout: "", stderr: e instanceof Error ? e.message : String(e) };
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
async shell(command, opts = {}) {
|
|
2160
|
+
try {
|
|
2161
|
+
const r = await execa(command, {
|
|
2162
|
+
shell: true,
|
|
2163
|
+
cwd: opts.cwd,
|
|
2164
|
+
env: opts.env,
|
|
2165
|
+
input: opts.input,
|
|
2166
|
+
reject: false,
|
|
2167
|
+
stdio: opts.inherit ? "inherit" : "pipe"
|
|
2168
|
+
});
|
|
2169
|
+
return { exitCode: r.exitCode ?? 1, stdout: String(r.stdout ?? ""), stderr: String(r.stderr ?? "") };
|
|
2170
|
+
} catch (e) {
|
|
2171
|
+
return { exitCode: 127, stdout: "", stderr: e instanceof Error ? e.message : String(e) };
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
2174
|
+
async has(bin) {
|
|
2175
|
+
const probe = process.platform === "win32" ? await this.run("where", [bin]) : await this.run("which", [bin]);
|
|
2176
|
+
return probe.exitCode === 0;
|
|
2177
|
+
}
|
|
2178
|
+
};
|
|
2179
|
+
|
|
2180
|
+
// src/infrastructure/providers/support.ts
|
|
2181
|
+
import { createHash } from "crypto";
|
|
2182
|
+
import { PassThrough } from "stream";
|
|
2183
|
+
import { ZipArchive } from "archiver";
|
|
2184
|
+
var DEFAULT_POLL = { intervalMs: 3e3, timeoutMs: 20 * 6e4 };
|
|
2185
|
+
async function poll(fn, done, opts) {
|
|
2186
|
+
const deadline = Date.now() + opts.timeoutMs;
|
|
2187
|
+
for (; ; ) {
|
|
2188
|
+
const value = await fn();
|
|
2189
|
+
if (done(value)) return { value, timedOut: false };
|
|
2190
|
+
if (Date.now() >= deadline) return { value, timedOut: true };
|
|
2191
|
+
await new Promise((r) => setTimeout(r, opts.intervalMs));
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
var sha1 = (data) => createHash("sha1").update(data).digest("hex");
|
|
2195
|
+
async function zip(files) {
|
|
2196
|
+
const archive = new ZipArchive({ zlib: { level: 9 } });
|
|
2197
|
+
const sink = new PassThrough();
|
|
2198
|
+
const chunks = [];
|
|
2199
|
+
sink.on("data", (c) => chunks.push(c));
|
|
2200
|
+
const finished = new Promise((resolve3, reject) => {
|
|
2201
|
+
sink.on("end", resolve3);
|
|
2202
|
+
archive.on("error", reject);
|
|
2203
|
+
});
|
|
2204
|
+
archive.pipe(sink);
|
|
2205
|
+
for (const f of files) archive.append(Buffer.from(f.data), { name: f.path });
|
|
2206
|
+
await archive.finalize();
|
|
2207
|
+
await finished;
|
|
2208
|
+
return new Uint8Array(Buffer.concat(chunks));
|
|
2209
|
+
}
|
|
2210
|
+
var textFile = (path, text2) => ({ path, data: new TextEncoder().encode(text2) });
|
|
2211
|
+
var redact = (text2, secret) => secret ? text2.split(secret).join("***") : text2;
|
|
2212
|
+
|
|
2213
|
+
// src/infrastructure/providers/GitHubPagesProvider.ts
|
|
2214
|
+
var API = "https://api.github.com";
|
|
2215
|
+
var GitHubPagesProvider = class {
|
|
2216
|
+
constructor(http, fs, git, pollOpts = { ...DEFAULT_POLL, timeoutMs: 3 * 6e4 }) {
|
|
2217
|
+
this.http = http;
|
|
2218
|
+
this.fs = fs;
|
|
2219
|
+
this.git = git;
|
|
2220
|
+
this.pollOpts = pollOpts;
|
|
2221
|
+
}
|
|
2222
|
+
http;
|
|
2223
|
+
fs;
|
|
2224
|
+
git;
|
|
2225
|
+
pollOpts;
|
|
2226
|
+
id = "github-pages";
|
|
2227
|
+
displayName = "GitHub Pages";
|
|
2228
|
+
capabilities = {
|
|
2229
|
+
kinds: ["static"],
|
|
2230
|
+
serverLanguages: [],
|
|
2231
|
+
buildsFromGit: false,
|
|
2232
|
+
auth: { envVar: "GITHUB_TOKEN", tokenUrl: "https://github.com/settings/tokens/new?scopes=repo&description=omnideploy" }
|
|
2233
|
+
};
|
|
2234
|
+
requiredArtifacts() {
|
|
2235
|
+
return [];
|
|
2236
|
+
}
|
|
2237
|
+
async validate(ctx) {
|
|
2238
|
+
await this.repo(ctx.rootDir, ctx.options);
|
|
2239
|
+
}
|
|
2240
|
+
async deploy(ctx) {
|
|
2241
|
+
const { owner, repo } = await this.repo(ctx.profile.rootDir, ctx.options);
|
|
2242
|
+
const branch = ctx.options.branch ?? "gh-pages";
|
|
2243
|
+
const cname = ctx.options.cname;
|
|
2244
|
+
const userSite = repo.toLowerCase() === `${owner.toLowerCase()}.github.io`;
|
|
2245
|
+
if (!cname && !userSite && /^(vite|create-react-app|angular)/.test(ctx.profile.framework ?? "")) {
|
|
2246
|
+
ctx.logger.warn(`GitHub Pages serves this site under /${repo}/. If assets 404, set the base path (Vite: base: '/${repo}/', CRA: "homepage").`);
|
|
2247
|
+
}
|
|
2248
|
+
ctx.progress("preparing files");
|
|
2249
|
+
const tmp = await this.fs.makeTempDir("omnideploy-pages");
|
|
2250
|
+
try {
|
|
2251
|
+
await this.fs.copyDir(ctx.sourceDir, tmp);
|
|
2252
|
+
await this.fs.writeText(this.fs.join(tmp, ".nojekyll"), "");
|
|
2253
|
+
if (cname) await this.fs.writeText(this.fs.join(tmp, "CNAME"), cname + "\n");
|
|
2254
|
+
if (isSinglePageApp(ctx.profile)) {
|
|
2255
|
+
const index = await this.fs.readTextIfExists(this.fs.join(tmp, "index.html"));
|
|
2256
|
+
if (index && !await this.fs.exists(this.fs.join(tmp, "404.html"))) await this.fs.writeText(this.fs.join(tmp, "404.html"), index);
|
|
2257
|
+
}
|
|
2258
|
+
ctx.progress(`pushing to ${branch}`);
|
|
2259
|
+
const remoteUrl = `https://x-access-token:${ctx.token}@github.com/${owner}/${repo}.git`;
|
|
2260
|
+
try {
|
|
2261
|
+
await this.git.publishDirectory(tmp, remoteUrl, branch, `Deploy to GitHub Pages (omnideploy) ${(/* @__PURE__ */ new Date()).toISOString()}`);
|
|
2262
|
+
} catch (e) {
|
|
2263
|
+
throw new GitError(redact(e instanceof Error ? e.message : String(e), ctx.token), 'Check that the token has the "repo" scope and push access.');
|
|
2264
|
+
}
|
|
2265
|
+
} finally {
|
|
2266
|
+
await this.fs.remove(tmp);
|
|
2267
|
+
}
|
|
2268
|
+
ctx.progress("enabling GitHub Pages");
|
|
2269
|
+
const pages = await this.ensurePages(ctx.token, owner, repo, branch);
|
|
2270
|
+
ctx.progress("waiting for Pages build");
|
|
2271
|
+
const { value: build, timedOut } = await poll(
|
|
2272
|
+
() => this.latestBuild(ctx.token, owner, repo),
|
|
2273
|
+
(b) => b?.status === "built" || b?.status === "errored",
|
|
2274
|
+
this.pollOpts
|
|
2275
|
+
);
|
|
2276
|
+
if (build?.status === "errored") {
|
|
2277
|
+
throw new DomainError(`GitHub Pages build failed: ${build.error?.message ?? "unknown error"}`, `See https://github.com/${owner}/${repo}/actions`, "DEPLOYMENT_FAILED");
|
|
2278
|
+
}
|
|
2279
|
+
const url = cname ? `https://${cname}/` : pages.html_url ?? (userSite ? `https://${repo}/` : `https://${owner}.github.io/${repo}/`);
|
|
2280
|
+
return {
|
|
2281
|
+
provider: this.id,
|
|
2282
|
+
id: build?.commit ?? "latest",
|
|
2283
|
+
url,
|
|
2284
|
+
state: timedOut || !build ? "building" : "ready",
|
|
2285
|
+
dashboardUrl: `https://github.com/${owner}/${repo}/settings/pages`,
|
|
2286
|
+
configUpdates: { repo: `${owner}/${repo}`, branch }
|
|
2287
|
+
};
|
|
2288
|
+
}
|
|
2289
|
+
async status(ctx) {
|
|
2290
|
+
const [owner, repo] = String(ctx.options.repo ?? "").split("/");
|
|
2291
|
+
if (!owner || !repo) return { id: "-", state: "unknown", message: "Deploy once first (repo is stored in omnideploy.json)." };
|
|
2292
|
+
const b = await this.latestBuild(ctx.token, owner, repo);
|
|
2293
|
+
if (!b) return { id: "-", state: "unknown", message: "No Pages builds yet." };
|
|
2294
|
+
return {
|
|
2295
|
+
id: b.commit ?? "latest",
|
|
2296
|
+
state: b.status === "built" ? "ready" : b.status === "errored" ? "error" : "building",
|
|
2297
|
+
createdAt: b.created_at,
|
|
2298
|
+
message: b.error?.message ?? void 0
|
|
2299
|
+
};
|
|
2300
|
+
}
|
|
2301
|
+
async repo(rootDir, options) {
|
|
2302
|
+
if (typeof options.repo === "string" && options.repo.includes("/")) {
|
|
2303
|
+
const [owner, repo] = options.repo.split("/");
|
|
2304
|
+
return { owner, repo };
|
|
2305
|
+
}
|
|
2306
|
+
const remote = await this.git.remote(rootDir);
|
|
2307
|
+
if (!remote?.github) {
|
|
2308
|
+
throw new DomainError(
|
|
2309
|
+
"GitHub Pages needs a GitHub repository.",
|
|
2310
|
+
'Add a GitHub remote (git remote add origin https://github.com/<you>/<repo>.git) or set providers["github-pages"].repo = "owner/repo".',
|
|
2311
|
+
"NO_GITHUB_REMOTE"
|
|
2312
|
+
);
|
|
2313
|
+
}
|
|
2314
|
+
return remote.github;
|
|
2315
|
+
}
|
|
2316
|
+
async ensurePages(token, owner, repo, branch) {
|
|
2317
|
+
const headers = this.headers(token);
|
|
2318
|
+
const url = `${API}/repos/${owner}/${repo}/pages`;
|
|
2319
|
+
const existing = await this.http.request({ url, headers }, { provider: "GitHub", allowStatus: [404] });
|
|
2320
|
+
const source = { branch, path: "/" };
|
|
2321
|
+
if (existing.status === 404) {
|
|
2322
|
+
return (await this.http.request({ method: "POST", url, headers, json: { source } }, { provider: "GitHub", allowStatus: [409] })).data;
|
|
2323
|
+
}
|
|
2324
|
+
if (existing.data.source?.branch !== branch) {
|
|
2325
|
+
await this.http.request({ method: "PUT", url, headers, json: { source } }, { provider: "GitHub" });
|
|
2326
|
+
}
|
|
2327
|
+
return existing.data;
|
|
2328
|
+
}
|
|
2329
|
+
async latestBuild(token, owner, repo) {
|
|
2330
|
+
const r = await this.http.request(
|
|
2331
|
+
{ url: `${API}/repos/${owner}/${repo}/pages/builds/latest`, headers: this.headers(token) },
|
|
2332
|
+
{ provider: "GitHub", allowStatus: [404] }
|
|
2333
|
+
);
|
|
2334
|
+
return r.status === 404 ? null : r.data;
|
|
2335
|
+
}
|
|
2336
|
+
headers(token) {
|
|
2337
|
+
return { authorization: `Bearer ${token}`, accept: "application/vnd.github+json", "x-github-api-version": "2022-11-28" };
|
|
2338
|
+
}
|
|
2339
|
+
};
|
|
2340
|
+
|
|
2341
|
+
// src/infrastructure/providers/NetlifyProvider.ts
|
|
2342
|
+
var API2 = "https://api.netlify.com/api/v1";
|
|
2343
|
+
var STATE = {
|
|
2344
|
+
new: "queued",
|
|
2345
|
+
uploading: "building",
|
|
2346
|
+
uploaded: "building",
|
|
2347
|
+
preparing: "building",
|
|
2348
|
+
prepared: "building",
|
|
2349
|
+
processing: "building",
|
|
2350
|
+
processed: "building",
|
|
2351
|
+
ready: "ready",
|
|
2352
|
+
error: "error"
|
|
2353
|
+
};
|
|
2354
|
+
var NetlifyProvider = class {
|
|
2355
|
+
constructor(http, fs, pollOpts = DEFAULT_POLL) {
|
|
2356
|
+
this.http = http;
|
|
2357
|
+
this.fs = fs;
|
|
2358
|
+
this.pollOpts = pollOpts;
|
|
2359
|
+
}
|
|
2360
|
+
http;
|
|
2361
|
+
fs;
|
|
2362
|
+
pollOpts;
|
|
2363
|
+
id = "netlify";
|
|
2364
|
+
displayName = "Netlify";
|
|
2365
|
+
capabilities = {
|
|
2366
|
+
kinds: ["static"],
|
|
2367
|
+
serverLanguages: [],
|
|
2368
|
+
buildsFromGit: false,
|
|
2369
|
+
auth: { envVar: "NETLIFY_AUTH_TOKEN", tokenUrl: "https://app.netlify.com/user/applications#personal-access-tokens" }
|
|
2370
|
+
};
|
|
2371
|
+
requiredArtifacts() {
|
|
2372
|
+
return ["netlify-toml"];
|
|
2373
|
+
}
|
|
2374
|
+
async validate() {
|
|
2375
|
+
}
|
|
2376
|
+
async deploy(ctx) {
|
|
2377
|
+
const site = await this.ensureSite(ctx);
|
|
2378
|
+
ctx.progress("packaging files");
|
|
2379
|
+
const paths = await this.fs.collectFiles(ctx.sourceDir);
|
|
2380
|
+
const files = await Promise.all(paths.map(async (p11) => ({ path: p11, data: await this.fs.readBytes(this.fs.join(ctx.sourceDir, p11)) })));
|
|
2381
|
+
if (isSinglePageApp(ctx.profile) && !paths.includes("_redirects")) files.push(textFile("_redirects", "/* /index.html 200\n"));
|
|
2382
|
+
const archive = await zip(files);
|
|
2383
|
+
ctx.progress(`uploading ${files.length} files`);
|
|
2384
|
+
const created = await this.http.request(
|
|
2385
|
+
{
|
|
2386
|
+
method: "POST",
|
|
2387
|
+
url: `${API2}/sites/${site.id}/deploys`,
|
|
2388
|
+
headers: { ...this.auth(ctx.token), "content-type": "application/zip" },
|
|
2389
|
+
query: { draft: ctx.production ? void 0 : true },
|
|
2390
|
+
body: archive
|
|
2391
|
+
},
|
|
2392
|
+
{ provider: "Netlify" }
|
|
2393
|
+
);
|
|
2394
|
+
ctx.progress("processing");
|
|
2395
|
+
const { value: d, timedOut } = await poll(
|
|
2396
|
+
() => this.getDeploy(ctx.token, created.data.id),
|
|
2397
|
+
(d2) => d2.state === "ready" || d2.state === "error",
|
|
2398
|
+
this.pollOpts
|
|
2399
|
+
);
|
|
2400
|
+
if (d.state === "error") throw new DeploymentFailedError("Netlify", d.error_message ?? "unknown error", d.admin_url);
|
|
2401
|
+
return {
|
|
2402
|
+
provider: this.id,
|
|
2403
|
+
id: d.id,
|
|
2404
|
+
url: ctx.production ? site.ssl_url ?? d.ssl_url ?? site.url : d.deploy_ssl_url ?? d.ssl_url,
|
|
2405
|
+
state: timedOut ? "building" : "ready",
|
|
2406
|
+
dashboardUrl: site.admin_url,
|
|
2407
|
+
configUpdates: { siteId: site.id, siteName: site.name }
|
|
2408
|
+
};
|
|
2409
|
+
}
|
|
2410
|
+
async status(ctx, deploymentId) {
|
|
2411
|
+
let d;
|
|
2412
|
+
if (deploymentId) d = await this.getDeploy(ctx.token, deploymentId);
|
|
2413
|
+
else {
|
|
2414
|
+
const siteId = ctx.options.siteId;
|
|
2415
|
+
if (!siteId) return { id: "-", state: "unknown", message: "No Netlify site yet \u2014 deploy first." };
|
|
2416
|
+
const r = await this.http.request({ url: `${API2}/sites/${siteId}/deploys`, headers: this.auth(ctx.token), query: { per_page: 1 } }, { provider: "Netlify" });
|
|
2417
|
+
d = r.data[0];
|
|
2418
|
+
}
|
|
2419
|
+
if (!d) return { id: "-", state: "unknown", message: "No deployments yet." };
|
|
2420
|
+
return { id: d.id, state: STATE[d.state] ?? "unknown", url: d.deploy_ssl_url ?? d.ssl_url, createdAt: d.created_at, message: d.error_message };
|
|
2421
|
+
}
|
|
2422
|
+
async ensureSite(ctx) {
|
|
2423
|
+
const siteId = ctx.options.siteId;
|
|
2424
|
+
if (siteId) {
|
|
2425
|
+
return (await this.http.request({ url: `${API2}/sites/${siteId}`, headers: this.auth(ctx.token) }, { provider: "Netlify" })).data;
|
|
2426
|
+
}
|
|
2427
|
+
ctx.progress("creating site");
|
|
2428
|
+
const base = ctx.options.siteName ?? ctx.serviceName;
|
|
2429
|
+
for (const name of [base, `${base}-${randomSuffix()}`, `${base}-${randomSuffix()}`]) {
|
|
2430
|
+
const r = await this.http.request(
|
|
2431
|
+
{ method: "POST", url: `${API2}/sites`, headers: this.auth(ctx.token), json: { name } },
|
|
2432
|
+
{ provider: "Netlify", allowStatus: [422] }
|
|
2433
|
+
);
|
|
2434
|
+
if (r.status !== 422) return r.data;
|
|
2435
|
+
}
|
|
2436
|
+
throw new DeploymentFailedError("Netlify", `could not create a site named "${base}"`, "Set providers.netlify.siteName in omnideploy.json.");
|
|
2437
|
+
}
|
|
2438
|
+
async getDeploy(token, id) {
|
|
2439
|
+
return (await this.http.request({ url: `${API2}/deploys/${id}`, headers: this.auth(token) }, { provider: "Netlify" })).data;
|
|
2440
|
+
}
|
|
2441
|
+
auth(token) {
|
|
2442
|
+
return { authorization: `Bearer ${token}` };
|
|
2443
|
+
}
|
|
2444
|
+
};
|
|
2445
|
+
var randomSuffix = () => Math.random().toString(36).slice(2, 7);
|
|
2446
|
+
|
|
2447
|
+
// src/infrastructure/providers/RailwayProvider.ts
|
|
2448
|
+
var INSTALL = "npm i -g @railway/cli (or: brew install railway)";
|
|
2449
|
+
var RailwayProvider = class {
|
|
2450
|
+
constructor(proc) {
|
|
2451
|
+
this.proc = proc;
|
|
2452
|
+
}
|
|
2453
|
+
proc;
|
|
2454
|
+
id = "railway";
|
|
2455
|
+
displayName = "Railway";
|
|
2456
|
+
capabilities = {
|
|
2457
|
+
kinds: ["static", "server", "serverless"],
|
|
2458
|
+
serverLanguages: "any",
|
|
2459
|
+
buildsFromGit: false,
|
|
2460
|
+
requiredCli: { bin: "railway", install: INSTALL },
|
|
2461
|
+
auth: { envVar: "RAILWAY_TOKEN", tokenUrl: "https://railway.com/account/tokens (create a *project* token: Project \u2192 Settings \u2192 Tokens)" }
|
|
2462
|
+
};
|
|
2463
|
+
requiredArtifacts() {
|
|
2464
|
+
return ["dockerfile", "dockerignore", "railway-json"];
|
|
2465
|
+
}
|
|
2466
|
+
async validate() {
|
|
2467
|
+
if (!await this.proc.has("railway")) throw new MissingToolError("railway", INSTALL);
|
|
2468
|
+
}
|
|
2469
|
+
async deploy(ctx) {
|
|
2470
|
+
const env2 = { RAILWAY_TOKEN: ctx.token };
|
|
2471
|
+
const scope = [
|
|
2472
|
+
...ctx.options.service ? ["--service", String(ctx.options.service)] : [],
|
|
2473
|
+
...ctx.options.environment ? ["--environment", String(ctx.options.environment)] : []
|
|
2474
|
+
];
|
|
2475
|
+
ctx.progress("uploading and building (this can take a few minutes)");
|
|
2476
|
+
const up = await this.proc.run("railway", ["up", "--ci", ...scope], { cwd: ctx.profile.rootDir, env: env2 });
|
|
2477
|
+
const output = redact(`${up.stdout}
|
|
2478
|
+
${up.stderr}`, ctx.token);
|
|
2479
|
+
const logsUrl = output.match(/https:\/\/railway\.(?:com|app)\/project\/\S+/)?.[0];
|
|
2480
|
+
if (up.exitCode !== 0) {
|
|
2481
|
+
const tail = output.trim().split("\n").slice(-12).join("\n");
|
|
2482
|
+
throw new DeploymentFailedError("Railway", tail || `railway up exited with ${up.exitCode}`, logsUrl ? `Build logs: ${logsUrl}` : void 0);
|
|
2483
|
+
}
|
|
2484
|
+
ctx.progress("fetching public URL");
|
|
2485
|
+
const domain = await this.proc.run("railway", ["domain", ...scope], { cwd: ctx.profile.rootDir, env: env2 });
|
|
2486
|
+
const url = `${domain.stdout}
|
|
2487
|
+
${domain.stderr}`.match(/https:\/\/[a-z0-9.-]+\.(?:up\.railway\.app|railway\.app|[a-z]{2,})/i)?.[0];
|
|
2488
|
+
return {
|
|
2489
|
+
provider: this.id,
|
|
2490
|
+
id: logsUrl?.match(/[?&]id=([\w-]+)/)?.[1] ?? "latest",
|
|
2491
|
+
url,
|
|
2492
|
+
state: "ready",
|
|
2493
|
+
dashboardUrl: logsUrl
|
|
2494
|
+
};
|
|
2495
|
+
}
|
|
2496
|
+
};
|
|
2497
|
+
|
|
2498
|
+
// src/infrastructure/providers/RenderProvider.ts
|
|
2499
|
+
var API3 = "https://api.render.com/v1";
|
|
2500
|
+
var STATE2 = {
|
|
2501
|
+
created: "queued",
|
|
2502
|
+
queued: "queued",
|
|
2503
|
+
build_in_progress: "building",
|
|
2504
|
+
update_in_progress: "building",
|
|
2505
|
+
pre_deploy_in_progress: "building",
|
|
2506
|
+
live: "ready",
|
|
2507
|
+
deactivated: "ready",
|
|
2508
|
+
build_failed: "error",
|
|
2509
|
+
update_failed: "error",
|
|
2510
|
+
pre_deploy_failed: "error",
|
|
2511
|
+
canceled: "canceled"
|
|
2512
|
+
};
|
|
2513
|
+
var FINAL = /* @__PURE__ */ new Set(["live", "deactivated", "build_failed", "update_failed", "pre_deploy_failed", "canceled"]);
|
|
2514
|
+
var RenderProvider = class {
|
|
2515
|
+
constructor(http, git, pollOpts = DEFAULT_POLL) {
|
|
2516
|
+
this.http = http;
|
|
2517
|
+
this.git = git;
|
|
2518
|
+
this.pollOpts = pollOpts;
|
|
2519
|
+
}
|
|
2520
|
+
http;
|
|
2521
|
+
git;
|
|
2522
|
+
pollOpts;
|
|
2523
|
+
id = "render";
|
|
2524
|
+
displayName = "Render";
|
|
2525
|
+
capabilities = {
|
|
2526
|
+
kinds: ["static", "server", "serverless"],
|
|
2527
|
+
serverLanguages: "any",
|
|
2528
|
+
buildsFromGit: true,
|
|
2529
|
+
auth: { envVar: "RENDER_API_KEY", tokenUrl: "https://dashboard.render.com/u/settings#api-keys" }
|
|
2530
|
+
};
|
|
2531
|
+
requiredArtifacts(profile) {
|
|
2532
|
+
return renderUsesDocker(profile) ? ["dockerfile", "dockerignore", "render-yaml"] : ["render-yaml"];
|
|
2533
|
+
}
|
|
2534
|
+
async validate(ctx) {
|
|
2535
|
+
if (ctx.options.serviceId) return;
|
|
2536
|
+
const remote = await this.git.remote(ctx.rootDir);
|
|
2537
|
+
if (remote && !toHttpsRepo(remote.url)) {
|
|
2538
|
+
throw new DomainError(`Render cannot use the git remote "${remote.url}".`, "Use a GitHub, GitLab or Bitbucket remote.", "UNSUPPORTED_REMOTE");
|
|
2539
|
+
}
|
|
2540
|
+
}
|
|
2541
|
+
async deploy(ctx) {
|
|
2542
|
+
let serviceId = ctx.options.serviceId;
|
|
2543
|
+
let ownerId = ctx.options.ownerId;
|
|
2544
|
+
let deployId;
|
|
2545
|
+
if (!serviceId) {
|
|
2546
|
+
ownerId ??= await this.ownerId(ctx);
|
|
2547
|
+
ctx.progress("creating service");
|
|
2548
|
+
const created = await this.createService(ctx, ownerId);
|
|
2549
|
+
serviceId = created.service.id;
|
|
2550
|
+
deployId = created.deployId;
|
|
2551
|
+
} else {
|
|
2552
|
+
ctx.progress("triggering deploy");
|
|
2553
|
+
const r = await this.http.request(
|
|
2554
|
+
{ method: "POST", url: `${API3}/services/${serviceId}/deploys`, headers: this.auth(ctx.token), json: { clearCache: "do_not_clear" } },
|
|
2555
|
+
{ provider: "Render" }
|
|
2556
|
+
);
|
|
2557
|
+
deployId = r.data.id;
|
|
2558
|
+
}
|
|
2559
|
+
const { value: d, timedOut } = await poll(
|
|
2560
|
+
() => this.getDeploy(ctx.token, serviceId, deployId),
|
|
2561
|
+
(d2) => {
|
|
2562
|
+
ctx.progress(`${d2.status.replace(/_/g, " ")} (Render builds can take several minutes)`);
|
|
2563
|
+
return FINAL.has(d2.status);
|
|
2564
|
+
},
|
|
2565
|
+
this.pollOpts
|
|
2566
|
+
);
|
|
2567
|
+
const service = await this.getService(ctx.token, serviceId);
|
|
2568
|
+
const state = STATE2[d.status] ?? "unknown";
|
|
2569
|
+
if (state === "error" || state === "canceled") {
|
|
2570
|
+
throw new DeploymentFailedError("Render", d.status.replace(/_/g, " "), service.dashboardUrl ? `Logs: ${service.dashboardUrl}/deploys/${d.id}` : void 0);
|
|
2571
|
+
}
|
|
2572
|
+
return {
|
|
2573
|
+
provider: this.id,
|
|
2574
|
+
id: d.id,
|
|
2575
|
+
url: service.serviceDetails?.url,
|
|
2576
|
+
state: timedOut ? "building" : state,
|
|
2577
|
+
dashboardUrl: service.dashboardUrl,
|
|
2578
|
+
configUpdates: { serviceId, ownerId }
|
|
2579
|
+
};
|
|
2580
|
+
}
|
|
2581
|
+
async status(ctx, deploymentId) {
|
|
2582
|
+
const serviceId = ctx.options.serviceId;
|
|
2583
|
+
if (!serviceId) return { id: "-", state: "unknown", message: "No Render service yet \u2014 deploy first." };
|
|
2584
|
+
let d;
|
|
2585
|
+
if (deploymentId) d = await this.getDeploy(ctx.token, serviceId, deploymentId);
|
|
2586
|
+
else {
|
|
2587
|
+
const r = await this.http.request({ url: `${API3}/services/${serviceId}/deploys`, headers: this.auth(ctx.token), query: { limit: 1 } }, { provider: "Render" });
|
|
2588
|
+
d = r.data[0]?.deploy;
|
|
2589
|
+
}
|
|
2590
|
+
if (!d) return { id: "-", state: "unknown", message: "No deploys yet." };
|
|
2591
|
+
const service = await this.getService(ctx.token, serviceId);
|
|
2592
|
+
return { id: d.id, state: STATE2[d.status] ?? "unknown", url: service.serviceDetails?.url, createdAt: d.createdAt, message: d.status };
|
|
2593
|
+
}
|
|
2594
|
+
async ownerId(ctx) {
|
|
2595
|
+
const r = await this.http.request(
|
|
2596
|
+
{ url: `${API3}/owners`, headers: this.auth(ctx.token), query: { limit: 20 } },
|
|
2597
|
+
{ provider: "Render" }
|
|
2598
|
+
);
|
|
2599
|
+
const owners = r.data.map((o) => o.owner);
|
|
2600
|
+
if (owners.length === 0) throw new DomainError("Your Render account has no workspace.", "Create one at https://dashboard.render.com.", "RENDER_NO_OWNER");
|
|
2601
|
+
if (owners.length > 1) ctx.logger.warn(`Using Render workspace "${owners[0].name}". Set providers.render.ownerId to choose another.`);
|
|
2602
|
+
return owners[0].id;
|
|
2603
|
+
}
|
|
2604
|
+
async createService(ctx, ownerId) {
|
|
2605
|
+
const p11 = ctx.profile;
|
|
2606
|
+
const remote = await this.git.remote(p11.rootDir);
|
|
2607
|
+
const repo = remote && toHttpsRepo(remote.url);
|
|
2608
|
+
if (!repo) throw new DomainError("Render needs a git remote to build from.", "git remote add origin <url> && git push -u origin HEAD", "NO_GIT_REMOTE");
|
|
2609
|
+
const branch = ctx.options.branch ?? await this.git.currentBranch(p11.rootDir) ?? "main";
|
|
2610
|
+
const runtime = renderRuntime(p11);
|
|
2611
|
+
const serviceDetails = runtime === "static" ? { buildCommand: p11.buildCommand ?? "", publishPath: p11.outputDir ?? "." } : {
|
|
2612
|
+
runtime,
|
|
2613
|
+
plan: ctx.options.plan ?? "free",
|
|
2614
|
+
region: ctx.options.region ?? "oregon",
|
|
2615
|
+
healthCheckPath: ctx.options.healthCheckPath ?? "/",
|
|
2616
|
+
envSpecificDetails: runtime === "docker" ? { dockerfilePath: "./Dockerfile", dockerContext: "." } : { buildCommand: renderNativeBuild(p11), startCommand: p11.startCommand ?? "" }
|
|
2617
|
+
};
|
|
2618
|
+
try {
|
|
2619
|
+
const r = await this.http.request(
|
|
2620
|
+
{
|
|
2621
|
+
method: "POST",
|
|
2622
|
+
url: `${API3}/services`,
|
|
2623
|
+
headers: this.auth(ctx.token),
|
|
2624
|
+
json: {
|
|
2625
|
+
type: runtime === "static" ? "static_site" : "web_service",
|
|
2626
|
+
name: ctx.options.serviceName ?? ctx.serviceName,
|
|
2627
|
+
ownerId,
|
|
2628
|
+
repo,
|
|
2629
|
+
branch,
|
|
2630
|
+
autoDeploy: "yes",
|
|
2631
|
+
serviceDetails
|
|
2632
|
+
}
|
|
2633
|
+
},
|
|
2634
|
+
{ provider: "Render" }
|
|
2635
|
+
);
|
|
2636
|
+
return r.data;
|
|
2637
|
+
} catch (e) {
|
|
2638
|
+
if (e instanceof ProviderApiError && e.status && e.status < 500 && /repo/i.test(e.message)) {
|
|
2639
|
+
throw new ProviderApiError("Render", e.message, e.status, "Connect your Git provider to Render (Account settings \u2192 Git) and grant access to this repository.");
|
|
2640
|
+
}
|
|
2641
|
+
throw e;
|
|
2642
|
+
}
|
|
2643
|
+
}
|
|
2644
|
+
async getDeploy(token, serviceId, deployId) {
|
|
2645
|
+
return (await this.http.request({ url: `${API3}/services/${serviceId}/deploys/${deployId}`, headers: this.auth(token) }, { provider: "Render" })).data;
|
|
2646
|
+
}
|
|
2647
|
+
async getService(token, serviceId) {
|
|
2648
|
+
return (await this.http.request({ url: `${API3}/services/${serviceId}`, headers: this.auth(token) }, { provider: "Render" })).data;
|
|
2649
|
+
}
|
|
2650
|
+
auth(token) {
|
|
2651
|
+
return { authorization: `Bearer ${token}` };
|
|
2652
|
+
}
|
|
2653
|
+
};
|
|
2654
|
+
function toHttpsRepo(url) {
|
|
2655
|
+
const m = url.match(/^(?:git@|ssh:\/\/git@|https?:\/\/(?:[^@/]+@)?)(github\.com|gitlab\.com|bitbucket\.org)[:/](.+?)(?:\.git)?\/?$/);
|
|
2656
|
+
return m ? `https://${m[1]}/${m[2]}` : null;
|
|
2657
|
+
}
|
|
2658
|
+
|
|
2659
|
+
// src/infrastructure/providers/VercelProvider.ts
|
|
2660
|
+
var API4 = "https://api.vercel.com";
|
|
2661
|
+
var STATE3 = {
|
|
2662
|
+
QUEUED: "queued",
|
|
2663
|
+
INITIALIZING: "building",
|
|
2664
|
+
BUILDING: "building",
|
|
2665
|
+
READY: "ready",
|
|
2666
|
+
ERROR: "error",
|
|
2667
|
+
CANCELED: "canceled"
|
|
2668
|
+
};
|
|
2669
|
+
var SOURCE_IGNORES = [".vercel/**", ".next/**", ".nuxt/**", ".output/**", ".svelte-kit/**", "dist/**", ".env", ".env.*"];
|
|
2670
|
+
var VercelProvider = class {
|
|
2671
|
+
constructor(http, fs, pollOpts = DEFAULT_POLL) {
|
|
2672
|
+
this.http = http;
|
|
2673
|
+
this.fs = fs;
|
|
2674
|
+
this.pollOpts = pollOpts;
|
|
2675
|
+
}
|
|
2676
|
+
http;
|
|
2677
|
+
fs;
|
|
2678
|
+
pollOpts;
|
|
2679
|
+
id = "vercel";
|
|
2680
|
+
displayName = "Vercel";
|
|
2681
|
+
capabilities = {
|
|
2682
|
+
kinds: ["static", "serverless"],
|
|
2683
|
+
serverLanguages: [],
|
|
2684
|
+
serverlessFrameworks: ["nextjs", "nuxt", "sveltekit", "remix", "astro"],
|
|
2685
|
+
buildsFromGit: false,
|
|
2686
|
+
auth: { envVar: "VERCEL_TOKEN", tokenUrl: "https://vercel.com/account/tokens" }
|
|
2687
|
+
};
|
|
2688
|
+
requiredArtifacts(profile) {
|
|
2689
|
+
return isSinglePageApp(profile) ? ["vercel-json"] : [];
|
|
2690
|
+
}
|
|
2691
|
+
async validate() {
|
|
2692
|
+
}
|
|
2693
|
+
async deploy(ctx) {
|
|
2694
|
+
const isStatic = ctx.profile.kind === "static";
|
|
2695
|
+
const files = await this.collect(ctx, isStatic);
|
|
2696
|
+
const name = ctx.options.project ?? ctx.serviceName;
|
|
2697
|
+
const query = { teamId: ctx.options.teamId, skipAutoDetectionConfirmation: 1 };
|
|
2698
|
+
const manifest = files.map((f) => ({ file: f.path, sha: sha1(f.data), size: f.data.byteLength }));
|
|
2699
|
+
const body = {
|
|
2700
|
+
name,
|
|
2701
|
+
files: manifest,
|
|
2702
|
+
target: ctx.production ? "production" : void 0,
|
|
2703
|
+
projectSettings: isStatic ? { framework: null } : { framework: ctx.profile.framework === "nextjs" ? "nextjs" : ctx.profile.framework }
|
|
2704
|
+
};
|
|
2705
|
+
ctx.progress(`creating deployment (${files.length} files)`);
|
|
2706
|
+
let created = await this.http.request(
|
|
2707
|
+
{ method: "POST", url: `${API4}/v13/deployments`, headers: this.auth(ctx.token), query, json: body },
|
|
2708
|
+
{ provider: "Vercel", allowStatus: [400] }
|
|
2709
|
+
);
|
|
2710
|
+
if (created.status === 400) {
|
|
2711
|
+
const missing = new Set(created.data.error?.missing ?? []);
|
|
2712
|
+
if (created.data.error?.code !== "missing_files" || missing.size === 0) {
|
|
2713
|
+
throw new ProviderApiError("Vercel", JSON.stringify(created.data.error), 400);
|
|
2714
|
+
}
|
|
2715
|
+
await this.upload(ctx, files.filter((f) => missing.has(sha1(f.data))), query.teamId);
|
|
2716
|
+
ctx.progress("creating deployment");
|
|
2717
|
+
created = await this.http.request({ method: "POST", url: `${API4}/v13/deployments`, headers: this.auth(ctx.token), query, json: body }, { provider: "Vercel" });
|
|
2718
|
+
}
|
|
2719
|
+
ctx.progress("building");
|
|
2720
|
+
const { value: d, timedOut } = await poll(
|
|
2721
|
+
() => this.get(ctx.token, created.data.id, query.teamId),
|
|
2722
|
+
(d2) => ["READY", "ERROR", "CANCELED"].includes(d2.readyState ?? d2.status ?? ""),
|
|
2723
|
+
this.pollOpts
|
|
2724
|
+
);
|
|
2725
|
+
const state = STATE3[d.readyState ?? d.status ?? ""] ?? "unknown";
|
|
2726
|
+
if (state === "error" || state === "canceled") {
|
|
2727
|
+
throw new DeploymentFailedError("Vercel", d.errorMessage ?? state, d.inspectorUrl ? `Build logs: ${d.inspectorUrl}` : void 0);
|
|
2728
|
+
}
|
|
2729
|
+
const host = ctx.production ? d.alias?.[0] ?? d.url : d.url;
|
|
2730
|
+
return {
|
|
2731
|
+
provider: this.id,
|
|
2732
|
+
id: d.id,
|
|
2733
|
+
url: `https://${host}`,
|
|
2734
|
+
state: timedOut ? "building" : state,
|
|
2735
|
+
dashboardUrl: d.inspectorUrl,
|
|
2736
|
+
configUpdates: { project: name, projectId: d.projectId }
|
|
2737
|
+
};
|
|
2738
|
+
}
|
|
2739
|
+
async status(ctx, deploymentId) {
|
|
2740
|
+
const teamId = ctx.options.teamId;
|
|
2741
|
+
let id = deploymentId;
|
|
2742
|
+
if (!id) {
|
|
2743
|
+
const list = await this.http.request(
|
|
2744
|
+
{ url: `${API4}/v6/deployments`, headers: this.auth(ctx.token), query: { projectId: ctx.options.projectId, limit: 1, teamId } },
|
|
2745
|
+
{ provider: "Vercel" }
|
|
2746
|
+
);
|
|
2747
|
+
id = list.data.deployments[0]?.uid;
|
|
2748
|
+
if (!id) return { id: "-", state: "unknown", message: "No deployments yet." };
|
|
2749
|
+
}
|
|
2750
|
+
const d = await this.get(ctx.token, id, teamId);
|
|
2751
|
+
return {
|
|
2752
|
+
id: d.id,
|
|
2753
|
+
state: STATE3[d.readyState ?? d.status ?? ""] ?? "unknown",
|
|
2754
|
+
url: `https://${d.url}`,
|
|
2755
|
+
createdAt: d.createdAt ? new Date(d.createdAt).toISOString() : void 0,
|
|
2756
|
+
message: d.errorMessage
|
|
2757
|
+
};
|
|
2758
|
+
}
|
|
2759
|
+
async collect(ctx, isStatic) {
|
|
2760
|
+
const root = ctx.profile.rootDir;
|
|
2761
|
+
const paths = isStatic ? await this.fs.collectFiles(ctx.sourceDir) : await this.fs.collectFiles(root, { respectGitignore: true, ignore: SOURCE_IGNORES });
|
|
2762
|
+
const base = isStatic ? ctx.sourceDir : root;
|
|
2763
|
+
const files = await Promise.all(paths.map(async (p11) => ({ path: p11, data: await this.fs.readBytes(this.fs.join(base, p11)) })));
|
|
2764
|
+
if (isStatic && !paths.includes("vercel.json")) {
|
|
2765
|
+
const cfg = await this.fs.readTextIfExists(this.fs.join(root, "vercel.json"));
|
|
2766
|
+
if (cfg) files.push({ path: "vercel.json", data: new TextEncoder().encode(cfg) });
|
|
2767
|
+
}
|
|
2768
|
+
return files;
|
|
2769
|
+
}
|
|
2770
|
+
async upload(ctx, files, teamId) {
|
|
2771
|
+
let done = 0;
|
|
2772
|
+
const queue = [...files];
|
|
2773
|
+
const worker = async () => {
|
|
2774
|
+
for (let f = queue.shift(); f; f = queue.shift()) {
|
|
2775
|
+
await this.http.request(
|
|
2776
|
+
{
|
|
2777
|
+
method: "POST",
|
|
2778
|
+
url: `${API4}/v2/files`,
|
|
2779
|
+
query: { teamId },
|
|
2780
|
+
headers: { ...this.auth(ctx.token), "content-type": "application/octet-stream", "x-vercel-digest": sha1(f.data) },
|
|
2781
|
+
body: f.data
|
|
2782
|
+
},
|
|
2783
|
+
{ provider: "Vercel" }
|
|
2784
|
+
);
|
|
2785
|
+
ctx.progress(`uploading files ${++done}/${files.length}`);
|
|
2786
|
+
}
|
|
2787
|
+
};
|
|
2788
|
+
await Promise.all(Array.from({ length: Math.min(8, files.length) }, worker));
|
|
2789
|
+
}
|
|
2790
|
+
async get(token, id, teamId) {
|
|
2791
|
+
const r = await this.http.request({ url: `${API4}/v13/deployments/${id}`, headers: this.auth(token), query: { teamId } }, { provider: "Vercel" });
|
|
2792
|
+
return r.data;
|
|
2793
|
+
}
|
|
2794
|
+
auth(token) {
|
|
2795
|
+
return { authorization: `Bearer ${token}` };
|
|
2796
|
+
}
|
|
2797
|
+
};
|
|
2798
|
+
|
|
2799
|
+
// src/infrastructure/providers/index.ts
|
|
2800
|
+
function builtInProviders({ http, fs, git, proc }) {
|
|
2801
|
+
return [
|
|
2802
|
+
new VercelProvider(http, fs),
|
|
2803
|
+
new NetlifyProvider(http, fs),
|
|
2804
|
+
new GitHubPagesProvider(http, fs, git),
|
|
2805
|
+
new RailwayProvider(proc),
|
|
2806
|
+
new RenderProvider(http, git)
|
|
2807
|
+
];
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2810
|
+
// src/cli/presenters/ClackPrompt.ts
|
|
2811
|
+
import * as p9 from "@clack/prompts";
|
|
2812
|
+
function unwrap(value) {
|
|
2813
|
+
if (p9.isCancel(value)) throw new UserCancelledError();
|
|
2814
|
+
return value;
|
|
2815
|
+
}
|
|
2816
|
+
var ClackPrompt = class {
|
|
2817
|
+
constructor(interactive) {
|
|
2818
|
+
this.interactive = interactive;
|
|
2819
|
+
}
|
|
2820
|
+
interactive;
|
|
2821
|
+
async confirm(message, initial = true) {
|
|
2822
|
+
return unwrap(await p9.confirm({ message, initialValue: initial }));
|
|
2823
|
+
}
|
|
2824
|
+
async select(message, choices) {
|
|
2825
|
+
return unwrap(await p9.select({ message, options: choices }));
|
|
2826
|
+
}
|
|
2827
|
+
async multiselect(message, choices, initial = []) {
|
|
2828
|
+
return unwrap(await p9.multiselect({ message, options: choices, initialValues: initial, required: true }));
|
|
2829
|
+
}
|
|
2830
|
+
async text(message, opts = {}) {
|
|
2831
|
+
return unwrap(await p9.text({ message, placeholder: opts.placeholder, initialValue: opts.initial }));
|
|
2832
|
+
}
|
|
2833
|
+
async password(message) {
|
|
2834
|
+
return unwrap(await p9.password({ message }));
|
|
2835
|
+
}
|
|
2836
|
+
note(body, title) {
|
|
2837
|
+
p9.note(body, title);
|
|
2838
|
+
}
|
|
2839
|
+
spinner() {
|
|
2840
|
+
if (!this.interactive) {
|
|
2841
|
+
return {
|
|
2842
|
+
start: (m) => p9.log.step(m),
|
|
2843
|
+
message: () => {
|
|
2844
|
+
},
|
|
2845
|
+
stop: (m, ok = true) => ok ? p9.log.success(m) : p9.log.error(m)
|
|
2846
|
+
};
|
|
2847
|
+
}
|
|
2848
|
+
const s = p9.spinner();
|
|
2849
|
+
return {
|
|
2850
|
+
start: (m) => s.start(m),
|
|
2851
|
+
message: (m) => s.message(m),
|
|
2852
|
+
stop: (m, ok = true) => ok ? s.stop(m) : s.error(m)
|
|
2853
|
+
};
|
|
2854
|
+
}
|
|
2855
|
+
};
|
|
2856
|
+
|
|
2857
|
+
// src/cli/presenters/ConsoleLogger.ts
|
|
2858
|
+
import * as p10 from "@clack/prompts";
|
|
2859
|
+
import pc9 from "picocolors";
|
|
2860
|
+
var ConsoleLogger = class {
|
|
2861
|
+
constructor(debugEnabled = false) {
|
|
2862
|
+
this.debugEnabled = debugEnabled;
|
|
2863
|
+
}
|
|
2864
|
+
debugEnabled;
|
|
2865
|
+
info(message) {
|
|
2866
|
+
p10.log.info(message);
|
|
2867
|
+
}
|
|
2868
|
+
step(message) {
|
|
2869
|
+
p10.log.step(message);
|
|
2870
|
+
}
|
|
2871
|
+
success(message) {
|
|
2872
|
+
p10.log.success(message);
|
|
2873
|
+
}
|
|
2874
|
+
warn(message) {
|
|
2875
|
+
p10.log.warn(message);
|
|
2876
|
+
}
|
|
2877
|
+
error(message) {
|
|
2878
|
+
p10.log.error(message);
|
|
2879
|
+
}
|
|
2880
|
+
debug(message) {
|
|
2881
|
+
if (this.debugEnabled) p10.log.message(pc9.dim(message));
|
|
2882
|
+
}
|
|
2883
|
+
};
|
|
2884
|
+
|
|
2885
|
+
// src/cli/container.ts
|
|
2886
|
+
function credentialsPath() {
|
|
2887
|
+
const base = process.env.OMNIDEPLOY_CONFIG_DIR ?? (process.platform === "win32" ? join2(process.env.APPDATA ?? join2(homedir(), "AppData", "Roaming"), "omnideploy") : join2(process.env.XDG_CONFIG_HOME ?? join2(homedir(), ".config"), "omnideploy"));
|
|
2888
|
+
return join2(base, "credentials.json");
|
|
2889
|
+
}
|
|
2890
|
+
function createContainer(opts) {
|
|
2891
|
+
const fs = new NodeFileSystem();
|
|
2892
|
+
const proc = new ExecaProcessRunner();
|
|
2893
|
+
const http = new FetchHttpClient({ userAgent: `omnideploy/${opts.version}` });
|
|
2894
|
+
const git = new CliGit(proc);
|
|
2895
|
+
const store = new FileCredentialStore(credentialsPath());
|
|
2896
|
+
const configRepo = new JsonConfigRepository(fs);
|
|
2897
|
+
const prompt = new ClackPrompt(opts.interactive);
|
|
2898
|
+
const logger = new ConsoleLogger(opts.debug);
|
|
2899
|
+
const providers = new ProviderRegistry(builtInProviders({ http, fs, git, proc }));
|
|
2900
|
+
const credentials = new CredentialResolver(store, prompt);
|
|
2901
|
+
const detect = new DetectProject(builtInDetectors(fs), configRepo);
|
|
2902
|
+
const generate = new GenerateArtifacts(builtInGenerators(), providers, fs, prompt, logger);
|
|
2903
|
+
const build = new BuildProject(proc, fs, logger);
|
|
2904
|
+
const listProviders = new ListProviders(providers, store, proc);
|
|
2905
|
+
return {
|
|
2906
|
+
prompt,
|
|
2907
|
+
logger,
|
|
2908
|
+
providers,
|
|
2909
|
+
detect,
|
|
2910
|
+
generate,
|
|
2911
|
+
listProviders,
|
|
2912
|
+
init: new InitProject(detect, providers, generate, configRepo, prompt, logger),
|
|
2913
|
+
deploy: new DeployProject(detect, providers, generate, build, credentials, configRepo, git, prompt, logger),
|
|
2914
|
+
login: new LoginProvider(providers, store, prompt),
|
|
2915
|
+
logout: new LogoutProvider(providers, store),
|
|
2916
|
+
doctor: new DoctorCheck(detect, listProviders, git),
|
|
2917
|
+
status: new GetDeploymentStatus(providers, credentials, configRepo)
|
|
2918
|
+
};
|
|
2919
|
+
}
|
|
2920
|
+
|
|
2921
|
+
// src/cli/main.ts
|
|
2922
|
+
var VERSION = true ? "0.1.0" : "0.0.0-dev";
|
|
2923
|
+
var program = new Command().name("omnideploy").description("Detect, containerize and deploy any project to Vercel, Netlify, GitHub Pages, Railway, Render \u2014 with one CLI.").version(VERSION).option("-C, --cwd <dir>", "project directory", process.cwd()).option("--debug", "show stack traces and debug output").option("--no-interactive", "never prompt (also implied by CI=true or a non-TTY stdin)").showHelpAfterError();
|
|
2924
|
+
var container;
|
|
2925
|
+
var globals = () => program.opts();
|
|
2926
|
+
var env = {
|
|
2927
|
+
cwd: () => resolve2(globals().cwd),
|
|
2928
|
+
debug: () => Boolean(globals().debug),
|
|
2929
|
+
container: () => container ??= createContainer({
|
|
2930
|
+
debug: Boolean(globals().debug),
|
|
2931
|
+
interactive: globals().interactive && Boolean(process.stdin.isTTY) && !process.env.CI,
|
|
2932
|
+
version: VERSION
|
|
2933
|
+
})
|
|
2934
|
+
};
|
|
2935
|
+
for (const register of [registerInit, registerDeploy, registerGenerate, registerAuth, registerProviders, registerDoctor, registerStatus]) {
|
|
2936
|
+
register(program, env);
|
|
2937
|
+
}
|
|
2938
|
+
try {
|
|
2939
|
+
await program.parseAsync(process.argv);
|
|
2940
|
+
} catch (error) {
|
|
2941
|
+
process.exitCode = presentError(error, env.debug());
|
|
2942
|
+
}
|
|
2943
|
+
//# sourceMappingURL=main.js.map
|