@0dai-dev/cli 4.0.0 → 4.2.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/0dai.js +82 -7
- package/lib/commands/auth.js +67 -28
- package/lib/commands/doctor.js +59 -13
- package/lib/commands/graph.js +103 -4
- package/lib/commands/init.js +142 -9
- package/lib/commands/models.js +49 -10
- package/lib/commands/persona-simulate.js +19 -0
- package/lib/commands/provider.js +18 -0
- package/lib/commands/ssh.js +416 -0
- package/lib/commands/status.js +105 -35
- package/lib/commands/swarm.js +39 -1
- package/lib/commands/tui.js +49 -0
- package/lib/commands/workspace.js +297 -0
- package/lib/onboarding.js +21 -7
- package/lib/shared.js +123 -4
- package/lib/tui/index.mjs +34610 -0
- package/lib/utils/model_ratings.js +77 -0
- package/lib/wizard.js +1 -1
- package/package.json +18 -5
- package/scripts/build-tui.js +77 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const shared = require("../shared");
|
|
3
|
+
const { log, T, R, D, fs, path, spawnSync } = shared;
|
|
4
|
+
const os = require("os");
|
|
5
|
+
|
|
6
|
+
const CONFIG_NAME = "workspace.json";
|
|
7
|
+
|
|
8
|
+
function findConfig(target) {
|
|
9
|
+
const local = path.join(target, ".0dai", CONFIG_NAME);
|
|
10
|
+
if (fs.existsSync(local)) return { file: local, scope: "local" };
|
|
11
|
+
const globalFile = path.join(os.homedir(), ".0dai", CONFIG_NAME);
|
|
12
|
+
if (fs.existsSync(globalFile)) return { file: globalFile, scope: "global" };
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function loadConfig(target) {
|
|
17
|
+
const found = findConfig(target);
|
|
18
|
+
if (!found) return null;
|
|
19
|
+
try { return JSON.parse(fs.readFileSync(found.file, "utf8")); } catch { return null; }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function saveConfig(target, ws, globalFlag) {
|
|
23
|
+
const configFile = globalFlag
|
|
24
|
+
? path.join(os.homedir(), ".0dai", CONFIG_NAME)
|
|
25
|
+
: path.join(target, ".0dai", CONFIG_NAME);
|
|
26
|
+
fs.mkdirSync(path.dirname(configFile), { recursive: true, mode: 0o700 });
|
|
27
|
+
fs.writeFileSync(configFile, JSON.stringify(ws, null, 2) + "\n", { mode: 0o600 });
|
|
28
|
+
return configFile;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function tmux(args) {
|
|
32
|
+
try {
|
|
33
|
+
return spawnSync("tmux", args, { encoding: "utf8", timeout: 5000 });
|
|
34
|
+
} catch { return { status: 1, stderr: "tmux not found" }; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function sessionName(name) {
|
|
38
|
+
return "0dai-" + name.replace(/[^a-z0-9-]/gi, "-").toLowerCase();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function listTmuxSessions() {
|
|
42
|
+
const r = tmux(["list-sessions", "-F", "#{session_name}\t#{session_created}\t#{pane_pid}"]);
|
|
43
|
+
if (r.status !== 0) return [];
|
|
44
|
+
return r.stdout.trim().split("\n").filter(Boolean).map(line => {
|
|
45
|
+
const [name, created, pid] = line.split("\t");
|
|
46
|
+
return { name, created, pid };
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// --- Auto-detection ---
|
|
51
|
+
|
|
52
|
+
function detectSessions(target) {
|
|
53
|
+
const sessions = [];
|
|
54
|
+
|
|
55
|
+
// API server
|
|
56
|
+
if (fs.existsSync(path.join(target, "scripts", "api_server.py"))) {
|
|
57
|
+
sessions.push({
|
|
58
|
+
name: "api",
|
|
59
|
+
cmd: "python3 api_server.py --public --port 8440",
|
|
60
|
+
dir: "scripts",
|
|
61
|
+
auto_start: true,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Web dev (Next.js, etc.)
|
|
66
|
+
const pkgPath = path.join(target, "web", "package.json");
|
|
67
|
+
if (fs.existsSync(pkgPath)) {
|
|
68
|
+
try {
|
|
69
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
|
70
|
+
if (pkg.scripts && pkg.scripts.dev) {
|
|
71
|
+
sessions.push({ name: "web", cmd: "npm run dev", dir: "web", auto_start: true });
|
|
72
|
+
}
|
|
73
|
+
} catch {}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Uptime monitor
|
|
77
|
+
if (fs.existsSync(path.join(target, "scripts", "uptime_monitor.py"))) {
|
|
78
|
+
sessions.push({
|
|
79
|
+
name: "monitor",
|
|
80
|
+
cmd: "python3 uptime_monitor.py --url http://localhost:8440",
|
|
81
|
+
dir: "scripts",
|
|
82
|
+
auto_start: false,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Procfile support
|
|
87
|
+
const procfile = path.join(target, "Procfile");
|
|
88
|
+
if (fs.existsSync(procfile)) {
|
|
89
|
+
try {
|
|
90
|
+
const lines = fs.readFileSync(procfile, "utf8").split("\n");
|
|
91
|
+
for (const line of lines) {
|
|
92
|
+
const m = line.match(/^(\w+):\s*(.+)$/);
|
|
93
|
+
if (m) {
|
|
94
|
+
const existing = sessions.find(s => s.name === m[1]);
|
|
95
|
+
if (!existing) {
|
|
96
|
+
sessions.push({ name: m[1], cmd: m[2], dir: ".", auto_start: true });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
} catch {}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return sessions;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// --- Commands ---
|
|
107
|
+
|
|
108
|
+
function cmdWorkspaceInit(target, args) {
|
|
109
|
+
const globalFlag = args.includes("--global");
|
|
110
|
+
|
|
111
|
+
const sessions = detectSessions(target);
|
|
112
|
+
if (sessions.length === 0) {
|
|
113
|
+
log("no services detected. Create workspace config manually with: 0dai workspace add");
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const ws = {
|
|
118
|
+
name: path.basename(target),
|
|
119
|
+
root: target,
|
|
120
|
+
sessions,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const configFile = saveConfig(target, ws, globalFlag);
|
|
124
|
+
log(`workspace config created: ${T}${configFile}${R}`);
|
|
125
|
+
log(`${sessions.length} session(s) detected: ${sessions.map(s => s.name).join(", ")}`);
|
|
126
|
+
log(`run: ${T}0dai workspace up${R}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function cmdWorkspaceAdd(target, args) {
|
|
130
|
+
const nameIdx = args.indexOf("add");
|
|
131
|
+
const name = nameIdx >= 0 ? args[nameIdx + 1] : args[0];
|
|
132
|
+
if (!name) { log("usage: 0dai workspace add <name> --cmd '...' [--dir scripts] [--auto]"); return; }
|
|
133
|
+
|
|
134
|
+
const ws = loadConfig(target) || { name: path.basename(target), sessions: [] };
|
|
135
|
+
const cmdIdx = args.indexOf("--cmd");
|
|
136
|
+
const dirIdx = args.indexOf("--dir");
|
|
137
|
+
const cmd = cmdIdx >= 0 && args[cmdIdx + 1] ? args[cmdIdx + 1] : name;
|
|
138
|
+
const dir = dirIdx >= 0 && args[dirIdx + 1] ? args[dirIdx + 1] : ".";
|
|
139
|
+
const auto = args.includes("--auto");
|
|
140
|
+
|
|
141
|
+
const existing = ws.sessions.findIndex(s => s.name === name);
|
|
142
|
+
const entry = { name, cmd, dir, auto_start: auto };
|
|
143
|
+
if (existing >= 0) ws.sessions[existing] = entry;
|
|
144
|
+
else ws.sessions.push(entry);
|
|
145
|
+
|
|
146
|
+
const found = findConfig(target);
|
|
147
|
+
const configFile = found ? found.file : saveConfig(target, ws, false);
|
|
148
|
+
fs.writeFileSync(configFile, JSON.stringify(ws, null, 2) + "\n", { mode: 0o600 });
|
|
149
|
+
log(`session "${T}${name}${R}" ${existing >= 0 ? "updated" : "added"}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function cmdWorkspaceRm(target, name) {
|
|
153
|
+
if (!name) { log("usage: 0dai workspace rm <name>"); return; }
|
|
154
|
+
const ws = loadConfig(target);
|
|
155
|
+
if (!ws) { log("no workspace config"); return; }
|
|
156
|
+
ws.sessions = ws.sessions.filter(s => s.name !== name);
|
|
157
|
+
const found = findConfig(target);
|
|
158
|
+
fs.writeFileSync(found.file, JSON.stringify(ws, null, 2) + "\n", { mode: 0o600 });
|
|
159
|
+
log(`session "${T}${name}${R}" removed`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function cmdWorkspaceUp(target, args) {
|
|
163
|
+
const ws = loadConfig(target);
|
|
164
|
+
if (!ws) { log("no workspace config. Run: 0dai workspace init"); return; }
|
|
165
|
+
|
|
166
|
+
// Filter by name if specified
|
|
167
|
+
const filterNames = args.filter(a => !a.startsWith("-"));
|
|
168
|
+
const running = listTmuxSessions();
|
|
169
|
+
|
|
170
|
+
log(`starting workspace "${T}${ws.name}${R}"`);
|
|
171
|
+
|
|
172
|
+
for (const s of ws.sessions) {
|
|
173
|
+
if (!s.auto_start) continue;
|
|
174
|
+
if (filterNames.length && !filterNames.includes(s.name)) continue;
|
|
175
|
+
|
|
176
|
+
const sname = sessionName(s.name);
|
|
177
|
+
if (running.find(x => x.name === sname)) {
|
|
178
|
+
log(`${D}${s.name} already running (pid ${running.find(x => x.name === sname).pid})${R}`);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const dir = s.dir ? path.join(ws.root || target, s.dir) : (ws.root || target);
|
|
183
|
+
const cmd = `cd ${dir} && ${s.cmd}`;
|
|
184
|
+
const r = tmux(["new-session", "-d", "-s", sname, "bash", "-lc", cmd]);
|
|
185
|
+
if (r.status === 0) log(`${T}✓${R} ${s.name} started`);
|
|
186
|
+
else log(`${R}✗${R} ${s.name} failed: ${r.stderr?.trim() || r.error || "unknown"}`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (filterNames.length === 0) {
|
|
190
|
+
log(`workspace ready. ${D}Status: 0dai workspace status${R}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function cmdWorkspaceDown(target, args) {
|
|
195
|
+
const ws = loadConfig(target);
|
|
196
|
+
if (!ws) { log("no workspace config"); return; }
|
|
197
|
+
|
|
198
|
+
const filterNames = args.filter(a => !a.startsWith("-"));
|
|
199
|
+
log(`stopping workspace "${T}${ws.name}${R}"`);
|
|
200
|
+
|
|
201
|
+
for (const s of ws.sessions) {
|
|
202
|
+
if (filterNames.length && !filterNames.includes(s.name)) continue;
|
|
203
|
+
const sname = sessionName(s.name);
|
|
204
|
+
tmux(["kill-session", "-t", sname]);
|
|
205
|
+
log(`${D}${s.name} stopped${R}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function cmdWorkspaceStatus(target) {
|
|
210
|
+
const ws = loadConfig(target);
|
|
211
|
+
if (!ws) { log("no workspace config. Run: 0dai workspace init"); return; }
|
|
212
|
+
|
|
213
|
+
const running = listTmuxSessions();
|
|
214
|
+
const isTTY = process.stdout.isTTY;
|
|
215
|
+
const G = isTTY ? "\x1b[32m" : "";
|
|
216
|
+
const RD = isTTY ? "\x1b[31m" : "";
|
|
217
|
+
const DIM = isTTY ? "\x1b[2m" : "";
|
|
218
|
+
|
|
219
|
+
console.log(`\n ${T}Workspace: ${ws.name}${R}\n`);
|
|
220
|
+
console.log(` ${"SESSION".padEnd(14)} ${"STATUS".padEnd(10)} ${"PID".padEnd(8)} DIR`);
|
|
221
|
+
console.log(` ${"-".repeat(60)}`);
|
|
222
|
+
|
|
223
|
+
for (const s of ws.sessions) {
|
|
224
|
+
const sname = sessionName(s.name);
|
|
225
|
+
const match = running.find(x => x.name === sname);
|
|
226
|
+
const status = match ? `${G}running${R}` : `${RD}stopped${R}`;
|
|
227
|
+
const pid = match ? match.pid : "—";
|
|
228
|
+
const dir = s.dir || ".";
|
|
229
|
+
console.log(` ${s.name.padEnd(14)} ${status.padEnd(10)} ${String(pid).padEnd(8)} ${DIM}${dir}${R}`);
|
|
230
|
+
}
|
|
231
|
+
console.log();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function cmdWorkspaceAttach(target, name) {
|
|
235
|
+
if (!name) { log("usage: 0dai workspace attach <name>"); return; }
|
|
236
|
+
const sname = sessionName(name);
|
|
237
|
+
const running = listTmuxSessions();
|
|
238
|
+
if (!running.find(x => x.name === sname)) {
|
|
239
|
+
log(`session "${name}" not running. Run: 0dai workspace up`);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
log(`attaching to ${T}${name}${R}`);
|
|
243
|
+
try {
|
|
244
|
+
const { execSync } = require("child_process");
|
|
245
|
+
execSync(`tmux attach -t ${sname}`, { stdio: "inherit" });
|
|
246
|
+
} catch (e) { log(`error: ${e.message}`); }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function cmdWorkspaceLogs(target, name, args) {
|
|
250
|
+
if (!name) { log("usage: 0dai workspace logs <name> [-f]"); return; }
|
|
251
|
+
const sname = sessionName(name);
|
|
252
|
+
const running = listTmuxSessions();
|
|
253
|
+
if (!running.find(x => x.name === sname)) { log(`session "${name}" not running`); return; }
|
|
254
|
+
|
|
255
|
+
const follow = args.includes("-f") || args.includes("--follow");
|
|
256
|
+
if (follow) {
|
|
257
|
+
// Live follow mode
|
|
258
|
+
log(`following ${T}${name}${R} logs (Ctrl+C to exit)`);
|
|
259
|
+
try {
|
|
260
|
+
const { execSync } = require("child_process");
|
|
261
|
+
execSync(`tmux capture-pane -t ${sname} -p -e`, { stdio: "inherit" });
|
|
262
|
+
} catch (e) { log(`error: ${e.message}`); }
|
|
263
|
+
} else {
|
|
264
|
+
const r = tmux(["capture-pane", "-t", sname, "-p"]);
|
|
265
|
+
if (r.stdout) {
|
|
266
|
+
const lines = r.stdout.trim().split("\n").slice(-50);
|
|
267
|
+
console.log(lines.join("\n"));
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function cmdWorkspace(target, sub, args) {
|
|
273
|
+
switch (sub) {
|
|
274
|
+
case "init": cmdWorkspaceInit(target, args); break;
|
|
275
|
+
case "up": cmdWorkspaceUp(target, args); break;
|
|
276
|
+
case "down": cmdWorkspaceDown(target, args); break;
|
|
277
|
+
case "status": cmdWorkspaceStatus(target); break;
|
|
278
|
+
case "attach": cmdWorkspaceAttach(target, args[0]); break;
|
|
279
|
+
case "logs": cmdWorkspaceLogs(target, args[0], args); break;
|
|
280
|
+
case "add": cmdWorkspaceAdd(target, args); break;
|
|
281
|
+
case "rm": cmdWorkspaceRm(target, args[0]); break;
|
|
282
|
+
default:
|
|
283
|
+
console.log(`\n ${T}0dai workspace${R} — tmux session manager\n`);
|
|
284
|
+
console.log("Commands:");
|
|
285
|
+
console.log(" workspace init [--global] Create config (auto-detect services)");
|
|
286
|
+
console.log(" workspace up [name...] Start auto_start sessions (filter by name)");
|
|
287
|
+
console.log(" workspace down [name...] Stop sessions (filter by name)");
|
|
288
|
+
console.log(" workspace status Show session status table");
|
|
289
|
+
console.log(" workspace attach <name> Attach to a running session");
|
|
290
|
+
console.log(" workspace logs <name> [-f] Show session output (-f = follow)");
|
|
291
|
+
console.log(" workspace add <name> --cmd '...' [--dir X] [--auto]");
|
|
292
|
+
console.log(" workspace rm <name> Remove session from config");
|
|
293
|
+
console.log();
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
module.exports = { cmdWorkspace };
|
package/lib/onboarding.js
CHANGED
|
@@ -104,20 +104,28 @@ async function cmdQuickstart(target, { cmdDoctor, cmdStatus, cmdInit, log, ensur
|
|
|
104
104
|
|
|
105
105
|
// Step 1: Auth
|
|
106
106
|
console.log(`\n [1/${steps}] Checking authentication...`);
|
|
107
|
+
let signedIn = false;
|
|
107
108
|
let authInfo = "not signed in";
|
|
108
109
|
try {
|
|
109
110
|
const auth = JSON.parse(fs.readFileSync(path.join(require("os").homedir(), ".0dai", "auth.json"), "utf8"));
|
|
110
111
|
if (auth.access_token) {
|
|
112
|
+
signedIn = true;
|
|
111
113
|
authInfo = `signed in (${auth.plan || "free"} plan)`;
|
|
112
114
|
}
|
|
113
115
|
} catch {}
|
|
114
|
-
|
|
116
|
+
if (signedIn) {
|
|
117
|
+
console.log(` \u2713 ${authInfo}`);
|
|
118
|
+
} else {
|
|
119
|
+
console.log(` \u26a0 ${authInfo} \u2192 run 0dai auth login`);
|
|
120
|
+
}
|
|
115
121
|
|
|
116
122
|
// Step 2: Init
|
|
117
123
|
console.log(` [2/${steps}] Checking project config...`);
|
|
118
124
|
const aiExists = fs.existsSync(path.join(target, "ai", "VERSION"));
|
|
119
125
|
if (aiExists) {
|
|
120
126
|
console.log(" \u2713 ai/ layer found");
|
|
127
|
+
} else if (!signedIn) {
|
|
128
|
+
console.log(" \u26a0 skipped \u2192 sign in before first init");
|
|
121
129
|
} else {
|
|
122
130
|
console.log(" initializing...");
|
|
123
131
|
try {
|
|
@@ -132,9 +140,9 @@ async function cmdQuickstart(target, { cmdDoctor, cmdStatus, cmdInit, log, ensur
|
|
|
132
140
|
|
|
133
141
|
// Step 3: Doctor
|
|
134
142
|
console.log(` [3/${steps}] Running health check...`);
|
|
135
|
-
if (fs.existsSync(path.join(target, "ai"))) {
|
|
143
|
+
if (fs.existsSync(path.join(target, "ai", "VERSION"))) {
|
|
136
144
|
try {
|
|
137
|
-
cmdDoctor(target);
|
|
145
|
+
cmdDoctor(target, { suppressExitCode: true });
|
|
138
146
|
} catch {}
|
|
139
147
|
console.log(" \u2713 health check complete");
|
|
140
148
|
} else {
|
|
@@ -143,7 +151,7 @@ async function cmdQuickstart(target, { cmdDoctor, cmdStatus, cmdInit, log, ensur
|
|
|
143
151
|
|
|
144
152
|
// Step 4: Status
|
|
145
153
|
console.log(` [4/${steps}] Project status:`);
|
|
146
|
-
if (fs.existsSync(path.join(target, "ai"))) {
|
|
154
|
+
if (fs.existsSync(path.join(target, "ai", "VERSION"))) {
|
|
147
155
|
try {
|
|
148
156
|
cmdStatus(target);
|
|
149
157
|
} catch {}
|
|
@@ -155,9 +163,15 @@ async function cmdQuickstart(target, { cmdDoctor, cmdStatus, cmdInit, log, ensur
|
|
|
155
163
|
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
|
156
164
|
console.log(` [5/${steps}] Ready! (${elapsed}s)`);
|
|
157
165
|
console.log("");
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
166
|
+
if (!signedIn && !aiExists) {
|
|
167
|
+
console.log(" Next:");
|
|
168
|
+
console.log(" 0dai auth login Sign in to unlock init and sync");
|
|
169
|
+
console.log(" 0dai quickstart Re-run after sign-in");
|
|
170
|
+
} else {
|
|
171
|
+
console.log(" Your project is set up. Try:");
|
|
172
|
+
console.log(" 0dai swarm run --goal \"add auth\" (Pro)");
|
|
173
|
+
console.log(" 0dai graph push (Pro)");
|
|
174
|
+
}
|
|
161
175
|
console.log("");
|
|
162
176
|
}
|
|
163
177
|
|
package/lib/shared.js
CHANGED
|
@@ -11,6 +11,7 @@ const http = require("http");
|
|
|
11
11
|
const fs = require("fs");
|
|
12
12
|
const path = require("path");
|
|
13
13
|
const os = require("os");
|
|
14
|
+
const crypto = require("crypto");
|
|
14
15
|
const { spawnSync } = require("child_process");
|
|
15
16
|
|
|
16
17
|
const VERSION = require("../package.json").version;
|
|
@@ -48,6 +49,15 @@ const CONFIG_DIR = path.join(os.homedir(), ".0dai");
|
|
|
48
49
|
const AUTH_FILE = path.join(CONFIG_DIR, "auth.json");
|
|
49
50
|
const VERSION_CHECK_FILE = path.join(CONFIG_DIR, ".version_check");
|
|
50
51
|
const PROJECTS_FILE = path.join(CONFIG_DIR, "projects.json");
|
|
52
|
+
const DRIFT_TRACKED_CONFIGS = [
|
|
53
|
+
"CLAUDE.md",
|
|
54
|
+
"AGENTS.md",
|
|
55
|
+
"GEMINI.md",
|
|
56
|
+
"opencode.json",
|
|
57
|
+
".cursorrules",
|
|
58
|
+
".windsurfrules",
|
|
59
|
+
".aider.conf.yml",
|
|
60
|
+
];
|
|
51
61
|
|
|
52
62
|
// --- API ---
|
|
53
63
|
function apiCall(endpoint, data) {
|
|
@@ -84,7 +94,7 @@ function apiCall(endpoint, data) {
|
|
|
84
94
|
});
|
|
85
95
|
});
|
|
86
96
|
req.on("error", (e) => resolve({ error: `${e.message}. Is ${API_URL} reachable?` }));
|
|
87
|
-
req.on("timeout", () => { req.destroy(); resolve({ error: "
|
|
97
|
+
req.on("timeout", () => { req.destroy(); resolve({ error: "request timed out after 60s. Check your internet connection or try again." }); });
|
|
88
98
|
if (body) req.write(body);
|
|
89
99
|
req.end();
|
|
90
100
|
});
|
|
@@ -160,14 +170,123 @@ async function ensureLicenseActivation() {
|
|
|
160
170
|
}
|
|
161
171
|
|
|
162
172
|
// --- Project Heartbeat ---
|
|
163
|
-
|
|
173
|
+
function _hashFileSha256(filePath) {
|
|
174
|
+
const buf = fs.readFileSync(filePath);
|
|
175
|
+
return crypto.createHash("sha256").update(buf).digest("hex");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function computeProjectDriftSummary(target) {
|
|
179
|
+
const hashesPath = path.join(target, "ai", "manifest", "config_hashes.json");
|
|
180
|
+
if (!fs.existsSync(hashesPath)) return null;
|
|
181
|
+
let hashes;
|
|
182
|
+
try {
|
|
183
|
+
hashes = JSON.parse(fs.readFileSync(hashesPath, "utf8"));
|
|
184
|
+
} catch {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
const findings = [];
|
|
188
|
+
let totalConfigs = 0;
|
|
189
|
+
for (const name of DRIFT_TRACKED_CONFIGS) {
|
|
190
|
+
const filePath = path.join(target, name);
|
|
191
|
+
const exists = fs.existsSync(filePath) && fs.statSync(filePath).isFile();
|
|
192
|
+
const recorded = hashes[name];
|
|
193
|
+
if (exists) totalConfigs += 1;
|
|
194
|
+
if (recorded && !exists) {
|
|
195
|
+
findings.push({ config: name, type: "missing", severity: "warning" });
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (!recorded && exists) {
|
|
199
|
+
findings.push({ config: name, type: "extra", severity: "info" });
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (recorded && exists) {
|
|
203
|
+
try {
|
|
204
|
+
const currentHash = _hashFileSha256(filePath);
|
|
205
|
+
if (currentHash !== String(recorded.hash || "")) {
|
|
206
|
+
findings.push({ config: name, type: "modified", severity: "warning" });
|
|
207
|
+
}
|
|
208
|
+
} catch {
|
|
209
|
+
findings.push({ config: name, type: "unreadable", severity: "warning" });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const driftedCount = findings.filter((f) => f.type === "modified" || f.type === "missing").length;
|
|
214
|
+
return {
|
|
215
|
+
available: true,
|
|
216
|
+
clean: findings.length === 0,
|
|
217
|
+
drifted_count: driftedCount,
|
|
218
|
+
total_configs: totalConfigs,
|
|
219
|
+
findings,
|
|
220
|
+
updated_at: new Date().toISOString(),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function sendProjectHeartbeat(target, identity, result, extra = {}) {
|
|
225
|
+
const drift = computeProjectDriftSummary(target);
|
|
164
226
|
return apiCall("/v1/projects/heartbeat", {
|
|
165
227
|
project_id: identity.project_id, stack: result.stack || identity.stack || "unknown",
|
|
166
228
|
cli_version: VERSION, activation_status: "active", binding_status: "bound",
|
|
167
|
-
runtime_sessions: 0, swarm_active: 0, swarm_done: 0, channel: "npm",
|
|
229
|
+
runtime_sessions: 0, swarm_active: 0, swarm_done: 0, channel: "npm",
|
|
230
|
+
...(drift ? { drift } : {}),
|
|
231
|
+
...extra,
|
|
168
232
|
});
|
|
169
233
|
}
|
|
170
234
|
|
|
235
|
+
// --- First-run proof gate (issue #342) ---
|
|
236
|
+
//
|
|
237
|
+
// Idempotently appends a `first_run.success` event to ai/manifest/audit.jsonl
|
|
238
|
+
// when all 4 activation gates have passed. Matches scripts/audit.py shape.
|
|
239
|
+
// Returns { fired: boolean, elapsedS: number|null }.
|
|
240
|
+
function logFirstRunSuccess(target, gates) {
|
|
241
|
+
try {
|
|
242
|
+
const auditPath = path.join(target, "ai", "manifest", "audit.jsonl");
|
|
243
|
+
if (!fs.existsSync(path.dirname(auditPath))) {
|
|
244
|
+
fs.mkdirSync(path.dirname(auditPath), { recursive: true });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Idempotency: skip if a first_run.success event already exists for this project.
|
|
248
|
+
if (fs.existsSync(auditPath)) {
|
|
249
|
+
const existing = fs.readFileSync(auditPath, "utf8");
|
|
250
|
+
if (existing.includes('"action":"first_run.success"') ||
|
|
251
|
+
existing.includes('"action": "first_run.success"')) {
|
|
252
|
+
return { fired: false, elapsedS: null };
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Compute elapsed_s from the local first-run marker.
|
|
257
|
+
let elapsedS = null;
|
|
258
|
+
try {
|
|
259
|
+
const ob = require("./onboarding");
|
|
260
|
+
ob.trackFirstInit(target);
|
|
261
|
+
elapsedS = ob.getTimeToInit(target);
|
|
262
|
+
} catch {}
|
|
263
|
+
|
|
264
|
+
// Read current ai/VERSION for the entry's ai_version field.
|
|
265
|
+
let aiVersion = null;
|
|
266
|
+
try {
|
|
267
|
+
const vf = path.join(target, "ai", "VERSION");
|
|
268
|
+
if (fs.existsSync(vf)) aiVersion = fs.readFileSync(vf, "utf8").trim();
|
|
269
|
+
} catch {}
|
|
270
|
+
|
|
271
|
+
const entry = {
|
|
272
|
+
timestamp: new Date().toISOString(),
|
|
273
|
+
action: "first_run.success",
|
|
274
|
+
actor: "cli",
|
|
275
|
+
user: process.env.USER || process.env.USERNAME || "unknown",
|
|
276
|
+
ai_version: aiVersion,
|
|
277
|
+
details: JSON.stringify({
|
|
278
|
+
elapsed_s: elapsedS,
|
|
279
|
+
gates: gates || {},
|
|
280
|
+
}),
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
fs.appendFileSync(auditPath, JSON.stringify(entry) + "\n", "utf8");
|
|
284
|
+
return { fired: true, elapsedS };
|
|
285
|
+
} catch {
|
|
286
|
+
return { fired: false, elapsedS: null };
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
171
290
|
// --- File Writing ---
|
|
172
291
|
function mergeSettingsJson(existing, incoming) {
|
|
173
292
|
try {
|
|
@@ -273,7 +392,7 @@ module.exports = {
|
|
|
273
392
|
// Plan / Tier
|
|
274
393
|
_detectPlanLocal, requirePlan, getSwarmQuotaLocal,
|
|
275
394
|
// Project
|
|
276
|
-
sendProjectHeartbeat, recordExperienceEvent,
|
|
395
|
+
sendProjectHeartbeat, recordExperienceEvent, logFirstRunSuccess,
|
|
277
396
|
// Files
|
|
278
397
|
mergeSettingsJson, writeFiles, findRepoScript,
|
|
279
398
|
// Version
|