aether-code 0.42.0 → 0.43.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/README.md +2 -0
- package/bin/aether-code.js +514 -514
- package/package.json +75 -75
- package/src/agent.js +54 -4
- package/src/project-context.js +712 -707
- package/src/render.js +254 -254
- package/src/repl.js +444 -444
- package/src/sessions.js +167 -167
- package/src/setup.js +141 -141
- package/src/tools.js +1288 -1288
- package/src/version.js +15 -15
package/src/sessions.js
CHANGED
|
@@ -1,167 +1,167 @@
|
|
|
1
|
-
// Session persistence. Saves conversation state to ~/.aether/sessions/ so
|
|
2
|
-
// users can close the terminal, come back tomorrow, and resume exactly
|
|
3
|
-
// where they left off. Auto-saves after every agent turn.
|
|
4
|
-
|
|
5
|
-
import fs from "node:fs";
|
|
6
|
-
import path from "node:path";
|
|
7
|
-
import os from "node:os";
|
|
8
|
-
import crypto from "node:crypto";
|
|
9
|
-
|
|
10
|
-
const SESSIONS_DIR = path.join(os.homedir(), ".aether", "sessions");
|
|
11
|
-
const MAX_SESSIONS = 50;
|
|
12
|
-
const STALE_DAYS = 14;
|
|
13
|
-
|
|
14
|
-
function ensureDir() {
|
|
15
|
-
fs.mkdirSync(SESSIONS_DIR, { recursive: true });
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function cwdHash(cwd) {
|
|
19
|
-
return crypto.createHash("sha256").update(cwd.toLowerCase()).digest("hex").slice(0, 12);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function sessionPath(id) {
|
|
23
|
-
return path.join(SESSIONS_DIR, `${id}.json`);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export function generateSessionId(cwd) {
|
|
27
|
-
const hash = cwdHash(cwd);
|
|
28
|
-
const ts = Date.now().toString(36);
|
|
29
|
-
return `${hash}-${ts}`;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export function saveSession(session) {
|
|
33
|
-
ensureDir();
|
|
34
|
-
const data = {
|
|
35
|
-
id: session.id,
|
|
36
|
-
cwd: session.cwd,
|
|
37
|
-
model: session.model ?? null,
|
|
38
|
-
modelName: session.modelName ?? "Aether Core",
|
|
39
|
-
messages: session.messages ?? [],
|
|
40
|
-
credits: session.credits ?? 0,
|
|
41
|
-
tokensIn: session.tokensIn ?? 0,
|
|
42
|
-
tokensOut: session.tokensOut ?? 0,
|
|
43
|
-
createdAt: session.createdAt ?? new Date().toISOString(),
|
|
44
|
-
updatedAt: new Date().toISOString(),
|
|
45
|
-
summary: session.summary ?? "",
|
|
46
|
-
turnCount: session.turnCount ?? 0,
|
|
47
|
-
};
|
|
48
|
-
const filePath = sessionPath(data.id);
|
|
49
|
-
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
50
|
-
try { fs.chmodSync(filePath, 0o600); } catch { /* windows */ }
|
|
51
|
-
pruneOldSessions();
|
|
52
|
-
return data;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export function loadSession(id) {
|
|
56
|
-
const filePath = sessionPath(id);
|
|
57
|
-
try {
|
|
58
|
-
const raw = fs.readFileSync(filePath, "utf8");
|
|
59
|
-
return JSON.parse(raw);
|
|
60
|
-
} catch {
|
|
61
|
-
return null;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export function deleteSession(id) {
|
|
66
|
-
try {
|
|
67
|
-
fs.unlinkSync(sessionPath(id));
|
|
68
|
-
return true;
|
|
69
|
-
} catch {
|
|
70
|
-
return false;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export function listSessions(filterCwd) {
|
|
75
|
-
ensureDir();
|
|
76
|
-
let files;
|
|
77
|
-
try {
|
|
78
|
-
files = fs.readdirSync(SESSIONS_DIR).filter((f) => f.endsWith(".json"));
|
|
79
|
-
} catch {
|
|
80
|
-
return [];
|
|
81
|
-
}
|
|
82
|
-
const sessions = [];
|
|
83
|
-
for (const f of files) {
|
|
84
|
-
try {
|
|
85
|
-
const raw = fs.readFileSync(path.join(SESSIONS_DIR, f), "utf8");
|
|
86
|
-
const data = JSON.parse(raw);
|
|
87
|
-
if (filterCwd && data.cwd?.toLowerCase() !== filterCwd.toLowerCase()) continue;
|
|
88
|
-
sessions.push({
|
|
89
|
-
id: data.id,
|
|
90
|
-
cwd: data.cwd,
|
|
91
|
-
model: data.model,
|
|
92
|
-
modelName: data.modelName,
|
|
93
|
-
summary: data.summary,
|
|
94
|
-
turnCount: data.turnCount ?? 0,
|
|
95
|
-
messageCount: data.messages?.length ?? 0,
|
|
96
|
-
credits: data.credits ?? 0,
|
|
97
|
-
createdAt: data.createdAt,
|
|
98
|
-
updatedAt: data.updatedAt,
|
|
99
|
-
});
|
|
100
|
-
} catch {
|
|
101
|
-
continue;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
sessions.sort((a, b) => (b.updatedAt || "").localeCompare(a.updatedAt || ""));
|
|
105
|
-
return sessions;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
export function findRecentSession(cwd, maxAgeMs = 24 * 60 * 60 * 1000) {
|
|
109
|
-
const sessions = listSessions(cwd);
|
|
110
|
-
if (sessions.length === 0) return null;
|
|
111
|
-
const newest = sessions[0];
|
|
112
|
-
if (!newest.updatedAt) return null;
|
|
113
|
-
const age = Date.now() - new Date(newest.updatedAt).getTime();
|
|
114
|
-
if (age > maxAgeMs) return null;
|
|
115
|
-
if (newest.messageCount < 2) return null;
|
|
116
|
-
return newest;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function pruneOldSessions() {
|
|
120
|
-
let files;
|
|
121
|
-
try {
|
|
122
|
-
files = fs.readdirSync(SESSIONS_DIR).filter((f) => f.endsWith(".json"));
|
|
123
|
-
} catch {
|
|
124
|
-
return;
|
|
125
|
-
}
|
|
126
|
-
if (files.length <= MAX_SESSIONS) return;
|
|
127
|
-
const entries = [];
|
|
128
|
-
for (const f of files) {
|
|
129
|
-
try {
|
|
130
|
-
const stat = fs.statSync(path.join(SESSIONS_DIR, f));
|
|
131
|
-
entries.push({ file: f, mtime: stat.mtimeMs });
|
|
132
|
-
} catch {
|
|
133
|
-
continue;
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
entries.sort((a, b) => b.mtime - a.mtime);
|
|
137
|
-
const cutoff = Date.now() - STALE_DAYS * 24 * 60 * 60 * 1000;
|
|
138
|
-
for (const e of entries.slice(MAX_SESSIONS)) {
|
|
139
|
-
if (e.mtime < cutoff) {
|
|
140
|
-
try { fs.unlinkSync(path.join(SESSIONS_DIR, e.file)); } catch { /* ignore */ }
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
export function extractSummary(messages) {
|
|
146
|
-
if (!messages || messages.length === 0) return "";
|
|
147
|
-
const firstUser = messages.find((m) => m.role === "user");
|
|
148
|
-
if (!firstUser || typeof firstUser.content !== "string") return "";
|
|
149
|
-
const text = firstUser.content
|
|
150
|
-
.replace(/\[environment\][\s\S]*?\[\/environment\]\s*/g, "")
|
|
151
|
-
.replace(/\[project\][\s\S]*?\[\/project\]\s*/g, "")
|
|
152
|
-
.replace(/=== LOADED SKILLS[\s\S]*?---\s*/g, "")
|
|
153
|
-
.trim();
|
|
154
|
-
return text.length > 120 ? text.slice(0, 117) + "..." : text;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
export function formatAge(isoDate) {
|
|
158
|
-
if (!isoDate) return "unknown";
|
|
159
|
-
const ms = Date.now() - new Date(isoDate).getTime();
|
|
160
|
-
const mins = Math.floor(ms / 60_000);
|
|
161
|
-
if (mins < 1) return "just now";
|
|
162
|
-
if (mins < 60) return `${mins}m ago`;
|
|
163
|
-
const hours = Math.floor(mins / 60);
|
|
164
|
-
if (hours < 24) return `${hours}h ago`;
|
|
165
|
-
const days = Math.floor(hours / 24);
|
|
166
|
-
return `${days}d ago`;
|
|
167
|
-
}
|
|
1
|
+
// Session persistence. Saves conversation state to ~/.aether/sessions/ so
|
|
2
|
+
// users can close the terminal, come back tomorrow, and resume exactly
|
|
3
|
+
// where they left off. Auto-saves after every agent turn.
|
|
4
|
+
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import crypto from "node:crypto";
|
|
9
|
+
|
|
10
|
+
const SESSIONS_DIR = path.join(os.homedir(), ".aether", "sessions");
|
|
11
|
+
const MAX_SESSIONS = 50;
|
|
12
|
+
const STALE_DAYS = 14;
|
|
13
|
+
|
|
14
|
+
function ensureDir() {
|
|
15
|
+
fs.mkdirSync(SESSIONS_DIR, { recursive: true });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function cwdHash(cwd) {
|
|
19
|
+
return crypto.createHash("sha256").update(cwd.toLowerCase()).digest("hex").slice(0, 12);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function sessionPath(id) {
|
|
23
|
+
return path.join(SESSIONS_DIR, `${id}.json`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function generateSessionId(cwd) {
|
|
27
|
+
const hash = cwdHash(cwd);
|
|
28
|
+
const ts = Date.now().toString(36);
|
|
29
|
+
return `${hash}-${ts}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function saveSession(session) {
|
|
33
|
+
ensureDir();
|
|
34
|
+
const data = {
|
|
35
|
+
id: session.id,
|
|
36
|
+
cwd: session.cwd,
|
|
37
|
+
model: session.model ?? null,
|
|
38
|
+
modelName: session.modelName ?? "Aether Core",
|
|
39
|
+
messages: session.messages ?? [],
|
|
40
|
+
credits: session.credits ?? 0,
|
|
41
|
+
tokensIn: session.tokensIn ?? 0,
|
|
42
|
+
tokensOut: session.tokensOut ?? 0,
|
|
43
|
+
createdAt: session.createdAt ?? new Date().toISOString(),
|
|
44
|
+
updatedAt: new Date().toISOString(),
|
|
45
|
+
summary: session.summary ?? "",
|
|
46
|
+
turnCount: session.turnCount ?? 0,
|
|
47
|
+
};
|
|
48
|
+
const filePath = sessionPath(data.id);
|
|
49
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
50
|
+
try { fs.chmodSync(filePath, 0o600); } catch { /* windows */ }
|
|
51
|
+
pruneOldSessions();
|
|
52
|
+
return data;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function loadSession(id) {
|
|
56
|
+
const filePath = sessionPath(id);
|
|
57
|
+
try {
|
|
58
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
59
|
+
return JSON.parse(raw);
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function deleteSession(id) {
|
|
66
|
+
try {
|
|
67
|
+
fs.unlinkSync(sessionPath(id));
|
|
68
|
+
return true;
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function listSessions(filterCwd) {
|
|
75
|
+
ensureDir();
|
|
76
|
+
let files;
|
|
77
|
+
try {
|
|
78
|
+
files = fs.readdirSync(SESSIONS_DIR).filter((f) => f.endsWith(".json"));
|
|
79
|
+
} catch {
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
const sessions = [];
|
|
83
|
+
for (const f of files) {
|
|
84
|
+
try {
|
|
85
|
+
const raw = fs.readFileSync(path.join(SESSIONS_DIR, f), "utf8");
|
|
86
|
+
const data = JSON.parse(raw);
|
|
87
|
+
if (filterCwd && data.cwd?.toLowerCase() !== filterCwd.toLowerCase()) continue;
|
|
88
|
+
sessions.push({
|
|
89
|
+
id: data.id,
|
|
90
|
+
cwd: data.cwd,
|
|
91
|
+
model: data.model,
|
|
92
|
+
modelName: data.modelName,
|
|
93
|
+
summary: data.summary,
|
|
94
|
+
turnCount: data.turnCount ?? 0,
|
|
95
|
+
messageCount: data.messages?.length ?? 0,
|
|
96
|
+
credits: data.credits ?? 0,
|
|
97
|
+
createdAt: data.createdAt,
|
|
98
|
+
updatedAt: data.updatedAt,
|
|
99
|
+
});
|
|
100
|
+
} catch {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
sessions.sort((a, b) => (b.updatedAt || "").localeCompare(a.updatedAt || ""));
|
|
105
|
+
return sessions;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function findRecentSession(cwd, maxAgeMs = 24 * 60 * 60 * 1000) {
|
|
109
|
+
const sessions = listSessions(cwd);
|
|
110
|
+
if (sessions.length === 0) return null;
|
|
111
|
+
const newest = sessions[0];
|
|
112
|
+
if (!newest.updatedAt) return null;
|
|
113
|
+
const age = Date.now() - new Date(newest.updatedAt).getTime();
|
|
114
|
+
if (age > maxAgeMs) return null;
|
|
115
|
+
if (newest.messageCount < 2) return null;
|
|
116
|
+
return newest;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function pruneOldSessions() {
|
|
120
|
+
let files;
|
|
121
|
+
try {
|
|
122
|
+
files = fs.readdirSync(SESSIONS_DIR).filter((f) => f.endsWith(".json"));
|
|
123
|
+
} catch {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (files.length <= MAX_SESSIONS) return;
|
|
127
|
+
const entries = [];
|
|
128
|
+
for (const f of files) {
|
|
129
|
+
try {
|
|
130
|
+
const stat = fs.statSync(path.join(SESSIONS_DIR, f));
|
|
131
|
+
entries.push({ file: f, mtime: stat.mtimeMs });
|
|
132
|
+
} catch {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
entries.sort((a, b) => b.mtime - a.mtime);
|
|
137
|
+
const cutoff = Date.now() - STALE_DAYS * 24 * 60 * 60 * 1000;
|
|
138
|
+
for (const e of entries.slice(MAX_SESSIONS)) {
|
|
139
|
+
if (e.mtime < cutoff) {
|
|
140
|
+
try { fs.unlinkSync(path.join(SESSIONS_DIR, e.file)); } catch { /* ignore */ }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function extractSummary(messages) {
|
|
146
|
+
if (!messages || messages.length === 0) return "";
|
|
147
|
+
const firstUser = messages.find((m) => m.role === "user");
|
|
148
|
+
if (!firstUser || typeof firstUser.content !== "string") return "";
|
|
149
|
+
const text = firstUser.content
|
|
150
|
+
.replace(/\[environment\][\s\S]*?\[\/environment\]\s*/g, "")
|
|
151
|
+
.replace(/\[project\][\s\S]*?\[\/project\]\s*/g, "")
|
|
152
|
+
.replace(/=== LOADED SKILLS[\s\S]*?---\s*/g, "")
|
|
153
|
+
.trim();
|
|
154
|
+
return text.length > 120 ? text.slice(0, 117) + "..." : text;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function formatAge(isoDate) {
|
|
158
|
+
if (!isoDate) return "unknown";
|
|
159
|
+
const ms = Date.now() - new Date(isoDate).getTime();
|
|
160
|
+
const mins = Math.floor(ms / 60_000);
|
|
161
|
+
if (mins < 1) return "just now";
|
|
162
|
+
if (mins < 60) return `${mins}m ago`;
|
|
163
|
+
const hours = Math.floor(mins / 60);
|
|
164
|
+
if (hours < 24) return `${hours}h ago`;
|
|
165
|
+
const days = Math.floor(hours / 24);
|
|
166
|
+
return `${days}d ago`;
|
|
167
|
+
}
|
package/src/setup.js
CHANGED
|
@@ -1,141 +1,141 @@
|
|
|
1
|
-
// First-run / no-key setup flow.
|
|
2
|
-
//
|
|
3
|
-
// UX mirrors `gh auth login` / `railway login` / `npm login`:
|
|
4
|
-
// 1. Print a friendly explanation
|
|
5
|
-
// 2. Open the browser to https://trynoguard.com/account
|
|
6
|
-
// 3. Prompt the user to paste the ak_live_ key
|
|
7
|
-
// 4. Validate format + verify against /api/v1/me
|
|
8
|
-
// 5. Save to ~/.aetherrc with mode 0600
|
|
9
|
-
//
|
|
10
|
-
// If the user is in a non-TTY environment (CI, piped stdin), we skip the
|
|
11
|
-
// auto-open and just print clear instructions for AETHER_API_KEY env var.
|
|
12
|
-
|
|
13
|
-
import readline from "node:readline";
|
|
14
|
-
import { spawn } from "node:child_process";
|
|
15
|
-
import { writeConfigFile, CONFIG_PATH } from "./config.js";
|
|
16
|
-
import { fetchBalance, AetherError } from "./api.js";
|
|
17
|
-
import { c, errorLine } from "./render.js";
|
|
18
|
-
|
|
19
|
-
const ACCOUNT_URL = "https://trynoguard.com/account";
|
|
20
|
-
const SIGNUP_URL = "https://trynoguard.com/signup";
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Cross-platform "open this URL in the user's default browser".
|
|
24
|
-
* Best-effort — if it fails (no GUI, blocked, etc.) we just continue.
|
|
25
|
-
*/
|
|
26
|
-
function openInBrowser(url) {
|
|
27
|
-
try {
|
|
28
|
-
const platform = process.platform;
|
|
29
|
-
if (platform === "win32") {
|
|
30
|
-
// The empty "" is the window title — required because cmd parses the
|
|
31
|
-
// first quoted arg as the title otherwise.
|
|
32
|
-
spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" }).unref();
|
|
33
|
-
} else if (platform === "darwin") {
|
|
34
|
-
spawn("open", [url], { detached: true, stdio: "ignore" }).unref();
|
|
35
|
-
} else {
|
|
36
|
-
spawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref();
|
|
37
|
-
}
|
|
38
|
-
return true;
|
|
39
|
-
} catch {
|
|
40
|
-
return false;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function ask(rl, question) {
|
|
45
|
-
return new Promise((resolve) => rl.question(question, (answer) => resolve(answer.trim())));
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Returns true if setup completed successfully (key saved + verified).
|
|
50
|
-
* Returns false if the user gave up.
|
|
51
|
-
*/
|
|
52
|
-
export async function runSetup() {
|
|
53
|
-
console.log("");
|
|
54
|
-
console.log(c.bold(c.magenta("aether")) + c.gray(" — first-time setup"));
|
|
55
|
-
console.log(c.gray("─".repeat(60)));
|
|
56
|
-
console.log("");
|
|
57
|
-
console.log("To use Aether, you need an API key tied to your account.");
|
|
58
|
-
console.log("Keys start with " + c.cyan("ak_live_") + ".");
|
|
59
|
-
console.log("");
|
|
60
|
-
|
|
61
|
-
// Non-TTY — bail with instructions instead of prompting
|
|
62
|
-
if (!process.stdin.isTTY) {
|
|
63
|
-
console.log(errorLine("Can't run interactive setup (stdin isn't a TTY)."));
|
|
64
|
-
console.log("");
|
|
65
|
-
console.log("Options:");
|
|
66
|
-
console.log(` · Set ${c.cyan("AETHER_API_KEY")} env var to your ak_live_ key.`);
|
|
67
|
-
console.log(` · Or run ${c.cyan("aether config set <key>")} from a real terminal.`);
|
|
68
|
-
console.log(` · Get a key at ${c.blue(ACCOUNT_URL)}.`);
|
|
69
|
-
console.log("");
|
|
70
|
-
return false;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
console.log(c.bold("Step 1: ") + "Open " + c.blue(ACCOUNT_URL) + " in your browser.");
|
|
74
|
-
console.log(c.gray(" (no account yet? sign up free at " + SIGNUP_URL + ")"));
|
|
75
|
-
|
|
76
|
-
const opened = openInBrowser(ACCOUNT_URL);
|
|
77
|
-
if (opened) {
|
|
78
|
-
console.log(c.gray(" ↪ opened it for you."));
|
|
79
|
-
}
|
|
80
|
-
console.log("");
|
|
81
|
-
console.log(c.bold("Step 2: ") + "Click " + c.cyan("Generate API key") + " and copy the key shown.");
|
|
82
|
-
console.log(c.gray(" (the key is shown ONCE — copy it before navigating away)"));
|
|
83
|
-
console.log("");
|
|
84
|
-
console.log(c.bold("Step 3: ") + "Paste the key below.");
|
|
85
|
-
console.log("");
|
|
86
|
-
|
|
87
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
88
|
-
|
|
89
|
-
// Up to 3 attempts at a valid key
|
|
90
|
-
let saved = false;
|
|
91
|
-
for (let attempt = 0; attempt < 3; attempt++) {
|
|
92
|
-
const key = await ask(rl, c.magenta("API key: "));
|
|
93
|
-
if (!key) {
|
|
94
|
-
console.log(c.gray("(empty — skipping)"));
|
|
95
|
-
break;
|
|
96
|
-
}
|
|
97
|
-
if (key.toLowerCase() === "q" || key.toLowerCase() === "quit") {
|
|
98
|
-
console.log(c.gray("Cancelled."));
|
|
99
|
-
break;
|
|
100
|
-
}
|
|
101
|
-
if (!key.startsWith("ak_live_")) {
|
|
102
|
-
console.log(errorLine(`Keys start with ${c.cyan("ak_live_")} — that doesn't look right. Try again or type 'q' to cancel.`));
|
|
103
|
-
continue;
|
|
104
|
-
}
|
|
105
|
-
if (key.length < 30) {
|
|
106
|
-
console.log(errorLine("That key looks too short. Try copying again."));
|
|
107
|
-
continue;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
process.stdout.write(c.gray("Verifying..."));
|
|
111
|
-
try {
|
|
112
|
-
const me = await fetchBalance(key);
|
|
113
|
-
writeConfigFile({ apiKey: key });
|
|
114
|
-
if (process.env.AETHER_API_KEY && process.env.AETHER_API_KEY !== key) {
|
|
115
|
-
process.env.AETHER_API_KEY = key;
|
|
116
|
-
console.log(c.yellow(" ⚠ AETHER_API_KEY env var was set to a different key — overriding for this session."));
|
|
117
|
-
console.log(c.yellow(" Update your shell profile to avoid this next time."));
|
|
118
|
-
}
|
|
119
|
-
console.log(c.green(" ●"));
|
|
120
|
-
console.log("");
|
|
121
|
-
console.log(c.green(c.bold("Setup complete.")));
|
|
122
|
-
console.log(
|
|
123
|
-
c.gray(`Saved to ${CONFIG_PATH} (mode 0600).`) +
|
|
124
|
-
c.gray(`\nPlan: ${me.plan} · Balance: ${me.balance.toLocaleString()} credits`),
|
|
125
|
-
);
|
|
126
|
-
console.log("");
|
|
127
|
-
saved = true;
|
|
128
|
-
break;
|
|
129
|
-
} catch (err) {
|
|
130
|
-
console.log(c.red(" ×"));
|
|
131
|
-
if (err instanceof AetherError && err.status === 401) {
|
|
132
|
-
console.log(errorLine("Server rejected that key (401). Double-check you copied it correctly."));
|
|
133
|
-
} else {
|
|
134
|
-
console.log(errorLine(err.message || String(err)));
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
rl.close();
|
|
140
|
-
return saved;
|
|
141
|
-
}
|
|
1
|
+
// First-run / no-key setup flow.
|
|
2
|
+
//
|
|
3
|
+
// UX mirrors `gh auth login` / `railway login` / `npm login`:
|
|
4
|
+
// 1. Print a friendly explanation
|
|
5
|
+
// 2. Open the browser to https://trynoguard.com/account
|
|
6
|
+
// 3. Prompt the user to paste the ak_live_ key
|
|
7
|
+
// 4. Validate format + verify against /api/v1/me
|
|
8
|
+
// 5. Save to ~/.aetherrc with mode 0600
|
|
9
|
+
//
|
|
10
|
+
// If the user is in a non-TTY environment (CI, piped stdin), we skip the
|
|
11
|
+
// auto-open and just print clear instructions for AETHER_API_KEY env var.
|
|
12
|
+
|
|
13
|
+
import readline from "node:readline";
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import { writeConfigFile, CONFIG_PATH } from "./config.js";
|
|
16
|
+
import { fetchBalance, AetherError } from "./api.js";
|
|
17
|
+
import { c, errorLine } from "./render.js";
|
|
18
|
+
|
|
19
|
+
const ACCOUNT_URL = "https://trynoguard.com/account";
|
|
20
|
+
const SIGNUP_URL = "https://trynoguard.com/signup";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Cross-platform "open this URL in the user's default browser".
|
|
24
|
+
* Best-effort — if it fails (no GUI, blocked, etc.) we just continue.
|
|
25
|
+
*/
|
|
26
|
+
function openInBrowser(url) {
|
|
27
|
+
try {
|
|
28
|
+
const platform = process.platform;
|
|
29
|
+
if (platform === "win32") {
|
|
30
|
+
// The empty "" is the window title — required because cmd parses the
|
|
31
|
+
// first quoted arg as the title otherwise.
|
|
32
|
+
spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" }).unref();
|
|
33
|
+
} else if (platform === "darwin") {
|
|
34
|
+
spawn("open", [url], { detached: true, stdio: "ignore" }).unref();
|
|
35
|
+
} else {
|
|
36
|
+
spawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref();
|
|
37
|
+
}
|
|
38
|
+
return true;
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function ask(rl, question) {
|
|
45
|
+
return new Promise((resolve) => rl.question(question, (answer) => resolve(answer.trim())));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Returns true if setup completed successfully (key saved + verified).
|
|
50
|
+
* Returns false if the user gave up.
|
|
51
|
+
*/
|
|
52
|
+
export async function runSetup() {
|
|
53
|
+
console.log("");
|
|
54
|
+
console.log(c.bold(c.magenta("aether")) + c.gray(" — first-time setup"));
|
|
55
|
+
console.log(c.gray("─".repeat(60)));
|
|
56
|
+
console.log("");
|
|
57
|
+
console.log("To use Aether, you need an API key tied to your account.");
|
|
58
|
+
console.log("Keys start with " + c.cyan("ak_live_") + ".");
|
|
59
|
+
console.log("");
|
|
60
|
+
|
|
61
|
+
// Non-TTY — bail with instructions instead of prompting
|
|
62
|
+
if (!process.stdin.isTTY) {
|
|
63
|
+
console.log(errorLine("Can't run interactive setup (stdin isn't a TTY)."));
|
|
64
|
+
console.log("");
|
|
65
|
+
console.log("Options:");
|
|
66
|
+
console.log(` · Set ${c.cyan("AETHER_API_KEY")} env var to your ak_live_ key.`);
|
|
67
|
+
console.log(` · Or run ${c.cyan("aether config set <key>")} from a real terminal.`);
|
|
68
|
+
console.log(` · Get a key at ${c.blue(ACCOUNT_URL)}.`);
|
|
69
|
+
console.log("");
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
console.log(c.bold("Step 1: ") + "Open " + c.blue(ACCOUNT_URL) + " in your browser.");
|
|
74
|
+
console.log(c.gray(" (no account yet? sign up free at " + SIGNUP_URL + ")"));
|
|
75
|
+
|
|
76
|
+
const opened = openInBrowser(ACCOUNT_URL);
|
|
77
|
+
if (opened) {
|
|
78
|
+
console.log(c.gray(" ↪ opened it for you."));
|
|
79
|
+
}
|
|
80
|
+
console.log("");
|
|
81
|
+
console.log(c.bold("Step 2: ") + "Click " + c.cyan("Generate API key") + " and copy the key shown.");
|
|
82
|
+
console.log(c.gray(" (the key is shown ONCE — copy it before navigating away)"));
|
|
83
|
+
console.log("");
|
|
84
|
+
console.log(c.bold("Step 3: ") + "Paste the key below.");
|
|
85
|
+
console.log("");
|
|
86
|
+
|
|
87
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
88
|
+
|
|
89
|
+
// Up to 3 attempts at a valid key
|
|
90
|
+
let saved = false;
|
|
91
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
92
|
+
const key = await ask(rl, c.magenta("API key: "));
|
|
93
|
+
if (!key) {
|
|
94
|
+
console.log(c.gray("(empty — skipping)"));
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
if (key.toLowerCase() === "q" || key.toLowerCase() === "quit") {
|
|
98
|
+
console.log(c.gray("Cancelled."));
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
if (!key.startsWith("ak_live_")) {
|
|
102
|
+
console.log(errorLine(`Keys start with ${c.cyan("ak_live_")} — that doesn't look right. Try again or type 'q' to cancel.`));
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (key.length < 30) {
|
|
106
|
+
console.log(errorLine("That key looks too short. Try copying again."));
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
process.stdout.write(c.gray("Verifying..."));
|
|
111
|
+
try {
|
|
112
|
+
const me = await fetchBalance(key);
|
|
113
|
+
writeConfigFile({ apiKey: key });
|
|
114
|
+
if (process.env.AETHER_API_KEY && process.env.AETHER_API_KEY !== key) {
|
|
115
|
+
process.env.AETHER_API_KEY = key;
|
|
116
|
+
console.log(c.yellow(" ⚠ AETHER_API_KEY env var was set to a different key — overriding for this session."));
|
|
117
|
+
console.log(c.yellow(" Update your shell profile to avoid this next time."));
|
|
118
|
+
}
|
|
119
|
+
console.log(c.green(" ●"));
|
|
120
|
+
console.log("");
|
|
121
|
+
console.log(c.green(c.bold("Setup complete.")));
|
|
122
|
+
console.log(
|
|
123
|
+
c.gray(`Saved to ${CONFIG_PATH} (mode 0600).`) +
|
|
124
|
+
c.gray(`\nPlan: ${me.plan} · Balance: ${me.balance.toLocaleString()} credits`),
|
|
125
|
+
);
|
|
126
|
+
console.log("");
|
|
127
|
+
saved = true;
|
|
128
|
+
break;
|
|
129
|
+
} catch (err) {
|
|
130
|
+
console.log(c.red(" ×"));
|
|
131
|
+
if (err instanceof AetherError && err.status === 401) {
|
|
132
|
+
console.log(errorLine("Server rejected that key (401). Double-check you copied it correctly."));
|
|
133
|
+
} else {
|
|
134
|
+
console.log(errorLine(err.message || String(err)));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
rl.close();
|
|
140
|
+
return saved;
|
|
141
|
+
}
|