@0dai-dev/cli 4.0.0 → 4.2.0

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