Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/quality.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: Catalog quality

on:
pull_request:
paths:
- "catalog.json"
- "scripts/**"
- ".github/workflows/**"
push:
branches: [main]

permissions:
contents: read

concurrency:
group: extensions-quality-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Validate compatibility catalog
run: node scripts/validate-catalog.mjs
- name: Run replacement and integrity tests
run: node --test scripts/validate-catalog.test.mjs
- name: Actionlint
uses: raven-actions/actionlint@3d39aea434753780c3b3d4a1a31c854b4dbf49d7 # v2
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,32 @@
# Kosmos Extensions

Binary marketplace for Kosmos .kext extension packages.
This repository is a frozen, compatibility-only catalog for legacy `.kext`
clients. It is not the source of truth for current product discovery or
installation.

Legacy entries in `catalog.json` carry explicit deprecation and
`replacementId` metadata:

- Arrancador and the legacy Arcadia entry migrate to `com.kosmos.arcadia`;
- Eden migrates to `com.kosmos.memoria`;
- Delphi migrates to `com.kosmos.agenda`.

A compatibility client may resolve these aliases for upgrade, but no entry in
this feed is an active current product. New first-party discovery belongs in
the signed Store catalog, and installation authority belongs in the signed
Package Index. Do not publish new first-party artifacts here.

## Validation

Catalog CI rejects duplicate identities, malformed metadata, non-HTTPS or
invalid artifact integrity values, missing deprecation reasons/replacements, and
replacement cycles:

```powershell
node scripts/validate-catalog.mjs
node --test scripts/validate-catalog.test.mjs
```

The final migration of existing `.kext` data, settings, and grants remains a
consumer/client release gate; this repository cannot prove that external
upgrade path by itself.
28 changes: 22 additions & 6 deletions catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
"iconUrl": "https://raw.githubusercontent.com/makekosmos/extensions/main/extensions/akasha/icon.svg",
"downloadUrl": "https://github.com/makekosmos/extensions/releases/download/akasha-v0.1.2/akasha-0.1.2.kext",
"sha256": "a180b15143ab1778fde7ee004482649521277dbbf2085ad2a0fdc77a2ad7d246",
"size": 1244170
"size": 1244170,
"status": "legacy"
},
{
"id": "arcadia",
Expand All @@ -24,7 +25,10 @@
"iconUrl": "https://github.com/makekosmos/extensions/releases/download/arcadia-v0.1.5/arcadia-0.1.5.icon.png",
"downloadUrl": "https://github.com/makekosmos/extensions/releases/download/arcadia-v0.1.5/arcadia-0.1.5.kext",
"sha256": "c890b4ad91019418ebf1e42524690237f44b68ebe2c56e1bfa9ec4fe802817e9",
"size": 1201728
"size": 1201728,
"status": "deprecated",
"replacementId": "com.kosmos.arcadia",
"deprecationReason": "Renamed package; retained only for compatibility migration."
},
{
"id": "arrancador",
Expand All @@ -36,7 +40,10 @@
"iconUrl": "https://raw.githubusercontent.com/makekosmos/extensions/main/extensions/arrancador/icon.png",
"downloadUrl": "https://github.com/makekosmos/extensions/releases/download/arrancador-v0.1.5/arrancador-0.1.5.kext",
"sha256": "3443f338952b5c8272b88328fa4639077e5cc078f0a44f05bd5a58cfd97bb20e",
"size": 1439639
"size": 1439639,
"status": "deprecated",
"replacementId": "com.kosmos.arcadia",
"deprecationReason": "Renamed package; retained only for compatibility migration."
},
{
"id": "delphi",
Expand All @@ -48,7 +55,10 @@
"iconUrl": "https://raw.githubusercontent.com/makekosmos/extensions/main/extensions/delphi/icon.png",
"downloadUrl": "https://github.com/makekosmos/extensions/releases/download/delphi-v0.1.8/delphi-0.1.8.kext",
"sha256": "9f3e24debcdaf770ddbcc3283b977d201813bcfb7968a3ec3ece1dcddd195d6c",
"size": 1601813
"size": 1601813,
"status": "deprecated",
"replacementId": "com.kosmos.agenda",
"deprecationReason": "Renamed package; retained only for compatibility migration."
},
{
"id": "eden",
Expand All @@ -60,7 +70,13 @@
"iconUrl": "https://raw.githubusercontent.com/makekosmos/extensions/main/extensions/eden/icon.png",
"downloadUrl": "https://github.com/makekosmos/extensions/releases/download/eden-v0.5.4/eden-0.5.4.kext",
"sha256": "6c0476e2263f0aa45ecbdb713cbf940aef72d0aa4ce81417bc9610ddb1fe7f70",
"size": 12013737
"size": 12013737,
"status": "deprecated",
"replacementId": "com.kosmos.memoria",
"deprecationReason": "Renamed package; retained only for compatibility migration."
}
]
],
"status": "compatibility-only",
"supportedClientMax": "legacy",
"replacementPolicy": "Existing clients may resolve replacementId; new releases belong in Store and Package Index."
}
45 changes: 45 additions & 0 deletions scripts/validate-catalog.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
#!/usr/bin/env node
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";

