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.
- package/dist/index.js +203 -13
- 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.
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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": "
|
|
27
|
+
"codexport": "dist/index.js"
|
|
28
28
|
},
|
|
29
29
|
"files": [
|
|
30
30
|
"dist",
|