oh-my-codex 0.21.31 → 0.21.32
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/Cargo.lock +6 -6
- package/Cargo.toml +1 -1
- package/dist/cli/__tests__/setup-skill-validation.test.js +7 -0
- package/dist/cli/__tests__/setup-skill-validation.test.js.map +1 -1
- package/dist/cli/__tests__/setup-skills-overwrite.test.js +55 -0
- package/dist/cli/__tests__/setup-skills-overwrite.test.js.map +1 -1
- package/dist/cli/__tests__/setup-symlink-destinations.test.js +207 -5
- package/dist/cli/__tests__/setup-symlink-destinations.test.js.map +1 -1
- package/dist/cli/setup.d.ts +6 -0
- package/dist/cli/setup.d.ts.map +1 -1
- package/dist/cli/setup.js +328 -103
- package/dist/cli/setup.js.map +1 -1
- package/package.json +1 -1
- package/plugins/oh-my-codex/.codex-plugin/plugin.json +1 -1
package/dist/cli/setup.js
CHANGED
|
@@ -207,9 +207,14 @@ function getBackupContext(scope, projectRoot) {
|
|
|
207
207
|
baseRoot: projectRoot,
|
|
208
208
|
};
|
|
209
209
|
}
|
|
210
|
+
// For user scope, keep backups under ~/.omx/backups (not under CODEX_HOME).
|
|
211
|
+
// The baseRoot is used for validation scope determination per destination:
|
|
212
|
+
// - Destinations under CODEX_HOME are validated against CODEX_HOME
|
|
213
|
+
// - Destinations under ~/.omx are validated against homedir() (or OMX_ROOT if set)
|
|
214
|
+
const homeDir = homedir();
|
|
210
215
|
return {
|
|
211
|
-
backupRoot: join(
|
|
212
|
-
baseRoot:
|
|
216
|
+
backupRoot: join(homeDir, ".omx", "backups", "setup", timestamp),
|
|
217
|
+
baseRoot: homeDir,
|
|
213
218
|
};
|
|
214
219
|
}
|
|
215
220
|
function logManagedCodexHooksPlanDiagnostics(diagnostics, options) {
|
|
@@ -1100,12 +1105,39 @@ async function cleanupNativeHookTransactionStagedDeletions(applied, ancestorPrec
|
|
|
1100
1105
|
}
|
|
1101
1106
|
}
|
|
1102
1107
|
}
|
|
1108
|
+
/**
|
|
1109
|
+
* Sanitize an absolute path to be a valid relative path component for backups.
|
|
1110
|
+
* Handles Windows paths by removing drive letters, colons, and backslashes.
|
|
1111
|
+
* On Windows: C:\custom-codex\config.toml -> custom-codex_config.toml
|
|
1112
|
+
* On Unix: /custom-codex/config.toml -> custom-codex_config.toml
|
|
1113
|
+
*/
|
|
1114
|
+
function sanitizePathForBackup(absPath) {
|
|
1115
|
+
// Remove the Windows drive letter (e.g., "C:"), then leading slashes and backslashes
|
|
1116
|
+
let sanitized = absPath.replace(/^[A-Za-z]:/, "").replace(/^[\\/]+/, "");
|
|
1117
|
+
// Remove any remaining backslashes and colons, replacing with underscores
|
|
1118
|
+
sanitized = sanitized.replace(/[\\/\\:]/g, "_");
|
|
1119
|
+
// Collapse multiple underscores
|
|
1120
|
+
sanitized = sanitized.replace(/_+/g, "_");
|
|
1121
|
+
return sanitized;
|
|
1122
|
+
}
|
|
1123
|
+
/**
|
|
1124
|
+
* Relative location of `targetPath` inside a backup root. Falls back to a sanitized
|
|
1125
|
+
* absolute path when the target is outside `baseRoot`, including Windows cross-drive
|
|
1126
|
+
* targets where `relative()` returns an absolute path instead of a `..` path.
|
|
1127
|
+
*/
|
|
1128
|
+
export function backupRelativePath(baseRoot, targetPath, pathApi = { relative, isAbsolute }) {
|
|
1129
|
+
const relativePath = pathApi.relative(baseRoot, targetPath);
|
|
1130
|
+
if (relativePath === "" ||
|
|
1131
|
+
relativePath === ".." ||
|
|
1132
|
+
relativePath.startsWith("../") ||
|
|
1133
|
+
relativePath.startsWith("..\\") ||
|
|
1134
|
+
pathApi.isAbsolute(relativePath)) {
|
|
1135
|
+
return sanitizePathForBackup(targetPath);
|
|
1136
|
+
}
|
|
1137
|
+
return relativePath;
|
|
1138
|
+
}
|
|
1103
1139
|
function nativeHookTransactionBackupPath(artifactPath, backupContext) {
|
|
1104
|
-
|
|
1105
|
-
const safeRelativePath = relativePath.startsWith("..") || relativePath === ""
|
|
1106
|
-
? artifactPath.replace(/^[/]+/, "")
|
|
1107
|
-
: relativePath;
|
|
1108
|
-
return join(backupContext.backupRoot, safeRelativePath);
|
|
1140
|
+
return join(backupContext.backupRoot, backupRelativePath(backupContext.baseRoot, artifactPath));
|
|
1109
1141
|
}
|
|
1110
1142
|
async function ensureSnapshotBackup(artifact, backupContext, options, tracker) {
|
|
1111
1143
|
const bytes = artifact.before.bytes;
|
|
@@ -1241,15 +1273,113 @@ async function commitNativeHookTransaction(artifacts, preconditions, ancestorPre
|
|
|
1241
1273
|
throw new Error(`Native hook transaction failed and was rolled back: ${message}`);
|
|
1242
1274
|
}
|
|
1243
1275
|
}
|
|
1276
|
+
/**
|
|
1277
|
+
* Canonicalize a path to its real form using the deepest-existing-ancestor pattern.
|
|
1278
|
+
* Uses fs.realpath (follows symlinks) on the deepest existing component, then
|
|
1279
|
+
* re-appends any non-existent tail. This ensures consistent handling of OS-level
|
|
1280
|
+
* symlinks in parent directories (e.g., /tmp -> /private/tmp on macOS).
|
|
1281
|
+
* #4352912480: Handles MISSING scope roots by canonicalizing through deepest existing ancestor.
|
|
1282
|
+
*/
|
|
1283
|
+
async function canonicalizePathToRealForm(absolutePath) {
|
|
1284
|
+
let realPath = absolutePath;
|
|
1285
|
+
let currentPath = absolutePath;
|
|
1286
|
+
while (currentPath && currentPath !== dirname(currentPath)) {
|
|
1287
|
+
try {
|
|
1288
|
+
// Check with lstat first to detect dangling symlinks before realpath (#4353639282)
|
|
1289
|
+
// If this component is a symlink (even dangling), try its parent instead of following it
|
|
1290
|
+
try {
|
|
1291
|
+
const stats = await lstat(currentPath);
|
|
1292
|
+
if (stats.isSymbolicLink()) {
|
|
1293
|
+
// This is a symlink; don't follow it, try the parent instead
|
|
1294
|
+
currentPath = dirname(currentPath);
|
|
1295
|
+
continue;
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
catch (lstatError) {
|
|
1299
|
+
// lstat failed; this might be ENOENT, continue to realpath attempt
|
|
1300
|
+
}
|
|
1301
|
+
// Try to realpath the current component (follows all symlinks)
|
|
1302
|
+
realPath = await realpath(currentPath);
|
|
1303
|
+
// Successfully realpathhed; now append the remaining tail
|
|
1304
|
+
const tail = absolutePath.slice(currentPath.length);
|
|
1305
|
+
realPath = tail ? join(realPath, tail) : realPath;
|
|
1306
|
+
break;
|
|
1307
|
+
}
|
|
1308
|
+
catch (error) {
|
|
1309
|
+
if (isMissingPathError(error)) {
|
|
1310
|
+
// This component doesn't exist; try its parent
|
|
1311
|
+
currentPath = dirname(currentPath);
|
|
1312
|
+
if (currentPath === dirname(currentPath)) {
|
|
1313
|
+
// Reached the filesystem root; use absolute path as-is
|
|
1314
|
+
realPath = absolutePath;
|
|
1315
|
+
break;
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
else {
|
|
1319
|
+
throw error;
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
return realPath;
|
|
1324
|
+
}
|
|
1325
|
+
/**
|
|
1326
|
+
* Try to find an alternative symlinked path that resolves to the canonical path.
|
|
1327
|
+
* Used for error messages to show symlinked paths when possible.
|
|
1328
|
+
*/
|
|
1329
|
+
async function findSymlinkedAlternativePath(canonicalPath) {
|
|
1330
|
+
try {
|
|
1331
|
+
const parts = canonicalPath.split(sep).filter(Boolean);
|
|
1332
|
+
if (parts.length < 2)
|
|
1333
|
+
return null;
|
|
1334
|
+
// Walk up the canonical path, checking each ancestor for symlinks
|
|
1335
|
+
for (let i = 1; i < parts.length; i++) {
|
|
1336
|
+
const canonicalPrefix = sep + parts.slice(0, i).join(sep);
|
|
1337
|
+
const suffix = parts.slice(i).join(sep);
|
|
1338
|
+
try {
|
|
1339
|
+
const parentDir = dirname(canonicalPrefix);
|
|
1340
|
+
const baseName = basename(canonicalPrefix);
|
|
1341
|
+
// Check if there's a symlink with the same name at the same level
|
|
1342
|
+
try {
|
|
1343
|
+
const symlinkPath = join(parentDir, baseName);
|
|
1344
|
+
const stat = await lstat(symlinkPath);
|
|
1345
|
+
if (stat.isSymbolicLink()) {
|
|
1346
|
+
// Verify it points to the right place
|
|
1347
|
+
const target = await canonicalizePathToRealForm(symlinkPath);
|
|
1348
|
+
if (target === canonicalPrefix) {
|
|
1349
|
+
return join(symlinkPath, suffix);
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
catch {
|
|
1354
|
+
// Continue if this check fails
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
catch {
|
|
1358
|
+
// Continue searching
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
catch {
|
|
1363
|
+
// If anything goes wrong, return null and use the canonical path
|
|
1364
|
+
}
|
|
1365
|
+
return null;
|
|
1366
|
+
}
|
|
1244
1367
|
/**
|
|
1245
1368
|
* Validates that a destination path is safe for managed setup operations (install, read, compare).
|
|
1246
1369
|
* Refuses if the destination is a symlink or lies outside the specified scope root,
|
|
1247
1370
|
* or if any ancestor component within the scope is a symlink.
|
|
1248
1371
|
*
|
|
1249
1372
|
* This guard prevents symlink attacks that would redirect reads, backups, and writes outside
|
|
1250
|
-
* the managed tree.
|
|
1251
|
-
*
|
|
1252
|
-
*
|
|
1373
|
+
* the managed tree. Uses a single algorithm that:
|
|
1374
|
+
* 1. Canonicalizes the scope root via deepest-existing-ancestor realpath (follows ALL symlinks)
|
|
1375
|
+
* 2. Walks prefixes of the LEXICAL absolute destination from shortest to longest
|
|
1376
|
+
* 3. Finds the FIRST prefix P whose deepest-existing-ancestor canonical form equals canonicalScope
|
|
1377
|
+
* 4. For each lexical component after P down to destination, lstat it:
|
|
1378
|
+
* - symlink (including dangling) → throw error
|
|
1379
|
+
* - ENOENT → stop (rest is missing, fine)
|
|
1380
|
+
*
|
|
1381
|
+
* This handles cases like /tmp -> /private/tmp on macOS where scopeRoot and destination
|
|
1382
|
+
* may be in different lexical forms but resolve to the same canonical path.
|
|
1253
1383
|
*
|
|
1254
1384
|
* MUST be called BEFORE reading/comparing contents and BEFORE mkdir of any intermediate dir.
|
|
1255
1385
|
* It is the single authoritative source of symlink validation for all managed scope writes.
|
|
@@ -1260,78 +1390,134 @@ async function commitNativeHookTransaction(artifacts, preconditions, ancestorPre
|
|
|
1260
1390
|
* - 4351560195: respects CODEX_HOME via codexHome() ✓
|
|
1261
1391
|
* - 4351560207: validate before compare ✓
|
|
1262
1392
|
* - 4351560219: validate before mkdir ✓
|
|
1393
|
+
* - 4352912480: canonicalizes through OS-level symlinks in ancestors ✓
|
|
1394
|
+
* - 4352912502: validates against override root ✓
|
|
1263
1395
|
*/
|
|
1264
1396
|
async function assertManagedDestination(destinationPath, scopeRoot) {
|
|
1265
1397
|
// Resolve paths to absolute forms
|
|
1266
1398
|
const absolutePath = isAbsolute(destinationPath) ? destinationPath : join(process.cwd(), destinationPath);
|
|
1267
1399
|
const absoluteScopeRoot = isAbsolute(scopeRoot) ? scopeRoot : join(process.cwd(), scopeRoot);
|
|
1268
|
-
//
|
|
1269
|
-
|
|
1270
|
-
//
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
if (
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1400
|
+
// Step 1: Canonicalize the scope root via deepest-existing-ancestor realpath.
|
|
1401
|
+
const canonicalScope = await canonicalizePathToRealForm(absoluteScopeRoot);
|
|
1402
|
+
// Step 2 & 3: Walk lexical prefixes of destination to find the matching scope point.
|
|
1403
|
+
// Build the list of prefixes from root downward.
|
|
1404
|
+
const prefixes = [];
|
|
1405
|
+
let currentPrefix = absolutePath;
|
|
1406
|
+
while (true) {
|
|
1407
|
+
prefixes.unshift(currentPrefix);
|
|
1408
|
+
if (currentPrefix === sep)
|
|
1409
|
+
break;
|
|
1410
|
+
const parent = dirname(currentPrefix);
|
|
1411
|
+
if (parent === currentPrefix) {
|
|
1412
|
+
// Shouldn't happen, but break to avoid infinite loop
|
|
1413
|
+
break;
|
|
1282
1414
|
}
|
|
1415
|
+
currentPrefix = parent;
|
|
1283
1416
|
}
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
}
|
|
1290
|
-
// Walk from destination up to and including the scope root, checking each component
|
|
1291
|
-
// for symlinks. We do NOT resolve symlinks while walking (to catch them).
|
|
1292
|
-
// Stop at the scope root (don't check parents of the scope root).
|
|
1293
|
-
let currentPath = absolutePath;
|
|
1294
|
-
const visited = new Set();
|
|
1295
|
-
while (currentPath && currentPath !== dirname(currentPath)) {
|
|
1296
|
-
if (visited.has(currentPath))
|
|
1297
|
-
break; // Avoid infinite loops
|
|
1298
|
-
visited.add(currentPath);
|
|
1299
|
-
// Stop before checking ancestors beyond the scope root
|
|
1300
|
-
if (currentPath === realScopeRoot)
|
|
1417
|
+
let matchingPrefixPath = '';
|
|
1418
|
+
for (const prefix of prefixes) {
|
|
1419
|
+
const canonicalPrefix = await canonicalizePathToRealForm(prefix);
|
|
1420
|
+
if (canonicalPrefix === canonicalScope) {
|
|
1421
|
+
matchingPrefixPath = prefix;
|
|
1301
1422
|
break;
|
|
1302
|
-
// Check if this path component is a symlink (using lstat, not stat)
|
|
1303
|
-
let stats;
|
|
1304
|
-
try {
|
|
1305
|
-
stats = await lstat(currentPath);
|
|
1306
1423
|
}
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1424
|
+
}
|
|
1425
|
+
if (!matchingPrefixPath) {
|
|
1426
|
+
// No prefix matched the scope; destination is outside managed scope
|
|
1427
|
+
throw new Error(`Destination ${absolutePath} is outside managed scope ${absoluteScopeRoot}.`);
|
|
1428
|
+
}
|
|
1429
|
+
// Step 4: For each component lexically after the matching prefix, check with lstat.
|
|
1430
|
+
// Extract the remaining path components after the matching prefix.
|
|
1431
|
+
let current = matchingPrefixPath;
|
|
1432
|
+
const remaining = relative(current, absolutePath);
|
|
1433
|
+
if (remaining && remaining !== '.') {
|
|
1434
|
+
const remainingComponents = remaining.split(sep);
|
|
1435
|
+
let currentForDisplay = matchingPrefixPath;
|
|
1436
|
+
for (const component of remainingComponents) {
|
|
1437
|
+
current = join(current, component);
|
|
1438
|
+
currentForDisplay = join(currentForDisplay, component);
|
|
1439
|
+
try {
|
|
1440
|
+
const stats = await lstat(current);
|
|
1441
|
+
if (stats.isSymbolicLink()) {
|
|
1442
|
+
// Try to find a symlinked alternative path for the error message
|
|
1443
|
+
let displayPath = current;
|
|
1444
|
+
const altPath = await findSymlinkedAlternativePath(current);
|
|
1445
|
+
if (altPath) {
|
|
1446
|
+
displayPath = altPath;
|
|
1447
|
+
}
|
|
1448
|
+
const msg = `Refusing to install through symlink ${displayPath}. ` +
|
|
1449
|
+
`Either remove the symlink (rm ${displayPath}), ` +
|
|
1450
|
+
`or move the target to the expected location before running setup again.`;
|
|
1451
|
+
throw new Error(msg);
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
catch (error) {
|
|
1455
|
+
// If component doesn't exist yet, that's fine; we'll create it during mkdir
|
|
1456
|
+
if (isMissingPathError(error)) {
|
|
1457
|
+
// Stop checking further components; rest of path is missing
|
|
1458
|
+
break;
|
|
1459
|
+
}
|
|
1460
|
+
// Re-throw symlink errors and other unexpected errors
|
|
1461
|
+
if (error instanceof Error && error.message.includes('Refusing to install through symlink')) {
|
|
1462
|
+
throw error;
|
|
1463
|
+
}
|
|
1464
|
+
throw error;
|
|
1312
1465
|
}
|
|
1313
|
-
throw error;
|
|
1314
|
-
}
|
|
1315
|
-
if (stats.isSymbolicLink()) {
|
|
1316
|
-
const msg = `Refusing to install through symlink ${currentPath}. ` +
|
|
1317
|
-
`Either remove the symlink (rm ${currentPath}), ` +
|
|
1318
|
-
`or move the target to the expected location before running setup again.`;
|
|
1319
|
-
throw new Error(msg);
|
|
1320
1466
|
}
|
|
1321
|
-
currentPath = dirname(currentPath);
|
|
1322
1467
|
}
|
|
1323
1468
|
}
|
|
1469
|
+
/**
|
|
1470
|
+
* Determine the validation scope root for a destination based on its location.
|
|
1471
|
+
* Canonicalizes paths to handle OS-level symlinks (e.g., /tmp -> /private/tmp on macOS).
|
|
1472
|
+
* For project scope: validates against projectRoot (#4352912487)
|
|
1473
|
+
* Destinations under CODEX_HOME validate against CODEX_HOME;
|
|
1474
|
+
* Destinations under OMX_ROOT (if set) validate against OMX_ROOT (#4352912502);
|
|
1475
|
+
* other paths (like ~/.omx, ~/.claude) validate against the user's home.
|
|
1476
|
+
*/
|
|
1477
|
+
async function getValidationScopeRoot(destinationPath) {
|
|
1478
|
+
const absPath = isAbsolute(destinationPath) ? destinationPath : join(process.cwd(), destinationPath);
|
|
1479
|
+
const projectRoot = process.cwd();
|
|
1480
|
+
const codexHomeDir = codexHome();
|
|
1481
|
+
const projectCodexHome = join(projectRoot, ".codex");
|
|
1482
|
+
// Canonicalize both paths to handle symlinks consistently
|
|
1483
|
+
const realDestPath = await canonicalizePathToRealForm(absPath);
|
|
1484
|
+
const realCodexHome = await canonicalizePathToRealForm(isAbsolute(codexHomeDir) ? codexHomeDir : join(process.cwd(), codexHomeDir));
|
|
1485
|
+
const realProjectCodexHome = await canonicalizePathToRealForm(projectCodexHome);
|
|
1486
|
+
const realProjectRoot = await canonicalizePathToRealForm(projectRoot);
|
|
1487
|
+
// Check OMX_ROOT override first (#4352912502)
|
|
1488
|
+
const omxRoot = process.env.OMX_ROOT;
|
|
1489
|
+
if (omxRoot) {
|
|
1490
|
+
const realOmxRoot = await canonicalizePathToRealForm(isAbsolute(omxRoot) ? omxRoot : join(process.cwd(), omxRoot));
|
|
1491
|
+
if (realDestPath.startsWith(realOmxRoot + sep) || realDestPath === realOmxRoot) {
|
|
1492
|
+
return realOmxRoot; // Return the canonical OMX_ROOT for consistent scope comparison
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
// Check if destination is under project root or project .codex (#4352912487)
|
|
1496
|
+
// This indicates project scope files that should validate against projectRoot
|
|
1497
|
+
if (realDestPath.startsWith(realProjectRoot + sep) || realDestPath === realProjectRoot ||
|
|
1498
|
+
realDestPath.startsWith(realProjectCodexHome + sep) || realDestPath === realProjectCodexHome) {
|
|
1499
|
+
// If the destination is under the project root but NOT under user's CODEX_HOME,
|
|
1500
|
+
// it's a project-scoped file that should validate against projectRoot
|
|
1501
|
+
if (!realDestPath.startsWith(realCodexHome + sep) && realDestPath !== realCodexHome) {
|
|
1502
|
+
return realProjectRoot; // Return canonical project root
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
// Check if the destination is under CODEX_HOME (using real paths for comparison)
|
|
1506
|
+
if (realDestPath.startsWith(realCodexHome + sep) || realDestPath === realCodexHome) {
|
|
1507
|
+
return realCodexHome; // Return the canonical CODEX_HOME for consistent scope comparison
|
|
1508
|
+
}
|
|
1509
|
+
// Otherwise, it's validated against the home directory
|
|
1510
|
+
return await canonicalizePathToRealForm(homedir());
|
|
1511
|
+
}
|
|
1324
1512
|
async function ensureBackup(destinationPath, contentChanged, backupContext, options) {
|
|
1325
|
-
await
|
|
1513
|
+
const scopeRoot = await getValidationScopeRoot(destinationPath);
|
|
1514
|
+
await assertManagedDestination(destinationPath, scopeRoot);
|
|
1326
1515
|
if (!contentChanged || !existsSync(destinationPath))
|
|
1327
1516
|
return false;
|
|
1328
|
-
const
|
|
1329
|
-
const safeRelativePath = relativePath.startsWith("..") || relativePath === ""
|
|
1330
|
-
? destinationPath.replace(/^[/]+/, "")
|
|
1331
|
-
: relativePath;
|
|
1332
|
-
const backupPath = join(backupContext.backupRoot, safeRelativePath);
|
|
1517
|
+
const backupPath = join(backupContext.backupRoot, backupRelativePath(backupContext.baseRoot, destinationPath));
|
|
1333
1518
|
if (!options.dryRun) {
|
|
1334
|
-
await
|
|
1519
|
+
const backupScopeRoot = await getValidationScopeRoot(backupPath);
|
|
1520
|
+
await assertManagedDestination(backupPath, backupScopeRoot);
|
|
1335
1521
|
await mkdir(dirname(backupPath), { recursive: true });
|
|
1336
1522
|
await copyFile(destinationPath, backupPath);
|
|
1337
1523
|
}
|
|
@@ -1436,28 +1622,55 @@ export async function validateSkillFile(skillMdPath) {
|
|
|
1436
1622
|
}
|
|
1437
1623
|
const INSTALLED_SKILL_BADGE_PREFIX = "[OMX] ";
|
|
1438
1624
|
function installedSkillReceiptPath(scope, projectRoot) {
|
|
1439
|
-
|
|
1625
|
+
// For user scope, use CODEX_HOME as the managed authority root, not the user's home directory.
|
|
1626
|
+
const authorityRoot = scope === "project" ? projectRoot : codexHome();
|
|
1440
1627
|
return join(authorityRoot, ".omx", "state", "setup", "installed-skills.json");
|
|
1441
1628
|
}
|
|
1442
|
-
async function readInstalledSkillReceipt(receiptPath) {
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
const
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1629
|
+
async function readInstalledSkillReceipt(receiptPath, legacyReceiptPath) {
|
|
1630
|
+
const parseReceipt = (content) => {
|
|
1631
|
+
try {
|
|
1632
|
+
const parsed = JSON.parse(content);
|
|
1633
|
+
if (parsed?.version === 1 && parsed.skills && typeof parsed.skills === "object") {
|
|
1634
|
+
// Re-key through a prototype-less record so a `__proto__` entry in a persisted receipt is a
|
|
1635
|
+
// real own property on both sides of the comparison.
|
|
1636
|
+
const skills = Object.create(null);
|
|
1637
|
+
for (const [name, entry] of Object.entries(parsed.skills)) {
|
|
1638
|
+
const files = Object.create(null);
|
|
1639
|
+
for (const [file, digest] of Object.entries(entry?.files ?? {}))
|
|
1640
|
+
files[file] = String(digest);
|
|
1641
|
+
skills[name] = { files };
|
|
1642
|
+
}
|
|
1643
|
+
return { version: 1, skills };
|
|
1454
1644
|
}
|
|
1455
|
-
return { version: 1, skills };
|
|
1456
1645
|
}
|
|
1646
|
+
catch {
|
|
1647
|
+
// Invalid or malformed receipt
|
|
1648
|
+
}
|
|
1649
|
+
return null;
|
|
1650
|
+
};
|
|
1651
|
+
// Try reading from the new receipt path first
|
|
1652
|
+
try {
|
|
1653
|
+
const content = await readFile(receiptPath, "utf-8");
|
|
1654
|
+
const parsed = parseReceipt(content);
|
|
1655
|
+
if (parsed)
|
|
1656
|
+
return parsed;
|
|
1457
1657
|
}
|
|
1458
1658
|
catch {
|
|
1459
|
-
//
|
|
1659
|
+
// New receipt does not exist or is unreadable; try legacy path
|
|
1460
1660
|
}
|
|
1661
|
+
// Try reading from the legacy receipt path if provided
|
|
1662
|
+
if (legacyReceiptPath) {
|
|
1663
|
+
try {
|
|
1664
|
+
const content = await readFile(legacyReceiptPath, "utf-8");
|
|
1665
|
+
const parsed = parseReceipt(content);
|
|
1666
|
+
if (parsed)
|
|
1667
|
+
return parsed;
|
|
1668
|
+
}
|
|
1669
|
+
catch {
|
|
1670
|
+
// Legacy receipt does not exist or is unreadable
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
// No valid receipt found; return empty receipt
|
|
1461
1674
|
return { version: 1, skills: Object.create(null) };
|
|
1462
1675
|
}
|
|
1463
1676
|
/**
|
|
@@ -1491,8 +1704,8 @@ async function digestSkillDirectory(skillDir) {
|
|
|
1491
1704
|
return files;
|
|
1492
1705
|
}
|
|
1493
1706
|
/** True only when every current file matches the digest OMX recorded at install time. */
|
|
1494
|
-
async function isUnmodifiedRecordedInstall(receiptPath, skillName, skillDir) {
|
|
1495
|
-
const receipt = await readInstalledSkillReceipt(receiptPath);
|
|
1707
|
+
async function isUnmodifiedRecordedInstall(receiptPath, skillName, skillDir, legacyReceiptPath) {
|
|
1708
|
+
const receipt = await readInstalledSkillReceipt(receiptPath, legacyReceiptPath);
|
|
1496
1709
|
const recorded = receipt.skills[skillName]?.files;
|
|
1497
1710
|
if (!recorded || Object.keys(recorded).length === 0)
|
|
1498
1711
|
return false;
|
|
@@ -2649,7 +2862,8 @@ export async function setup(options = {}) {
|
|
|
2649
2862
|
if (!dryRun) {
|
|
2650
2863
|
const omxPath = join(projectRoot, ".omx");
|
|
2651
2864
|
try {
|
|
2652
|
-
await
|
|
2865
|
+
const omxScopeRoot = await getValidationScopeRoot(omxPath);
|
|
2866
|
+
await assertManagedDestination(omxPath, omxScopeRoot);
|
|
2653
2867
|
}
|
|
2654
2868
|
catch (error) {
|
|
2655
2869
|
// Missing .omx is fine, but symlinks must be refused
|
|
@@ -2770,6 +2984,8 @@ export async function setup(options = {}) {
|
|
|
2770
2984
|
const scopeSourceMessage = resolvedScope.source === "persisted" ? " (from .omx/setup-scope.json)" : "";
|
|
2771
2985
|
const backupContext = getBackupContext(resolvedScope.scope, projectRoot);
|
|
2772
2986
|
const skillReceiptPath = installedSkillReceiptPath(resolvedScope.scope, projectRoot);
|
|
2987
|
+
// For user scope, compute the legacy receipt path at $HOME/.omx/state/setup/installed-skills.json
|
|
2988
|
+
const legacySkillReceiptPath = resolvedScope.scope === "user" ? join(homedir(), ".omx", "state", "setup", "installed-skills.json") : undefined;
|
|
2773
2989
|
const isPluginInstallMode = resolvedInstallMode?.installMode === "plugin";
|
|
2774
2990
|
if (repairPluginCache && !isPluginInstallMode) {
|
|
2775
2991
|
throw new Error("--repair-plugin-cache requires plugin setup mode; add --plugin.");
|
|
@@ -2895,19 +3111,23 @@ export async function setup(options = {}) {
|
|
|
2895
3111
|
? [scopeDirs.codexHomeDir, scopeDirs.nativeAgentsDir]
|
|
2896
3112
|
: [scopeDirs.codexHomeDir, scopeDirs.promptsDir, scopeDirs.skillsDir, scopeDirs.nativeAgentsDir];
|
|
2897
3113
|
const projectScopeDirs = [omxStateDir(projectRoot), omxPlansDir(projectRoot), omxLogsDir(projectRoot)];
|
|
2898
|
-
// Validate and create user-scope directories against
|
|
3114
|
+
// Validate and create user-scope directories against appropriate authority root
|
|
3115
|
+
// (CODEX_HOME for files under CODEX_HOME, homedir() for ~/.omx and other paths)
|
|
2899
3116
|
for (const dir of userScopeDirs) {
|
|
2900
3117
|
if (!dryRun) {
|
|
2901
|
-
await
|
|
3118
|
+
const dirScopeRoot = await getValidationScopeRoot(dir);
|
|
3119
|
+
await assertManagedDestination(dir, dirScopeRoot);
|
|
2902
3120
|
await mkdir(dir, { recursive: true });
|
|
2903
3121
|
}
|
|
2904
3122
|
if (verbose)
|
|
2905
3123
|
console.log(` mkdir ${dir}`);
|
|
2906
3124
|
}
|
|
2907
|
-
// Validate and create project-scope directories against
|
|
3125
|
+
// Validate and create project-scope directories against appropriate authority root
|
|
3126
|
+
// (projectRoot for normal files, OMX_ROOT if set for .omx/* paths)
|
|
2908
3127
|
for (const dir of projectScopeDirs) {
|
|
2909
3128
|
if (!dryRun) {
|
|
2910
|
-
await
|
|
3129
|
+
const dirScopeRoot = await getValidationScopeRoot(dir);
|
|
3130
|
+
await assertManagedDestination(dir, dirScopeRoot);
|
|
2911
3131
|
await mkdir(dir, { recursive: true });
|
|
2912
3132
|
}
|
|
2913
3133
|
if (verbose)
|
|
@@ -2978,7 +3198,7 @@ export async function setup(options = {}) {
|
|
|
2978
3198
|
const skillsDst = scopeDirs.skillsDir;
|
|
2979
3199
|
if (isPluginInstallMode) {
|
|
2980
3200
|
summary.skills = createEmptyCategorySummary();
|
|
2981
|
-
const cleanup = await cleanupLegacyManagedSkills(skillsSrc, skillsDst, backupContext, { dryRun, verbose, skillReceiptPath });
|
|
3201
|
+
const cleanup = await cleanupLegacyManagedSkills(skillsSrc, skillsDst, backupContext, { dryRun, verbose, skillReceiptPath, legacySkillReceiptPath });
|
|
2982
3202
|
summary.skills.backedUp += cleanup.backedUp;
|
|
2983
3203
|
summary.skills.removed += cleanup.removedSkillNames.length;
|
|
2984
3204
|
summary.skills.skipped += cleanup.skippedSkillNames.length;
|
|
@@ -3413,6 +3633,7 @@ export async function setup(options = {}) {
|
|
|
3413
3633
|
if (force || !existsSync(hudConfigPath)) {
|
|
3414
3634
|
if (!dryRun) {
|
|
3415
3635
|
const defaultHudConfig = { preset: "focused" };
|
|
3636
|
+
await mkdir(dirname(hudConfigPath), { recursive: true });
|
|
3416
3637
|
await writeFile(hudConfigPath, JSON.stringify(defaultHudConfig, null, 2));
|
|
3417
3638
|
}
|
|
3418
3639
|
if (verbose)
|
|
@@ -3520,7 +3741,8 @@ function isGitHubCliConfigured() {
|
|
|
3520
3741
|
return cachedGitHubCliConfigured;
|
|
3521
3742
|
}
|
|
3522
3743
|
async function syncManagedFileFromDisk(srcPath, dstPath, summary, backupContext, options, verboseLabel) {
|
|
3523
|
-
await
|
|
3744
|
+
const dstScopeRoot = await getValidationScopeRoot(dstPath);
|
|
3745
|
+
await assertManagedDestination(dstPath, dstScopeRoot);
|
|
3524
3746
|
const destinationExists = existsSync(dstPath);
|
|
3525
3747
|
const changed = !destinationExists || (await filesDiffer(srcPath, dstPath));
|
|
3526
3748
|
if (!changed) {
|
|
@@ -3540,7 +3762,8 @@ async function syncManagedFileFromDisk(srcPath, dstPath, summary, backupContext,
|
|
|
3540
3762
|
}
|
|
3541
3763
|
}
|
|
3542
3764
|
async function syncManagedContent(content, dstPath, summary, backupContext, options, verboseLabel) {
|
|
3543
|
-
await
|
|
3765
|
+
const dstScopeRoot = await getValidationScopeRoot(dstPath);
|
|
3766
|
+
await assertManagedDestination(dstPath, dstScopeRoot);
|
|
3544
3767
|
const destinationExists = existsSync(dstPath);
|
|
3545
3768
|
let changed = true;
|
|
3546
3769
|
if (destinationExists) {
|
|
@@ -3607,7 +3830,8 @@ async function writeNativeAgentInstallManifest(agentsDir, manifest, scopeRoot) {
|
|
|
3607
3830
|
async function syncNativeAgentToml(content, dstPath, summary, backupContext, options, verboseLabel, manifest) {
|
|
3608
3831
|
const fileName = basename(dstPath);
|
|
3609
3832
|
const nextHash = hashContent(content);
|
|
3610
|
-
await
|
|
3833
|
+
const dstScopeRoot = await getValidationScopeRoot(dstPath);
|
|
3834
|
+
await assertManagedDestination(dstPath, dstScopeRoot);
|
|
3611
3835
|
const destinationExists = existsSync(dstPath);
|
|
3612
3836
|
if (!destinationExists) {
|
|
3613
3837
|
if (!options.dryRun) {
|
|
@@ -3827,7 +4051,8 @@ async function cleanupGeneratedNonInstallableNativeAgents(agentsDir, manifest, b
|
|
|
3827
4051
|
async function refreshNativeAgentConfigs(pkgRoot, agentsDir, backupContext, options) {
|
|
3828
4052
|
const summary = createEmptyCategorySummary();
|
|
3829
4053
|
if (!options.dryRun) {
|
|
3830
|
-
await
|
|
4054
|
+
const agentsDirScopeRoot = await getValidationScopeRoot(agentsDir);
|
|
4055
|
+
await assertManagedDestination(agentsDir, agentsDirScopeRoot);
|
|
3831
4056
|
await mkdir(agentsDir, { recursive: true });
|
|
3832
4057
|
}
|
|
3833
4058
|
const nativeAgentManifest = await readNativeAgentInstallManifest(agentsDir);
|
|
@@ -3910,7 +4135,8 @@ async function refreshNativeAgentConfigs(pkgRoot, agentsDir, backupContext, opti
|
|
|
3910
4135
|
}
|
|
3911
4136
|
}
|
|
3912
4137
|
if (!options.dryRun) {
|
|
3913
|
-
await
|
|
4138
|
+
const manifestScopeRoot = await getValidationScopeRoot(join(agentsDir, "..", ".omx", "native-agents.json"));
|
|
4139
|
+
await writeNativeAgentInstallManifest(agentsDir, nativeAgentManifest, manifestScopeRoot);
|
|
3914
4140
|
}
|
|
3915
4141
|
return summary;
|
|
3916
4142
|
}
|
|
@@ -3962,7 +4188,8 @@ export async function installSkills(srcDir, dstDir, backupContext, options) {
|
|
|
3962
4188
|
return summary;
|
|
3963
4189
|
// Validate the skills directory before creating any skill subdirectories
|
|
3964
4190
|
if (!options.dryRun) {
|
|
3965
|
-
await
|
|
4191
|
+
const dstDirScopeRoot = await getValidationScopeRoot(dstDir);
|
|
4192
|
+
await assertManagedDestination(dstDir, dstDirScopeRoot);
|
|
3966
4193
|
}
|
|
3967
4194
|
const installableSkillNames = getSetupInstallableSkillNames();
|
|
3968
4195
|
const installableSkills = [];
|
|
@@ -4020,7 +4247,8 @@ export async function installSkills(srcDir, dstDir, backupContext, options) {
|
|
|
4020
4247
|
const skillSrc = skill.sourceDir;
|
|
4021
4248
|
const skillDst = skill.destinationDir;
|
|
4022
4249
|
if (!options.dryRun) {
|
|
4023
|
-
await
|
|
4250
|
+
const skillDstScopeRoot = await getValidationScopeRoot(skillDst);
|
|
4251
|
+
await assertManagedDestination(skillDst, skillDstScopeRoot);
|
|
4024
4252
|
await mkdir(skillDst, { recursive: true });
|
|
4025
4253
|
}
|
|
4026
4254
|
const skillFiles = await readdir(skillSrc);
|
|
@@ -4044,7 +4272,8 @@ export async function installSkills(srcDir, dstDir, backupContext, options) {
|
|
|
4044
4272
|
// Without an authority path, refuse to create a receipt rather than placing deletion authority
|
|
4045
4273
|
// in the user-writable skills directory.
|
|
4046
4274
|
if (options.skillReceiptPath) {
|
|
4047
|
-
await
|
|
4275
|
+
const receiptScopeRoot = await getValidationScopeRoot(options.skillReceiptPath);
|
|
4276
|
+
await writeInstalledSkillReceipt(options.skillReceiptPath, dstDir, writtenFilesBySkill, options, receiptScopeRoot);
|
|
4048
4277
|
}
|
|
4049
4278
|
if (manifest && existsSync(dstDir)) {
|
|
4050
4279
|
for (const staleSkill of staleCandidateSkillNames) {
|
|
@@ -4098,11 +4327,7 @@ async function removeDirectoryCopyAware(sourceDir, backupContext, options) {
|
|
|
4098
4327
|
const destinationExists = existsSync(sourceDir);
|
|
4099
4328
|
if (!destinationExists)
|
|
4100
4329
|
return false;
|
|
4101
|
-
const
|
|
4102
|
-
const safeRelativePath = relativePath.startsWith("..") || relativePath === ""
|
|
4103
|
-
? sourceDir.replace(/^[/]+/, "")
|
|
4104
|
-
: relativePath;
|
|
4105
|
-
const backupPath = join(backupContext.backupRoot, safeRelativePath);
|
|
4330
|
+
const backupPath = join(backupContext.backupRoot, backupRelativePath(backupContext.baseRoot, sourceDir));
|
|
4106
4331
|
if (!options.dryRun) {
|
|
4107
4332
|
await mkdir(dirname(backupPath), { recursive: true });
|
|
4108
4333
|
await cp(sourceDir, backupPath, { recursive: true });
|
|
@@ -4148,7 +4373,7 @@ async function cleanupLegacyManagedSkills(srcDir, dstDir, backupContext, options
|
|
|
4148
4373
|
// A matching SKILL.md does not prove the whole directory is ours: a user file sitting beside it
|
|
4149
4374
|
// would be deleted with the directory. Require the same unmodified-install proof the ordinary
|
|
4150
4375
|
// retirement sweep uses.
|
|
4151
|
-
if (!options.skillReceiptPath || !(await isUnmodifiedRecordedInstall(options.skillReceiptPath, skillName, installedSkillDir))) {
|
|
4376
|
+
if (!options.skillReceiptPath || !(await isUnmodifiedRecordedInstall(options.skillReceiptPath, skillName, installedSkillDir, options.legacySkillReceiptPath))) {
|
|
4152
4377
|
const warning = `Skipping legacy skill cleanup for ${skillName}: directory contents are not a receipted unmodified OMX install.`;
|
|
4153
4378
|
result.skippedSkillNames.push(skillName);
|
|
4154
4379
|
result.warnings.push(warning);
|
|
@@ -4171,7 +4396,7 @@ async function cleanupLegacyManagedSkills(srcDir, dstDir, backupContext, options
|
|
|
4171
4396
|
if (!(await isOmxManagedInstalledSkillDir(installedSkillDir)))
|
|
4172
4397
|
continue;
|
|
4173
4398
|
// The badge alone is not proof of ownership for deletion; a user-edited body keeps it.
|
|
4174
|
-
if (!options.skillReceiptPath || !(await isUnmodifiedRecordedInstall(options.skillReceiptPath, entry.name, installedSkillDir))) {
|
|
4399
|
+
if (!options.skillReceiptPath || !(await isUnmodifiedRecordedInstall(options.skillReceiptPath, entry.name, installedSkillDir, options.legacySkillReceiptPath))) {
|
|
4175
4400
|
result.skippedSkillNames.push(entry.name);
|
|
4176
4401
|
result.warnings.push(`Skipping retired skill ${entry.name}: modified since install, or installed before receipts existed.`);
|
|
4177
4402
|
continue;
|