@0dai-dev/cli 4.1.0 → 4.3.4
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 +30 -5
- package/bin/0dai.js +308 -60
- package/lib/commands/audit.js +13 -0
- package/lib/commands/auth.js +404 -122
- package/lib/commands/boneyard.js +44 -0
- package/lib/commands/ci.js +329 -0
- package/lib/commands/compliance.js +20 -0
- package/lib/commands/doctor.js +79 -14
- package/lib/commands/experience.js +5 -1
- package/lib/commands/feedback.js +92 -5
- package/lib/commands/gh.js +506 -0
- package/lib/commands/graph.js +78 -10
- package/lib/commands/heatmap.js +17 -0
- package/lib/commands/import_claude_code_agents.js +367 -0
- package/lib/commands/init.js +553 -53
- package/lib/commands/loop.js +108 -0
- package/lib/commands/mcp.js +410 -0
- package/lib/commands/models.js +42 -12
- package/lib/commands/paste.js +114 -0
- package/lib/commands/persona-simulate.js +19 -0
- package/lib/commands/play.js +173 -0
- package/lib/commands/provider.js +87 -0
- package/lib/commands/quota.js +76 -0
- package/lib/commands/receipt.js +53 -0
- package/lib/commands/report.js +29 -2
- package/lib/commands/run.js +44 -4
- package/lib/commands/runner.js +527 -0
- package/lib/commands/session.js +1 -7
- package/lib/commands/ssh.js +416 -0
- package/lib/commands/standup.js +40 -0
- package/lib/commands/status.js +131 -36
- package/lib/commands/swarm.js +97 -4
- package/lib/commands/tui.js +117 -0
- package/lib/commands/usage.js +87 -0
- package/lib/commands/vault.js +246 -0
- package/lib/commands/workspace.js +1 -0
- package/lib/onboarding.js +30 -10
- package/lib/shared.js +153 -96
- package/lib/tui/index.mjs +34994 -0
- package/lib/utils/auth.js +1 -0
- package/lib/utils/canonical-counts.js +54 -0
- package/lib/utils/diff-preview.js +192 -0
- package/lib/utils/identity.js +76 -18
- package/lib/utils/mcp-auth.js +607 -0
- package/lib/utils/model_ratings.js +77 -0
- package/lib/utils/plan.js +37 -2
- package/lib/vault/cipher.js +125 -0
- package/lib/vault/identity.js +122 -0
- package/lib/vault/index.js +184 -0
- package/lib/vault/storage.js +84 -0
- package/lib/wizard.js +19 -12
- package/package.json +13 -5
- package/scripts/build-tui.js +77 -0
package/lib/commands/init.js
CHANGED
|
@@ -1,16 +1,147 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
const crypto = require("crypto");
|
|
2
3
|
const shared = require("../shared");
|
|
3
4
|
const {
|
|
4
|
-
T, R, D, log,
|
|
5
|
+
T, R, D, W, log,
|
|
5
6
|
fs, path,
|
|
6
7
|
VERSION, SUPPORTED_CLIS,
|
|
7
|
-
|
|
8
|
+
CONFIG_DIR, PROJECTS_FILE,
|
|
9
|
+
apiCall, makeEnsureAuthenticated, ensureLicenseActivation, loadAuthState,
|
|
8
10
|
collectMetadata, buildProjectIdentity, registerProject,
|
|
9
|
-
writeFiles,
|
|
11
|
+
writeFiles, sendProjectHeartbeat, recordExperienceEvent,
|
|
12
|
+
logFirstRunSuccess,
|
|
10
13
|
} = shared;
|
|
11
|
-
const { cmdAuthLogin } = require("./auth");
|
|
14
|
+
const { cmdAuthLogin, ensureAccountForActivation, parseActivationArgs } = require("./auth");
|
|
15
|
+
const { ensureGithubFlowPolicy, warnHooksPathDrift } = require("./gh");
|
|
16
|
+
const { renderFileMapDiff, confirmOrExit, shouldAutoYes } = require("../utils/diff-preview");
|
|
17
|
+
const { bootstrapMcp } = require("../utils/mcp-auth");
|
|
12
18
|
|
|
13
19
|
const ensureAuthenticated = makeEnsureAuthenticated(cmdAuthLogin);
|
|
20
|
+
const SYNC_FULL_CONTENT_LIMIT = 10000;
|
|
21
|
+
const CLOUD_INIT_CHECKPOINT_REL = path.join(".0dai", "cloud-init-checkpoint.json");
|
|
22
|
+
|
|
23
|
+
function hashFile(filePath) {
|
|
24
|
+
const hash = crypto.createHash("sha256");
|
|
25
|
+
const fd = fs.openSync(filePath, "r");
|
|
26
|
+
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
|
27
|
+
try {
|
|
28
|
+
let bytesRead = 0;
|
|
29
|
+
do {
|
|
30
|
+
bytesRead = fs.readSync(fd, buffer, 0, buffer.length, null);
|
|
31
|
+
if (bytesRead > 0) hash.update(buffer.subarray(0, bytesRead));
|
|
32
|
+
} while (bytesRead > 0);
|
|
33
|
+
} finally {
|
|
34
|
+
fs.closeSync(fd);
|
|
35
|
+
}
|
|
36
|
+
return hash.digest("hex");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function describeCurrentFile(filePath, stat) {
|
|
40
|
+
if (stat.size < SYNC_FULL_CONTENT_LIMIT) return fs.readFileSync(filePath, "utf8");
|
|
41
|
+
return {
|
|
42
|
+
size: stat.size,
|
|
43
|
+
sha256: hashFile(filePath),
|
|
44
|
+
compare: "hash-only",
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// detectRegistryDrift — Phase 1.5 of #2237 registry self-heal.
|
|
49
|
+
// Returns { drifted: bool, existingPath?: string, currentPath: string } when an
|
|
50
|
+
// entry with the same `name` is already in ~/.0dai/projects.json but points at
|
|
51
|
+
// a different directory than the one we're about to register. Caller decides
|
|
52
|
+
// what to do (warn, prompt, or auto-overwrite based on flags).
|
|
53
|
+
function detectRegistryDrift(target, name) {
|
|
54
|
+
const currentPath = path.resolve(target);
|
|
55
|
+
try {
|
|
56
|
+
if (!fs.existsSync(PROJECTS_FILE)) return { drifted: false, currentPath };
|
|
57
|
+
const raw = fs.readFileSync(PROJECTS_FILE, "utf8");
|
|
58
|
+
const data = JSON.parse(raw);
|
|
59
|
+
const projects = Array.isArray(data && data.projects) ? data.projects : [];
|
|
60
|
+
for (const entry of projects) {
|
|
61
|
+
if (!entry || typeof entry !== "object") continue;
|
|
62
|
+
if (entry.name !== name) continue;
|
|
63
|
+
if (!entry.path || typeof entry.path !== "string") continue;
|
|
64
|
+
if (entry.path === currentPath) continue;
|
|
65
|
+
return { drifted: true, existingPath: entry.path, currentPath, archived: entry.archived === true };
|
|
66
|
+
}
|
|
67
|
+
} catch {}
|
|
68
|
+
return { drifted: false, currentPath };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// guardRegistryDrift — returns true when caller may proceed with registration,
|
|
72
|
+
// false if the operator declined the overwrite. Async to support interactive
|
|
73
|
+
// prompt; honours --non-interactive (and --yes / common CI flags) by
|
|
74
|
+
// auto-overwriting silently. ODAI_REGISTRY_DRIFT_OVERWRITE=1 also bypasses
|
|
75
|
+
// the prompt for scripted runs.
|
|
76
|
+
async function guardRegistryDrift(target, name, args) {
|
|
77
|
+
const drift = detectRegistryDrift(target, name);
|
|
78
|
+
if (!drift.drifted) return true;
|
|
79
|
+
// Archived entries are not a real conflict — registry_audit.py archives
|
|
80
|
+
// missing paths; let init silently overwrite them.
|
|
81
|
+
if (drift.archived) return true;
|
|
82
|
+
|
|
83
|
+
const nonInteractive =
|
|
84
|
+
args.includes("--non-interactive") ||
|
|
85
|
+
args.includes("--yes") ||
|
|
86
|
+
args.includes("-y") ||
|
|
87
|
+
!process.stdout.isTTY ||
|
|
88
|
+
process.env.ODAI_REGISTRY_DRIFT_OVERWRITE === "1";
|
|
89
|
+
|
|
90
|
+
log(`${W}registry drift: project '${name}' is registered at ${drift.existingPath}${R}`);
|
|
91
|
+
console.log(` current cwd: ${drift.currentPath}`);
|
|
92
|
+
if (nonInteractive) {
|
|
93
|
+
console.log(` ${D}auto-overwriting registry entry (non-interactive mode)${R}`);
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const readline = require("readline");
|
|
99
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
100
|
+
const answer = await new Promise((resolve) => {
|
|
101
|
+
rl.question(` Overwrite registry entry to point at ${drift.currentPath}? [y/N] `, (a) => {
|
|
102
|
+
rl.close();
|
|
103
|
+
resolve(String(a || "").trim().toLowerCase());
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
if (answer === "y" || answer === "yes") return true;
|
|
107
|
+
} catch {
|
|
108
|
+
// readline failed (e.g. no TTY) — fall through to skip
|
|
109
|
+
}
|
|
110
|
+
console.log(
|
|
111
|
+
` ${D}skipping registry update. Set ODAI_REGISTRY_DRIFT_OVERWRITE=1 or pass --non-interactive to overwrite.${R}`,
|
|
112
|
+
);
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function registerProjectOrExit(target, name, stack) {
|
|
117
|
+
try {
|
|
118
|
+
return registerProject(target, name, stack, CONFIG_DIR, PROJECTS_FILE);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
log(`error: failed to update project registry: ${err.message}`);
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function collectCurrentAiFiles(target) {
|
|
126
|
+
const currentFiles = {};
|
|
127
|
+
const aiDir = path.join(target, "ai");
|
|
128
|
+
if (fs.existsSync(aiDir)) {
|
|
129
|
+
const walk = (dir) => {
|
|
130
|
+
for (const f of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
131
|
+
const p = path.join(dir, f.name);
|
|
132
|
+
if (f.isDirectory()) walk(p);
|
|
133
|
+
else {
|
|
134
|
+
try {
|
|
135
|
+
const stat = fs.statSync(p);
|
|
136
|
+
if (stat.isFile()) currentFiles[path.relative(target, p)] = describeCurrentFile(p, stat);
|
|
137
|
+
} catch {}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
walk(aiDir);
|
|
142
|
+
}
|
|
143
|
+
return currentFiles;
|
|
144
|
+
}
|
|
14
145
|
|
|
15
146
|
// bindProjectForCloud — binds project to cloud via /v1/projects/bind
|
|
16
147
|
async function bindProjectForCloud(target, metadata, identity) {
|
|
@@ -31,24 +162,163 @@ async function bindProjectForCloud(target, metadata, identity) {
|
|
|
31
162
|
};
|
|
32
163
|
}
|
|
33
164
|
|
|
165
|
+
function cloudInitCheckpointPath(target) {
|
|
166
|
+
return path.join(target, CLOUD_INIT_CHECKPOINT_REL);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function _hasAuthToken() {
|
|
170
|
+
const auth = loadAuthState();
|
|
171
|
+
return !!(auth && (auth.api_key || auth.access_token || auth.token));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function _quoteCommandArg(value) {
|
|
175
|
+
const text = String(value || "");
|
|
176
|
+
if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(text)) return text;
|
|
177
|
+
return `'${text.replace(/'/g, "'\"'\"'")}'`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function _initCommandName(args = []) {
|
|
181
|
+
const first = String((Array.isArray(args) ? args : [])[0] || "");
|
|
182
|
+
return first === "init-existing" ? "init-existing" : "init";
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function _resumeArgs(args = []) {
|
|
186
|
+
const raw = Array.isArray(args) ? args : [];
|
|
187
|
+
const withoutCommand = raw[0] === "init" || raw[0] === "init-existing" ? raw.slice(1) : raw.slice();
|
|
188
|
+
const skipWithValue = new Set(["--auth-code", "--oauth-code", "--exchange-code", "--code", "--activation-code", "--redeem-code", "--plan-code"]);
|
|
189
|
+
const filtered = [];
|
|
190
|
+
for (let i = 0; i < withoutCommand.length; i++) {
|
|
191
|
+
const arg = String(withoutCommand[i] || "");
|
|
192
|
+
if (!arg || arg === "--resume") continue;
|
|
193
|
+
if (skipWithValue.has(arg)) { i++; continue; }
|
|
194
|
+
if ([...skipWithValue].some((name) => arg.startsWith(`${name}=`))) continue;
|
|
195
|
+
filtered.push(arg);
|
|
196
|
+
}
|
|
197
|
+
return filtered;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function buildCloudInitResumeCommand(target, args = []) {
|
|
201
|
+
const parts = ["0dai", _initCommandName(args), "--target", _quoteCommandArg(path.resolve(target)), "--resume"];
|
|
202
|
+
for (const arg of _resumeArgs(args)) parts.push(_quoteCommandArg(arg));
|
|
203
|
+
return parts.join(" ");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function writeCloudInitCheckpoint(target, details = {}) {
|
|
207
|
+
const checkpointPath = cloudInitCheckpointPath(target);
|
|
208
|
+
const checkpoint = {
|
|
209
|
+
version: 1,
|
|
210
|
+
command: _initCommandName(details.args || []),
|
|
211
|
+
stage: details.stage || "auth_required",
|
|
212
|
+
reason: details.reason || "missing_auth",
|
|
213
|
+
target: path.resolve(target),
|
|
214
|
+
created_at: new Date().toISOString(),
|
|
215
|
+
next_command: "0dai auth login --device --no-browser",
|
|
216
|
+
resume_command: buildCloudInitResumeCommand(target, details.args || []),
|
|
217
|
+
};
|
|
218
|
+
fs.mkdirSync(path.dirname(checkpointPath), { recursive: true, mode: 0o700 });
|
|
219
|
+
fs.writeFileSync(checkpointPath, JSON.stringify(checkpoint, null, 2) + "\n", { mode: 0o600 });
|
|
220
|
+
return checkpoint;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function clearCloudInitCheckpoint(target) {
|
|
224
|
+
try { fs.unlinkSync(cloudInitCheckpointPath(target)); } catch {}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function maybePauseCloudInitForAuth(target, args = [], options = {}) {
|
|
228
|
+
if (options.dryRun || options.localMode) return false;
|
|
229
|
+
if (_hasAuthToken()) return false;
|
|
230
|
+
const parsed = parseActivationArgs(args, { genericCode: "activation" });
|
|
231
|
+
if (parsed.authCode) return false;
|
|
232
|
+
if (process.stdout.isTTY && process.stdin.isTTY) return false;
|
|
233
|
+
|
|
234
|
+
const checkpoint = writeCloudInitCheckpoint(target, {
|
|
235
|
+
args,
|
|
236
|
+
stage: "auth_required",
|
|
237
|
+
reason: "missing_auth",
|
|
238
|
+
});
|
|
239
|
+
log("authentication required for init");
|
|
240
|
+
console.log(` ${D}Checkpoint: ${CLOUD_INIT_CHECKPOINT_REL}${R}`);
|
|
241
|
+
console.log(` ${D}Run: ${checkpoint.next_command}${R}`);
|
|
242
|
+
console.log(` ${D}Then: ${checkpoint.resume_command}${R}`);
|
|
243
|
+
process.exit(1);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function cmdProjectBind(target, args = []) {
|
|
247
|
+
const json = args.includes("--json");
|
|
248
|
+
const authStatus = await ensureAuthenticated("project bind");
|
|
249
|
+
const license = await ensureLicenseActivation();
|
|
250
|
+
const metadata = collectMetadata(target);
|
|
251
|
+
const identity = buildProjectIdentity(target, metadata);
|
|
252
|
+
const boundProject = await bindProjectForCloud(target, metadata, identity);
|
|
253
|
+
const stack = boundProject.stack || identity.stack || "unknown";
|
|
254
|
+
if (await guardRegistryDrift(target, path.basename(target), args)) {
|
|
255
|
+
registerProjectOrExit(target, path.basename(target), stack);
|
|
256
|
+
}
|
|
257
|
+
const heartbeat = await sendProjectHeartbeat(target, identity, { stack }, {
|
|
258
|
+
project_id: boundProject.project_id || identity.project_id,
|
|
259
|
+
}).catch((err) => ({ error: err.message || String(err) }));
|
|
260
|
+
const payload = {
|
|
261
|
+
ok: true,
|
|
262
|
+
account: {
|
|
263
|
+
email: authStatus.email || null,
|
|
264
|
+
plan: authStatus.plan || license.plan || "free",
|
|
265
|
+
activation: license.status || "active",
|
|
266
|
+
},
|
|
267
|
+
project: {
|
|
268
|
+
project_id: boundProject.project_id || identity.project_id,
|
|
269
|
+
name: boundProject.name || identity.project_name,
|
|
270
|
+
stack,
|
|
271
|
+
binding_status: boundProject.binding_status || "bound",
|
|
272
|
+
},
|
|
273
|
+
heartbeat: !(heartbeat && heartbeat.error),
|
|
274
|
+
};
|
|
275
|
+
if (json) {
|
|
276
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
277
|
+
} else {
|
|
278
|
+
log("project bound");
|
|
279
|
+
console.log(` account: ${payload.account.email || "unknown"} · plan: ${payload.account.plan} · activation: ${payload.account.activation}`);
|
|
280
|
+
console.log(` project: ${payload.project.project_id}`);
|
|
281
|
+
if (!payload.heartbeat) console.log(` ${D}heartbeat deferred; run 0dai status to inspect local state${R}`);
|
|
282
|
+
}
|
|
283
|
+
return payload;
|
|
284
|
+
}
|
|
285
|
+
|
|
34
286
|
async function cmdInit(target, args = []) {
|
|
35
287
|
const dryRun = args.includes("--dry-run");
|
|
36
288
|
const minimal = args.includes("--minimal");
|
|
37
289
|
const noWizard = args.includes("--no-wizard");
|
|
290
|
+
const localMode = args.includes("--local");
|
|
38
291
|
|
|
39
292
|
if (fs.existsSync(path.join(target, "ai", "VERSION"))) {
|
|
40
293
|
const v = fs.readFileSync(path.join(target, "ai", "VERSION"), "utf8").trim();
|
|
41
294
|
log(`ai/ layer already exists (v${v}). Run '0dai sync' to update.`);
|
|
295
|
+
if (args.includes("--resume")) clearCloudInitCheckpoint(target);
|
|
296
|
+
if (!dryRun) await runMcpBootstrap(target, args);
|
|
42
297
|
return;
|
|
43
298
|
}
|
|
44
299
|
|
|
300
|
+
// Pre-check: verify init quota before starting wizard (avoid 10 min wizard → "limit reached")
|
|
301
|
+
if (!dryRun) {
|
|
302
|
+
try {
|
|
303
|
+
const precheck = await apiCall("/v1/projects/precheck", {
|
|
304
|
+
device_id: shared.deviceFingerprint(),
|
|
305
|
+
});
|
|
306
|
+
if (precheck.error && precheck.error.includes("limit")) {
|
|
307
|
+
log(`${precheck.error}`);
|
|
308
|
+
if (precheck.hint) console.log(` ${D}${precheck.hint}${R}`);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
} catch {}
|
|
312
|
+
}
|
|
313
|
+
|
|
45
314
|
// First-run wizard (unless --no-wizard or non-interactive)
|
|
46
|
-
if (!noWizard && !dryRun && !minimal) {
|
|
315
|
+
if (!noWizard && !dryRun && !minimal && !localMode) {
|
|
47
316
|
try {
|
|
48
317
|
const { runWizard, isInteractive } = require("../wizard");
|
|
49
318
|
if (isInteractive()) {
|
|
50
319
|
const result = await runWizard(target);
|
|
51
320
|
if (result.completed) {
|
|
321
|
+
await runMcpBootstrap(target, args);
|
|
52
322
|
try {
|
|
53
323
|
const ob = require("../onboarding");
|
|
54
324
|
ob.trackFirstInit(target);
|
|
@@ -56,9 +326,25 @@ async function cmdInit(target, args = []) {
|
|
|
56
326
|
} catch {}
|
|
57
327
|
return;
|
|
58
328
|
}
|
|
329
|
+
if (result.cancelled) return;
|
|
59
330
|
}
|
|
60
331
|
} catch {}
|
|
61
332
|
}
|
|
333
|
+
if (localMode) {
|
|
334
|
+
const { runWizard } = require("../wizard");
|
|
335
|
+
const result = await runWizard(target, { forceLocal: true });
|
|
336
|
+
if (result.completed) {
|
|
337
|
+
await runMcpBootstrap(target, args);
|
|
338
|
+
try {
|
|
339
|
+
const ob = require("../onboarding");
|
|
340
|
+
ob.trackFirstInit(target);
|
|
341
|
+
ob.showWhatsNext("local", false);
|
|
342
|
+
} catch {}
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
maybePauseCloudInitForAuth(target, args, { dryRun, localMode });
|
|
62
348
|
|
|
63
349
|
const isTTY = process.stdout.isTTY;
|
|
64
350
|
let spinner = null;
|
|
@@ -68,7 +354,7 @@ async function cmdInit(target, args = []) {
|
|
|
68
354
|
|
|
69
355
|
const metadata = collectMetadata(target);
|
|
70
356
|
const { projectFiles, manifestContents, clis } = metadata;
|
|
71
|
-
const authStatus = await
|
|
357
|
+
const authStatus = await ensureAccountForActivation("init", args);
|
|
72
358
|
const license = await ensureLicenseActivation();
|
|
73
359
|
const identity = buildProjectIdentity(target, metadata);
|
|
74
360
|
const boundProject = await bindProjectForCloud(target, metadata, identity);
|
|
@@ -108,6 +394,11 @@ async function cmdInit(target, args = []) {
|
|
|
108
394
|
return;
|
|
109
395
|
}
|
|
110
396
|
writeFiles(target, result.files || {});
|
|
397
|
+
const envManifest = normalizeEnvironmentManifest(target);
|
|
398
|
+
if (envManifest.changed) {
|
|
399
|
+
log("environment manifest target normalized: ai/manifest/environment.yaml");
|
|
400
|
+
}
|
|
401
|
+
await runMcpBootstrap(target, args);
|
|
111
402
|
|
|
112
403
|
// Ensure ai/VERSION matches CLI version
|
|
113
404
|
const versionFile = path.join(target, "ai", "VERSION");
|
|
@@ -121,8 +412,15 @@ async function cmdInit(target, args = []) {
|
|
|
121
412
|
if (!text.includes(".0dai")) fs.appendFileSync(gi, "\n.0dai/\n");
|
|
122
413
|
} catch {}
|
|
123
414
|
|
|
124
|
-
|
|
125
|
-
|
|
415
|
+
const gitPolicy = ensureGithubFlowPolicy(target);
|
|
416
|
+
if (gitPolicy && gitPolicy.protection && gitPolicy.protection.skipped) {
|
|
417
|
+
console.log(` ${D}branch protection: ${gitPolicy.protection.reason}${R}`);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Register in global portfolio (Phase 1.5 #2237: drift guard)
|
|
421
|
+
if (await guardRegistryDrift(target, path.basename(target), args)) {
|
|
422
|
+
registerProjectOrExit(target, path.basename(target), result.stack);
|
|
423
|
+
}
|
|
126
424
|
|
|
127
425
|
log(`initialized (${result.file_count || "?"} files)`);
|
|
128
426
|
console.log(` account: ${authStatus.email} · plan: ${authStatus.plan || license.plan || "free"} · activation: ${license.status}`);
|
|
@@ -150,9 +448,9 @@ async function cmdInit(target, args = []) {
|
|
|
150
448
|
}
|
|
151
449
|
console.log(` ${D}3.${R} Open dashboard: ${D}https://0dai.dev/dashboard${R}`);
|
|
152
450
|
|
|
153
|
-
await sendProjectHeartbeat(identity, result, {
|
|
451
|
+
const heartbeat = await sendProjectHeartbeat(target, identity, result, {
|
|
154
452
|
project_id: boundProject.project_id || identity.project_id,
|
|
155
|
-
}).catch(() =>
|
|
453
|
+
}).catch(() => null);
|
|
156
454
|
recordExperienceEvent(target, {
|
|
157
455
|
event_type: "config_generated",
|
|
158
456
|
agent: "cli",
|
|
@@ -162,18 +460,107 @@ async function cmdInit(target, args = []) {
|
|
|
162
460
|
context: { stack: result.stack || identity.stack || "unknown", files_touched: Number(result.file_count || 0), tests_passed: true },
|
|
163
461
|
});
|
|
164
462
|
|
|
463
|
+
// First-run proof gate (issue #342). All 4 gates pass once we reach here:
|
|
464
|
+
// license active (line above), project bound, ai/ layer written, heartbeat sent.
|
|
465
|
+
// Idempotent — only fires once per project. See docs/first-run.md.
|
|
466
|
+
const firstRun = logFirstRunSuccess(target, {
|
|
467
|
+
license: true,
|
|
468
|
+
project_bound: true,
|
|
469
|
+
layer_written: true,
|
|
470
|
+
heartbeat: !!heartbeat && !heartbeat.error,
|
|
471
|
+
});
|
|
472
|
+
if (firstRun.fired) {
|
|
473
|
+
const suffix = typeof firstRun.elapsedS === "number" ? ` (${firstRun.elapsedS}s)` : "";
|
|
474
|
+
console.log(` ${D}first-run gate: success${suffix}${R}`);
|
|
475
|
+
}
|
|
476
|
+
|
|
165
477
|
// Send anonymous usage ping
|
|
166
478
|
apiCall("/v1/feedback", { report: {
|
|
167
479
|
stack_detected: result.stack || "?", _auto: true, _plan: result.plan || "trial",
|
|
168
480
|
_cli_version: VERSION, _files_generated: result.file_count || 0,
|
|
169
481
|
}}).catch(() => {});
|
|
482
|
+
clearCloudInitCheckpoint(target);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async function runMcpBootstrap(target, args = []) {
|
|
486
|
+
const result = await bootstrapMcp(target, args, (message) => log(message));
|
|
487
|
+
if (!result.ok) {
|
|
488
|
+
log(`warn: MCP bootstrap skipped: ${result.warnings.join("; ")}`);
|
|
489
|
+
return result;
|
|
490
|
+
}
|
|
491
|
+
if (result.config) {
|
|
492
|
+
const verbs = [];
|
|
493
|
+
if (result.config.added && result.config.added.length) verbs.push(`${result.config.added.length} server(s) added`);
|
|
494
|
+
if (result.config.updated && result.config.updated.length) verbs.push(`${result.config.updated.length} server(s) reset`);
|
|
495
|
+
if (result.config.changed) {
|
|
496
|
+
log(`MCP config ready (${verbs.join(", ") || "updated"}): .mcp.json`);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
if (result.settings && result.settings.changed) {
|
|
500
|
+
log("Claude project MCP auto-load enabled: .claude/settings.json");
|
|
501
|
+
}
|
|
502
|
+
if (result.auth) {
|
|
503
|
+
if (result.auth.status === "preserved") {
|
|
504
|
+
log("MCP auth token preserved");
|
|
505
|
+
} else if (result.auth.status === "written") {
|
|
506
|
+
log(`MCP auth token stored: ${result.auth.tokenPath}`);
|
|
507
|
+
} else if (result.auth.status === "disabled") {
|
|
508
|
+
log("MCP cloud auth skipped (--no-mcp-auth)");
|
|
509
|
+
} else if (result.auth.status === "skipped") {
|
|
510
|
+
console.log(` ${D}MCP cloud auth skipped. In Claude Code, run /mcp Authenticate for claude.ai 0dai${R}`);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
for (const warning of result.warnings || []) log(`warn: ${warning}`);
|
|
514
|
+
return result;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function normalizeEnvironmentManifest(target) {
|
|
518
|
+
const filePath = path.join(target, "ai", "manifest", "environment.yaml");
|
|
519
|
+
if (!fs.existsSync(filePath)) return { path: filePath, changed: false };
|
|
520
|
+
const original = fs.readFileSync(filePath, "utf8");
|
|
521
|
+
const lines = original.split(/\n/);
|
|
522
|
+
let inWorkspace = false;
|
|
523
|
+
let sawTarget = false;
|
|
524
|
+
let sawCwd = false;
|
|
525
|
+
const next = [];
|
|
526
|
+
for (const line of lines) {
|
|
527
|
+
if (/^\S/.test(line) && !line.startsWith("workspace:")) inWorkspace = false;
|
|
528
|
+
if (line.trim() === "workspace:") {
|
|
529
|
+
inWorkspace = true;
|
|
530
|
+
next.push(line);
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
if (inWorkspace && /^ target:\s*/.test(line)) {
|
|
534
|
+
next.push(" target: .");
|
|
535
|
+
sawTarget = true;
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
if (inWorkspace && /^ cwd:\s*/.test(line)) {
|
|
539
|
+
next.push(" cwd: .");
|
|
540
|
+
sawCwd = true;
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
if (inWorkspace && /^available_clis:\s*$/.test(line)) {
|
|
544
|
+
if (!sawTarget) next.push(" target: .");
|
|
545
|
+
if (!sawCwd) next.push(" cwd: .");
|
|
546
|
+
inWorkspace = false;
|
|
547
|
+
}
|
|
548
|
+
next.push(line);
|
|
549
|
+
}
|
|
550
|
+
if (inWorkspace) {
|
|
551
|
+
if (!sawTarget) next.push(" target: .");
|
|
552
|
+
if (!sawCwd) next.push(" cwd: .");
|
|
553
|
+
}
|
|
554
|
+
const rendered = next.join("\n");
|
|
555
|
+
if (rendered === original) return { path: filePath, changed: false };
|
|
556
|
+
fs.writeFileSync(filePath, rendered, "utf8");
|
|
557
|
+
return { path: filePath, changed: true };
|
|
170
558
|
}
|
|
171
559
|
|
|
172
560
|
async function cmdSync(target, args = []) {
|
|
173
561
|
const dryRun = args.includes("--dry-run");
|
|
174
562
|
const quiet = args.includes("--quiet") || args.includes("-q");
|
|
175
563
|
const force = args.includes("--force");
|
|
176
|
-
const updateTemplates = args.includes("--update-templates");
|
|
177
564
|
|
|
178
565
|
// Quick local check: skip API if already at current version (unless dry-run or force)
|
|
179
566
|
let version = "unknown";
|
|
@@ -181,8 +568,6 @@ async function cmdSync(target, args = []) {
|
|
|
181
568
|
|
|
182
569
|
const metadata = collectMetadata(target);
|
|
183
570
|
const { manifestContents, clis } = metadata;
|
|
184
|
-
const authStatus = await ensureAuthenticated("sync");
|
|
185
|
-
const license = await ensureLicenseActivation();
|
|
186
571
|
let stack = "generic", agents = [];
|
|
187
572
|
try {
|
|
188
573
|
const d = JSON.parse(fs.readFileSync(path.join(target, "ai", "manifest", "discovery.json"), "utf8"));
|
|
@@ -190,35 +575,41 @@ async function cmdSync(target, args = []) {
|
|
|
190
575
|
agents = d.selected_agents || [];
|
|
191
576
|
} catch {}
|
|
192
577
|
const identity = buildProjectIdentity(target, metadata, stack);
|
|
193
|
-
const boundProject = await bindProjectForCloud(target, metadata, identity);
|
|
194
578
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
}
|
|
579
|
+
if (dryRun) {
|
|
580
|
+
const auth = loadAuthState();
|
|
581
|
+
const hasAuth = !!(auth && (auth.api_key || auth.access_token || auth.token));
|
|
582
|
+
if (!hasAuth) {
|
|
583
|
+
const preview = buildLocalSyncPreview(target, { version, stack, cliVersion: VERSION });
|
|
584
|
+
log(`${D}dry-run: local preview without auth (exact cloud plan unavailable)${R}`);
|
|
585
|
+
console.log(` stack: ${preview.stack}`);
|
|
586
|
+
console.log(` ai version: ${preview.current_version} ${preview.version_matches ? `${D}(matches CLI ${preview.cli_version})${R}` : `${D}(CLI ${preview.cli_version})${R}`}`);
|
|
587
|
+
if (preview.changes.length) {
|
|
588
|
+
console.log(" likely changes:");
|
|
589
|
+
for (const change of preview.changes) console.log(` ~ ${change}`);
|
|
590
|
+
} else {
|
|
591
|
+
console.log(` ${D}no obvious local drift found${R}`);
|
|
209
592
|
}
|
|
210
|
-
|
|
211
|
-
|
|
593
|
+
console.log(` ${D}Run: 0dai auth login for exact managed diff and write-mode sync${R}`);
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
212
596
|
}
|
|
213
597
|
|
|
598
|
+
const authStatus = await ensureAuthenticated("sync");
|
|
599
|
+
const license = await ensureLicenseActivation();
|
|
600
|
+
const boundProject = await bindProjectForCloud(target, metadata, identity);
|
|
601
|
+
|
|
602
|
+
// Collect current ai/ files. Small files send content; larger files send
|
|
603
|
+
// hash descriptors so the server can compare without a full upload.
|
|
604
|
+
const currentFiles = collectCurrentAiFiles(target);
|
|
605
|
+
|
|
214
606
|
if (dryRun) log(`${D}dry-run: checking what sync would change...${R}`);
|
|
215
607
|
if (force && !dryRun) log(`${T}force mode: will overwrite native configs from ai/ source${R}`);
|
|
216
|
-
if (updateTemplates && !dryRun) log(`${T}template update mode: will refresh managed native configs from latest templates${R}`);
|
|
217
608
|
|
|
218
609
|
const result = await apiCall("/v1/sync", {
|
|
219
610
|
ai_version: version, stack, agents: agents.length ? agents : clis,
|
|
220
611
|
current_files: currentFiles, manifest_contents: manifestContents,
|
|
221
|
-
dry_run: dryRun, quiet, force,
|
|
612
|
+
dry_run: dryRun, quiet, force,
|
|
222
613
|
project_name: identity.project_name,
|
|
223
614
|
project_id: boundProject.project_id || identity.project_id,
|
|
224
615
|
remote_origin: identity.remote_origin,
|
|
@@ -237,17 +628,38 @@ async function cmdSync(target, args = []) {
|
|
|
237
628
|
const files = Object.keys(updated);
|
|
238
629
|
if (files.length) {
|
|
239
630
|
log(`${D}dry-run: would update ${files.length} file(s):${R}`);
|
|
240
|
-
|
|
631
|
+
const { diff, summary } = renderFileMapDiff(updated, currentFiles);
|
|
632
|
+
for (const f of summary.added) console.log(` ${D}+ ${f}${R}`);
|
|
633
|
+
for (const f of summary.modified) console.log(` ${D}~ ${f}${R}`);
|
|
634
|
+
for (const f of summary.removed) console.log(` ${D}- ${f}${R}`);
|
|
635
|
+
if (diff && !args.includes("--no-diff")) {
|
|
636
|
+
console.log("");
|
|
637
|
+
console.log(diff);
|
|
638
|
+
}
|
|
241
639
|
} else {
|
|
242
640
|
log(`${D}dry-run: nothing to update${R}`);
|
|
243
641
|
}
|
|
244
|
-
if (result.template_update_available) {
|
|
245
|
-
console.log(` ${D}template update available: run 0dai sync --update-templates${R}`);
|
|
246
|
-
}
|
|
247
642
|
return;
|
|
248
643
|
}
|
|
249
644
|
const changedCount = Object.keys(updated).length;
|
|
250
645
|
if (changedCount) {
|
|
646
|
+
if (!quiet && !shouldAutoYes(args)) {
|
|
647
|
+
const { diff, summary } = renderFileMapDiff(updated, currentFiles);
|
|
648
|
+
log(`sync would change ${changedCount} file(s):`);
|
|
649
|
+
for (const f of summary.added) console.log(` ${D}+ ${f}${R}`);
|
|
650
|
+
for (const f of summary.modified) console.log(` ${D}~ ${f}${R}`);
|
|
651
|
+
for (const f of summary.removed) console.log(` ${D}- ${f}${R}`);
|
|
652
|
+
if (diff && !args.includes("--no-diff")) {
|
|
653
|
+
console.log("");
|
|
654
|
+
console.log(diff);
|
|
655
|
+
console.log("");
|
|
656
|
+
}
|
|
657
|
+
const ok = await confirmOrExit({ args, quiet, message: "Apply sync?", defaultYes: false });
|
|
658
|
+
if (!ok) {
|
|
659
|
+
log("aborted by user (no files changed). Re-run with --yes to skip this prompt.");
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
251
663
|
writeFiles(target, updated);
|
|
252
664
|
if (!quiet) {
|
|
253
665
|
for (const f of Object.keys(updated)) console.log(` ~ ${f}`);
|
|
@@ -257,19 +669,39 @@ async function cmdSync(target, args = []) {
|
|
|
257
669
|
} else {
|
|
258
670
|
log("already up to date");
|
|
259
671
|
}
|
|
260
|
-
|
|
261
|
-
if (
|
|
262
|
-
|
|
672
|
+
const envManifest = normalizeEnvironmentManifest(target);
|
|
673
|
+
if (!quiet && envManifest.changed) {
|
|
674
|
+
log("environment manifest target normalized: ai/manifest/environment.yaml");
|
|
675
|
+
}
|
|
676
|
+
await runMcpBootstrap(target, [...args, "--no-mcp-auth"]);
|
|
677
|
+
|
|
678
|
+
// Round-trip imported personas back to native agent dirs (Phase 2 of #2197).
|
|
679
|
+
// Currently supports claude-code only. Persona files marked
|
|
680
|
+
// `imported_from: "claude-code"` are mirrored into `.claude/agents/<name>.md`
|
|
681
|
+
// so users can edit either side without losing parity.
|
|
682
|
+
// Phase 2.5 (deferred): full export — generate agents for personas that
|
|
683
|
+
// were authored in 0dai but not imported. Tracked in #2197.
|
|
684
|
+
try {
|
|
685
|
+
const { syncImportedClaudeCodeAgents } = require("./import_claude_code_agents");
|
|
686
|
+
const rt = syncImportedClaudeCodeAgents(target, { dryRun });
|
|
687
|
+
if (!quiet && rt.written.length) {
|
|
688
|
+
log(`round-trip: wrote ${rt.written.length} claude-code agent file(s) from imported personas`);
|
|
689
|
+
for (const w of rt.written) {
|
|
690
|
+
const rel = path.relative(target, w.outPath).replace(/\\/g, "/");
|
|
691
|
+
console.log(` -> ${rel}`);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
} catch (err) {
|
|
695
|
+
if (!quiet) console.log(` ${D}round-trip skipped: ${err.message}${R}`);
|
|
263
696
|
}
|
|
264
697
|
|
|
265
698
|
// --force: also overwrite native configs (CLAUDE.md, AGENTS.md, etc.) from ai/ source
|
|
266
699
|
if (force && result.native_configs) {
|
|
700
|
+
const NATIVE_CONFIGS = ["CLAUDE.md", "AGENTS.md", "GEMINI.md", "opencode.json", ".cursorrules", ".windsurfrules", ".aider.conf.yml"];
|
|
267
701
|
let overwritten = 0;
|
|
268
|
-
for (const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
if (content) {
|
|
272
|
-
fs.writeFileSync(targetPath, content, "utf8");
|
|
702
|
+
for (const name of NATIVE_CONFIGS) {
|
|
703
|
+
if (result.native_configs[name]) {
|
|
704
|
+
fs.writeFileSync(path.join(target, name), result.native_configs[name], "utf8");
|
|
273
705
|
overwritten++;
|
|
274
706
|
if (!quiet) console.log(` [force] ${name} overwritten from ai/ source`);
|
|
275
707
|
}
|
|
@@ -277,11 +709,6 @@ async function cmdSync(target, args = []) {
|
|
|
277
709
|
if (overwritten && !quiet) {
|
|
278
710
|
log(`force: ${overwritten} native config file(s) overwritten`);
|
|
279
711
|
}
|
|
280
|
-
} else if (updateTemplates && result.native_configs) {
|
|
281
|
-
writeManagedFiles(target, result.native_configs);
|
|
282
|
-
if (!quiet) {
|
|
283
|
-
log("template update: managed native configs refreshed");
|
|
284
|
-
}
|
|
285
712
|
}
|
|
286
713
|
|
|
287
714
|
// --force: update drift baseline hashes so drift clears after regeneration
|
|
@@ -298,6 +725,7 @@ async function cmdSync(target, args = []) {
|
|
|
298
725
|
if (!quiet) {
|
|
299
726
|
console.log(` account: ${authStatus.email} · plan: ${authStatus.plan || license.plan || "free"} · activation: ${license.status}`);
|
|
300
727
|
console.log(` project: ${boundProject.project_id || identity.project_id}`);
|
|
728
|
+
warnHooksPathDrift(target);
|
|
301
729
|
}
|
|
302
730
|
|
|
303
731
|
// Ensure ai/VERSION matches CLI version after successful sync
|
|
@@ -309,9 +737,11 @@ async function cmdSync(target, args = []) {
|
|
|
309
737
|
}
|
|
310
738
|
} catch {}
|
|
311
739
|
|
|
312
|
-
// Update portfolio registry
|
|
313
|
-
|
|
314
|
-
|
|
740
|
+
// Update portfolio registry (Phase 1.5 #2237: drift guard)
|
|
741
|
+
if (await guardRegistryDrift(target, path.basename(target), args)) {
|
|
742
|
+
registerProjectOrExit(target, path.basename(target), stack);
|
|
743
|
+
}
|
|
744
|
+
await sendProjectHeartbeat(target, identity, result, {
|
|
315
745
|
project_id: boundProject.project_id || identity.project_id,
|
|
316
746
|
}).catch(() => {});
|
|
317
747
|
recordExperienceEvent(target, {
|
|
@@ -324,4 +754,74 @@ async function cmdSync(target, args = []) {
|
|
|
324
754
|
});
|
|
325
755
|
}
|
|
326
756
|
|
|
327
|
-
|
|
757
|
+
function buildLocalSyncPreview(target, { version, stack, cliVersion }) {
|
|
758
|
+
const changes = [];
|
|
759
|
+
const expectedAiFiles = [
|
|
760
|
+
"ai/VERSION",
|
|
761
|
+
"ai/manifest/project.yaml",
|
|
762
|
+
"ai/manifest/commands.yaml",
|
|
763
|
+
"ai/manifest/discovery.json",
|
|
764
|
+
];
|
|
765
|
+
for (const rel of expectedAiFiles) {
|
|
766
|
+
if (!fs.existsSync(path.join(target, rel))) changes.push(`${rel} (missing)`);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
if (version !== "unknown" && version !== cliVersion) {
|
|
770
|
+
changes.push(`ai/VERSION (${version} -> ${cliVersion})`);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const driftTracked = [
|
|
774
|
+
"CLAUDE.md",
|
|
775
|
+
"AGENTS.md",
|
|
776
|
+
"GEMINI.md",
|
|
777
|
+
"opencode.json",
|
|
778
|
+
".cursorrules",
|
|
779
|
+
".windsurfrules",
|
|
780
|
+
".aider.conf.yml",
|
|
781
|
+
];
|
|
782
|
+
const hashesPath = path.join(target, "ai", "manifest", "config_hashes.json");
|
|
783
|
+
try {
|
|
784
|
+
const hashes = JSON.parse(fs.readFileSync(hashesPath, "utf8"));
|
|
785
|
+
const crypto = require("crypto");
|
|
786
|
+
for (const rel of driftTracked) {
|
|
787
|
+
const filePath = path.join(target, rel);
|
|
788
|
+
const recorded = hashes[rel];
|
|
789
|
+
const exists = fs.existsSync(filePath) && fs.statSync(filePath).isFile();
|
|
790
|
+
if (recorded && !exists) changes.push(`${rel} (missing from workspace)`);
|
|
791
|
+
if (recorded && exists) {
|
|
792
|
+
const currentHash = crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
|
|
793
|
+
if (currentHash !== String(recorded.hash || "")) changes.push(`${rel} (local edits)`);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
} catch {}
|
|
797
|
+
|
|
798
|
+
if (!fs.existsSync(path.join(target, "ai"))) {
|
|
799
|
+
changes.push("ai/ layer missing — run 0dai init after auth");
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
return {
|
|
803
|
+
stack,
|
|
804
|
+
current_version: version,
|
|
805
|
+
cli_version: cliVersion,
|
|
806
|
+
version_matches: version === cliVersion,
|
|
807
|
+
changes,
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
module.exports = {
|
|
812
|
+
cmdInit,
|
|
813
|
+
cmdSync,
|
|
814
|
+
cmdProjectBind,
|
|
815
|
+
buildLocalSyncPreview,
|
|
816
|
+
runMcpBootstrap,
|
|
817
|
+
bindProjectForCloud,
|
|
818
|
+
cloudInitCheckpointPath,
|
|
819
|
+
writeCloudInitCheckpoint,
|
|
820
|
+
clearCloudInitCheckpoint,
|
|
821
|
+
buildCloudInitResumeCommand,
|
|
822
|
+
collectCurrentAiFiles,
|
|
823
|
+
hashFile,
|
|
824
|
+
detectRegistryDrift,
|
|
825
|
+
guardRegistryDrift,
|
|
826
|
+
SYNC_FULL_CONTENT_LIMIT,
|
|
827
|
+
};
|