codexport 0.1.4 → 0.1.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.
Files changed (2) hide show
  1. package/dist/index.js +203 -13
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import { Command, Option } from "commander";
3
3
  import chokidar from "chokidar";
4
4
  import { createHash, randomBytes } from "node:crypto";
5
5
  import { createServer, request } from "node:http";
6
- import { lstat, mkdir, readFile, readdir, readlink, rename, rm, stat, symlink, writeFile } from "node:fs/promises";
6
+ import { chmod, lstat, mkdir, readFile, readdir, readlink, rename, rm, stat, symlink, writeFile } from "node:fs/promises";
7
7
  import { existsSync } from "node:fs";
8
8
  import { realpathSync } from "node:fs";
9
9
  import { fileURLToPath } from "node:url";
@@ -11,7 +11,7 @@ import { homedir, platform } from "node:os";
11
11
  import path from "node:path";
12
12
  import { spawn } from "node:child_process";
13
13
  import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
14
- const VERSION = "0.1.4";
14
+ const VERSION = "0.1.6";
15
15
  const DEFAULT_PORT = 17342;
16
16
  const DEFAULT_TIMEOUT_MS = 5_000;
17
17
  const CODEXPORT_DIR = ".codexport";
@@ -114,6 +114,31 @@ async function writeJsonAtomic(filePath, value) {
114
114
  await writeFile(tmpPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
115
115
  await rename(tmpPath, filePath);
116
116
  }
117
+ async function writeFileReplacingExisting(filePath, content, options) {
118
+ try {
119
+ await writeFile(filePath, content, options);
120
+ return;
121
+ }
122
+ catch (error) {
123
+ if (!isPermissionError(error))
124
+ throw error;
125
+ }
126
+ if (await pathExists(filePath)) {
127
+ try {
128
+ await chmod(filePath, 0o666);
129
+ }
130
+ catch (error) {
131
+ if (!isPermissionError(error))
132
+ throw error;
133
+ }
134
+ await rm(filePath, { force: true });
135
+ }
136
+ await writeFile(filePath, content, options);
137
+ }
138
+ function isPermissionError(error) {
139
+ const code = error.code;
140
+ return code === "EACCES" || code === "EPERM";
141
+ }
117
142
  function parseTomlObject(text, filePath) {
118
143
  try {
119
144
  const parsed = parseToml(text);
@@ -336,8 +361,8 @@ function extractTomlTableNames(text, prefix) {
336
361
  }
337
362
  return names;
338
363
  }
339
- function mergeTomlText(canonical, localMcpText, localConfig) {
340
- const expandedCanonical = expandPathVariables(canonical, localConfig);
364
+ function mergeTomlText(canonical, localMcpText, localConfig, sourceRoot) {
365
+ const expandedCanonical = expandPathVariables(rewritePortableConfig(canonical, sourceRoot), localConfig);
341
366
  if (!localMcpText?.trim())
342
367
  return expandedCanonical;
343
368
  const canonicalMcps = extractTomlTableNames(canonical, "mcp_servers");
@@ -349,10 +374,171 @@ function mergeTomlText(canonical, localMcpText, localConfig) {
349
374
  }
350
375
  return `${expandedCanonical.trimEnd()}\n\n# Follower-local MCP overlay from ~/.codexport/mcps.local.toml\n${localMcpText.trim()}\n`;
351
376
  }
377
+ function rewritePortableConfig(canonical, sourceRoot) {
378
+ let parsed;
379
+ try {
380
+ parsed = parseTomlObject(canonical, "canonical config.toml");
381
+ }
382
+ catch {
383
+ return canonical;
384
+ }
385
+ const sourceHome = inferHomeFromCodexDir(sourceRoot);
386
+ const mcpServers = parsed.mcp_servers;
387
+ if (!mcpServers || typeof mcpServers !== "object" || Array.isArray(mcpServers)) {
388
+ rewritePortableTableKeys(parsed, sourceRoot, sourceHome);
389
+ return stringifyToml(parsed);
390
+ }
391
+ rewritePortableTableKeys(parsed, sourceRoot, sourceHome);
392
+ for (const [name, rawServer] of Object.entries(mcpServers)) {
393
+ if (!rawServer || typeof rawServer !== "object" || Array.isArray(rawServer))
394
+ continue;
395
+ const server = rawServer;
396
+ rewritePortableMcpServer(name, server, sourceRoot, sourceHome);
397
+ }
398
+ return stringifyToml(parsed);
399
+ }
400
+ function rewritePortableTableKeys(table, sourceRoot, sourceHome) {
401
+ const rewritten = {};
402
+ for (const [key, value] of Object.entries(table)) {
403
+ if (value && typeof value === "object" && !Array.isArray(value)) {
404
+ rewritePortableTableKeys(value, sourceRoot, sourceHome);
405
+ }
406
+ rewritten[rewritePortablePath(key, sourceRoot, sourceHome)] = value;
407
+ }
408
+ for (const key of Object.keys(table)) {
409
+ delete table[key];
410
+ }
411
+ for (const [key, value] of Object.entries(rewritten)) {
412
+ table[key] = value;
413
+ }
414
+ }
415
+ function rewritePortableMcpServer(_name, server, sourceRoot, sourceHome) {
416
+ if (typeof server.url === "string")
417
+ return;
418
+ if (typeof server.command === "string" && Array.isArray(server.args)) {
419
+ const nodePackage = nodePackageFromServer(server.command, server.args);
420
+ if (nodePackage) {
421
+ server.command = "npx";
422
+ server.args = ["-y", nodePackage.packageName, ...nodePackage.remainingArgs.map((arg) => rewritePortablePath(arg, sourceRoot, sourceHome))];
423
+ return;
424
+ }
425
+ }
426
+ if (typeof server.command === "string") {
427
+ server.command = rewritePortableCommand(server.command, sourceRoot);
428
+ }
429
+ if (Array.isArray(server.args)) {
430
+ server.args = server.args.map((arg) => typeof arg === "string" ? rewritePortablePath(arg, sourceRoot, sourceHome) : arg);
431
+ }
432
+ if (server.env && typeof server.env === "object" && !Array.isArray(server.env)) {
433
+ for (const [key, value] of Object.entries(server.env)) {
434
+ if (typeof value === "string") {
435
+ server.env[key] = rewritePortablePath(value, sourceRoot, sourceHome);
436
+ }
437
+ }
438
+ }
439
+ }
440
+ function rewritePortableCommand(command, sourceRoot) {
441
+ const sourceRelative = rewriteSourceRootPath(command, sourceRoot);
442
+ if (sourceRelative !== command)
443
+ return sourceRelative;
444
+ if (!isAbsoluteAnyPlatform(command))
445
+ return command;
446
+ return basenameAnyPlatform(command);
447
+ }
448
+ function rewritePortablePath(value, sourceRoot, sourceHome) {
449
+ const sourceRootPath = rewriteSourceRootPath(value, sourceRoot);
450
+ if (sourceRootPath !== value)
451
+ return sourceRootPath;
452
+ return rewriteSourceHomePath(value, sourceHome);
453
+ }
454
+ function nodePackageFromServer(command, args) {
455
+ if (basenameAnyPlatform(command) !== "node")
456
+ return undefined;
457
+ const [entrypoint, ...remainingArgs] = args;
458
+ if (typeof entrypoint !== "string" || !isAbsoluteAnyPlatform(entrypoint))
459
+ return undefined;
460
+ if (!remainingArgs.every((arg) => typeof arg === "string"))
461
+ return undefined;
462
+ const packageName = packageNameFromNodeModulesPath(entrypoint);
463
+ if (!packageName)
464
+ return undefined;
465
+ return { packageName, remainingArgs: remainingArgs };
466
+ }
467
+ function packageNameFromNodeModulesPath(value) {
468
+ const parts = normalizePathForCompare(value).split("/");
469
+ const nodeModulesIndex = parts.lastIndexOf("node_modules");
470
+ if (nodeModulesIndex === -1)
471
+ return undefined;
472
+ const first = parts[nodeModulesIndex + 1];
473
+ if (!first)
474
+ return undefined;
475
+ if (first.startsWith("@")) {
476
+ const second = parts[nodeModulesIndex + 2];
477
+ return second ? `${first}/${second}` : undefined;
478
+ }
479
+ return first;
480
+ }
481
+ function inferHomeFromCodexDir(sourceRoot) {
482
+ if (!sourceRoot || basenameAnyPlatform(sourceRoot) !== ".codex")
483
+ return undefined;
484
+ const normalized = normalizePathForCompare(sourceRoot);
485
+ return normalized.slice(0, -"/.codex".length);
486
+ }
487
+ function rewriteSourceRootPath(value, sourceRoot) {
488
+ if (!sourceRoot || !isAbsoluteAnyPlatform(value))
489
+ return value;
490
+ const normalizedSourceRoot = normalizePathForCompare(sourceRoot);
491
+ const normalizedValue = normalizePathForCompare(value);
492
+ if (normalizedValue === normalizedSourceRoot)
493
+ return "${codexDir}";
494
+ if (!normalizedValue.startsWith(`${normalizedSourceRoot}/`))
495
+ return value;
496
+ const relative = normalizedValue.slice(normalizedSourceRoot.length + 1);
497
+ return `\${codexDir}/${relative}`;
498
+ }
499
+ function rewriteSourceHomePath(value, sourceHome) {
500
+ if (!sourceHome || !isAbsoluteAnyPlatform(value))
501
+ return value;
502
+ const normalizedSourceHome = normalizePathForCompare(sourceHome);
503
+ const normalizedValue = normalizePathForCompare(value);
504
+ if (normalizedValue === normalizedSourceHome)
505
+ return "${home}";
506
+ if (!normalizedValue.startsWith(`${normalizedSourceHome}/`))
507
+ return value;
508
+ const relative = normalizedValue.slice(normalizedSourceHome.length + 1);
509
+ return `\${home}/${relative}`;
510
+ }
511
+ function isAbsoluteAnyPlatform(value) {
512
+ return path.posix.isAbsolute(value) || path.win32.isAbsolute(value);
513
+ }
514
+ function basenameAnyPlatform(value) {
515
+ return value.includes("\\") ? path.win32.basename(value) : path.posix.basename(value);
516
+ }
517
+ function dirnameAnyPlatform(value) {
518
+ return value.includes("\\") || /^[A-Za-z]:\//.test(value) ? path.win32.dirname(value) : path.posix.dirname(value);
519
+ }
520
+ function normalizePathForCompare(value) {
521
+ return value.replace(/\\/g, "/").replace(/\/+$/g, "");
522
+ }
523
+ async function bundleForCurrentRevision(ctx, local, meta, timeoutMs) {
524
+ const cached = await readJsonIfExists(path.join(ctx.stateDir, CACHE_BUNDLE_FILE));
525
+ if (cached?.revision === meta.revision) {
526
+ verifyBundle(cached);
527
+ return cached;
528
+ }
529
+ if (!local.masterUrl)
530
+ throw new CliError("This machine is not enrolled. Run codexport follower join first.", 1);
531
+ const bundle = await fetchBundle(local.masterUrl, timeoutMs);
532
+ if (bundle.revision !== meta.revision)
533
+ throw new CliError("Master changed revision during sync. Retry.", 1);
534
+ await writeCachedBundle(ctx, bundle);
535
+ return bundle;
536
+ }
352
537
  function expandPathVariables(text, localConfig) {
538
+ const configuredCodexDir = localConfig.codexDir ?? path.join(homedir(), ".codex");
353
539
  const variables = {
354
- home: homedir(),
355
- codexDir: localConfig.codexDir ?? path.join(homedir(), ".codex"),
540
+ home: basenameAnyPlatform(configuredCodexDir) === ".codex" ? dirnameAnyPlatform(configuredCodexDir) : homedir(),
541
+ codexDir: configuredCodexDir,
356
542
  ...(localConfig.pathVariables ?? {})
357
543
  };
358
544
  return text.replace(/\$\{([A-Za-z0-9_]+)\}/g, (match, name) => {
@@ -395,19 +581,19 @@ async function applyBundle(ctx, bundle) {
395
581
  await ensureDir(path.dirname(target));
396
582
  if (file.path === "config.toml")
397
583
  continue;
398
- await writeFile(target, decodeFile(file), { mode: file.mode });
584
+ await writeFileReplacingExisting(target, decodeFile(file), { mode: file.mode });
399
585
  }
400
586
  const configEntry = bundle.files.find((file) => file.path === "config.toml");
401
587
  if (configEntry) {
402
588
  const canonicalConfig = decodeFile(configEntry).toString("utf8");
403
589
  const localMcpText = await readTextIfExists(path.join(ctx.stateDir, MCPS_LOCAL_FILE));
404
- const generated = mergeTomlText(canonicalConfig, localMcpText, localConfig);
590
+ const generated = mergeTomlText(canonicalConfig, localMcpText, { ...localConfig, codexDir: ctx.codexDir }, bundle.sourceRoot);
405
591
  const configPath = path.join(ctx.codexDir, "config.toml");
406
592
  if (await pathExists(configPath)) {
407
593
  const backupPath = `${configPath}.codexport-backup-${new Date().toISOString().replace(/[:.]/g, "-")}`;
408
594
  await writeFile(backupPath, await readFile(configPath));
409
595
  }
410
- await writeFile(configPath, generated, "utf8");
596
+ await writeFileReplacingExisting(configPath, generated, "utf8");
411
597
  }
412
598
  const localSkillsDir = path.join(ctx.stateDir, "skills");
413
599
  if (await pathExists(localSkillsDir)) {
@@ -428,7 +614,7 @@ async function copyDirectory(source, target) {
428
614
  }
429
615
  else if (entry.isFile()) {
430
616
  await ensureDir(path.dirname(targetPath));
431
- await writeFile(targetPath, await readFile(sourcePath));
617
+ await writeFileReplacingExisting(targetPath, await readFile(sourcePath));
432
618
  }
433
619
  else if (entry.isSymbolicLink()) {
434
620
  const linkTarget = await readlink(sourcePath);
@@ -618,12 +804,16 @@ async function commandSync(ctx, options) {
618
804
  throw new CliError("Stored master fingerprint does not match the reachable master. Refusing to sync; re-enroll or reset trust explicitly.", 1);
619
805
  }
620
806
  if (local.lastRevision === meta.revision) {
807
+ if (options.apply) {
808
+ const bundle = await bundleForCurrentRevision(ctx, local, meta, options.timeoutMs);
809
+ await applyBundle(ctx, bundle);
810
+ print(ctx, { status: "applied", revision: bundle.revision, files: bundle.files.length });
811
+ return;
812
+ }
621
813
  print(ctx, { status: "current", revision: meta.revision });
622
814
  return;
623
815
  }
624
- const bundle = await fetchBundle(local.masterUrl, options.timeoutMs);
625
- if (bundle.revision !== meta.revision)
626
- throw new CliError("Master changed revision during sync. Retry.", 1);
816
+ const bundle = await bundleForCurrentRevision(ctx, local, meta, options.timeoutMs);
627
817
  await writeCachedBundle(ctx, bundle);
628
818
  if (options.apply) {
629
819
  await applyBundle(ctx, bundle);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codexport",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "sync a canonical Codex setup from one master machine to follower machines",
5
5
  "author": "Microck <contact@micr.dev>",
6
6
  "license": "MIT",
@@ -24,7 +24,7 @@
24
24
  "automation"
25
25
  ],
26
26
  "bin": {
27
- "codexport": "./dist/index.js"
27
+ "codexport": "dist/index.js"
28
28
  },
29
29
  "files": [
30
30
  "dist",