@ovrdev/cli 0.1.0-alpha.16

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,356 @@
1
+ import {
2
+ CONFIG_DIR
3
+ } from "../index-bj59btcy.js";
4
+
5
+ // src/plugins/supabase.ts
6
+ import { execFile, spawn } from "node:child_process";
7
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ var renderVal = (v) => typeof v === "string" ? JSON.stringify(v) : String(v);
10
+ function patchSupabaseConfig(text, patch) {
11
+ const pending = {};
12
+ for (const [sec, kv] of Object.entries({ "": { project_id: patch.projectId }, ...patch.set ?? {} })) {
13
+ pending[sec] = { ...pending[sec] ?? {}, ...kv };
14
+ }
15
+ const urls = patch.redirectUrls ?? [];
16
+ const retarget = (line) => patch.apiPort && /^\s*redirect_uri\s*=/.test(line) ? line.replace(/http:\/\/localhost:\d+\//, `http://localhost:${patch.apiPort}/`) : line;
17
+ const out = [];
18
+ const lines = text.split(`
19
+ `);
20
+ let cur = "";
21
+ let inUrls = false;
22
+ let urlsSeen = [];
23
+ let urlsIndent = " ";
24
+ let authHadUrls = false;
25
+ const flush = (sec) => {
26
+ for (const [k, v] of Object.entries(pending[sec] ?? {}))
27
+ out.push(`${k} = ${renderVal(v)}`);
28
+ delete pending[sec];
29
+ if (sec === "auth" && !authHadUrls && urls.length) {
30
+ out.push("additional_redirect_urls = [", ...urls.map((u) => `${urlsIndent}${JSON.stringify(u)},`), "]");
31
+ }
32
+ };
33
+ for (const raw of lines) {
34
+ const line = retarget(raw);
35
+ if (inUrls) {
36
+ const entry = /"([^"]+)"/.exec(line);
37
+ if (entry)
38
+ urlsSeen.push(entry[1]);
39
+ if (line.includes("]")) {
40
+ inUrls = false;
41
+ for (const u of urls) {
42
+ if (!urlsSeen.includes(u))
43
+ out.push(`${urlsIndent}${JSON.stringify(u)},`);
44
+ }
45
+ out.push(line);
46
+ continue;
47
+ }
48
+ out.push(line);
49
+ continue;
50
+ }
51
+ const header = /^\s*\[([^\]]+)\]\s*$/.exec(line);
52
+ if (header) {
53
+ flush(cur);
54
+ cur = header[1];
55
+ out.push(line);
56
+ continue;
57
+ }
58
+ const kv = /^(\s*)([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line);
59
+ if (kv && pending[cur] && kv[2] in pending[cur]) {
60
+ out.push(`${kv[1]}${kv[2]} = ${renderVal(pending[cur][kv[2]])}`);
61
+ delete pending[cur][kv[2]];
62
+ continue;
63
+ }
64
+ if (kv && cur === "auth" && kv[2] === "additional_redirect_urls" && urls.length) {
65
+ authHadUrls = true;
66
+ if (line.includes("]")) {
67
+ const seen = [...line.matchAll(/"([^"]+)"/g)].map((m) => m[1]);
68
+ const merged = [...seen, ...urls.filter((u) => !seen.includes(u))];
69
+ out.push(`${kv[1]}additional_redirect_urls = [${merged.map((u) => JSON.stringify(u)).join(", ")}]`);
70
+ continue;
71
+ }
72
+ inUrls = true;
73
+ urlsSeen = [];
74
+ urlsIndent = " ";
75
+ out.push(line);
76
+ continue;
77
+ }
78
+ out.push(line);
79
+ }
80
+ flush(cur);
81
+ for (const [sec, kv] of Object.entries(pending)) {
82
+ const entries = Object.entries(kv);
83
+ if (!entries.length)
84
+ continue;
85
+ if (sec)
86
+ out.push("", `[${sec}]`);
87
+ for (const [k, v] of entries)
88
+ out.push(`${k} = ${renderVal(v)}`);
89
+ }
90
+ return out.join(`
91
+ `);
92
+ }
93
+ var DEFAULT_CONFIG = `project_id = "ovr"
94
+
95
+ [api]
96
+ enabled = true
97
+ port = 54321
98
+
99
+ [db]
100
+ port = 54322
101
+ shadow_port = 54320
102
+ major_version = 17
103
+
104
+ [db.seed]
105
+ enabled = false
106
+
107
+ [realtime]
108
+ enabled = true
109
+
110
+ [storage]
111
+ enabled = true
112
+
113
+ [auth]
114
+ enabled = true
115
+ site_url = "http://localhost:3000"
116
+
117
+ [studio]
118
+ enabled = false
119
+
120
+ [inbucket]
121
+ enabled = false
122
+
123
+ [analytics]
124
+ enabled = false
125
+ `;
126
+ function statusExports(s) {
127
+ const out = {};
128
+ const put = (k, v) => {
129
+ if (v)
130
+ out[k] = v;
131
+ };
132
+ put("url", s.API_URL);
133
+ put("publishableKey", s.PUBLISHABLE_KEY);
134
+ put("secretKey", s.SECRET_KEY);
135
+ put("dbUrl", s.DB_URL);
136
+ put("prismaUrl", s.DB_URL);
137
+ put("graphqlUrl", s.GRAPHQL_URL);
138
+ put("jwtSecret", s.JWT_SECRET);
139
+ return out;
140
+ }
141
+ function migrationNeeds(sql) {
142
+ return {
143
+ storage: /\bstorage"?\."?\w/.test(sql),
144
+ realtime: /supabase_realtime_admin|\brealtime"?\."?\w/.test(sql)
145
+ };
146
+ }
147
+ var run = (cmd, args, opts = {}) => new Promise((resolve) => {
148
+ execFile(cmd, args, { ...opts, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
149
+ resolve({ code: err ? err.code ?? 1 : 0, stdout, stderr });
150
+ });
151
+ });
152
+ var relink = (target, link) => {
153
+ rmSync(link, { force: true, recursive: true });
154
+ symlinkSync(target, link);
155
+ };
156
+ var supabasePlugin = {
157
+ name: "supabase",
158
+ async start(spec, ctx) {
159
+ const { workspace, repo, service, dir } = ctx.scope;
160
+ const projectId = `ovr-${workspace}-${repo}`.replace(/[^A-Za-z0-9_-]/g, "-");
161
+ const workdir = join(CONFIG_DIR, "workdirs", `${workspace}.${repo}.${service}`);
162
+ mkdirSync(join(workdir, "supabase"), { recursive: true });
163
+ const services = {
164
+ realtime: true,
165
+ storage: true,
166
+ studio: false,
167
+ inbucket: false,
168
+ analytics: false,
169
+ ...spec.services
170
+ };
171
+ const repoSupa = join(dir, "supabase");
172
+ const migrationsDir = join(repoSupa, "migrations");
173
+ if ((!services.storage || !services.realtime) && existsSync(migrationsDir)) {
174
+ const sql = readdirSync(migrationsDir).filter((f) => f.endsWith(".sql")).map((f) => readFileSync(join(migrationsDir, f), "utf8")).join(`
175
+ `);
176
+ const needs = migrationNeeds(sql);
177
+ for (const svc of ["storage", "realtime"]) {
178
+ if (!services[svc] && needs[svc]) {
179
+ throw new Error(`supabase.stack: migrations reference ${svc} (tables/roles) but services.${svc} is false — enable it or the replay fails`);
180
+ }
181
+ }
182
+ }
183
+ const apiPort = spec.ports?.api ?? await ctx.allocPort("api");
184
+ const dbPort = spec.ports?.db ?? await ctx.allocPort("db");
185
+ const shadowPort = await ctx.allocPort("shadow_db");
186
+ const base = existsSync(join(repoSupa, "config.toml")) ? readFileSync(join(repoSupa, "config.toml"), "utf8") : DEFAULT_CONFIG;
187
+ const set = {
188
+ api: { port: apiPort },
189
+ db: { port: dbPort, shadow_port: shadowPort },
190
+ studio: { enabled: services.studio },
191
+ inbucket: { enabled: services.inbucket },
192
+ analytics: { enabled: services.analytics },
193
+ realtime: { enabled: services.realtime },
194
+ storage: { enabled: services.storage }
195
+ };
196
+ if (/\[db\.pooler\][^[]*enabled\s*=\s*true/.test(base))
197
+ set["db.pooler"] = { port: await ctx.allocPort("pooler") };
198
+ if (!/\[edge_runtime\][^[]*enabled\s*=\s*false/.test(base)) {
199
+ set.edge_runtime = { inspector_port: await ctx.allocPort("edge_inspector") };
200
+ }
201
+ for (const ui of ["studio", "inbucket", "analytics"]) {
202
+ if (services[ui])
203
+ set[ui] = { enabled: true, port: await ctx.allocPort(ui) };
204
+ }
205
+ writeFileSync(join(workdir, "supabase", "config.toml"), patchSupabaseConfig(base, { projectId, set, redirectUrls: spec.redirectUrls, apiPort }));
206
+ for (const item of ["migrations", "seed.sql", "functions", "roles.sql"]) {
207
+ if (existsSync(join(repoSupa, item)))
208
+ relink(join(repoSupa, item), join(workdir, "supabase", item));
209
+ }
210
+ const boot = ctx.sub("boot");
211
+ ctx.log(`▶ supabase start (:${apiPort} api, :${dbPort} db — workdir ${workdir})`);
212
+ const bootOnce = () => new Promise((resolve) => {
213
+ let out = "";
214
+ const child = spawn("supabase", ["start", "--workdir", workdir], {
215
+ cwd: dir,
216
+ env: ctx.env,
217
+ stdio: ["ignore", "pipe", "pipe"]
218
+ });
219
+ child.stdout.on("data", (d) => {
220
+ out += String(d);
221
+ });
222
+ child.stderr.on("data", (d) => {
223
+ out += String(d);
224
+ });
225
+ boot.pipe(child.stdout);
226
+ boot.pipe(child.stderr);
227
+ child.on("exit", (code) => resolve({ code: code ?? 1, out }));
228
+ child.on("error", () => resolve({ code: 127, out }));
229
+ });
230
+ let booted = await bootOnce();
231
+ if (booted.code !== 0 && /already running/i.test(booted.out)) {
232
+ boot.log("⟳ stale stack state — supabase stop, retrying once");
233
+ await run("supabase", ["stop", "--workdir", workdir], { env: ctx.env });
234
+ booted = await bootOnce();
235
+ }
236
+ if (booted.code !== 0) {
237
+ boot.setStatus("failed");
238
+ throw new Error(`supabase start exited ${booted.code} (see the boot stream)`);
239
+ }
240
+ boot.setStatus("exited");
241
+ const status = await run("supabase", ["status", "--workdir", workdir, "-o", "json"], { env: ctx.env });
242
+ let vars = {};
243
+ try {
244
+ vars = JSON.parse(status.stdout.slice(status.stdout.indexOf("{"), status.stdout.lastIndexOf("}") + 1));
245
+ } catch {
246
+ ctx.log(`⚠ supabase status unparsable (exit ${status.code})`);
247
+ }
248
+ const followers = [];
249
+ const ps = await run("docker", ["ps", "--filter", `name=${projectId}`, "--format", "{{.Names}}"]);
250
+ for (const name of ps.stdout.split(`
251
+ `).filter(Boolean)) {
252
+ const short = name.replace(/^supabase_/, "").replace(new RegExp(`_${projectId}$`), "");
253
+ const sub = ctx.sub(short);
254
+ const f = spawn("docker", ["logs", "-f", "--tail", "5", name], { stdio: ["ignore", "pipe", "pipe"] });
255
+ sub.pipe(f.stdout);
256
+ sub.pipe(f.stderr);
257
+ f.on("exit", () => sub.setStatus("exited"));
258
+ followers.push(f);
259
+ }
260
+ const actions = [
261
+ {
262
+ id: "psql",
263
+ label: "psql (postgres)",
264
+ command: "docker",
265
+ args: ["exec", "-it", `supabase_db_${projectId}`, "psql", "-U", "postgres"],
266
+ cwd: dir,
267
+ title: "psql"
268
+ },
269
+ {
270
+ id: "supabase-status",
271
+ label: "supabase status",
272
+ command: "supabase",
273
+ args: ["status", "--workdir", workdir],
274
+ cwd: dir,
275
+ title: "status"
276
+ }
277
+ ];
278
+ let resolveExited = () => {};
279
+ const exited = new Promise((r) => {
280
+ resolveExited = r;
281
+ });
282
+ ctx.log(`✓ supabase up (${vars.API_URL ?? `http://127.0.0.1:${apiPort}`})`);
283
+ if (spec.onReady)
284
+ await spec.onReady();
285
+ return {
286
+ exports: {
287
+ ...statusExports(vars),
288
+ host: "localhost",
289
+ port: String(apiPort),
290
+ ...spec.exports?.({ status: vars, ports: { api: apiPort, db: dbPort } })
291
+ },
292
+ info: `:${apiPort}`,
293
+ actions,
294
+ ready: spec.ready ? spec.ready({ status: vars, ports: { api: apiPort, db: dbPort } }) : Promise.resolve(),
295
+ exited,
296
+ stop: async () => {
297
+ for (const f of followers)
298
+ f.kill("SIGTERM");
299
+ await run("supabase", ["stop", "--workdir", workdir]);
300
+ resolveExited();
301
+ }
302
+ };
303
+ }
304
+ };
305
+ var kinds = {
306
+ stack: (opts = {}) => ({
307
+ plugin: supabasePlugin,
308
+ spec: opts
309
+ })
310
+ };
311
+ function supabase() {
312
+ let scopeDir = "/";
313
+ let dockerUp = true;
314
+ return {
315
+ name: "supabase",
316
+ kinds,
317
+ provide(ctx) {
318
+ scopeDir = ctx.scope.dir;
319
+ return {};
320
+ },
321
+ actions() {
322
+ return dockerUp ? [] : [
323
+ {
324
+ id: "docker-start",
325
+ label: "docker: start Docker Desktop",
326
+ command: "open",
327
+ args: ["-a", "Docker"],
328
+ cwd: scopeDir,
329
+ title: "docker"
330
+ }
331
+ ];
332
+ },
333
+ async diagnose() {
334
+ const out = [];
335
+ const cli = await run("supabase", ["--version"]);
336
+ out.push(cli.code === 0 ? { id: "cli", status: "ok", message: `supabase CLI ${cli.stdout.trim()}` } : { id: "cli", status: "error", message: "supabase CLI not found — brew install supabase/tap/supabase" });
337
+ const docker = await run("docker", ["info", "--format", "{{.ServerVersion}}"]);
338
+ dockerUp = docker.code === 0;
339
+ out.push(dockerUp ? { id: "docker", status: "ok", message: `docker daemon ${docker.stdout.trim()}` } : {
340
+ id: "docker",
341
+ status: "error",
342
+ message: "docker daemon not running — the stack can't start",
343
+ fix: "docker-start"
344
+ });
345
+ return out;
346
+ }
347
+ };
348
+ }
349
+ export {
350
+ supabasePlugin,
351
+ supabase,
352
+ statusExports,
353
+ patchSupabaseConfig,
354
+ migrationNeeds,
355
+ DEFAULT_CONFIG
356
+ };
@@ -0,0 +1,2 @@
1
+ /** Minimal dotenv parse (KEY=VALUE, optional quotes, # comments). Shared by env sources. */
2
+ export declare function parseDotenv(text: string): Record<string, string>;
@@ -0,0 +1,11 @@
1
+ /** A random string of `length` chars from `alphabet` (default: alphanumeric), unbiased. */
2
+ export declare function randomString(length?: number, alphabet?: string): string;
3
+ /**
4
+ * A random password: alphanumeric plus a few shell/URL-safe symbols by default. `symbols:false`
5
+ * keeps it strictly alphanumeric (for places that choke on punctuation).
6
+ */
7
+ export declare function randomPassword(length?: number, opts?: {
8
+ symbols?: boolean;
9
+ }): string;
10
+ /** A hex token (e.g. a webhook secret) — `bytes` random bytes, hex-encoded (2× length). */
11
+ export declare function randomHex(bytes?: number): string;
@@ -0,0 +1,18 @@
1
+ export type WaitOptions = {
2
+ /** Give up (reject) after this long. Default 60s. */
3
+ timeoutMs?: number;
4
+ /** Delay between attempts. Default 500ms. */
5
+ intervalMs?: number;
6
+ /** Label for the timeout error. */
7
+ label?: string;
8
+ };
9
+ /** Poll until `probe()` resolves truthy, or reject at the deadline. */
10
+ export declare function waitUntil(probe: () => Promise<boolean> | boolean, opts?: WaitOptions): Promise<void>;
11
+ /** Ready when `url` answers (any status < 500 by default, or an exact `status`). */
12
+ export declare function waitHttp(url: string, opts?: WaitOptions & {
13
+ status?: number;
14
+ }): Promise<void>;
15
+ /** Ready when a TCP connection to `host:port` succeeds. */
16
+ export declare function waitTcp(port: number, opts?: WaitOptions & {
17
+ host?: string;
18
+ }): Promise<void>;
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@ovrdev/cli",
3
+ "version": "0.1.0-alpha.16",
4
+ "description": "Override — the dev control plane: environments, repos, services, tasks",
5
+ "type": "module",
6
+ "bin": {
7
+ "ovr": "dist/cli/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "scripts/postinstall.mjs"
12
+ ],
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/core/core.d.ts",
16
+ "default": "./dist/core/core.js"
17
+ },
18
+ "./plugin/shell": {
19
+ "types": "./dist/plugins/shell.d.ts",
20
+ "default": "./dist/plugins/shell.js"
21
+ },
22
+ "./package.json": "./package.json"
23
+ },
24
+ "engines": {
25
+ "node": ">=22.6"
26
+ },
27
+ "dependencies": {
28
+ "@clack/prompts": "^1.6.0",
29
+ "@xterm/headless": "^6.0.0",
30
+ "ink": "^7.1.0",
31
+ "node-pty": "^1.1.0",
32
+ "react": "^19.2.7"
33
+ },
34
+ "devDependencies": {
35
+ "@types/node": "^22.10.0",
36
+ "@types/react": "^19.2.17",
37
+ "typescript": "^5.7.2"
38
+ },
39
+ "scripts": {
40
+ "build": "bun build src/cli/index.ts src/core/core.ts src/plugins/shell.ts --root src --outdir=dist --target=node --splitting --format=esm --packages=external && tsc -p tsconfig.build.json && node scripts/fix-dts.mjs && bun build packages/plugin-portless/src/index.ts --outdir=packages/plugin-portless/dist --target=node --format=esm --packages=external && tsc -p packages/plugin-portless/tsconfig.build.json && node scripts/fix-dts.mjs packages/plugin-portless/dist && bun build packages/plugin-docker/src/index.ts --outdir=packages/plugin-docker/dist --target=node --format=esm --packages=external && tsc -p packages/plugin-docker/tsconfig.build.json && node scripts/fix-dts.mjs packages/plugin-docker/dist && bun build packages/plugin-infisical/src/index.ts --outdir=packages/plugin-infisical/dist --target=node --format=esm --packages=external && tsc -p packages/plugin-infisical/tsconfig.build.json && node scripts/fix-dts.mjs packages/plugin-infisical/dist && bun build packages/plugin-js/src/index.ts --outdir=packages/plugin-js/dist --target=node --format=esm --packages=external && tsc -p packages/plugin-js/tsconfig.build.json && node scripts/fix-dts.mjs packages/plugin-js/dist && bun build packages/plugin-postgres/src/index.ts --outdir=packages/plugin-postgres/dist --target=node --format=esm --packages=external && bun build packages/plugin-postgres/src/sql-runner.ts --outdir=packages/plugin-postgres/dist --target=node --format=esm && tsc -p packages/plugin-postgres/tsconfig.build.json && node scripts/fix-dts.mjs packages/plugin-postgres/dist && bun build packages/plugin-supabase/src/index.ts --outdir=packages/plugin-supabase/dist --target=node --format=esm --packages=external && tsc -p packages/plugin-supabase/tsconfig.build.json && node scripts/fix-dts.mjs packages/plugin-supabase/dist",
41
+ "dev": "bun build src/cli/index.ts src/core/core.ts src/plugins/shell.ts --root src --outdir=dist --target=node --splitting --format=esm --packages=external --watch",
42
+ "postinstall": "node scripts/postinstall.mjs",
43
+ "test": "bun run build && bun test"
44
+ }
45
+ }
@@ -0,0 +1,59 @@
1
+ // node-pty ships prebuilt binaries with a separate `spawn-helper`, and the prebuild
2
+ // downloader can drop its executable bit — which surfaces later as a cryptic
3
+ // "posix_spawnp failed" the moment ovr opens a PTY. Re-arm it across EVERY install layout:
4
+ // • npm / yarn (flat/hoisted): <nm>/node-pty/prebuilds/<platform>/spawn-helper
5
+ // • pnpm: <nm>/.pnpm/node-pty@*/node_modules/node-pty/prebuilds/…
6
+ // • nested under ovr: <ovr>/node_modules/node-pty/prebuilds/…
7
+ // (The earlier version only checked the pnpm layout, so npm installs stayed broken.)
8
+ import { chmodSync, existsSync, readdirSync } from "node:fs"
9
+ import { dirname, join } from "node:path"
10
+
11
+ const ovrRoot = join(import.meta.dirname, "..") // this file lives at <ovr>/scripts/
12
+
13
+ /** Nearest ancestor named `node_modules` (the consumer's root when ovr is a dependency). */
14
+ function nearestNodeModules(dir) {
15
+ let d = dir
16
+ for (let i = 0; i < 12; i++) {
17
+ if (d.endsWith("node_modules")) return d
18
+ const up = dirname(d)
19
+ if (up === d) return null
20
+ d = up
21
+ }
22
+ return null
23
+ }
24
+
25
+ // Where node_modules trees might live: the consumer's (ovr-as-dep) and ovr's own (dev/nested).
26
+ const nmRoots = new Set()
27
+ const near = nearestNodeModules(ovrRoot)
28
+ if (near) nmRoots.add(near)
29
+ nmRoots.add(join(ovrRoot, "node_modules"))
30
+
31
+ // Every node-pty install dir reachable from those roots.
32
+ const ptyDirs = []
33
+ for (const nm of nmRoots) {
34
+ ptyDirs.push(join(nm, "node-pty")) // flat / hoisted / dev-checkout top level
35
+ const pnpm = join(nm, ".pnpm")
36
+ if (existsSync(pnpm)) {
37
+ for (const p of readdirSync(pnpm)) {
38
+ if (p.startsWith("node-pty@")) ptyDirs.push(join(pnpm, p, "node_modules", "node-pty"))
39
+ }
40
+ }
41
+ }
42
+
43
+ let armed = 0
44
+ for (const d of ptyDirs) {
45
+ const prebuilds = join(d, "prebuilds")
46
+ if (!existsSync(prebuilds)) continue
47
+ for (const platform of readdirSync(prebuilds)) {
48
+ const helper = join(prebuilds, platform, "spawn-helper")
49
+ if (!existsSync(helper)) continue
50
+ try {
51
+ chmodSync(helper, 0o755)
52
+ armed++
53
+ } catch {
54
+ // read-only fs / already fine — best effort
55
+ }
56
+ }
57
+ }
58
+
59
+ if (process.env.OVR_POSTINSTALL_DEBUG) console.error(`ovr postinstall: re-armed ${armed} spawn-helper(s)`)