mirror of
https://github.com/affaan-m/ECC.git
synced 2026-08-17 21:15:40 +02:00
fix: harden Nasiko artifact lifecycle
This commit is contained in:
@@ -11,7 +11,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ecc",
|
"name": "ecc",
|
||||||
"source": "./",
|
"source": "./",
|
||||||
"description": "Harness-native ECC operator layer - 68 agents, 284 skills, 94 legacy command shims, reusable hooks, rules, selective install profiles, and production-ready workflows for Claude Code, Codex, OpenCode, Cursor, and related agent harnesses",
|
"description": "Harness-native ECC operator layer - 68 agents, 285 skills, 94 legacy command shims, reusable hooks, rules, selective install profiles, and production-ready workflows for Claude Code, Codex, OpenCode, Cursor, and related agent harnesses",
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Affaan Mustafa",
|
"name": "Affaan Mustafa",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ecc",
|
"name": "ecc",
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"description": "Harness-native ECC plugin for engineering teams - 68 agents, 284 skills, 94 legacy command shims, reusable hooks, rules, MCP conventions, and operator workflows for Claude Code plus adjacent agent harnesses",
|
"description": "Harness-native ECC plugin for engineering teams - 68 agents, 285 skills, 94 legacy command shims, reusable hooks, rules, MCP conventions, and operator workflows for Claude Code plus adjacent agent harnesses",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Affaan Mustafa",
|
"name": "Affaan Mustafa",
|
||||||
"url": "https://x.com/affaanmustafa"
|
"url": "https://x.com/affaanmustafa"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Everything Claude Code (ECC) — Agent Instructions
|
# Everything Claude Code (ECC) — Agent Instructions
|
||||||
|
|
||||||
This is a **production-ready AI coding plugin** providing 68 specialized agents, 284 skills, 94 commands, and automated hook workflows for software development.
|
This is a **production-ready AI coding plugin** providing 68 specialized agents, 285 skills, 94 commands, and automated hook workflows for software development.
|
||||||
|
|
||||||
**Version:** 2.2.0
|
**Version:** 2.2.0
|
||||||
|
|
||||||
@@ -154,7 +154,7 @@ Troubleshoot failures: check test isolation → verify mocks → fix implementat
|
|||||||
|
|
||||||
```
|
```
|
||||||
agents/ — 68 specialized subagents
|
agents/ — 68 specialized subagents
|
||||||
skills/ — 284 workflow skills and domain knowledge
|
skills/ — 285 workflow skills and domain knowledge
|
||||||
commands/ — 94 slash commands
|
commands/ — 94 slash commands
|
||||||
hooks/ — Trigger-based automations
|
hooks/ — Trigger-based automations
|
||||||
rules/ — Always-follow guidelines (common + per-language)
|
rules/ — Always-follow guidelines (common + per-language)
|
||||||
|
|||||||
@@ -130,12 +130,12 @@ Instead of rebuilding that process in every prompt, you install it once and make
|
|||||||
|
|
||||||
ECC is MIT-licensed open source. It works best with Claude Code today, has a supported Codex sync path, and provides capability-limited adapters for Cursor, OpenCode, Gemini, Zed, GitHub Copilot, Antigravity, Qwen, and other harnesses. See the [support status matrix](#platform-support) before assuming feature parity.
|
ECC is MIT-licensed open source. It works best with Claude Code today, has a supported Codex sync path, and provides capability-limited adapters for Cursor, OpenCode, Gemini, Zed, GitHub Copilot, Antigravity, Qwen, and other harnesses. See the [support status matrix](#platform-support) before assuming feature parity.
|
||||||
|
|
||||||
Access to 68 agents, 284 skills, and 94 legacy command shims, plus hooks, rules, memory, continuous learning, and AgentShield security scanning. The agents are specialized for planning, review, build repair, security, architecture, and domain work.
|
Access to 68 agents, 285 skills, and 94 legacy command shims, plus hooks, rules, memory, continuous learning, and AgentShield security scanning. The agents are specialized for planning, review, build repair, security, architecture, and domain work.
|
||||||
|
|
||||||
| Included | Count | What it gives you |
|
| Included | Count | What it gives you |
|
||||||
| ---------------- | ----------: | ------------------------------------------------------------------------------------ |
|
| ---------------- | ----------: | ------------------------------------------------------------------------------------ |
|
||||||
| Agents | 68 agents | Planning, review, build repair, security, architecture, and domain work |
|
| Agents | 68 agents | Planning, review, build repair, security, architecture, and domain work |
|
||||||
| Skills | 284 skills | TDD, research, security, docs, frontend, data, ML, operations, and more |
|
| Skills | 285 skills | TDD, research, security, docs, frontend, data, ML, operations, and more |
|
||||||
| Commands | 94 commands | Convenient entry points while ECC moves to a skills-first surface |
|
| Commands | 94 commands | Convenient entry points while ECC moves to a skills-first surface |
|
||||||
| Hooks and memory | Runtime | Enforcement, session summaries, continuous learning, instincts, and context controls |
|
| Hooks and memory | Runtime | Enforcement, session summaries, continuous learning, instincts, and context controls |
|
||||||
| Rules | Selective | Always-loaded standards you choose by language or project |
|
| Rules | Selective | Always-loaded standards you choose by language or project |
|
||||||
|
|||||||
+1
-1
@@ -196,7 +196,7 @@ Copy-Item -Recurse rules/typescript "$HOME/.claude/rules/"
|
|||||||
/plugin list ecc@ecc
|
/plugin list ecc@ecc
|
||||||
```
|
```
|
||||||
|
|
||||||
**完成!** 你现在可以使用 68 个代理、284 个技能和 94 个命令。
|
**完成!** 你现在可以使用 68 个代理、285 个技能和 94 个命令。
|
||||||
|
|
||||||
### multi-* 命令需要额外配置
|
### multi-* 命令需要额外配置
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
# Everything Claude Code (ECC) — Agent Talimatları
|
# Everything Claude Code (ECC) — Agent Talimatları
|
||||||
|
|
||||||
Bu, yazılım geliştirme için 68 özel agent, 284 skill, 94 command ve otomatik hook iş akışları sağlayan **üretime hazır bir AI kodlama eklentisidir**.
|
Bu, yazılım geliştirme için 68 özel agent, 285 skill, 94 command ve otomatik hook iş akışları sağlayan **üretime hazır bir AI kodlama eklentisidir**.
|
||||||
|
|
||||||
**Sürüm:** 2.2.0
|
**Sürüm:** 2.2.0
|
||||||
|
|
||||||
@@ -142,7 +142,7 @@ Başarısızlık sorunlarını giderin: test izolasyonunu kontrol edin → mockl
|
|||||||
|
|
||||||
```
|
```
|
||||||
agents/ — 68 özel subagent
|
agents/ — 68 özel subagent
|
||||||
skills/ — 284 iş akışı skillleri ve alan bilgisi
|
skills/ — 285 iş akışı skillleri ve alan bilgisi
|
||||||
commands/ — 94 slash command
|
commands/ — 94 slash command
|
||||||
hooks/ — Tetikleyici tabanlı otomasyonlar
|
hooks/ — Tetikleyici tabanlı otomasyonlar
|
||||||
rules/ — Her zaman uyulması gereken kurallar (ortak + dile özel)
|
rules/ — Her zaman uyulması gereken kurallar (ortak + dile özel)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Everything Claude Code (ECC) — 智能体指令
|
# Everything Claude Code (ECC) — 智能体指令
|
||||||
|
|
||||||
这是一个**生产就绪的 AI 编码插件**,提供 68 个专业代理、284 项技能、94 条命令以及自动化钩子工作流,用于软件开发。
|
这是一个**生产就绪的 AI 编码插件**,提供 68 个专业代理、285 项技能、94 条命令以及自动化钩子工作流,用于软件开发。
|
||||||
|
|
||||||
**版本:** 2.2.0
|
**版本:** 2.2.0
|
||||||
|
|
||||||
@@ -147,7 +147,7 @@
|
|||||||
|
|
||||||
```
|
```
|
||||||
agents/ — 68 个专业子代理
|
agents/ — 68 个专业子代理
|
||||||
skills/ — 284 个工作流技能和领域知识
|
skills/ — 285 个工作流技能和领域知识
|
||||||
commands/ — 94 个斜杠命令
|
commands/ — 94 个斜杠命令
|
||||||
hooks/ — 基于触发的自动化
|
hooks/ — 基于触发的自动化
|
||||||
rules/ — 始终遵循的指导方针(通用 + 每种语言)
|
rules/ — 始终遵循的指导方针(通用 + 每种语言)
|
||||||
|
|||||||
@@ -260,7 +260,7 @@ Copy-Item -Recurse rules/typescript "$HOME/.claude/rules/"
|
|||||||
/plugin list ecc@ecc
|
/plugin list ecc@ecc
|
||||||
```
|
```
|
||||||
|
|
||||||
**搞定!** 你现在可以使用 68 个智能体、284 项技能和 94 个命令了。
|
**搞定!** 你现在可以使用 68 个智能体、285 项技能和 94 个命令了。
|
||||||
|
|
||||||
***
|
***
|
||||||
|
|
||||||
@@ -1174,7 +1174,7 @@ opencode
|
|||||||
|---------|---------------|----------|--------|
|
|---------|---------------|----------|--------|
|
||||||
| 智能体 | PASS: 68 个 | PASS: 12 个 | **Claude Code 领先** |
|
| 智能体 | PASS: 68 个 | PASS: 12 个 | **Claude Code 领先** |
|
||||||
| 命令 | PASS: 94 个 | PASS: 35 个 | **Claude Code 领先** |
|
| 命令 | PASS: 94 个 | PASS: 35 个 | **Claude Code 领先** |
|
||||||
| 技能 | PASS: 284 项 | PASS: 37 项 | **Claude Code 领先** |
|
| 技能 | PASS: 285 项 | PASS: 37 项 | **Claude Code 领先** |
|
||||||
| 钩子 | PASS: 8 种事件类型 | PASS: 11 种事件 | **OpenCode 更多!** |
|
| 钩子 | PASS: 8 种事件类型 | PASS: 11 种事件 | **OpenCode 更多!** |
|
||||||
| 规则 | PASS: 29 条 | PASS: 13 条指令 | **Claude Code 领先** |
|
| 规则 | PASS: 29 条 | PASS: 13 条指令 | **Claude Code 领先** |
|
||||||
| MCP 服务器 | PASS: 14 个 | PASS: 完整 | **完全对等** |
|
| MCP 服务器 | PASS: 14 个 | PASS: 完整 | **完全对等** |
|
||||||
@@ -1282,7 +1282,7 @@ ECC 是**第一个最大化利用每个主要 AI 编码工具的插件**。以
|
|||||||
|---------|-----------------------|------------|-----------|----------|
|
|---------|-----------------------|------------|-----------|----------|
|
||||||
| **智能体** | 68 | 共享 (AGENTS.md) | 共享 (AGENTS.md) | 12 |
|
| **智能体** | 68 | 共享 (AGENTS.md) | 共享 (AGENTS.md) | 12 |
|
||||||
| **命令** | 94 | 共享 | 基于指令 | 35 |
|
| **命令** | 94 | 共享 | 基于指令 | 35 |
|
||||||
| **技能** | 284 | 共享 | 10 (原生格式) | 37 |
|
| **技能** | 285 | 共享 | 10 (原生格式) | 37 |
|
||||||
| **钩子事件** | 8 种类型 | 15 种类型 | SessionStart(1 种类型) | 11 种类型 |
|
| **钩子事件** | 8 种类型 | 15 种类型 | SessionStart(1 种类型) | 11 种类型 |
|
||||||
| **钩子脚本** | 20+ 个脚本 | 16 个脚本 (DRY 适配器) | 1 个 SessionStart 引导脚本 | 插件钩子 |
|
| **钩子脚本** | 20+ 个脚本 | 16 个脚本 (DRY 适配器) | 1 个 SessionStart 引导脚本 | 插件钩子 |
|
||||||
| **规则** | 34 (通用 + 语言) | 34 (YAML 前页) | 基于指令 | 13 条指令 |
|
| **规则** | 34 (通用 + 语言) | 34 (YAML 前页) | 基于指令 | 13 条指令 |
|
||||||
|
|||||||
@@ -89,6 +89,7 @@
|
|||||||
"optimization-workflows",
|
"optimization-workflows",
|
||||||
"prediction-market-skills",
|
"prediction-market-skills",
|
||||||
"ito-compute",
|
"ito-compute",
|
||||||
|
"nasiko-control-plane",
|
||||||
"social-distribution",
|
"social-distribution",
|
||||||
"media-generation",
|
"media-generation",
|
||||||
"orchestration",
|
"orchestration",
|
||||||
|
|||||||
@@ -119,7 +119,6 @@
|
|||||||
"scripts/install-plan.js",
|
"scripts/install-plan.js",
|
||||||
"scripts/ito.js",
|
"scripts/ito.js",
|
||||||
"scripts/nasiko.js",
|
"scripts/nasiko.js",
|
||||||
"scripts/lib/nasiko-release.js",
|
|
||||||
"scripts/lib/",
|
"scripts/lib/",
|
||||||
"scripts/list-installed.js",
|
"scripts/list-installed.js",
|
||||||
"scripts/loop-status.js",
|
"scripts/loop-status.js",
|
||||||
|
|||||||
+203
-231
@@ -5,57 +5,46 @@ const fs = require('fs');
|
|||||||
const https = require('https');
|
const https = require('https');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { spawnSync } = require('child_process');
|
const zlib = require('zlib');
|
||||||
|
|
||||||
const REGISTRY_ORIGIN = 'https://registry.nasiko.dev';
|
const REGISTRY_ORIGIN = 'https://registry.nasiko.dev';
|
||||||
const REPOSITORY = 'nasiko/nasiko';
|
const REPOSITORY = 'nasiko/nasiko';
|
||||||
|
const SOURCE_URL = 'https://github.com/Nasiko-Labs/nasiko';
|
||||||
|
const LICENSE = 'Apache-2.0';
|
||||||
|
const METADATA_FILENAME = '.ecc-nasiko-install.json';
|
||||||
const MAX_MANIFEST_BYTES = 1024 * 1024;
|
const MAX_MANIFEST_BYTES = 1024 * 1024;
|
||||||
const MAX_ARCHIVE_BYTES = 100 * 1024 * 1024;
|
const MAX_ARCHIVE_BYTES = 100 * 1024 * 1024;
|
||||||
|
const MAX_BINARY_BYTES = 64 * 1024 * 1024;
|
||||||
|
const MAX_METADATA_BYTES = 64 * 1024;
|
||||||
const SHA256_PATTERN = /^sha256:[a-f0-9]{64}$/;
|
const SHA256_PATTERN = /^sha256:[a-f0-9]{64}$/;
|
||||||
|
|
||||||
const QUALIFIED_RELEASES = Object.freeze({
|
const QUALIFIED_RELEASES = Object.freeze({
|
||||||
'v0.1.0': Object.freeze({
|
'v0.1.0': Object.freeze({
|
||||||
'linux/amd64': 'sha256:0df748a40f3d714b6b6a3376a1d13a224c05bdb8d1628f31ace5a7bee8ceb9de',
|
'linux/amd64': Object.freeze({ manifestDigest: 'sha256:0df748a40f3d714b6b6a3376a1d13a224c05bdb8d1628f31ace5a7bee8ceb9de', binaryDigest: 'sha256:94a2bcab2d3832257e0111480bb7c3dcac81d63a7ae9bac53206a92c0957ee0f' }),
|
||||||
'linux/arm64': 'sha256:655021a129c7df4621a80d16ea4eab38018530bfe9a20da646641d9f4ac5c249',
|
'linux/arm64': Object.freeze({ manifestDigest: 'sha256:655021a129c7df4621a80d16ea4eab38018530bfe9a20da646641d9f4ac5c249', binaryDigest: 'sha256:85f9fa5cfbed6c276fce6df2d71e9d0c66d7d8e8d46a40297464833d38db7a7e' }),
|
||||||
'darwin/amd64': 'sha256:b4188482621efd7da5a2ab630f653665ab5f80b9aceae448b6bb5fc93e003f06',
|
'darwin/amd64': Object.freeze({ manifestDigest: 'sha256:b4188482621efd7da5a2ab630f653665ab5f80b9aceae448b6bb5fc93e003f06', binaryDigest: 'sha256:ed6232e0bb96a2dcfd86d3c25f86021091600d52f09250403a54528bfe8100a3' }),
|
||||||
'darwin/arm64': 'sha256:ce7e54fa19f989a5d125c4409b3587ca9503bb5a07bc5ff223c60e0fbad437f0',
|
'darwin/arm64': Object.freeze({ manifestDigest: 'sha256:ce7e54fa19f989a5d125c4409b3587ca9503bb5a07bc5ff223c60e0fbad437f0', binaryDigest: 'sha256:3c60f862b04eea1b9a633593b39f1a443d9ca2123cf3edfd150d313e95f3894b' }),
|
||||||
'windows/amd64': 'sha256:0760fe1fc98e8fedb66796aaf891a1de9268af1338c5e88b949656fda5d9f045',
|
'windows/amd64': Object.freeze({ manifestDigest: 'sha256:0760fe1fc98e8fedb66796aaf891a1de9268af1338c5e88b949656fda5d9f045', binaryDigest: 'sha256:0f57672d24fc3c70e4cbbf22864e65b35b9978ddaae3793803841536e682929b' }),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
function normalizePlatform(platform = process.platform, architecture = process.arch) {
|
function normalizePlatform(platform = process.platform, architecture = process.arch) {
|
||||||
const osName = platform === 'win32' ? 'windows' : platform;
|
const osName = platform === 'win32' ? 'windows' : platform;
|
||||||
if (!['linux', 'darwin', 'windows'].includes(osName)) {
|
if (!['linux', 'darwin', 'windows'].includes(osName)) throw new Error(`Unsupported platform: ${platform}`);
|
||||||
throw new Error(`Unsupported platform: ${platform}`);
|
|
||||||
}
|
|
||||||
const arch = architecture === 'x64' ? 'amd64' : architecture;
|
const arch = architecture === 'x64' ? 'amd64' : architecture;
|
||||||
if (!['amd64', 'arm64'].includes(arch)) {
|
if (!['amd64', 'arm64'].includes(arch)) throw new Error(`Unsupported architecture: ${architecture}`);
|
||||||
throw new Error(`Unsupported architecture: ${architecture}`);
|
if (osName === 'windows' && arch !== 'amd64') throw new Error(`Unsupported architecture for Windows: ${architecture}`);
|
||||||
}
|
return { os: osName, arch, binaryName: osName === 'windows' ? 'nasiko.exe' : 'nasiko' };
|
||||||
if (osName === 'windows' && arch !== 'amd64') {
|
|
||||||
throw new Error(`Unsupported architecture for Windows: ${architecture}`);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
os: osName,
|
|
||||||
arch,
|
|
||||||
binaryName: osName === 'windows' ? 'nasiko.exe' : 'nasiko',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getQualifiedRelease(version, platform = process.platform, architecture = process.arch) {
|
function getQualifiedRelease(version, platform = process.platform, architecture = process.arch) {
|
||||||
if (!/^v\d+\.\d+\.\d+$/.test(String(version || ''))) {
|
if (!/^v\d+\.\d+\.\d+$/.test(String(version || ''))) {
|
||||||
throw new Error('Nasiko installation requires a pinned version such as v0.1.0; latest is not allowed.');
|
throw new Error('Nasiko installation requires a pinned version such as v0.1.0; latest is not allowed.');
|
||||||
}
|
}
|
||||||
const release = QUALIFIED_RELEASES[version];
|
|
||||||
if (!release) {
|
|
||||||
throw new Error(`Nasiko ${version} is not qualified by this ECC release.`);
|
|
||||||
}
|
|
||||||
const normalized = normalizePlatform(platform, architecture);
|
const normalized = normalizePlatform(platform, architecture);
|
||||||
const manifestDigest = release[`${normalized.os}/${normalized.arch}`];
|
const qualification = QUALIFIED_RELEASES[version]?.[`${normalized.os}/${normalized.arch}`];
|
||||||
if (!manifestDigest) {
|
if (!qualification) throw new Error(`Nasiko ${version} is not qualified for ${normalized.os}/${normalized.arch}.`);
|
||||||
throw new Error(`Nasiko ${version} is not qualified for ${normalized.os}/${normalized.arch}.`);
|
return { version, ...normalized, ...qualification, license: LICENSE, sourceUrl: SOURCE_URL };
|
||||||
}
|
|
||||||
return { version, ...normalized, manifestDigest };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function digestBytes(bytes) {
|
function digestBytes(bytes) {
|
||||||
@@ -63,22 +52,14 @@ function digestBytes(bytes) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function assertDigest(bytes, expectedDigest, label) {
|
function assertDigest(bytes, expectedDigest, label) {
|
||||||
if (!SHA256_PATTERN.test(expectedDigest)) {
|
if (!SHA256_PATTERN.test(expectedDigest)) throw new Error(`${label} has an invalid expected digest.`);
|
||||||
throw new Error(`${label} has an invalid expected digest.`);
|
const actual = digestBytes(bytes);
|
||||||
}
|
if (actual !== expectedDigest) throw new Error(`${label} digest mismatch: expected ${expectedDigest}, got ${actual}.`);
|
||||||
const actualDigest = digestBytes(bytes);
|
|
||||||
if (actualDigest !== expectedDigest) {
|
|
||||||
throw new Error(`${label} digest mismatch: expected ${expectedDigest}, got ${actualDigest}.`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateManifest(manifestBytes) {
|
function validateManifest(bytes) {
|
||||||
let manifest;
|
let manifest;
|
||||||
try {
|
try { manifest = JSON.parse(bytes.toString('utf8')); } catch (_error) { throw new Error('Nasiko manifest is not valid JSON.'); }
|
||||||
manifest = JSON.parse(manifestBytes.toString('utf8'));
|
|
||||||
} catch (_error) {
|
|
||||||
throw new Error('Nasiko manifest is not valid JSON.');
|
|
||||||
}
|
|
||||||
if (manifest.schemaVersion !== 2 || !Array.isArray(manifest.layers) || manifest.layers.length !== 1) {
|
if (manifest.schemaVersion !== 2 || !Array.isArray(manifest.layers) || manifest.layers.length !== 1) {
|
||||||
throw new Error('Nasiko manifest must contain exactly one OCI layer.');
|
throw new Error('Nasiko manifest must contain exactly one OCI layer.');
|
||||||
}
|
}
|
||||||
@@ -92,98 +73,63 @@ function validateManifest(manifestBytes) {
|
|||||||
return { digest: layer.digest, size: layer.size };
|
return { digest: layer.digest, size: layer.size };
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateArchiveEntries(entries, expectedBinaryName) {
|
function readTarString(block, offset, length) {
|
||||||
if (!Array.isArray(entries) || entries.length !== 1) {
|
return block.subarray(offset, offset + length).toString('utf8').replace(/\0.*$/, '');
|
||||||
throw new Error('Unsafe archive: expected exactly one binary file.');
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
const header = tar.subarray(offset, offset + 512);
|
||||||
|
if (header.every(byte => byte === 0)) 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 start = offset + 512;
|
||||||
|
const end = start + size;
|
||||||
|
if (!Number.isSafeInteger(size) || size < 0 || end > tar.length) throw new Error('Nasiko archive is truncated.');
|
||||||
|
const payload = tar.subarray(start, end);
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
const [entry] = entries;
|
if (!binary) throw new Error('Unsafe Nasiko archive: expected exactly one bounded regular binary file.');
|
||||||
const normalizedPath = String(entry.path || '').replace(/^\.\//, '');
|
return binary;
|
||||||
if (normalizedPath !== expectedBinaryName || normalizedPath.includes('..') || path.isAbsolute(normalizedPath)) {
|
|
||||||
throw new Error('Unsafe archive path: expected only the Nasiko binary.');
|
|
||||||
}
|
|
||||||
if (entry.type !== 'file') {
|
|
||||||
throw new Error('Nasiko archive entry must be a regular file.');
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchBytes(url, options = {}) {
|
function fetchBytes(url, options = {}) {
|
||||||
const maxBytes = options.maxBytes || MAX_ARCHIVE_BYTES;
|
|
||||||
const timeoutMs = options.timeoutMs || 15000;
|
|
||||||
const parsed = new URL(url);
|
const parsed = new URL(url);
|
||||||
if (parsed.origin !== REGISTRY_ORIGIN || parsed.protocol !== 'https:') {
|
if (parsed.origin !== REGISTRY_ORIGIN || parsed.protocol !== 'https:') return Promise.reject(new Error('Nasiko download origin is not allowed.'));
|
||||||
return Promise.reject(new Error('Nasiko download origin is not allowed.'));
|
const maxBytes = options.maxBytes || MAX_ARCHIVE_BYTES;
|
||||||
}
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const request = https.get(parsed, {
|
const request = https.get(parsed, { headers: options.accept ? { Accept: options.accept } : {} }, response => {
|
||||||
headers: options.accept ? { Accept: options.accept } : {},
|
if (response.statusCode >= 300 && response.statusCode < 400) { response.resume(); reject(new Error('Nasiko registry redirects are not allowed.')); return; }
|
||||||
}, response => {
|
if (response.statusCode !== 200) { response.resume(); reject(new Error(`Nasiko registry returned HTTP ${response.statusCode}.`)); return; }
|
||||||
if (response.statusCode >= 300 && response.statusCode < 400) {
|
|
||||||
response.resume();
|
|
||||||
reject(new Error('Nasiko registry redirects are not allowed.'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (response.statusCode !== 200) {
|
|
||||||
response.resume();
|
|
||||||
reject(new Error(`Nasiko registry returned HTTP ${response.statusCode}.`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const chunks = [];
|
const chunks = [];
|
||||||
let totalBytes = 0;
|
let total = 0;
|
||||||
response.on('data', chunk => {
|
response.on('data', chunk => {
|
||||||
totalBytes += chunk.length;
|
total += chunk.length;
|
||||||
if (totalBytes > maxBytes) {
|
if (total > maxBytes) request.destroy(new Error('Nasiko registry response exceeded the size limit.'));
|
||||||
request.destroy(new Error('Nasiko registry response exceeded the size limit.'));
|
else chunks.push(chunk);
|
||||||
return;
|
|
||||||
}
|
|
||||||
chunks.push(chunk);
|
|
||||||
});
|
});
|
||||||
response.on('end', () => resolve(Buffer.concat(chunks)));
|
response.on('end', () => resolve(Buffer.concat(chunks)));
|
||||||
response.on('error', reject);
|
response.on('error', reject);
|
||||||
});
|
});
|
||||||
request.setTimeout(timeoutMs, () => request.destroy(new Error('Nasiko registry request timed out.')));
|
request.setTimeout(options.timeoutMs || 15000, () => request.destroy(new Error('Nasiko registry request timed out.')));
|
||||||
request.on('error', reject);
|
request.on('error', reject);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function inspectArchive(archivePath) {
|
|
||||||
const result = spawnSync('tar', ['-tvzf', archivePath], {
|
|
||||||
encoding: 'utf8',
|
|
||||||
shell: false,
|
|
||||||
timeout: 15000,
|
|
||||||
});
|
|
||||||
if (result.status !== 0) {
|
|
||||||
throw new Error('Nasiko archive inspection failed.');
|
|
||||||
}
|
|
||||||
return result.stdout.split(/\r?\n/).filter(Boolean).map(line => {
|
|
||||||
const typeMarker = line[0];
|
|
||||||
const entryPath = line.trim().split(/\s+/).at(-1);
|
|
||||||
return {
|
|
||||||
path: entryPath,
|
|
||||||
type: typeMarker === '-' ? 'file' : typeMarker === 'l' ? 'symlink' : 'other',
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractArchive(archivePath, destination) {
|
|
||||||
const result = spawnSync('tar', ['-xzf', archivePath, '-C', destination], {
|
|
||||||
encoding: 'utf8',
|
|
||||||
shell: false,
|
|
||||||
timeout: 30000,
|
|
||||||
});
|
|
||||||
if (result.status !== 0) {
|
|
||||||
throw new Error('Nasiko archive extraction failed.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function runVersion(executable) {
|
|
||||||
return spawnSync(executable, ['--version'], {
|
|
||||||
encoding: 'utf8',
|
|
||||||
shell: false,
|
|
||||||
timeout: 10000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultInstallDirectory(normalized, environment = process.env, homeDirectory = os.homedir()) {
|
function defaultInstallDirectory(normalized, environment = process.env, homeDirectory = os.homedir()) {
|
||||||
if (normalized.os === 'windows') {
|
if (normalized.os === 'windows') {
|
||||||
if (!environment.LOCALAPPDATA) throw new Error('LOCALAPPDATA is required on Windows.');
|
if (!environment.LOCALAPPDATA) throw new Error('LOCALAPPDATA is required on Windows.');
|
||||||
@@ -192,132 +138,158 @@ function defaultInstallDirectory(normalized, environment = process.env, homeDire
|
|||||||
return path.join(homeDirectory, '.local', 'bin');
|
return path.join(homeDirectory, '.local', 'bin');
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateInstallDirectory(installDirectory) {
|
function validateInstallDirectory(directory) {
|
||||||
if (typeof installDirectory !== 'string' || installDirectory.includes('\0') || !path.isAbsolute(installDirectory)) {
|
if (typeof directory !== 'string' || directory.includes('\0') || !path.isAbsolute(directory)) throw new Error('Nasiko install directory must be an absolute path.');
|
||||||
throw new Error('Nasiko install directory must be an absolute path.');
|
if (/^(?:\\\\|\\\\\?\\|\\\\\.\\)/.test(directory)) throw new Error('Nasiko install directory must be on a local filesystem.');
|
||||||
}
|
const resolved = path.resolve(directory);
|
||||||
const resolved = path.resolve(installDirectory);
|
if (resolved === path.parse(resolved).root) throw new Error('Nasiko cannot install directly into a filesystem root.');
|
||||||
if (resolved === path.parse(resolved).root) {
|
let ancestor = resolved;
|
||||||
throw new Error('Nasiko cannot install directly into a filesystem root.');
|
while (!fs.existsSync(ancestor)) ancestor = path.dirname(ancestor);
|
||||||
}
|
const canonical = fs.realpathSync(ancestor);
|
||||||
return resolved;
|
return path.join(canonical, path.relative(ancestor, resolved));
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertDirectoryNotSymlink(directoryPath) {
|
function assertPrivateInstallDirectory(directory) {
|
||||||
if (!fs.existsSync(directoryPath)) return;
|
const stats = fs.lstatSync(directory);
|
||||||
const stats = fs.lstatSync(directoryPath);
|
if (!stats.isDirectory() || stats.isSymbolicLink()) throw new Error('Nasiko install directory must be a real directory, not a symlink.');
|
||||||
if (!stats.isDirectory() || stats.isSymbolicLink()) {
|
if (process.platform !== 'win32') {
|
||||||
throw new Error('Nasiko install directory must be a real directory, not a symlink.');
|
if (typeof process.getuid === 'function' && stats.uid !== process.getuid()) throw new Error('Nasiko install directory must be owned by the current user.');
|
||||||
|
if ((stats.mode & 0o022) !== 0) throw new Error('Nasiko install directory must not be group- or world-writable.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function metadataPathFor(executable) { return path.join(path.dirname(executable), METADATA_FILENAME); }
|
||||||
|
|
||||||
|
function readMetadata(executable) {
|
||||||
|
try {
|
||||||
|
const metadataPath = metadataPathFor(executable);
|
||||||
|
const stats = fs.lstatSync(metadataPath);
|
||||||
|
if (!stats.isFile() || stats.isSymbolicLink() || stats.size <= 0 || stats.size > MAX_METADATA_BYTES) return null;
|
||||||
|
return JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
|
||||||
|
}
|
||||||
|
catch (_error) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function inspectInstalledNasiko(executable, resolveRelease = getQualifiedRelease) {
|
||||||
|
if (!executable || !fs.existsSync(executable)) return { installed: false, qualified: false, version: null, executable: executable || null };
|
||||||
|
const stats = fs.lstatSync(executable);
|
||||||
|
if (!stats.isFile() || stats.isSymbolicLink()) throw new Error('Nasiko executable must be a regular file, not a symlink.');
|
||||||
|
if (stats.size <= 0 || stats.size > MAX_BINARY_BYTES) return { installed: true, qualified: false, version: null, executable, binaryDigest: null, metadataPath: metadataPathFor(executable) };
|
||||||
|
const binaryDigest = digestBytes(fs.readFileSync(executable));
|
||||||
|
const metadata = readMetadata(executable);
|
||||||
|
let release = null;
|
||||||
|
try { if (metadata) release = resolveRelease(metadata.version, metadata.platform, metadata.architecture); } catch (_error) { release = null; }
|
||||||
|
const qualified = Boolean(release
|
||||||
|
&& metadata.installedPath === executable
|
||||||
|
&& metadata.manifestDigest === release.manifestDigest
|
||||||
|
&& metadata.binaryDigest === release.binaryDigest
|
||||||
|
&& binaryDigest === release.binaryDigest
|
||||||
|
&& metadata.license === release.license
|
||||||
|
&& metadata.sourceUrl === release.sourceUrl);
|
||||||
|
return { installed: true, qualified, version: qualified ? metadata.version : null, executable, binaryDigest, metadataPath: metadataPathFor(executable) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeMetadataExclusive(metadataPath, metadata) {
|
||||||
|
fs.writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function acquireLifecycleLock(installDirectory) {
|
||||||
|
const lockPath = path.join(installDirectory, '.ecc-nasiko-lifecycle.lock');
|
||||||
|
let descriptor;
|
||||||
|
try { descriptor = fs.openSync(lockPath, 'wx', 0o600); }
|
||||||
|
catch (error) {
|
||||||
|
if (error.code === 'EEXIST') throw new Error('Another Nasiko lifecycle operation is already in progress.');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
fs.closeSync(descriptor);
|
||||||
|
fs.rmSync(lockPath, { force: true });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function installNasiko(options = {}, dependencies = {}) {
|
async function installNasiko(options = {}, dependencies = {}) {
|
||||||
const version = options.version || 'v0.1.0';
|
const version = options.version || 'v0.1.0';
|
||||||
const qualified = getQualifiedRelease(
|
const base = getQualifiedRelease(version, dependencies.platform || process.platform, dependencies.arch || process.arch);
|
||||||
version,
|
const release = dependencies.releaseOverride ? { ...base, ...dependencies.releaseOverride } : base;
|
||||||
dependencies.platform || process.platform,
|
const installDirectory = validateInstallDirectory(options.installDir || defaultInstallDirectory(release, dependencies.environment || process.env, dependencies.homeDirectory || os.homedir()));
|
||||||
dependencies.arch || process.arch
|
|
||||||
);
|
|
||||||
const release = dependencies.releaseOverride
|
|
||||||
? { ...qualified, ...dependencies.releaseOverride }
|
|
||||||
: qualified;
|
|
||||||
const installDirectory = validateInstallDirectory(options.installDir || defaultInstallDirectory(
|
|
||||||
release,
|
|
||||||
dependencies.environment || process.env,
|
|
||||||
dependencies.homeDirectory || os.homedir()
|
|
||||||
));
|
|
||||||
const destination = path.join(installDirectory, release.binaryName);
|
const destination = path.join(installDirectory, release.binaryName);
|
||||||
const plan = {
|
const plan = { dryRun: Boolean(options.dryRun), version, platform: release.os, architecture: release.arch, manifestDigest: release.manifestDigest, binaryDigest: release.binaryDigest, registryOrigin: REGISTRY_ORIGIN, destination, license: release.license, sourceUrl: release.sourceUrl };
|
||||||
dryRun: Boolean(options.dryRun),
|
|
||||||
version,
|
|
||||||
platform: release.os,
|
|
||||||
architecture: release.arch,
|
|
||||||
manifestDigest: release.manifestDigest,
|
|
||||||
registryOrigin: REGISTRY_ORIGIN,
|
|
||||||
destination,
|
|
||||||
};
|
|
||||||
if (options.dryRun) return plan;
|
if (options.dryRun) return plan;
|
||||||
if (!options.yes) throw new Error('Nasiko installation requires explicit --yes consent.');
|
if (!options.yes) throw new Error('Nasiko installation requires explicit --yes consent.');
|
||||||
|
|
||||||
assertDirectoryNotSymlink(installDirectory);
|
|
||||||
fs.mkdirSync(installDirectory, { recursive: true, mode: 0o755 });
|
fs.mkdirSync(installDirectory, { recursive: true, mode: 0o755 });
|
||||||
assertDirectoryNotSymlink(installDirectory);
|
assertPrivateInstallDirectory(installDirectory);
|
||||||
if (fs.existsSync(destination)) {
|
const releaseLock = acquireLifecycleLock(installDirectory);
|
||||||
if (fs.lstatSync(destination).isSymbolicLink()) {
|
const metadataPath = metadataPathFor(destination);
|
||||||
throw new Error('Refusing to replace a symlinked Nasiko executable.');
|
let destinationOwned = false;
|
||||||
}
|
let metadataOwned = false;
|
||||||
const existing = (dependencies.runVersion || runVersion)(destination);
|
|
||||||
const output = `${existing.stdout || ''}\n${existing.stderr || ''}`;
|
|
||||||
if (existing.status === 0 && output.includes(version)) {
|
|
||||||
return { ...plan, dryRun: false, installed: true, reused: true };
|
|
||||||
}
|
|
||||||
throw new Error('An incompatible Nasiko executable already exists at the destination.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const retrieve = dependencies.fetchBytes || fetchBytes;
|
|
||||||
const manifestUrl = `${REGISTRY_ORIGIN}/v2/${REPOSITORY}/manifests/${release.manifestDigest}`;
|
|
||||||
const manifestBytes = await retrieve(manifestUrl, {
|
|
||||||
accept: 'application/vnd.oci.image.manifest.v1+json',
|
|
||||||
maxBytes: MAX_MANIFEST_BYTES,
|
|
||||||
});
|
|
||||||
assertDigest(manifestBytes, release.manifestDigest, 'Nasiko manifest');
|
|
||||||
const layer = validateManifest(manifestBytes);
|
|
||||||
const archiveUrl = `${REGISTRY_ORIGIN}/v2/${REPOSITORY}/blobs/${layer.digest}`;
|
|
||||||
const archiveBytes = await retrieve(archiveUrl, { maxBytes: MAX_ARCHIVE_BYTES });
|
|
||||||
if (archiveBytes.length !== layer.size) throw new Error('Nasiko archive size mismatch.');
|
|
||||||
assertDigest(archiveBytes, layer.digest, 'Nasiko archive');
|
|
||||||
|
|
||||||
const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-install-'));
|
|
||||||
try {
|
try {
|
||||||
const archivePath = path.join(temporaryDirectory, 'nasiko.tar.gz');
|
if (fs.existsSync(destination) || fs.existsSync(metadataPath)) {
|
||||||
const extractionDirectory = path.join(temporaryDirectory, 'extract');
|
const existing = inspectInstalledNasiko(destination);
|
||||||
fs.mkdirSync(extractionDirectory, { mode: 0o700 });
|
if (existing.qualified && existing.version === version) return { ...plan, dryRun: false, installed: true, reused: true };
|
||||||
fs.writeFileSync(archivePath, archiveBytes, { mode: 0o600 });
|
throw new Error('An unqualified or incompatible Nasiko executable or receipt already exists at the destination.');
|
||||||
const inspect = dependencies.inspectArchive || inspectArchive;
|
|
||||||
validateArchiveEntries(inspect(archivePath), release.binaryName);
|
|
||||||
(dependencies.extractArchive || extractArchive)(archivePath, extractionDirectory);
|
|
||||||
const extractedBinary = path.join(extractionDirectory, release.binaryName);
|
|
||||||
const extractedStats = fs.lstatSync(extractedBinary);
|
|
||||||
if (!extractedStats.isFile() || extractedStats.isSymbolicLink()) {
|
|
||||||
throw new Error('Extracted Nasiko binary is not a regular file.');
|
|
||||||
}
|
}
|
||||||
fs.chmodSync(extractedBinary, 0o755);
|
const retrieve = dependencies.fetchBytes || fetchBytes;
|
||||||
const stagedDestination = path.join(installDirectory, `.${release.binaryName}.tmp-${process.pid}`);
|
const manifestBytes = await retrieve(`${REGISTRY_ORIGIN}/v2/${REPOSITORY}/manifests/${release.manifestDigest}`, { accept: 'application/vnd.oci.image.manifest.v1+json', maxBytes: MAX_MANIFEST_BYTES });
|
||||||
fs.copyFileSync(extractedBinary, stagedDestination, fs.constants.COPYFILE_EXCL);
|
assertDigest(manifestBytes, release.manifestDigest, 'Nasiko manifest');
|
||||||
fs.chmodSync(stagedDestination, 0o755);
|
const layer = validateManifest(manifestBytes);
|
||||||
fs.renameSync(stagedDestination, destination);
|
const archiveBytes = await retrieve(`${REGISTRY_ORIGIN}/v2/${REPOSITORY}/blobs/${layer.digest}`, { maxBytes: MAX_ARCHIVE_BYTES });
|
||||||
const versionResult = (dependencies.runVersion || runVersion)(destination);
|
if (archiveBytes.length !== layer.size) throw new Error('Nasiko archive size mismatch.');
|
||||||
const versionOutput = `${versionResult.stdout || ''}\n${versionResult.stderr || ''}`;
|
assertDigest(archiveBytes, layer.digest, 'Nasiko archive');
|
||||||
if (versionResult.status !== 0 || !versionOutput.includes(version)) {
|
const binary = (dependencies.extractBinary || extractQualifiedTarGzip)(archiveBytes, release.binaryName);
|
||||||
fs.rmSync(destination, { force: true });
|
assertDigest(binary, release.binaryDigest, 'Nasiko binary');
|
||||||
throw new Error('Installed Nasiko binary did not report the qualified version.');
|
if (dependencies.beforePublish) dependencies.beforePublish(destination);
|
||||||
}
|
const descriptor = fs.openSync(destination, 'wx', 0o700);
|
||||||
const metadata = {
|
destinationOwned = true;
|
||||||
version,
|
try { fs.writeFileSync(descriptor, binary); fs.fsyncSync(descriptor); } finally { fs.closeSync(descriptor); }
|
||||||
platform: release.os,
|
assertDigest(fs.readFileSync(destination), release.binaryDigest, 'Published Nasiko binary');
|
||||||
architecture: release.arch,
|
const metadata = { version, platform: release.os, architecture: release.arch, manifestDigest: release.manifestDigest, artifactDigest: layer.digest, binaryDigest: release.binaryDigest, installedPath: destination, license: release.license, sourceUrl: release.sourceUrl };
|
||||||
manifestDigest: release.manifestDigest,
|
(dependencies.writeMetadata || writeMetadataExclusive)(metadataPath, metadata);
|
||||||
artifactDigest: layer.digest,
|
metadataOwned = true;
|
||||||
installedPath: destination,
|
|
||||||
};
|
|
||||||
const metadataPath = path.join(installDirectory, '.ecc-nasiko-install.json');
|
|
||||||
const temporaryMetadata = `${metadataPath}.tmp-${process.pid}`;
|
|
||||||
fs.writeFileSync(temporaryMetadata, `${JSON.stringify(metadata, null, 2)}\n`, { mode: 0o600 });
|
|
||||||
fs.renameSync(temporaryMetadata, metadataPath);
|
|
||||||
return { ...plan, dryRun: false, installed: true, reused: false, artifactDigest: layer.digest };
|
return { ...plan, dryRun: false, installed: true, reused: false, artifactDigest: layer.digest };
|
||||||
} finally {
|
} catch (error) {
|
||||||
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
|
if (metadataOwned) fs.rmSync(metadataPath, { force: true });
|
||||||
}
|
if (destinationOwned) fs.rmSync(destination, { force: true });
|
||||||
|
throw error;
|
||||||
|
} finally { releaseLock(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
function uninstallNasiko(options = {}, dependencies = {}) {
|
||||||
QUALIFIED_RELEASES,
|
const version = options.version || 'v0.1.0';
|
||||||
REGISTRY_ORIGIN,
|
const release = getQualifiedRelease(version, dependencies.platform || process.platform, dependencies.arch || process.arch);
|
||||||
digestBytes,
|
const installDirectory = validateInstallDirectory(options.installDir || defaultInstallDirectory(release, dependencies.environment || process.env, dependencies.homeDirectory || os.homedir()));
|
||||||
fetchBytes,
|
const destination = path.join(installDirectory, release.binaryName);
|
||||||
getQualifiedRelease,
|
const plan = { dryRun: Boolean(options.dryRun), version, destination };
|
||||||
installNasiko,
|
if (options.dryRun) return plan;
|
||||||
normalizePlatform,
|
if (!options.yes) throw new Error('Nasiko uninstall requires explicit --yes consent.');
|
||||||
validateArchiveEntries,
|
if (!fs.existsSync(installDirectory)) return { ...plan, dryRun: false, removed: false };
|
||||||
validateInstallDirectory,
|
assertPrivateInstallDirectory(installDirectory);
|
||||||
};
|
const releaseLock = acquireLifecycleLock(installDirectory);
|
||||||
|
const metadataPath = metadataPathFor(destination);
|
||||||
|
const suffix = `${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
|
||||||
|
const binaryTombstone = `${destination}.remove-${suffix}`;
|
||||||
|
const metadataTombstone = `${metadataPath}.remove-${suffix}`;
|
||||||
|
let binaryStaged = false;
|
||||||
|
let metadataStaged = false;
|
||||||
|
const rename = dependencies.rename || fs.renameSync;
|
||||||
|
try {
|
||||||
|
const status = (dependencies.inspectInstalled || inspectInstalledNasiko)(destination);
|
||||||
|
if (!status.installed) return { ...plan, dryRun: false, removed: false };
|
||||||
|
if (!status.qualified || status.version !== version) throw new Error('Refusing to remove an unqualified or modified Nasiko executable.');
|
||||||
|
rename(destination, binaryTombstone);
|
||||||
|
binaryStaged = true;
|
||||||
|
rename(metadataPath, metadataTombstone);
|
||||||
|
metadataStaged = true;
|
||||||
|
const cleanupPending = [];
|
||||||
|
try { fs.rmSync(metadataTombstone); } catch (_error) { cleanupPending.push(metadataTombstone); }
|
||||||
|
metadataStaged = false;
|
||||||
|
try { fs.rmSync(binaryTombstone); } catch (_error) { cleanupPending.push(binaryTombstone); }
|
||||||
|
binaryStaged = false;
|
||||||
|
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);
|
||||||
|
throw error;
|
||||||
|
} finally { releaseLock(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { QUALIFIED_RELEASES, REGISTRY_ORIGIN, digestBytes, extractQualifiedTarGzip, fetchBytes, getQualifiedRelease, inspectInstalledNasiko, installNasiko, normalizePlatform, uninstallNasiko, validateInstallDirectory };
|
||||||
|
|||||||
+46
-28
@@ -4,10 +4,11 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { spawnSync } = require('child_process');
|
|
||||||
const {
|
const {
|
||||||
|
inspectInstalledNasiko,
|
||||||
installNasiko,
|
installNasiko,
|
||||||
normalizePlatform,
|
normalizePlatform,
|
||||||
|
uninstallNasiko,
|
||||||
validateInstallDirectory,
|
validateInstallDirectory,
|
||||||
} = require('./lib/nasiko-release');
|
} = require('./lib/nasiko-release');
|
||||||
|
|
||||||
@@ -16,9 +17,10 @@ function helpText() {
|
|||||||
ECC Nasiko control-plane bridge
|
ECC Nasiko control-plane bridge
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
ecc nasiko status [--json]
|
ecc nasiko status [--install-dir <absolute-path>] [--json]
|
||||||
ecc nasiko install --version v0.1.0 --yes [--install-dir <absolute-path>] [--json]
|
ecc nasiko install --version v0.1.0 --yes [--install-dir <absolute-path>] [--json]
|
||||||
ecc nasiko install --version v0.1.0 --dry-run [--install-dir <absolute-path>] [--json]
|
ecc nasiko install --version v0.1.0 --dry-run [--install-dir <absolute-path>] [--json]
|
||||||
|
ecc nasiko uninstall --version v0.1.0 --yes [--install-dir <absolute-path>] [--json]
|
||||||
|
|
||||||
The installer is opt-in, accepts only ECC-qualified pinned releases, downloads
|
The installer is opt-in, accepts only ECC-qualified pinned releases, downloads
|
||||||
content-addressed OCI artifacts from registry.nasiko.dev, verifies SHA-256
|
content-addressed OCI artifacts from registry.nasiko.dev, verifies SHA-256
|
||||||
@@ -26,7 +28,7 @@ digests before extraction, and never executes fetched shell or PowerShell code.
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseInstallArguments(argumentsList) {
|
function parseMutationArguments(argumentsList, command = 'install') {
|
||||||
let options = { dryRun: false, installDir: undefined, json: false, version: undefined, yes: false };
|
let options = { dryRun: false, installDir: undefined, json: false, version: undefined, yes: false };
|
||||||
for (let index = 0; index < argumentsList.length; index += 1) {
|
for (let index = 0; index < argumentsList.length; index += 1) {
|
||||||
const argument = argumentsList[index];
|
const argument = argumentsList[index];
|
||||||
@@ -45,10 +47,10 @@ function parseInstallArguments(argumentsList) {
|
|||||||
} else if (argument === '--json') {
|
} else if (argument === '--json') {
|
||||||
options = { ...options, json: true };
|
options = { ...options, json: true };
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Unknown Nasiko install argument: ${argument}`);
|
throw new Error(`Unknown Nasiko ${command} argument: ${argument}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!options.version) throw new Error('Nasiko install requires --version v0.1.0.');
|
if (!options.version) throw new Error(`Nasiko ${command} requires --version v0.1.0.`);
|
||||||
if (options.installDir) validateInstallDirectory(options.installDir);
|
if (options.installDir) validateInstallDirectory(options.installDir);
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
@@ -63,9 +65,12 @@ function defaultExecutablePath() {
|
|||||||
return path.join(os.homedir(), '.local', 'bin', normalized.binaryName);
|
return path.join(os.homedir(), '.local', 'bin', normalized.binaryName);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveExecutable() {
|
function resolveExecutable(options = {}) {
|
||||||
const configured = process.env.ECC_NASIKO_CLI_EXECUTABLE;
|
const configured = process.env.ECC_NASIKO_CLI_EXECUTABLE;
|
||||||
const candidate = configured || defaultExecutablePath();
|
const normalized = normalizePlatform();
|
||||||
|
const candidate = options.installDir
|
||||||
|
? path.join(validateInstallDirectory(options.installDir), normalized.binaryName)
|
||||||
|
: configured || defaultExecutablePath();
|
||||||
if (!candidate) return null;
|
if (!candidate) return null;
|
||||||
if (!path.isAbsolute(candidate)) {
|
if (!path.isAbsolute(candidate)) {
|
||||||
throw new Error('ECC_NASIKO_CLI_EXECUTABLE must be an absolute path.');
|
throw new Error('ECC_NASIKO_CLI_EXECUTABLE must be an absolute path.');
|
||||||
@@ -78,21 +83,25 @@ function resolveExecutable() {
|
|||||||
return candidate;
|
return candidate;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readStatus() {
|
function readStatus(options = {}) {
|
||||||
const executable = resolveExecutable();
|
const executable = resolveExecutable(options);
|
||||||
if (!executable) return { installed: false, version: null, executable: null };
|
if (!executable) return { installed: false, version: null, executable: null };
|
||||||
const result = spawnSync(executable, ['--version'], {
|
return inspectInstalledNasiko(executable);
|
||||||
encoding: 'utf8',
|
}
|
||||||
shell: false,
|
|
||||||
timeout: 10000,
|
function parseStatusArguments(argumentsList) {
|
||||||
});
|
let options = { installDir: undefined, json: false };
|
||||||
if (result.status !== 0) {
|
for (let index = 0; index < argumentsList.length; index += 1) {
|
||||||
throw new Error('Nasiko executable failed its version check.');
|
const argument = argumentsList[index];
|
||||||
|
if (argument === '--json') options = { ...options, json: true };
|
||||||
|
else if (argument === '--install-dir') {
|
||||||
|
const value = argumentsList[index + 1];
|
||||||
|
if (!value || value.startsWith('--')) throw new Error('Missing value for --install-dir.');
|
||||||
|
options = { ...options, installDir: validateInstallDirectory(value) };
|
||||||
|
index += 1;
|
||||||
|
} else throw new Error(`Unknown Nasiko status argument: ${argument}`);
|
||||||
}
|
}
|
||||||
const output = `${result.stdout || ''}\n${result.stderr || ''}`;
|
return options;
|
||||||
const version = output.match(/\bv\d+\.\d+\.\d+\b/)?.[0] || null;
|
|
||||||
if (!version) throw new Error('Nasiko executable returned an unrecognized version.');
|
|
||||||
return { installed: true, version, executable };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main(argumentsList = process.argv.slice(2)) {
|
async function main(argumentsList = process.argv.slice(2)) {
|
||||||
@@ -102,23 +111,32 @@ async function main(argumentsList = process.argv.slice(2)) {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (command === 'status') {
|
if (command === 'status') {
|
||||||
const unknown = rest.filter(argument => argument !== '--json');
|
const options = parseStatusArguments(rest);
|
||||||
if (unknown.length > 0) throw new Error(`Unknown Nasiko status argument: ${unknown[0]}`);
|
const status = readStatus(options);
|
||||||
const status = readStatus();
|
if (options.json) process.stdout.write(`${JSON.stringify(status, null, 2)}\n`);
|
||||||
if (rest.includes('--json')) process.stdout.write(`${JSON.stringify(status, null, 2)}\n`);
|
else process.stdout.write(status.qualified
|
||||||
else process.stdout.write(status.installed
|
? `Qualified Nasiko ${status.version} is installed at ${status.executable}.\n`
|
||||||
? `Nasiko ${status.version} is installed at ${status.executable}.\n`
|
: status.installed
|
||||||
|
? `An unqualified Nasiko file exists at ${status.executable}; it was not executed.\n`
|
||||||
: 'Nasiko is not installed in the ECC-qualified location.\n');
|
: 'Nasiko is not installed in the ECC-qualified location.\n');
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (command === 'install') {
|
if (command === 'install') {
|
||||||
const options = parseInstallArguments(rest);
|
const options = parseMutationArguments(rest, 'install');
|
||||||
const result = await installNasiko(options);
|
const result = await installNasiko(options);
|
||||||
if (options.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
if (options.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||||
else if (result.dryRun) process.stdout.write(`Would install Nasiko ${result.version} to ${result.destination}.\n`);
|
else if (result.dryRun) process.stdout.write(`Would install Nasiko ${result.version} to ${result.destination}.\n`);
|
||||||
else process.stdout.write(`${result.reused ? 'Using existing' : 'Installed'} Nasiko ${result.version} at ${result.destination}.\n`);
|
else process.stdout.write(`${result.reused ? 'Using existing' : 'Installed'} Nasiko ${result.version} at ${result.destination}.\n`);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
if (command === 'uninstall') {
|
||||||
|
const options = parseMutationArguments(rest, 'uninstall');
|
||||||
|
const result = uninstallNasiko(options);
|
||||||
|
if (options.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||||
|
else if (result.dryRun) process.stdout.write(`Would uninstall Nasiko ${result.version} from ${result.destination}.\n`);
|
||||||
|
else process.stdout.write(result.removed ? `Uninstalled Nasiko ${result.version}.\n` : 'Nasiko was not installed.\n');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
throw new Error(`Unsupported Nasiko command: ${command}`);
|
throw new Error(`Unsupported Nasiko command: ${command}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,4 +149,4 @@ if (require.main === module) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { main, parseInstallArguments, readStatus, resolveExecutable };
|
module.exports = { main, parseInstallArguments: parseMutationArguments, parseMutationArguments, parseStatusArguments, readStatus, resolveExecutable };
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ Nasiko control plane with ECC.
|
|||||||
- Preview first with `ecc nasiko install --version v0.1.0 --dry-run --json`.
|
- Preview first with `ecc nasiko install --version v0.1.0 --dry-run --json`.
|
||||||
- Install with `ecc nasiko install --version v0.1.0 --yes --json` only after the
|
- Install with `ecc nasiko install --version v0.1.0 --yes --json` only after the
|
||||||
user reviews the version, registry origin, digest, and destination.
|
user reviews the version, registry origin, digest, and destination.
|
||||||
|
- Remove only a still-qualified ECC-managed binary with
|
||||||
|
`ecc nasiko uninstall --version v0.1.0 --yes --json`. Preview removal with
|
||||||
|
`--dry-run` first.
|
||||||
|
- The qualified source is `https://github.com/Nasiko-Labs/nasiko`, licensed
|
||||||
|
under Apache-2.0; artifact and extracted-binary SHA-256 values are pinned.
|
||||||
- Never replace the qualified command with a downloaded shell or PowerShell
|
- Never replace the qualified command with a downloaded shell or PowerShell
|
||||||
bootstrap script.
|
bootstrap script.
|
||||||
- Never put secrets or credentials in command arguments, logs, skill output,
|
- Never put secrets or credentials in command arguments, logs, skill output,
|
||||||
@@ -25,8 +30,8 @@ Nasiko control plane with ECC.
|
|||||||
|
|
||||||
## Lifecycle boundary
|
## Lifecycle boundary
|
||||||
|
|
||||||
The initial ECC bridge supports only qualified installation and read-only
|
The initial ECC bridge supports qualified installation, read-only status, and
|
||||||
status. Use the canonical Nasiko CLI directly for connection, authentication,
|
ownership-checked uninstall. Use the canonical Nasiko CLI directly for connection, authentication,
|
||||||
launch, deployment, or shutdown until those verbs have their own verified ECC
|
launch, deployment, or shutdown until those verbs have their own verified ECC
|
||||||
contracts. Do not guess CLI verbs.
|
contracts. Do not guess CLI verbs.
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ async function main() {
|
|||||||
binaryName: 'nasiko.exe',
|
binaryName: 'nasiko.exe',
|
||||||
});
|
});
|
||||||
assert.match(getQualifiedRelease('v0.1.0', 'linux', 'x64').manifestDigest, /^sha256:[a-f0-9]{64}$/);
|
assert.match(getQualifiedRelease('v0.1.0', 'linux', 'x64').manifestDigest, /^sha256:[a-f0-9]{64}$/);
|
||||||
|
assert.match(getQualifiedRelease('v0.1.0', 'linux', 'x64').binaryDigest, /^sha256:[a-f0-9]{64}$/);
|
||||||
|
assert.strictEqual(getQualifiedRelease('v0.1.0', 'linux', 'x64').license, 'Apache-2.0');
|
||||||
assert.throws(() => getQualifiedRelease('latest', 'darwin', 'arm64'), /pinned version/i);
|
assert.throws(() => getQualifiedRelease('latest', 'darwin', 'arm64'), /pinned version/i);
|
||||||
assert.throws(() => getQualifiedRelease('v1.0.0', 'darwin', 'arm64'), /not qualified/i);
|
assert.throws(() => getQualifiedRelease('v1.0.0', 'darwin', 'arm64'), /not qualified/i);
|
||||||
assert.throws(() => normalizePlatform('freebsd', 'x64'), /unsupported platform/i);
|
assert.throws(() => normalizePlatform('freebsd', 'x64'), /unsupported platform/i);
|
||||||
@@ -86,6 +88,7 @@ async function main() {
|
|||||||
['verifies manifest and blob digests before an atomic install', async () => {
|
['verifies manifest and blob digests before an atomic install', async () => {
|
||||||
const { installNasiko } = require('../../scripts/lib/nasiko-release');
|
const { installNasiko } = require('../../scripts/lib/nasiko-release');
|
||||||
const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-green-'));
|
const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-green-'));
|
||||||
|
const binary = Buffer.from('#!/bin/sh\necho nasiko 0.1.0\n');
|
||||||
const manifest = Buffer.from(JSON.stringify({
|
const manifest = Buffer.from(JSON.stringify({
|
||||||
schemaVersion: 2,
|
schemaVersion: 2,
|
||||||
mediaType: 'application/vnd.oci.image.manifest.v1+json',
|
mediaType: 'application/vnd.oci.image.manifest.v1+json',
|
||||||
@@ -105,24 +108,50 @@ async function main() {
|
|||||||
}, {
|
}, {
|
||||||
platform: 'darwin',
|
platform: 'darwin',
|
||||||
arch: 'arm64',
|
arch: 'arm64',
|
||||||
releaseOverride: { manifestDigest: sha256Digest(manifest) },
|
releaseOverride: {
|
||||||
fetchBytes: async (url) => url.includes('/manifests/') ? manifest : archive,
|
manifestDigest: sha256Digest(manifest),
|
||||||
inspectArchive: () => [{ path: 'nasiko', type: 'file' }],
|
binaryDigest: sha256Digest(binary),
|
||||||
extractArchive: (_archivePath, destination) => {
|
|
||||||
fs.writeFileSync(path.join(destination, 'nasiko'), '#!/bin/sh\necho nasiko v0.1.0\n', { mode: 0o755 });
|
|
||||||
},
|
},
|
||||||
runVersion: executable => ({ status: 0, stdout: `${executable}: nasiko v0.1.0\n`, stderr: '' }),
|
fetchBytes: async (url) => url.includes('/manifests/') ? manifest : archive,
|
||||||
|
extractBinary: () => binary,
|
||||||
});
|
});
|
||||||
assert.strictEqual(result.installed, true);
|
assert.strictEqual(result.installed, true);
|
||||||
assert.strictEqual(result.version, 'v0.1.0');
|
assert.strictEqual(result.version, 'v0.1.0');
|
||||||
assert.strictEqual(fs.existsSync(path.join(installRoot, 'nasiko')), true);
|
assert.strictEqual(fs.existsSync(path.join(installRoot, 'nasiko')), true);
|
||||||
assert.strictEqual(fs.existsSync(path.join(installRoot, '.ecc-nasiko-install.json')), true);
|
assert.strictEqual(fs.existsSync(path.join(installRoot, '.ecc-nasiko-install.json')), true);
|
||||||
|
const { getQualifiedRelease, inspectInstalledNasiko, uninstallNasiko } = require('../../scripts/lib/nasiko-release');
|
||||||
|
const fakeRelease = {
|
||||||
|
...getQualifiedRelease('v0.1.0', 'darwin', 'arm64'),
|
||||||
|
manifestDigest: sha256Digest(manifest),
|
||||||
|
binaryDigest: sha256Digest(binary),
|
||||||
|
};
|
||||||
|
const preview = await uninstallNasiko({ installDir: installRoot, dryRun: true }, {
|
||||||
|
platform: 'darwin', arch: 'arm64', releaseOverride: result,
|
||||||
|
});
|
||||||
|
assert.strictEqual(preview.dryRun, true);
|
||||||
|
let renameCount = 0;
|
||||||
|
await assert.rejects(async () => uninstallNasiko({ installDir: installRoot, yes: true }, {
|
||||||
|
platform: 'darwin', arch: 'arm64',
|
||||||
|
inspectInstalled: destination => inspectInstalledNasiko(destination, () => fakeRelease),
|
||||||
|
rename: (source, destination) => {
|
||||||
|
renameCount += 1;
|
||||||
|
if (renameCount === 2) throw new Error('metadata staging unavailable');
|
||||||
|
fs.renameSync(source, destination);
|
||||||
|
},
|
||||||
|
}), /metadata staging unavailable/i);
|
||||||
|
assert.strictEqual(fs.existsSync(path.join(installRoot, 'nasiko')), true);
|
||||||
|
assert.strictEqual(fs.existsSync(path.join(installRoot, '.ecc-nasiko-install.json')), true);
|
||||||
|
await uninstallNasiko({ installDir: installRoot, yes: true }, {
|
||||||
|
platform: 'darwin', arch: 'arm64',
|
||||||
|
inspectInstalled: destination => inspectInstalledNasiko(destination, () => fakeRelease),
|
||||||
|
});
|
||||||
|
assert.strictEqual(fs.existsSync(path.join(installRoot, 'nasiko')), false);
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(installRoot, { recursive: true, force: true });
|
fs.rmSync(installRoot, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
}],
|
}],
|
||||||
['rejects digest mismatch and unsafe archive entries without installing', async () => {
|
['rejects digest mismatch and unsafe archive entries without installing', async () => {
|
||||||
const { installNasiko, validateArchiveEntries } = require('../../scripts/lib/nasiko-release');
|
const { extractQualifiedTarGzip, installNasiko } = require('../../scripts/lib/nasiko-release');
|
||||||
const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-reject-'));
|
const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-reject-'));
|
||||||
const manifest = Buffer.from('{"schemaVersion":2,"layers":[]}');
|
const manifest = Buffer.from('{"schemaVersion":2,"layers":[]}');
|
||||||
try {
|
try {
|
||||||
@@ -136,22 +165,16 @@ async function main() {
|
|||||||
/manifest digest mismatch/i
|
/manifest digest mismatch/i
|
||||||
);
|
);
|
||||||
assert.strictEqual(fs.existsSync(path.join(installRoot, 'nasiko')), false);
|
assert.strictEqual(fs.existsSync(path.join(installRoot, 'nasiko')), false);
|
||||||
assert.throws(
|
assert.throws(() => extractQualifiedTarGzip(Buffer.from('not gzip'), 'nasiko'), /invalid|size limit/i);
|
||||||
() => validateArchiveEntries([{ path: '../nasiko', type: 'file' }], 'nasiko'),
|
|
||||||
/unsafe archive/i
|
|
||||||
);
|
|
||||||
assert.throws(
|
|
||||||
() => validateArchiveEntries([{ path: 'nasiko', type: 'symlink' }], 'nasiko'),
|
|
||||||
/regular file/i
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(installRoot, { recursive: true, force: true });
|
fs.rmSync(installRoot, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
}],
|
}],
|
||||||
['routes read-only status through an explicit absolute executable', () => {
|
['read-only status never executes an unqualified explicit executable', () => {
|
||||||
const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-status-'));
|
const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-status-'));
|
||||||
const executable = path.join(fixtureRoot, 'nasiko');
|
const executable = path.join(fixtureRoot, 'nasiko');
|
||||||
fs.writeFileSync(executable, '#!/bin/sh\nprintf "nasiko v0.1.0\\n"\n', { mode: 0o755 });
|
const marker = path.join(fixtureRoot, 'executed');
|
||||||
|
fs.writeFileSync(executable, `#!/bin/sh\ntouch ${JSON.stringify(marker)}\nprintf "nasiko 0.1.0\\n"\n`, { mode: 0o755 });
|
||||||
try {
|
try {
|
||||||
const result = spawnSync(process.execPath, [
|
const result = spawnSync(process.execPath, [
|
||||||
path.join(REPO_ROOT, 'scripts', 'ecc.js'),
|
path.join(REPO_ROOT, 'scripts', 'ecc.js'),
|
||||||
@@ -165,12 +188,80 @@ async function main() {
|
|||||||
assert.strictEqual(result.status, 0, result.stderr);
|
assert.strictEqual(result.status, 0, result.stderr);
|
||||||
const status = JSON.parse(result.stdout);
|
const status = JSON.parse(result.stdout);
|
||||||
assert.strictEqual(status.installed, true);
|
assert.strictEqual(status.installed, true);
|
||||||
assert.strictEqual(status.version, 'v0.1.0');
|
assert.strictEqual(status.qualified, false);
|
||||||
|
assert.strictEqual(status.version, null);
|
||||||
assert.strictEqual(status.executable, executable);
|
assert.strictEqual(status.executable, executable);
|
||||||
|
assert.strictEqual(fs.existsSync(marker), false);
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(fixtureRoot, { recursive: true, force: true });
|
fs.rmSync(fixtureRoot, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
}],
|
}],
|
||||||
|
['rejects and never executes an unqualified pre-existing binary', async () => {
|
||||||
|
const { installNasiko } = require('../../scripts/lib/nasiko-release');
|
||||||
|
const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-existing-'));
|
||||||
|
const executable = path.join(installRoot, 'nasiko');
|
||||||
|
const marker = path.join(installRoot, 'executed');
|
||||||
|
fs.writeFileSync(executable, `#!/bin/sh\ntouch ${JSON.stringify(marker)}\necho nasiko 0.1.0\n`, { mode: 0o755 });
|
||||||
|
try {
|
||||||
|
await assert.rejects(
|
||||||
|
installNasiko({ version: 'v0.1.0', yes: true, installDir: installRoot }, {
|
||||||
|
platform: 'darwin', arch: 'arm64',
|
||||||
|
}),
|
||||||
|
/unqualified|digest|metadata/i
|
||||||
|
);
|
||||||
|
assert.strictEqual(fs.existsSync(marker), false);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(installRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
['rolls back a published binary when metadata persistence fails', async () => {
|
||||||
|
const { installNasiko } = require('../../scripts/lib/nasiko-release');
|
||||||
|
const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-rollback-'));
|
||||||
|
const binary = Buffer.from('qualified binary');
|
||||||
|
const archive = Buffer.from('verified archive');
|
||||||
|
const manifest = Buffer.from(JSON.stringify({
|
||||||
|
schemaVersion: 2,
|
||||||
|
mediaType: 'application/vnd.oci.image.manifest.v1+json',
|
||||||
|
layers: [{ mediaType: 'application/gzip', digest: sha256Digest(archive), size: archive.length }],
|
||||||
|
}));
|
||||||
|
try {
|
||||||
|
await assert.rejects(
|
||||||
|
installNasiko({ version: 'v0.1.0', yes: true, installDir: installRoot }, {
|
||||||
|
platform: 'darwin',
|
||||||
|
arch: 'arm64',
|
||||||
|
releaseOverride: {
|
||||||
|
manifestDigest: sha256Digest(manifest),
|
||||||
|
binaryDigest: sha256Digest(binary),
|
||||||
|
},
|
||||||
|
fetchBytes: async url => url.includes('/manifests/') ? manifest : archive,
|
||||||
|
extractBinary: () => binary,
|
||||||
|
writeMetadata: () => { throw new Error('metadata unavailable'); },
|
||||||
|
}),
|
||||||
|
/metadata unavailable/i
|
||||||
|
);
|
||||||
|
assert.strictEqual(fs.existsSync(path.join(installRoot, 'nasiko')), false);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(installRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
['never overwrites or deletes a destination created during publication', async () => {
|
||||||
|
const { installNasiko } = require('../../scripts/lib/nasiko-release');
|
||||||
|
const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-race-'));
|
||||||
|
const binary = Buffer.from('qualified binary');
|
||||||
|
const intruder = Buffer.from('concurrent owner');
|
||||||
|
const archive = Buffer.from('verified archive');
|
||||||
|
const manifest = Buffer.from(JSON.stringify({ schemaVersion: 2, layers: [{ mediaType: 'application/gzip', digest: sha256Digest(archive), size: archive.length }] }));
|
||||||
|
try {
|
||||||
|
await assert.rejects(installNasiko({ version: 'v0.1.0', yes: true, installDir: installRoot }, {
|
||||||
|
platform: 'darwin', arch: 'arm64',
|
||||||
|
releaseOverride: { manifestDigest: sha256Digest(manifest), binaryDigest: sha256Digest(binary) },
|
||||||
|
fetchBytes: async url => url.includes('/manifests/') ? manifest : archive,
|
||||||
|
extractBinary: () => binary,
|
||||||
|
beforePublish: destination => fs.writeFileSync(destination, intruder, { flag: 'wx' }),
|
||||||
|
}), /exist/i);
|
||||||
|
assert.deepStrictEqual(fs.readFileSync(path.join(installRoot, 'nasiko')), intruder);
|
||||||
|
} finally { fs.rmSync(installRoot, { recursive: true, force: true }); }
|
||||||
|
}],
|
||||||
['ships a canonical opt-in skill without silently bundling Nasiko', () => {
|
['ships a canonical opt-in skill without silently bundling Nasiko', () => {
|
||||||
const skill = read('skills/nasiko-control-plane/SKILL.md');
|
const skill = read('skills/nasiko-control-plane/SKILL.md');
|
||||||
assert.match(skill, /^name: nasiko-control-plane$/m);
|
assert.match(skill, /^name: nasiko-control-plane$/m);
|
||||||
@@ -180,6 +271,7 @@ async function main() {
|
|||||||
assert.match(skill, /telemetry.*opt-in/i);
|
assert.match(skill, /telemetry.*opt-in/i);
|
||||||
assert.match(skill, /never.*secrets|never.*credentials/i);
|
assert.match(skill, /never.*secrets|never.*credentials/i);
|
||||||
assert.match(skill, /install.*does not prove/i);
|
assert.match(skill, /install.*does not prove/i);
|
||||||
|
assert.match(skill, /ecc nasiko uninstall/i);
|
||||||
assert.doesNotMatch(skill, /curl[^\n]*\|[^\n]*bash|irm[^\n]*\|[^\n]*iex/i);
|
assert.doesNotMatch(skill, /curl[^\n]*\|[^\n]*bash|irm[^\n]*\|[^\n]*iex/i);
|
||||||
|
|
||||||
const modules = readJson('manifests/install-modules.json').modules;
|
const modules = readJson('manifests/install-modules.json').modules;
|
||||||
@@ -202,14 +294,15 @@ async function main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const profiles = readJson('manifests/install-profiles.json').profiles;
|
const profiles = readJson('manifests/install-profiles.json').profiles;
|
||||||
for (const profile of Object.values(profiles)) {
|
assert.ok(profiles.full.modules.includes('nasiko-control-plane'));
|
||||||
assert.ok(!profile.modules.includes('nasiko-control-plane'));
|
for (const [profileId, profile] of Object.entries(profiles)) {
|
||||||
|
if (profileId !== 'full') assert.ok(!profile.modules.includes('nasiko-control-plane'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const packageJson = readJson('package.json');
|
const packageJson = readJson('package.json');
|
||||||
assert.ok(packageJson.files.includes('skills/nasiko-control-plane/'));
|
assert.ok(packageJson.files.includes('skills/nasiko-control-plane/'));
|
||||||
assert.ok(packageJson.files.includes('scripts/nasiko.js'));
|
assert.ok(packageJson.files.includes('scripts/nasiko.js'));
|
||||||
assert.ok(packageJson.files.includes('scripts/lib/nasiko-release.js'));
|
assert.ok(packageJson.files.includes('scripts/lib/'));
|
||||||
assert.ok(!packageJson.dependencies?.nasiko);
|
assert.ok(!packageJson.dependencies?.nasiko);
|
||||||
assert.ok(!packageJson.optionalDependencies?.nasiko);
|
assert.ok(!packageJson.optionalDependencies?.nasiko);
|
||||||
}],
|
}],
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ function buildExpectedPublishPaths(repoRoot) {
|
|||||||
"scripts/loop-status.js",
|
"scripts/loop-status.js",
|
||||||
"scripts/memory.js",
|
"scripts/memory.js",
|
||||||
"scripts/memory-mcp.mjs",
|
"scripts/memory-mcp.mjs",
|
||||||
|
"scripts/nasiko.js",
|
||||||
"scripts/observability-readiness.js",
|
"scripts/observability-readiness.js",
|
||||||
"scripts/plan-canvas.js",
|
"scripts/plan-canvas.js",
|
||||||
"scripts/operator-readiness-dashboard.js",
|
"scripts/operator-readiness-dashboard.js",
|
||||||
@@ -160,6 +161,8 @@ function main() {
|
|||||||
"scripts/ito.js",
|
"scripts/ito.js",
|
||||||
"scripts/memory.js",
|
"scripts/memory.js",
|
||||||
"scripts/memory-mcp.mjs",
|
"scripts/memory-mcp.mjs",
|
||||||
|
"scripts/nasiko.js",
|
||||||
|
"scripts/lib/nasiko-release.js",
|
||||||
"scripts/lib/memory-vault-format.js",
|
"scripts/lib/memory-vault-format.js",
|
||||||
"scripts/lib/memory-vault.js",
|
"scripts/lib/memory-vault.js",
|
||||||
"scripts/discussion-audit.js",
|
"scripts/discussion-audit.js",
|
||||||
|
|||||||
Reference in New Issue
Block a user