mirror of
https://github.com/affaan-m/ECC.git
synced 2026-08-28 18:59:47 +02:00
fix(nasiko): harden lifecycle recovery
This commit is contained in:
@@ -23,6 +23,7 @@
|
||||
- `ecc memory` writes and `--body-file` reads failed on Windows under Node 22.12-22.16 and 24.0-24.1. libuv resolved path-based `stat()`/`lstat()` through `GetFileInformationByName` without setting the volume serial, while `fstat()` reported it, so the memory vault's TOCTOU guard rejected every operation. Fixed upstream in libuv 1.51.0; the guard no longer depends on the runtime's patch level. The guard's stat calls now request `BigInt` values, so Windows file IDs past `Number.MAX_SAFE_INTEGER` can no longer collapse two distinct files into one identity.
|
||||
- Selective reinstall now merges the prior ownership ledger, so later module additions do not orphan files from earlier installs and uninstall removes the complete managed surface.
|
||||
- Legacy Codex sync uninstall now uses ownership evidence, preserves user files, and requires an explicit opt-in for weaker marker-only cleanup.
|
||||
- Nasiko lifecycle operations now recover locks only after confirming the recorded owner is dead, preserve replacement locks, strictly reject malformed tar sizes, padding, terminators, and trailing data, and fail uninstall when staged files remain.
|
||||
- Hook, plan-canvas, session, memory, observer, skill-evolution, Discord delivery, and Windows compatibility regressions fixed across the runtime.
|
||||
|
||||
### Release audit
|
||||
|
||||
@@ -8,6 +8,7 @@ ECC 2.2.0 makes the universal installer a first-class, cross-harness distributio
|
||||
- Repeated selective installs retain the complete managed ownership ledger. A later module install no longer causes previously installed ECC files to survive uninstall.
|
||||
- OpenCode home installs use `~/.config/opencode`. Reinstall or repair discovers legacy `~/.opencode` ownership, migrates unchanged ECC-managed files, and preserves modified files for review. Bundled agent definitions inherit the user's selected model provider.
|
||||
- Legacy Codex sync cleanup requires ownership evidence by default and preserves untracked or modified user files.
|
||||
- Nasiko lifecycle locks recover only when their recorded owner is confirmed dead. Its pinned archive parser rejects malformed boundaries, and incomplete uninstall cleanup returns an error with retained-file guidance.
|
||||
- `skill-comply` is included in both the install graph and npm archive. Python bytecode and pytest caches remain excluded.
|
||||
|
||||
## New capabilities
|
||||
|
||||
@@ -40,13 +40,13 @@ Commit `5aa66021` moved ambient-override checks into isolated child processes an
|
||||
## GREEN
|
||||
|
||||
- Focused installer, lifecycle, packaging, release-workflow, manifest, OpenCode, Antigravity, and uninstall tests passed.
|
||||
- Full repository suite: 3,980 passed, 0 failed.
|
||||
- Full repository suite: 3,985 passed, 0 failed.
|
||||
- `npm audit --audit-level=low`: 0 vulnerabilities.
|
||||
- Supply-chain IOC scan: 207 files inspected, no findings.
|
||||
- Both release workflow YAML files parsed successfully.
|
||||
- Both release workflows derive reviewed notes from the validated tag and fail clearly when that version's notes are absent.
|
||||
- Release-note selection follows the lowercase filename convention shared by prior release directories.
|
||||
- Exact packed archive lifecycle passed on macOS with Node 24.9.0 using SHA-256 `072404f03255dfabd6d651a71432b7afae4aa5d03ae8c81b29ffa32caea061e0`.
|
||||
- Exact packed archive lifecycle passed on macOS with Node 24.9.0 using SHA-256 `de51641fee3fd7318937ec3bb45fe86f597b06b36501bf31960efe5ab7c8b42c`.
|
||||
- The packed lifecycle covered npm installation, public CLI setup, cumulative Cursor install, drift detection, repair, uninstall, user-file preservation, Antigravity install/doctor/uninstall, and OpenCode install/doctor/uninstall.
|
||||
- Simulated hosted-runner `OPENCODE_CONFIG_DIR` and `XDG_CONFIG_HOME` overrides passed the adapter, MCP inventory, lifecycle, legacy migration, doctor, repair, list, and uninstall suites while explicit CLI environments continued to honor those overrides.
|
||||
|
||||
|
||||
+141
-15
@@ -77,32 +77,60 @@ function readTarString(block, offset, length) {
|
||||
return block.subarray(offset, offset + length).toString('utf8').replace(/\0.*$/, '');
|
||||
}
|
||||
|
||||
function readTarOctal(block, offset, length) {
|
||||
const field = block.subarray(offset, offset + length).toString('ascii');
|
||||
const match = /^ *([0-7]+)[ \0]*$/.exec(field);
|
||||
if (!match) throw new Error('Unsafe Nasiko archive: invalid tar size field.');
|
||||
const size = Number.parseInt(match[1], 8);
|
||||
if (!Number.isSafeInteger(size) || size < 0) {
|
||||
throw new Error('Unsafe Nasiko archive: invalid tar size field.');
|
||||
}
|
||||
return size;
|
||||
}
|
||||
|
||||
function extractQualifiedTarGzip(archiveBytes, expectedName) {
|
||||
let tar;
|
||||
try { tar = zlib.gunzipSync(archiveBytes, { maxOutputLength: MAX_BINARY_BYTES + 2048 }); }
|
||||
catch (_error) { throw new Error('Nasiko archive is invalid or exceeds the decompressed size limit.'); }
|
||||
let offset = 0;
|
||||
let binary = null;
|
||||
while (offset + 512 <= tar.length) {
|
||||
let terminated = false;
|
||||
while (offset < tar.length) {
|
||||
if (offset + 512 > tar.length) throw new Error('Unsafe Nasiko archive: truncated tar header.');
|
||||
const header = tar.subarray(offset, offset + 512);
|
||||
if (header.every(byte => byte === 0)) break;
|
||||
if (header.every(byte => byte === 0)) {
|
||||
const terminatorEnd = offset + 1024;
|
||||
if (
|
||||
terminatorEnd > tar.length
|
||||
|| !tar.subarray(offset + 512, terminatorEnd).every(byte => byte === 0)
|
||||
|| !tar.subarray(terminatorEnd).every(byte => byte === 0)
|
||||
) {
|
||||
throw new Error('Unsafe Nasiko archive: incomplete terminator or nonzero trailing data.');
|
||||
}
|
||||
terminated = true;
|
||||
break;
|
||||
}
|
||||
const name = readTarString(header, 0, 100);
|
||||
const prefix = readTarString(header, 345, 155);
|
||||
const type = String.fromCharCode(header[156] || 48);
|
||||
const rawSize = readTarString(header, 124, 12).trim();
|
||||
const size = Number.parseInt(rawSize || '0', 8);
|
||||
const size = readTarOctal(header, 124, 12);
|
||||
const start = offset + 512;
|
||||
const end = start + size;
|
||||
if (!Number.isSafeInteger(size) || size < 0 || end > tar.length) throw new Error('Nasiko archive is truncated.');
|
||||
const paddedEnd = start + Math.ceil(size / 512) * 512;
|
||||
if (!Number.isSafeInteger(end) || paddedEnd > tar.length) throw new Error('Nasiko archive is truncated.');
|
||||
const payload = tar.subarray(start, end);
|
||||
if (!tar.subarray(end, paddedEnd).every(byte => byte === 0)) {
|
||||
throw new Error('Unsafe Nasiko archive: nonzero tar padding.');
|
||||
}
|
||||
const isBinary = !prefix && name === expectedName && (type === '0' || type === '\0');
|
||||
const isAppleDouble = !prefix && name === `._${expectedName}` && type === '0' && size <= 1024 * 1024;
|
||||
const isPaxMetadata = !prefix && name === `PaxHeader/${expectedName}` && type === 'x' && size <= 64 * 1024
|
||||
&& !/(?:^|\n)(?:path|linkpath)=/i.test(payload.toString('utf8'));
|
||||
if (isBinary && !binary && size > 0 && size <= MAX_BINARY_BYTES) binary = Buffer.from(payload);
|
||||
else if (!isAppleDouble && !isPaxMetadata) throw new Error('Unsafe Nasiko archive: expected exactly one bounded regular binary file.');
|
||||
offset = start + Math.ceil(size / 512) * 512;
|
||||
offset = paddedEnd;
|
||||
}
|
||||
if (!terminated) throw new Error('Unsafe Nasiko archive: missing complete tar terminator.');
|
||||
if (!binary) throw new Error('Unsafe Nasiko archive: expected exactly one bounded regular binary file.');
|
||||
return binary;
|
||||
}
|
||||
@@ -229,26 +257,118 @@ function writeMetadataExclusive(metadataPath, metadata) {
|
||||
fs.writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
|
||||
}
|
||||
|
||||
function acquireLifecycleLock(installDirectory, fileSystem = fs) {
|
||||
const lockPath = path.join(installDirectory, '.ecc-nasiko-lifecycle.lock');
|
||||
function sameFileIdentity(left, right) {
|
||||
return left.dev === right.dev && left.ino === right.ino;
|
||||
}
|
||||
|
||||
function processIsAlive(pid) {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return error.code !== 'ESRCH';
|
||||
}
|
||||
}
|
||||
|
||||
function inspectLifecycleLock(lockPath, fileSystem) {
|
||||
const descriptor = fileSystem.openSync(lockPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
|
||||
try {
|
||||
const descriptorStats = fileSystem.fstatSync(descriptor);
|
||||
if (!descriptorStats.isFile() || descriptorStats.size <= 0 || descriptorStats.size > 4096) return null;
|
||||
const bytes = fileSystem.readFileSync(descriptor);
|
||||
const pathStats = fileSystem.lstatSync(lockPath);
|
||||
if (pathStats.isSymbolicLink() || !pathStats.isFile() || !sameFileIdentity(descriptorStats, pathStats)) return null;
|
||||
let metadata;
|
||||
try { metadata = JSON.parse(bytes.toString('utf8')); } catch (_error) { return null; }
|
||||
if (
|
||||
!Number.isSafeInteger(metadata.pid)
|
||||
|| metadata.pid <= 0
|
||||
|| typeof metadata.startedAt !== 'string'
|
||||
|| !Number.isFinite(Date.parse(metadata.startedAt))
|
||||
) return null;
|
||||
return { metadata, stats: descriptorStats };
|
||||
} finally { fileSystem.closeSync(descriptor); }
|
||||
}
|
||||
|
||||
function removeLockIfOwned(lockPath, expectedStats, fileSystem) {
|
||||
try {
|
||||
const current = fileSystem.lstatSync(lockPath);
|
||||
if (!current.isSymbolicLink() && current.isFile() && sameFileIdentity(current, expectedStats)) {
|
||||
fileSystem.rmSync(lockPath, { force: true });
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function createLifecycleLock(lockPath, fileSystem) {
|
||||
let descriptor;
|
||||
try {
|
||||
descriptor = fileSystem.openSync(lockPath, 'wx', 0o600);
|
||||
fileSystem.writeFileSync(descriptor, `${JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() })}\n`);
|
||||
fileSystem.writeFileSync(descriptor, `${JSON.stringify({
|
||||
pid: process.pid,
|
||||
startedAt: new Date().toISOString(),
|
||||
token: crypto.randomBytes(16).toString('hex'),
|
||||
})}\n`);
|
||||
fileSystem.fsyncSync(descriptor);
|
||||
}
|
||||
catch (error) {
|
||||
if (error.code === 'EEXIST') throw new Error(`Another Nasiko lifecycle operation is already in progress; inspect ${lockPath} before recovering a stale lock.`);
|
||||
if (descriptor !== undefined) {
|
||||
try { fileSystem.closeSync(descriptor); } finally { fileSystem.rmSync(lockPath, { force: true }); }
|
||||
const ownedStats = fileSystem.fstatSync(descriptor);
|
||||
try { fileSystem.closeSync(descriptor); } finally { removeLockIfOwned(lockPath, ownedStats, fileSystem); }
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const ownedStats = fileSystem.fstatSync(descriptor);
|
||||
let released = false;
|
||||
return () => {
|
||||
try { fileSystem.closeSync(descriptor); } finally { fileSystem.rmSync(lockPath, { force: true }); }
|
||||
if (released) return;
|
||||
released = true;
|
||||
try { fileSystem.closeSync(descriptor); } finally { removeLockIfOwned(lockPath, ownedStats, fileSystem); }
|
||||
};
|
||||
}
|
||||
|
||||
function acquireLifecycleLock(installDirectory, fileSystem = fs, options = {}) {
|
||||
const lockPath = path.join(installDirectory, '.ecc-nasiko-lifecycle.lock');
|
||||
try {
|
||||
return createLifecycleLock(lockPath, fileSystem);
|
||||
} catch (error) {
|
||||
if (error.code !== 'EEXIST') throw error;
|
||||
}
|
||||
|
||||
let existing;
|
||||
try { existing = inspectLifecycleLock(lockPath, fileSystem); }
|
||||
catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
try { return createLifecycleLock(lockPath, fileSystem); }
|
||||
catch (retryError) {
|
||||
if (retryError.code === 'EEXIST') {
|
||||
throw new Error(`Another Nasiko lifecycle operation won lock acquisition: ${lockPath}.`);
|
||||
}
|
||||
throw retryError;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const isProcessAlive = options.isProcessAlive || processIsAlive;
|
||||
if (!existing || isProcessAlive(existing.metadata.pid)) {
|
||||
throw new Error(`Another Nasiko lifecycle operation is already in progress; inspect ${lockPath} before recovering a stale lock.`);
|
||||
}
|
||||
if (!removeLockIfOwned(lockPath, existing.stats, fileSystem)) {
|
||||
throw new Error(`Nasiko lifecycle lock changed during stale-owner recovery: ${lockPath}.`);
|
||||
}
|
||||
try {
|
||||
return createLifecycleLock(lockPath, fileSystem);
|
||||
} catch (error) {
|
||||
if (error.code === 'EEXIST') {
|
||||
throw new Error(`Another Nasiko lifecycle operation won stale-lock recovery: ${lockPath}.`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function installNasiko(options = {}, dependencies = {}) {
|
||||
const version = options.version || 'v0.1.0';
|
||||
const base = getQualifiedRelease(version, dependencies.platform || process.platform, dependencies.arch || process.arch);
|
||||
@@ -316,6 +436,7 @@ function uninstallNasiko(options = {}, dependencies = {}) {
|
||||
let binaryStaged = false;
|
||||
let metadataStaged = false;
|
||||
const rename = dependencies.rename || fs.renameSync;
|
||||
const remove = dependencies.remove || (target => fs.rmSync(target));
|
||||
try {
|
||||
const status = (dependencies.inspectInstalled || inspectInstalledNasiko)(destination);
|
||||
if (!status.installed) return { ...plan, dryRun: false, removed: false };
|
||||
@@ -325,11 +446,16 @@ function uninstallNasiko(options = {}, dependencies = {}) {
|
||||
rename(metadataPath, metadataTombstone);
|
||||
metadataStaged = true;
|
||||
const cleanupPending = [];
|
||||
try { fs.rmSync(metadataTombstone); } catch (_error) { cleanupPending.push(metadataTombstone); }
|
||||
try { remove(metadataTombstone); } catch (_error) { cleanupPending.push(metadataTombstone); }
|
||||
metadataStaged = false;
|
||||
try { fs.rmSync(binaryTombstone); } catch (_error) { cleanupPending.push(binaryTombstone); }
|
||||
try { remove(binaryTombstone); } catch (_error) { cleanupPending.push(binaryTombstone); }
|
||||
binaryStaged = false;
|
||||
return { ...plan, dryRun: false, removed: true, cleanupPending };
|
||||
if (cleanupPending.length > 0) {
|
||||
const cleanupError = new Error(`Nasiko uninstall is incomplete; retained staged file(s): ${cleanupPending.join(', ')}. Remove these files before reinstalling.`);
|
||||
cleanupError.cleanupPending = cleanupPending;
|
||||
throw cleanupError;
|
||||
}
|
||||
return { ...plan, dryRun: false, removed: true, cleanupPending: [] };
|
||||
} catch (error) {
|
||||
if (metadataStaged && !fs.existsSync(metadataPath)) rename(metadataTombstone, metadataPath);
|
||||
if (binaryStaged && !fs.existsSync(destination)) rename(binaryTombstone, destination);
|
||||
|
||||
@@ -35,6 +35,29 @@ function sha256Digest(value) {
|
||||
return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
|
||||
}
|
||||
|
||||
function tarGzipFixture({
|
||||
name = 'nasiko',
|
||||
payload = Buffer.from('x'),
|
||||
sizeField = null,
|
||||
padding = true,
|
||||
terminatorBlocks = 2,
|
||||
trailing = Buffer.alloc(0),
|
||||
} = {}) {
|
||||
const zlib = require('zlib');
|
||||
const header = Buffer.alloc(512);
|
||||
header.write(name, 0, 100, 'utf8');
|
||||
header.write(sizeField || `${payload.length.toString(8).padStart(11, '0')}\0`, 124, 12, 'ascii');
|
||||
header[156] = '0'.charCodeAt(0);
|
||||
const paddingBytes = padding ? Buffer.alloc((512 - (payload.length % 512)) % 512) : Buffer.alloc(0);
|
||||
return zlib.gzipSync(Buffer.concat([
|
||||
header,
|
||||
payload,
|
||||
paddingBytes,
|
||||
Buffer.alloc(terminatorBlocks * 512),
|
||||
trailing,
|
||||
]));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('\n=== Testing Nasiko control-plane integration ===\n');
|
||||
|
||||
@@ -102,6 +125,61 @@ async function main() {
|
||||
assert.strictEqual(fs.existsSync(lockPath), false);
|
||||
} finally { fs.rmSync(installRoot, { recursive: true, force: true }); }
|
||||
}],
|
||||
['recovers only locks whose recorded owner is confirmed dead', () => {
|
||||
const { acquireLifecycleLock } = require('../../scripts/lib/nasiko-release');
|
||||
const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-stale-lock-'));
|
||||
const lockPath = path.join(installRoot, '.ecc-nasiko-lifecycle.lock');
|
||||
try {
|
||||
fs.writeFileSync(lockPath, `${JSON.stringify({
|
||||
pid: 424242,
|
||||
startedAt: '2026-08-25T00:00:00.000Z',
|
||||
token: 'stale-owner',
|
||||
})}\n`, { mode: 0o600 });
|
||||
assert.throws(
|
||||
() => acquireLifecycleLock(installRoot, fs, { isProcessAlive: () => true }),
|
||||
/already in progress/i
|
||||
);
|
||||
const releaseLock = acquireLifecycleLock(installRoot, fs, { isProcessAlive: () => false });
|
||||
assert.strictEqual(fs.existsSync(lockPath), true);
|
||||
releaseLock();
|
||||
assert.strictEqual(fs.existsSync(lockPath), false);
|
||||
|
||||
fs.writeFileSync(lockPath, '{"pid":"unknown"}\n', { mode: 0o600 });
|
||||
assert.throws(
|
||||
() => acquireLifecycleLock(installRoot, fs, { isProcessAlive: () => false }),
|
||||
/already in progress/i
|
||||
);
|
||||
} finally { fs.rmSync(installRoot, { recursive: true, force: true }); }
|
||||
}],
|
||||
['recovers a lock abandoned by a finished process', () => {
|
||||
const { acquireLifecycleLock } = require('../../scripts/lib/nasiko-release');
|
||||
const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-dead-process-lock-'));
|
||||
const lockPath = path.join(installRoot, '.ecc-nasiko-lifecycle.lock');
|
||||
const modulePath = path.join(REPO_ROOT, 'scripts', 'lib', 'nasiko-release.js');
|
||||
try {
|
||||
const child = spawnSync(process.execPath, ['-e',
|
||||
`require(${JSON.stringify(modulePath)}).acquireLifecycleLock(${JSON.stringify(installRoot)});`
|
||||
], { encoding: 'utf8' });
|
||||
assert.strictEqual(child.status, 0, child.stderr);
|
||||
assert.strictEqual(fs.existsSync(lockPath), true);
|
||||
const releaseLock = acquireLifecycleLock(installRoot);
|
||||
releaseLock();
|
||||
assert.strictEqual(fs.existsSync(lockPath), false);
|
||||
} finally { fs.rmSync(installRoot, { recursive: true, force: true }); }
|
||||
}],
|
||||
['a prior release callback never removes a replacement lifecycle lock', () => {
|
||||
const { acquireLifecycleLock } = require('../../scripts/lib/nasiko-release');
|
||||
const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-replaced-lock-'));
|
||||
const lockPath = path.join(installRoot, '.ecc-nasiko-lifecycle.lock');
|
||||
const displacedPath = `${lockPath}.displaced`;
|
||||
try {
|
||||
const releaseLock = acquireLifecycleLock(installRoot);
|
||||
fs.renameSync(lockPath, displacedPath);
|
||||
fs.writeFileSync(lockPath, '{"pid":1,"startedAt":"2026-08-25T00:00:00.000Z","token":"replacement"}\n');
|
||||
releaseLock();
|
||||
assert.strictEqual(fs.existsSync(lockPath), true);
|
||||
} finally { fs.rmSync(installRoot, { recursive: true, force: true }); }
|
||||
}],
|
||||
['verifies manifest and blob digests before an atomic install', async () => {
|
||||
const { installNasiko } = require('../../scripts/lib/nasiko-release');
|
||||
const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-green-'));
|
||||
@@ -190,6 +268,29 @@ async function main() {
|
||||
fs.rmSync(installRoot, { recursive: true, force: true });
|
||||
}
|
||||
}],
|
||||
['accepts one complete tar entry and rejects malformed tar boundaries', () => {
|
||||
const { extractQualifiedTarGzip } = require('../../scripts/lib/nasiko-release');
|
||||
assert.deepStrictEqual(
|
||||
extractQualifiedTarGzip(tarGzipFixture(), 'nasiko'),
|
||||
Buffer.from('x')
|
||||
);
|
||||
assert.throws(
|
||||
() => extractQualifiedTarGzip(tarGzipFixture({ padding: false }), 'nasiko'),
|
||||
/unsafe|truncated|terminator/i
|
||||
);
|
||||
assert.throws(
|
||||
() => extractQualifiedTarGzip(tarGzipFixture({ trailing: Buffer.from([1]) }), 'nasiko'),
|
||||
/unsafe|trailing/i
|
||||
);
|
||||
assert.throws(
|
||||
() => extractQualifiedTarGzip(tarGzipFixture({ sizeField: '00000000001x' }), 'nasiko'),
|
||||
/size|octal|unsafe/i
|
||||
);
|
||||
assert.throws(
|
||||
() => extractQualifiedTarGzip(tarGzipFixture({ terminatorBlocks: 1 }), 'nasiko'),
|
||||
/terminator|truncated|unsafe/i
|
||||
);
|
||||
}],
|
||||
['read-only status never executes an unqualified explicit executable', () => {
|
||||
const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-status-'));
|
||||
const executable = path.join(fixtureRoot, 'nasiko');
|
||||
@@ -295,6 +396,23 @@ async function main() {
|
||||
assert.deepStrictEqual(fs.readFileSync(path.join(installRoot, 'nasiko')), intruder);
|
||||
} finally { fs.rmSync(installRoot, { recursive: true, force: true }); }
|
||||
}],
|
||||
['fails uninstall when staged tombstones cannot be removed', () => {
|
||||
const { uninstallNasiko } = require('../../scripts/lib/nasiko-release');
|
||||
const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-cleanup-failure-'));
|
||||
const executable = path.join(installRoot, 'nasiko');
|
||||
const metadataPath = path.join(installRoot, '.ecc-nasiko-install.json');
|
||||
fs.writeFileSync(executable, 'qualified binary', { mode: 0o700 });
|
||||
fs.writeFileSync(metadataPath, '{}', { mode: 0o600 });
|
||||
try {
|
||||
assert.throws(() => uninstallNasiko({ installDir: installRoot, yes: true }, {
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
inspectInstalled: () => ({ installed: true, qualified: true, version: 'v0.1.0' }),
|
||||
remove: target => { throw new Error(`retained ${target}`); },
|
||||
}), /incomplete|retained|cleanup/i);
|
||||
assert.ok(fs.readdirSync(installRoot).some(name => name.includes('.remove-')));
|
||||
} finally { fs.rmSync(installRoot, { recursive: true, force: true }); }
|
||||
}],
|
||||
['ships a canonical opt-in skill without silently bundling Nasiko', () => {
|
||||
const skill = read('skills/nasiko-control-plane/SKILL.md');
|
||||
assert.match(skill, /^name: nasiko-control-plane$/m);
|
||||
|
||||
Reference in New Issue
Block a user