@0dai-dev/cli 4.1.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 +25 -6
- package/lib/commands/auth.js +67 -28
- package/lib/commands/doctor.js +59 -13
- package/lib/commands/init.js +116 -28
- package/lib/commands/models.js +15 -9
- 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/tui.js +49 -0
- package/lib/commands/workspace.js +1 -0
- package/lib/onboarding.js +21 -7
- package/lib/shared.js +124 -82
- package/lib/tui/index.mjs +34610 -0
- package/lib/utils/model_ratings.js +77 -0
- package/package.json +12 -4
- package/scripts/build-tui.js +77 -0
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const shared = require("../shared");
|
|
4
|
+
const { https, http, fs, path, os, API_URL, AUTH_FILE, log } = shared;
|
|
5
|
+
const { cmdAuthLogin } = require("./auth");
|
|
6
|
+
|
|
7
|
+
const BEGIN_MARKER = "# BEGIN 0dai managed";
|
|
8
|
+
const END_MARKER = "# END 0dai managed";
|
|
9
|
+
|
|
10
|
+
function _apiRequest(method, endpoint, payload, extraHeaders = {}) {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
const url = new URL(endpoint, API_URL);
|
|
13
|
+
const mod = url.protocol === "https:" ? https : http;
|
|
14
|
+
const body = payload === undefined ? null : JSON.stringify(payload);
|
|
15
|
+
const headers = {
|
|
16
|
+
Accept: "application/json",
|
|
17
|
+
"User-Agent": "0dai-cli/ssh",
|
|
18
|
+
...extraHeaders,
|
|
19
|
+
};
|
|
20
|
+
if (body !== null) {
|
|
21
|
+
headers["Content-Type"] = "application/json";
|
|
22
|
+
headers["Content-Length"] = Buffer.byteLength(body);
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
const auth = JSON.parse(fs.readFileSync(AUTH_FILE, "utf8"));
|
|
26
|
+
const token = auth.api_key || auth.access_token || auth.token;
|
|
27
|
+
if (token && !headers.Authorization) headers.Authorization = `Bearer ${token}`;
|
|
28
|
+
} catch {}
|
|
29
|
+
|
|
30
|
+
const req = mod.request(
|
|
31
|
+
{
|
|
32
|
+
hostname: url.hostname,
|
|
33
|
+
port: url.port || (url.protocol === "https:" ? 443 : 80),
|
|
34
|
+
path: url.pathname,
|
|
35
|
+
method,
|
|
36
|
+
headers,
|
|
37
|
+
timeout: 60000,
|
|
38
|
+
},
|
|
39
|
+
(res) => {
|
|
40
|
+
const chunks = [];
|
|
41
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
42
|
+
res.on("end", () => {
|
|
43
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
44
|
+
let parsed = {};
|
|
45
|
+
try {
|
|
46
|
+
parsed = raw ? JSON.parse(raw) : {};
|
|
47
|
+
} catch {
|
|
48
|
+
parsed = { error: raw || `HTTP ${res.statusCode}` };
|
|
49
|
+
}
|
|
50
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
51
|
+
resolve(parsed);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
reject(new Error(parsed.error || parsed.message || `HTTP ${res.statusCode}`));
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
);
|
|
58
|
+
req.on("error", (err) => reject(err));
|
|
59
|
+
req.on("timeout", () => {
|
|
60
|
+
req.destroy();
|
|
61
|
+
reject(new Error("request timed out after 60s"));
|
|
62
|
+
});
|
|
63
|
+
if (body !== null) req.write(body);
|
|
64
|
+
req.end();
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function _ensureAuthenticated() {
|
|
69
|
+
let authed = false;
|
|
70
|
+
try {
|
|
71
|
+
const auth = JSON.parse(fs.readFileSync(AUTH_FILE, "utf8"));
|
|
72
|
+
authed = Boolean(auth?.api_key || auth?.access_token || auth?.token);
|
|
73
|
+
} catch {}
|
|
74
|
+
if (!authed) {
|
|
75
|
+
if (process.stdout.isTTY && process.stdin.isTTY) {
|
|
76
|
+
await cmdAuthLogin();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
throw new Error("authentication required — run '0dai auth login' first");
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function _parseArgs(args) {
|
|
84
|
+
const options = {
|
|
85
|
+
name: "",
|
|
86
|
+
file: "",
|
|
87
|
+
publicKey: "",
|
|
88
|
+
keyId: "",
|
|
89
|
+
grantId: "",
|
|
90
|
+
hostId: "",
|
|
91
|
+
environment: "",
|
|
92
|
+
userEmail: "",
|
|
93
|
+
unixUser: "",
|
|
94
|
+
tokenEnv: "ODAI_SSH_SYNC_TOKEN",
|
|
95
|
+
homeRoot: "",
|
|
96
|
+
json: false,
|
|
97
|
+
apply: false,
|
|
98
|
+
};
|
|
99
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
100
|
+
const arg = args[i];
|
|
101
|
+
if (arg === "--name" && args[i + 1]) {
|
|
102
|
+
options.name = args[++i];
|
|
103
|
+
} else if (arg === "--file" && args[i + 1]) {
|
|
104
|
+
options.file = args[++i];
|
|
105
|
+
} else if (arg === "--public-key" && args[i + 1]) {
|
|
106
|
+
options.publicKey = args[++i];
|
|
107
|
+
} else if (arg === "--key-id" && args[i + 1]) {
|
|
108
|
+
options.keyId = args[++i];
|
|
109
|
+
} else if (arg === "--grant-id" && args[i + 1]) {
|
|
110
|
+
options.grantId = args[++i];
|
|
111
|
+
} else if (arg === "--host-id" && args[i + 1]) {
|
|
112
|
+
options.hostId = args[++i];
|
|
113
|
+
} else if (arg === "--env" && args[i + 1]) {
|
|
114
|
+
options.environment = args[++i];
|
|
115
|
+
} else if (arg === "--user-email" && args[i + 1]) {
|
|
116
|
+
options.userEmail = args[++i];
|
|
117
|
+
} else if (arg === "--unix-user" && args[i + 1]) {
|
|
118
|
+
options.unixUser = args[++i];
|
|
119
|
+
} else if (arg === "--token-env" && args[i + 1]) {
|
|
120
|
+
options.tokenEnv = args[++i];
|
|
121
|
+
} else if (arg === "--home-root" && args[i + 1]) {
|
|
122
|
+
options.homeRoot = args[++i];
|
|
123
|
+
} else if (arg === "--json") {
|
|
124
|
+
options.json = true;
|
|
125
|
+
} else if (arg === "--apply") {
|
|
126
|
+
options.apply = true;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return options;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function _require(value, label) {
|
|
133
|
+
if (!value) throw new Error(`${label} required`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function _readPublicKey(options) {
|
|
137
|
+
if (options.publicKey) return options.publicKey.trim();
|
|
138
|
+
if (options.file) return fs.readFileSync(options.file, "utf8").trim();
|
|
139
|
+
throw new Error("--file or --public-key required");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function _printJson(payload) {
|
|
143
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function _printKeys(keys) {
|
|
147
|
+
if (!Array.isArray(keys) || keys.length === 0) {
|
|
148
|
+
console.log("No SSH keys yet. Add one with: 0dai ssh key add --name laptop --file ~/.ssh/id_ed25519.pub");
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
console.log(`SSH Keys (${keys.length})`);
|
|
152
|
+
console.log("ID Name Algorithm Status Fingerprint");
|
|
153
|
+
console.log("-".repeat(96));
|
|
154
|
+
for (const key of keys) {
|
|
155
|
+
console.log(
|
|
156
|
+
`${String(key.key_id || "").slice(0, 16).padEnd(16)} ` +
|
|
157
|
+
`${String(key.name || "").slice(0, 14).padEnd(14)} ` +
|
|
158
|
+
`${String(key.algorithm || "").slice(0, 25).padEnd(25)} ` +
|
|
159
|
+
`${String(key.status || "").padEnd(8)} ` +
|
|
160
|
+
`${key.fingerprint_sha256 || ""}`
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function _printHosts(ownedHosts, accessibleHosts) {
|
|
166
|
+
console.log(`Owned Hosts (${ownedHosts.length})`);
|
|
167
|
+
if (!ownedHosts.length) {
|
|
168
|
+
console.log(" none");
|
|
169
|
+
} else {
|
|
170
|
+
for (const host of ownedHosts) {
|
|
171
|
+
console.log(` ${host.host_id} ${host.name} [${host.environment}] token:${host.token_hint || "—"} last-sync:${host.last_sync_at || "never"}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
console.log("");
|
|
175
|
+
console.log(`Accessible Hosts (${accessibleHosts.length})`);
|
|
176
|
+
if (!accessibleHosts.length) {
|
|
177
|
+
console.log(" none");
|
|
178
|
+
} else {
|
|
179
|
+
for (const host of accessibleHosts) {
|
|
180
|
+
console.log(` ${host.name} unix:${host.unix_user} owner:${host.owner_email || "—"}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function _printGrants(grants) {
|
|
186
|
+
if (!Array.isArray(grants) || grants.length === 0) {
|
|
187
|
+
console.log("No grants yet.");
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
console.log(`SSH Grants (${grants.length})`);
|
|
191
|
+
for (const grant of grants) {
|
|
192
|
+
console.log(` ${grant.grant_id} host:${grant.host_name || grant.host_id} user:${grant.user_email} unix:${grant.unix_user} ${grant.state}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function _printAudit(audit) {
|
|
197
|
+
if (!Array.isArray(audit) || audit.length === 0) {
|
|
198
|
+
console.log("No SSH audit events yet.");
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
console.log(`SSH Audit (${audit.length})`);
|
|
202
|
+
for (const entry of audit.slice(-20).reverse()) {
|
|
203
|
+
console.log(` ${entry.created_at} ${entry.kind} ${entry.subject_id}`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function _resolveAuthorizedKeysPath(unixUser, homeRoot) {
|
|
208
|
+
const currentUser = process.env.USER || process.env.LOGNAME || "";
|
|
209
|
+
if (unixUser === currentUser) {
|
|
210
|
+
return path.join(os.homedir(), ".ssh", "authorized_keys");
|
|
211
|
+
}
|
|
212
|
+
const base = homeRoot || "/home";
|
|
213
|
+
return path.join(base, unixUser, ".ssh", "authorized_keys");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function _mergeManagedBlock(existingText, managedText) {
|
|
217
|
+
const lines = String(existingText || "").split(/\r?\n/);
|
|
218
|
+
const kept = [];
|
|
219
|
+
let insideManaged = false;
|
|
220
|
+
for (const line of lines) {
|
|
221
|
+
if (line.trim() === BEGIN_MARKER) {
|
|
222
|
+
insideManaged = true;
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (line.trim() === END_MARKER) {
|
|
226
|
+
insideManaged = false;
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (!insideManaged) kept.push(line);
|
|
230
|
+
}
|
|
231
|
+
const unmanaged = kept.join("\n").trim();
|
|
232
|
+
return unmanaged ? `${unmanaged}\n\n${managedText}\n` : `${managedText}\n`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function _buildManagedBlock(hostId, unixUser, authorizedKeys) {
|
|
236
|
+
const lines = [
|
|
237
|
+
BEGIN_MARKER,
|
|
238
|
+
`# host_id=${hostId} unix_user=${unixUser}`,
|
|
239
|
+
...authorizedKeys,
|
|
240
|
+
END_MARKER,
|
|
241
|
+
];
|
|
242
|
+
return lines.join("\n");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function _applySyncState(state, options) {
|
|
246
|
+
const results = [];
|
|
247
|
+
for (const entry of state.unix_users || []) {
|
|
248
|
+
const unixUser = entry.unix_user;
|
|
249
|
+
const filePath = _resolveAuthorizedKeysPath(unixUser, options.homeRoot);
|
|
250
|
+
const dirPath = path.dirname(filePath);
|
|
251
|
+
fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 });
|
|
252
|
+
let existing = "";
|
|
253
|
+
if (fs.existsSync(filePath)) {
|
|
254
|
+
existing = fs.readFileSync(filePath, "utf8");
|
|
255
|
+
const backupPath = `${filePath}.bak.${Date.now()}`;
|
|
256
|
+
fs.copyFileSync(filePath, backupPath);
|
|
257
|
+
}
|
|
258
|
+
const nextContent = _mergeManagedBlock(
|
|
259
|
+
existing,
|
|
260
|
+
_buildManagedBlock(state.host.host_id, unixUser, entry.authorized_keys || [])
|
|
261
|
+
);
|
|
262
|
+
const tmpPath = `${filePath}.tmp`;
|
|
263
|
+
fs.writeFileSync(tmpPath, nextContent, { mode: 0o600 });
|
|
264
|
+
fs.renameSync(tmpPath, filePath);
|
|
265
|
+
results.push({ unix_user: unixUser, path: filePath, key_count: (entry.authorized_keys || []).length });
|
|
266
|
+
}
|
|
267
|
+
return results;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function cmdSsh(_target, sub, args) {
|
|
271
|
+
const entity = sub || "help";
|
|
272
|
+
const action = args[2] || "";
|
|
273
|
+
const options = _parseArgs(args);
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
if (entity === "sync") {
|
|
277
|
+
_require(options.hostId, "--host-id");
|
|
278
|
+
const syncToken = String(process.env[options.tokenEnv] || "").trim();
|
|
279
|
+
if (!syncToken) throw new Error(`${options.tokenEnv} is empty`);
|
|
280
|
+
const state = await _apiRequest("POST", "/v1/ssh/sync", { host_id: options.hostId }, { "X-SSH-Sync-Token": syncToken });
|
|
281
|
+
if (options.json) {
|
|
282
|
+
_printJson(state);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
console.log(`SSH sync state ${state.state_version} for ${state.host.name}`);
|
|
286
|
+
for (const entry of state.unix_users || []) {
|
|
287
|
+
console.log(` ${entry.unix_user}: ${(entry.authorized_keys || []).length} keys`);
|
|
288
|
+
}
|
|
289
|
+
if (!options.apply) {
|
|
290
|
+
console.log("");
|
|
291
|
+
console.log("Dry run only. Re-run with --apply to update authorized_keys.");
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const results = _applySyncState(state, options);
|
|
295
|
+
console.log("");
|
|
296
|
+
console.log("Applied managed authorized_keys blocks:");
|
|
297
|
+
for (const item of results) {
|
|
298
|
+
console.log(` ${item.unix_user} → ${item.path} (${item.key_count} keys)`);
|
|
299
|
+
}
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
await _ensureAuthenticated();
|
|
304
|
+
|
|
305
|
+
if (entity === "key") {
|
|
306
|
+
if (action === "add") {
|
|
307
|
+
const payload = { name: options.name, public_key: _readPublicKey(options) };
|
|
308
|
+
const result = await _apiRequest("POST", "/v1/ssh/keys/add", payload);
|
|
309
|
+
if (options.json) return _printJson(result);
|
|
310
|
+
console.log(`Added SSH key ${result.key.key_id}`);
|
|
311
|
+
console.log(` ${result.key.fingerprint_sha256}`);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (action === "revoke") {
|
|
315
|
+
_require(options.keyId, "--key-id");
|
|
316
|
+
const result = await _apiRequest("POST", "/v1/ssh/keys/revoke", { key_id: options.keyId });
|
|
317
|
+
if (options.json) return _printJson(result);
|
|
318
|
+
console.log(`Revoked SSH key ${result.key.key_id}`);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const result = await _apiRequest("POST", "/v1/ssh/keys/list", {});
|
|
322
|
+
if (options.json) return _printJson(result);
|
|
323
|
+
_printKeys(result.keys || []);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (entity === "host") {
|
|
328
|
+
if (action === "register") {
|
|
329
|
+
_require(options.name, "--name");
|
|
330
|
+
const result = await _apiRequest("POST", "/v1/ssh/hosts/register", { name: options.name, environment: options.environment });
|
|
331
|
+
if (options.json) return _printJson(result);
|
|
332
|
+
console.log(`Registered host ${result.host.host_id} (${result.host.name})`);
|
|
333
|
+
console.log(` export ODAI_SSH_SYNC_TOKEN=${result.sync_token}`);
|
|
334
|
+
console.log(` 0dai ssh sync --host-id ${result.host.host_id} --apply`);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (action === "rotate-token") {
|
|
338
|
+
_require(options.hostId, "--host-id");
|
|
339
|
+
const result = await _apiRequest("POST", "/v1/ssh/hosts/rotate-token", { host_id: options.hostId });
|
|
340
|
+
if (options.json) return _printJson(result);
|
|
341
|
+
console.log(`Rotated sync token for ${result.host.name}`);
|
|
342
|
+
console.log(` export ODAI_SSH_SYNC_TOKEN=${result.sync_token}`);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const result = await _apiRequest("POST", "/v1/ssh/hosts/list", {});
|
|
346
|
+
if (options.json) return _printJson(result);
|
|
347
|
+
_printHosts(result.owned_hosts || [], result.accessible_hosts || []);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (entity === "grant") {
|
|
352
|
+
if (action === "add") {
|
|
353
|
+
_require(options.hostId, "--host-id");
|
|
354
|
+
_require(options.userEmail, "--user-email");
|
|
355
|
+
_require(options.unixUser, "--unix-user");
|
|
356
|
+
const result = await _apiRequest("POST", "/v1/ssh/grants/add", {
|
|
357
|
+
host_id: options.hostId,
|
|
358
|
+
user_email: options.userEmail,
|
|
359
|
+
unix_user: options.unixUser,
|
|
360
|
+
});
|
|
361
|
+
if (options.json) return _printJson(result);
|
|
362
|
+
console.log(`Added SSH grant ${result.grant.grant_id}`);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
if (action === "revoke") {
|
|
366
|
+
const grantId = options.grantId || args[3];
|
|
367
|
+
_require(grantId, "--grant-id");
|
|
368
|
+
const result = await _apiRequest("POST", "/v1/ssh/grants/revoke", { grant_id: grantId });
|
|
369
|
+
if (options.json) return _printJson(result);
|
|
370
|
+
console.log(`Revoked SSH grant ${result.grant.grant_id}`);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
const result = await _apiRequest("POST", "/v1/ssh/overview", {});
|
|
374
|
+
if (options.json) return _printJson(result);
|
|
375
|
+
_printGrants(result.grants || []);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (entity === "audit") {
|
|
380
|
+
const result = await _apiRequest("POST", "/v1/ssh/overview", {});
|
|
381
|
+
if (options.json) return _printJson(result);
|
|
382
|
+
_printAudit(result.audit || []);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (entity === "status" || entity === "overview") {
|
|
387
|
+
const result = await _apiRequest("POST", "/v1/ssh/overview", {});
|
|
388
|
+
if (options.json) return _printJson(result);
|
|
389
|
+
_printKeys(result.keys || []);
|
|
390
|
+
console.log("");
|
|
391
|
+
_printHosts(result.owned_hosts || [], result.accessible_hosts || []);
|
|
392
|
+
console.log("");
|
|
393
|
+
_printGrants(result.grants || []);
|
|
394
|
+
console.log("");
|
|
395
|
+
_printAudit(result.audit || []);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
console.log("Usage:");
|
|
400
|
+
console.log(" 0dai ssh key add --name laptop --file ~/.ssh/id_ed25519.pub");
|
|
401
|
+
console.log(" 0dai ssh key list");
|
|
402
|
+
console.log(" 0dai ssh key revoke --key-id <sshk_...>");
|
|
403
|
+
console.log(" 0dai ssh host register --name prod-api --env production");
|
|
404
|
+
console.log(" 0dai ssh host list");
|
|
405
|
+
console.log(" 0dai ssh host rotate-token --host-id <sshh_...>");
|
|
406
|
+
console.log(" 0dai ssh grant add --host-id <sshh_...> --user-email dev@example.com --unix-user deploy");
|
|
407
|
+
console.log(" 0dai ssh grant revoke --grant-id <sshg_...>");
|
|
408
|
+
console.log(" 0dai ssh audit");
|
|
409
|
+
console.log(" 0dai ssh sync --host-id <sshh_...> --token-env ODAI_SSH_SYNC_TOKEN [--apply] [--home-root /home]");
|
|
410
|
+
} catch (err) {
|
|
411
|
+
log(`error: ${err.message}`);
|
|
412
|
+
process.exit(1);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
module.exports = { cmdSsh };
|
package/lib/commands/status.js
CHANGED
|
@@ -1,43 +1,40 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
const shared = require("../shared");
|
|
3
|
-
const {
|
|
3
|
+
const {
|
|
4
|
+
log, T, R, D, fs, path, spawnSync, findRepoScript,
|
|
5
|
+
getSwarmQuotaLocal, _detectPlanLocal, PLAN_LEVELS, loadAuthState,
|
|
6
|
+
} = shared;
|
|
4
7
|
|
|
5
|
-
function
|
|
8
|
+
function countJson(dir) {
|
|
9
|
+
try { return fs.readdirSync(dir).filter(f => f.endsWith(".json")).length; } catch { return 0; }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function loadJson(file) {
|
|
13
|
+
try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function collectStatusPayload(target) {
|
|
6
17
|
const ai = path.join(target, "ai");
|
|
7
18
|
let v = "?", stack = "?";
|
|
8
19
|
try { v = fs.readFileSync(path.join(ai, "VERSION"), "utf8").trim(); } catch {}
|
|
9
20
|
try { stack = JSON.parse(fs.readFileSync(path.join(ai, "manifest", "discovery.json"), "utf8")).stack || "?"; } catch {}
|
|
10
|
-
log(`v${v} | stack: ${stack}`);
|
|
11
21
|
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
const d = count(path.join(ai, "swarm", "done"));
|
|
16
|
-
if (q || a || d) console.log(` swarm: ${q} queued, ${a} active, ${d} done`);
|
|
22
|
+
const q = countJson(path.join(ai, "swarm", "queue"));
|
|
23
|
+
const a = countJson(path.join(ai, "swarm", "active"));
|
|
24
|
+
const d = countJson(path.join(ai, "swarm", "done"));
|
|
17
25
|
|
|
18
|
-
// Swarm quota
|
|
19
26
|
const quota = getSwarmQuotaLocal(target);
|
|
20
|
-
if (quota.plan === "free") {
|
|
21
|
-
console.log(` swarm quota: ${D}locked (Free) — upgrade for ${quota.daily_limit} tasks/day${R}`);
|
|
22
|
-
} else {
|
|
23
|
-
console.log(` swarm quota: ${quota.used_today}/${quota.daily_limit} tasks today (${quota.plan})`);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
// Session roaming status
|
|
27
27
|
const sessPlan = _detectPlanLocal(target);
|
|
28
28
|
const sessLocked = PLAN_LEVELS[sessPlan] < PLAN_LEVELS["pro"];
|
|
29
|
-
if (sessLocked) {
|
|
30
|
-
console.log(` session roaming: ${D}locked (Free) — upgrade to save/resume sessions${R}`);
|
|
31
|
-
} else {
|
|
32
|
-
console.log(` session roaming: ${T}available (${sessPlan})${R}`);
|
|
33
|
-
}
|
|
34
29
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
30
|
+
const session = loadJson(path.join(ai, "sessions", "active.json"));
|
|
31
|
+
const auth = loadAuthState() || {};
|
|
32
|
+
const budget = loadJson(path.join(ai, "swarm", "budget.json")) || {};
|
|
33
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
34
|
+
const budgetDaily = budget.daily && typeof budget.daily === "object" ? budget.daily : {};
|
|
39
35
|
|
|
40
|
-
|
|
36
|
+
let driftDetected = false;
|
|
37
|
+
let warningCount = 0;
|
|
41
38
|
try {
|
|
42
39
|
const ds = findRepoScript(target, "anti_pattern_detector.py");
|
|
43
40
|
if (ds) {
|
|
@@ -45,25 +42,98 @@ function cmdStatus(target) {
|
|
|
45
42
|
{ stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", timeout: 5000 });
|
|
46
43
|
if (wr.status === 0 && wr.stdout) {
|
|
47
44
|
const wc = JSON.parse(wr.stdout.trim());
|
|
48
|
-
|
|
45
|
+
warningCount = Number(wc.count || 0);
|
|
49
46
|
}
|
|
50
47
|
}
|
|
51
48
|
} catch {}
|
|
52
49
|
|
|
53
|
-
// First-status tip (shows once after init)
|
|
54
|
-
try { require("../onboarding").showFirstStatusTip(target); } catch {}
|
|
55
|
-
|
|
56
|
-
// Drift warning (lightweight)
|
|
57
50
|
try {
|
|
58
51
|
const ds = findRepoScript(target, "drift_detector.py");
|
|
59
52
|
if (ds) {
|
|
60
53
|
const dr = spawnSync("python3", [ds, "report", "--target", target],
|
|
61
54
|
{ stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", timeout: 5000 });
|
|
62
|
-
|
|
63
|
-
console.log(` drift: config changes detected — run: 0dai doctor --drift`);
|
|
64
|
-
}
|
|
55
|
+
driftDetected = !!(dr.stdout && (dr.stdout.includes("MODIFIED") || dr.stdout.includes("CONTRADICTS")));
|
|
65
56
|
}
|
|
66
57
|
} catch {}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
version: v,
|
|
61
|
+
stack,
|
|
62
|
+
swarm: {
|
|
63
|
+
queued: q,
|
|
64
|
+
active: a,
|
|
65
|
+
done: d,
|
|
66
|
+
quota_state: quota.plan === "free" ? "locked" : "available",
|
|
67
|
+
quota_plan: quota.plan,
|
|
68
|
+
used_today: Number(quota.used_today || 0),
|
|
69
|
+
daily_limit: Number(quota.daily_limit || 0),
|
|
70
|
+
},
|
|
71
|
+
session: {
|
|
72
|
+
id: session ? (session.id || null) : null,
|
|
73
|
+
name: session ? ((session.task || {}).goal || null) : null,
|
|
74
|
+
agent: session ? (session.current_agent || null) : null,
|
|
75
|
+
archive_count: countJson(path.join(ai, "sessions", "archive")),
|
|
76
|
+
roaming_state: sessLocked ? "locked" : "available",
|
|
77
|
+
roaming_plan: sessPlan,
|
|
78
|
+
},
|
|
79
|
+
auth: {
|
|
80
|
+
logged_in: !!(auth.api_key || auth.access_token || auth.token),
|
|
81
|
+
email: auth.email || auth.user || null,
|
|
82
|
+
tier: auth.plan || sessPlan || "free",
|
|
83
|
+
activation: ((auth.license || {}).status) || "inactive",
|
|
84
|
+
activation_id: ((auth.license || {}).activation_id) || null,
|
|
85
|
+
},
|
|
86
|
+
budget: {
|
|
87
|
+
today: Number(budgetDaily[today] || 0),
|
|
88
|
+
total_spent: Number(budget.total_spent || 0),
|
|
89
|
+
},
|
|
90
|
+
drift: {
|
|
91
|
+
detected: driftDetected,
|
|
92
|
+
},
|
|
93
|
+
warnings: warningCount,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function cmdStatus(target, options = {}) {
|
|
98
|
+
const payload = collectStatusPayload(target);
|
|
99
|
+
if (options.json) {
|
|
100
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
101
|
+
return payload;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
log(`v${payload.version} | stack: ${payload.stack}`);
|
|
105
|
+
|
|
106
|
+
if (payload.swarm.queued || payload.swarm.active || payload.swarm.done) {
|
|
107
|
+
console.log(` swarm: ${payload.swarm.queued} queued, ${payload.swarm.active} active, ${payload.swarm.done} done`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (payload.swarm.quota_state === "locked") {
|
|
111
|
+
console.log(` swarm quota: ${D}locked (Free) — upgrade for ${payload.swarm.daily_limit} tasks/day${R}`);
|
|
112
|
+
} else {
|
|
113
|
+
console.log(` swarm quota: ${payload.swarm.used_today}/${payload.swarm.daily_limit} tasks today (${payload.swarm.quota_plan})`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (payload.session.roaming_state === "locked") {
|
|
117
|
+
console.log(` session roaming: ${D}locked (Free) — upgrade to save/resume sessions${R}`);
|
|
118
|
+
} else {
|
|
119
|
+
console.log(` session roaming: ${T}available (${payload.session.roaming_plan})${R}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (payload.session.name) {
|
|
123
|
+
console.log(` session: ${payload.session.name} (agent: ${payload.session.agent || "?"})`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (payload.warnings > 0) {
|
|
127
|
+
console.log(` warnings: ${payload.warnings} active — run: 0dai experience warnings`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
try { require("../onboarding").showFirstStatusTip(target); } catch {}
|
|
131
|
+
|
|
132
|
+
if (payload.drift.detected) {
|
|
133
|
+
console.log(` drift: config changes detected — run: 0dai doctor --drift`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return payload;
|
|
67
137
|
}
|
|
68
138
|
|
|
69
|
-
module.exports = { cmdStatus };
|
|
139
|
+
module.exports = { cmdStatus, collectStatusPayload };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const shared = require("../shared");
|
|
4
|
+
const { log, D, R, VERSION } = shared;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `0dai tui` — read-only dashboard (issue #373).
|
|
8
|
+
*
|
|
9
|
+
* Lazy-loads lib/tui/index.js (pre-built by scripts/build-tui.js at prepack
|
|
10
|
+
* time). Degrades gracefully if the bundle / peer deps aren't available so
|
|
11
|
+
* non-TUI users on minimal installs still get a useful message.
|
|
12
|
+
*/
|
|
13
|
+
async function cmdTui(target, args = []) {
|
|
14
|
+
if (!process.stdout.isTTY) {
|
|
15
|
+
console.error("0dai tui requires an interactive TTY. Try: 0dai status");
|
|
16
|
+
process.exit(2);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Ink 5.x is ESM; bundle is .mjs so we load it via dynamic import
|
|
20
|
+
// from this CJS entry.
|
|
21
|
+
const bundlePath = path.join(__dirname, "..", "tui", "index.mjs");
|
|
22
|
+
let tui;
|
|
23
|
+
try {
|
|
24
|
+
// Use the file:// URL form so Windows paths with drive letters load too.
|
|
25
|
+
const url = require("url").pathToFileURL(bundlePath).href;
|
|
26
|
+
tui = await import(url);
|
|
27
|
+
} catch (err) {
|
|
28
|
+
const code = err && err.code;
|
|
29
|
+
const msg = String(err && err.message || "");
|
|
30
|
+
if (code === "ERR_MODULE_NOT_FOUND" || msg.includes("Cannot find module")) {
|
|
31
|
+
log("TUI bundle missing. Rebuild with:");
|
|
32
|
+
console.log(` ${D}cd $(npm root -g)/@0dai-dev/cli && node scripts/build-tui.js${R}`);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
throw err;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let plan = "free";
|
|
39
|
+
try {
|
|
40
|
+
const auth = shared.loadAuthState();
|
|
41
|
+
if (auth && auth.plan) plan = auth.plan;
|
|
42
|
+
} catch {
|
|
43
|
+
// keep default plan
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
await tui.run({ target, version: VERSION, plan });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = { cmdTui };
|
|
@@ -108,6 +108,7 @@ function detectSessions(target) {
|
|
|
108
108
|
function cmdWorkspaceInit(target, args) {
|
|
109
109
|
const globalFlag = args.includes("--global");
|
|
110
110
|
|
|
111
|
+
const sessions = detectSessions(target);
|
|
111
112
|
if (sessions.length === 0) {
|
|
112
113
|
log("no services detected. Create workspace config manually with: 0dai workspace add");
|
|
113
114
|
return;
|