@0dai-dev/cli 4.3.4 → 4.3.6
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 +75 -12
- package/bin/0dai.js +97 -11
- package/lib/commands/auth.js +62 -6
- package/lib/commands/detect.js +10 -4
- package/lib/commands/doctor.js +61 -17
- package/lib/commands/export.js +73 -0
- package/lib/commands/init.js +77 -3
- package/lib/commands/mcp.js +33 -3
- package/lib/commands/run.js +64 -4
- package/lib/commands/status.js +9 -0
- package/lib/commands/trust.js +286 -0
- package/lib/commands/upgrade.js +58 -0
- package/lib/commands/vault.js +3 -1
- package/lib/shared.js +2 -2
- package/lib/utils/activation_telemetry.js +378 -0
- package/lib/utils/constants.js +7 -0
- package/lib/utils/export-bundler.js +285 -0
- package/lib/utils/identity.js +14 -1
- package/lib/utils/plan.js +10 -0
- package/lib/utils/run_cost.js +91 -0
- package/package.json +8 -4
- package/lib/tui/index.mjs +0 -34994
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Cohort detection
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
// Slug fragments that identify the 0dai self-build (dogfood) repo.
|
|
11
|
+
// Any project whose scrubbed remote origin contains one of these strings is
|
|
12
|
+
// classified as "dogfood". Everything else is "external".
|
|
13
|
+
// Limitation (honest): until real external users exist, the "external" bucket
|
|
14
|
+
// will be empty — local-only repos (no remote) also default to "dogfood"
|
|
15
|
+
// because there is no reliable signal to distinguish them from the self-build.
|
|
16
|
+
const DOGFOOD_ORIGIN_FRAGMENTS = ["iGeezmo/0dai", "0dai-dev/0dai", "0dai/0dai"];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Classify the repo at `target` as "dogfood" or "external".
|
|
20
|
+
*
|
|
21
|
+
* Heuristic: if the git remote.origin.url (scrubbed of credentials) contains
|
|
22
|
+
* a known 0dai repo slug, the cohort is "dogfood". Otherwise "external".
|
|
23
|
+
* Falls back to "dogfood" when no remote is present (local-only repos).
|
|
24
|
+
*
|
|
25
|
+
* Honesty caveat: this is a name-match heuristic, not cryptographic identity.
|
|
26
|
+
* It will mis-classify a fork that still has the upstream remote as dogfood.
|
|
27
|
+
* It defaults to "dogfood" for local-only repos, so it NEVER over-counts
|
|
28
|
+
* external activations — it may under-count them.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} target - absolute path to the repo root
|
|
31
|
+
* @returns {"dogfood"|"external"}
|
|
32
|
+
*/
|
|
33
|
+
function detectCohort(target) {
|
|
34
|
+
try {
|
|
35
|
+
const { execFileSync } = require("child_process");
|
|
36
|
+
const rawUrl = execFileSync(
|
|
37
|
+
"git", ["config", "--get", "remote.origin.url"],
|
|
38
|
+
{ cwd: target, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", timeout: 3000 },
|
|
39
|
+
).trim();
|
|
40
|
+
if (!rawUrl) return "dogfood";
|
|
41
|
+
// Scrub credentials (https://user:token@host → https://host)
|
|
42
|
+
const url = rawUrl.replace(/^(https?:\/\/)[^@/\s]+@/, "$1");
|
|
43
|
+
for (const frag of DOGFOOD_ORIGIN_FRAGMENTS) {
|
|
44
|
+
if (url.includes(frag)) return "dogfood";
|
|
45
|
+
}
|
|
46
|
+
return "external";
|
|
47
|
+
} catch {
|
|
48
|
+
// No git, no remote, or git error → safe fallback
|
|
49
|
+
return "dogfood";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Merged-PR detection via git log
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Detect the timestamp of the earliest merge commit in the repo's local history.
|
|
59
|
+
* Looks for commits that git identifies as merge commits (two+ parents), which
|
|
60
|
+
* covers both GitHub squash+merge and traditional merge strategies.
|
|
61
|
+
*
|
|
62
|
+
* Returns ISO timestamp of the earliest such commit, or null if none found.
|
|
63
|
+
*
|
|
64
|
+
* Limitation: reads only local history — will miss PRs merged on the remote
|
|
65
|
+
* before the repo was cloned if those commits were not fetched. That is
|
|
66
|
+
* acceptable: we record the first merge *experienced* in this checkout.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} target - absolute path to the repo root
|
|
69
|
+
* @returns {string|null} ISO timestamp or null
|
|
70
|
+
*/
|
|
71
|
+
function detectFirstMergedPrTs(target) {
|
|
72
|
+
try {
|
|
73
|
+
const { execFileSync } = require("child_process");
|
|
74
|
+
// --merges selects only merge commits; --reverse gives oldest first; -1 takes the first
|
|
75
|
+
const out = execFileSync(
|
|
76
|
+
"git",
|
|
77
|
+
["log", "--all", "--merges", "--format=%aI\t%s", "--reverse", "--max-count=1"],
|
|
78
|
+
{ cwd: target, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", timeout: 5000 },
|
|
79
|
+
).trim();
|
|
80
|
+
if (!out) return null;
|
|
81
|
+
const tab = out.indexOf("\t");
|
|
82
|
+
if (tab < 0) return null;
|
|
83
|
+
const ts = out.slice(0, tab).trim();
|
|
84
|
+
return ts || null;
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// Core helpers
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
function activationPath(target) {
|
|
95
|
+
return path.join(target, "ai", "meta", "telemetry", "activation.jsonl");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function nowIso() {
|
|
99
|
+
return new Date().toISOString();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function ensureDir(filePath) {
|
|
103
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function readEvents(target) {
|
|
107
|
+
const file = activationPath(target);
|
|
108
|
+
if (!fs.existsSync(file)) return [];
|
|
109
|
+
const lines = fs.readFileSync(file, "utf8").trim().split("\n").filter(Boolean);
|
|
110
|
+
const events = [];
|
|
111
|
+
for (const ln of lines) {
|
|
112
|
+
try {
|
|
113
|
+
const row = JSON.parse(ln);
|
|
114
|
+
if (row && row.event) events.push(row);
|
|
115
|
+
} catch { /* ignore malformed lines */ }
|
|
116
|
+
}
|
|
117
|
+
return events;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function appendEvent(target, entry) {
|
|
121
|
+
const file = activationPath(target);
|
|
122
|
+
try {
|
|
123
|
+
ensureDir(file);
|
|
124
|
+
fs.appendFileSync(file, JSON.stringify(entry) + "\n", "utf8");
|
|
125
|
+
return true;
|
|
126
|
+
} catch {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function hasEvent(events, eventName, projectId) {
|
|
132
|
+
return events.some((e) => e.event === eventName && e.project_id === projectId);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function findInitTs(events, projectId) {
|
|
136
|
+
const init = events.find((e) => e.event === "init" && e.project_id === projectId);
|
|
137
|
+
return init && init.ts ? init.ts : null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function parseTs(ts) {
|
|
141
|
+
const ms = Date.parse(ts);
|
|
142
|
+
return Number.isFinite(ms) ? ms : null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// Recorders
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
// Idempotently append init telemetry on successful init.
|
|
150
|
+
function recordActivationInit(target, projectId, metadata = {}) {
|
|
151
|
+
try {
|
|
152
|
+
if (!projectId) return { fired: false };
|
|
153
|
+
const events = readEvents(target);
|
|
154
|
+
if (hasEvent(events, "init", projectId)) return { fired: false };
|
|
155
|
+
const cohort = detectCohort(target);
|
|
156
|
+
const stack = metadata.stack_detected || metadata.stack || "unknown";
|
|
157
|
+
const entry = {
|
|
158
|
+
event: "init",
|
|
159
|
+
ts: nowIso(),
|
|
160
|
+
project_id: projectId,
|
|
161
|
+
repo_fingerprint_hash: projectId,
|
|
162
|
+
stack_detected: stack,
|
|
163
|
+
cohort,
|
|
164
|
+
};
|
|
165
|
+
return { fired: appendEvent(target, entry) };
|
|
166
|
+
} catch {
|
|
167
|
+
return { fired: false };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Idempotently append first_task with duration_ms = first_task.ts - init.ts.
|
|
172
|
+
function recordActivationFirstTask(target, projectId, metadata = {}) {
|
|
173
|
+
try {
|
|
174
|
+
if (!projectId) return { fired: false, durationMs: null };
|
|
175
|
+
const events = readEvents(target);
|
|
176
|
+
if (hasEvent(events, "first_task", projectId)) {
|
|
177
|
+
return { fired: false, durationMs: null };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const initTs = findInitTs(events, projectId);
|
|
181
|
+
const ts = nowIso();
|
|
182
|
+
let durationMs = null;
|
|
183
|
+
if (initTs) {
|
|
184
|
+
const initMs = parseTs(initTs);
|
|
185
|
+
const taskMs = parseTs(ts);
|
|
186
|
+
if (initMs != null && taskMs != null) {
|
|
187
|
+
durationMs = Math.max(0, taskMs - initMs);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const cohort = detectCohort(target);
|
|
192
|
+
const entry = {
|
|
193
|
+
event: "first_task",
|
|
194
|
+
ts,
|
|
195
|
+
project_id: projectId,
|
|
196
|
+
duration_ms: durationMs,
|
|
197
|
+
cohort,
|
|
198
|
+
outcome: metadata.outcome || "task_queued",
|
|
199
|
+
};
|
|
200
|
+
if (Number.isFinite(metadata.task_count)) entry.task_count = metadata.task_count;
|
|
201
|
+
const fired = appendEvent(target, entry);
|
|
202
|
+
return { fired, durationMs };
|
|
203
|
+
} catch {
|
|
204
|
+
return { fired: false, durationMs: null };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Idempotently append a `first_merged_pr` activation event.
|
|
210
|
+
*
|
|
211
|
+
* This event marks the end-to-end activation funnel: init → first_task →
|
|
212
|
+
* first_merged_pr. It is what M6 "activation p50 <5min (measured)" needs to
|
|
213
|
+
* be verifiable end-to-end.
|
|
214
|
+
*
|
|
215
|
+
* Detection: inspects `git log --merges` for the earliest merge commit in
|
|
216
|
+
* the local history. If none exists yet, does nothing (caller should invoke
|
|
217
|
+
* again after a merge occurs, e.g. from `0dai status` or `0dai ci`).
|
|
218
|
+
*
|
|
219
|
+
* Fields emitted:
|
|
220
|
+
* - event: "first_merged_pr"
|
|
221
|
+
* - ts: ISO timestamp of the first merge commit (reflects when the PR was
|
|
222
|
+
* actually merged, not wall-clock detection time)
|
|
223
|
+
* - project_id: hashed repo identity (no raw origin stored — privacy-safe)
|
|
224
|
+
* - cohort: "dogfood" | "external"
|
|
225
|
+
* - duration_ms: ms between init event and the merge timestamp (null if no
|
|
226
|
+
* init event found in the local activation.jsonl)
|
|
227
|
+
*
|
|
228
|
+
* @param {string} target - absolute path to the repo root
|
|
229
|
+
* @param {string} projectId - opaque project identifier
|
|
230
|
+
* @returns {{ fired: boolean, durationMs: number|null }}
|
|
231
|
+
*/
|
|
232
|
+
function recordActivationFirstMergedPR(target, projectId) {
|
|
233
|
+
try {
|
|
234
|
+
if (!projectId) return { fired: false, durationMs: null };
|
|
235
|
+
const events = readEvents(target);
|
|
236
|
+
if (hasEvent(events, "first_merged_pr", projectId)) {
|
|
237
|
+
return { fired: false, durationMs: null };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const mergeTs = detectFirstMergedPrTs(target);
|
|
241
|
+
if (!mergeTs) return { fired: false, durationMs: null };
|
|
242
|
+
|
|
243
|
+
const initTs = findInitTs(events, projectId);
|
|
244
|
+
let durationMs = null;
|
|
245
|
+
if (initTs) {
|
|
246
|
+
const initMs = parseTs(initTs);
|
|
247
|
+
const mergeMs = parseTs(mergeTs);
|
|
248
|
+
if (initMs != null && mergeMs != null) {
|
|
249
|
+
durationMs = Math.max(0, mergeMs - initMs);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const cohort = detectCohort(target);
|
|
254
|
+
const entry = {
|
|
255
|
+
event: "first_merged_pr",
|
|
256
|
+
ts: mergeTs,
|
|
257
|
+
project_id: projectId,
|
|
258
|
+
cohort,
|
|
259
|
+
duration_ms: durationMs,
|
|
260
|
+
};
|
|
261
|
+
const fired = appendEvent(target, entry);
|
|
262
|
+
return { fired, durationMs };
|
|
263
|
+
} catch {
|
|
264
|
+
return { fired: false, durationMs: null };
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
// Stats
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
function percentile(sorted, p) {
|
|
273
|
+
if (!sorted.length) return null;
|
|
274
|
+
if (sorted.length === 1) return sorted[0];
|
|
275
|
+
const idx = (sorted.length - 1) * p;
|
|
276
|
+
const lo = Math.floor(idx);
|
|
277
|
+
const hi = Math.ceil(idx);
|
|
278
|
+
if (lo === hi) return sorted[lo];
|
|
279
|
+
const weight = idx - lo;
|
|
280
|
+
return Math.round(sorted[lo] * (1 - weight) + sorted[hi] * weight);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function getActivationDurationStats(target) {
|
|
284
|
+
const events = readEvents(target);
|
|
285
|
+
const durations = events
|
|
286
|
+
.filter((e) => e.event === "first_task"
|
|
287
|
+
&& typeof e.duration_ms === "number"
|
|
288
|
+
&& Number.isFinite(e.duration_ms))
|
|
289
|
+
.map((e) => e.duration_ms)
|
|
290
|
+
.sort((a, b) => a - b);
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
count: durations.length,
|
|
294
|
+
p50_ms: percentile(durations, 0.5),
|
|
295
|
+
p90_ms: percentile(durations, 0.9),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Stats over `first_merged_pr` events — parallel to getActivationDurationStats.
|
|
301
|
+
* Used by `0dai status` to surface end-to-end activation timing.
|
|
302
|
+
*
|
|
303
|
+
* @param {string} target
|
|
304
|
+
* @returns {{ count: number, p50_ms: number|null, p90_ms: number|null,
|
|
305
|
+
* cohorts: { dogfood: number, external: number } }}
|
|
306
|
+
*/
|
|
307
|
+
function getActivationMergedPrStats(target) {
|
|
308
|
+
const events = readEvents(target);
|
|
309
|
+
const mergedPrEvents = events.filter((e) => e.event === "first_merged_pr");
|
|
310
|
+
|
|
311
|
+
const durations = mergedPrEvents
|
|
312
|
+
.filter((e) => typeof e.duration_ms === "number" && Number.isFinite(e.duration_ms))
|
|
313
|
+
.map((e) => e.duration_ms)
|
|
314
|
+
.sort((a, b) => a - b);
|
|
315
|
+
|
|
316
|
+
const cohorts = { dogfood: 0, external: 0 };
|
|
317
|
+
for (const e of mergedPrEvents) {
|
|
318
|
+
if (e.cohort === "external") cohorts.external++;
|
|
319
|
+
else cohorts.dogfood++;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
count: durations.length,
|
|
324
|
+
p50_ms: percentile(durations, 0.5),
|
|
325
|
+
p90_ms: percentile(durations, 0.9),
|
|
326
|
+
cohorts,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ---------------------------------------------------------------------------
|
|
331
|
+
// Display helpers
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
function formatDurationMs(ms) {
|
|
335
|
+
if (ms == null) return "?";
|
|
336
|
+
if (ms < 1000) return `${ms}ms`;
|
|
337
|
+
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
338
|
+
return `${(ms / 60000).toFixed(1)}m`;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function printActivationStats(target) {
|
|
342
|
+
const stats = getActivationDurationStats(target);
|
|
343
|
+
if (stats.count > 0) {
|
|
344
|
+
console.log(
|
|
345
|
+
` activation TTFV: p50 ${formatDurationMs(stats.p50_ms)}`
|
|
346
|
+
+ ` / p90 ${formatDurationMs(stats.p90_ms)}`
|
|
347
|
+
+ ` (${stats.count} sample${stats.count === 1 ? "" : "s"})`,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const mrStats = getActivationMergedPrStats(target);
|
|
352
|
+
if (mrStats.count > 0) {
|
|
353
|
+
const cohortNote = mrStats.cohorts.external > 0
|
|
354
|
+
? ` [dogfood: ${mrStats.cohorts.dogfood}, external: ${mrStats.cohorts.external}]`
|
|
355
|
+
: ` [dogfood only — no external activations yet]`;
|
|
356
|
+
console.log(
|
|
357
|
+
` activation first-PR: p50 ${formatDurationMs(mrStats.p50_ms)}`
|
|
358
|
+
+ ` / p90 ${formatDurationMs(mrStats.p90_ms)}`
|
|
359
|
+
+ ` (${mrStats.count} sample${mrStats.count === 1 ? "" : "s"})${cohortNote}`,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return { ttfv: stats, firstPr: mrStats };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
module.exports = {
|
|
367
|
+
activationPath,
|
|
368
|
+
readEvents,
|
|
369
|
+
detectCohort,
|
|
370
|
+
detectFirstMergedPrTs,
|
|
371
|
+
recordActivationInit,
|
|
372
|
+
recordActivationFirstTask,
|
|
373
|
+
recordActivationFirstMergedPR,
|
|
374
|
+
getActivationDurationStats,
|
|
375
|
+
getActivationMergedPrStats,
|
|
376
|
+
printActivationStats,
|
|
377
|
+
formatDurationMs,
|
|
378
|
+
};
|
package/lib/utils/constants.js
CHANGED
|
@@ -46,6 +46,13 @@ const SUPPORTED_CLIS = [
|
|
|
46
46
|
altAuth: null,
|
|
47
47
|
agentFiles: [".qoder/settings.json"],
|
|
48
48
|
},
|
|
49
|
+
{
|
|
50
|
+
name: "cursor", bin: "cursor-agent",
|
|
51
|
+
pkg: null, pkgType: "curl",
|
|
52
|
+
install: "curl https://cursor.com/install -fsS | bash",
|
|
53
|
+
altAuth: "Cursor Pro/Business subscription",
|
|
54
|
+
agentFiles: [".cursor/mcp.json", ".cursor/hooks.json"],
|
|
55
|
+
},
|
|
49
56
|
];
|
|
50
57
|
|
|
51
58
|
const MANIFEST_FILES = [
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (c) 2026 0dai.dev
|
|
3
|
+
//
|
|
4
|
+
// F14 G4 Phase 2 — tarball builder for `0dai export --all`.
|
|
5
|
+
//
|
|
6
|
+
// Honors the layout pinned in docs/governance/data-export-contract.md
|
|
7
|
+
// (F14 G4 Phase 1 / PR #3569). Phase 2 ships personas +
|
|
8
|
+
// path-protect.yaml as real data, usage-ledger.jsonl when present,
|
|
9
|
+
// and 6 placeholders for the rest.
|
|
10
|
+
//
|
|
11
|
+
// Uses `tar` shell command (always present on Linux/macOS) rather
|
|
12
|
+
// than the `tar` npm module to avoid a new runtime dep.
|
|
13
|
+
|
|
14
|
+
"use strict";
|
|
15
|
+
|
|
16
|
+
const crypto = require("crypto");
|
|
17
|
+
const fs = require("fs");
|
|
18
|
+
const os = require("os");
|
|
19
|
+
const path = require("path");
|
|
20
|
+
const { spawnSync } = require("child_process");
|
|
21
|
+
|
|
22
|
+
const PHASE = "F14 G4 Phase 2";
|
|
23
|
+
const SCHEMA_VERSION = "1";
|
|
24
|
+
|
|
25
|
+
const PLACEHOLDER_SURFACES = [
|
|
26
|
+
// (surface name, tarball relative path)
|
|
27
|
+
["knowledge-graph", "knowledge-graph/entries.jsonl"],
|
|
28
|
+
["audit-log", "audit-log/events.jsonl"],
|
|
29
|
+
["team-config", "team/config.yaml"],
|
|
30
|
+
["repo-links", "repo-links.json"],
|
|
31
|
+
["webhooks", "webhooks.yaml"],
|
|
32
|
+
["feature-flags", "feature-flags.yaml"],
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
function sha256OfFile(filePath) {
|
|
36
|
+
const hash = crypto.createHash("sha256");
|
|
37
|
+
const fd = fs.openSync(filePath, "r");
|
|
38
|
+
const buf = Buffer.allocUnsafe(1024 * 1024);
|
|
39
|
+
try {
|
|
40
|
+
let n = 0;
|
|
41
|
+
do {
|
|
42
|
+
n = fs.readSync(fd, buf, 0, buf.length, null);
|
|
43
|
+
if (n > 0) hash.update(buf.subarray(0, n));
|
|
44
|
+
} while (n > 0);
|
|
45
|
+
} finally {
|
|
46
|
+
fs.closeSync(fd);
|
|
47
|
+
}
|
|
48
|
+
return hash.digest("hex");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function sha256OfBytes(bytes) {
|
|
52
|
+
return crypto.createHash("sha256").update(bytes).digest("hex");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function listPersonas(sourceRoot) {
|
|
56
|
+
const personasDir = path.join(sourceRoot, "ai", "personas");
|
|
57
|
+
if (!fs.existsSync(personasDir)) return [];
|
|
58
|
+
return fs.readdirSync(personasDir)
|
|
59
|
+
.filter((name) => name.endsWith(".yaml") || name.endsWith(".yml"))
|
|
60
|
+
.map((name) => ({
|
|
61
|
+
name,
|
|
62
|
+
sourcePath: path.join(personasDir, name),
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function pathProtectPath(sourceRoot) {
|
|
67
|
+
const p = path.join(sourceRoot, "ai", "policy", "path-protect.yaml");
|
|
68
|
+
return fs.existsSync(p) ? p : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function usageLedgerPath(sourceRoot) {
|
|
72
|
+
const p = path.join(sourceRoot, "ai", "telemetry", "usage-ledger.jsonl");
|
|
73
|
+
return fs.existsSync(p) ? p : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function readme(tenantId) {
|
|
77
|
+
return `# 0dai export bundle
|
|
78
|
+
|
|
79
|
+
Generated by \`0dai export --all\` (F14 G4 Phase 2).
|
|
80
|
+
|
|
81
|
+
Verify per docs/governance/data-export-contract.md.
|
|
82
|
+
|
|
83
|
+
Tenant: ${tenantId}
|
|
84
|
+
Schema version: ${SCHEMA_VERSION}
|
|
85
|
+
`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function placeholderPayload(surface) {
|
|
89
|
+
return JSON.stringify({
|
|
90
|
+
_status: "not-implemented",
|
|
91
|
+
_surface: surface,
|
|
92
|
+
_since: PHASE,
|
|
93
|
+
_note: "Phase 3 will populate this surface; today it ships as a placeholder so re-import tooling can validate the layout.",
|
|
94
|
+
}, null, 2) + "\n";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function buildExportTarball({ sourceRoot, outputPath }) {
|
|
98
|
+
if (!sourceRoot || !fs.existsSync(sourceRoot)) {
|
|
99
|
+
throw new Error(`source root not found: ${sourceRoot}`);
|
|
100
|
+
}
|
|
101
|
+
const outDir = path.dirname(path.resolve(outputPath));
|
|
102
|
+
if (!fs.existsSync(outDir)) {
|
|
103
|
+
throw new Error(`output dir does not exist: ${outDir}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const stagingRoot = fs.mkdtempSync(path.join(os.tmpdir(), "0dai-export-"));
|
|
107
|
+
try {
|
|
108
|
+
// README.md
|
|
109
|
+
const tenantId = path.basename(sourceRoot);
|
|
110
|
+
const readmeBytes = Buffer.from(readme(tenantId), "utf8");
|
|
111
|
+
fs.writeFileSync(path.join(stagingRoot, "README.md"), readmeBytes);
|
|
112
|
+
|
|
113
|
+
// tenant.json (stub — Phase 3 populates from API)
|
|
114
|
+
const tenantBytes = Buffer.from(JSON.stringify({
|
|
115
|
+
_status: "stub",
|
|
116
|
+
_since: PHASE,
|
|
117
|
+
tenant_id: tenantId,
|
|
118
|
+
exported_at: new Date().toISOString(),
|
|
119
|
+
}, null, 2) + "\n", "utf8");
|
|
120
|
+
fs.writeFileSync(path.join(stagingRoot, "tenant.json"), tenantBytes);
|
|
121
|
+
|
|
122
|
+
// personas/<name>.yaml (real data)
|
|
123
|
+
const personasOutDir = path.join(stagingRoot, "personas");
|
|
124
|
+
fs.mkdirSync(personasOutDir, { recursive: true });
|
|
125
|
+
const personas = listPersonas(sourceRoot);
|
|
126
|
+
const personaManifest = [];
|
|
127
|
+
for (const p of personas) {
|
|
128
|
+
const dest = path.join(personasOutDir, p.name);
|
|
129
|
+
fs.copyFileSync(p.sourcePath, dest);
|
|
130
|
+
personaManifest.push({
|
|
131
|
+
path: `personas/${p.name}`,
|
|
132
|
+
bytes: fs.statSync(dest).size,
|
|
133
|
+
sha256: sha256OfFile(dest),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// path-protect.yaml (real data, if present)
|
|
138
|
+
const pp = pathProtectPath(sourceRoot);
|
|
139
|
+
let pathProtectManifest = null;
|
|
140
|
+
if (pp) {
|
|
141
|
+
const dest = path.join(stagingRoot, "path-protect.yaml");
|
|
142
|
+
fs.copyFileSync(pp, dest);
|
|
143
|
+
pathProtectManifest = {
|
|
144
|
+
path: "path-protect.yaml",
|
|
145
|
+
bytes: fs.statSync(dest).size,
|
|
146
|
+
sha256: sha256OfFile(dest),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// usage/history.jsonl (real data from ai/telemetry/usage-ledger.jsonl, if present)
|
|
151
|
+
// Schema is the LedgerEntry dataclass in scripts/usage_ledger.py (~line 92):
|
|
152
|
+
// ts, task_id, agent, model, model_tier, tokens_input/output/total, cost_usd, plan, ...
|
|
153
|
+
const ul = usageLedgerPath(sourceRoot);
|
|
154
|
+
let usageLedgerManifest = null;
|
|
155
|
+
if (ul) {
|
|
156
|
+
const usageOutDir = path.join(stagingRoot, "usage");
|
|
157
|
+
fs.mkdirSync(usageOutDir, { recursive: true });
|
|
158
|
+
const dest = path.join(usageOutDir, "history.jsonl");
|
|
159
|
+
fs.copyFileSync(ul, dest);
|
|
160
|
+
usageLedgerManifest = {
|
|
161
|
+
path: "usage/history.jsonl",
|
|
162
|
+
bytes: fs.statSync(dest).size,
|
|
163
|
+
sha256: sha256OfFile(dest),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 6 placeholder surfaces
|
|
168
|
+
const placeholderManifest = [];
|
|
169
|
+
for (const [surface, relPath] of PLACEHOLDER_SURFACES) {
|
|
170
|
+
const dest = path.join(stagingRoot, relPath);
|
|
171
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
172
|
+
const payload = placeholderPayload(surface);
|
|
173
|
+
fs.writeFileSync(dest, payload, "utf8");
|
|
174
|
+
placeholderManifest.push({
|
|
175
|
+
path: relPath,
|
|
176
|
+
bytes: Buffer.byteLength(payload, "utf8"),
|
|
177
|
+
sha256: sha256OfBytes(Buffer.from(payload, "utf8")),
|
|
178
|
+
status: "not-implemented",
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// MANIFEST.json (must come last so other files' checksums are settled)
|
|
183
|
+
const manifest = {
|
|
184
|
+
schema_version: SCHEMA_VERSION,
|
|
185
|
+
generated_at: new Date().toISOString(),
|
|
186
|
+
since: PHASE,
|
|
187
|
+
tenant_id: tenantId,
|
|
188
|
+
files: [
|
|
189
|
+
{ path: "README.md", bytes: readmeBytes.length, sha256: sha256OfBytes(readmeBytes) },
|
|
190
|
+
{ path: "tenant.json", bytes: tenantBytes.length, sha256: sha256OfBytes(tenantBytes) },
|
|
191
|
+
...personaManifest,
|
|
192
|
+
...(pathProtectManifest ? [pathProtectManifest] : []),
|
|
193
|
+
...(usageLedgerManifest ? [usageLedgerManifest] : []),
|
|
194
|
+
...placeholderManifest,
|
|
195
|
+
],
|
|
196
|
+
};
|
|
197
|
+
fs.writeFileSync(
|
|
198
|
+
path.join(stagingRoot, "MANIFEST.json"),
|
|
199
|
+
JSON.stringify(manifest, null, 2) + "\n",
|
|
200
|
+
"utf8",
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
// Create gzipped tarball.
|
|
204
|
+
const r = spawnSync("tar", ["-czf", path.resolve(outputPath), "-C", stagingRoot, "."]);
|
|
205
|
+
if (r.status !== 0) {
|
|
206
|
+
const stderr = r.stderr ? r.stderr.toString("utf8") : "(no stderr)";
|
|
207
|
+
throw new Error(`tar exited ${r.status}: ${stderr}`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// F14 G4 Phase 3 — try cosign keyless signing. Falls back to
|
|
211
|
+
// empty .sig + .crt stubs (preserving the layout contract)
|
|
212
|
+
// when cosign is absent or the operator opts out.
|
|
213
|
+
const sigPath = `${outputPath}.sig`;
|
|
214
|
+
const crtPath = `${outputPath}.crt`;
|
|
215
|
+
const signResult = trySignTarball({
|
|
216
|
+
tarballPath: path.resolve(outputPath),
|
|
217
|
+
sigPath,
|
|
218
|
+
crtPath,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
tarballPath: path.resolve(outputPath),
|
|
223
|
+
sigPath,
|
|
224
|
+
crtPath,
|
|
225
|
+
sha256: sha256OfFile(outputPath),
|
|
226
|
+
signed: signResult.signed,
|
|
227
|
+
signSkipReason: signResult.skipReason,
|
|
228
|
+
counts: {
|
|
229
|
+
personas: personaManifest.length,
|
|
230
|
+
pathProtect: pathProtectManifest ? 1 : 0,
|
|
231
|
+
usageLedger: usageLedgerManifest ? 1 : 0,
|
|
232
|
+
placeholders: placeholderManifest.length,
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
} finally {
|
|
236
|
+
// Best-effort cleanup; ignore errors.
|
|
237
|
+
try { fs.rmSync(stagingRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function trySignTarball({ tarballPath, sigPath, crtPath }) {
|
|
242
|
+
// F14 G4 Phase 3. Trust anchor matches the F16 G2 release-signing
|
|
243
|
+
// pipeline (release-artifact-signing.yml from PR #3521).
|
|
244
|
+
//
|
|
245
|
+
// Skipped when:
|
|
246
|
+
// - ODAI_EXPORT_SKIP_SIGN=1 is set (explicit opt-out)
|
|
247
|
+
// - cosign binary is not on PATH (local dev without sigstore)
|
|
248
|
+
// - cosign exits non-zero (Fulcio OIDC unavailable, etc.)
|
|
249
|
+
// In every skip case we write empty .sig + .crt stubs so the
|
|
250
|
+
// tarball layout contract from F14 G4 Phase 1 (#3569) holds.
|
|
251
|
+
if (process.env.ODAI_EXPORT_SKIP_SIGN === "1") {
|
|
252
|
+
fs.writeFileSync(sigPath, "");
|
|
253
|
+
fs.writeFileSync(crtPath, "");
|
|
254
|
+
return { signed: false, skipReason: "ODAI_EXPORT_SKIP_SIGN=1" };
|
|
255
|
+
}
|
|
256
|
+
// Probe for cosign on PATH without bombing the export when absent.
|
|
257
|
+
const probe = spawnSync("cosign", ["version"], { stdio: ["ignore", "pipe", "pipe"] });
|
|
258
|
+
if (probe.error || probe.status !== 0) {
|
|
259
|
+
fs.writeFileSync(sigPath, "");
|
|
260
|
+
fs.writeFileSync(crtPath, "");
|
|
261
|
+
return { signed: false, skipReason: "cosign not on PATH" };
|
|
262
|
+
}
|
|
263
|
+
const env = { ...process.env, COSIGN_EXPERIMENTAL: process.env.COSIGN_EXPERIMENTAL || "1" };
|
|
264
|
+
const r = spawnSync(
|
|
265
|
+
"cosign",
|
|
266
|
+
[
|
|
267
|
+
"sign-blob",
|
|
268
|
+
"--yes",
|
|
269
|
+
"--output-signature", sigPath,
|
|
270
|
+
"--output-certificate", crtPath,
|
|
271
|
+
tarballPath,
|
|
272
|
+
],
|
|
273
|
+
{ env, stdio: ["ignore", "pipe", "pipe"] },
|
|
274
|
+
);
|
|
275
|
+
if (r.status !== 0) {
|
|
276
|
+
// Leave (or write) empty stubs so the layout still holds.
|
|
277
|
+
try { fs.writeFileSync(sigPath, ""); } catch { /* ignore */ }
|
|
278
|
+
try { fs.writeFileSync(crtPath, ""); } catch { /* ignore */ }
|
|
279
|
+
const stderr = r.stderr ? r.stderr.toString("utf8").trim().slice(0, 200) : `exit ${r.status}`;
|
|
280
|
+
return { signed: false, skipReason: `cosign sign-blob failed: ${stderr}` };
|
|
281
|
+
}
|
|
282
|
+
return { signed: true, skipReason: null };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
module.exports = { buildExportTarball, listPersonas, pathProtectPath, usageLedgerPath, trySignTarball, PLACEHOLDER_SURFACES, PHASE, SCHEMA_VERSION };
|
package/lib/utils/identity.js
CHANGED
|
@@ -148,6 +148,19 @@ function detectStackHint(projectFiles, manifestContents) {
|
|
|
148
148
|
return "unknown";
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Return a {filename: sha256hex} map for the given manifest contents.
|
|
153
|
+
* The raw content never leaves the machine — only the hash is exported.
|
|
154
|
+
*/
|
|
155
|
+
function hashManifestFiles(manifestContents) {
|
|
156
|
+
const crypto = require("crypto");
|
|
157
|
+
const hashes = {};
|
|
158
|
+
for (const [name, content] of Object.entries(manifestContents)) {
|
|
159
|
+
hashes[name] = crypto.createHash("sha256").update(content, "utf8").digest("hex");
|
|
160
|
+
}
|
|
161
|
+
return hashes;
|
|
162
|
+
}
|
|
163
|
+
|
|
151
164
|
function collectMetadata(target) {
|
|
152
165
|
const projectFiles = [];
|
|
153
166
|
const manifestContents = {};
|
|
@@ -201,5 +214,5 @@ module.exports = {
|
|
|
201
214
|
deviceFingerprint, registerProject, projectIdFor, projectIdSeed,
|
|
202
215
|
scrubRemoteUrl, readProjectManifest, readDiscovery,
|
|
203
216
|
getGitRemoteOrigin, inferProjectName, detectStackHint,
|
|
204
|
-
collectMetadata, buildProjectIdentity,
|
|
217
|
+
collectMetadata, buildProjectIdentity, hashManifestFiles,
|
|
205
218
|
};
|