@oalo-ai/cli 0.1.1 → 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.
@@ -0,0 +1,321 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
2
+ import { execFile } from "node:child_process";
3
+ import { join } from "node:path";
4
+ import { OaloChannel } from "../channel.js";
5
+ import { resolve as resolveConfig, controlPlane, HOW_TO_CONNECT } from "../config.js";
6
+ import { requireFeature } from "../server.js";
7
+ import { dim } from "../events.js";
8
+ import { buildPlan, PLATFORMS } from "../migrate/plan.js";
9
+
10
+ /**
11
+ * `oalo migrate <heroku|railway|render|fly|primcloud>`: what an app on another
12
+ * platform becomes on Oalo, from the repo's files and that platform's own CLI.
13
+ *
14
+ * oalo migrate heroku [--from-app shop] the plan; nothing changes
15
+ * oalo migrate heroku --apply write the Procfile and .oalo/app.json,
16
+ * and set the variables on the live app
17
+ *
18
+ * It reads fly.toml, railway.json/railway.toml, render.yaml, app.json and the
19
+ * Procfile; asks the platform's CLI for variables (Heroku, Railway) or secret
20
+ * names (Fly) and dyno sizes (Heroku); drops what Oalo or the old platform
21
+ * sets, renames DATABASE_URL to EXTERNAL_DATABASE_URL, and says what doesn't
22
+ * carry over. Values are never printed or written to a file: they go straight
23
+ * to the app's prod variables. An existing Procfile is never overwritten, and
24
+ * .oalo/app.json only gains keys it lacks.
25
+ */
26
+ export async function migrate(flags, rest, opts = {}) {
27
+ const platform = rest[1];
28
+ if (!platform || !PLATFORMS[platform]) {
29
+ return fail(`usage: oalo migrate <${Object.keys(PLATFORMS).join("|")}> [--from-app <name>] [--env-file <file>] [--apply]`, 2);
30
+ }
31
+ const dir = opts.cwd || process.cwd();
32
+ const run = opts.exec || execCli;
33
+ const connect = opts.channel || ((o) => new OaloChannel(o));
34
+ const fromApp = typeof flags["from-app"] === "string" ? flags["from-app"] : null;
35
+
36
+ let envFile = null;
37
+ if (typeof flags["env-file"] === "string") {
38
+ try {
39
+ envFile = readFileSync(flags["env-file"], "utf8");
40
+ } catch (e) {
41
+ return fail(`can't read ${flags["env-file"]}: ${e.code || e.message}`, 2);
42
+ }
43
+ }
44
+
45
+ const files = readFiles(dir);
46
+ const remote = flags["no-cli"] ? { notes: [] } : await askPlatform(platform, fromApp, files, run);
47
+
48
+ let plan;
49
+ try {
50
+ plan = buildPlan(platform, { files, remote, envFile, fromApp });
51
+ } catch (e) {
52
+ return fail(e.message, 1);
53
+ }
54
+
55
+ const cfg = resolveConfig(flags, dir);
56
+ const app = typeof flags.app === "string" ? flags.app : cfg.appId;
57
+
58
+ if (!flags.apply) {
59
+ if (flags.json) return void console.log(JSON.stringify(publicPlan(plan, app), null, 2));
60
+ printPlan(plan, app, dir);
61
+ return;
62
+ }
63
+
64
+ // --apply: files first (never overwriting), then the variables.
65
+ const wrote = [];
66
+ if (plan.procfile.action === "write" && !existsSync(join(dir, "Procfile"))) {
67
+ writeFileSync(join(dir, "Procfile"), plan.procfile.text);
68
+ wrote.push("Procfile");
69
+ }
70
+ if (plan.manifest.action === "write" || plan.manifest.action === "merge") {
71
+ mkdirSync(join(dir, ".oalo"), { recursive: true });
72
+ writeFileSync(join(dir, ".oalo", "app.json"), plan.manifest.text);
73
+ wrote.push(".oalo/app.json");
74
+ }
75
+
76
+ let varsResult = null;
77
+ if (plan.vars.set.length) {
78
+ if (!app) {
79
+ varsResult = { skipped: "no app" };
80
+ } else {
81
+ const { url, token } = controlPlane(cfg);
82
+ if (!url || !token) return fail(`setting the variables needs a CLI token.\n\n${HOW_TO_CONNECT}`, 2);
83
+ try {
84
+ await requireFeature(url, "env", "oalo migrate");
85
+ } catch (e) {
86
+ return fail(e.message, 1);
87
+ }
88
+ const ch = connect({ url, token });
89
+ try {
90
+ await ch.connect();
91
+ const r = await ch.call("env:set", {
92
+ app,
93
+ env: "prod",
94
+ vars: plan.vars.set.map(({ name, value }) => ({ name, value })),
95
+ restart: false,
96
+ });
97
+ varsResult = { app: r.app, saved: r.saved.length, relaunch: !!(r.relaunching || r.next_release) };
98
+ } catch (e) {
99
+ return fail(`the files are written, but the variables weren't set: ${e.message}`, 1);
100
+ } finally {
101
+ ch.close();
102
+ }
103
+ }
104
+ }
105
+
106
+ if (flags.json) return void console.log(JSON.stringify({ ...publicPlan(plan, app), wrote, variables: varsResult }, null, 2));
107
+
108
+ console.log(`Moving from ${plan.label}.`);
109
+ if (wrote.length) console.log(`wrote ${wrote.join(" and ")}: review them, then commit`);
110
+ else console.log(dim("no files to write"));
111
+ if (varsResult?.saved != null) {
112
+ console.log(`set ${varsResult.saved} variable${varsResult.saved === 1 ? "" : "s"} on ${varsResult.app} (prod)`);
113
+ if (varsResult.relaunch) console.log(dim("the live app gets them at its next release"));
114
+ } else if (varsResult?.skipped) {
115
+ console.log(`variables not set yet: this folder isn't linked to an Oalo app. After \`oalo deploy --new\`, run \`oalo migrate ${platform} --apply\` again${fromApp ? ` --from-app ${fromApp}` : ""}${flags["env-file"] ? ` --env-file ${flags["env-file"]}` : ""}.`);
116
+ }
117
+ printNeeds(plan);
118
+ printNext(plan, app, platform, true);
119
+ }
120
+
121
+ // ── reading ───────────────────────────────────────────────────────────
122
+
123
+ const LOCKFILES = ["package-lock.json", "yarn.lock", "pnpm-lock.yaml", "bun.lock", "bun.lockb", "npm-shrinkwrap.json"];
124
+
125
+ export function readFiles(dir) {
126
+ const read = (name) => {
127
+ try {
128
+ return readFileSync(join(dir, name), "utf8");
129
+ } catch {
130
+ return null;
131
+ }
132
+ };
133
+ return {
134
+ procfile: read("Procfile"),
135
+ appJson: read("app.json"),
136
+ flyToml: read("fly.toml"),
137
+ railwayJson: read("railway.json"),
138
+ railwayToml: read("railway.toml"),
139
+ renderYaml: read("render.yaml"),
140
+ dockerfile: read("Dockerfile"),
141
+ aptfile: existsSync(join(dir, "Aptfile")),
142
+ packageJson: existsSync(join(dir, "package.json")),
143
+ lockfile: LOCKFILES.some((f) => existsSync(join(dir, f))),
144
+ oaloAppJson: read(join(".oalo", "app.json")),
145
+ };
146
+ }
147
+
148
+ /** What the platform's own CLI can tell: variables, secret names, dyno sizes. */
149
+ async function askPlatform(platform, fromApp, files, run) {
150
+ const notes = [];
151
+ const out = { notes, vars: null, secretNames: null, dynos: null };
152
+ const flyApp = () => {
153
+ if (fromApp) return fromApp;
154
+ const m = files.flyToml?.match(/^\s*app\s*=\s*["']([^"']+)["']/m);
155
+ return m ? m[1] : null;
156
+ };
157
+
158
+ if (platform === "heroku") {
159
+ const a = fromApp ? ["-a", fromApp] : [];
160
+ const cfg = await run("heroku", ["config", "--json", ...a]);
161
+ if (cfg.ok) out.vars = asObject(cfg.stdout);
162
+ else notes.push(cliNote("heroku", cfg, "config vars", "heroku config --shell"));
163
+ const ps = await run("heroku", ["ps", "--json", ...a]);
164
+ if (ps.ok) out.dynos = asArray(ps.stdout).map((d) => ({ type: d.type, size: d.size }));
165
+ } else if (platform === "railway") {
166
+ const a = fromApp ? ["--service", fromApp] : [];
167
+ const r = await run("railway", ["variable", "list", "--json", ...a]);
168
+ if (r.ok) out.vars = asObject(r.stdout);
169
+ else notes.push(cliNote("railway", r, "variables", "railway variable list --kv"));
170
+ } else if (platform === "fly") {
171
+ const name = flyApp();
172
+ const a = name ? ["-a", name] : [];
173
+ let r = await run("fly", ["secrets", "list", "--json", ...a]);
174
+ if (!r.ok && r.missing) r = await run("flyctl", ["secrets", "list", "--json", ...a]);
175
+ if (r.ok) out.secretNames = asArray(r.stdout).map((s) => s.Name || s.name).filter(Boolean);
176
+ else notes.push(cliNote("fly", r, "secret names", "fly secrets list"));
177
+ }
178
+ return out;
179
+ }
180
+
181
+ function cliNote(cli, r, what, manual) {
182
+ if (r.missing) return `The ${cli} CLI isn't installed here, so its ${what} weren't read. Install it and sign in, or run \`${manual}\` elsewhere, save the values to a .env file and pass --env-file.`;
183
+ const why = firstLine(r.stderr) || "it failed";
184
+ return `\`${cli}\` couldn't list the ${what} (${why}). Sign in and link the project, or pass --from-app, or put them in a .env file and pass --env-file.`;
185
+ }
186
+
187
+ function execCli(cmd, args) {
188
+ return new Promise((resolve) => {
189
+ execFile(cmd, args, { timeout: 30_000, maxBuffer: 8 * 1024 * 1024, env: process.env }, (err, stdout, stderr) => {
190
+ if (err && err.code === "ENOENT") return resolve({ ok: false, missing: true, stdout: "", stderr: "" });
191
+ resolve({ ok: !err, missing: false, stdout: String(stdout || ""), stderr: String(stderr || "") });
192
+ });
193
+ });
194
+ }
195
+
196
+ function asObject(text) {
197
+ try {
198
+ const j = JSON.parse(text);
199
+ if (Array.isArray(j)) return Object.fromEntries(j.map((x) => [x.name ?? x.key, x.value]).filter(([k]) => k));
200
+ return j && typeof j === "object" ? j : {};
201
+ } catch {
202
+ return {};
203
+ }
204
+ }
205
+
206
+ function asArray(text) {
207
+ try {
208
+ const j = JSON.parse(text);
209
+ return Array.isArray(j) ? j : [];
210
+ } catch {
211
+ return [];
212
+ }
213
+ }
214
+
215
+ // Only a short reason, and never anything that looks like a URL with a password.
216
+ function firstLine(s) {
217
+ const line = String(s || "").split(/\r?\n/).map((x) => x.trim()).find(Boolean) || "";
218
+ return line.replace(/\/\/[^\s/@]+@/g, "//***@").slice(0, 160);
219
+ }
220
+
221
+ // ── printing ──────────────────────────────────────────────────────────
222
+
223
+ /** The plan without any values: what --json prints. */
224
+ function publicPlan(plan, app) {
225
+ return {
226
+ platform: plan.platform,
227
+ app: app || null,
228
+ procfile: { action: plan.procfile.action, lines: plan.procfile.lines.map(([name, command]) => ({ name, command })) },
229
+ manifest: { action: plan.manifest.action, sizes: plan.manifest.sizes, health: plan.manifest.health },
230
+ variables: {
231
+ set: plan.vars.set.map((v) => ({ name: v.name, from: v.from })),
232
+ renamed: plan.vars.renamed,
233
+ dropped: plan.vars.dropped,
234
+ missing: plan.vars.missing,
235
+ },
236
+ needs: plan.warnings,
237
+ };
238
+ }
239
+
240
+ function printPlan(plan, app, dir) {
241
+ console.log(`Moving from ${plan.label}. Nothing changes until you add --apply.\n`);
242
+
243
+ const pf = plan.procfile;
244
+ if (pf.action === "keep") console.log(`Procfile keep yours (${pf.lines.map(([n]) => n).join(", ") || "no lines"})`);
245
+ else if (pf.action === "write") {
246
+ console.log("Procfile write:");
247
+ for (const [n, c] of pf.lines) console.log(` ${n}: ${c}`);
248
+ } else console.log(`Procfile none ${dim("(web starts the usual way for its language, or from the Dockerfile)")}`);
249
+
250
+ const m = plan.manifest;
251
+ if (m.action === "write" || m.action === "merge") {
252
+ const parts = Object.entries(m.sizes).map(([p, s]) => `${p} ${s.size} (${s.from})`);
253
+ if (m.health) parts.push(`health ${m.health}`);
254
+ console.log(`.oalo/app.json ${m.action === "merge" ? "add" : "write"}: ${parts.join(", ")}`);
255
+ } else {
256
+ console.log(`.oalo/app.json ${m.action === "keep" ? "keep yours" : "none"} ${dim("(every process gets small)")}`);
257
+ }
258
+
259
+ const v = plan.vars;
260
+ console.log("");
261
+ if (v.set.length) {
262
+ console.log(`Variables ${v.set.length} to set on the live app${app ? ` (${app})` : ""}, values not shown:`);
263
+ console.log(wrap(v.set.map((x) => x.name).join(", "), 18, 0));
264
+ } else {
265
+ console.log("Variables none read");
266
+ }
267
+ for (const r of v.renamed) console.log(` ${r.to} from ${r.from} ${dim("(on Oalo, DATABASE_URL is your Base's own Postgres)")}`);
268
+ if (v.dropped.length) console.log(dim(` left out: ${v.dropped.map((d) => `${d.name} (${d.why})`).join(", ")}`));
269
+ if (v.missing.length) {
270
+ console.log(" still needed, values not available here:");
271
+ for (const x of v.missing) console.log(` ${x.name}: ${x.why}`);
272
+ }
273
+
274
+ printNeeds(plan);
275
+ printNext(plan, app, plan.platform, false);
276
+ if (!existsSync(join(dir, ".git"))) console.log(dim("\nThis folder isn't a git repo: oalo deploy --new sends commits, so run this in the app's repo."));
277
+ }
278
+
279
+ function printNeeds(plan) {
280
+ if (!plan.warnings.length) return;
281
+ console.log("\nNeeds you");
282
+ for (const w of plan.warnings) console.log(wrap(`- ${w}`, 2, 2));
283
+ }
284
+
285
+ function printNext(plan, app, platform, applied) {
286
+ console.log("\nNext");
287
+ const steps = [];
288
+ const files = plan.procfile.action === "write" || plan.manifest.action === "write" || plan.manifest.action === "merge";
289
+ const later = !app && plan.vars.set.length;
290
+ if (!applied) steps.push(`oalo migrate ${platform} --apply${later ? " (writes the files; the variables wait until the app exists)" : ""}`);
291
+ if (files) steps.push('git add Procfile .oalo/app.json && git commit -m "Move to Oalo"');
292
+ if (!app) steps.push("oalo deploy --new");
293
+ if (later) steps.push(`oalo migrate ${platform} --apply (now it sets the variables)`);
294
+ if (plan.vars.missing.length) steps.push("oalo env set NAME --secret --env prod (for each one still needed)");
295
+ steps.push("oalo env set NAME --env dev (test values for the preview)");
296
+ steps.push("your data: keep the database and set EXTERNAL_DATABASE_URL, or copy it in with oalo db restore");
297
+ steps.push("oalo deploy --env prod");
298
+ steps.push("move the domain: the app's Custom Domain panel in the dashboard");
299
+ steps.forEach((s, i) => console.log(` ${i + 1}. ${s}`));
300
+ console.log(dim(`\nThe whole walk-through: oalo docs migrating-from-${platform}`));
301
+ }
302
+
303
+ function wrap(text, indent, hang = indent) {
304
+ const width = Math.max(40, (process.stdout.columns || 100) - 2);
305
+ const words = text.split(" ");
306
+ const lines = [];
307
+ let line = " ".repeat(indent);
308
+ for (const w of words) {
309
+ if (line.trim() && line.length + 1 + w.length > width) {
310
+ lines.push(line);
311
+ line = " ".repeat(indent + hang) + w;
312
+ } else line += (line.trim() ? " " : "") + w;
313
+ }
314
+ if (line.trim()) lines.push(line);
315
+ return lines.join("\n");
316
+ }
317
+
318
+ function fail(message, code) {
319
+ console.error(message);
320
+ process.exitCode = code;
321
+ }
package/src/help.js CHANGED
@@ -245,18 +245,35 @@ export const COMMANDS = [
245
245
  },
