codexport 0.1.4 → 0.1.5

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 +174 -9
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -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.5";
15
15
  const DEFAULT_PORT = 17342;
16
16
  const DEFAULT_TIMEOUT_MS = 5_000;
17
17
  const CODEXPORT_DIR = ".codexport";
@@ -336,8 +336,8 @@ function extractTomlTableNames(text, prefix) {
336
336
  }
337
337
  return names;
338
338
  }
339
- function mergeTomlText(canonical, localMcpText, localConfig) {
340
- const expandedCanonical = expandPathVariables(canonical, localConfig);
339
+ function mergeTomlText(canonical, localMcpText, localConfig, sourceRoot) {
340
+ const expandedCanonical = expandPathVariables(rewritePortableConfig(canonical, sourceRoot), localConfig);
341
341
  if (!localMcpText?.trim())
342
342
  return expandedCanonical;
343
343
  const canonicalMcps = extractTomlTableNames(canonical, "mcp_servers");
@@ -349,10 +349,171 @@ function mergeTomlText(canonical, localMcpText, localConfig) {
349
349
  }
350
350
  return `${expandedCanonical.trimEnd()}\n\n# Follower-local MCP overlay from ~/.codexport/mcps.local.toml\n${localMcpText.trim()}\n`;
351
351
  }
352
+ function rewritePortableConfig(canonical, sourceRoot) {
353
+ let parsed;
354
+ try {
355
+ parsed = parseTomlObject(canonical, "canonical config.toml");
356
+ }
357
+ catch {
358
+ return canonical;
359
+ }
360
+ const sourceHome = inferHomeFromCodexDir(sourceRoot);
361
+ const mcpServers = parsed.mcp_servers;
362
+ if (!mcpServers || typeof mcpServers !== "object" || Array.isArray(mcpServers)) {
363
+ rewritePortableTableKeys(parsed, sourceRoot, sourceHome);
364
+ return stringifyToml(parsed);
365
+ }
366
+ rewritePortableTableKeys(parsed, sourceRoot, sourceHome);
367
+ for (const [name, rawServer] of Object.entries(mcpServers)) {
368
+ if (!rawServer || typeof rawServer !== "object" || Array.isArray(rawServer))
369
+ continue;
370
+ const server = rawServer;
371
+ rewritePortableMcpServer(name, server, sourceRoot, sourceHome);
372
+ }
373
+ return stringifyToml(parsed);
374
+ }
375
+ function rewritePortableTableKeys(table, sourceRoot, sourceHome) {
376
+ const rewritten = {};
377
+ for (const [key, value] of Object.entries(table)) {
378
+ if (value && typeof value === "object" && !Array.isArray(value)) {
379
+ rewritePortableTableKeys(value, sourceRoot, sourceHome);
380
+ }
381
+ rewritten[rewritePortablePath(key, sourceRoot, sourceHome)] = value;
382
+ }
383
+ for (const key of Object.keys(table)) {
384
+ delete table[key];
385
+ }
386
+ for (const [key, value] of Object.entries(rewritten)) {
387
+ table[key] = value;
388
+ }
389
+ }
390
+ function rewritePortableMcpServer(_name, server, sourceRoot, sourceHome) {
391
+ if (typeof server.url === "string")
392
+ return;
393
+ if (typeof server.command === "string" && Array.isArray(server.args)) {
394
+ const nodePackage = nodePackageFromServer(server.command, server.args);
395
+ if (nodePackage) {
396
+ server.command = "npx";
397
+ server.args = ["-y", nodePackage.packageName, ...nodePackage.remainingArgs.map((arg) => rewritePortablePath(arg, sourceRoot, sourceHome))];
398
+ return;
399
+ }
400
+ }
401
+ if (typeof server.command === "string") {
402
+ server.command = rewritePortableCommand(server.command, sourceRoot);
403
+ }
404
+ if (Array.isArray(server.args)) {
405
+ server.args = server.args.map((arg) => typeof arg === "string" ? rewritePortablePath(arg, sourceRoot, sourceHome) : arg);
406
+ }
407
+ if (server.env && typeof server.env === "object" && !Array.isArray(server.env)) {
408
+ for (const [key, value] of Object.entries(server.env)) {
409
+ if (typeof value === "string") {
410
+ server.env[key] = rewritePortablePath(value, sourceRoot, sourceHome);
411
+ }
412
+ }
413
+ }
414
+ }
415
+ function rewritePortableCommand(command, sourceRoot) {
416
+ const sourceRelative = rewriteSourceRootPath(command, sourceRoot);
417
+ if (sourceRelative !== command)
418
+ return sourceRelative;
419
+ if (!isAbsoluteAnyPlatform(command))
420
+ return command;
421
+ return basenameAnyPlatform(command);
422
+ }
423
+ function rewritePortablePath(value, sourceRoot, sourceHome) {
424
+ const sourceRootPath = rewriteSourceRootPath(value, sourceRoot);
425
+ if (sourceRootPath !== value)
426
+ return sourceRootPath;
427
+ return rewriteSourceHomePath(value, sourceHome);
428
+ }
429
+ function nodePackageFromServer(command, args) {
430
+ if (basenameAnyPlatform(command) !== "node")
431
+ return undefined;
432
+ const [entrypoint, ...remainingArgs] = args;
433
+ if (typeof entrypoint !== "string" || !isAbsoluteAnyPlatform(entrypoint))
434
+ return undefined;
435
+ if (!remainingArgs.every((arg) => typeof arg === "string"))
436
+ return undefined;
437
+ const packageName = packageNameFromNodeModulesPath(entrypoint);
438
+ if (!packageName)
439
+ return undefined;
440
+ return { packageName, remainingArgs: remainingArgs };
441
+ }
442
+ function packageNameFromNodeModulesPath(value) {
443
+ const parts = normalizePathForCompare(value).split("/");
444
+ const nodeModulesIndex = parts.lastIndexOf("node_modules");
445
+ if (nodeModulesIndex === -1)
446
+ return undefined;
447
+ const first = parts[nodeModulesIndex + 1];
448
+ if (!first)
449
+ return undefined;
450
+ if (first.startsWith("@")) {
451
+ const second = parts[nodeModulesIndex + 2];
452
+ return second ? `${first}/${second}` : undefined;
453
+ }
454
+ return first;
455
+ }
456
+ function inferHomeFromCodexDir(sourceRoot) {
457
+ if (!sourceRoot || basenameAnyPlatform(sourceRoot) !== ".codex")
458
+ return undefined;
459
+ const normalized = normalizePathForCompare(sourceRoot);
460
+ return normalized.slice(0, -"/.codex".length);
461
+ }
462
+ function rewriteSourceRootPath(value, sourceRoot) {
463
+ if (!sourceRoot || !isAbsoluteAnyPlatform(value))
464
+ return value;
465
+ const normalizedSourceRoot = normalizePathForCompare(sourceRoot);
466
+ const normalizedValue = normalizePathForCompare(value);
467
+ if (normalizedValue === normalizedSourceRoot)
468
+ return "${codexDir}";
469
+ if (!normalizedValue.startsWith(`${normalizedSourceRoot}/`))
470
+ return value;
471
+ const relative = normalizedValue.slice(normalizedSourceRoot.length + 1);
472
+ return `\${codexDir}/${relative}`;
473
+ }
474
+ function rewriteSourceHomePath(value, sourceHome) {
475
+ if (!sourceHome || !isAbsoluteAnyPlatform(value))
476
+ return value;
477
+ const normalizedSourceHome = normalizePathForCompare(sourceHome);
478
+ const normalizedValue = normalizePathForCompare(value);
479
+ if (normalizedValue === normalizedSourceHome)
480
+ return "${home}";
481
+ if (!normalizedValue.startsWith(`${normalizedSourceHome}/`))
482
+ return value;
483
+ const relative = normalizedValue.slice(normalizedSourceHome.length + 1);
484
+ return `\${home}/${relative}`;
485
+ }
486
+ function isAbsoluteAnyPlatform(value) {
487
+ return path.posix.isAbsolute(value) || path.win32.isAbsolute(value);
488
+ }
489
+ function basenameAnyPlatform(value) {
490
+ return value.includes("\\") ? path.win32.basename(value) : path.posix.basename(value);
491
+ }
492
+ function dirnameAnyPlatform(value) {
493
+ return value.includes("\\") || /^[A-Za-z]:\//.test(value) ? path.win32.dirname(value) : path.posix.dirname(value);
494
+ }
495
+ function normalizePathForCompare(value) {
496
+ return value.replace(/\\/g, "/").replace(/\/+$/g, "");
497
+ }
498
+ async function bundleForCurrentRevision(ctx, local, meta, timeoutMs) {
499
+ const cached = await readJsonIfExists(path.join(ctx.stateDir, CACHE_BUNDLE_FILE));
500
+ if (cached?.revision === meta.revision) {
501
+ verifyBundle(cached);
502
+ return cached;
503
+ }
504
+ if (!local.masterUrl)
505
+ throw new CliError("This machine is not enrolled. Run codexport follower join first.", 1);
506
+ const bundle = await fetchBundle(local.masterUrl, timeoutMs);
507
+ if (bundle.revision !== meta.revision)
508
+ throw new CliError("Master changed revision during sync. Retry.", 1);
509
+ await writeCachedBundle(ctx, bundle);
510
+ return bundle;
511
+ }
352
512
  function expandPathVariables(text, localConfig) {
513
+ const configuredCodexDir = localConfig.codexDir ?? path.join(homedir(), ".codex");
353
514
  const variables = {
354
- home: homedir(),
355
- codexDir: localConfig.codexDir ?? path.join(homedir(), ".codex"),
515
+ home: basenameAnyPlatform(configuredCodexDir) === ".codex" ? dirnameAnyPlatform(configuredCodexDir) : homedir(),
516
+ codexDir: configuredCodexDir,
356
517
  ...(localConfig.pathVariables ?? {})
357
518
  };
358
519
  return text.replace(/\$\{([A-Za-z0-9_]+)\}/g, (match, name) => {
@@ -401,7 +562,7 @@ async function applyBundle(ctx, bundle) {
401
562
  if (configEntry) {
402
563
  const canonicalConfig = decodeFile(configEntry).toString("utf8");
403
564
  const localMcpText = await readTextIfExists(path.join(ctx.stateDir, MCPS_LOCAL_FILE));
404
- const generated = mergeTomlText(canonicalConfig, localMcpText, localConfig);
565
+ const generated = mergeTomlText(canonicalConfig, localMcpText, { ...localConfig, codexDir: ctx.codexDir }, bundle.sourceRoot);
405
566
  const configPath = path.join(ctx.codexDir, "config.toml");
406
567
  if (await pathExists(configPath)) {
407
568
  const backupPath = `${configPath}.codexport-backup-${new Date().toISOString().replace(/[:.]/g, "-")}`;
@@ -618,12 +779,16 @@ async function commandSync(ctx, options) {
618
779
  throw new CliError("Stored master fingerprint does not match the reachable master. Refusing to sync; re-enroll or reset trust explicitly.", 1);
619
780
  }
620
781
  if (local.lastRevision === meta.revision) {
782
+ if (options.apply) {
783
+ const bundle = await bundleForCurrentRevision(ctx, local, meta, options.timeoutMs);
784
+ await applyBundle(ctx, bundle);
785
+ print(ctx, { status: "applied", revision: bundle.revision, files: bundle.files.length });
786
+ return;
787
+ }
621
788
  print(ctx, { status: "current", revision: meta.revision });
622
789
  return;
623
790
  }
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);
791
+ const bundle = await bundleForCurrentRevision(ctx, local, meta, options.timeoutMs);
627
792
  await writeCachedBundle(ctx, bundle);
628
793
  if (options.apply) {
629
794
  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.5",
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",