@0dai-dev/cli 4.3.5 → 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.
@@ -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 };
@@ -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
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@0dai-dev/cli",
3
- "version": "4.3.5",
4
- "description": "One config layer for seven AI coding agents Claude Code, Codex, OpenCode, Gemini, Aider, Qoder",
3
+ "version": "4.3.6",
4
+ "description": "One config layer for seven AI coding agents \u2014 Claude Code, Codex, OpenCode, Gemini, Aider, Qoder",
5
5
  "bin": {
6
6
  "0dai": "./bin/0dai.js"
7
7
  },