246
246
  {
247
247
  name: "db",
248
- summary: "All of an env's data as plain SQL (every app's own tables and the Oalo models), to keep a copy or take it anywhere.",
249
- usage: ["oalo db dump [--env dev|prod] [--out <file.sql>] [--force]"],
248
+ summary: "All of an env's data as plain SQL (every app's own tables and the Oalo models), to keep a copy or take it anywhere. Or load an existing Postgres database into an env.",
249
+ usage: [
250
+ "oalo db dump [--env dev|prod] [--out <file.sql>] [--force]",
251
+ "oalo db restore <file> --env dev|prod [--schema <name>] [--check] [--yes]",
252
+ "oalo db restore --from-env <VAR> --env dev|prod [--schema <name>] [--check] [--yes]",
253
+ ],
250
254
  flags: [
251
- { flag: "--env dev|prod", desc: "Which env's data. Default prod." },
255
+ { flag: "--env dev|prod", desc: "dump: which env's data. Default prod. restore: which env the data goes into, always on the command line." },
252
256
  { flag: "--out <file.sql>", desc: "Where to write it. Default <workspace>-<env>-<date>.sql here." },
253
257
  { flag: "--force", desc: "Replace the file if it exists." },
258
+ { flag: "--from-env <VAR>", desc: "restore: copy from a running database instead of a file. VAR is the environment variable that holds its postgres:// or postgresql:// URL. Only its host is ever shown." },
259
+ { flag: "--schema <name>", desc: "restore: the source schema to load. Default public, or the only one when there's just one." },
260
+ { flag: "--check", desc: "restore: do everything but the write, and report what would happen. Nothing is written, so it never asks." },
261
+ { flag: "--yes", desc: "restore: go ahead with prod without asking. Needed for prod when there's no terminal to ask at." },
254
262
  ],
255
263
  notes: [
256
264
  "Workspace admins only: it holds every member's data. Restore it into any Postgres with psql -f.",
257
265
  "Your code is in git (oalo deploy --pull); this is the data. Together they are everything you need to run your apps elsewhere.",
266
+ "restore loads a dump file made by pg_dump (like Heroku's latest.dump), up to 10 GB, or copies from a database URL. Admins only, and one restore at a time per Base.",
267
+ "The tables land in the env's schema, owned by the app's database login, so its Procfile `release:` migrations can alter them. Your app reads them at DATABASE_URL.",
268
+ "It's refused if a table name already exists there, and the names are listed. A failed restore leaves the env as it was.",
269
+ "The URL is never a flag value, since that stays in your shell history. Put it in a variable and pass --from-env VAR.",
270
+ "Run it with --check first. prod asks before it writes; without a terminal it needs --yes. Stop writes to the old database before the copy: writes made after it starts are lost.",
258
271
  ],
259
- exits: { 0: "a whole dump was written", 1: "refused, unreachable, or cut off (the partial file is removed)", 2: "bad arguments, the file exists, or no CLI token" },
272
+ exits: {
273
+ 0: "a whole dump was written, or the restore (or check) passed",
274
+ 1: "refused, unreachable, cut off (a partial dump is removed), or the restore failed or was declined",
275
+ 2: "bad arguments, the file exists or is missing, or no CLI token",
276
+ },
260
277
  },