export function validateCatalog(catalog) {
const entries = catalog?.extensions;
if (catalog?.schemaVersion !== 1 || catalog.status !== "compatibility-only" || catalog.supportedClientMax !== "legacy") {
throw new Error("catalog must declare schemaVersion 1 and compatibility-only status");
}
if (!Array.isArray(entries) || entries.length === 0) throw new Error("extensions must be a non-empty array");

const ids = new Set();
const edges = new Map();
for (const entry of entries) {
if (!entry || typeof entry.id !== "string" || ids.has(entry.id)) throw new Error("duplicate or invalid extension id");
ids.add(entry.id);
if (typeof entry.name !== "string" || typeof entry.description !== "string") throw new Error(`${entry.id}: name/description required`);
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(entry.version)) throw new Error(`${entry.id}: invalid semver`);
if (!/^https:\/\//.test(entry.iconUrl) || !/^https:\/\//.test(entry.downloadUrl)) throw new Error(`${entry.id}: HTTPS URLs required`);
if (!/^[a-f0-9]{64}$/.test(entry.sha256) || !Number.isSafeInteger(entry.size) || entry.size <= 0) throw new Error(`${entry.id}: invalid artifact integrity`);
if (entry.status === "deprecated") {
if (typeof entry.replacementId !== "string" || !entry.replacementId || entry.replacementId === entry.id) throw new Error(`${entry.id}: deprecated entries require a distinct replacementId`);
if (typeof entry.deprecationReason !== "string" || !entry.deprecationReason.trim()) throw new Error(`${entry.id}: deprecationReason is required`);
edges.set(entry.id, entry.replacementId);
} else if (entry.status !== "legacy") {
throw new Error(`${entry.id}: compatibility entries must be legacy or deprecated`);
}
}
for (const [id, replacement] of edges) {
if (edges.has(replacement)) throw new Error(`${id}: replacement chain must terminate outside the legacy catalog`);
}
return true;
}

async function main() {
const catalog = JSON.parse(await readFile(new URL("../catalog.json", import.meta.url), "utf8"));
validateCatalog(catalog);
console.log(`Validated ${catalog.extensions.length} compatibility entries.`);
}

if (path.resolve(process.argv[1] || "") === fileURLToPath(import.meta.url)) main().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});
28 changes: 28 additions & 0 deletions scripts/validate-catalog.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { validateCatalog } from "./validate-catalog.mjs";

const source = JSON.parse(await readFile(new URL("../catalog.json", import.meta.url), "utf8"));
const copy = () => structuredClone(source);

test("accepts frozen compatibility catalog", () => assert.equal(validateCatalog(source), true));

for (const [name, mutate, pattern] of [
["duplicate identities", (c) => c.extensions.push(structuredClone(c.extensions[0])), /duplicate/],
["bad artifact URL", (c) => { c.extensions[0].downloadUrl = "http://example.invalid/a.kext"; }, /HTTPS/],
["bad artifact hash", (c) => { c.extensions[0].sha256 = "bad"; }, /integrity/],
["missing replacement metadata", (c) => { c.extensions[1].replacementId = ""; }, /replacementId/],
["missing deprecation reason", (c) => { c.extensions[1].deprecationReason = ""; }, /deprecationReason/],
["replacement cycle", (c) => {
c.extensions[1].replacementId = "arrancador";
c.extensions[2].replacementId = "arcadia";
}, /replacement chain/],
["active entry in compatibility feed", (c) => { c.extensions[0].status = "active"; }, /legacy or deprecated/],
]) {
test(name, () => assert.throws(() => {
const c = copy();
mutate(c);
validateCatalog(c);
}, pattern));
}
Loading