@genex-ai/cli-demo 0.1.0 → 0.1.2

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/src/lib/auth.ts DELETED
@@ -1,365 +0,0 @@
1
- import http from "node:http";
2
- import crypto from "node:crypto";
3
- import readline from "node:readline";
4
- import { spawn } from "node:child_process";
5
- import { URL } from "node:url";
6
- import type { AddressInfo } from "node:net";
7
- import type { Logger } from "../utils/logger.ts";
8
-
9
- export interface AuthorizeOptions {
10
- log: Logger;
11
- /** How long to wait for the redirect before giving up. Default 5 minutes. */
12
- timeoutMs?: number;
13
- /**
14
- * Opens a URL in the user's browser and returns whether the attempt was made.
15
- * `onError` is invoked if the launch fails asynchronously (e.g. the opener
16
- * binary is missing). Injectable for testing; defaults to a cross-platform
17
- * opener.
18
- */
19
- open?: (url: string, onError: () => void) => boolean;
20
- /**
21
- * Whether to also accept a token pasted into the terminal (handy over SSH or
22
- * when the browser can't reach localhost). Defaults to true when stdin is a TTY.
23
- */
24
- interactive?: boolean;
25
- }
26
-
27
- const SUCCESS_HTML = `<!doctype html><html><head><meta charset="utf-8"><title>Genex</title>
28
- <style>body{font-family:system-ui,sans-serif;background:#0b0b0f;color:#eaeaea;display:grid;place-items:center;height:100vh;margin:0}
29
- .card{text-align:center;padding:2rem 3rem;border:1px solid #26262e;border-radius:14px;background:#13131a}
30
- h1{font-size:1.3rem;margin:0 0 .5rem}p{color:#9a9aa6;margin:0}</style></head>
31
- <body><div class="card"><h1>✓ Authorized</h1><p>You can close this tab and return to your terminal.</p></div></body></html>`;
32
-
33
- const ERROR_HTML = `<!doctype html><html><head><meta charset="utf-8"><title>Genex</title></head>
34
- <body style="font-family:system-ui,sans-serif"><h1>Authorization failed</h1>
35
- <p>No token was provided. Please return to your terminal and try again.</p></body></html>`;
36
-
37
- /**
38
- * Run the interactive authorization flow and resolve with the auth token.
39
- *
40
- * Spins up a temporary loopback HTTP server, opens the browser to the auth
41
- * site (passing the local `redirect_uri` and a CSRF `state`), and waits for the
42
- * site to hand the token back — either by redirecting to the callback with a
43
- * `?token=` query, or by POSTing `{ token }` (JSON or form-encoded) to it.
44
- *
45
- * If the browser can't be opened, the URL is printed for the user to open
46
- * manually. When `interactive`, a pasted token is also accepted as a fallback.
47
- */
48
- export async function authorize(
49
- authBaseUrl: string,
50
- options: AuthorizeOptions,
51
- ): Promise<string> {
52
- const {
53
- log,
54
- timeoutMs = 5 * 60 * 1000,
55
- open = openBrowser,
56
- interactive = Boolean(process.stdin.isTTY),
57
- } = options;
58
-
59
- const state = crypto.randomBytes(16).toString("hex");
60
- const server = http.createServer();
61
-
62
- return await new Promise<string>((resolve, reject) => {
63
- let settled = false;
64
- let timer: NodeJS.Timeout | undefined;
65
- let rl: readline.Interface | undefined;
66
-
67
- const cleanup = (): void => {
68
- if (timer) clearTimeout(timer);
69
- if (rl) rl.close();
70
- server.close();
71
- };
72
-
73
- const succeed = (token: string): void => {
74
- if (settled) return;
75
- settled = true;
76
- cleanup();
77
- resolve(token);
78
- };
79
-
80
- const fail = (err: Error): void => {
81
- if (settled) return;
82
- settled = true;
83
- cleanup();
84
- reject(err);
85
- };
86
-
87
- server.on("request", (req, res) => {
88
- handleCallback(req, res, state, log)
89
- .then((token) => {
90
- if (token) succeed(token);
91
- })
92
- .catch((err: unknown) => {
93
- // A single bad request must not abort a legitimate pending auth, but
94
- // we must always end the response so the client isn't left hanging.
95
- log.dim(`callback error: ${String(err)}`);
96
- if (!res.writableEnded) {
97
- res.writeHead(500);
98
- res.end();
99
- }
100
- });
101
- });
102
-
103
- server.on("error", (err) => fail(err as Error));
104
-
105
- // Port 0 → OS assigns a free ephemeral port on loopback.
106
- server.listen(0, "127.0.0.1", () => {
107
- const { port } = server.address() as AddressInfo;
108
- const redirectUri = `http://127.0.0.1:${port}/callback`;
109
- const authUrl = buildAuthUrl(authBaseUrl, redirectUri, state);
110
-
111
- log.step("Opening your browser to authorize…");
112
- log.dim(authUrl);
113
-
114
- let warned = false;
115
- const warnManual = (): void => {
116
- if (warned || settled) return;
117
- warned = true;
118
- log.warn(
119
- "Couldn't open a browser automatically. Open the URL above manually to continue.",
120
- );
121
- };
122
-
123
- const opened = open(authUrl, warnManual);
124
- if (!opened) warnManual();
125
-
126
- if (interactive) {
127
- rl = readline.createInterface({
128
- input: process.stdin,
129
- output: process.stdout,
130
- });
131
- rl.question(
132
- "\nWaiting for the browser redirect… or paste your token here and press Enter:\n> ",
133
- (answer) => {
134
- const token = answer.trim();
135
- if (token) succeed(token);
136
- },
137
- );
138
- }
139
-
140
- timer = setTimeout(() => {
141
- fail(
142
- new Error(
143
- "Timed out waiting for authorization. Re-run the command to try again.",
144
- ),
145
- );
146
- }, timeoutMs);
147
- });
148
- });
149
- }
150
-
151
- function buildAuthUrl(
152
- authBaseUrl: string,
153
- redirectUri: string,
154
- state: string,
155
- ): string {
156
- const url = new URL(`${authBaseUrl}/cli/auth`);
157
- url.searchParams.set("redirect_uri", redirectUri);
158
- url.searchParams.set("state", state);
159
- return url.toString();
160
- }
161
-
162
- /**
163
- * Handle a request to the loopback server. Returns the token when the callback
164
- * carried one (and the state matched), otherwise `null`.
165
- */
166
- async function handleCallback(
167
- req: http.IncomingMessage,
168
- res: http.ServerResponse,
169
- expectedState: string,
170
- log: Logger,
171
- ): Promise<string | null> {
172
- // Permit the auth site's https origin to POST to our loopback server.
173
- res.setHeader("Access-Control-Allow-Origin", "*");
174
- res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
175
- res.setHeader("Access-Control-Allow-Headers", "Content-Type");
176
-
177
- const reqUrl = new URL(req.url ?? "/", "http://127.0.0.1");
178
-
179
- if (req.method === "OPTIONS") {
180
- res.writeHead(204);
181
- res.end();
182
- return null;
183
- }
184
-
185
- if (reqUrl.pathname !== "/callback") {
186
- res.writeHead(404);
187
- res.end();
188
- return null;
189
- }
190
-
191
- let token: string | null = null;
192
- let state: string | null = null;
193
-
194
- if (req.method === "GET") {
195
- token = reqUrl.searchParams.get("token");
196
- state = reqUrl.searchParams.get("state");
197
- } else if (req.method === "POST") {
198
- let parsed: ParsedBody;
199
- try {
200
- parsed = await readBody(req);
201
- } catch (err) {
202
- // Malformed / oversized body — reject this request but keep waiting.
203
- log.dim(`ignoring malformed callback body: ${String(err)}`);
204
- res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
205
- res.end(ERROR_HTML);
206
- return null;
207
- }
208
- token = parsed.token;
209
- state = parsed.state ?? reqUrl.searchParams.get("state");
210
- } else {
211
- res.writeHead(405);
212
- res.end();
213
- return null;
214
- }
215
-
216
- // Guard against cross-site request forgery on the loopback callback. The
217
- // state is mandatory: a missing state (null) must fail just like a mismatch.
218
- if (state !== expectedState) {
219
- log.dim("ignoring callback with missing or mismatched state");
220
- res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
221
- res.end(ERROR_HTML);
222
- return null;
223
- }
224
-
225
- if (!token) {
226
- res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
227
- res.end(ERROR_HTML);
228
- return null;
229
- }
230
-
231
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
232
- res.end(SUCCESS_HTML);
233
- return token;
234
- }
235
-
236
- interface ParsedBody {
237
- token: string | null;
238
- state: string | null;
239
- }
240
-
241
- async function readBody(req: http.IncomingMessage): Promise<ParsedBody> {
242
- const chunks: Buffer[] = [];
243
- let total = 0;
244
- const MAX = 1024 * 1024; // 1 MB cap — tokens are tiny; reject anything huge.
245
-
246
- for await (const chunk of req) {
247
- const buf = chunk as Buffer;
248
- total += buf.length;
249
- if (total > MAX) {
250
- throw new Error("request body too large");
251
- }
252
- chunks.push(buf);
253
- }
254
-
255
- const raw = Buffer.concat(chunks).toString("utf8").trim();
256
- if (!raw) return { token: null, state: null };
257
-
258
- const contentType = req.headers["content-type"] ?? "";
259
-
260
- if (contentType.includes("application/json")) {
261
- try {
262
- const obj = JSON.parse(raw) as Record<string, unknown>;
263
- return {
264
- token: typeof obj.token === "string" ? obj.token : null,
265
- state: typeof obj.state === "string" ? obj.state : null,
266
- };
267
- } catch {
268
- return { token: null, state: null };
269
- }
270
- }
271
-
272
- // Fall back to form-encoded (application/x-www-form-urlencoded).
273
- const params = new URLSearchParams(raw);
274
- return {
275
- token: params.get("token"),
276
- state: params.get("state"),
277
- };
278
- }
279
-
280
- /**
281
- * Best-effort cross-platform browser opener. Returns false if we couldn't even
282
- * launch the platform's open command.
283
- */
284
- export function openBrowser(url: string, onError: () => void = () => {}): boolean {
285
- let command: string;
286
- let args: string[];
287
-
288
- // Respect an explicit opener override (the conventional BROWSER variable, or
289
- // our own GENEX_BROWSER which takes precedence). Tokenized like a shell
290
- // command so both arguments (`open -a Safari`) and quoted paths containing
291
- // spaces (`"/Applications/My Browser.app/.../My Browser"`) work. The URL is
292
- // appended as the final argument.
293
- const custom = (process.env.GENEX_BROWSER || process.env.BROWSER)?.trim();
294
- const customParts = custom ? tokenizeCommand(custom) : [];
295
-
296
- if (customParts.length > 0) {
297
- command = customParts[0] as string;
298
- args = [...customParts.slice(1), url];
299
- } else {
300
- switch (process.platform) {
301
- case "darwin":
302
- command = "open";
303
- args = [url];
304
- break;
305
- case "win32":
306
- command = "cmd";
307
- // `start` needs an empty title arg so a quoted URL isn't taken as one.
308
- args = ["/c", "start", "", url];
309
- break;
310
- default:
311
- command = "xdg-open";
312
- args = [url];
313
- break;
314
- }
315
- }
316
-
317
- try {
318
- const child = spawn(command, args, {
319
- stdio: "ignore",
320
- detached: true,
321
- });
322
- // If the binary is missing, 'error' fires asynchronously — surface it so the
323
- // caller can tell the user to open the URL manually, but don't let the
324
- // unhandled error crash the process.
325
- child.on("error", () => onError());
326
- child.unref();
327
- return true;
328
- } catch {
329
- return false;
330
- }
331
- }
332
-
333
- /**
334
- * Split a command string into argv, honoring single/double quotes so that
335
- * arguments and quoted paths-with-spaces both survive. This is a deliberately
336
- * small tokenizer — not a full shell parser (no escapes, globs, or variable
337
- * expansion) — which is all an opener command needs.
338
- */
339
- export function tokenizeCommand(input: string): string[] {
340
- const tokens: string[] = [];
341
- let current = "";
342
- let quote: '"' | "'" | null = null;
343
- let inToken = false;
344
-
345
- for (const ch of input) {
346
- if (quote) {
347
- if (ch === quote) quote = null;
348
- else current += ch;
349
- } else if (ch === '"' || ch === "'") {
350
- quote = ch;
351
- inToken = true;
352
- } else if (/\s/.test(ch)) {
353
- if (inToken) {
354
- tokens.push(current);
355
- current = "";
356
- inToken = false;
357
- }
358
- } else {
359
- current += ch;
360
- inToken = true;
361
- }
362
- }
363
- if (inToken) tokens.push(current);
364
- return tokens;
365
- }
@@ -1,81 +0,0 @@
1
- import fs from "node:fs/promises";
2
- import path from "node:path";
3
-
4
- export interface CopyResult {
5
- /** Files written, as paths relative to the source root. */
6
- copied: string[];
7
- /** Files left untouched because they already existed at the destination. */
8
- skipped: string[];
9
- }
10
-
11
- export interface CopyOptions {
12
- /**
13
- * Overwrite files that already exist at the destination. Defaults to `false`,
14
- * which is the whole point of this helper: never clobber the user's files.
15
- */
16
- force?: boolean;
17
- }
18
-
19
- /**
20
- * Recursively copy everything from `srcDir` into `destDir`, merging into any
21
- * existing directory tree.
22
- *
23
- * Existing files at the destination are **never overwritten** unless
24
- * `force` is set — they are reported in `skipped` instead. Directories are
25
- * created as needed. Symlinks in the source are ignored for safety.
26
- */
27
- export async function copyTemplates(
28
- srcDir: string,
29
- destDir: string,
30
- opts: CopyOptions = {},
31
- ): Promise<CopyResult> {
32
- const result: CopyResult = { copied: [], skipped: [] };
33
- await walk(srcDir, srcDir, destDir, opts, result);
34
- return result;
35
- }
36
-
37
- async function walk(
38
- rootSrc: string,
39
- src: string,
40
- dest: string,
41
- opts: CopyOptions,
42
- result: CopyResult,
43
- ): Promise<void> {
44
- const entries = await fs.readdir(src, { withFileTypes: true });
45
-
46
- for (const entry of entries) {
47
- const srcPath = path.join(src, entry.name);
48
- const destPath = path.join(dest, entry.name);
49
- const rel = path.relative(rootSrc, srcPath);
50
-
51
- if (entry.isDirectory()) {
52
- // Create the directory up front so empty directories are mirrored too.
53
- await fs.mkdir(destPath, { recursive: true });
54
- await walk(rootSrc, srcPath, destPath, opts, result);
55
- continue;
56
- }
57
-
58
- if (!entry.isFile()) {
59
- // Skip symlinks, sockets, fifos, etc.
60
- continue;
61
- }
62
-
63
- if (!opts.force && (await exists(destPath))) {
64
- result.skipped.push(rel);
65
- continue;
66
- }
67
-
68
- await fs.mkdir(path.dirname(destPath), { recursive: true });
69
- await fs.copyFile(srcPath, destPath);
70
- result.copied.push(rel);
71
- }
72
- }
73
-
74
- async function exists(p: string): Promise<boolean> {
75
- try {
76
- await fs.access(p);
77
- return true;
78
- } catch {
79
- return false;
80
- }
81
- }
package/src/lib/env.ts DELETED
@@ -1,109 +0,0 @@
1
- import fs from "node:fs/promises";
2
- import path from "node:path";
3
- import { spawn } from "node:child_process";
4
-
5
- export type EnvWriteMode = "created" | "updated" | "appended";
6
-
7
- export interface EnvWriteResult {
8
- mode: EnvWriteMode;
9
- path: string;
10
- }
11
-
12
- /**
13
- * Set `KEY=value` in a `.env` file without disturbing the rest of it.
14
- *
15
- * - If the file does not exist, it is created (`created`).
16
- * - If the key already exists, its line is replaced in place (`updated`).
17
- * - Otherwise the assignment is appended to the end (`appended`).
18
- *
19
- * The file is written/owned at mode 0600 since it holds a secret. Values that
20
- * contain whitespace or shell-significant characters are double-quoted.
21
- */
22
- export async function writeEnvVar(
23
- envPath: string,
24
- key: string,
25
- value: string,
26
- ): Promise<EnvWriteResult> {
27
- let content = "";
28
- let existed = false;
29
- try {
30
- content = await fs.readFile(envPath, "utf8");
31
- existed = true;
32
- } catch {
33
- // Missing file — we'll create it below.
34
- }
35
-
36
- const assignment = `${key}=${formatValue(value)}`;
37
- // Match `KEY=...` (optionally preceded by `export `) on any line. The `g`
38
- // flag ensures *every* occurrence is rewritten, so a file that already has
39
- // duplicate entries for the key never keeps a stale value.
40
- const keyPattern = new RegExp(
41
- `^(\\s*export\\s+)?${escapeRegExp(key)}=.*$`,
42
- "gm",
43
- );
44
-
45
- let next: string;
46
- let mode: EnvWriteMode;
47
-
48
- if (keyPattern.test(content)) {
49
- next = content.replace(keyPattern, assignment);
50
- mode = "updated";
51
- } else {
52
- let prefix = content;
53
- if (prefix.length > 0 && !prefix.endsWith("\n")) prefix += "\n";
54
- next = prefix + assignment + "\n";
55
- mode = existed ? "appended" : "created";
56
- }
57
-
58
- // Create the parent dir if missing (e.g. ~/.genex on first run).
59
- await fs.mkdir(path.dirname(envPath), { recursive: true });
60
- await fs.writeFile(envPath, next, { mode: 0o600 });
61
- await restrictFilePermissions(envPath);
62
-
63
- return { mode, path: envPath };
64
- }
65
-
66
- /**
67
- * Restrict a secret file to the current user only.
68
- *
69
- * On POSIX this is a `chmod 0600`. On Windows, POSIX modes are ignored, so we
70
- * make a best-effort attempt to strip inherited ACLs and grant only the current
71
- * user via `icacls`. Every step is non-fatal — if it fails the token is still
72
- * written (see the Windows caveat in the README).
73
- */
74
- async function restrictFilePermissions(filePath: string): Promise<void> {
75
- if (process.platform !== "win32") {
76
- // writeFile's mode only applies on creation; enforce it for existing files too.
77
- await fs.chmod(filePath, 0o600).catch(() => {});
78
- return;
79
- }
80
-
81
- const user = process.env.USERNAME ?? process.env.USER;
82
- if (!user) return;
83
-
84
- await new Promise<void>((resolve) => {
85
- try {
86
- const child = spawn(
87
- "icacls",
88
- [filePath, "/inheritance:r", "/grant:r", `${user}:F`],
89
- { stdio: "ignore" },
90
- );
91
- child.on("error", () => resolve());
92
- child.on("close", () => resolve());
93
- } catch {
94
- resolve();
95
- }
96
- });
97
- }
98
-
99
- function formatValue(value: string): string {
100
- if (/[\s#"'$`\\]/.test(value)) {
101
- // Quote and escape backslashes/double-quotes for dotenv-style parsers.
102
- return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
103
- }
104
- return value;
105
- }
106
-
107
- function escapeRegExp(s: string): string {
108
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
109
- }
@@ -1,109 +0,0 @@
1
- import crypto from "node:crypto";
2
- import type { Logger } from "../utils/logger.ts";
3
- import { c } from "../utils/colors.ts";
4
- import type { ProjectMetadata } from "./store.ts";
5
-
6
- export interface CreateProjectOptions {
7
- /** API base URL (e.g. https://demo-api.glotech.world or http://localhost:3000). */
8
- apiUrl: string;
9
- /** Bearer token (from ~/.genex/env). */
10
- token: string;
11
- /** Desired project name (the API slugifies it). */
12
- name: string;
13
- /** OpenSSH PUBLIC key registered as the repo's write deploy key (required). */
14
- deployKey: string;
15
- /** Colyseus relay URL, stored in metadata for the game's connect(). */
16
- colyseusUrl: string;
17
- /** Auth/web origin, used to print the dashboard link. */
18
- dashboardUrl: string;
19
- log: Logger;
20
- }
21
-
22
- interface ProjectResponse {
23
- project?: {
24
- id: string;
25
- slug: string;
26
- title: string;
27
- playUrl?: string | null;
28
- };
29
- sshUrl?: string;
30
- error?: string;
31
- }
32
-
33
- /**
34
- * Create a draft project via `POST /api/projects { name, deployKey }`. Returns
35
- * the project metadata (incl. the `sshUrl` the agent pushes to) for the caller
36
- * to persist, or null on failure. Best-effort: a network/HTTP failure is logged
37
- * and returns null — it never aborts `genex init` (the token is already saved).
38
- */
39
- export async function createDraftProject(
40
- opts: CreateProjectOptions,
41
- ): Promise<ProjectMetadata | null> {
42
- const { apiUrl, token, deployKey, colyseusUrl, dashboardUrl, log } = opts;
43
-
44
- log.step("Creating your project…");
45
-
46
- // Try the requested name, then one retry with a unique suffix on a slug clash.
47
- const names = [opts.name, `${opts.name}-${randomSuffix()}`];
48
-
49
- for (let i = 0; i < names.length; i++) {
50
- const name = names[i] as string;
51
- let res: Response;
52
- try {
53
- res = await fetch(`${apiUrl}/api/projects`, {
54
- method: "POST",
55
- headers: {
56
- "Content-Type": "application/json",
57
- Authorization: `Bearer ${token}`,
58
- },
59
- body: JSON.stringify({ name, deployKey }),
60
- });
61
- } catch (err) {
62
- log.warn(`Couldn't reach the API at ${apiUrl} to create the project.`);
63
- log.dim(` ${String(err)}`);
64
- return null;
65
- }
66
-
67
- if (res.status === 409 && i === 0) continue; // slug taken — retry suffixed
68
- if (res.status === 401) {
69
- log.warn("Not authorized to create the project (token rejected).");
70
- return null;
71
- }
72
- if (res.status === 400) {
73
- log.warn("The API rejected the deploy key (must be an OpenSSH public key).");
74
- return null;
75
- }
76
- if (!res.ok) {
77
- log.warn(`Couldn't create the project (HTTP ${res.status}).`);
78
- return null;
79
- }
80
-
81
- const data = (await res.json().catch(() => null)) as ProjectResponse | null;
82
- const project = data?.project;
83
- if (!project || !data?.sshUrl) {
84
- log.warn("Project created, but the API response was unexpected.");
85
- return null;
86
- }
87
-
88
- log.success(`Created project ${c.cyan(project.slug)}.`);
89
- if (project.playUrl) log.dim(` play (after publish): ${project.playUrl}`);
90
- log.dim(` dashboard: ${dashboardUrl}/dashboard`);
91
-
92
- return {
93
- id: project.id,
94
- slug: project.slug,
95
- sshUrl: data.sshUrl,
96
- apiUrl,
97
- colyseusUrl,
98
- playUrl: project.playUrl ?? undefined,
99
- status: "draft",
100
- };
101
- }
102
-
103
- log.warn("Couldn't create a project with a unique name. Try `--name <unique>`.");
104
- return null;
105
- }
106
-
107
- function randomSuffix(): string {
108
- return crypto.randomBytes(3).toString("hex");
109
- }