mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-08 07:37:48 +02:00
fix(install): harden ECC installer lifecycle
Make Antigravity 2.0 installs native and safely migrate legacy state. Ensure doctor, repair, status projection, repeat installs, legacy Codex sync, and uninstall converge without losing user files. Exclude Python bytecode and harden repo-scan bootstrap guidance. Gate publishing and pull-request merges on one exact packed artifact completing install, repeat, drift, repair, status, and uninstall across Linux, macOS, and Windows. Co-authored-by: lorencifernando-coder <lorenci.fernando@gmail.com> Co-authored-by: Suliman Abdulrazzaq <suliman9000a@gmail.com> Co-authored-by: Wu Shuwen <mikewushuwen@outlook.com>
This commit is contained in:
co-authored by
lorencifernando-coder
Suliman Abdulrazzaq
Wu Shuwen
parent
eb49702651
commit
1db5c8ab4a
@@ -0,0 +1,448 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { pathToFileURL } = require('url');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const PACKAGE_NAME = 'ecc-universal';
|
||||
const HASH_PATTERN = /^[a-f0-9]{64}$/i;
|
||||
const PACKAGE_PATH_PATTERN = /^release-artifacts\/ecc-universal-[0-9A-Za-z.+-]+\.tgz$/;
|
||||
|
||||
function parseEnvironment(environment = process.env, cwd = process.cwd()) {
|
||||
const packageValue = environment.ECC_RELEASE_PACKAGE;
|
||||
const hashValue = environment.ECC_RELEASE_SHA256;
|
||||
|
||||
if (!packageValue) {
|
||||
throw new Error('ECC_RELEASE_PACKAGE must name the downloaded release .tgz');
|
||||
}
|
||||
if (!PACKAGE_PATH_PATTERN.test(String(packageValue))) {
|
||||
throw new Error('ECC_RELEASE_PACKAGE must name one ECC .tgz under release-artifacts');
|
||||
}
|
||||
if (!HASH_PATTERN.test(hashValue || '')) {
|
||||
throw new Error('ECC_RELEASE_SHA256 must be a 64-character SHA-256 digest');
|
||||
}
|
||||
|
||||
return {
|
||||
packagePath: path.resolve(cwd, packageValue),
|
||||
expectedSha256: hashValue.toLowerCase(),
|
||||
};
|
||||
}
|
||||
|
||||
function assertDownloadedArtifact(packagePath, cwd) {
|
||||
const artifactRoot = path.resolve(cwd, 'release-artifacts');
|
||||
const packageStat = fs.lstatSync(packagePath);
|
||||
if (!packageStat.isFile() || packageStat.isSymbolicLink()) {
|
||||
throw new Error('Release package must be a regular, non-symlink file');
|
||||
}
|
||||
|
||||
const realArtifactRoot = fs.realpathSync(artifactRoot);
|
||||
const realPackagePath = fs.realpathSync(packagePath);
|
||||
const relativePath = path.relative(realArtifactRoot, realPackagePath);
|
||||
if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
|
||||
throw new Error('Release package escapes release-artifacts');
|
||||
}
|
||||
|
||||
const archives = fs.readdirSync(realArtifactRoot).filter(name => name.endsWith('.tgz'));
|
||||
if (archives.length !== 1 || archives[0] !== path.basename(realPackagePath)) {
|
||||
throw new Error('Expected exactly one downloaded release archive');
|
||||
}
|
||||
}
|
||||
|
||||
function hashFile(filePath) {
|
||||
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
|
||||
}
|
||||
|
||||
function assertHash(actualSha256, expectedSha256) {
|
||||
if (actualSha256 !== expectedSha256) {
|
||||
throw new Error(
|
||||
`Downloaded artifact SHA-256 ${actualSha256} does not match packed artifact ${expectedSha256}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function createLifecycleEnvironment(baseEnvironment, homeDir) {
|
||||
const environment = {};
|
||||
const inheritedNames = [
|
||||
'CI',
|
||||
'ComSpec',
|
||||
'LANG',
|
||||
'LC_ALL',
|
||||
'NO_COLOR',
|
||||
'PATH',
|
||||
'Path',
|
||||
'PATHEXT',
|
||||
'SystemRoot',
|
||||
'TEMP',
|
||||
'TMP',
|
||||
'TMPDIR',
|
||||
'WINDIR',
|
||||
];
|
||||
|
||||
for (const name of inheritedNames) {
|
||||
if (baseEnvironment[name] !== undefined) {
|
||||
environment[name] = baseEnvironment[name];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...environment,
|
||||
HOME: homeDir,
|
||||
USERPROFILE: homeDir,
|
||||
APPDATA: path.join(homeDir, 'AppData', 'Roaming'),
|
||||
LOCALAPPDATA: path.join(homeDir, 'AppData', 'Local'),
|
||||
XDG_CONFIG_HOME: path.join(homeDir, '.config'),
|
||||
XDG_DATA_HOME: path.join(homeDir, '.local', 'share'),
|
||||
NPM_CONFIG_CACHE: path.join(homeDir, '.npm'),
|
||||
NPM_CONFIG_USERCONFIG: path.join(homeDir, '.npmrc'),
|
||||
};
|
||||
}
|
||||
|
||||
function runProcess(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
const expectedStatus = options.expectedStatus ?? 0;
|
||||
if (result.status !== expectedStatus) {
|
||||
throw new Error([
|
||||
`${options.label || command} exited ${result.status}, expected ${expectedStatus}.`,
|
||||
result.stdout ? `stdout:\n${result.stdout}` : '',
|
||||
result.stderr ? `stderr:\n${result.stderr}` : '',
|
||||
].filter(Boolean).join('\n'));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function getNpmExecInvocation(publicArgs, environment, platform = process.platform) {
|
||||
const npmArgs = ['exec', '--offline', '--yes=false', '--', ...publicArgs];
|
||||
if (platform !== 'win32') {
|
||||
return { command: 'npm', args: npmArgs };
|
||||
}
|
||||
|
||||
const commandParts = ['npm', ...npmArgs];
|
||||
for (const part of commandParts) {
|
||||
if (!/^[A-Za-z0-9_.=+/-]+$/.test(part)) {
|
||||
throw new Error(`Unsafe npm exec argument for Windows lifecycle: ${part}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
command: environment.ComSpec || 'cmd.exe',
|
||||
args: ['/d', '/s', '/c', commandParts.join(' ')],
|
||||
};
|
||||
}
|
||||
|
||||
function installPackage(projectDir, packagePath, environment) {
|
||||
const projectManifest = {
|
||||
name: 'ecc-packed-artifact-lifecycle',
|
||||
version: '1.0.0',
|
||||
private: true,
|
||||
dependencies: {
|
||||
[PACKAGE_NAME]: pathToFileURL(packagePath).href,
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(projectDir, 'package.json'),
|
||||
`${JSON.stringify(projectManifest, null, 2)}\n`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
runProcess(
|
||||
environment.ComSpec || 'cmd.exe',
|
||||
['/d', '/s', '/c', 'npm install --no-audit --no-fund'],
|
||||
{ cwd: projectDir, env: environment, label: 'npm install packed artifact' }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
runProcess('npm', ['install', '--no-audit', '--no-fund'], {
|
||||
cwd: projectDir,
|
||||
env: environment,
|
||||
label: 'npm install packed artifact',
|
||||
});
|
||||
}
|
||||
|
||||
function parseJsonOutput(result, label) {
|
||||
try {
|
||||
return JSON.parse(result.stdout);
|
||||
} catch (error) {
|
||||
throw new Error(`${label} did not emit valid JSON: ${error.message}\n${result.stdout}`);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveManagedExistingPath(destinationPath, cursorRoot) {
|
||||
const normalizedRoot = fs.realpathSync(cursorRoot);
|
||||
const lexicalPath = path.resolve(destinationPath);
|
||||
const lexicalRelativePath = path.relative(normalizedRoot, lexicalPath);
|
||||
if (
|
||||
lexicalRelativePath === ''
|
||||
|| lexicalRelativePath.startsWith('..')
|
||||
|| path.isAbsolute(lexicalRelativePath)
|
||||
|| !fs.existsSync(lexicalPath)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pathStat = fs.lstatSync(lexicalPath);
|
||||
if (pathStat.isSymbolicLink()) {
|
||||
throw new Error(`Managed lifecycle path must not be a symlink: ${lexicalPath}`);
|
||||
}
|
||||
|
||||
const realPath = fs.realpathSync(lexicalPath);
|
||||
const realRelativePath = path.relative(normalizedRoot, realPath);
|
||||
if (realRelativePath.startsWith('..') || path.isAbsolute(realRelativePath)) {
|
||||
throw new Error(`Managed lifecycle path escapes Cursor root: ${lexicalPath}`);
|
||||
}
|
||||
|
||||
return { path: realPath, stat: pathStat };
|
||||
}
|
||||
|
||||
function getManagedOperationSnapshot(state, cursorRoot) {
|
||||
const snapshot = [];
|
||||
for (const operation of state.operations) {
|
||||
if (operation.ownership !== 'managed' || typeof operation.destinationPath !== 'string') {
|
||||
continue;
|
||||
}
|
||||
const resolved = resolveManagedExistingPath(operation.destinationPath, cursorRoot);
|
||||
if (resolved) {
|
||||
snapshot.push({ path: resolved.path, isFile: resolved.stat.isFile() });
|
||||
}
|
||||
}
|
||||
return [...new Map(snapshot.map(entry => [entry.path, entry])).values()]
|
||||
.sort((left, right) => left.path.localeCompare(right.path));
|
||||
}
|
||||
|
||||
function getOperationLedger(state) {
|
||||
return state.operations.map(operation => ({
|
||||
kind: operation.kind,
|
||||
moduleId: operation.moduleId,
|
||||
sourceRelativePath: operation.sourceRelativePath || null,
|
||||
destinationPath: operation.destinationPath,
|
||||
strategy: operation.strategy,
|
||||
ownership: operation.ownership,
|
||||
contentSha256: operation.contentSha256 || null,
|
||||
}));
|
||||
}
|
||||
|
||||
function findDriftCandidate(state, cursorRoot) {
|
||||
const operation = state.operations.find(candidate => {
|
||||
if (candidate.kind !== 'copy-file' || typeof candidate.destinationPath !== 'string') {
|
||||
return false;
|
||||
}
|
||||
const resolved = resolveManagedExistingPath(candidate.destinationPath, cursorRoot);
|
||||
return resolved && resolved.stat.isFile();
|
||||
});
|
||||
|
||||
assert.ok(operation, 'installed state must contain a managed Cursor file that can be drifted');
|
||||
return resolveManagedExistingPath(operation.destinationPath, cursorRoot).path;
|
||||
}
|
||||
|
||||
function runLifecycle(options) {
|
||||
assert.ok(fs.existsSync(options.packagePath), `release package does not exist: ${options.packagePath}`);
|
||||
assertDownloadedArtifact(options.packagePath, process.cwd());
|
||||
assertHash(hashFile(options.packagePath), options.expectedSha256);
|
||||
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-packed-lifecycle-'));
|
||||
const homeDir = path.join(tempRoot, 'home');
|
||||
const projectDir = path.join(tempRoot, 'project');
|
||||
fs.mkdirSync(homeDir, { recursive: true });
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
|
||||
const environment = createLifecycleEnvironment(process.env, homeDir);
|
||||
|
||||
try {
|
||||
installPackage(projectDir, options.packagePath, environment);
|
||||
|
||||
const cursorRoot = path.join(projectDir, '.cursor');
|
||||
const statePath = path.join(cursorRoot, 'ecc-install-state.json');
|
||||
const sentinelPath = path.join(cursorRoot, 'user-sentinel.txt');
|
||||
fs.mkdirSync(cursorRoot, { recursive: true });
|
||||
fs.writeFileSync(sentinelPath, 'keep this user file\n', 'utf8');
|
||||
|
||||
const runPublicCli = (publicArgs, commandOptions = {}) => {
|
||||
const invocation = getNpmExecInvocation(publicArgs, environment);
|
||||
return runProcess(invocation.command, invocation.args, {
|
||||
cwd: projectDir,
|
||||
env: environment,
|
||||
label: `npm exec -- ${publicArgs.join(' ')}`,
|
||||
...commandOptions,
|
||||
});
|
||||
};
|
||||
const runCli = (args, commandOptions = {}) => runPublicCli(
|
||||
['ecc', ...args],
|
||||
commandOptions
|
||||
);
|
||||
|
||||
const setupHelp = runPublicCli(['ecc-universal', 'setup', '--help']);
|
||||
assert.match(setupHelp.stdout, /ECC guided setup/);
|
||||
assert.match(setupHelp.stdout, /ecc setup --mode claude-plugin/);
|
||||
|
||||
parseJsonOutput(
|
||||
runCli(['install', '--profile', 'core', '--target', 'cursor', '--json']),
|
||||
'initial install'
|
||||
);
|
||||
assert.ok(fs.existsSync(statePath), 'initial install must write Cursor install-state');
|
||||
const initialState = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
||||
const initialLedger = getOperationLedger(initialState);
|
||||
const managedSnapshot = getManagedOperationSnapshot(initialState, cursorRoot);
|
||||
assert.ok(managedSnapshot.length > 0, 'initial install must create managed Cursor files');
|
||||
|
||||
parseJsonOutput(
|
||||
runCli(['install', '--profile', 'core', '--target', 'cursor', '--json']),
|
||||
'repeat install'
|
||||
);
|
||||
const repeatState = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
||||
assert.deepStrictEqual(
|
||||
getOperationLedger(repeatState),
|
||||
initialLedger,
|
||||
'repeat install must preserve the complete ownership ledger'
|
||||
);
|
||||
for (const entry of managedSnapshot) {
|
||||
assert.ok(fs.existsSync(entry.path), `repeat install lost managed path: ${entry.path}`);
|
||||
}
|
||||
assert.strictEqual(
|
||||
fs.readFileSync(sentinelPath, 'utf8'),
|
||||
'keep this user file\n',
|
||||
'repeat install must preserve user-owned files'
|
||||
);
|
||||
|
||||
const statusAfterInstall = parseJsonOutput(
|
||||
runCli(['status', '--json']),
|
||||
'status after install'
|
||||
);
|
||||
assert.strictEqual(statusAfterInstall.installHealth.status, 'healthy');
|
||||
assert.strictEqual(statusAfterInstall.installHealth.totalCount, 1);
|
||||
assert.strictEqual(statusAfterInstall.installStateProjection.status, 'ok');
|
||||
assert.strictEqual(statusAfterInstall.installStateProjection.warningCount, 0);
|
||||
assert.strictEqual(statusAfterInstall.readiness.status, 'ok');
|
||||
|
||||
const healthyBeforeDrift = parseJsonOutput(
|
||||
runCli(['doctor', '--target', 'cursor', '--json']),
|
||||
'doctor before drift'
|
||||
);
|
||||
assert.strictEqual(healthyBeforeDrift.summary.errorCount, 0);
|
||||
assert.strictEqual(healthyBeforeDrift.summary.warningCount, 0);
|
||||
|
||||
const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
||||
const driftPath = findDriftCandidate(state, cursorRoot);
|
||||
fs.appendFileSync(driftPath, '\nECC_PACKED_LIFECYCLE_DRIFT\n', 'utf8');
|
||||
|
||||
const driftedDoctor = parseJsonOutput(
|
||||
runCli(['doctor', '--target', 'cursor', '--json'], { expectedStatus: 1 }),
|
||||
'doctor after drift'
|
||||
);
|
||||
assert.ok(
|
||||
driftedDoctor.summary.errorCount + driftedDoctor.summary.warningCount > 0,
|
||||
'doctor must detect induced managed-file drift'
|
||||
);
|
||||
|
||||
const repair = parseJsonOutput(
|
||||
runCli(['repair', '--target', 'cursor', '--json']),
|
||||
'repair'
|
||||
);
|
||||
assert.ok(repair.summary.repairedCount > 0, 'repair must restore the drifted managed file');
|
||||
|
||||
const healthyAfterRepair = parseJsonOutput(
|
||||
runCli(['doctor', '--target', 'cursor', '--json']),
|
||||
'doctor after repair'
|
||||
);
|
||||
assert.strictEqual(healthyAfterRepair.summary.errorCount, 0);
|
||||
assert.strictEqual(healthyAfterRepair.summary.warningCount, 0);
|
||||
|
||||
const statusAfterRepair = parseJsonOutput(
|
||||
runCli(['status', '--json']),
|
||||
'status after repair'
|
||||
);
|
||||
assert.strictEqual(statusAfterRepair.installHealth.status, 'healthy');
|
||||
assert.strictEqual(statusAfterRepair.installHealth.totalCount, 1);
|
||||
assert.strictEqual(statusAfterRepair.installStateProjection.status, 'ok');
|
||||
assert.strictEqual(statusAfterRepair.installStateProjection.warningCount, 0);
|
||||
assert.strictEqual(statusAfterRepair.readiness.status, 'ok');
|
||||
|
||||
parseJsonOutput(
|
||||
runCli(['uninstall', '--target', 'cursor', '--json']),
|
||||
'uninstall'
|
||||
);
|
||||
assert.ok(!fs.existsSync(statePath), 'uninstall must remove Cursor install-state');
|
||||
for (const entry of managedSnapshot) {
|
||||
assert.ok(!fs.existsSync(entry.path), `uninstall left managed path behind: ${entry.path}`);
|
||||
}
|
||||
assert.strictEqual(
|
||||
fs.readFileSync(sentinelPath, 'utf8'),
|
||||
'keep this user file\n',
|
||||
'uninstall must preserve user-owned files'
|
||||
);
|
||||
|
||||
const statusAfterUninstall = parseJsonOutput(
|
||||
runCli(['status', '--json']),
|
||||
'status after uninstall'
|
||||
);
|
||||
assert.strictEqual(statusAfterUninstall.installHealth.status, 'missing');
|
||||
assert.strictEqual(statusAfterUninstall.installHealth.totalCount, 0);
|
||||
assert.strictEqual(statusAfterUninstall.installStateProjection.status, 'ok');
|
||||
assert.strictEqual(statusAfterUninstall.installStateProjection.warningCount, 0);
|
||||
assert.strictEqual(statusAfterUninstall.readiness.status, 'ok');
|
||||
|
||||
return {
|
||||
packageSha256: options.expectedSha256,
|
||||
platform: process.platform,
|
||||
node: process.version,
|
||||
lifecycle: [
|
||||
'npm-install',
|
||||
'public-ecc-universal-setup',
|
||||
'cursor-install',
|
||||
'cursor-repeat-install',
|
||||
'doctor-clean',
|
||||
'status-installed',
|
||||
'doctor-drift',
|
||||
'repair',
|
||||
'doctor-repaired',
|
||||
'status-repaired',
|
||||
'uninstall',
|
||||
'status-uninstalled',
|
||||
'sentinel-preserved',
|
||||
],
|
||||
};
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
try {
|
||||
const report = runLifecycle(parseEnvironment());
|
||||
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
||||
} catch (error) {
|
||||
process.stderr.write(`Packed-artifact lifecycle failed: ${error.message}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
assertDownloadedArtifact,
|
||||
assertHash,
|
||||
createLifecycleEnvironment,
|
||||
getNpmExecInvocation,
|
||||
hashFile,
|
||||
parseEnvironment,
|
||||
runLifecycle,
|
||||
};
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const lifecycle = require('./packed-artifact-lifecycle');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
passed += 1;
|
||||
} catch (error) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n=== Testing packed-artifact lifecycle runner ===\n');
|
||||
|
||||
test('resolves package and hash from explicit environment variables', () => {
|
||||
const options = lifecycle.parseEnvironment({
|
||||
ECC_RELEASE_PACKAGE: 'release-artifacts/ecc-universal-2.2.0.tgz',
|
||||
ECC_RELEASE_SHA256: 'a'.repeat(64),
|
||||
}, '/workspace');
|
||||
|
||||
assert.strictEqual(
|
||||
options.packagePath,
|
||||
path.resolve('/workspace', 'release-artifacts/ecc-universal-2.2.0.tgz')
|
||||
);
|
||||
assert.strictEqual(options.expectedSha256, 'a'.repeat(64));
|
||||
});
|
||||
|
||||
test('rejects missing, malformed, and non-tgz release inputs', () => {
|
||||
assert.throws(() => lifecycle.parseEnvironment({}, '/workspace'), /ECC_RELEASE_PACKAGE/);
|
||||
assert.throws(() => lifecycle.parseEnvironment({
|
||||
ECC_RELEASE_PACKAGE: 'package.zip',
|
||||
ECC_RELEASE_SHA256: 'a'.repeat(64),
|
||||
}, '/workspace'), /\.tgz/);
|
||||
assert.throws(() => lifecycle.parseEnvironment({
|
||||
ECC_RELEASE_PACKAGE: 'release-artifacts/ecc-universal-2.2.0.tgz',
|
||||
ECC_RELEASE_SHA256: 'not-a-hash',
|
||||
}, '/workspace'), /SHA-256/);
|
||||
assert.throws(() => lifecycle.parseEnvironment({
|
||||
ECC_RELEASE_PACKAGE: '../release-artifacts/ecc-universal-2.2.0.tgz',
|
||||
ECC_RELEASE_SHA256: 'a'.repeat(64),
|
||||
}, '/workspace'), /release-artifacts/);
|
||||
assert.throws(() => lifecycle.parseEnvironment({
|
||||
ECC_RELEASE_PACKAGE: '/tmp/ecc-universal-2.2.0.tgz',
|
||||
ECC_RELEASE_SHA256: 'a'.repeat(64),
|
||||
}, '/workspace'), /release-artifacts/);
|
||||
});
|
||||
|
||||
test('hashFile computes a lowercase SHA-256 digest', () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-packed-hash-'));
|
||||
const filePath = path.join(tempDir, 'package.tgz');
|
||||
|
||||
try {
|
||||
fs.writeFileSync(filePath, 'exact packed bytes');
|
||||
const expected = crypto.createHash('sha256').update('exact packed bytes').digest('hex');
|
||||
assert.strictEqual(lifecycle.hashFile(filePath), expected);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('assertHash rejects an artifact whose bytes do not match', () => {
|
||||
assert.throws(
|
||||
() => lifecycle.assertHash('a'.repeat(64), 'b'.repeat(64)),
|
||||
/does not match/
|
||||
);
|
||||
});
|
||||
|
||||
test('lifecycle child processes receive no inherited credentials', () => {
|
||||
const environment = lifecycle.createLifecycleEnvironment({
|
||||
PATH: '/tools',
|
||||
GITHUB_TOKEN: 'github-secret',
|
||||
NODE_AUTH_TOKEN: 'npm-secret',
|
||||
ACTIONS_RUNTIME_TOKEN: 'actions-secret',
|
||||
AWS_SECRET_ACCESS_KEY: 'cloud-secret',
|
||||
}, '/isolated-home');
|
||||
|
||||
assert.strictEqual(environment.PATH, '/tools');
|
||||
assert.strictEqual(environment.HOME, '/isolated-home');
|
||||
assert.strictEqual(environment.USERPROFILE, '/isolated-home');
|
||||
assert.strictEqual(environment.GITHUB_TOKEN, undefined);
|
||||
assert.strictEqual(environment.NODE_AUTH_TOKEN, undefined);
|
||||
assert.strictEqual(environment.ACTIONS_RUNTIME_TOKEN, undefined);
|
||||
assert.strictEqual(environment.AWS_SECRET_ACCESS_KEY, undefined);
|
||||
});
|
||||
|
||||
test('public CLI invocations use npm exec instead of internal package paths', () => {
|
||||
const invocation = lifecycle.getNpmExecInvocation(
|
||||
['ecc-universal', 'setup', '--help'],
|
||||
{ ComSpec: 'C:\\Windows\\System32\\cmd.exe' },
|
||||
'win32'
|
||||
);
|
||||
|
||||
assert.strictEqual(invocation.command, 'C:\\Windows\\System32\\cmd.exe');
|
||||
assert.deepStrictEqual(invocation.args, [
|
||||
'/d',
|
||||
'/s',
|
||||
'/c',
|
||||
'npm exec --offline --yes=false -- ecc-universal setup --help',
|
||||
]);
|
||||
|
||||
const unixInvocation = lifecycle.getNpmExecInvocation(
|
||||
['ecc', 'doctor', '--target', 'cursor', '--json'],
|
||||
{},
|
||||
'linux'
|
||||
);
|
||||
assert.strictEqual(unixInvocation.command, 'npm');
|
||||
assert.deepStrictEqual(
|
||||
unixInvocation.args.slice(0, 4),
|
||||
['exec', '--offline', '--yes=false', '--']
|
||||
);
|
||||
assert.strictEqual(unixInvocation.args[4], 'ecc');
|
||||
assert.ok(!unixInvocation.args.some(argument => argument.includes('node_modules')));
|
||||
});
|
||||
|
||||
console.log(`\nPassed: ${passed}`);
|
||||
console.log(`Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,167 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..', '..');
|
||||
const workflowPaths = [
|
||||
'.github/workflows/release.yml',
|
||||
'.github/workflows/reusable-release.yml',
|
||||
];
|
||||
const lifecycleRunnerSource = load('tests/ci/packed-artifact-lifecycle.js');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
passed += 1;
|
||||
} catch (error) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function load(relativePath) {
|
||||
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8').replace(/\r\n/g, '\n');
|
||||
}
|
||||
|
||||
function jobBlock(source, jobName, nextJobName) {
|
||||
const startMarker = `\n ${jobName}:\n`;
|
||||
const start = source.indexOf(startMarker);
|
||||
assert.ok(start >= 0, `missing ${jobName} job`);
|
||||
|
||||
if (!nextJobName) {
|
||||
return source.slice(start);
|
||||
}
|
||||
|
||||
const end = source.indexOf(`\n ${nextJobName}:\n`, start + startMarker.length);
|
||||
assert.ok(end > start, `missing ${nextJobName} job after ${jobName}`);
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
console.log('\n=== Testing packed-artifact release workflows ===\n');
|
||||
|
||||
for (const workflowPath of workflowPaths) {
|
||||
const source = load(workflowPath);
|
||||
|
||||
test(`${workflowPath} packs once and exports the package name and SHA-256`, () => {
|
||||
assert.strictEqual(
|
||||
(source.match(/npm pack --json/g) || []).length,
|
||||
1,
|
||||
'release workflow must pack exactly once'
|
||||
);
|
||||
assert.match(source, /package_sha256:\s*\$\{\{ steps\.pack\.outputs\.package_sha256 \}\}/);
|
||||
assert.match(source, /createHash\(['"]sha256['"]\)/);
|
||||
assert.match(source, /package_sha256=['"]? \+ digest/);
|
||||
assert.match(source, /release_commit:\s*\$\{\{ steps\.source\.outputs\.release_commit \}\}/);
|
||||
assert.match(source, /release_commit=\$\{RELEASE_COMMIT\}/);
|
||||
});
|
||||
|
||||
test(`${workflowPath} invokes only test files present in the release source`, () => {
|
||||
const referencedTests = [...source.matchAll(/\bnode (tests\/[A-Za-z0-9_./-]+\.js)\b/g)]
|
||||
.map(match => match[1]);
|
||||
assert.ok(referencedTests.length > 0, 'release workflow should run repository tests');
|
||||
for (const testPath of referencedTests) {
|
||||
assert.ok(fs.existsSync(path.join(repoRoot, testPath)), `missing workflow test: ${testPath}`);
|
||||
}
|
||||
});
|
||||
|
||||
test(`${workflowPath} uploads the one packed tgz as the release artifact`, () => {
|
||||
const verify = jobBlock(source, 'verify', 'lifecycle');
|
||||
const packIndex = verify.indexOf('name: Pack npm artifact');
|
||||
const uploadIndex = verify.indexOf('name: Upload release artifacts');
|
||||
|
||||
assert.ok(packIndex >= 0, 'missing pack step');
|
||||
assert.ok(uploadIndex > packIndex, 'artifact upload must happen after pack and hash');
|
||||
assert.match(verify, /name:\s*ecc-release-artifacts/);
|
||||
assert.match(verify, /\$\{\{ steps\.pack\.outputs\.package_file \}\}/);
|
||||
});
|
||||
|
||||
test(`${workflowPath} fails retries when npm already has different bytes`, () => {
|
||||
const verify = jobBlock(source, 'verify', 'lifecycle');
|
||||
assert.match(verify, /name:\s*Verify existing npm artifact matches candidate/);
|
||||
assert.match(verify, /if:\s*steps\.npm_publish_state\.outputs\.already_published == 'true'/);
|
||||
assert.match(verify, /npm view "\$\{PACKAGE_NAME\}@\$\{PACKAGE_VERSION\}" dist\.integrity/);
|
||||
assert.match(verify, /createHash\(['"]sha512['"]\)/);
|
||||
assert.match(verify, /Existing npm artifact does not match tested candidate/);
|
||||
});
|
||||
|
||||
test(`${workflowPath} verifies the same tgz on Node 20 across three operating systems`, () => {
|
||||
const lifecycle = jobBlock(source, 'lifecycle', 'publish');
|
||||
|
||||
assert.match(lifecycle, /needs:\s*verify/);
|
||||
assert.match(lifecycle, /os:\s*\[ubuntu-latest, macos-latest, windows-latest\]/);
|
||||
assert.match(lifecycle, /runs-on:\s*\$\{\{ matrix\.os \}\}/);
|
||||
assert.match(lifecycle, /node-version:\s*['"]20\.x['"]/);
|
||||
assert.match(lifecycle, /uses:\s*actions\/download-artifact@/);
|
||||
assert.match(lifecycle, /name:\s*ecc-release-artifacts/);
|
||||
assert.match(lifecycle, /ECC_RELEASE_PACKAGE:\s*release-artifacts\/\$\{\{ needs\.verify\.outputs\.package_file \}\}/);
|
||||
assert.match(lifecycle, /ECC_RELEASE_SHA256:\s*\$\{\{ needs\.verify\.outputs\.package_sha256 \}\}/);
|
||||
assert.match(lifecycle, /node tests\/ci\/packed-artifact-lifecycle\.js/);
|
||||
assert.match(lifecycle, /ref:\s*\$\{\{ needs\.verify\.outputs\.release_commit \}\}/);
|
||||
assert.doesNotMatch(lifecycle, /\bsecrets\s*:/, 'lifecycle job must not receive secrets');
|
||||
assert.doesNotMatch(lifecycle, /\$\{\{\s*secrets\./, 'lifecycle job must not reference secrets');
|
||||
});
|
||||
|
||||
test(`${workflowPath} blocks publishing on packed-artifact lifecycle success`, () => {
|
||||
const publish = jobBlock(source, 'publish');
|
||||
|
||||
assert.match(publish, /needs:\s*\[verify, lifecycle\]/);
|
||||
assert.match(publish, /ECC_RELEASE_PACKAGE:\s*\$\{\{ needs\.verify\.outputs\.package_file \}\}/);
|
||||
assert.match(publish, /npm publish "\.\/\$\{ECC_RELEASE_PACKAGE\}"/);
|
||||
assert.match(publish, /name:\s*Verify artifact before publish/);
|
||||
assert.match(publish, /ECC_RELEASE_SHA256:\s*\$\{\{ needs\.verify\.outputs\.package_sha256 \}\}/);
|
||||
assert.match(publish, /createHash\(['"]sha256['"]\)/);
|
||||
assert.match(publish, /ecc-universal-\[0-9A-Za-z\.\+-\]/);
|
||||
assert.ok(
|
||||
publish.indexOf('name: Verify artifact before publish')
|
||||
< publish.indexOf('name: Create GitHub Release'),
|
||||
'publish must verify the independently downloaded archive before creating the release'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('reusable release requires its input to resolve through the tag namespace', () => {
|
||||
const source = load('.github/workflows/reusable-release.yml');
|
||||
const verify = jobBlock(source, 'verify', 'lifecycle');
|
||||
assert.match(verify, /ref:\s*refs\/tags\/\$\{\{ inputs\.tag \}\}/);
|
||||
});
|
||||
|
||||
test('pull-request CI packs once and exports the exact installer artifact identity', () => {
|
||||
const source = load('.github/workflows/ci.yml');
|
||||
const pack = jobBlock(source, 'pack-installer', 'packed-install-lifecycle');
|
||||
assert.strictEqual((pack.match(/npm pack --json/g) || []).length, 1);
|
||||
assert.match(pack, /package_file:\s*\$\{\{ steps\.pack\.outputs\.package_file \}\}/);
|
||||
assert.match(pack, /package_sha256:\s*\$\{\{ steps\.pack\.outputs\.package_sha256 \}\}/);
|
||||
assert.match(pack, /createHash\(['"]sha256['"]\)/);
|
||||
assert.match(pack, /name:\s*ecc-ci-installer-artifact/);
|
||||
});
|
||||
|
||||
test('pull-request CI runs the same packed installer on Linux, macOS, and Windows', () => {
|
||||
const source = load('.github/workflows/ci.yml');
|
||||
const lifecycle = jobBlock(source, 'packed-install-lifecycle', 'validate');
|
||||
assert.match(lifecycle, /needs:\s*pack-installer/);
|
||||
assert.match(lifecycle, /os:\s*\[ubuntu-latest, macos-latest, windows-latest\]/);
|
||||
assert.match(lifecycle, /node-version:\s*['"]20\.x['"]/);
|
||||
assert.match(lifecycle, /name:\s*ecc-ci-installer-artifact/);
|
||||
assert.match(lifecycle, /ECC_RELEASE_PACKAGE:\s*release-artifacts\/\$\{\{ needs\.pack-installer\.outputs\.package_file \}\}/);
|
||||
assert.match(lifecycle, /ECC_RELEASE_SHA256:\s*\$\{\{ needs\.pack-installer\.outputs\.package_sha256 \}\}/);
|
||||
assert.match(lifecycle, /node tests\/ci\/packed-artifact-lifecycle\.js/);
|
||||
assert.doesNotMatch(lifecycle, /\$\{\{\s*secrets\./);
|
||||
});
|
||||
|
||||
test('packed lifecycle invokes installed public bins, including setup help', () => {
|
||||
assert.match(lifecycleRunnerSource, /getNpmExecInvocation/);
|
||||
assert.match(lifecycleRunnerSource, /\['ecc-universal', 'setup', '--help'\]/);
|
||||
assert.match(lifecycleRunnerSource, /\['ecc', \.\.\.args\]/);
|
||||
assert.doesNotMatch(lifecycleRunnerSource, /node_modules.*scripts.*ecc\.js/);
|
||||
});
|
||||
|
||||
console.log(`\nPassed: ${passed}`);
|
||||
console.log(`Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
Reference in New Issue
Block a user