@oalo-ai/cli 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +392 -2
- package/man/oalo.1 +1530 -0
- package/package.json +44 -4
- package/plugin/.claude-plugin/plugin.json +12 -0
- package/plugin/skills/oalo/SKILL.md +414 -0
- package/src/api.js +132 -0
- package/src/app-ref.js +11 -0
- package/src/args.js +67 -0
- package/src/bot-settings.json +468 -0
- package/src/channel.js +162 -0
- package/src/commands/apps.js +64 -0
- package/src/commands/bots.js +278 -0
- package/src/commands/call.js +179 -0
- package/src/commands/channels.js +110 -0
- package/src/commands/data.js +446 -0
- package/src/commands/db.js +58 -0
- package/src/commands/deploy.js +431 -0
- package/src/commands/dockerfile.js +81 -0
- package/src/commands/docs.js +70 -0
- package/src/commands/emit.js +124 -0
- package/src/commands/env.js +223 -0
- package/src/commands/graphs.js +570 -0
- package/src/commands/incidents.js +44 -0
- package/src/commands/link.js +150 -0
- package/src/commands/login.js +180 -0
- package/src/commands/logs.js +69 -0
- package/src/commands/mcp.js +125 -0
- package/src/commands/metrics.js +168 -0
- package/src/commands/products.js +169 -0
- package/src/commands/qa.js +98 -0
- package/src/commands/rehearse.js +100 -0
- package/src/commands/run.js +152 -0
- package/src/commands/secrets.js +68 -0
- package/src/commands/setup.js +96 -0
- package/src/commands/skill.js +142 -0
- package/src/commands/sources.js +377 -0
- package/src/commands/switches.js +156 -0
- package/src/commands/tail.js +84 -0
- package/src/commands/tokens.js +85 -0
- package/src/commands/typegen.js +505 -0
- package/src/commands/whoami.js +112 -0
- package/src/config.js +157 -0
- package/src/data.js +392 -0
- package/src/envfile.js +71 -0
- package/src/events.js +63 -0
- package/src/graphfiles.js +273 -0
- package/src/help.js +920 -0
- package/src/index.js +216 -0
- package/src/mcp/tools.js +773 -0
- package/src/server.js +73 -0
- package/src/workspace.js +44 -0
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { mkdtempSync, rmSync, statSync, createReadStream, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { Readable } from "node:stream";
|
|
6
|
+
import { resolve as resolveConfig, controlPlane, HOW_TO_CONNECT, readConfig, writeConfig } from "../config.js";
|
|
7
|
+
import { requireFeature } from "../server.js";
|
|
8
|
+
import { targetEnv } from "../data.js";
|
|
9
|
+
import { dim } from "../events.js";
|
|
10
|
+
import { readValue } from "./env.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `oalo deploy`: send this repo's commits to an app on the workspace and run them.
|
|
14
|
+
*
|
|
15
|
+
* oalo deploy [--app <slug>] to the preview (dev)
|
|
16
|
+
* oalo deploy --new [<name>] a new app from this repo, its history kept
|
|
17
|
+
* oalo deploy --new [<name>] --from <url> a new app from a Git URL, cloned by the Base
|
|
18
|
+
* oalo deploy --app <slug> --env prod build and swap the live release
|
|
19
|
+
* oalo deploy --app <slug> --pull fetch the app's code from Oalo as oalo/<slug>
|
|
20
|
+
* oalo deploy --app <slug> --replace make this branch the app's code (old code is tagged)
|
|
21
|
+
* oalo deploy --app <slug> --history the live releases it has had
|
|
22
|
+
* oalo deploy --app <slug> --rollback [--to <sha>] make an earlier release live again
|
|
23
|
+
*
|
|
24
|
+
* What is sent is the current branch's commits, as a git bundle, signed in with
|
|
25
|
+
* the CLI token: no git remote or Forgejo credential is needed. Uncommitted
|
|
26
|
+
* changes are not deployed. The box only fast-forwards, so if the app has
|
|
27
|
+
* commits this repo lacks (the builder agent changed it), the deploy stops and
|
|
28
|
+
* says to `--pull` and merge first.
|
|
29
|
+
*/
|
|
30
|
+
export async function deploy(flags) {
|
|
31
|
+
const cfg = resolveConfig(flags);
|
|
32
|
+
const { url, token } = controlPlane(cfg);
|
|
33
|
+
if (!url || !token) return fail(`oalo deploy needs a CLI token.\n\n${HOW_TO_CONNECT}`, 2);
|
|
34
|
+
|
|
35
|
+
// --new [<name>] --from <url>: the code comes from a Git URL, not this folder.
|
|
36
|
+
if (flags.from !== undefined) return fromGit(url, token, flags);
|
|
37
|
+
|
|
38
|
+
// --new [<name>]: a new app from this repo (its slug from the name, or the
|
|
39
|
+
// repo's folder).
|
|
40
|
+
const creating = flags.new !== undefined;
|
|
41
|
+
let app = typeof flags.app === "string" ? flags.app : cfg.appId;
|
|
42
|
+
if (creating) {
|
|
43
|
+
let top;
|
|
44
|
+
try {
|
|
45
|
+
top = git(["rev-parse", "--show-toplevel"]);
|
|
46
|
+
} catch {
|
|
47
|
+
return fail("not inside a git repo. oalo deploy --new makes an app from this repo's commits.", 2);
|
|
48
|
+
}
|
|
49
|
+
app = slugify(typeof flags.new === "string" ? flags.new : top.split("/").pop());
|
|
50
|
+
if (!app) return fail("give the new app a name: oalo deploy --new <name>", 2);
|
|
51
|
+
}
|
|
52
|
+
if (!app) return fail("which app? Pass --app <slug> (`oalo apps` lists them), save it with `oalo link --app <slug>`, or make one from this repo with `oalo deploy --new`.", 2);
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
await requireFeature(url, "deploy", "oalo deploy");
|
|
56
|
+
} catch (e) {
|
|
57
|
+
return fail(e.message, 1);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (flags.history || flags.rollback) return releases(url, token, app, flags);
|
|
61
|
+
|
|
62
|
+
let root;
|
|
63
|
+
try {
|
|
64
|
+
root = git(["rev-parse", "--show-toplevel"]);
|
|
65
|
+
} catch {
|
|
66
|
+
return fail(
|
|
67
|
+
flags.pull
|
|
68
|
+
? `not inside a git repo. --pull fetches ${app}'s code into one: \`git init\` here (or cd into the app's repo), then run it again.`
|
|
69
|
+
: "not inside a git repo. oalo deploy sends this repo's commits.",
|
|
70
|
+
2,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (flags.pull) return pull(url, token, app, root);
|
|
75
|
+
|
|
76
|
+
let env;
|
|
77
|
+
try {
|
|
78
|
+
env = targetEnv(flags, cfg);
|
|
79
|
+
} catch (e) {
|
|
80
|
+
return fail(e.message, 2);
|
|
81
|
+
}
|
|
82
|
+
if (creating && env !== "dev") return fail("a new app starts on its preview: run `oalo deploy --new` without --env prod, then deploy it live.", 2);
|
|
83
|
+
|
|
84
|
+
const dirty = git(["status", "--porcelain", "--untracked-files=no"], root);
|
|
85
|
+
if (dirty && !flags["allow-dirty"]) {
|
|
86
|
+
return fail("you have uncommitted changes, and a deploy sends commits. Commit them (or stash), or pass --allow-dirty to deploy the last commit anyway.", 2);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Files that decide how the app runs, written but never committed: the
|
|
90
|
+
// deploy sends commits only, so they'd be silently left out (audit R4).
|
|
91
|
+
const untracked = untrackedRunFiles(root);
|
|
92
|
+
if (untracked.length) {
|
|
93
|
+
console.error(`not committed, so this deploy leaves them out: ${untracked.join(", ")}`);
|
|
94
|
+
console.error(dim(` git add ${untracked.join(" ")} && git commit, then deploy again (they decide how the app installs and starts).`));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const sha = git(["rev-parse", "HEAD"], root);
|
|
98
|
+
const branch = git(["rev-parse", "--abbrev-ref", "HEAD"], root);
|
|
99
|
+
const tracked = `refs/remotes/oalo/${app}`;
|
|
100
|
+
const base = !creating && refExists(tracked, root) ? git(["rev-parse", tracked], root) : null;
|
|
101
|
+
// Send only commits the app lacks. When it already runs this commit (say, dev
|
|
102
|
+
// before prod), one step back is enough: the box has that too.
|
|
103
|
+
const parent = refExists(`${sha}~1`, root) ? git(["rev-parse", `${sha}~1`], root) : null;
|
|
104
|
+
const incremental = base && isAncestor(base, sha, root) ? (base === sha ? parent : base) : null;
|
|
105
|
+
|
|
106
|
+
console.error(dim(creating ? `creating ${app} from ${branch} at ${sha.slice(0, 8)} on ${url}` : `deploying ${branch} at ${sha.slice(0, 8)} to ${app} (${env}) on ${url}`));
|
|
107
|
+
if (env === "prod") console.error(dim("live visitors get this release when the build finishes"));
|
|
108
|
+
|
|
109
|
+
const create = creating ? { name: typeof flags.name === "string" ? flags.name : app } : null;
|
|
110
|
+
let result = await send({ url, token, app, env, sha, root, base: incremental, replace: !!flags.replace, create });
|
|
111
|
+
if (result?.error?.code === "needs_full_bundle" && incremental) {
|
|
112
|
+
console.error(dim("the app doesn't have the commits this builds on; sending the full history"));
|
|
113
|
+
result = await send({ url, token, app, env, sha, root, base: null, replace: !!flags.replace });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!result) return fail("the connection closed before the deploy finished. Check `oalo apps` for the app's state, then try again.", 1);
|
|
117
|
+
|
|
118
|
+
if (result.error) {
|
|
119
|
+
const e = result.error;
|
|
120
|
+
console.error(`deploy failed: ${e.message}`);
|
|
121
|
+
for (const c of e.box_only || []) console.error(dim(` on Oalo only: ${c}`));
|
|
122
|
+
process.exitCode = 1;
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// A new app: this project now deploys to it (as `oalo link --app` saves).
|
|
127
|
+
if (result.created) {
|
|
128
|
+
writeConfig({ ...(readConfig(root) || {}), serverUrl: url, appId: result.app, env: "dev" }, root);
|
|
129
|
+
if (result.app_type === "code_only") {
|
|
130
|
+
// The server's progress line already said what to add.
|
|
131
|
+
console.log(`created ${result.app} from ${result.commit.slice(0, 8)}, its history kept. Nothing runs yet (see above).`);
|
|
132
|
+
console.log(dim("After adding what it needs: commit, then `oalo deploy`. `oalo docs bring-your-repo` has the details."));
|
|
133
|
+
} else {
|
|
134
|
+
console.log(`created ${result.app} (${result.app_type}) from ${result.commit.slice(0, 8)}, its history kept: ${result.url}`);
|
|
135
|
+
console.log(dim("Its preview is being set up (`oalo logs` shows it). Next deploys: `oalo deploy`; live: `oalo deploy --env prod`."));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Remember what the app now runs, so the next deploy sends only new commits.
|
|
140
|
+
git(["update-ref", `refs/remotes/oalo/${result.app || app}`, result.commit], root);
|
|
141
|
+
if (result.created) return flags.json ? void console.log(JSON.stringify(result, null, 2)) : undefined;
|
|
142
|
+
// The code is saved either way; a preview that doesn't run is still a failed
|
|
143
|
+
// deploy for whoever checks the exit code.
|
|
144
|
+
const previewFailed = env === "dev" && result.preview === "failed";
|
|
145
|
+
if (previewFailed) process.exitCode = 1;
|
|
146
|
+
if (flags.json) return void console.log(JSON.stringify(result, null, 2));
|
|
147
|
+
if (result.code_only) {
|
|
148
|
+
console.log(`saved ${result.commit.slice(0, 8)} to ${app}, but nothing runs yet: Oalo still can't tell what kind of app it is (see above).`);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
// A live deploy of the commit that's already live still builds and swaps it
|
|
152
|
+
// (a relaunch, e.g. for changed prod variables); a dev one restarts the preview.
|
|
153
|
+
const short = result.commit.slice(0, 8);
|
|
154
|
+
console.log(
|
|
155
|
+
result.relation !== "up_to_date"
|
|
156
|
+
? `deployed ${sha} to ${app} (${env}): ${result.url}`
|
|
157
|
+
: env === "prod"
|
|
158
|
+
? `relaunched ${app} at ${sha}, already its live commit (prod): ${result.url}`
|
|
159
|
+
: `no new commits; restarted ${app}'s preview at ${sha} (dev): ${result.url}`,
|
|
160
|
+
);
|
|
161
|
+
if (previewFailed) {
|
|
162
|
+
console.error(`the preview isn't running: ${result.preview_error || "it didn't start"}. Your code is saved; fix it and deploy again. \`oalo logs --app ${app}\` shows why.`);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (result.install === "failed") console.log(dim("The install failed, so the preview may be missing packages (see above)."));
|
|
166
|
+
if (env === "dev") console.log(dim(`When the preview looks right: oalo deploy --app ${app} --env prod`));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* `--new --from <url>`: the Base clones the repository (https to a public
|
|
171
|
+
* host, one branch with its whole history) and makes the app exactly as
|
|
172
|
+
* `--new` does from a local repo. A private repo's access token comes from an
|
|
173
|
+
* environment variable (`--token-env VAR`) or, at a terminal, a hidden prompt
|
|
174
|
+
* when the host asks for one: never a flag value, which would stay in shell
|
|
175
|
+
* history. It is sent once, over HTTPS, for that clone only.
|
|
176
|
+
*/
|
|
177
|
+
async function fromGit(url, token, flags, opts = {}) {
|
|
178
|
+
const from = typeof flags.from === "string" ? flags.from.trim() : "";
|
|
179
|
+
if (flags.new === undefined) return fail("--from makes a new app: oalo deploy --new [<name>] --from <url>. To update an app, deploy from a clone of its repo.", 2);
|
|
180
|
+
if (!from) return fail("--from needs the repository's https address: oalo deploy --new --from https://github.com/you/repo", 2);
|
|
181
|
+
if (!/^https:\/\//i.test(from)) return fail("--from takes an https:// address, as the repository's Clone button shows it. ssh and git@ addresses can't be used.", 2);
|
|
182
|
+
if (flags.env !== undefined && flags.env !== "dev") return fail("a new app starts on its preview: run it without --env prod, then deploy it live.", 2);
|
|
183
|
+
const branch = typeof flags.branch === "string" ? flags.branch : undefined;
|
|
184
|
+
if (flags.branch !== undefined && !branch) return fail("--branch needs a branch name", 2);
|
|
185
|
+
|
|
186
|
+
let gitToken;
|
|
187
|
+
if (flags["token-env"] !== undefined) {
|
|
188
|
+
const name = typeof flags["token-env"] === "string" ? flags["token-env"] : "";
|
|
189
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) return fail("--token-env takes the name of the environment variable that holds the token, like --token-env GITHUB_TOKEN", 2);
|
|
190
|
+
gitToken = (opts.env || process.env)[name];
|
|
191
|
+
if (!gitToken) return fail(`${name} is empty or not set. Put the access token in it, then run this again.`, 2);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const app = slugify(typeof flags.new === "string" ? flags.new : repoName(from));
|
|
195
|
+
if (!app) return fail("give the new app a name: oalo deploy --new <name> --from <url>", 2);
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
await requireFeature(url, "deploy_from_git", "oalo deploy --from");
|
|
199
|
+
} catch (e) {
|
|
200
|
+
return fail(e.message, 1);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const name = typeof flags.name === "string" ? flags.name : app;
|
|
204
|
+
console.error(dim(`creating ${app} from ${from}${branch ? ` (branch ${branch})` : ""} on ${url}: the Base clones it, then sets up its preview`));
|
|
205
|
+
let result = await importGit({ url, token, app, body: { url: from, name, ...(branch ? { branch } : {}), ...(gitToken ? { token: gitToken } : {}) } });
|
|
206
|
+
|
|
207
|
+
// A private repo and no token yet: ask for one, hidden, at a terminal.
|
|
208
|
+
const ask = opts.readToken || (process.stdin.isTTY ? () => readValue("access token") : null);
|
|
209
|
+
if (result?.error?.code === "needs_token" && !gitToken && ask) {
|
|
210
|
+
console.error(result.error.message);
|
|
211
|
+
try {
|
|
212
|
+
gitToken = (await ask()).trim();
|
|
213
|
+
} catch {
|
|
214
|
+
return fail("cancelled", 1);
|
|
215
|
+
}
|
|
216
|
+
if (gitToken) result = await importGit({ url, token, app, body: { url: from, name, ...(branch ? { branch } : {}), token: gitToken } });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (!result) return fail("the connection closed before the import finished. `oalo apps` shows whether the app was made; the Base finishes an import it started.", 1);
|
|
220
|
+
if (result.error) {
|
|
221
|
+
console.error(`import failed: ${result.error.message}`);
|
|
222
|
+
if (result.error.code === "needs_token") console.error(dim("For a private repository, put a token that can read it in an environment variable and pass --token-env VAR."));
|
|
223
|
+
process.exitCode = 1;
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (flags.json) return void console.log(JSON.stringify(result, null, 2));
|
|
228
|
+
if (result.app_type === "code_only") {
|
|
229
|
+
console.log(`created ${result.app} from ${result.commit.slice(0, 8)}, its history kept. Nothing runs yet (see above).`);
|
|
230
|
+
} else {
|
|
231
|
+
console.log(`created ${result.app} (${result.app_type}) from ${result.commit.slice(0, 8)}, its history kept: ${result.url}`);
|
|
232
|
+
console.log(dim("Its preview is being set up (`oalo logs` shows it)."));
|
|
233
|
+
}
|
|
234
|
+
console.log(dim(`To work on it here: clone the repository, run \`oalo link --app ${result.app}\` in it, then \`oalo deploy\` (the app has the same commits, so it fast-forwards).`));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export const _fromGit = fromGit;
|
|
238
|
+
|
|
239
|
+
/** The repository's name from its address: https://github.com/acme/shop.git -> shop. */
|
|
240
|
+
export function repoName(address) {
|
|
241
|
+
try {
|
|
242
|
+
return new URL(address).pathname.replace(/\/+$/, "").split("/").pop().replace(/\.git$/, "");
|
|
243
|
+
} catch {
|
|
244
|
+
return "";
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function importGit({ url, token, app, body }) {
|
|
249
|
+
const res = await fetch(`${url}/api/cli/v1/apps/${encodeURIComponent(app)}/import`, {
|
|
250
|
+
method: "POST",
|
|
251
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
252
|
+
body: JSON.stringify(body),
|
|
253
|
+
});
|
|
254
|
+
if (!res.ok && !(res.headers.get("content-type") || "").includes("ndjson")) {
|
|
255
|
+
const reply = await res.json().catch(() => ({}));
|
|
256
|
+
return { error: { code: String(res.status), message: reply?.error?.message || `HTTP ${res.status}` } };
|
|
257
|
+
}
|
|
258
|
+
return await follow(res);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function send({ url, token, app, env, sha, root, base, replace, create }) {
|
|
262
|
+
const dir = mkdtempSync(join(tmpdir(), "oalo-deploy-"));
|
|
263
|
+
const bundle = join(dir, "app.bundle");
|
|
264
|
+
try {
|
|
265
|
+
git(["bundle", "create", "--quiet", bundle, base ? `${base}..HEAD` : "HEAD"], root);
|
|
266
|
+
const size = statSync(bundle).size;
|
|
267
|
+
console.error(dim(`sending ${base ? "new commits" : "the full history"} (${(size / 1024).toFixed(0)} KB)`));
|
|
268
|
+
|
|
269
|
+
const q = new URLSearchParams({ env, sha, ...(replace ? { replace: "true" } : {}), ...(create ? { create: "true", name: create.name } : {}) });
|
|
270
|
+
const res = await fetch(`${url}/api/cli/v1/apps/${encodeURIComponent(app)}/deploy?${q}`, {
|
|
271
|
+
method: "POST",
|
|
272
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/octet-stream", "Content-Length": String(size) },
|
|
273
|
+
body: Readable.toWeb(createReadStream(bundle)),
|
|
274
|
+
duplex: "half",
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
if (!res.ok && !(res.headers.get("content-type") || "").includes("ndjson")) {
|
|
278
|
+
const body = await res.json().catch(() => ({}));
|
|
279
|
+
return { error: { code: String(res.status), message: body?.error?.message || `HTTP ${res.status}` } };
|
|
280
|
+
}
|
|
281
|
+
return await follow(res);
|
|
282
|
+
} finally {
|
|
283
|
+
rmSync(dir, { recursive: true, force: true });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Print each progress line; return the last line (done or error). */
|
|
288
|
+
async function follow(res) {
|
|
289
|
+
const decoder = new TextDecoder();
|
|
290
|
+
let buf = "";
|
|
291
|
+
let last = null;
|
|
292
|
+
for await (const part of res.body) {
|
|
293
|
+
buf += decoder.decode(part, { stream: true });
|
|
294
|
+
let i;
|
|
295
|
+
while ((i = buf.indexOf("\n")) >= 0) {
|
|
296
|
+
const raw = buf.slice(0, i).trim();
|
|
297
|
+
buf = buf.slice(i + 1);
|
|
298
|
+
if (!raw) continue;
|
|
299
|
+
let msg;
|
|
300
|
+
try {
|
|
301
|
+
msg = JSON.parse(raw);
|
|
302
|
+
} catch {
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if (msg.done || msg.error) last = msg;
|
|
306
|
+
else if (msg.phase === "waiting") process.stderr.write(dim("."));
|
|
307
|
+
else if (msg.message) console.error(dim(` ${msg.phase}: ${msg.message}`));
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return last;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function pull(url, token, app, root) {
|
|
314
|
+
const res = await fetch(`${url}/api/cli/v1/apps/${encodeURIComponent(app)}/bundle`, { headers: { Authorization: `Bearer ${token}` } });
|
|
315
|
+
if (!res.ok) {
|
|
316
|
+
const body = await res.json().catch(() => ({}));
|
|
317
|
+
return fail(`could not fetch ${app}: ${body?.error?.message || `HTTP ${res.status}`}`, 1);
|
|
318
|
+
}
|
|
319
|
+
const dir = mkdtempSync(join(tmpdir(), "oalo-pull-"));
|
|
320
|
+
try {
|
|
321
|
+
const file = join(dir, "app.bundle");
|
|
322
|
+
writeFileSync(file, Buffer.from(await res.arrayBuffer()));
|
|
323
|
+
const ref = `refs/remotes/oalo/${app}`;
|
|
324
|
+
git(["fetch", "--quiet", "--no-tags", file, `+HEAD:${ref}`], root);
|
|
325
|
+
const sha = git(["rev-parse", ref], root);
|
|
326
|
+
console.log(`fetched ${app} as it is on Oalo (${sha.slice(0, 8)}) into oalo/${app}.`);
|
|
327
|
+
console.log(dim(`Merge it, then deploy again:\n git merge oalo/${app}\n oalo deploy --app ${app}`));
|
|
328
|
+
} finally {
|
|
329
|
+
rmSync(dir, { recursive: true, force: true });
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// The files at the repo's root (and in .oalo/) that decide how an app builds,
|
|
334
|
+
// installs and starts.
|
|
335
|
+
const RUN_FILES = new Set([
|
|
336
|
+
"Procfile", "Dockerfile", ".dockerignore", ".oalo/app.json",
|
|
337
|
+
"package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "bun.lock", "bun.lockb",
|
|
338
|
+
"requirements.txt", "pyproject.toml", "poetry.lock", "uv.lock", "Pipfile.lock",
|
|
339
|
+
"Gemfile", "Gemfile.lock", "go.mod", "go.sum", "Cargo.toml", "Cargo.lock", "composer.json", "composer.lock",
|
|
340
|
+
"mix.exs", "mix.lock", "pubspec.yaml", "pubspec.lock", "deno.json", "deno.jsonc",
|
|
341
|
+
]);
|
|
342
|
+
|
|
343
|
+
export function untrackedRunFiles(root) {
|
|
344
|
+
let out = "";
|
|
345
|
+
try {
|
|
346
|
+
out = git(["ls-files", "--others", "--exclude-standard"], root);
|
|
347
|
+
} catch {
|
|
348
|
+
return [];
|
|
349
|
+
}
|
|
350
|
+
return out.split("\n").filter((f) => RUN_FILES.has(f));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function git(args, cwd = process.cwd()) {
|
|
354
|
+
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function refExists(ref, cwd) {
|
|
358
|
+
try {
|
|
359
|
+
git(["rev-parse", "--verify", "--quiet", ref], cwd);
|
|
360
|
+
return true;
|
|
361
|
+
} catch {
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function isAncestor(a, b, cwd) {
|
|
367
|
+
try {
|
|
368
|
+
git(["merge-base", "--is-ancestor", a, b], cwd);
|
|
369
|
+
return true;
|
|
370
|
+
} catch {
|
|
371
|
+
return false;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function fail(message, code) {
|
|
376
|
+
console.error(message);
|
|
377
|
+
process.exitCode = code;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* `--history` lists the live releases; `--rollback` launches the one before the
|
|
382
|
+
* current release (or `--to <sha>`). A rollback builds and checks that commit
|
|
383
|
+
* like any live deploy, so a failing one leaves the current release live. It
|
|
384
|
+
* doesn't touch the app's code on Oalo: the next deploy carries on from there.
|
|
385
|
+
*/
|
|
386
|
+
async function releases(url, token, app, flags, opts = {}) {
|
|
387
|
+
const { OaloChannel } = await import("../channel.js");
|
|
388
|
+
const ch = (opts.channel || ((o) => new OaloChannel(o)))({ url, token });
|
|
389
|
+
try {
|
|
390
|
+
await ch.connect();
|
|
391
|
+
if (flags.history) {
|
|
392
|
+
const r = await ch.call("release:history", { app });
|
|
393
|
+
if (flags.json) return void console.log(JSON.stringify(r, null, 2));
|
|
394
|
+
if (!r.releases?.length) return void console.log(`${r.app} has never been live. \`oalo deploy --env prod\` launches it.`);
|
|
395
|
+
r.releases.forEach((x, i) => console.log(`${x.commit_hash.slice(0, 8)} ${dim(`${String(x.launched_at || "").slice(0, 16).replace("T", " ")}${i === 0 ? ", live now" : ""}`)}`));
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
console.error(dim(`rolling ${app} back${typeof flags.to === "string" ? ` to ${flags.to}` : " to the release before this one"}: building it, then swapping when it answers`));
|
|
399
|
+
// The Base pushes the build's progress while it runs (a newer Base does).
|
|
400
|
+
const off = ch.onEvent?.((event, p) => {
|
|
401
|
+
if (event === "rollback_progress" && p?.message) console.error(dim(` ${p.phase}: ${p.message}`));
|
|
402
|
+
});
|
|
403
|
+
let r;
|
|
404
|
+
try {
|
|
405
|
+
r = await ch.call("release:rollback", { app, ...(typeof flags.to === "string" ? { to: flags.to } : {}) }, ROLLBACK_WAIT_MS);
|
|
406
|
+
} finally {
|
|
407
|
+
off?.();
|
|
408
|
+
}
|
|
409
|
+
if (flags.json) return void console.log(JSON.stringify(r, null, 2));
|
|
410
|
+
console.log(`${r.app} is live at ${r.commit.slice(0, 8)}: ${r.url}`);
|
|
411
|
+
} catch (e) {
|
|
412
|
+
if (/timed out/.test(e.message)) {
|
|
413
|
+
return fail(`stopped waiting after ${ROLLBACK_WAIT_MS / 60_000} minutes, but the rollback may still be running on the Base. ` +
|
|
414
|
+
`\`oalo deploy --app ${app} --history\` shows which release is live; don't start another rollback until it changes or \`oalo logs --app ${app} --env prod\` shows it failed.`, 1);
|
|
415
|
+
}
|
|
416
|
+
fail(`rollback failed: ${e.message}`, 1);
|
|
417
|
+
} finally {
|
|
418
|
+
ch.close();
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export const _releases = releases;
|
|
423
|
+
|
|
424
|
+
// A rollback builds like a live deploy: CodeBuild allows 30 minutes, then the
|
|
425
|
+
// new release has a minute to answer. Wait longer than that.
|
|
426
|
+
export const ROLLBACK_WAIT_MS = 35 * 60_000;
|
|
427
|
+
|
|
428
|
+
/** An app slug from a name: lowercase letters, digits and dashes. */
|
|
429
|
+
export function slugify(name) {
|
|
430
|
+
return String(name || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
|
|
431
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { existsSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { execFileSync } 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
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `oalo dockerfile`: how a server app's live image is built, and the
|
|
11
|
+
* Dockerfile it builds from.
|
|
12
|
+
*
|
|
13
|
+
* oalo dockerfile [--app <slug>] print it
|
|
14
|
+
* oalo dockerfile --write [--force] [--app <slug>] save Oalo's at the repo's root
|
|
15
|
+
*
|
|
16
|
+
* A server app builds from the repo's own Dockerfile; with none, from Oalo's
|
|
17
|
+
* for its runtime (Bun, Elixir, Rust, Deno, Dart); otherwise with buildpacks (Node,
|
|
18
|
+
* Python, Ruby, Go, Java, PHP, .NET). `--write` puts Oalo's in the repo, to
|
|
19
|
+
* change: once committed, the repo's copy is used.
|
|
20
|
+
*/
|
|
21
|
+
export async function dockerfile(flags, rest, opts = {}) {
|
|
22
|
+
const cfg = resolveConfig(flags);
|
|
23
|
+
const { url, token } = controlPlane(cfg);
|
|
24
|
+
if (!url || !token) return fail(`oalo dockerfile needs a CLI token.\n\n${HOW_TO_CONNECT}`, 2);
|
|
25
|
+
|
|
26
|
+
const app = typeof flags.app === "string" ? flags.app : cfg.appId;
|
|
27
|
+
if (!app) return fail("which app? Pass --app <slug> (`oalo apps` lists them), or save it with `oalo link --app <slug>`.", 2);
|
|
28
|
+
|
|
29
|
+
if (!opts.channel) {
|
|
30
|
+
try {
|
|
31
|
+
await requireFeature(url, "dockerfile", "oalo dockerfile");
|
|
32
|
+
} catch (e) {
|
|
33
|
+
return fail(e.message, 1);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const ch = (opts.channel || ((o) => new OaloChannel(o)))({ url, token });
|
|
38
|
+
let r;
|
|
39
|
+
try {
|
|
40
|
+
await ch.connect();
|
|
41
|
+
r = await ch.call("cli_api:dockerfile", { app });
|
|
42
|
+
} catch (e) {
|
|
43
|
+
return fail(e.message, 1);
|
|
44
|
+
} finally {
|
|
45
|
+
ch.close();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (flags.json) return void console.log(JSON.stringify(r, null, 2));
|
|
49
|
+
|
|
50
|
+
if (r.method === "buildpacks") {
|
|
51
|
+
console.log(`${r.app} builds with buildpacks: no Dockerfile needed (Node, Python, Ruby, Go, Java, PHP or .NET, found from the repo).`);
|
|
52
|
+
console.log(dim("To build it your own way, commit a Dockerfile at the repo's root; it's used from the next deploy."));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!flags.write) {
|
|
57
|
+
const from = r.source === "repo" ? "the repo's Dockerfile" : `Oalo's ${r.runtime} Dockerfile (the repo has none)`;
|
|
58
|
+
console.error(dim(`${r.app} builds from ${from}:\n`));
|
|
59
|
+
process.stdout.write(r.dockerfile);
|
|
60
|
+
if (r.source === "oalo") console.error(dim("\n`oalo dockerfile --write` saves it at the repo's root to change; once committed, the repo's copy is used."));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (r.source === "repo") return fail(`${r.app} already builds from its own Dockerfile.`, 1);
|
|
65
|
+
// At the repo's root, wherever in it this runs: that's where a build looks.
|
|
66
|
+
let root = ".";
|
|
67
|
+
try {
|
|
68
|
+
root = execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
69
|
+
} catch {
|
|
70
|
+
/* not a git repo: the current folder */
|
|
71
|
+
}
|
|
72
|
+
const path = opts.path || join(root, "Dockerfile");
|
|
73
|
+
if (existsSync(path) && !flags.force) return fail(`${path} exists. Pass --force to replace it.`, 1);
|
|
74
|
+
writeFileSync(path, r.dockerfile);
|
|
75
|
+
console.log(`wrote ${path} (Oalo's ${r.runtime} Dockerfile). Commit it, and the next deploy builds from it.`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function fail(message, code) {
|
|
79
|
+
console.error(message);
|
|
80
|
+
process.exitCode = code;
|
|
81
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { OaloChannel } from "../channel.js";
|
|
2
|
+
import { resolve as resolveConfig, controlPlane, HOW_TO_CONNECT } from "../config.js";
|
|
3
|
+
import { requireFeature } from "../server.js";
|
|
4
|
+
import { dim } from "../events.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `oalo docs`: the platform knowledge pages, from the workspace's own box, so
|
|
8
|
+
* they match what it runs.
|
|
9
|
+
*
|
|
10
|
+
* oalo docs every page: slug and one-line summary
|
|
11
|
+
* oalo docs <slug> one page, as markdown on stdout
|
|
12
|
+
* oalo docs --search "<words>" pages ranked for the words, with a snippet
|
|
13
|
+
* oalo docs --audience developer only pages for developers (or trainer)
|
|
14
|
+
*
|
|
15
|
+
* Pages are written for people and agents alike. `oalo docs sync-rest-api >
|
|
16
|
+
* notes.md` saves one; `--json` gives the structured form.
|
|
17
|
+
*/
|
|
18
|
+
export async function docs(flags, rest) {
|
|
19
|
+
const slug = rest[1];
|
|
20
|
+
const cfg = resolveConfig(flags);
|
|
21
|
+
const { url, token } = controlPlane(cfg);
|
|
22
|
+
if (!url || !token) {
|
|
23
|
+
console.error(`oalo docs needs a CLI token.\n\n${HOW_TO_CONNECT}`);
|
|
24
|
+
process.exitCode = 2;
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const ch = new OaloChannel({ url, token });
|
|
29
|
+
try {
|
|
30
|
+
await requireFeature(url, "docs", "oalo docs");
|
|
31
|
+
await ch.connect();
|
|
32
|
+
|
|
33
|
+
if (slug) {
|
|
34
|
+
let r;
|
|
35
|
+
try {
|
|
36
|
+
r = await ch.call("docs:get", { slug });
|
|
37
|
+
} catch (e) {
|
|
38
|
+
const sugg = e.reply?.suggestions || [];
|
|
39
|
+
console.error(`${e.message}${sugg.length ? `. Did you mean: ${sugg.join(", ")}?` : ""}\nList the pages with \`oalo docs\`, or search with \`oalo docs --search "<words>"\`.`);
|
|
40
|
+
process.exitCode = 1;
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (flags.json) return void console.log(JSON.stringify(r.doc, null, 2));
|
|
44
|
+
process.stdout.write(r.doc.markdown.endsWith("\n") ? r.doc.markdown : r.doc.markdown + "\n");
|
|
45
|
+
if (r.doc.related?.length) console.error(dim(`related: ${r.doc.related.join(", ")}`));
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const q = typeof flags.search === "string" ? flags.search : null;
|
|
50
|
+
const params = q ? { q } : typeof flags.audience === "string" ? { audience: flags.audience } : {};
|
|
51
|
+
const { docs: list } = await ch.call("docs:list", params);
|
|
52
|
+
if (flags.json) return void console.log(JSON.stringify(list, null, 2));
|
|
53
|
+
|
|
54
|
+
if (!list.length) {
|
|
55
|
+
console.log(q ? `no page matches "${q}". Try other words, or \`oalo docs\` for the full list.` : "no pages on this workspace");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const width = Math.min(32, Math.max(...list.map((d) => d.slug.length)));
|
|
59
|
+
for (const d of list) {
|
|
60
|
+
console.log(`${d.slug.padEnd(width)} ${d.summary || d.title}`);
|
|
61
|
+
if (q && d.snippet && d.snippet !== d.summary) console.log(dim(`${" ".repeat(width)} ${d.snippet}`));
|
|
62
|
+
}
|
|
63
|
+
console.error(dim(`\nRead one with: oalo docs <slug>`));
|
|
64
|
+
} catch (e) {
|
|
65
|
+
console.error(e.message);
|
|
66
|
+
process.exitCode = 1;
|
|
67
|
+
} finally {
|
|
68
|
+
ch.close();
|
|
69
|
+
}
|
|
70
|
+
}
|