@oalo-ai/cli 0.1.0 → 0.2.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 +1 -1
- package/man/oalo.1 +78 -17
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/skills/oalo/SKILL.md +23 -0
- package/src/channel.js +1 -1
- package/src/commands/db.js +336 -4
- package/src/commands/deploy.js +4 -4
- package/src/commands/env.js +2 -2
- package/src/commands/link.js +3 -3
- package/src/commands/logs.js +1 -1
- package/src/commands/migrate.js +321 -0
- package/src/commands/secrets.js +1 -1
- package/src/commands/skill.js +4 -4
- package/src/commands/typegen.js +8 -8
- package/src/commands/whoami.js +1 -1
- package/src/config.js +1 -1
- package/src/help.js +57 -17
- package/src/index.js +4 -0
- package/src/migrate/plan.js +478 -0
- package/src/migrate/toml.js +242 -0
- package/src/migrate/yaml.js +179 -0
- package/src/server.js +1 -1
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
import { parseToml } from "./toml.js";
|
|
2
|
+
import { parseYaml } from "./yaml.js";
|
|
3
|
+
import { parseDotenv } from "../envfile.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `oalo migrate`'s plan: what an app on another platform becomes on Oalo,
|
|
7
|
+
* worked out from its files and what that platform's CLI reports. Pure: the
|
|
8
|
+
* command reads the files and runs the CLIs, this decides.
|
|
9
|
+
*
|
|
10
|
+
* input.files { procfile, appJson, flyToml, railwayJson, railwayToml, renderYaml,
|
|
11
|
+
* dockerfile, aptfile, packageJson, lockfile, oaloAppJson } (text, or true/false)
|
|
12
|
+
* input.remote { vars: {NAME: value} | null, secretNames: [..] | null, dynos: [{type, size}] | null, notes: [..] }
|
|
13
|
+
* input.envFile text of a .env file to add, or null
|
|
14
|
+
* input.fromApp the app or service on that platform, when named
|
|
15
|
+
*
|
|
16
|
+
* Returns { platform, label, procfile, manifest, vars, warnings } where vars
|
|
17
|
+
* carry values (for the command to send) and nothing else does.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export const PLATFORMS = {
|
|
21
|
+
heroku: { label: "Heroku", cli: "heroku", prefixes: ["HEROKU_"] },
|
|
22
|
+
railway: { label: "Railway", cli: "railway", prefixes: ["RAILWAY_", "RAILPACK_", "NIXPACKS_"] },
|
|
23
|
+
render: { label: "Render", cli: null, prefixes: ["RENDER_"], names: ["IS_PULL_REQUEST", "NODE_VERSION", "PYTHON_VERSION", "RUBY_VERSION", "GO_VERSION", "BUN_VERSION", "ELIXIR_VERSION", "ERLANG_VERSION", "POETRY_VERSION", "UV_VERSION"] },
|
|
24
|
+
fly: { label: "Fly.io", cli: "fly", prefixes: ["FLY_"], names: ["PRIMARY_REGION"] },
|
|
25
|
+
primcloud: { label: "Primcloud", cli: null, prefixes: ["PRIMCLOUD_"] },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// What Oalo sets itself (the Base refuses these names), as in `oalo env`.
|
|
29
|
+
const RESERVED = new Set(
|
|
30
|
+
`PORT DATABASE_URL APP_SCHEMA APP_SLUG OALO_ENV EXPO_PUBLIC_OALO_ENV VITE_PORT CONTAINER_HOST VITE_CONTAINER_HOST
|
|
31
|
+
EXPO_PUBLIC_CONTAINER_HOST BROWSER MIX_ENV MIX_HOME HEX_HOME PATH HOME LANG LANGUAGE LC_ALL LC_CTYPE TZ TMPDIR
|
|
32
|
+
FLUTTER_HOME FLUTTER_ROOT PUB_CACHE HOSTNAME`.split(/\s+/),
|
|
33
|
+
);
|
|
34
|
+
const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
|
|
35
|
+
const PROC_RE = /^[A-Za-z0-9_-]+$/;
|
|
36
|
+
const MAX_VALUE = 32768;
|
|
37
|
+
const MAX_VARS = 200;
|
|
38
|
+
|
|
39
|
+
// Oalo's sizes, smallest first, by memory.
|
|
40
|
+
export const SIZES = [
|
|
41
|
+
{ size: "tiny", mb: 512 },
|
|
42
|
+
{ size: "small", mb: 1024 },
|
|
43
|
+
{ size: "medium", mb: 2048 },
|
|
44
|
+
{ size: "large", mb: 4096 },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
/** The smallest Oalo size with at least `mb` of memory, and whether even large is short. */
|
|
48
|
+
export function sizeFor(mb) {
|
|
49
|
+
if (!(mb > 0)) return null;
|
|
50
|
+
const fit = SIZES.find((s) => s.mb >= mb);
|
|
51
|
+
return fit ? { size: fit.size, over: false } : { size: "large", over: true };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const HEROKU_MB = {
|
|
55
|
+
free: 512, hobby: 512, eco: 512, basic: 512, "standard-1x": 512, "1x": 512, "standard-2x": 1024, "2x": 1024,
|
|
56
|
+
"performance-m": 2560, "performance-l": 14336, "performance-l-ram": 30720, "performance-xl": 63488, "performance-2xl": 129024,
|
|
57
|
+
"private-s": 1024, "private-m": 2560, "private-l": 14336, "shield-s": 1024, "shield-m": 2560, "shield-l": 14336,
|
|
58
|
+
};
|
|
59
|
+
const RENDER_MB = { free: 512, starter: 512, standard: 2048, pro: 4096, "pro plus": 8192, "pro max": 16384, "pro ultra": 32768 };
|
|
60
|
+
const FLY_MB = {
|
|
61
|
+
"shared-cpu-1x": 256, "shared-cpu-2x": 512, "shared-cpu-4x": 1024, "shared-cpu-8x": 2048,
|
|
62
|
+
"performance-1x": 2048, "performance-2x": 4096, "performance-4x": 8192, "performance-8x": 16384, "performance-16x": 32768,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export function buildPlan(platform, input) {
|
|
66
|
+
const def = PLATFORMS[platform];
|
|
67
|
+
if (!def) throw new Error(`unknown platform ${platform}`);
|
|
68
|
+
const files = input.files || {};
|
|
69
|
+
const remote = input.remote || {};
|
|
70
|
+
const warnings = [...(remote.notes || [])];
|
|
71
|
+
const vars = new Map(); // name -> { value, from }
|
|
72
|
+
const missing = []; // { name, why }
|
|
73
|
+
|
|
74
|
+
let read;
|
|
75
|
+
if (platform === "heroku") read = fromHeroku(files, remote, warnings);
|
|
76
|
+
else if (platform === "railway") read = fromRailway(files, warnings);
|
|
77
|
+
else if (platform === "render") read = fromRender(files, input.fromApp, warnings, missing, vars);
|
|
78
|
+
else if (platform === "fly") read = fromFly(files, warnings, vars, remote, missing);
|
|
79
|
+
else read = fromPrimcloud(files, warnings);
|
|
80
|
+
|
|
81
|
+
if (remote.vars) for (const [name, value] of Object.entries(remote.vars)) vars.set(name, { value: value == null ? "" : String(value), from: def.label });
|
|
82
|
+
if (input.envFile != null) {
|
|
83
|
+
for (const { name, value } of parseDotenv(input.envFile)) vars.set(name, { value, from: "--env-file" });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const procfile = procfilePlan(files, read.processes, read.release, warnings);
|
|
87
|
+
const manifest = manifestPlan(files, procfile, read.sizes || {}, read.health, warnings);
|
|
88
|
+
const varPlan = filterVars(def, vars, missing, warnings);
|
|
89
|
+
commonChecks(files, warnings);
|
|
90
|
+
|
|
91
|
+
return { platform, label: def.label, procfile, manifest, vars: varPlan, warnings: dedupe(warnings) };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── per platform ──────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
function fromHeroku(files, remote, warnings) {
|
|
97
|
+
const sizes = {};
|
|
98
|
+
let fromSize = null;
|
|
99
|
+
let appJson = null;
|
|
100
|
+
if (files.appJson) {
|
|
101
|
+
try {
|
|
102
|
+
appJson = JSON.parse(files.appJson);
|
|
103
|
+
} catch {
|
|
104
|
+
warnings.push("app.json isn't valid JSON, so its formation sizes were skipped.");
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const list = remote.dynos?.length
|
|
108
|
+
? remote.dynos.map((d) => ({ type: d.type, size: d.size }))
|
|
109
|
+
: Object.entries(appJson?.formation || {}).map(([type, f]) => ({ type, size: f?.size }));
|
|
110
|
+
for (const { type, size } of list) {
|
|
111
|
+
if (!type || !size || sizes[type]) continue;
|
|
112
|
+
const mb = HEROKU_MB[String(size).toLowerCase()];
|
|
113
|
+
if (mb) sizes[type] = { mb, from: `${size} dyno` };
|
|
114
|
+
else fromSize = size;
|
|
115
|
+
}
|
|
116
|
+
if (fromSize) warnings.push(`Dyno size ${fromSize} isn't one oalo migrate knows: set that process's size in .oalo/app.json by hand.`);
|
|
117
|
+
if (files.aptfile) warnings.push("Aptfile: Oalo's buildpacks don't read it. Add a Dockerfile that installs those packages.");
|
|
118
|
+
warnings.push("Heroku Scheduler jobs can't be read from here. Add a `scheduler:` Procfile line that runs your language's cron library, or a workflow on a schedule.");
|
|
119
|
+
return { processes: null, release: null, sizes, health: null };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function fromRailway(files, warnings) {
|
|
123
|
+
let cfg = null;
|
|
124
|
+
if (files.railwayJson) cfg = parseOrWarn(() => JSON.parse(files.railwayJson), "railway.json", warnings);
|
|
125
|
+
else if (files.railwayToml) cfg = parseOrWarn(() => parseToml(files.railwayToml), "railway.toml", warnings);
|
|
126
|
+
const deploy = cfg?.deploy || {};
|
|
127
|
+
const build = cfg?.build || {};
|
|
128
|
+
const processes = [];
|
|
129
|
+
let release = null;
|
|
130
|
+
|
|
131
|
+
if (deploy.cronSchedule) {
|
|
132
|
+
warnings.push(`This service runs on a schedule (${deploy.cronSchedule}), not all the time. On Oalo that's a \`scheduler:\` Procfile line running your language's cron library, or a workflow on a schedule. No web line was written for it.`);
|
|
133
|
+
} else if (deploy.startCommand) {
|
|
134
|
+
processes.push(["web", String(deploy.startCommand)]);
|
|
135
|
+
}
|
|
136
|
+
if (deploy.preDeployCommand) release = joinCommands(deploy.preDeployCommand);
|
|
137
|
+
if (deploy.numReplicas > 1) warnings.push(`Railway ran ${deploy.numReplicas} replicas. Each Oalo process runs as one copy.`);
|
|
138
|
+
if (build.buildCommand) warnings.push(`Railway's build command (${build.buildCommand}) doesn't carry over: put it in package.json's \`build\` script, or a Dockerfile.`);
|
|
139
|
+
if (build.dockerfilePath && !/^\.?\/?Dockerfile$/.test(build.dockerfilePath)) {
|
|
140
|
+
warnings.push(`Railway built from ${build.dockerfilePath}. Oalo builds from ./Dockerfile at the repo's root.`);
|
|
141
|
+
}
|
|
142
|
+
if (!cfg) warnings.push("No railway.json or railway.toml here, so start and pre-deploy commands come from the Railway dashboard: copy them into a Procfile (`web:` and `release:`).");
|
|
143
|
+
warnings.push("A service built from another repo becomes its own Oalo app: run oalo migrate in that repo too.");
|
|
144
|
+
warnings.push("Volumes don't come along: an Oalo process can write only to /tmp, which is emptied when it restarts.");
|
|
145
|
+
return { processes, release, sizes: {}, health: deploy.healthcheckPath || null };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function fromRender(files, fromApp, warnings, missing, vars) {
|
|
149
|
+
if (!files.renderYaml) {
|
|
150
|
+
warnings.push("No render.yaml here, so start and pre-deploy commands come from each service's Settings page: copy them into a Procfile (`web:`, `worker:` and `release:`), and the variables into a file for --env-file.");
|
|
151
|
+
return { processes: null, release: null, sizes: {}, health: null };
|
|
152
|
+
}
|
|
153
|
+
const doc = parseOrWarn(() => parseYaml(files.renderYaml), "render.yaml", warnings);
|
|
154
|
+
if (!doc) return { processes: null, release: null, sizes: {}, health: null };
|
|
155
|
+
|
|
156
|
+
const groups = new Map((doc.envVarGroups || []).map((g) => [g?.name, g?.envVars || []]));
|
|
157
|
+
let services = (doc.services || []).filter((s) => s && typeof s === "object");
|
|
158
|
+
if (fromApp) {
|
|
159
|
+
const named = services.filter((s) => s.name === fromApp);
|
|
160
|
+
if (!named.length) warnings.push(`render.yaml has no service named ${fromApp}; all its services were read.`);
|
|
161
|
+
else services = named;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const processes = [];
|
|
165
|
+
const sizes = {};
|
|
166
|
+
let release = null;
|
|
167
|
+
let health = null;
|
|
168
|
+
let webDone = false;
|
|
169
|
+
|
|
170
|
+
for (const s of services) {
|
|
171
|
+
const type = s.type;
|
|
172
|
+
const runtime = s.runtime || s.env;
|
|
173
|
+
const name = s.name || type;
|
|
174
|
+
const cmd = s.startCommand || s.dockerCommand || null;
|
|
175
|
+
|
|
176
|
+
if (type === "web" && runtime === "static") {
|
|
177
|
+
warnings.push(`${name} is a static site: on Oalo it's its own web app (oalo deploy --new in its folder), not a process.`);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (type === "static") {
|
|
181
|
+
warnings.push(`${name} is a static site: on Oalo it's its own web app (oalo deploy --new in its folder), not a process.`);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (type === "cron") {
|
|
185
|
+
warnings.push(`${name} is a cron job (${s.schedule || "on a schedule"}: ${cmd || "its command"}). On Oalo that's a \`scheduler:\` Procfile line running your language's cron library, or a workflow on a schedule.`);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (type === "pserv") {
|
|
189
|
+
warnings.push(`${name} is a private service: on Oalo it's its own app, reached at its public address.`);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (type === "keyvalue" || type === "redis") {
|
|
193
|
+
warnings.push(`${name} is Render Key Value: keep it, and set its external URL as REDIS_URL (its access settings must let your Base in).`);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let proc;
|
|
198
|
+
if (type === "web") {
|
|
199
|
+
if (webDone) {
|
|
200
|
+
warnings.push(`${name} is a second web service: on Oalo it's its own app.`);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
proc = "web";
|
|
204
|
+
webDone = true;
|
|
205
|
+
if (s.preDeployCommand) release = joinCommands(s.preDeployCommand);
|
|
206
|
+
if (s.healthCheckPath) health = s.healthCheckPath;
|
|
207
|
+
} else if (type === "worker" || type === "bworker") {
|
|
208
|
+
proc = procName(name);
|
|
209
|
+
} else {
|
|
210
|
+
warnings.push(`${name} has type ${type}, which oalo migrate doesn't map: add it to the Procfile by hand.`);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (cmd) processes.push([proc, String(cmd).trim()]);
|
|
215
|
+
else if (runtime !== "docker") warnings.push(`${name} has no startCommand in render.yaml: add its \`${proc}:\` line by hand, from its Settings page.`);
|
|
216
|
+
if (s.plan && RENDER_MB[String(s.plan).toLowerCase()]) sizes[proc] = { mb: RENDER_MB[String(s.plan).toLowerCase()], from: `Render ${s.plan}` };
|
|
217
|
+
if (s.buildCommand) warnings.push(`${name}'s build command (${s.buildCommand}) doesn't carry over: Oalo builds with buildpacks or your Dockerfile. Put extra steps in package.json's \`build\` script, or the Dockerfile.`);
|
|
218
|
+
if (s.disk) warnings.push(`${name} has a persistent disk (${s.disk.mountPath || s.disk.name || "mounted"}): it doesn't come along. A process can write only to /tmp, which is emptied when it restarts.`);
|
|
219
|
+
if (s.numInstances > 1 || s.scaling) warnings.push(`${name} ran more than one instance. Each Oalo process runs as one copy.`);
|
|
220
|
+
if (s.rootDir) warnings.push(`${name} builds from ${s.rootDir}. Oalo reads the repo's root: run oalo migrate and oalo deploy --new from that folder's own repo, or add a root Dockerfile.`);
|
|
221
|
+
|
|
222
|
+
for (const v of s.envVars || []) renderVar(v, groups, vars, missing);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
for (const db of doc.databases || []) {
|
|
226
|
+
if (db?.name) warnings.push(`Render Postgres ${db.name}: keep it and set its external URL as EXTERNAL_DATABASE_URL, or copy its data in with oalo db restore.`);
|
|
227
|
+
}
|
|
228
|
+
return { processes, release, sizes, health };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function renderVar(v, groups, vars, missing) {
|
|
232
|
+
if (!v || typeof v !== "object") return;
|
|
233
|
+
if (v.fromGroup) {
|
|
234
|
+
for (const g of groups.get(v.fromGroup) || []) renderVar(g, groups, vars, missing);
|
|
235
|
+
if (!groups.has(v.fromGroup)) missing.push({ name: `(group ${v.fromGroup})`, why: "an environment group render.yaml doesn't define: copy its variables from the dashboard" });
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (!v.key) return;
|
|
239
|
+
if (v.value !== undefined && v.value !== null) vars.set(v.key, { value: String(v.value), from: "render.yaml" });
|
|
240
|
+
else if (v.fromDatabase) missing.push({ name: v.key, why: "Render fills it from a database: use that database's external URL" });
|
|
241
|
+
else if (v.fromService) missing.push({ name: v.key, why: "Render fills it from another service: use that service's public address or value" });
|
|
242
|
+
else if (v.generateValue) missing.push({ name: v.key, why: "Render generated it: copy it from the Environment page" });
|
|
243
|
+
else missing.push({ name: v.key, why: "set in the dashboard (sync: false): copy it from the Environment page" });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function fromFly(files, warnings, vars, remote, missing) {
|
|
247
|
+
if (!files.flyToml) {
|
|
248
|
+
warnings.push("No fly.toml here: run oalo migrate fly in the folder that has it.");
|
|
249
|
+
return { processes: null, release: null, sizes: {}, health: null };
|
|
250
|
+
}
|
|
251
|
+
const cfg = parseOrWarn(() => parseToml(files.flyToml), "fly.toml", warnings);
|
|
252
|
+
if (!cfg) return { processes: null, release: null, sizes: {}, health: null };
|
|
253
|
+
|
|
254
|
+
const http = cfg.http_service || null;
|
|
255
|
+
const webGroup = (http?.processes && http.processes[0]) || "app";
|
|
256
|
+
const processes = [];
|
|
257
|
+
const sizes = {};
|
|
258
|
+
for (const [group, cmd] of Object.entries(cfg.processes || {})) {
|
|
259
|
+
processes.push([group === webGroup ? "web" : procName(group), String(cmd)]);
|
|
260
|
+
}
|
|
261
|
+
if (!Object.keys(cfg.processes || {}).length && !files.dockerfile && !files.procfile) {
|
|
262
|
+
warnings.push("fly.toml names no [processes] and there's no Dockerfile or Procfile: add a Procfile with a `web:` line.");
|
|
263
|
+
}
|
|
264
|
+
const release = cfg.deploy?.release_command ? String(cfg.deploy.release_command) : null;
|
|
265
|
+
|
|
266
|
+
const port = http?.internal_port || cfg.services?.[0]?.internal_port;
|
|
267
|
+
if (port) warnings.push(`Fly sent traffic to port ${port}. On Oalo, web must listen on $PORT (0.0.0.0): check the server reads it.`);
|
|
268
|
+
|
|
269
|
+
const health = flyHealth(cfg);
|
|
270
|
+
|
|
271
|
+
for (const vm of [].concat(cfg.vm || [])) {
|
|
272
|
+
const mb = flyMemory(vm);
|
|
273
|
+
if (!mb) continue;
|
|
274
|
+
const groups = vm.processes?.length ? vm.processes : Object.keys(cfg.processes || {}).length ? Object.keys(cfg.processes) : [webGroup];
|
|
275
|
+
for (const g of groups) {
|
|
276
|
+
const proc = g === webGroup ? "web" : procName(g);
|
|
277
|
+
if (!sizes[proc]) sizes[proc] = { mb, from: `Fly VM, ${mb} MB` };
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
for (const [name, value] of Object.entries(cfg.env || {})) vars.set(name, { value: String(value), from: "fly.toml [env]" });
|
|
282
|
+
for (const name of remote.secretNames || []) {
|
|
283
|
+
if (!vars.has(name)) missing.push({ name, why: "a Fly secret: Fly never shows its value, so get it from where it came from" });
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (cfg.mounts) warnings.push("fly.toml mounts a volume: it doesn't come along. A process can write only to /tmp, which is emptied when it restarts.");
|
|
287
|
+
if (http?.auto_stop_machines) warnings.push("Machines that stopped when idle become processes that are always on.");
|
|
288
|
+
if (files.dockerfile && !/^\s*USER\s+/im.test(files.dockerfile)) {
|
|
289
|
+
warnings.push("The Dockerfile has no USER line. On Oalo the image runs as uid 1000 with a read-only filesystem (only /tmp is writable): make sure nothing needs root or writes elsewhere.");
|
|
290
|
+
}
|
|
291
|
+
return { processes, release, sizes, health };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function flyHealth(cfg) {
|
|
295
|
+
const checks = [];
|
|
296
|
+
for (const c of [].concat(cfg.http_service?.checks || [])) checks.push(c);
|
|
297
|
+
for (const s of [].concat(cfg.services || [])) for (const c of [].concat(s.http_checks || [])) checks.push(c);
|
|
298
|
+
for (const c of Object.values(cfg.checks || {})) if (c?.type === "http") checks.push(c);
|
|
299
|
+
const path = checks.find((c) => typeof c?.path === "string")?.path;
|
|
300
|
+
return path || null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function flyMemory(vm) {
|
|
304
|
+
if (typeof vm.memory_mb === "number") return vm.memory_mb;
|
|
305
|
+
const m = vm.memory;
|
|
306
|
+
if (typeof m === "number") return m;
|
|
307
|
+
if (typeof m === "string") {
|
|
308
|
+
const x = m.trim().toLowerCase().match(/^(\d+(?:\.\d+)?)\s*(gb|g|mb|m)?$/);
|
|
309
|
+
if (x) return Math.round(Number(x[1]) * (x[2] && x[2].startsWith("g") ? 1024 : 1));
|
|
310
|
+
}
|
|
311
|
+
if (vm.size && FLY_MB[vm.size]) return FLY_MB[vm.size];
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function fromPrimcloud(files, warnings) {
|
|
316
|
+
warnings.push("Primcloud's formation sizes can't be read from here: set each process's size in .oalo/app.json (Standard 1x is tiny, 2x small, 4x or Performance 1x medium, Performance 2x large).");
|
|
317
|
+
warnings.push("Secrets: reveal them in Primcloud's Settings, Secrets, put them in a .env file and pass it with --env-file.");
|
|
318
|
+
if (!files.procfile) warnings.push("No Procfile here: Primcloud apps usually have one. Add it at the repo's root.");
|
|
319
|
+
return { processes: null, release: null, sizes: {}, health: null };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ── Procfile and .oalo/app.json ───────────────────────────────────────
|
|
323
|
+
|
|
324
|
+
function procfilePlan(files, processes, release, warnings) {
|
|
325
|
+
const lines = [];
|
|
326
|
+
if (release) lines.push(["release", release]);
|
|
327
|
+
for (const p of processes || []) lines.push(p);
|
|
328
|
+
|
|
329
|
+
if (files.procfile) {
|
|
330
|
+
const have = parseProcfile(files.procfile);
|
|
331
|
+
const missingLines = lines.filter(([n]) => !have.some(([h]) => h === n));
|
|
332
|
+
if (missingLines.length) {
|
|
333
|
+
warnings.push(`Your Procfile is kept as it is. From the old config it would also have: ${missingLines.map(([n, c]) => `\`${n}: ${c}\``).join(", ")}.`);
|
|
334
|
+
}
|
|
335
|
+
return { action: "keep", lines: have };
|
|
336
|
+
}
|
|
337
|
+
if (!lines.length) return { action: "none", lines: [] };
|
|
338
|
+
return { action: "write", lines, text: lines.map(([n, c]) => `${n}: ${c}`).join("\n") + "\n" };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function parseProcfile(text) {
|
|
342
|
+
const out = [];
|
|
343
|
+
for (const line of String(text).split(/\r?\n/)) {
|
|
344
|
+
if (line.trim().startsWith("#")) continue;
|
|
345
|
+
const m = line.match(/^\s*([A-Za-z0-9_-]+)\s*:\s*(.+?)\s*$/);
|
|
346
|
+
if (m) out.push([m[1], m[2]]);
|
|
347
|
+
}
|
|
348
|
+
return out;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function manifestPlan(files, procfile, sizesIn, health, warnings) {
|
|
352
|
+
let existing = null;
|
|
353
|
+
if (files.oaloAppJson) {
|
|
354
|
+
try {
|
|
355
|
+
existing = JSON.parse(files.oaloAppJson);
|
|
356
|
+
} catch {
|
|
357
|
+
warnings.push(".oalo/app.json isn't valid JSON, so it's left alone. Fix it before you deploy.");
|
|
358
|
+
return { action: "none", sizes: {}, health: null };
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const procs = new Set(procfile.lines.map(([n]) => n).filter((n) => n !== "release"));
|
|
363
|
+
const sizes = {};
|
|
364
|
+
for (const [proc, s] of Object.entries(sizesIn)) {
|
|
365
|
+
if (procs.size && !procs.has(proc)) continue;
|
|
366
|
+
const fit = sizeFor(s.mb);
|
|
367
|
+
if (!fit) continue;
|
|
368
|
+
sizes[proc] = { size: fit.size, from: s.from };
|
|
369
|
+
if (fit.over) warnings.push(`${proc} had ${s.mb} MB (${s.from}). The largest Oalo size is large (2 CPUs, 4 GB): check it fits before you go live.`);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const next = { ...(existing || {}) };
|
|
373
|
+
const added = { sizes: {}, health: null };
|
|
374
|
+
for (const [proc, s] of Object.entries(sizes)) {
|
|
375
|
+
if (next.sizes?.[proc]) continue;
|
|
376
|
+
next.sizes = { ...(next.sizes || {}), [proc]: s.size };
|
|
377
|
+
added.sizes[proc] = s;
|
|
378
|
+
}
|
|
379
|
+
if (health && !next.health && /^\/(?!\/)[^\s]*$/.test(health) && !health.includes("..") && health.length <= 200) {
|
|
380
|
+
next.health = health;
|
|
381
|
+
added.health = health;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const changed = Object.keys(added.sizes).length || added.health;
|
|
385
|
+
if (!changed) return { action: existing ? "keep" : "none", sizes: {}, health: null };
|
|
386
|
+
return { action: existing ? "merge" : "write", sizes: added.sizes, health: added.health, text: JSON.stringify(next, null, 2) + "\n" };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ── variables ─────────────────────────────────────────────────────────
|
|
390
|
+
|
|
391
|
+
function filterVars(def, vars, missing, warnings) {
|
|
392
|
+
const set = [];
|
|
393
|
+
const dropped = [];
|
|
394
|
+
const renamed = [];
|
|
395
|
+
const hasExternal = vars.has("EXTERNAL_DATABASE_URL");
|
|
396
|
+
|
|
397
|
+
for (const [name, { value, from }] of vars) {
|
|
398
|
+
if (name === "DATABASE_URL") {
|
|
399
|
+
if (hasExternal) dropped.push({ name, why: "EXTERNAL_DATABASE_URL is already there" });
|
|
400
|
+
else {
|
|
401
|
+
renamed.push({ from: name, to: "EXTERNAL_DATABASE_URL" });
|
|
402
|
+
set.push({ name: "EXTERNAL_DATABASE_URL", value, from });
|
|
403
|
+
}
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
if (RESERVED.has(name) || name.startsWith("OALO_")) {
|
|
407
|
+
dropped.push({ name, why: "Oalo sets it" });
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
if (def.prefixes.some((p) => name.startsWith(p)) || (def.names || []).includes(name)) {
|
|
411
|
+
dropped.push({ name, why: `${def.label}'s own` });
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
if (!NAME_RE.test(name)) {
|
|
415
|
+
dropped.push({ name, why: "not a name Oalo takes (letters, digits and _)" });
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
if (Buffer.byteLength(value, "utf8") > MAX_VALUE) {
|
|
419
|
+
dropped.push({ name, why: "over 32 KB" });
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
set.push({ name, value, from });
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
for (const v of set) {
|
|
426
|
+
if (/\$\{\{/.test(v.value)) warnings.push(`${v.name} holds a \`\${{ ... }}\` reference, which only resolves on Railway: put its plain value in --env-file.`);
|
|
427
|
+
const host = privateHost(v.value);
|
|
428
|
+
if (host) warnings.push(`${v.name} points at ${host}, which your Base can't reach: use that service's public address.`);
|
|
429
|
+
}
|
|
430
|
+
if (set.length > MAX_VARS) warnings.push(`That's ${set.length} variables; an app takes up to ${MAX_VARS}.`);
|
|
431
|
+
|
|
432
|
+
const setNames = new Set(set.map((v) => v.name));
|
|
433
|
+
return {
|
|
434
|
+
set: set.sort((a, b) => a.name.localeCompare(b.name)),
|
|
435
|
+
renamed,
|
|
436
|
+
dropped: dropped.sort((a, b) => a.name.localeCompare(b.name)),
|
|
437
|
+
missing: missing
|
|
438
|
+
.map((m) => (m.name === "DATABASE_URL" ? { name: "EXTERNAL_DATABASE_URL", why: `${m.why} (DATABASE_URL on Oalo is the Base's own Postgres)` } : m))
|
|
439
|
+
.filter((m) => !setNames.has(m.name)),
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function privateHost(value) {
|
|
444
|
+
const m = String(value).match(/(?:@|\/\/|^)([A-Za-z0-9.-]+\.(?:railway\.internal|flycast|internal)|localhost|127\.0\.0\.1)(?=[:/]|$)/);
|
|
445
|
+
return m ? m[1] : null;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ── checks every platform gets ────────────────────────────────────────
|
|
449
|
+
|
|
450
|
+
function commonChecks(files, warnings) {
|
|
451
|
+
if (files.packageJson && !files.lockfile && !files.dockerfile) {
|
|
452
|
+
warnings.push("package.json has no lockfile committed next to it (package-lock.json, yarn.lock, pnpm-lock.yaml or bun.lock): the build needs one.");
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ── helpers ───────────────────────────────────────────────────────────
|
|
457
|
+
|
|
458
|
+
function joinCommands(c) {
|
|
459
|
+
return Array.isArray(c) ? c.map(String).join(" && ") : String(c);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function procName(name) {
|
|
463
|
+
const n = String(name).toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
464
|
+
return n && PROC_RE.test(n) && n !== "web" && n !== "release" ? n : "worker";
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function parseOrWarn(fn, file, warnings) {
|
|
468
|
+
try {
|
|
469
|
+
return fn();
|
|
470
|
+
} catch (e) {
|
|
471
|
+
warnings.push(`${file} couldn't be read (${e.message}): copy its commands by hand.`);
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function dedupe(list) {
|
|
477
|
+
return [...new Set(list)];
|
|
478
|
+
}
|