@egnis-it/vibe-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/vibe.js ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { main } from "../src/cli.js";
4
+
5
+ main(process.argv.slice(2)).catch((error) => {
6
+ const message = error && error.message ? error.message : String(error);
7
+ console.error(`Error: ${message}`);
8
+ process.exitCode = error.exitCode || 1;
9
+ });
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@egnis-it/vibe-cli",
3
+ "version": "0.1.0",
4
+ "description": "CLI for deploying apps to Egnis Vibe.",
5
+ "type": "module",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "package.json"
13
+ ],
14
+ "bin": {
15
+ "vibe": "bin/vibe.js"
16
+ },
17
+ "scripts": {
18
+ "test": "node --test"
19
+ },
20
+ "engines": {
21
+ "node": ">=20"
22
+ }
23
+ }
package/src/cli.js ADDED
@@ -0,0 +1,285 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createInterface } from "node:readline/promises";
3
+ import { stdin as input, stdout as output } from "node:process";
4
+
5
+ import {
6
+ checkSlug,
7
+ cleanupZip,
8
+ createZip,
9
+ deployApp,
10
+ getConfigSummary,
11
+ inspectApp,
12
+ listDatasources,
13
+ loadSession,
14
+ login,
15
+ openApp,
16
+ requireSlug,
17
+ streamLogs,
18
+ validateDatasourceSelection,
19
+ } from "./vibe.js";
20
+
21
+ const HELP = `vibe - Egnis Vibe deployment CLI
22
+
23
+ Usage:
24
+ vibe login
25
+ vibe preflight [path] [--type <auto|static|server>]
26
+ vibe deploy [path] --slug <slug> --title <title> [options]
27
+ vibe status <slug>
28
+ vibe logs <jobId>
29
+ vibe datasources
30
+ vibe open <slug> [--browser]
31
+ vibe config
32
+
33
+ Options:
34
+ --dry-run Inspect and package locally without uploading.
35
+ --keep-zip Keep the generated zip after deploy/dry-run.
36
+ --type <auto|static|server> App type. Defaults to auto.
37
+ --datasources <ids> Comma-separated datasource ids for server apps.
38
+ --description <text> Hub description.
39
+ --tags <text> Hub tags.
40
+ --usage <text> Hub usage text.
41
+ --yes Update an existing slug you own without prompting.
42
+ --stream Stream build logs after deploy.
43
+ --api-base <url> Defaults to https://vibe.egnis.net.
44
+ --json Print machine-readable JSON where supported.
45
+ `;
46
+
47
+ export async function main(argv) {
48
+ const { command, positional, options } = parseArgs(argv);
49
+ const apiBase = options.apiBase || process.env.VIBE_API_BASE || "https://vibe.egnis.net";
50
+
51
+ if (!command || options.help || command === "help") {
52
+ console.log(HELP);
53
+ return;
54
+ }
55
+
56
+ if (command === "login") {
57
+ const session = await login({ apiBase, log: console.log });
58
+ if (options.json) console.log(JSON.stringify({ ok: true, email: session.email || null }, null, 2));
59
+ return;
60
+ }
61
+
62
+ if (command === "preflight") {
63
+ const appDir = positional[0] || process.cwd();
64
+ const inspection = await inspectApp(appDir, options.type || "auto");
65
+ if (options.json) console.log(JSON.stringify({ ok: true, ...inspection }, null, 2));
66
+ else printInspection(inspection);
67
+ return;
68
+ }
69
+
70
+ if (command === "deploy") {
71
+ const appDir = positional[0] || process.cwd();
72
+ const slug = requireSlug(options.slug);
73
+ const title = options.title;
74
+ if (!title) throw usageError("Missing --title.");
75
+
76
+ let zipPath;
77
+ try {
78
+ const inspection = await inspectApp(appDir, options.type || "auto");
79
+ const appType = inspection.appType;
80
+ zipPath = await createZip(appDir);
81
+ if (options.dryRun) {
82
+ const result = { ok: true, dryRun: true, zipPath: options.keepZip ? zipPath : null, ...inspection };
83
+ if (options.json) console.log(JSON.stringify(result, null, 2));
84
+ else {
85
+ printInspection(inspection);
86
+ if (options.keepZip) console.log(`Zip: ${zipPath}`);
87
+ else console.log("Zip verified and cleaned up. Use --keep-zip to inspect it.");
88
+ console.log("Dry run complete. Nothing uploaded.");
89
+ }
90
+ return;
91
+ }
92
+
93
+ const session = await loadSession();
94
+ if (options.datasources) {
95
+ const datasources = await listDatasources({ apiBase, session: session.session });
96
+ validateDatasourceSelection(options.datasources, datasources);
97
+ }
98
+ const slugInfo = await checkSlug({ apiBase, session: session.session, slug });
99
+
100
+ if (!slugInfo.available && !slugInfo.mine) {
101
+ throw usageError(`Slug '${slug}' is not available.`);
102
+ }
103
+
104
+ if (slugInfo.mine && !options.yes) {
105
+ const ok = await confirm(`'${slug}' already belongs to you. Update it? [y/N] `);
106
+ if (!ok) throw usageError("Deploy cancelled.");
107
+ }
108
+
109
+ const result = await deployApp({
110
+ apiBase,
111
+ session: session.session,
112
+ zipPath,
113
+ slug,
114
+ title,
115
+ appType,
116
+ datasources: options.datasources,
117
+ description: options.description,
118
+ tags: options.tags,
119
+ usage: options.usage,
120
+ });
121
+ const urls = openApp(slug);
122
+
123
+ if (options.json) {
124
+ console.log(JSON.stringify({ ok: true, ...result, appType, urls }, null, 2));
125
+ } else {
126
+ console.log(`Uploaded ${slug} as ${appType}.`);
127
+ if (result.jobId) console.log(`Job: ${result.jobId}`);
128
+ console.log(`App: ${urls.app}`);
129
+ console.log(`Hub: ${urls.hub}`);
130
+ }
131
+
132
+ if (options.stream && result.jobId) {
133
+ const logs = await streamLogs({ apiBase, session: session.session, jobId: result.jobId, log: console.log });
134
+ if (!options.json && logs.done) {
135
+ console.log("Build log reached done event.");
136
+ console.log(`App: ${urls.app}`);
137
+ console.log(`Hub: ${urls.hub}`);
138
+ }
139
+ }
140
+ } finally {
141
+ if (!options.keepZip) await cleanupZip(zipPath);
142
+ }
143
+ return;
144
+ }
145
+
146
+ if (command === "status") {
147
+ const slug = requireSlug(positional[0] || options.slug);
148
+ const session = await loadSession();
149
+ const result = await checkSlug({ apiBase, session: session.session, slug });
150
+ if (options.json) console.log(JSON.stringify(result, null, 2));
151
+ else printStatus(slug, result);
152
+ return;
153
+ }
154
+
155
+ if (command === "logs") {
156
+ const jobId = positional[0] || options.jobId;
157
+ if (!jobId) throw usageError("Missing jobId.");
158
+ const session = await loadSession();
159
+ await streamLogs({ apiBase, session: session.session, jobId, log: console.log });
160
+ return;
161
+ }
162
+
163
+ if (command === "datasources") {
164
+ const session = await loadSession();
165
+ const result = await listDatasources({ apiBase, session: session.session });
166
+ if (options.json) console.log(JSON.stringify(result, null, 2));
167
+ else printDatasources(result);
168
+ return;
169
+ }
170
+
171
+ if (command === "open") {
172
+ const slug = requireSlug(positional[0] || options.slug);
173
+ const urls = openApp(slug);
174
+ console.log(urls.app);
175
+ console.log(urls.hub);
176
+ if (options.browser) await spawnAndWait("open", [urls.app]);
177
+ return;
178
+ }
179
+
180
+ if (command === "config") {
181
+ const summary = await getConfigSummary();
182
+ console.log(JSON.stringify(summary, null, 2));
183
+ return;
184
+ }
185
+
186
+ throw usageError(`Unknown command '${command}'.`);
187
+ }
188
+
189
+ export function parseArgs(argv) {
190
+ const options = {};
191
+ const positional = [];
192
+ let command = null;
193
+
194
+ for (let i = 0; i < argv.length; i += 1) {
195
+ const token = argv[i];
196
+ if (!command && !token.startsWith("-")) {
197
+ command = token;
198
+ continue;
199
+ }
200
+
201
+ if (token.startsWith("--")) {
202
+ const [rawKey, inlineValue] = token.slice(2).split(/=(.*)/s, 2);
203
+ const key = toCamelCase(rawKey);
204
+ if (["yes", "stream", "json", "help", "browser", "dryRun", "keepZip"].includes(key)) {
205
+ options[key] = inlineValue === undefined ? true : inlineValue !== "false";
206
+ continue;
207
+ }
208
+ const value = inlineValue === undefined ? argv[++i] : inlineValue;
209
+ if (value === undefined || value.startsWith("--")) throw usageError(`Missing value for --${rawKey}.`);
210
+ options[key] = value;
211
+ continue;
212
+ }
213
+
214
+ positional.push(token);
215
+ }
216
+
217
+ return { command, positional, options };
218
+ }
219
+
220
+ function printStatus(slug, result) {
221
+ if (result.available) {
222
+ console.log(`${slug}: available`);
223
+ } else if (result.mine) {
224
+ console.log(`${slug}: owned by you`);
225
+ if (result.title) console.log(`Title: ${result.title}`);
226
+ } else {
227
+ console.log(`${slug}: unavailable`);
228
+ }
229
+ }
230
+
231
+ function printInspection(inspection) {
232
+ console.log(`App type: ${inspection.appType}`);
233
+ console.log(`Root: ${inspection.root}`);
234
+ console.log(`Files: start.sh=${inspection.files.startSh}, index.html=${inspection.files.indexHtml}, package.json=${inspection.files.packageJson}`);
235
+ if (inspection.package) {
236
+ console.log(`Package: name=${inspection.package.name || "(none)"}, build=${inspection.package.hasBuildScript}`);
237
+ }
238
+ if (inspection.excludes?.length) console.log(`Excluded: ${inspection.excludes.join(", ")}`);
239
+ for (const warning of inspection.warnings || []) {
240
+ console.log(`Warning: ${warning}`);
241
+ }
242
+ }
243
+
244
+ function printDatasources(datasources) {
245
+ if (!Array.isArray(datasources)) {
246
+ console.log(JSON.stringify(datasources, null, 2));
247
+ return;
248
+ }
249
+ for (const item of datasources) {
250
+ const state = item.available ? "available" : "unavailable";
251
+ console.log(`${item.id}: ${state}${item.label ? ` - ${item.label}` : ""}`);
252
+ }
253
+ }
254
+
255
+ async function confirm(question) {
256
+ if (!process.stdin.isTTY) return false;
257
+ const rl = createInterface({ input, output });
258
+ try {
259
+ const answer = await rl.question(question);
260
+ return ["y", "yes"].includes(answer.trim().toLowerCase());
261
+ } finally {
262
+ rl.close();
263
+ }
264
+ }
265
+
266
+ function spawnAndWait(command, args) {
267
+ return new Promise((resolve, reject) => {
268
+ const child = spawn(command, args, { stdio: "ignore" });
269
+ child.on("error", reject);
270
+ child.on("close", (code) => {
271
+ if (code === 0) resolve();
272
+ else reject(usageError(`${command} exited with code ${code}.`));
273
+ });
274
+ });
275
+ }
276
+
277
+ function toCamelCase(value) {
278
+ return value.replace(/-([a-z])/g, (_, char) => char.toUpperCase());
279
+ }
280
+
281
+ function usageError(message) {
282
+ const error = new Error(`${message}\n\n${HELP}`);
283
+ error.exitCode = 2;
284
+ return error;
285
+ }
package/src/vibe.js ADDED
@@ -0,0 +1,485 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
3
+ import { homedir, tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { deflateRawSync } from "node:zlib";
6
+
7
+ export const MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
8
+
9
+ export function requireSlug(slug) {
10
+ if (!slug) throw new VibeError("Missing slug.");
11
+ if (!/^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/.test(slug)) {
12
+ throw new VibeError("Slug must use lowercase letters, numbers, and hyphens, and cannot start or end with a hyphen.");
13
+ }
14
+ return slug;
15
+ }
16
+
17
+ export async function detectAppType(appDir, requested = "auto") {
18
+ return (await inspectApp(appDir, requested)).appType;
19
+ }
20
+
21
+ export async function inspectApp(appDir, requested = "auto") {
22
+ const root = path.resolve(appDir);
23
+ await stat(root);
24
+
25
+ if (!["auto", "static", "server"].includes(requested)) {
26
+ throw new VibeError("--type must be auto, static, or server.");
27
+ }
28
+
29
+ const files = {
30
+ startSh: existsSync(path.join(root, "start.sh")),
31
+ indexHtml: existsSync(path.join(root, "index.html")),
32
+ packageJson: existsSync(path.join(root, "package.json")),
33
+ };
34
+ const packageInfo = files.packageJson ? await readPackageInfo(path.join(root, "package.json")) : null;
35
+
36
+ if (requested === "server") {
37
+ if (!files.startSh) throw new VibeError("Server apps require start.sh in the app root.");
38
+ return buildInspection(root, "server", files, packageInfo);
39
+ }
40
+
41
+ if (requested === "static") {
42
+ if (!files.indexHtml && !files.packageJson) throw new VibeError("Static apps require index.html or package.json in the app root.");
43
+ if (!files.indexHtml && packageInfo && !packageInfo.hasBuildScript) {
44
+ throw new VibeError("Static package apps require a package.json build script when index.html is not present.");
45
+ }
46
+ return buildInspection(root, "static", files, packageInfo);
47
+ }
48
+
49
+ if (files.startSh) return buildInspection(root, "server", files, packageInfo);
50
+ if (files.indexHtml) return buildInspection(root, "static", files, packageInfo);
51
+ if (files.packageJson) {
52
+ if (!packageInfo?.hasBuildScript) {
53
+ throw new VibeError("Found package.json, but no index.html or build script. Add index.html for a static app, add scripts.build for a built static app, or add start.sh for a server app.");
54
+ }
55
+ return buildInspection(root, "static", files, packageInfo);
56
+ }
57
+ throw new VibeError("No deployable app found. Add start.sh for server apps, or index.html/package.json for static apps.");
58
+ }
59
+
60
+ export async function createZip(appDir) {
61
+ const root = path.resolve(appDir);
62
+ await stat(root);
63
+ const workDir = await mkdtemp(path.join(tmpdir(), "vibe-deploy-"));
64
+ const zipPath = path.join(workDir, "vibe-deploy.zip");
65
+ try {
66
+ const entries = await collectZipEntries(root);
67
+ const totalBytes = entries.reduce((sum, entry) => sum + entry.size, 0);
68
+ if (totalBytes > MAX_UPLOAD_BYTES) {
69
+ throw new VibeError(`Upload is too large before compression: ${formatBytes(totalBytes)}. Vibe limit is ${formatBytes(MAX_UPLOAD_BYTES)}.`);
70
+ }
71
+
72
+ const zip = await buildZip(entries);
73
+ if (zip.length > MAX_UPLOAD_BYTES) {
74
+ throw new VibeError(`Upload zip is too large: ${formatBytes(zip.length)}. Vibe limit is ${formatBytes(MAX_UPLOAD_BYTES)}.`);
75
+ }
76
+
77
+ await writeFile(zipPath, zip);
78
+ return zipPath;
79
+ } catch (error) {
80
+ await cleanupZip(zipPath);
81
+ throw error;
82
+ }
83
+ }
84
+
85
+ export async function cleanupZip(zipPath) {
86
+ if (!zipPath) return;
87
+ await rm(path.dirname(zipPath), { recursive: true, force: true });
88
+ }
89
+
90
+ function buildInspection(root, appType, files, packageInfo) {
91
+ const warnings = [];
92
+ if (appType === "server") {
93
+ warnings.push("Server app must listen on process.env.PORT.");
94
+ if (!files.packageJson) warnings.push("No package.json found. This is OK for Python/shell apps if start.sh installs or runs everything needed.");
95
+ }
96
+ if (appType === "static" && files.packageJson) {
97
+ warnings.push("Static package apps are built by Vibe; keep build output out of the zip.");
98
+ if (!files.indexHtml && packageInfo && !packageInfo.hasBuildScript) {
99
+ warnings.push("No build script found. Static package deployment will fail unless Vibe has a custom build command.");
100
+ }
101
+ }
102
+ return {
103
+ root,
104
+ appType,
105
+ files,
106
+ package: packageInfo,
107
+ excludes: ZIP_EXCLUDES,
108
+ warnings,
109
+ };
110
+ }
111
+
112
+ const ZIP_EXCLUDES = ["node_modules", ".git", "dist", ".env*", ".npmrc", ".yarnrc", ".omx", ".codex", ".agents", ".claude", "coverage", "*.pem", "*.key", "*service-account*.json", "credentials*.json", "*.DS_Store"];
113
+
114
+ async function collectZipEntries(root) {
115
+ const entries = [];
116
+
117
+ async function walk(dir, relativeDir = "") {
118
+ const dirents = await readdir(dir, { withFileTypes: true });
119
+ for (const dirent of dirents) {
120
+ const relativePath = relativeDir ? `${relativeDir}/${dirent.name}` : dirent.name;
121
+ if (shouldExclude(relativePath, dirent.name)) continue;
122
+
123
+ const absolutePath = path.join(dir, dirent.name);
124
+ if (dirent.isDirectory()) {
125
+ await walk(absolutePath, relativePath);
126
+ } else if (dirent.isFile()) {
127
+ const info = await stat(absolutePath);
128
+ entries.push({
129
+ absolutePath,
130
+ zipPath: relativePath.split(path.sep).join("/"),
131
+ size: info.size,
132
+ mtime: info.mtime,
133
+ });
134
+ }
135
+ }
136
+ }
137
+
138
+ await walk(root);
139
+ entries.sort((a, b) => a.zipPath.localeCompare(b.zipPath));
140
+ return entries;
141
+ }
142
+
143
+ function shouldExclude(relativePath, basename) {
144
+ const parts = relativePath.split("/");
145
+ const first = parts[0];
146
+ if (["node_modules", ".git", "dist", ".omx", ".codex", ".agents", ".claude", "coverage"].includes(first)) return true;
147
+ if (basename === ".npmrc" || basename === ".yarnrc" || basename === ".DS_Store") return true;
148
+ if (basename.startsWith(".env")) return true;
149
+ if (basename.endsWith(".pem") || basename.endsWith(".key")) return true;
150
+ if (/service-account.*\.json$/i.test(basename)) return true;
151
+ if (/^credentials.*\.json$/i.test(basename)) return true;
152
+ return false;
153
+ }
154
+
155
+ async function buildZip(entries) {
156
+ const localParts = [];
157
+ const centralParts = [];
158
+ let offset = 0;
159
+
160
+ for (const entry of entries) {
161
+ const data = await readFile(entry.absolutePath);
162
+ const name = Buffer.from(entry.zipPath, "utf8");
163
+ const crc = crc32(data);
164
+ const { dosTime, dosDate } = toDosDateTime(entry.mtime);
165
+
166
+ const local = Buffer.alloc(30);
167
+ local.writeUInt32LE(0x04034b50, 0);
168
+ local.writeUInt16LE(20, 4);
169
+ local.writeUInt16LE(0x0800, 6); // UTF-8 names
170
+ const compressed = deflateRawSync(data, { level: 9 });
171
+ const useCompressed = compressed.length < data.length;
172
+ const payload = useCompressed ? compressed : data;
173
+ const method = useCompressed ? 8 : 0;
174
+
175
+ local.writeUInt16LE(method, 8);
176
+ local.writeUInt16LE(dosTime, 10);
177
+ local.writeUInt16LE(dosDate, 12);
178
+ local.writeUInt32LE(crc, 14);
179
+ local.writeUInt32LE(payload.length, 18);
180
+ local.writeUInt32LE(data.length, 22);
181
+ local.writeUInt16LE(name.length, 26);
182
+ local.writeUInt16LE(0, 28);
183
+ localParts.push(local, name, payload);
184
+
185
+ const central = Buffer.alloc(46);
186
+ central.writeUInt32LE(0x02014b50, 0);
187
+ central.writeUInt16LE(20, 4);
188
+ central.writeUInt16LE(20, 6);
189
+ central.writeUInt16LE(0x0800, 8);
190
+ central.writeUInt16LE(method, 10);
191
+ central.writeUInt16LE(dosTime, 12);
192
+ central.writeUInt16LE(dosDate, 14);
193
+ central.writeUInt32LE(crc, 16);
194
+ central.writeUInt32LE(payload.length, 20);
195
+ central.writeUInt32LE(data.length, 24);
196
+ central.writeUInt16LE(name.length, 28);
197
+ central.writeUInt16LE(0, 30);
198
+ central.writeUInt16LE(0, 32);
199
+ central.writeUInt16LE(0, 34);
200
+ central.writeUInt16LE(0, 36);
201
+ central.writeUInt32LE(0, 38);
202
+ central.writeUInt32LE(offset, 42);
203
+ centralParts.push(central, name);
204
+
205
+ offset += local.length + name.length + payload.length;
206
+ }
207
+
208
+ const centralSize = centralParts.reduce((sum, part) => sum + part.length, 0);
209
+ const end = Buffer.alloc(22);
210
+ end.writeUInt32LE(0x06054b50, 0);
211
+ end.writeUInt16LE(0, 4);
212
+ end.writeUInt16LE(0, 6);
213
+ end.writeUInt16LE(entries.length, 8);
214
+ end.writeUInt16LE(entries.length, 10);
215
+ end.writeUInt32LE(centralSize, 12);
216
+ end.writeUInt32LE(offset, 16);
217
+ end.writeUInt16LE(0, 20);
218
+
219
+ return Buffer.concat([...localParts, ...centralParts, end]);
220
+ }
221
+
222
+ function toDosDateTime(date) {
223
+ const year = Math.max(1980, date.getFullYear());
224
+ return {
225
+ dosTime: (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2),
226
+ dosDate: ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(),
227
+ };
228
+ }
229
+
230
+ const CRC_TABLE = Array.from({ length: 256 }, (_, index) => {
231
+ let value = index;
232
+ for (let i = 0; i < 8; i += 1) value = (value & 1) ? (0xedb88320 ^ (value >>> 1)) : (value >>> 1);
233
+ return value >>> 0;
234
+ });
235
+
236
+ function crc32(buffer) {
237
+ let crc = 0xffffffff;
238
+ for (const byte of buffer) crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
239
+ return (crc ^ 0xffffffff) >>> 0;
240
+ }
241
+
242
+ function formatBytes(bytes) {
243
+ return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
244
+ }
245
+
246
+ async function readPackageInfo(packagePath) {
247
+ try {
248
+ const parsed = JSON.parse(await readFile(packagePath, "utf8"));
249
+ return {
250
+ name: typeof parsed.name === "string" ? parsed.name : null,
251
+ private: Boolean(parsed.private),
252
+ hasBuildScript: Boolean(parsed.scripts && typeof parsed.scripts.build === "string" && parsed.scripts.build.trim()),
253
+ };
254
+ } catch {
255
+ throw new VibeError("package.json is not valid JSON.");
256
+ }
257
+ }
258
+
259
+ export async function login({ apiBase, log = () => {} }) {
260
+ const start = await postJson(`${apiBase}/__egnis/device/start`);
261
+ const deviceCode = start.device_code;
262
+ const userCode = start.user_code;
263
+ if (!deviceCode || !userCode) throw new VibeError("Device login did not return a code.");
264
+
265
+ log(`Open https://vibe.egnis.net/__egnis/device and enter code ${userCode}`);
266
+
267
+ for (let i = 0; i < 120; i += 1) {
268
+ await sleep(5000);
269
+ const poll = await postJson(`${apiBase}/__egnis/device/poll`, { device_code: deviceCode });
270
+ if (poll.status === "approved") {
271
+ if (!poll.session) throw new VibeError("Login was approved but no session was returned.");
272
+ const session = { session: poll.session, email: poll.email || null, apiBase, createdAt: new Date().toISOString() };
273
+ await saveSession(session);
274
+ log("Login complete.");
275
+ return session;
276
+ }
277
+ if (poll.status === "expired") throw new VibeError("Device code expired. Run vibe login again.");
278
+ if (poll.status && poll.status !== "pending") throw new VibeError(`Device login failed: ${poll.status}`);
279
+ }
280
+
281
+ throw new VibeError("Timed out waiting for device approval.");
282
+ }
283
+
284
+ export async function checkSlug({ apiBase, session, slug }) {
285
+ requireSlug(slug);
286
+ return getJson(`${apiBase}/api/slug-check/${slug}`, session);
287
+ }
288
+
289
+ export async function listDatasources({ apiBase, session }) {
290
+ return getJson(`${apiBase}/api/datasources`, session);
291
+ }
292
+
293
+ export function validateDatasourceSelection(requested, available) {
294
+ if (!requested) return [];
295
+ const ids = requested.split(",").map((id) => id.trim()).filter(Boolean);
296
+ const byId = new Map((Array.isArray(available) ? available : []).map((item) => [item.id, item]));
297
+ for (const id of ids) {
298
+ const item = byId.get(id);
299
+ if (!item) throw new VibeError(`Unknown datasource '${id}'.`);
300
+ if (!item.available) throw new VibeError(`Datasource '${id}' is not available.`);
301
+ }
302
+ return ids;
303
+ }
304
+
305
+ export async function deployApp({
306
+ apiBase,
307
+ session,
308
+ zipPath,
309
+ slug,
310
+ title,
311
+ appType,
312
+ datasources,
313
+ description,
314
+ tags,
315
+ usage,
316
+ }) {
317
+ const form = new FormData();
318
+ const zipBytes = await readFile(zipPath);
319
+ form.append("file", new Blob([zipBytes]), "vibe-deploy.zip");
320
+ form.append("slug", slug);
321
+ form.append("title", title);
322
+ if (description) form.append("description", description);
323
+ if (tags) form.append("tags", tags);
324
+ if (usage) form.append("usage", usage);
325
+ if (appType === "server") form.append("apptype", "server");
326
+ if (datasources) form.append("datasources", datasources);
327
+
328
+ const result = await fetchJson(`${apiBase}/upload`, {
329
+ method: "POST",
330
+ headers: { Cookie: `__egnis_session=${session}` },
331
+ body: form,
332
+ });
333
+
334
+ if (!result.ok) throw new VibeError(result.error || "Upload failed.");
335
+ return result;
336
+ }
337
+
338
+ export async function streamLogs({ apiBase, session, jobId, log = () => {} }) {
339
+ let lastResult = { done: false, data: null };
340
+ for (let attempt = 0; attempt < 3; attempt += 1) {
341
+ if (attempt > 0) log(`Reconnecting log stream (${attempt + 1}/3)...`);
342
+ lastResult = await streamLogsOnce({ apiBase, session, jobId, log });
343
+ if (lastResult.done) {
344
+ const data = lastResult.data;
345
+ if (data && (data.ok === false || data.success === false || data.error)) {
346
+ throw new VibeError(data.error || "Vibe build failed.");
347
+ }
348
+ return lastResult;
349
+ }
350
+ }
351
+ throw new VibeError("Log stream ended before the done event. Re-run `vibe logs <jobId>` to inspect the final result.");
352
+ }
353
+
354
+ async function streamLogsOnce({ apiBase, session, jobId, log }) {
355
+ const response = await fetch(`${apiBase}/events/${jobId}`, {
356
+ headers: { Cookie: `__egnis_session=${session}` },
357
+ });
358
+ if (!response.ok) throw new VibeError(`Log stream failed: HTTP ${response.status}`);
359
+
360
+ const state = { event: "message", data: [], done: false, doneData: null };
361
+ const decoder = new TextDecoder();
362
+ let buffer = "";
363
+
364
+ for await (const chunk of response.body) {
365
+ buffer += decoder.decode(chunk, { stream: true });
366
+ const lines = buffer.split(/\r?\n/);
367
+ buffer = lines.pop() || "";
368
+ for (const line of lines) handleSseLine(line, state, log);
369
+ if (state.done) break;
370
+ }
371
+
372
+ if (buffer) handleSseLine(buffer, state, log);
373
+ return { done: state.done, data: state.doneData };
374
+ }
375
+
376
+ function handleSseLine(line, state, log) {
377
+ if (line === "") {
378
+ dispatchSse(state);
379
+ return;
380
+ }
381
+ if (line.startsWith(":")) return;
382
+ log(line);
383
+ if (line.startsWith("event:")) state.event = line.slice(6).trim() || "message";
384
+ else if (line.startsWith("data:")) state.data.push(line.slice(5).trimStart());
385
+ }
386
+
387
+ function dispatchSse(state) {
388
+ if (state.event === "done") {
389
+ state.done = true;
390
+ const raw = state.data.join("\n").trim();
391
+ if (raw) {
392
+ try {
393
+ state.doneData = JSON.parse(raw);
394
+ } catch {
395
+ state.doneData = { raw };
396
+ }
397
+ }
398
+ }
399
+ state.event = "message";
400
+ state.data = [];
401
+ }
402
+
403
+ export function openApp(slug) {
404
+ requireSlug(slug);
405
+ return {
406
+ app: `https://${slug}.egnis.net`,
407
+ hub: `https://vibe.egnis.net/s/${slug}`,
408
+ };
409
+ }
410
+
411
+ export async function loadSession() {
412
+ const file = sessionPath();
413
+ let parsed;
414
+ try {
415
+ parsed = JSON.parse(await readFile(file, "utf8"));
416
+ } catch {
417
+ throw new VibeError("No Vibe session found. Run vibe login first.");
418
+ }
419
+ if (!parsed.session) throw new VibeError("Saved Vibe session is invalid. Run vibe login again.");
420
+ return parsed;
421
+ }
422
+
423
+ export async function saveSession(session) {
424
+ const file = sessionPath();
425
+ await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
426
+ await writeFile(file, `${JSON.stringify(session, null, 2)}\n`, { mode: 0o600 });
427
+ }
428
+
429
+ export async function getConfigSummary() {
430
+ const file = sessionPath();
431
+ try {
432
+ const saved = JSON.parse(await readFile(file, "utf8"));
433
+ return {
434
+ sessionPath: file,
435
+ loggedIn: Boolean(saved.session),
436
+ email: saved.email || null,
437
+ apiBase: saved.apiBase || null,
438
+ createdAt: saved.createdAt || null,
439
+ };
440
+ } catch {
441
+ return { sessionPath: file, loggedIn: false };
442
+ }
443
+ }
444
+
445
+ async function getJson(url, session) {
446
+ return fetchJson(url, { headers: { Cookie: `__egnis_session=${session}` } });
447
+ }
448
+
449
+ async function postJson(url, body) {
450
+ return fetchJson(url, {
451
+ method: "POST",
452
+ headers: body ? { "Content-Type": "application/json" } : undefined,
453
+ body: body ? JSON.stringify(body) : undefined,
454
+ });
455
+ }
456
+
457
+ async function fetchJson(url, options = {}) {
458
+ const response = await fetch(url, options);
459
+ const text = await response.text();
460
+ let data;
461
+ try {
462
+ data = text ? JSON.parse(text) : {};
463
+ } catch {
464
+ throw new VibeError(`Expected JSON from ${url}, got: ${text.slice(0, 200)}`);
465
+ }
466
+ if (!response.ok) throw new VibeError(data.error || `HTTP ${response.status}`);
467
+ return data;
468
+ }
469
+
470
+ function sessionPath() {
471
+ const root = process.env.VIBE_CONFIG_DIR
472
+ || (process.env.XDG_CONFIG_HOME ? path.join(process.env.XDG_CONFIG_HOME, "egnis-vibe") : path.join(homedir(), ".config", "egnis-vibe"));
473
+ return path.join(root, "session.json");
474
+ }
475
+
476
+ function sleep(ms) {
477
+ return new Promise((resolve) => setTimeout(resolve, ms));
478
+ }
479
+
480
+ export class VibeError extends Error {
481
+ constructor(message) {
482
+ super(message);
483
+ this.name = "VibeError";
484
+ }
485
+ }