codexport 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,746 @@
1
+ #!/usr/bin/env node
2
+ import { Command, Option } from "commander";
3
+ import chokidar from "chokidar";
4
+ import { createHash, randomBytes } from "node:crypto";
5
+ import { createServer, request } from "node:http";
6
+ import { mkdir, readFile, readdir, readlink, rename, rm, stat, symlink, writeFile } from "node:fs/promises";
7
+ import { existsSync } from "node:fs";
8
+ import { homedir, platform } from "node:os";
9
+ import path from "node:path";
10
+ import { spawn } from "node:child_process";
11
+ import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
12
+ const VERSION = "0.1.0";
13
+ const DEFAULT_PORT = 17342;
14
+ const DEFAULT_TIMEOUT_MS = 5_000;
15
+ const CODEXPORT_DIR = ".codexport";
16
+ const MASTER_ID_FILE = "master-id.json";
17
+ const LOCAL_FILE = "local.toml";
18
+ const MCPS_LOCAL_FILE = "mcps.local.toml";
19
+ const LAST_BUNDLE_FILE = "last-bundle.json";
20
+ const CACHE_BUNDLE_FILE = "bundle.json";
21
+ const APPLIED_FILES_FILE = "applied-files.json";
22
+ const INCLUDE_ROOTS = [
23
+ "AGENTS.md",
24
+ "RTK.md",
25
+ "config.toml",
26
+ "auth.json",
27
+ ".credentials.json",
28
+ "multi-auth",
29
+ "hooks.json",
30
+ "hooks",
31
+ "prompts",
32
+ "rules",
33
+ "skills",
34
+ "skill-libraries",
35
+ "mise.toml"
36
+ ];
37
+ const EXCLUDE_PARTS = new Set([
38
+ "logs",
39
+ "log",
40
+ "cache",
41
+ "caches",
42
+ "tmp",
43
+ "temp",
44
+ "sessions",
45
+ "history",
46
+ "compact-handoffs",
47
+ "shell-snapshots",
48
+ ".sqlite",
49
+ ".sqlite3"
50
+ ]);
51
+ class CliError extends Error {
52
+ exitCode;
53
+ details;
54
+ constructor(message, exitCode = 1, details) {
55
+ super(message);
56
+ this.exitCode = exitCode;
57
+ this.details = details;
58
+ }
59
+ }
60
+ function asError(error) {
61
+ return error instanceof Error ? error : new Error(String(error));
62
+ }
63
+ function sha256(input) {
64
+ return createHash("sha256").update(input).digest("hex");
65
+ }
66
+ function normalizeRelative(input) {
67
+ return input.split(path.sep).join("/");
68
+ }
69
+ function userPath(homeDir, ...parts) {
70
+ return path.join(homeDir, ...parts);
71
+ }
72
+ function defaultContext(options) {
73
+ const homeDir = options.home ? path.resolve(options.home) : homedir();
74
+ return {
75
+ homeDir,
76
+ stateDir: userPath(homeDir, CODEXPORT_DIR),
77
+ codexDir: path.resolve(options.codexDir ?? userPath(homeDir, ".codex")),
78
+ quiet: Boolean(options.quiet),
79
+ json: Boolean(options.json),
80
+ noInput: Boolean(options.noInput)
81
+ };
82
+ }
83
+ async function pathExists(filePath) {
84
+ try {
85
+ await stat(filePath);
86
+ return true;
87
+ }
88
+ catch (error) {
89
+ const code = error.code;
90
+ if (code === "ENOENT")
91
+ return false;
92
+ throw error;
93
+ }
94
+ }
95
+ async function ensureDir(dir) {
96
+ await mkdir(dir, { recursive: true });
97
+ }
98
+ async function readTextIfExists(filePath) {
99
+ if (!(await pathExists(filePath)))
100
+ return undefined;
101
+ return readFile(filePath, "utf8");
102
+ }
103
+ async function readJsonIfExists(filePath) {
104
+ const text = await readTextIfExists(filePath);
105
+ if (!text)
106
+ return undefined;
107
+ return JSON.parse(text);
108
+ }
109
+ async function writeJsonAtomic(filePath, value) {
110
+ await ensureDir(path.dirname(filePath));
111
+ const tmpPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${process.pid}.tmp`);
112
+ await writeFile(tmpPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
113
+ await rename(tmpPath, filePath);
114
+ }
115
+ function parseTomlObject(text, filePath) {
116
+ try {
117
+ const parsed = parseToml(text);
118
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
119
+ throw new CliError(`${filePath} must contain a TOML table`, 2);
120
+ }
121
+ return parsed;
122
+ }
123
+ catch (error) {
124
+ if (error instanceof CliError)
125
+ throw error;
126
+ throw new CliError(`Failed to parse ${filePath}: ${asError(error).message}`, 2);
127
+ }
128
+ }
129
+ async function readLocalConfig(ctx) {
130
+ const filePath = path.join(ctx.stateDir, LOCAL_FILE);
131
+ const text = await readTextIfExists(filePath);
132
+ if (!text)
133
+ return {};
134
+ const parsed = parseTomlObject(text, filePath);
135
+ return {
136
+ role: parsed.role,
137
+ masterUrl: parsed.masterUrl,
138
+ masterFingerprint: parsed.masterFingerprint,
139
+ lastRevision: parsed.lastRevision,
140
+ codexDir: parsed.codexDir,
141
+ port: typeof parsed.port === "number" ? parsed.port : undefined,
142
+ allowMcpOverrides: Array.isArray(parsed.allowMcpOverrides) ? parsed.allowMcpOverrides.map(String) : undefined,
143
+ allowSkillOverrides: Array.isArray(parsed.allowSkillOverrides) ? parsed.allowSkillOverrides.map(String) : undefined,
144
+ pathVariables: parsed.pathVariables && typeof parsed.pathVariables === "object" ? parsed.pathVariables : undefined
145
+ };
146
+ }
147
+ async function writeLocalConfig(ctx, config) {
148
+ await ensureDir(ctx.stateDir);
149
+ await writeFile(path.join(ctx.stateDir, LOCAL_FILE), stringifyToml(removeUndefined({ ...config })), "utf8");
150
+ }
151
+ function removeUndefined(input) {
152
+ const out = {};
153
+ for (const [key, value] of Object.entries(input)) {
154
+ if (value !== undefined)
155
+ out[key] = value;
156
+ }
157
+ return out;
158
+ }
159
+ async function loadMasterIdentity(ctx) {
160
+ const filePath = path.join(ctx.stateDir, MASTER_ID_FILE);
161
+ const existing = await readJsonIfExists(filePath);
162
+ if (existing?.secret && existing.fingerprint === sha256(existing.secret))
163
+ return existing;
164
+ const secret = randomBytes(32).toString("hex");
165
+ const identity = { secret, fingerprint: sha256(secret) };
166
+ await writeJsonAtomic(filePath, identity);
167
+ return identity;
168
+ }
169
+ function shouldExclude(relativePath) {
170
+ const parts = relativePath.split("/");
171
+ return parts.some((part) => EXCLUDE_PARTS.has(part) || part.endsWith(".sqlite") || part.endsWith(".sqlite3"));
172
+ }
173
+ async function collectFiles(root) {
174
+ const files = [];
175
+ for (const includeRoot of INCLUDE_ROOTS) {
176
+ const absolute = path.join(root, includeRoot);
177
+ if (!(await pathExists(absolute)))
178
+ continue;
179
+ await walkIncluded(root, absolute, files);
180
+ }
181
+ files.sort((a, b) => a.path.localeCompare(b.path));
182
+ return files;
183
+ }
184
+ async function walkIncluded(root, absolute, files) {
185
+ const relative = normalizeRelative(path.relative(root, absolute));
186
+ if (!relative || shouldExclude(relative))
187
+ return;
188
+ const entryStat = await stat(absolute);
189
+ if (entryStat.isDirectory()) {
190
+ const children = await readdir(absolute);
191
+ for (const child of children) {
192
+ await walkIncluded(root, path.join(absolute, child), files);
193
+ }
194
+ return;
195
+ }
196
+ if (entryStat.isFile()) {
197
+ files.push({
198
+ path: relative,
199
+ mode: entryStat.mode & 0o777,
200
+ kind: "file",
201
+ content: (await readFile(absolute)).toString("base64")
202
+ });
203
+ }
204
+ }
205
+ function computeRevision(files) {
206
+ const normalized = files.map((file) => ({
207
+ path: file.path,
208
+ mode: file.mode,
209
+ kind: file.kind,
210
+ contentHash: sha256(Buffer.from(file.content, "base64"))
211
+ }));
212
+ return sha256(JSON.stringify(normalized));
213
+ }
214
+ async function buildBundle(codexDir) {
215
+ const files = await collectFiles(codexDir);
216
+ const revision = computeRevision(files);
217
+ return {
218
+ version: 1,
219
+ builtAt: new Date().toISOString(),
220
+ sourceRoot: codexDir,
221
+ revision,
222
+ files
223
+ };
224
+ }
225
+ async function saveMasterBundle(ctx, bundle) {
226
+ await writeJsonAtomic(path.join(ctx.stateDir, LAST_BUNDLE_FILE), bundle);
227
+ }
228
+ async function readMasterBundle(ctx) {
229
+ const bundle = await readJsonIfExists(path.join(ctx.stateDir, LAST_BUNDLE_FILE));
230
+ if (bundle)
231
+ return bundle;
232
+ const built = await buildBundle(ctx.codexDir);
233
+ await saveMasterBundle(ctx, built);
234
+ return built;
235
+ }
236
+ function formatJoinCommand(masterUrl, fingerprint) {
237
+ return `npx codexport follower join --master ${masterUrl} --fingerprint ${fingerprint}`;
238
+ }
239
+ function buildJoinLink(masterUrl, fingerprint) {
240
+ const url = new URL(masterUrl);
241
+ const join = new URL("codexport://join");
242
+ join.searchParams.set("host", url.hostname);
243
+ join.searchParams.set("port", url.port || String(DEFAULT_PORT));
244
+ join.searchParams.set("fingerprint", fingerprint);
245
+ join.searchParams.set("protocol", url.protocol.replace(":", ""));
246
+ join.searchParams.set("version", "1");
247
+ return join.toString();
248
+ }
249
+ function parseJoinLink(input) {
250
+ if (!input.startsWith("codexport://")) {
251
+ throw new CliError("Join input must be a codexport://join link or use --master and --fingerprint.", 2);
252
+ }
253
+ const url = new URL(input);
254
+ if (url.hostname !== "join")
255
+ throw new CliError("Unsupported codexport link. Expected codexport://join.", 2);
256
+ const host = url.searchParams.get("host");
257
+ const port = url.searchParams.get("port") ?? String(DEFAULT_PORT);
258
+ const fingerprint = url.searchParams.get("fingerprint");
259
+ const protocol = url.searchParams.get("protocol") ?? "http";
260
+ if (!host || !fingerprint)
261
+ throw new CliError("Join link is missing host or fingerprint.", 2);
262
+ return { masterUrl: `${protocol}://${host}:${port}`, fingerprint };
263
+ }
264
+ function requestJson(url, timeoutMs) {
265
+ return new Promise((resolve, reject) => {
266
+ const req = request(url, { method: "GET", timeout: timeoutMs }, (res) => {
267
+ const chunks = [];
268
+ res.on("data", (chunk) => chunks.push(chunk));
269
+ res.on("end", () => {
270
+ const body = Buffer.concat(chunks).toString("utf8");
271
+ if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
272
+ reject(new CliError(`GET ${url} failed with HTTP ${res.statusCode}: ${body.slice(0, 300)}`, 1));
273
+ return;
274
+ }
275
+ try {
276
+ resolve(JSON.parse(body));
277
+ }
278
+ catch (error) {
279
+ reject(new CliError(`GET ${url} returned invalid JSON: ${asError(error).message}`, 1));
280
+ }
281
+ });
282
+ });
283
+ req.on("timeout", () => {
284
+ req.destroy(new CliError(`GET ${url} timed out after ${timeoutMs}ms`, 1));
285
+ });
286
+ req.on("error", reject);
287
+ req.end();
288
+ });
289
+ }
290
+ async function fetchMeta(masterUrl, timeoutMs) {
291
+ return requestJson(new URL("/meta", masterUrl).toString(), timeoutMs);
292
+ }
293
+ async function fetchBundle(masterUrl, timeoutMs) {
294
+ return requestJson(new URL("/bundle", masterUrl).toString(), timeoutMs);
295
+ }
296
+ function verifyBundle(bundle) {
297
+ if (bundle.version !== 1 || !Array.isArray(bundle.files)) {
298
+ throw new CliError("Bundle has an unsupported format.", 1);
299
+ }
300
+ const actualRevision = computeRevision(bundle.files);
301
+ if (bundle.revision !== actualRevision) {
302
+ throw new CliError(`Bundle revision mismatch. Expected ${bundle.revision}, computed ${actualRevision}.`, 1);
303
+ }
304
+ for (const file of bundle.files) {
305
+ if (path.isAbsolute(file.path) || file.path.includes("..") || file.path.includes("\\")) {
306
+ throw new CliError(`Bundle contains unsafe path: ${file.path}`, 1);
307
+ }
308
+ }
309
+ }
310
+ async function readCachedBundle(ctx) {
311
+ const bundle = await readJsonIfExists(path.join(ctx.stateDir, CACHE_BUNDLE_FILE));
312
+ if (!bundle)
313
+ throw new CliError("No staged bundle exists. Run codexport sync first.", 1);
314
+ verifyBundle(bundle);
315
+ return bundle;
316
+ }
317
+ async function writeCachedBundle(ctx, bundle) {
318
+ verifyBundle(bundle);
319
+ await writeJsonAtomic(path.join(ctx.stateDir, CACHE_BUNDLE_FILE), bundle);
320
+ }
321
+ function decodeFile(file) {
322
+ return Buffer.from(file.content, "base64");
323
+ }
324
+ function extractTomlTableNames(text, prefix) {
325
+ const names = new Set();
326
+ const pattern = new RegExp(`^\\s*\\[${prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\.\"?([^"\\]]+)\"?\\]\\s*$`);
327
+ for (const line of text.split(/\r?\n/)) {
328
+ const match = line.match(pattern);
329
+ if (match)
330
+ names.add(match[1]);
331
+ }
332
+ return names;
333
+ }
334
+ function mergeTomlText(canonical, localMcpText, localConfig) {
335
+ const expandedCanonical = expandPathVariables(canonical, localConfig);
336
+ if (!localMcpText?.trim())
337
+ return expandedCanonical;
338
+ const canonicalMcps = extractTomlTableNames(canonical, "mcp_servers");
339
+ const localMcps = extractTomlTableNames(localMcpText, "mcp_servers");
340
+ const allowed = new Set(localConfig.allowMcpOverrides ?? []);
341
+ const conflicts = [...localMcps].filter((name) => canonicalMcps.has(name) && !allowed.has(name));
342
+ if (conflicts.length) {
343
+ throw new CliError(`Local MCP conflicts with canonical names: ${conflicts.join(", ")}. Add allowMcpOverrides in ~/.codexport/local.toml to override intentionally.`, 1, { conflicts });
344
+ }
345
+ return `${expandedCanonical.trimEnd()}\n\n# Follower-local MCP overlay from ~/.codexport/mcps.local.toml\n${localMcpText.trim()}\n`;
346
+ }
347
+ function expandPathVariables(text, localConfig) {
348
+ const variables = {
349
+ home: homedir(),
350
+ codexDir: localConfig.codexDir ?? path.join(homedir(), ".codex"),
351
+ ...(localConfig.pathVariables ?? {})
352
+ };
353
+ return text.replace(/\$\{([A-Za-z0-9_]+)\}/g, (match, name) => {
354
+ const replacement = variables[name];
355
+ return replacement === undefined ? match : replacement.replace(/\\/g, "\\\\");
356
+ });
357
+ }
358
+ async function assertSkillConflicts(ctx, bundle, localConfig) {
359
+ const canonicalSkills = new Set(bundle.files
360
+ .filter((file) => file.path.startsWith("skills/"))
361
+ .map((file) => file.path.split("/")[1])
362
+ .filter(Boolean));
363
+ const localSkillsDir = path.join(ctx.stateDir, "skills");
364
+ if (!(await pathExists(localSkillsDir)))
365
+ return;
366
+ const localNames = await readdir(localSkillsDir);
367
+ const allowed = new Set(localConfig.allowSkillOverrides ?? []);
368
+ const conflicts = localNames.filter((name) => canonicalSkills.has(name) && !allowed.has(name));
369
+ if (conflicts.length) {
370
+ throw new CliError(`Local skills conflict with canonical names: ${conflicts.join(", ")}. Add allowSkillOverrides in ~/.codexport/local.toml to override intentionally.`, 1, { conflicts });
371
+ }
372
+ }
373
+ async function applyBundle(ctx, bundle) {
374
+ verifyBundle(bundle);
375
+ const localConfig = await readLocalConfig(ctx);
376
+ await assertSkillConflicts(ctx, bundle, localConfig);
377
+ await ensureDir(ctx.codexDir);
378
+ const nextFiles = new Set(bundle.files.map((file) => file.path));
379
+ const previousFiles = await readJsonIfExists(path.join(ctx.stateDir, APPLIED_FILES_FILE)) ?? [];
380
+ for (const previousFile of previousFiles) {
381
+ if (nextFiles.has(previousFile))
382
+ continue;
383
+ const target = path.join(ctx.codexDir, previousFile);
384
+ if (path.relative(ctx.codexDir, target).startsWith(".."))
385
+ continue;
386
+ await rm(target, { recursive: true, force: true });
387
+ }
388
+ for (const file of bundle.files) {
389
+ const target = path.join(ctx.codexDir, file.path);
390
+ await ensureDir(path.dirname(target));
391
+ if (file.path === "config.toml")
392
+ continue;
393
+ await writeFile(target, decodeFile(file), { mode: file.mode });
394
+ }
395
+ const configEntry = bundle.files.find((file) => file.path === "config.toml");
396
+ if (configEntry) {
397
+ const canonicalConfig = decodeFile(configEntry).toString("utf8");
398
+ const localMcpText = await readTextIfExists(path.join(ctx.stateDir, MCPS_LOCAL_FILE));
399
+ const generated = mergeTomlText(canonicalConfig, localMcpText, localConfig);
400
+ const configPath = path.join(ctx.codexDir, "config.toml");
401
+ if (await pathExists(configPath)) {
402
+ const backupPath = `${configPath}.codexport-backup-${new Date().toISOString().replace(/[:.]/g, "-")}`;
403
+ await writeFile(backupPath, await readFile(configPath));
404
+ }
405
+ await writeFile(configPath, generated, "utf8");
406
+ }
407
+ const localSkillsDir = path.join(ctx.stateDir, "skills");
408
+ if (await pathExists(localSkillsDir)) {
409
+ const targetLocalSkills = path.join(ctx.codexDir, "skills-local");
410
+ await rm(targetLocalSkills, { recursive: true, force: true });
411
+ await copyDirectory(localSkillsDir, targetLocalSkills);
412
+ }
413
+ await writeLocalConfig(ctx, { ...localConfig, lastRevision: bundle.revision, codexDir: ctx.codexDir });
414
+ await writeJsonAtomic(path.join(ctx.stateDir, APPLIED_FILES_FILE), bundle.files.map((file) => file.path));
415
+ }
416
+ async function copyDirectory(source, target) {
417
+ await ensureDir(target);
418
+ for (const entry of await readdir(source, { withFileTypes: true })) {
419
+ const sourcePath = path.join(source, entry.name);
420
+ const targetPath = path.join(target, entry.name);
421
+ if (entry.isDirectory()) {
422
+ await copyDirectory(sourcePath, targetPath);
423
+ }
424
+ else if (entry.isFile()) {
425
+ await ensureDir(path.dirname(targetPath));
426
+ await writeFile(targetPath, await readFile(sourcePath));
427
+ }
428
+ else if (entry.isSymbolicLink()) {
429
+ const linkTarget = await readlink(sourcePath);
430
+ await symlink(linkTarget, targetPath);
431
+ }
432
+ }
433
+ }
434
+ async function installHook(ctx, timeoutMs) {
435
+ await ensureDir(ctx.codexDir);
436
+ const hooksPath = path.join(ctx.codexDir, "hooks.json");
437
+ const existingText = await readTextIfExists(hooksPath);
438
+ const existing = existingText ? JSON.parse(existingText) : {};
439
+ const hooks = existing.SessionStart && Array.isArray(existing.SessionStart) ? existing.SessionStart : [];
440
+ const command = `codexport sync --apply --timeout-ms ${timeoutMs} --no-input`;
441
+ const filtered = hooks.filter((hook) => !(hook && typeof hook === "object" && hook.name === "codexport-sync"));
442
+ filtered.push({ name: "codexport-sync", command, timeoutMs });
443
+ await writeJsonAtomic(hooksPath, { ...existing, SessionStart: filtered });
444
+ }
445
+ function runCommand(command, args) {
446
+ return new Promise((resolve, reject) => {
447
+ const child = spawn(command, args, { stdio: "inherit" });
448
+ child.on("error", reject);
449
+ child.on("exit", (code) => {
450
+ if (code === 0)
451
+ resolve();
452
+ else
453
+ reject(new CliError(`${command} ${args.join(" ")} exited with ${code}`, code ?? 1));
454
+ });
455
+ });
456
+ }
457
+ async function installMasterService(ctx, port, dryRun) {
458
+ const command = `codexport master serve --port ${port}`;
459
+ if (platform() === "linux") {
460
+ const unitDir = path.join(ctx.homeDir, ".config", "systemd", "user");
461
+ const unitPath = path.join(unitDir, "codexport-master.service");
462
+ const unit = `[Unit]
463
+ Description=Codexport master server
464
+
465
+ [Service]
466
+ ExecStart=${command}
467
+ Restart=on-failure
468
+ RestartSec=5
469
+
470
+ [Install]
471
+ WantedBy=default.target
472
+ `;
473
+ if (!dryRun) {
474
+ await ensureDir(unitDir);
475
+ await writeFile(unitPath, unit, "utf8");
476
+ await runCommand("systemctl", ["--user", "daemon-reload"]);
477
+ await runCommand("systemctl", ["--user", "enable", "--now", "codexport-master.service"]);
478
+ }
479
+ return unitPath;
480
+ }
481
+ if (platform() === "win32") {
482
+ const taskName = "CodexportMaster";
483
+ if (!dryRun) {
484
+ await runCommand("schtasks.exe", ["/Create", "/TN", taskName, "/SC", "ONLOGON", "/TR", command, "/F"]);
485
+ await runCommand("schtasks.exe", ["/Run", "/TN", taskName]);
486
+ }
487
+ return taskName;
488
+ }
489
+ throw new CliError(`Unsupported service platform: ${platform()}`, 1);
490
+ }
491
+ function print(ctx, value) {
492
+ if (ctx.quiet)
493
+ return;
494
+ if (ctx.json) {
495
+ process.stdout.write(`${JSON.stringify(typeof value === "string" ? { message: value } : value, null, 2)}\n`);
496
+ }
497
+ else {
498
+ process.stdout.write(`${typeof value === "string" ? value : humanSummary(value)}\n`);
499
+ }
500
+ }
501
+ function humanSummary(value) {
502
+ if (!value || typeof value !== "object" || Array.isArray(value))
503
+ return String(value);
504
+ return Object.entries(value).map(([key, item]) => `${key}: ${String(item)}`).join("\n");
505
+ }
506
+ function masterUrl(host, port) {
507
+ if (host.startsWith("http://") || host.startsWith("https://"))
508
+ return host;
509
+ return `http://${host}:${port}`;
510
+ }
511
+ function parsePositiveInt(value) {
512
+ const parsed = Number.parseInt(value, 10);
513
+ if (!Number.isFinite(parsed) || parsed <= 0) {
514
+ throw new Error(`Expected a positive integer, got ${value}`);
515
+ }
516
+ return parsed;
517
+ }
518
+ async function commandMasterInit(ctx, options) {
519
+ const identity = await loadMasterIdentity(ctx);
520
+ const bundle = await buildBundle(ctx.codexDir);
521
+ await saveMasterBundle(ctx, bundle);
522
+ await writeLocalConfig(ctx, { ...(await readLocalConfig(ctx)), role: "master", codexDir: ctx.codexDir, port: options.port ?? DEFAULT_PORT });
523
+ print(ctx, { role: "master", fingerprint: identity.fingerprint, revision: bundle.revision, files: bundle.files.length });
524
+ }
525
+ async function commandMasterRebuild(ctx) {
526
+ const bundle = await buildBundle(ctx.codexDir);
527
+ await saveMasterBundle(ctx, bundle);
528
+ print(ctx, { revision: bundle.revision, files: bundle.files.length });
529
+ }
530
+ async function commandMasterLink(ctx, options) {
531
+ const identity = await loadMasterIdentity(ctx);
532
+ const local = await readLocalConfig(ctx);
533
+ const port = options.port ?? local.port ?? DEFAULT_PORT;
534
+ const host = options.host ?? "machine1.tailnet.ts.net";
535
+ const url = masterUrl(host, port);
536
+ print(ctx, {
537
+ joinLink: buildJoinLink(url, identity.fingerprint),
538
+ command: formatJoinCommand(url, identity.fingerprint)
539
+ });
540
+ }
541
+ async function commandMasterServe(ctx, options) {
542
+ const identity = await loadMasterIdentity(ctx);
543
+ let bundle = await readMasterBundle(ctx);
544
+ let rebuilding = false;
545
+ async function rebuild(reason) {
546
+ if (rebuilding)
547
+ return;
548
+ rebuilding = true;
549
+ try {
550
+ bundle = await buildBundle(ctx.codexDir);
551
+ await saveMasterBundle(ctx, bundle);
552
+ if (!ctx.quiet)
553
+ process.stderr.write(`rebuilt ${bundle.revision} after ${reason}\n`);
554
+ }
555
+ finally {
556
+ rebuilding = false;
557
+ }
558
+ }
559
+ if (options.watch) {
560
+ const watchPaths = INCLUDE_ROOTS.map((item) => path.join(ctx.codexDir, item)).filter(existsSync);
561
+ const watcher = chokidar.watch(watchPaths, { ignoreInitial: true, awaitWriteFinish: { stabilityThreshold: 250, pollInterval: 100 } });
562
+ watcher.on("all", (_event, changedPath) => {
563
+ void rebuild(path.relative(ctx.codexDir, changedPath));
564
+ });
565
+ }
566
+ const server = createServer((req, res) => {
567
+ if (!req.url || req.method !== "GET") {
568
+ res.writeHead(405).end("method not allowed");
569
+ return;
570
+ }
571
+ if (req.url === "/meta") {
572
+ res.setHeader("content-type", "application/json");
573
+ res.end(JSON.stringify({ version: 1, fingerprint: identity.fingerprint, revision: bundle.revision, fileCount: bundle.files.length }));
574
+ return;
575
+ }
576
+ if (req.url === "/bundle") {
577
+ res.setHeader("content-type", "application/json");
578
+ res.end(JSON.stringify(bundle));
579
+ return;
580
+ }
581
+ res.writeHead(404).end("not found");
582
+ });
583
+ server.listen(options.port, options.host, () => {
584
+ if (!ctx.quiet)
585
+ process.stderr.write(`codexport master serving ${bundle.revision} on http://${options.host}:${options.port}\n`);
586
+ });
587
+ }
588
+ async function commandFollowerJoin(ctx, input, options) {
589
+ const parsed = input ? parseJoinLink(input) : undefined;
590
+ const master = options.master ?? parsed?.masterUrl;
591
+ const fingerprint = options.fingerprint ?? parsed?.fingerprint;
592
+ if (!master || !fingerprint) {
593
+ throw new CliError("follower join requires a join link or both --master and --fingerprint.", 2);
594
+ }
595
+ const meta = await fetchMeta(master, options.timeoutMs);
596
+ if (meta.fingerprint !== fingerprint) {
597
+ throw new CliError(`Master fingerprint mismatch. Expected ${fingerprint}, got ${meta.fingerprint}.`, 1);
598
+ }
599
+ const current = await readLocalConfig(ctx);
600
+ await writeLocalConfig(ctx, { ...current, role: "follower", masterUrl: master, masterFingerprint: fingerprint, codexDir: ctx.codexDir });
601
+ if (options.apply) {
602
+ await commandSync(ctx, { apply: true, timeoutMs: options.timeoutMs });
603
+ }
604
+ print(ctx, { role: "follower", masterUrl: master, masterFingerprint: fingerprint, revision: meta.revision });
605
+ }
606
+ async function commandSync(ctx, options) {
607
+ const local = await readLocalConfig(ctx);
608
+ if (!local.masterUrl || !local.masterFingerprint) {
609
+ throw new CliError("This machine is not enrolled. Run codexport follower join first.", 1);
610
+ }
611
+ const meta = await fetchMeta(local.masterUrl, options.timeoutMs);
612
+ if (meta.fingerprint !== local.masterFingerprint) {
613
+ throw new CliError("Stored master fingerprint does not match the reachable master. Refusing to sync; re-enroll or reset trust explicitly.", 1);
614
+ }
615
+ if (local.lastRevision === meta.revision) {
616
+ print(ctx, { status: "current", revision: meta.revision });
617
+ return;
618
+ }
619
+ const bundle = await fetchBundle(local.masterUrl, options.timeoutMs);
620
+ if (bundle.revision !== meta.revision)
621
+ throw new CliError("Master changed revision during sync. Retry.", 1);
622
+ await writeCachedBundle(ctx, bundle);
623
+ if (options.apply) {
624
+ await applyBundle(ctx, bundle);
625
+ print(ctx, { status: "applied", revision: bundle.revision, files: bundle.files.length });
626
+ }
627
+ else {
628
+ print(ctx, { status: "staged", revision: bundle.revision, files: bundle.files.length });
629
+ }
630
+ }
631
+ async function commandApply(ctx) {
632
+ const bundle = await readCachedBundle(ctx);
633
+ await applyBundle(ctx, bundle);
634
+ print(ctx, { status: "applied", revision: bundle.revision, files: bundle.files.length });
635
+ }
636
+ async function commandStatus(ctx, options) {
637
+ const local = await readLocalConfig(ctx);
638
+ let remote = null;
639
+ if (local.masterUrl) {
640
+ try {
641
+ remote = await fetchMeta(local.masterUrl, options.timeoutMs);
642
+ }
643
+ catch (error) {
644
+ remote = { reachable: false, error: asError(error).message };
645
+ }
646
+ }
647
+ print(ctx, {
648
+ role: local.role ?? "unknown",
649
+ codexDir: ctx.codexDir,
650
+ stateDir: ctx.stateDir,
651
+ masterUrl: local.masterUrl ?? null,
652
+ masterFingerprint: local.masterFingerprint ?? null,
653
+ lastRevision: local.lastRevision ?? null,
654
+ remote
655
+ });
656
+ }
657
+ function addGlobalOptions(command) {
658
+ return command
659
+ .option("--home <path>", "home directory override for testing")
660
+ .option("--codex-dir <path>", "Codex directory to read or write")
661
+ .option("--json", "write structured JSON output")
662
+ .option("-q, --quiet", "suppress normal success output")
663
+ .option("--no-input", "never prompt for input")
664
+ .addOption(new Option("--no-color", "accepted for CLI convention compatibility"));
665
+ }
666
+ function contextFromCommand(command) {
667
+ const options = command.optsWithGlobals();
668
+ return defaultContext({ home: options.home, codexDir: options.codexDir, quiet: options.quiet, json: options.json, noInput: options.input === false });
669
+ }
670
+ async function main(argv) {
671
+ const program = addGlobalOptions(new Command())
672
+ .name("codexport")
673
+ .description("Replicate a canonical Codex setup from a master machine to follower machines.")
674
+ .version(VERSION);
675
+ const master = program.command("master").description("Manage the canonical Machine1 export.");
676
+ master.command("init")
677
+ .description("Create or refresh master identity and canonical bundle state.")
678
+ .option("--port <port>", "default serve port", parsePositiveInt, DEFAULT_PORT)
679
+ .action(async (options, command) => commandMasterInit(contextFromCommand(command), options));
680
+ master.command("rebuild")
681
+ .description("Force rebuild the master bundle from the current Codex directory.")
682
+ .action(async (_options, command) => commandMasterRebuild(contextFromCommand(command)));
683
+ master.command("link")
684
+ .description("Print a durable follower join link and copy-paste command.")
685
+ .option("--host <host>", "Tailscale host, IP, or full URL", "machine1.tailnet.ts.net")
686
+ .option("--port <port>", "master port", parsePositiveInt, DEFAULT_PORT)
687
+ .action(async (options, command) => commandMasterLink(contextFromCommand(command), options));
688
+ master.command("serve")
689
+ .description("Serve the current canonical bundle over HTTP.")
690
+ .option("--host <host>", "bind host", "0.0.0.0")
691
+ .option("--port <port>", "bind port", parsePositiveInt, DEFAULT_PORT)
692
+ .option("--no-watch", "disable automatic bundle rebuilds")
693
+ .action(async (options, command) => commandMasterServe(contextFromCommand(command), options));
694
+ const service = master.command("service").description("Manage the user-level master background service.");
695
+ service.command("install")
696
+ .description("Install and start the user-level master background service.")
697
+ .option("--port <port>", "master port", parsePositiveInt, DEFAULT_PORT)
698
+ .option("-n, --dry-run", "print the install target without changing system service state")
699
+ .action(async (options, command) => {
700
+ const ctx = contextFromCommand(command);
701
+ const target = await installMasterService(ctx, options.port, Boolean(options.dryRun));
702
+ print(ctx, { installed: !options.dryRun, target });
703
+ });
704
+ const follower = program.command("follower").description("Enroll and manage a follower machine.");
705
+ follower.command("join [link]")
706
+ .description("Enroll this follower from a codexport://join link or explicit master URL.")
707
+ .option("--master <url>", "master URL, for example http://machine1.tailnet.ts.net:17342")
708
+ .option("--fingerprint <hex>", "expected master fingerprint")
709
+ .option("--apply", "download and apply immediately after enrollment")
710
+ .option("--timeout-ms <ms>", "network timeout", parsePositiveInt, DEFAULT_TIMEOUT_MS)
711
+ .action(async (link, options, command) => commandFollowerJoin(contextFromCommand(command), link, options));
712
+ program.command("sync")
713
+ .description("Fetch the latest master bundle and optionally apply it.")
714
+ .option("--apply", "apply the fetched bundle immediately")
715
+ .option("--timeout-ms <ms>", "network timeout", parsePositiveInt, DEFAULT_TIMEOUT_MS)
716
+ .action(async (options, command) => commandSync(contextFromCommand(command), options));
717
+ program.command("apply")
718
+ .description("Apply the last staged bundle.")
719
+ .action(async (_options, command) => commandApply(contextFromCommand(command)));
720
+ const hook = program.command("hook").description("Manage follower Codex hooks.");
721
+ hook.command("install")
722
+ .description("Install a follower-only Codex SessionStart sync hook.")
723
+ .option("--timeout-ms <ms>", "hook sync timeout", parsePositiveInt, 3_000)
724
+ .action(async (options, command) => {
725
+ const ctx = contextFromCommand(command);
726
+ await installHook(ctx, options.timeoutMs);
727
+ print(ctx, { installed: true, hook: "SessionStart", command: `codexport sync --apply --timeout-ms ${options.timeoutMs} --no-input` });
728
+ });
729
+ program.command("status")
730
+ .description("Show local enrollment state and remote revision reachability.")
731
+ .option("--timeout-ms <ms>", "network timeout", parsePositiveInt, 1_500)
732
+ .action(async (options, command) => commandStatus(contextFromCommand(command), options));
733
+ await program.parseAsync(argv);
734
+ }
735
+ if (import.meta.url === `file://${process.argv[1]}`) {
736
+ main(process.argv).catch((error) => {
737
+ const err = asError(error);
738
+ const exitCode = error instanceof CliError ? error.exitCode : 1;
739
+ process.stderr.write(`${err.message}\n`);
740
+ if (!(error instanceof CliError) && process.env.DEBUG) {
741
+ process.stderr.write(`${err.stack ?? ""}\n`);
742
+ }
743
+ process.exit(exitCode);
744
+ });
745
+ }
746
+ export { applyBundle, buildBundle, buildJoinLink, computeRevision, defaultContext, installHook, mergeTomlText, parseJoinLink, verifyBundle };