claude-dev-env 2.14.1 → 2.15.1
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/_shared/advisor/scripts/tier_model_ids.py +6 -2
- package/bin/AGENTS.md +6 -4
- package/bin/install-constants.mjs +51 -0
- package/bin/install.codex-rules.test.mjs +173 -0
- package/bin/install.cursor-rules.test.mjs +103 -0
- package/bin/install.mjs +165 -51
- package/bin/install.profile-root.test.mjs +8 -0
- package/bin/install.prune.test.mjs +2 -1
- package/bin/install.test.mjs +42 -8
- package/bin/install.transaction.test.mjs +1 -0
- package/bin/install.uninstall-transaction.test.mjs +1 -0
- package/bin/resolve-install-root.mjs +43 -10
- package/codex-rules/claude-dev-env.rules +12 -0
- package/package.json +3 -2
- package/scripts/AGENTS.md +0 -7
- package/scripts/claude_chain_runner.py +49 -2
- package/scripts/invoke_code_review.py +48 -51
- package/scripts/resolve_worker_spawn.py +42 -47
- package/scripts/sync_to_cursor/AGENTS.md +3 -3
- package/scripts/sync_to_cursor/canonical_docs.py +11 -11
- package/scripts/sync_to_cursor/config/__init__.py +8 -0
- package/scripts/sync_to_cursor/engine.py +26 -1
- package/scripts/sync_to_cursor/rules.py +76 -5
- package/scripts/test_claude_chain_runner.py +28 -0
- package/scripts/test_dispatcher_profile_import.py +184 -0
- package/scripts/test_resolve_worker_spawn.py +119 -1
- package/scripts/test_validate_instruction_pairs.py +30 -0
- package/scripts/tests/AGENTS.md +2 -0
- package/scripts/tests/test_engine.py +102 -0
- package/scripts/tests/test_rules.py +79 -0
- package/scripts/profile-isolation-launchers/config/mcp-bundles.json +0 -25
- package/scripts/profile-isolation-launchers/config/profile-isolation-constants.mjs +0 -60
- package/scripts/profile-isolation-launchers/config/profiles.manifest.json +0 -54
- package/scripts/profile-isolation-launchers/config/shared-allowlist.json +0 -64
- package/scripts/profile-isolation-launchers/launcher-runtime.mjs +0 -180
- package/scripts/profile-isolation-launchers/lib/profile-manifest.mjs +0 -288
- package/scripts/profile-isolation-launchers/mcp-bundles.mjs +0 -275
- package/scripts/profile-isolation-launchers/profile-isolation-contract.test.mjs +0 -221
- package/scripts/profile-isolation-launchers/tests/launcher-runtime.test.mjs +0 -108
- package/scripts/profile-isolation-launchers/tests/mcp-bundles.test.mjs +0 -147
- package/scripts/profile-isolation-launchers/tests/shortcut-contract.test.ps1 +0 -102
- package/scripts/profile-isolation-launchers/tests/version-compatibility.test.mjs +0 -210
- package/scripts/profile-isolation-launchers/version-compatibility.mjs +0 -299
- package/scripts/profile-isolation-launchers/windows/shortcut-inventory.ps1 +0 -127
- package/scripts/profile-isolation-launchers/windows/shortcut-manifest.json +0 -51
- package/scripts/profile-isolation-launchers/windows/shortcut-reconcile.ps1 +0 -77
- package/scripts/sync_to_cursor/config.py +0 -5
|
@@ -1,147 +0,0 @@
|
|
|
1
|
-
import { test } from 'node:test';
|
|
2
|
-
import { strict as assert } from 'node:assert';
|
|
3
|
-
import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
|
-
import { tmpdir } from 'node:os';
|
|
6
|
-
import {
|
|
7
|
-
listServerNamesForBundle,
|
|
8
|
-
loadMcpBundlesDocument,
|
|
9
|
-
materializeProfileMcpConfig,
|
|
10
|
-
readEffectiveMcpServerInventory,
|
|
11
|
-
resolveProfileMcpBundleId,
|
|
12
|
-
SUPPORTED_ACTIVATION_INTERFACE,
|
|
13
|
-
validateMcpBundlesDocument,
|
|
14
|
-
} from '../mcp-bundles.mjs';
|
|
15
|
-
|
|
16
|
-
test('mcp bundles document loads with lean and full inventories', () => {
|
|
17
|
-
const document = loadMcpBundlesDocument();
|
|
18
|
-
assert.equal(document.supportedActivationInterface, SUPPORTED_ACTIVATION_INTERFACE);
|
|
19
|
-
assert.ok(document.bundles.lean.allServerNames.length >= 1);
|
|
20
|
-
assert.ok(document.bundles.full.allServerNames.length >= document.bundles.lean.allServerNames.length);
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
test('profile-a and profile-b resolve to lean mcp bundles from the profiles manifest', () => {
|
|
24
|
-
assert.equal(resolveProfileMcpBundleId('profile-a'), 'lean');
|
|
25
|
-
assert.equal(resolveProfileMcpBundleId('profile-b'), 'lean');
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
test('materializeProfileMcpConfig writes mcp.json inventory for profile-a', () => {
|
|
29
|
-
const claudeConfigDir = mkdtempSync(join(tmpdir(), 'mcp-profile-a-'));
|
|
30
|
-
try {
|
|
31
|
-
const result = materializeProfileMcpConfig({
|
|
32
|
-
profileId: 'profile-a',
|
|
33
|
-
claudeConfigDir,
|
|
34
|
-
});
|
|
35
|
-
assert.equal(result.activationInterface, SUPPORTED_ACTIVATION_INTERFACE);
|
|
36
|
-
assert.equal(result.bundleId, 'lean');
|
|
37
|
-
const inventory = readEffectiveMcpServerInventory(result.mcpConfigPath);
|
|
38
|
-
assert.deepEqual(inventory, [...result.allServerNames].sort());
|
|
39
|
-
const document = JSON.parse(readFileSync(result.mcpConfigPath, 'utf8'));
|
|
40
|
-
assert.ok(document.mcpServers['filesystem-readonly']);
|
|
41
|
-
} finally {
|
|
42
|
-
rmSync(claudeConfigDir, { recursive: true, force: true });
|
|
43
|
-
}
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
test('materializeProfileMcpConfig writes mcp.json inventory for profile-b', () => {
|
|
47
|
-
const claudeConfigDir = mkdtempSync(join(tmpdir(), 'mcp-profile-b-'));
|
|
48
|
-
try {
|
|
49
|
-
const result = materializeProfileMcpConfig({
|
|
50
|
-
profileId: 'profile-b',
|
|
51
|
-
claudeConfigDir,
|
|
52
|
-
});
|
|
53
|
-
assert.equal(result.bundleId, 'lean');
|
|
54
|
-
const inventory = readEffectiveMcpServerInventory(result.mcpConfigPath);
|
|
55
|
-
assert.deepEqual(inventory, listServerNamesForBundle('lean').sort());
|
|
56
|
-
} finally {
|
|
57
|
-
rmSync(claudeConfigDir, { recursive: true, force: true });
|
|
58
|
-
}
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
test('unknown profile id fails with an actionable message', () => {
|
|
62
|
-
assert.throws(
|
|
63
|
-
() => resolveProfileMcpBundleId('not-a-profile'),
|
|
64
|
-
/unknown profile id/,
|
|
65
|
-
);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
test('malformed bundles document is rejected', () => {
|
|
69
|
-
assert.throws(
|
|
70
|
-
() => validateMcpBundlesDocument({ schemaVersion: 2 }),
|
|
71
|
-
/schemaVersion must be 1/,
|
|
72
|
-
);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
test('missing mcp config readback fails closed', () => {
|
|
76
|
-
const claudeConfigDir = mkdtempSync(join(tmpdir(), 'mcp-missing-'));
|
|
77
|
-
try {
|
|
78
|
-
assert.throws(
|
|
79
|
-
() => readEffectiveMcpServerInventory(join(claudeConfigDir, 'mcp.json')),
|
|
80
|
-
/mcp config missing/,
|
|
81
|
-
);
|
|
82
|
-
} finally {
|
|
83
|
-
rmSync(claudeConfigDir, { recursive: true, force: true });
|
|
84
|
-
}
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
test('malformed mcp config readback fails closed', () => {
|
|
88
|
-
const claudeConfigDir = mkdtempSync(join(tmpdir(), 'mcp-bad-'));
|
|
89
|
-
try {
|
|
90
|
-
mkdirSync(claudeConfigDir, { recursive: true });
|
|
91
|
-
const path = join(claudeConfigDir, 'mcp.json');
|
|
92
|
-
writeFileSync(path, '{"notServers":true}\n', 'utf8');
|
|
93
|
-
assert.throws(
|
|
94
|
-
() => readEffectiveMcpServerInventory(path),
|
|
95
|
-
/malformed/,
|
|
96
|
-
);
|
|
97
|
-
} finally {
|
|
98
|
-
rmSync(claudeConfigDir, { recursive: true, force: true });
|
|
99
|
-
}
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
test('lean inventory must be a subset of full', () => {
|
|
103
|
-
assert.throws(
|
|
104
|
-
() => validateMcpBundlesDocument({
|
|
105
|
-
schemaVersion: 1,
|
|
106
|
-
supportedActivationInterface: SUPPORTED_ACTIVATION_INTERFACE,
|
|
107
|
-
mcpConfigFileName: 'mcp.json',
|
|
108
|
-
bundles: {
|
|
109
|
-
lean: { id: 'lean', allServerNames: ['only-lean'] },
|
|
110
|
-
full: { id: 'full', allServerNames: ['other'] },
|
|
111
|
-
},
|
|
112
|
-
serverByName: {
|
|
113
|
-
'only-lean': { command: 'npx', args: [] },
|
|
114
|
-
other: { command: 'npx', args: [] },
|
|
115
|
-
},
|
|
116
|
-
}),
|
|
117
|
-
/lean server only-lean must also appear in full/,
|
|
118
|
-
);
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
test('bundles and serverByName reject arrays', () => {
|
|
122
|
-
assert.throws(
|
|
123
|
-
() => validateMcpBundlesDocument({
|
|
124
|
-
schemaVersion: 1,
|
|
125
|
-
supportedActivationInterface: SUPPORTED_ACTIVATION_INTERFACE,
|
|
126
|
-
mcpConfigFileName: 'mcp.json',
|
|
127
|
-
bundles: [],
|
|
128
|
-
serverByName: {},
|
|
129
|
-
}),
|
|
130
|
-
/bundles must be an object/,
|
|
131
|
-
);
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
test('invalid JSON mcp config readback fails closed as malformed', () => {
|
|
135
|
-
const claudeConfigDir = mkdtempSync(join(tmpdir(), 'mcp-json-'));
|
|
136
|
-
try {
|
|
137
|
-
mkdirSync(claudeConfigDir, { recursive: true });
|
|
138
|
-
const path = join(claudeConfigDir, 'mcp.json');
|
|
139
|
-
writeFileSync(path, '{not-json', 'utf8');
|
|
140
|
-
assert.throws(
|
|
141
|
-
() => readEffectiveMcpServerInventory(path),
|
|
142
|
-
/malformed/,
|
|
143
|
-
);
|
|
144
|
-
} finally {
|
|
145
|
-
rmSync(claudeConfigDir, { recursive: true, force: true });
|
|
146
|
-
}
|
|
147
|
-
});
|
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
# Contract tests for N1 shortcut source semantics (read-only slice).
|
|
2
|
-
# Run: pwsh -NoProfile -File tests/shortcut-contract.test.ps1
|
|
3
|
-
|
|
4
|
-
Set-StrictMode -Version Latest
|
|
5
|
-
$ErrorActionPreference = 'Stop'
|
|
6
|
-
|
|
7
|
-
$packageLaunchersRoot = Split-Path -Parent $PSScriptRoot
|
|
8
|
-
$manifestPath = Join-Path $packageLaunchersRoot 'windows\shortcut-manifest.json'
|
|
9
|
-
$inventoryScript = Join-Path $packageLaunchersRoot 'windows\shortcut-inventory.ps1'
|
|
10
|
-
$reconcileScript = Join-Path $packageLaunchersRoot 'windows\shortcut-reconcile.ps1'
|
|
11
|
-
|
|
12
|
-
$script:allFailures = @()
|
|
13
|
-
|
|
14
|
-
function Assert-True {
|
|
15
|
-
param([bool]$Condition, [string]$Message)
|
|
16
|
-
if (-not $Condition) {
|
|
17
|
-
$script:allFailures += $Message
|
|
18
|
-
Write-Host "FAIL: $Message"
|
|
19
|
-
}
|
|
20
|
-
else {
|
|
21
|
-
Write-Host "PASS: $Message"
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
# Manifest loads with provisional native-Desktop-wins policy.
|
|
26
|
-
$manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json
|
|
27
|
-
Assert-True ($manifest.schemaVersion -eq 1) 'manifest schemaVersion is 1'
|
|
28
|
-
Assert-True ($manifest.policy.liveMutationAuthorized -eq $false) 'live mutation stays unauthorized until owner confirmation'
|
|
29
|
-
Assert-True ($manifest.policy.unsuffixedNames -match 'Native Desktop wins') 'provisional unsuffixed policy is native Desktop wins'
|
|
30
|
-
Assert-True ($manifest.policy.browserNames -match 'Chrome') 'browser names require explicit Chrome/Web labels'
|
|
31
|
-
|
|
32
|
-
# Every managed shortcut has one source, profile, and grouping identity.
|
|
33
|
-
$allVisibleNames = @{}
|
|
34
|
-
$allGrouping = @{}
|
|
35
|
-
foreach ($eachShortcut in $manifest.allManagedShortcuts) {
|
|
36
|
-
Assert-True ([string]::IsNullOrWhiteSpace($eachShortcut.id) -eq $false) "shortcut id present"
|
|
37
|
-
Assert-True ([string]::IsNullOrWhiteSpace($eachShortcut.source) -eq $false) "$($eachShortcut.id) has source"
|
|
38
|
-
Assert-True ([string]::IsNullOrWhiteSpace($eachShortcut.profileId) -eq $false) "$($eachShortcut.id) has profileId"
|
|
39
|
-
Assert-True ([string]::IsNullOrWhiteSpace($eachShortcut.groupingIdentity) -eq $false) "$($eachShortcut.id) has groupingIdentity"
|
|
40
|
-
Assert-True ([string]::IsNullOrWhiteSpace($eachShortcut.visibleName) -eq $false) "$($eachShortcut.id) has visibleName"
|
|
41
|
-
if ($allVisibleNames.ContainsKey($eachShortcut.visibleName)) {
|
|
42
|
-
Assert-True $false "duplicate visible name: $($eachShortcut.visibleName)"
|
|
43
|
-
}
|
|
44
|
-
else {
|
|
45
|
-
$allVisibleNames[$eachShortcut.visibleName] = $eachShortcut.id
|
|
46
|
-
Assert-True $true "unique visible name: $($eachShortcut.visibleName)"
|
|
47
|
-
}
|
|
48
|
-
if ($allGrouping.ContainsKey($eachShortcut.groupingIdentity)) {
|
|
49
|
-
Assert-True $false "duplicate grouping identity: $($eachShortcut.groupingIdentity)"
|
|
50
|
-
}
|
|
51
|
-
else {
|
|
52
|
-
$allGrouping[$eachShortcut.groupingIdentity] = $eachShortcut.id
|
|
53
|
-
Assert-True $true "unique grouping identity: $($eachShortcut.groupingIdentity)"
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
# Native and Chrome variants retain distinct grouping identity for profile-a and profile-b.
|
|
58
|
-
$profileANative = $manifest.allManagedShortcuts | Where-Object { $_.id -eq 'desktop-profile-a-native' }
|
|
59
|
-
$profileAChrome = $manifest.allManagedShortcuts | Where-Object { $_.id -eq 'start-profile-a-chrome' }
|
|
60
|
-
Assert-True ($profileANative.groupingIdentity -ne $profileAChrome.groupingIdentity) 'profile-a native/chrome grouping differs'
|
|
61
|
-
Assert-True ($profileANative.source -eq 'native-desktop') 'profile-a desktop source is native-desktop'
|
|
62
|
-
Assert-True ($profileAChrome.source -eq 'chrome-start-menu') 'profile-a start-menu source is chrome-start-menu'
|
|
63
|
-
|
|
64
|
-
# Inventory is read-only against disposable paths.
|
|
65
|
-
$tempRoot = Join-Path ([IO.Path]::GetTempPath()) ("shortcut-contract-" + [guid]::NewGuid().ToString('n'))
|
|
66
|
-
$desktop = Join-Path $tempRoot 'Desktop'
|
|
67
|
-
$startMenu = Join-Path $tempRoot 'StartMenu'
|
|
68
|
-
New-Item -ItemType Directory -Path $desktop, $startMenu | Out-Null
|
|
69
|
-
try {
|
|
70
|
-
$inventoryJson = & $inventoryScript -ManifestPath $manifestPath -DesktopPath $desktop -StartMenuPath $startMenu
|
|
71
|
-
$inventory = $inventoryJson | ConvertFrom-Json
|
|
72
|
-
Assert-True ($inventory.mode -eq 'read-only-inventory') 'inventory mode is read-only'
|
|
73
|
-
Assert-True ($inventory.shortcuts.Count -eq $manifest.allManagedShortcuts.Count) 'inventory rows match manifest'
|
|
74
|
-
Assert-True (-not (Get-ChildItem -LiteralPath $desktop -Force | Where-Object { $_.Extension -eq '.lnk' })) 'inventory does not create desktop shortcuts'
|
|
75
|
-
Assert-True (-not (Get-ChildItem -LiteralPath $startMenu -Force | Where-Object { $_.Extension -eq '.lnk' })) 'inventory does not create start-menu shortcuts'
|
|
76
|
-
|
|
77
|
-
$previewJson = & $reconcileScript -ManifestPath $manifestPath -DesktopPath $desktop -StartMenuPath $startMenu
|
|
78
|
-
$preview = $previewJson | ConvertFrom-Json
|
|
79
|
-
Assert-True ($preview.mode -eq 'preview-only') 'reconcile defaults to preview-only'
|
|
80
|
-
Assert-True ($preview.liveMutationAuthorized -eq $false) 'preview reports mutation unauthorized'
|
|
81
|
-
Assert-True ($preview.actions.Count -eq $manifest.allManagedShortcuts.Count) 'preview lists every managed shortcut'
|
|
82
|
-
|
|
83
|
-
$applyFailed = $false
|
|
84
|
-
try {
|
|
85
|
-
& $reconcileScript -ManifestPath $manifestPath -DesktopPath $desktop -StartMenuPath $startMenu -Apply | Out-Null
|
|
86
|
-
}
|
|
87
|
-
catch {
|
|
88
|
-
$applyFailed = $true
|
|
89
|
-
Assert-True ($_.Exception.Message -match 'live shortcut mutation refused') 'apply refuses without owner authorization'
|
|
90
|
-
}
|
|
91
|
-
Assert-True $applyFailed 'apply path must throw while unauthorized'
|
|
92
|
-
}
|
|
93
|
-
finally {
|
|
94
|
-
Remove-Item -Recurse -Force -Confirm:$false -LiteralPath $tempRoot
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
if ($script:allFailures.Count -gt 0) {
|
|
98
|
-
Write-Host ("FAILED {0} assertion(s)" -f $script:allFailures.Count)
|
|
99
|
-
exit 1
|
|
100
|
-
}
|
|
101
|
-
Write-Host 'shortcut-contract: all assertions passed'
|
|
102
|
-
exit 0
|
|
@@ -1,210 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* CLI / Desktop version compatibility contract (control-plane K1 / P-26).
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import { test } from 'node:test';
|
|
6
|
-
import { strict as assert } from 'node:assert';
|
|
7
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
8
|
-
import { join, dirname } from 'node:path';
|
|
9
|
-
import { fileURLToPath } from 'node:url';
|
|
10
|
-
import { spawnSync } from 'node:child_process';
|
|
11
|
-
import {
|
|
12
|
-
classifyVersionCompatibility,
|
|
13
|
-
shouldBlockLaunch,
|
|
14
|
-
parseSemver,
|
|
15
|
-
COMPATIBILITY_POLICY_VERSION,
|
|
16
|
-
COMPATIBILITY_ACTION_BY_CLASS,
|
|
17
|
-
} from '../version-compatibility.mjs';
|
|
18
|
-
import {
|
|
19
|
-
evaluateLauncherVersionCompatibility,
|
|
20
|
-
formatCompatibilityPreflightFailure,
|
|
21
|
-
} from '../launcher-runtime.mjs';
|
|
22
|
-
|
|
23
|
-
const PACKAGE_ROOT = join(
|
|
24
|
-
dirname(fileURLToPath(import.meta.url)),
|
|
25
|
-
'..',
|
|
26
|
-
'..',
|
|
27
|
-
'..',
|
|
28
|
-
);
|
|
29
|
-
|
|
30
|
-
test('action table covers every compatibility class and fails closed', () => {
|
|
31
|
-
const allExpectedClasses = [
|
|
32
|
-
'equal',
|
|
33
|
-
'patch-drift',
|
|
34
|
-
'minor-drift',
|
|
35
|
-
'major-drift',
|
|
36
|
-
'missing-binary',
|
|
37
|
-
'unreadable',
|
|
38
|
-
'process-error',
|
|
39
|
-
'non-semver',
|
|
40
|
-
];
|
|
41
|
-
for (const eachClass of allExpectedClasses) {
|
|
42
|
-
assert.ok(
|
|
43
|
-
Object.hasOwn(COMPATIBILITY_ACTION_BY_CLASS, eachClass),
|
|
44
|
-
`missing action for ${eachClass}`,
|
|
45
|
-
);
|
|
46
|
-
}
|
|
47
|
-
assert.equal(COMPATIBILITY_ACTION_BY_CLASS.equal, 'pass');
|
|
48
|
-
assert.equal(COMPATIBILITY_ACTION_BY_CLASS['patch-drift'], 'warn');
|
|
49
|
-
assert.equal(COMPATIBILITY_ACTION_BY_CLASS['minor-drift'], 'block');
|
|
50
|
-
assert.equal(COMPATIBILITY_ACTION_BY_CLASS['major-drift'], 'block');
|
|
51
|
-
assert.equal(COMPATIBILITY_ACTION_BY_CLASS['missing-binary'], 'block');
|
|
52
|
-
assert.equal(COMPATIBILITY_ACTION_BY_CLASS.unreadable, 'block');
|
|
53
|
-
assert.equal(COMPATIBILITY_ACTION_BY_CLASS['process-error'], 'block');
|
|
54
|
-
assert.equal(COMPATIBILITY_ACTION_BY_CLASS['non-semver'], 'block');
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
test('equal versions pass with both paths, versions, and policy version', () => {
|
|
58
|
-
const result = classifyVersionCompatibility({
|
|
59
|
-
cli: { path: 'C:\\cli\\claude.exe', versionText: '2.1.220' },
|
|
60
|
-
desktop: { path: 'C:\\desktop\\claude.exe', versionText: '2.1.220' },
|
|
61
|
-
});
|
|
62
|
-
assert.equal(result.class, 'equal');
|
|
63
|
-
assert.equal(result.action, 'pass');
|
|
64
|
-
assert.equal(result.policyVersion, COMPATIBILITY_POLICY_VERSION);
|
|
65
|
-
assert.equal(result.cliPath, 'C:\\cli\\claude.exe');
|
|
66
|
-
assert.equal(result.desktopPath, 'C:\\desktop\\claude.exe');
|
|
67
|
-
assert.equal(result.cliVersion, '2.1.220');
|
|
68
|
-
assert.equal(result.desktopVersion, '2.1.220');
|
|
69
|
-
assert.equal(shouldBlockLaunch(result), false);
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
test('patch drift warns and identifies the runtime pair', () => {
|
|
73
|
-
const result = classifyVersionCompatibility({
|
|
74
|
-
cli: { path: 'cli', versionText: '2.1.220' },
|
|
75
|
-
desktop: { path: 'desktop', versionText: '2.1.219' },
|
|
76
|
-
});
|
|
77
|
-
assert.equal(result.class, 'patch-drift');
|
|
78
|
-
assert.equal(result.action, 'warn');
|
|
79
|
-
assert.match(result.message, /patch drift/i);
|
|
80
|
-
assert.equal(shouldBlockLaunch(result), false);
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
test('minor and major drift block before mutation', () => {
|
|
84
|
-
const minor = classifyVersionCompatibility({
|
|
85
|
-
cli: { path: 'cli', versionText: '2.1.220' },
|
|
86
|
-
desktop: { path: 'desktop', versionText: '2.0.100' },
|
|
87
|
-
});
|
|
88
|
-
assert.equal(minor.class, 'minor-drift');
|
|
89
|
-
assert.equal(minor.action, 'block');
|
|
90
|
-
assert.equal(shouldBlockLaunch(minor), true);
|
|
91
|
-
|
|
92
|
-
const major = classifyVersionCompatibility({
|
|
93
|
-
cli: { path: 'cli', versionText: '2.1.220' },
|
|
94
|
-
desktop: { path: 'desktop', versionText: '1.9.0' },
|
|
95
|
-
});
|
|
96
|
-
assert.equal(major.class, 'major-drift');
|
|
97
|
-
assert.equal(major.action, 'block');
|
|
98
|
-
assert.equal(shouldBlockLaunch(major), true);
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
test('missing, unreadable, process-error, and non-semver block', () => {
|
|
102
|
-
const missing = classifyVersionCompatibility({
|
|
103
|
-
cli: { path: null, versionText: null, errorCode: 'missing' },
|
|
104
|
-
desktop: { path: 'desktop', versionText: '2.1.220' },
|
|
105
|
-
});
|
|
106
|
-
assert.equal(missing.class, 'missing-binary');
|
|
107
|
-
assert.equal(missing.action, 'block');
|
|
108
|
-
|
|
109
|
-
const unreadable = classifyVersionCompatibility({
|
|
110
|
-
cli: { path: 'cli', versionText: null, errorCode: 'unreadable' },
|
|
111
|
-
desktop: { path: 'desktop', versionText: '2.1.220' },
|
|
112
|
-
});
|
|
113
|
-
assert.equal(unreadable.class, 'unreadable');
|
|
114
|
-
assert.equal(unreadable.action, 'block');
|
|
115
|
-
|
|
116
|
-
const processError = classifyVersionCompatibility({
|
|
117
|
-
cli: {
|
|
118
|
-
path: 'cli',
|
|
119
|
-
versionText: null,
|
|
120
|
-
errorCode: 'process-error',
|
|
121
|
-
errorMessage: 'exit 127',
|
|
122
|
-
},
|
|
123
|
-
desktop: { path: 'desktop', versionText: '2.1.220' },
|
|
124
|
-
});
|
|
125
|
-
assert.equal(processError.class, 'process-error');
|
|
126
|
-
assert.equal(processError.action, 'block');
|
|
127
|
-
assert.match(processError.message, /exit 127/);
|
|
128
|
-
|
|
129
|
-
const nonSemver = classifyVersionCompatibility({
|
|
130
|
-
cli: { path: 'cli', versionText: 'unknown' },
|
|
131
|
-
desktop: { path: 'desktop', versionText: '2.1.220' },
|
|
132
|
-
});
|
|
133
|
-
assert.equal(nonSemver.class, 'non-semver');
|
|
134
|
-
assert.equal(nonSemver.action, 'block');
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
test('parseSemver reads the first major.minor.patch triple', () => {
|
|
138
|
-
assert.deepEqual(parseSemver('2.1.220'), { major: 2, minor: 1, patch: 220 });
|
|
139
|
-
assert.deepEqual(parseSemver('claude 2.1.219 (win)'), { major: 2, minor: 1, patch: 219 });
|
|
140
|
-
assert.equal(parseSemver('unknown'), null);
|
|
141
|
-
assert.equal(parseSemver(''), null);
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
test('launcher preflight evaluates probes and blocks incompatible pairs', () => {
|
|
145
|
-
const blocked = evaluateLauncherVersionCompatibility({
|
|
146
|
-
cli: { path: 'cli', versionText: '2.1.220' },
|
|
147
|
-
desktop: { path: 'desktop', versionText: '1.0.0' },
|
|
148
|
-
});
|
|
149
|
-
assert.equal(blocked.class, 'major-drift');
|
|
150
|
-
assert.equal(blocked.action, 'block');
|
|
151
|
-
assert.equal(shouldBlockLaunch(blocked), true);
|
|
152
|
-
const message = formatCompatibilityPreflightFailure(blocked);
|
|
153
|
-
assert.match(message, /version compatibility/i);
|
|
154
|
-
assert.match(message, /major drift/i);
|
|
155
|
-
|
|
156
|
-
const allowed = evaluateLauncherVersionCompatibility({
|
|
157
|
-
cli: { path: 'cli', versionText: '2.1.220' },
|
|
158
|
-
desktop: { path: 'desktop', versionText: '2.1.220' },
|
|
159
|
-
});
|
|
160
|
-
assert.equal(allowed.action, 'pass');
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
test('live CLI version diagnostic records a real probe when CLAUDE_CLI_PATH is set', () => {
|
|
164
|
-
const cliPath = process.env.CLAUDE_CLI_PATH;
|
|
165
|
-
if (!cliPath || !existsSync(cliPath)) {
|
|
166
|
-
// Policy matrix above is the unit evidence. Live readback is optional and
|
|
167
|
-
// activated by CLAUDE_CLI_PATH so the suite stays free of host-private paths.
|
|
168
|
-
const missing = classifyVersionCompatibility({
|
|
169
|
-
cli: { path: null, versionText: null, errorCode: 'missing' },
|
|
170
|
-
desktop: { path: null, versionText: null, errorCode: 'missing' },
|
|
171
|
-
});
|
|
172
|
-
assert.equal(missing.class, 'missing-binary');
|
|
173
|
-
return;
|
|
174
|
-
}
|
|
175
|
-
const probe = spawnSync(cliPath, ['--version'], {
|
|
176
|
-
encoding: 'utf8',
|
|
177
|
-
timeout: 15_000,
|
|
178
|
-
});
|
|
179
|
-
const versionText = `${probe.stdout || ''}${probe.stderr || ''}`.trim();
|
|
180
|
-
assert.equal(probe.error, undefined);
|
|
181
|
-
assert.ok(versionText.length > 0, 'claude --version produced empty output');
|
|
182
|
-
const parsed = parseSemver(versionText);
|
|
183
|
-
assert.ok(parsed, `expected semver in: ${versionText}`);
|
|
184
|
-
|
|
185
|
-
// Self-pair: equal pass against the same live CLI text (desktop diagnostic-only).
|
|
186
|
-
const result = classifyVersionCompatibility({
|
|
187
|
-
cli: { path: cliPath, versionText },
|
|
188
|
-
desktop: { path: cliPath, versionText },
|
|
189
|
-
});
|
|
190
|
-
assert.equal(result.class, 'equal');
|
|
191
|
-
assert.equal(result.action, 'pass');
|
|
192
|
-
assert.equal(result.policyVersion, COMPATIBILITY_POLICY_VERSION);
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
test('package ships version-compatibility module and test paths', () => {
|
|
196
|
-
const allPaths = [
|
|
197
|
-
join(PACKAGE_ROOT, 'scripts/profile-isolation-launchers/version-compatibility.mjs'),
|
|
198
|
-
join(PACKAGE_ROOT, 'scripts/profile-isolation-launchers/tests/version-compatibility.test.mjs'),
|
|
199
|
-
join(PACKAGE_ROOT, 'scripts/profile-isolation-launchers/launcher-runtime.mjs'),
|
|
200
|
-
];
|
|
201
|
-
for (const eachPath of allPaths) {
|
|
202
|
-
assert.ok(existsSync(eachPath), `missing ${eachPath}`);
|
|
203
|
-
}
|
|
204
|
-
const runtimeSource = readFileSync(
|
|
205
|
-
join(PACKAGE_ROOT, 'scripts/profile-isolation-launchers/launcher-runtime.mjs'),
|
|
206
|
-
'utf8',
|
|
207
|
-
);
|
|
208
|
-
assert.match(runtimeSource, /version-compatibility\.mjs/);
|
|
209
|
-
assert.match(runtimeSource, /evaluateLauncherVersionCompatibility/);
|
|
210
|
-
});
|