261
278
  {
262
279
  name: "metrics",
@@ -560,6 +577,29 @@ export const COMMANDS = [
560
577
  ],
561
578
  exits: { 0: "deployed", 1: "refused or failed", 2: "bad arguments, no CLI token, or not in a git repo" },
562
579
  },
580
+ {
581
+ name: "migrate",
582
+ summary: "Move an app from Heroku, Railway, Render, Fly.io or Primcloud: its Procfile, sizes, health path and variables, worked out from its files and that platform's CLI.",
583
+ usage: [
584
+ "oalo migrate <heroku|railway|render|fly|primcloud> [--from-app <name>] [--env-file <file>] [--json]",
585
+ "oalo migrate <platform> --apply [--app <slug>]",
586
+ ],
587
+ flags: [
588
+ { flag: "--from-app <name>", desc: "The app or service on the old platform (a Heroku or Fly app, a Railway or Render service). Default: what that platform's CLI is linked to here, or fly.toml's app." },
589
+ { flag: "--env-file <file>", desc: "More variables, from a .env file (NAME=value lines). They win over what the platform's CLI reported. For Primcloud, and for Fly secrets, which can't be read back." },
590
+ { flag: "--apply", desc: "Write the Procfile and .oalo/app.json (never over an existing Procfile; app.json only gains keys it lacks), and set the variables on the app's prod env. Without it, nothing changes." },
591
+ { flag: "--no-cli", desc: "Don't run the old platform's CLI; read only the files here and --env-file." },
592
+ { flag: "--app <slug>", desc: "The Oalo app the variables go to. Default: the app this folder is linked to (oalo deploy --new links it)." },
593
+ { flag: "--json", desc: "The plan as JSON: names only, never a value." },
594
+ ],
595
+ notes: [
596
+ "Run it in the app's repo. It reads fly.toml, railway.json or railway.toml, render.yaml, app.json and the Procfile, and asks the platform's CLI for variables (heroku, railway), secret names (fly) and dyno sizes (heroku) when it's installed and signed in.",
597
+ "Variables: PORT and each platform's own (HEROKU_*, RAILWAY_*, RENDER_*, FLY_*) are left out, and DATABASE_URL becomes EXTERNAL_DATABASE_URL, since DATABASE_URL on Oalo is the Base's own Postgres. Values are never printed or written to a file; --apply sends them to prod without restarting the app.",
598
+ "It says what doesn't carry over: cron jobs, volumes and disks, private addresses (*.railway.internal, *.flycast), extra replicas, build commands, an Aptfile, a VM bigger than large.",
599
+ "The order: oalo migrate <platform> (read the plan), --apply, commit, oalo deploy --new, oalo migrate <platform> --apply again (now the app exists, it sets the variables), then the data (oalo db restore) and oalo deploy --env prod. `oalo docs migrating-from-<platform>` walks through it.",
600
+ ],
601
+ exits: { 0: "the plan was shown, or applied", 1: "a config file couldn't be read, or the variables couldn't be set", 2: "bad arguments, an unknown platform, or no CLI token" },
602
+ },
563
603
  {
564
604
  name: "env",
565
605
  summary: "An app's environment variables on Oalo (API keys, settings), per env: list, set, unset, rotate-token.",
package/src/index.js CHANGED
@@ -32,6 +32,7 @@ import { channels } from "./commands/channels.js";
32
32
  import { products } from "./commands/products.js";
33
33
  import { metrics, dashboards } from "./commands/metrics.js";
34
34
  import { db } from "./commands/db.js";
35
+ import { migrate } from "./commands/migrate.js";
35
36
  import { overview, commandHelp, helpJson, COMMANDS } from "./help.js";
36
37
  import { parse } from "./args.js";
37
38
 
@@ -169,6 +170,9 @@ async function dispatch(cmd) {
169
170
  case "db":
170
171
  await db(flags, rest);
171
172
  break;
173
+ case "migrate":
174
+ await migrate(flags, rest);
175
+ break;
172
176
  case "secrets":
173
177
  await secrets(flags, rest);
174
178
  break;