@anna-ai/cli 0.1.9 → 0.1.12

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,411 @@
1
+ import { dirname, isAbsolute, resolve } from "node:path";
2
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
3
+ import { bold, cyan, dim, green, red, yellow } from "kleur/colors";
4
+ import { parse } from "smol-toml";
5
+
6
+ //#region src/commands/dev.ts
7
+ async function runDev(opts) {
8
+ const cwd = resolve(opts.cwd);
9
+ const manifestPath = isAbsolute(opts.manifestPath) ? opts.manifestPath : resolve(cwd, opts.manifestPath);
10
+ if (!existsSync(manifestPath)) {
11
+ console.error(red(`✗ manifest not found: ${manifestPath}`));
12
+ return 2;
13
+ }
14
+ let manifest;
15
+ try {
16
+ manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
17
+ } catch (e) {
18
+ console.error(red(`✗ manifest is not valid json: ${e.message}`));
19
+ return 2;
20
+ }
21
+ const slug = opts.slug ?? deriveSlug(manifest, manifestPath);
22
+ const bundleDir = opts.bundleDir ? resolve(cwd, opts.bundleDir) : resolve(dirname(manifestPath), "bundle");
23
+ const bundleEntry = manifest.ui?.bundle?.entry ?? "index.html";
24
+ if (!existsSync(bundleDir)) {
25
+ console.error(red(`✗ bundle dir not found: ${bundleDir}`));
26
+ return 2;
27
+ }
28
+ if (!existsSync(resolve(bundleDir, bundleEntry))) {
29
+ console.error(red(`✗ bundle entry "${bundleEntry}" not found under ${bundleDir}`));
30
+ return 2;
31
+ }
32
+ const matrixNexusRoot = await resolveMatrixNexusRoot(opts.matrixNexusRoot, cwd);
33
+ const mode = matrixNexusRoot ? "nexus-source" : "uvx";
34
+ const { PythonBridge, PINNED_RUNTIME_VERSION } = await import("./bridge-BEHyfpPI.js");
35
+ const { HarnessServer } = await import("./server-NXmiWJjX.js");
36
+ const bridge = new PythonBridge({
37
+ mode,
38
+ matrixNexusRoot: matrixNexusRoot ?? void 0,
39
+ onStderr: (line) => process.stderr.write(dim(`[bridge] ${line}\n`))
40
+ });
41
+ console.log(bold(cyan("anna-app dev")));
42
+ console.log(` manifest ${dim(manifestPath)}`);
43
+ console.log(` bundle ${dim(`${bundleDir}/${bundleEntry}`)}`);
44
+ if (mode === "nexus-source") {
45
+ console.log(` matrix-nexus root ${dim(matrixNexusRoot)}`);
46
+ console.log(` runtime ${dim("nexus-source (uv run)")}`);
47
+ } else console.log(` runtime ${dim(`uvx anna-app-runtime-local@${PINNED_RUNTIME_VERSION}`)}`);
48
+ console.log(` spawning python bridge…`);
49
+ try {
50
+ await bridge.start();
51
+ } catch (e) {
52
+ console.error(red(`✗ bridge failed to start: ${e.message}`));
53
+ if (mode === "uvx") console.error(dim(" (try: install uv from https://docs.astral.sh/uv/, or pass --matrix-nexus-root to use a checkout)"));
54
+ else console.error(dim(" (try: cd matrix-nexus && uv sync; ensure `uv` is on PATH; or unset --matrix-nexus-root to use uvx)"));
55
+ return 2;
56
+ }
57
+ try {
58
+ const pong = await bridge.call("ping");
59
+ if (!pong.pong) throw new Error("bridge ping returned non-pong");
60
+ } catch (e) {
61
+ console.error(red(`✗ bridge ping failed: ${e.message}`));
62
+ await bridge.stop();
63
+ return 2;
64
+ }
65
+ console.log(green(" ✓ bridge ready"));
66
+ const executas = opts.executas ?? autoDiscoverExecutas(dirname(manifestPath));
67
+ if (executas.length > 0) console.log(` executas ${dim(executas.map((e) => e.tool_id).join(", "))}`);
68
+ const llm = opts.noLlm ? { mode: "off" } : opts.mockLlm ? {
69
+ mode: "mock",
70
+ mockFile: opts.mockLlm
71
+ } : await resolveRealLlm({
72
+ cwd,
73
+ account: opts.llmAccount,
74
+ appSlug: opts.llmAppSlug ?? slug,
75
+ manifest
76
+ });
77
+ if (llm === null) return 2;
78
+ console.log(` llm bridge ${dim(llm.mode === "off" ? "disabled (--no-llm)" : llm.mode === "mock" ? `mock (${opts.mockLlm})` : `real${opts.llmAccount ? ` [${opts.llmAccount}]` : ""} → app_slug=${llm.appSlug}`)}`);
79
+ const server = new HarnessServer({
80
+ slug,
81
+ manifest,
82
+ bundleDir,
83
+ bundleEntry,
84
+ view: opts.view,
85
+ matrixNexusRoot,
86
+ userId: opts.userId,
87
+ port: opts.port,
88
+ executas,
89
+ watch: !opts.noWatch,
90
+ llm
91
+ }, bridge);
92
+ try {
93
+ await server.listen();
94
+ } catch (e) {
95
+ const msg = e.code === "EADDRINUSE" ? `port ${opts.port} already in use` : e.message;
96
+ console.error(red(`✗ harness failed to listen: ${msg}`));
97
+ await bridge.stop();
98
+ return 2;
99
+ }
100
+ console.log(` ${green("✓")} dashboard ${cyan(`http://localhost:${opts.port}/`)}`);
101
+ console.log(yellow(" press Ctrl+C to stop"));
102
+ const shutdown = async () => {
103
+ console.log(dim("\n shutting down…"));
104
+ try {
105
+ await server.close();
106
+ await bridge.stop();
107
+ } finally {
108
+ process.exit(0);
109
+ }
110
+ };
111
+ process.once("SIGINT", shutdown);
112
+ process.once("SIGTERM", shutdown);
113
+ await new Promise(() => {});
114
+ return 0;
115
+ }
116
+ /**
117
+ * Resolve the `real` LlmBridgeOptions: load the dev PAT, register
118
+ * (or re-use) an `AnnaApp(is_dev=True)` on nexus, cache the result.
119
+ *
120
+ * Returns `null` (after printing) when registration fails so the caller
121
+ * can exit cleanly. Otherwise returns a bridge config carrying the
122
+ * registered `appSlug`.
123
+ */
124
+ async function resolveRealLlm(args) {
125
+ const { getAccount } = await import("./credentials-CIOYq2Lm.js");
126
+ const { ensureDevAppRegistered } = await import("./dev-app-cache-cXvO2XwQ.js");
127
+ const acc = getAccount(args.account);
128
+ if (!acc) {
129
+ console.error(red("✗ no developer PAT on disk — run `anna-app login --host <nexus-url>` first.\n (or use `--no-llm` / `--mock-llm <fixture>` to develop offline.)"));
130
+ return null;
131
+ }
132
+ if (acc.expires_at && acc.expires_at < Math.floor(Date.now() / 1e3)) {
133
+ console.error(red("✗ PAT expired — run `anna-app login` again."));
134
+ return null;
135
+ }
136
+ const manifest = args.manifest;
137
+ try {
138
+ const entry = await ensureDevAppRegistered({
139
+ cwd: args.cwd,
140
+ host: acc.host,
141
+ pat: acc.pat,
142
+ input: {
143
+ slug: args.appSlug,
144
+ name: manifest.name,
145
+ category: manifest.category,
146
+ tagline: manifest.tagline
147
+ }
148
+ });
149
+ return {
150
+ mode: "real",
151
+ account: args.account,
152
+ appSlug: entry.slug
153
+ };
154
+ } catch (e) {
155
+ console.error(red(`✗ failed to register dev app on nexus: ${e.message}`));
156
+ return null;
157
+ }
158
+ }
159
+ function deriveSlug(manifest, path) {
160
+ const fromMan = manifest.slug ?? manifest.name;
161
+ if (typeof fromMan === "string" && fromMan) return fromMan;
162
+ return dirname(path).split(/[\\/]/).pop() || "anna-app-dev";
163
+ }
164
+ function autoDiscoverExecutas(manifestDir) {
165
+ const root = resolve(manifestDir, "executas");
166
+ if (!existsSync(root)) return [];
167
+ const found = [];
168
+ const seen = new Map();
169
+ for (const name of readdirSync(root)) {
170
+ if (name.startsWith(".")) continue;
171
+ const dir = resolve(root, name);
172
+ let st;
173
+ try {
174
+ st = statSync(dir);
175
+ } catch {
176
+ continue;
177
+ }
178
+ if (!st.isDirectory()) continue;
179
+ const result = discoverOne(dir, name);
180
+ if (result === "skip" || result === "disabled") continue;
181
+ if (result instanceof Error) {
182
+ console.warn(yellow(`⚠ skipping executa ${name}: ${result.message}`));
183
+ continue;
184
+ }
185
+ const prior = seen.get(result.tool_id);
186
+ if (prior) {
187
+ console.warn(yellow(`⚠ skipping executa ${name}: tool_id "${result.tool_id}" already provided by ${prior} (toggle "enabled": false in executa.json to silence)`));
188
+ continue;
189
+ }
190
+ seen.set(result.tool_id, name);
191
+ found.push(result);
192
+ }
193
+ return found;
194
+ }
195
+ function discoverOne(dir, name, opts = { respectEnabled: true }) {
196
+ const cfgPath = resolve(dir, "executa.json");
197
+ if (existsSync(cfgPath)) {
198
+ let cfg;
199
+ try {
200
+ cfg = JSON.parse(readFileSync(cfgPath, "utf-8"));
201
+ } catch (err) {
202
+ return new Error(`failed to parse executa.json (${err.message})`);
203
+ }
204
+ if (cfg.enabled === false && opts.respectEnabled !== false) return "disabled";
205
+ if (!cfg.tool_id) return new Error("executa.json missing required `tool_id`");
206
+ if (!cfg.type) return new Error("executa.json missing required `type`");
207
+ if (cfg.command && cfg.command.length > 0) return {
208
+ tool_id: cfg.tool_id,
209
+ project_dir: dir,
210
+ command: cfg.command
211
+ };
212
+ const cmd = defaultCommand(cfg.type, dir, cfg.tool_id);
213
+ if (cmd instanceof Error) return cmd;
214
+ return {
215
+ tool_id: cfg.tool_id,
216
+ project_dir: dir,
217
+ command: cmd
218
+ };
219
+ }
220
+ if (existsSync(resolve(dir, "pyproject.toml"))) return discoverPython(dir, name);
221
+ if (existsSync(resolve(dir, "package.json"))) return discoverNode(dir, name);
222
+ if (existsSync(resolve(dir, "go.mod"))) return new Error("Go executa requires an explicit executa.json with `tool_id` and `type: \"go\"` (go.mod has no script-name field)");
223
+ const binCandidate = resolve(dir, "bin", name);
224
+ if (existsSync(binCandidate)) return {
225
+ tool_id: name,
226
+ project_dir: dir,
227
+ command: [binCandidate]
228
+ };
229
+ return "skip";
230
+ }
231
+ function defaultCommand(type, dir, toolId) {
232
+ switch (type) {
233
+ case "python": return [
234
+ "uv",
235
+ "run",
236
+ "--project",
237
+ dir,
238
+ toolId
239
+ ];
240
+ case "node": {
241
+ const pkg = readPackageJson(dir);
242
+ if (!pkg) return new Error("type=node but no package.json found");
243
+ const entry = nodeEntry(pkg, toolId, dir);
244
+ if (entry instanceof Error) return entry;
245
+ return ["node", entry];
246
+ }
247
+ case "go": return [
248
+ "go",
249
+ "run",
250
+ "."
251
+ ];
252
+ case "binary": {
253
+ const guess = resolve(dir, "bin", toolId);
254
+ if (!existsSync(guess)) return new Error(`type=binary but no executable at ${guess}; specify "command" in executa.json explicitly`);
255
+ return [guess];
256
+ }
257
+ default: return new Error(`unknown type "${type}" — expected python | node | go | binary`);
258
+ }
259
+ }
260
+ function discoverPython(dir, name) {
261
+ const py = resolve(dir, "pyproject.toml");
262
+ let body;
263
+ try {
264
+ body = readFileSync(py, "utf-8");
265
+ } catch {
266
+ return new Error("could not read pyproject.toml");
267
+ }
268
+ let parsed;
269
+ try {
270
+ parsed = parse(body);
271
+ } catch (err) {
272
+ return new Error(`failed to parse pyproject.toml (${err.message})`);
273
+ }
274
+ const scripts = parsed?.project?.scripts;
275
+ if (!scripts || typeof scripts !== "object") return new Error("no [project.scripts] entry in pyproject.toml");
276
+ const keys = Object.keys(scripts);
277
+ if (keys.length === 0) return new Error("[project.scripts] is empty");
278
+ const toolId = keys[0];
279
+ return {
280
+ tool_id: toolId,
281
+ project_dir: dir,
282
+ command: [
283
+ "uv",
284
+ "run",
285
+ "--project",
286
+ dir,
287
+ toolId
288
+ ]
289
+ };
290
+ }
291
+ function discoverNode(dir, name) {
292
+ const pkg = readPackageJson(dir);
293
+ if (!pkg) return new Error("could not read package.json");
294
+ const toolId = (typeof pkg.executa === "object" && pkg.executa ? pkg.executa.tool_id : void 0) ?? firstBinKey(pkg) ?? (typeof pkg.name === "string" ? pkg.name : void 0);
295
+ if (!toolId) return new Error("package.json missing tool_id — set `executa.tool_id`, `bin`, or `name`");
296
+ const entry = nodeEntry(pkg, toolId, dir);
297
+ if (entry instanceof Error) return entry;
298
+ return {
299
+ tool_id: toolId,
300
+ project_dir: dir,
301
+ command: ["node", entry]
302
+ };
303
+ }
304
+ function readPackageJson(dir) {
305
+ const p = resolve(dir, "package.json");
306
+ if (!existsSync(p)) return null;
307
+ try {
308
+ return JSON.parse(readFileSync(p, "utf-8"));
309
+ } catch {
310
+ return null;
311
+ }
312
+ }
313
+ function firstBinKey(pkg) {
314
+ if (typeof pkg.bin === "string") return typeof pkg.name === "string" ? pkg.name : void 0;
315
+ if (pkg.bin && typeof pkg.bin === "object") {
316
+ const keys = Object.keys(pkg.bin);
317
+ return keys[0];
318
+ }
319
+ return void 0;
320
+ }
321
+ function nodeEntry(pkg, toolId, dir) {
322
+ let rel;
323
+ if (typeof pkg.bin === "string") rel = pkg.bin;
324
+ else if (pkg.bin && typeof pkg.bin === "object") {
325
+ const obj = pkg.bin;
326
+ const v = obj[toolId];
327
+ if (typeof v === "string") rel = v;
328
+ else {
329
+ const first = Object.values(obj).find((x) => typeof x === "string");
330
+ if (typeof first === "string") rel = first;
331
+ }
332
+ }
333
+ if (!rel && typeof pkg.main === "string") rel = pkg.main;
334
+ if (!rel && typeof pkg.module === "string") rel = pkg.module;
335
+ if (!rel) return new Error("package.json has no usable entry — set `bin`, `main`, or override `command` in executa.json");
336
+ const abs = isAbsolute(rel) ? rel : resolve(dir, rel);
337
+ if (!existsSync(abs)) return new Error(`node entry not found: ${abs}`);
338
+ return abs;
339
+ }
340
+ /**
341
+ * Parse `--executa <spec>` flag values from CLI. Spec syntax (comma-separated
342
+ * key=value pairs, all keys lowercase):
343
+ *
344
+ * --executa dir=./executas/foo,type=node
345
+ * --executa dir=/abs/path,tool_id=tool-x,command="node plugin.js"
346
+ *
347
+ * Required: `dir`. Optional: `tool_id`, `type`, `command` (space-split unless
348
+ * already an array via JSON), `cwd`. When `tool_id`/`type`/`command` are
349
+ * omitted, falls back to the same rules as `executa.json` discovery.
350
+ */
351
+ function parseExecutaSpec(spec, cwd) {
352
+ const parts = spec.split(",").map((s) => s.trim()).filter(Boolean);
353
+ const kv = {};
354
+ for (const p of parts) {
355
+ const eq = p.indexOf("=");
356
+ if (eq === -1) return new Error(`bad --executa spec part: "${p}"`);
357
+ kv[p.slice(0, eq).trim().toLowerCase()] = p.slice(eq + 1).trim();
358
+ }
359
+ const dirRaw = kv.dir;
360
+ if (!dirRaw) return new Error("--executa spec missing `dir=`");
361
+ const dir = isAbsolute(dirRaw) ? dirRaw : resolve(cwd, dirRaw);
362
+ if (!existsSync(dir)) return new Error(`--executa dir not found: ${dir}`);
363
+ if (!kv.tool_id && !kv.type && !kv.command) {
364
+ const result = discoverOne(dir, dir.split(/[\\/]/).pop() ?? dir, { respectEnabled: false });
365
+ if (result === "skip") return new Error(`nothing discoverable at ${dir} (no executa.json / pyproject.toml / package.json / bin/)`);
366
+ if (result === "disabled") return new Error(`executa.json at ${dir} has "enabled": false`);
367
+ if (result instanceof Error) return result;
368
+ return result;
369
+ }
370
+ const tool_id = kv.tool_id;
371
+ if (!tool_id) return new Error("--executa spec missing `tool_id=`");
372
+ let command;
373
+ if (kv.command) if (kv.command.startsWith("[")) try {
374
+ const arr = JSON.parse(kv.command);
375
+ if (!Array.isArray(arr) || !arr.every((x) => typeof x === "string")) return new Error("--executa command JSON must be a string array");
376
+ command = arr;
377
+ } catch (err) {
378
+ return new Error(`--executa command JSON invalid: ${err.message}`);
379
+ }
380
+ else command = kv.command.split(/\s+/);
381
+ else if (kv.type) {
382
+ const c = defaultCommand(kv.type, dir, tool_id);
383
+ if (c instanceof Error) return c;
384
+ command = c;
385
+ }
386
+ return {
387
+ tool_id,
388
+ project_dir: dir,
389
+ command
390
+ };
391
+ }
392
+ async function resolveMatrixNexusRoot(explicit, cwd) {
393
+ const candidates = [explicit, process.env.ANNA_NEXUS_ROOT];
394
+ let dir = cwd;
395
+ while (true) {
396
+ candidates.push(dir);
397
+ const parent = dirname(dir);
398
+ if (parent === dir) break;
399
+ dir = parent;
400
+ }
401
+ candidates.push(resolve(cwd, "..", "matrix-nexus"));
402
+ for (const c of candidates) {
403
+ if (!c) continue;
404
+ const abs = isAbsolute(c) ? c : resolve(cwd, c);
405
+ if (existsSync(resolve(abs, "packages/anna-app-runtime-local/pyproject.toml"))) return abs;
406
+ }
407
+ return null;
408
+ }
409
+
410
+ //#endregion
411
+ export { parseExecutaSpec, runDev };
@@ -0,0 +1,93 @@
1
+ import { canonicalHost } from "./credentials-BTv2IfUZ.js";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
+
5
+ //#region src/dev-app-cache.ts
6
+ const CACHE_DIR = ".anna";
7
+ const CACHE_FILE = "dev-app.json";
8
+ function cachePath(cwd) {
9
+ return resolve(cwd, CACHE_DIR, CACHE_FILE);
10
+ }
11
+ function readDevAppCache(cwd) {
12
+ const p = cachePath(cwd);
13
+ if (!existsSync(p)) return null;
14
+ try {
15
+ const raw = JSON.parse(readFileSync(p, "utf-8"));
16
+ if (typeof raw.host === "string" && typeof raw.slug === "string" && typeof raw.app_id === "number") return {
17
+ host: raw.host,
18
+ slug: raw.slug,
19
+ app_id: raw.app_id,
20
+ name: raw.name ?? raw.slug,
21
+ registered_at: raw.registered_at ?? ""
22
+ };
23
+ } catch {}
24
+ return null;
25
+ }
26
+ function writeDevAppCache(cwd, entry) {
27
+ const p = cachePath(cwd);
28
+ mkdirSync(dirname(p), { recursive: true });
29
+ writeFileSync(p, JSON.stringify(entry, null, 2) + "\n", "utf-8");
30
+ }
31
+ /** Call POST /api/v1/anna-apps/dev/apps/register. Idempotent server-side. */
32
+ async function registerDevApp(args) {
33
+ const url = `${canonicalHost(args.host)}/api/v1/anna-apps/dev/apps/register`;
34
+ const res = await fetch(url, {
35
+ method: "POST",
36
+ headers: { "content-type": "application/json" },
37
+ body: JSON.stringify({
38
+ pat: args.pat,
39
+ slug: args.input.slug,
40
+ name: args.input.name,
41
+ category: args.input.category,
42
+ tagline: args.input.tagline
43
+ })
44
+ });
45
+ if (!res.ok) {
46
+ const text = await res.text().catch(() => "");
47
+ throw new Error(`/dev/apps/register failed: HTTP ${res.status} ${text}`);
48
+ }
49
+ return await res.json();
50
+ }
51
+ /** Call GET /api/v1/anna-apps/dev/apps. */
52
+ async function listDevApps(args) {
53
+ const url = new URL(`${canonicalHost(args.host)}/api/v1/anna-apps/dev/apps`);
54
+ url.searchParams.set("pat", args.pat);
55
+ const res = await fetch(url, { method: "GET" });
56
+ if (!res.ok) {
57
+ const text = await res.text().catch(() => "");
58
+ throw new Error(`/dev/apps failed: HTTP ${res.status} ${text}`);
59
+ }
60
+ const body = await res.json();
61
+ return body.apps;
62
+ }
63
+ /**
64
+ * Convenience helper for `anna-app dev`: returns a valid cached entry if
65
+ * it still matches the manifest slug, otherwise hits the server to
66
+ * (re-)register. Throws if no PAT is available on disk.
67
+ */
68
+ async function ensureDevAppRegistered(args) {
69
+ const canonical = canonicalHost(args.host);
70
+ const cached = readDevAppCache(args.cwd);
71
+ if (cached && cached.host === canonical && cached.slug === args.input.slug) return cached;
72
+ const r = await registerDevApp({
73
+ host: args.host,
74
+ pat: args.pat,
75
+ input: args.input
76
+ });
77
+ const entry = {
78
+ host: canonical,
79
+ slug: r.slug,
80
+ app_id: r.app_id,
81
+ name: r.name,
82
+ registered_at: new Date().toISOString()
83
+ };
84
+ writeDevAppCache(args.cwd, entry);
85
+ return entry;
86
+ }
87
+ const _internal = {
88
+ cachePath,
89
+ CACHE_DIR: join(CACHE_DIR, CACHE_FILE)
90
+ };
91
+
92
+ //#endregion
93
+ export { ensureDevAppRegistered, listDevApps, readDevAppCache, registerDevApp, writeDevAppCache };
@@ -0,0 +1,4 @@
1
+ import "./credentials-BTv2IfUZ.js";
2
+ import { ensureDevAppRegistered, listDevApps, readDevAppCache, registerDevApp, writeDevAppCache } from "./dev-app-cache-BMfOlTHd.js";
3
+
4
+ export { ensureDevAppRegistered };
@@ -1,8 +1,8 @@
1
- import { PINNED_RUNTIME_VERSION } from "./bridge-CBcQUQGU.js";
1
+ import { PINNED_RUNTIME_VERSION } from "./bridge-BQUo6ehX.js";
2
2
  import { dirname, isAbsolute, resolve } from "node:path";
3
3
  import { existsSync, statSync } from "node:fs";
4
- import { bold, dim, green, red, yellow } from "kleur/colors";
5
4
  import { spawnSync } from "node:child_process";
5
+ import { bold, dim, green, red, yellow } from "kleur/colors";
6
6
  import { homedir } from "node:os";
7
7
 
8
8
  //#region src/commands/doctor.ts
@@ -0,0 +1,102 @@
1
+ import { canonicalHost, saveAccount } from "./credentials-BTv2IfUZ.js";
2
+
3
+ //#region src/commands/login.ts
4
+ const POLL_TIMEOUT_MS = 12 * 60 * 1e3;
5
+ function envBool(v) {
6
+ return !!v && /^(1|true|yes|on)$/i.test(v);
7
+ }
8
+ async function tryOpenBrowser(url) {
9
+ if (envBool(process.env.ANNA_LOGIN_NO_OPEN)) return;
10
+ const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
11
+ try {
12
+ const { spawn } = await import("node:child_process");
13
+ spawn(opener, [url], {
14
+ detached: true,
15
+ stdio: "ignore"
16
+ }).unref();
17
+ } catch {}
18
+ }
19
+ async function runLogin(opts) {
20
+ const host = canonicalHost(opts.host);
21
+ process.stdout.write(`▸ logging in to ${host}\n`);
22
+ let startResp;
23
+ try {
24
+ const res = await fetch(`${host}/api/v1/anna-apps/dev/login/start`, {
25
+ method: "POST",
26
+ headers: { "content-type": "application/json" }
27
+ });
28
+ if (!res.ok) {
29
+ console.error(`✗ /dev/login/start failed: HTTP ${res.status}`);
30
+ return 1;
31
+ }
32
+ startResp = await res.json();
33
+ } catch (e) {
34
+ console.error(`✗ cannot reach nexus: ${e.message}`);
35
+ return 1;
36
+ }
37
+ const verifyUrl = `${host}${startResp.verification_uri_complete}`;
38
+ process.stdout.write(`\n user_code: \x1b[1m${startResp.user_code}\x1b[0m\n`);
39
+ process.stdout.write(` open: ${verifyUrl}\n`);
40
+ process.stdout.write(` (waiting up to ${Math.round(startResp.expires_in / 60)} min)\n\n`);
41
+ if (!opts.noBrowser) await tryOpenBrowser(verifyUrl);
42
+ const interval = Math.max(2, Math.min(15, startResp.interval || 5));
43
+ const deadline = Date.now() + Math.min(POLL_TIMEOUT_MS, startResp.expires_in * 1e3);
44
+ const spinner = [
45
+ "⠋",
46
+ "⠙",
47
+ "⠹",
48
+ "⠸",
49
+ "⠼",
50
+ "⠴",
51
+ "⠦",
52
+ "⠧",
53
+ "⠇",
54
+ "⠏"
55
+ ];
56
+ let frame = 0;
57
+ while (Date.now() < deadline) {
58
+ process.stdout.write(`\r ${spinner[frame++ % spinner.length]} polling...`);
59
+ await new Promise((r) => setTimeout(r, interval * 1e3));
60
+ let resp;
61
+ try {
62
+ resp = await fetch(`${host}/api/v1/anna-apps/dev/login/poll`, {
63
+ method: "POST",
64
+ headers: { "content-type": "application/json" },
65
+ body: JSON.stringify({ device_code: startResp.device_code })
66
+ });
67
+ } catch (e) {
68
+ process.stdout.write("\n");
69
+ console.error(`✗ poll failed: ${e.message}`);
70
+ return 1;
71
+ }
72
+ if (resp.status === 202) continue;
73
+ if (resp.status === 410) {
74
+ process.stdout.write("\n✗ device code expired\n");
75
+ return 1;
76
+ }
77
+ if (resp.status === 200) {
78
+ process.stdout.write("\n");
79
+ const ok = await resp.json();
80
+ const now = Math.floor(Date.now() / 1e3);
81
+ saveAccount({
82
+ host,
83
+ user_id: null,
84
+ pat: ok.pat,
85
+ issued_at: now,
86
+ expires_at: now + (ok.expires_in || 0),
87
+ scopes: ok.scopes
88
+ });
89
+ console.log(`✓ logged in. PAT saved to ~/.config/anna/credentials.json`);
90
+ console.log(` expires in ~${Math.round((ok.expires_in || 0) / 86400)}d`);
91
+ return 0;
92
+ }
93
+ process.stdout.write("\n");
94
+ console.error(`✗ unexpected status: HTTP ${resp.status}`);
95
+ return 1;
96
+ }
97
+ process.stdout.write("\n✗ timed out waiting for approval\n");
98
+ return 1;
99
+ }
100
+
101
+ //#endregion
102
+ export { runLogin };
@@ -0,0 +1,23 @@
1
+ import { canonicalHost, getAccount, readCredentials, removeAccount } from "./credentials-BTv2IfUZ.js";
2
+
3
+ //#region src/commands/logout.ts
4
+ async function runLogout(opts) {
5
+ if (opts.all) {
6
+ const data = readCredentials();
7
+ const n = Object.keys(data.accounts).length;
8
+ for (const k of Object.keys(data.accounts)) removeAccount(k);
9
+ console.log(`✓ removed ${n} account(s)`);
10
+ return 0;
11
+ }
12
+ const acc = getAccount(opts.host);
13
+ if (!acc) {
14
+ console.error(opts.host ? `✗ no account for ${canonicalHost(opts.host)}` : "✗ no current account");
15
+ return 1;
16
+ }
17
+ removeAccount(acc.host);
18
+ console.log(`✓ removed account ${acc.host}`);
19
+ return 0;
20
+ }
21
+
22
+ //#endregion
23
+ export { runLogout };