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
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,13 @@ jobs:
with: { fetch-depth: 0 } # needed for only-new-issues / --new-from-rev
- uses: actions/setup-go@v5
with: { go-version: "1.25" }
- uses: actions/setup-node@v4
with: { node-version: "22" }
- run: go build ./...
- run: go test -count=1 -short -timeout 60s ./...
- run: npm ci --ignore-scripts
- run: npm test
- run: npm pack --dry-run
# Config validation gates on ERRORS (exit 1). It is intentionally NOT
# --strict: many warnings are environment-dependent (empty API-key env
# vars at lint time) and advisory, so they must not fail CI.
Expand Down
51 changes: 51 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,57 @@ permissions:
packages: write

jobs:
binaries:
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
strategy:
matrix:
include:
- { goos: linux, goarch: amd64, platform: linux, arch: x64, extension: "" }
- { goos: linux, goarch: arm64, platform: linux, arch: arm64, extension: "" }
- { goos: darwin, goarch: amd64, platform: darwin, arch: x64, extension: "" }
- { goos: darwin, goarch: arm64, platform: darwin, arch: arm64, extension: "" }
- { goos: windows, goarch: amd64, platform: win32, arch: x64, extension: ".exe" }
- { goos: windows, goarch: arm64, platform: win32, arch: arm64, extension: ".exe" }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version: "1.25" }
- name: Build native binary
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: "0"
run: |
asset="draftcat-${GITHUB_REF_NAME}-${{ matrix.platform }}-${{ matrix.arch }}${{ matrix.extension }}"
go build -trimpath -buildvcs=false -ldflags="-s -w" -o "$asset" .
gzip -n "$asset"
- uses: actions/upload-artifact@v4
with:
name: draftcat-${{ matrix.platform }}-${{ matrix.arch }}
path: draftcat-*.gz
if-no-files-found: error

release:
if: startsWith(github.ref, 'refs/tags/v')
needs: binaries
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v4
with:
pattern: draftcat-*
path: dist
merge-multiple: true
- name: Publish GitHub release
working-directory: dist
env:
GH_TOKEN: ${{ github.token }}
run: |
sha256sum draftcat-*.gz > SHA256SUMS
gh release create "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --generate-notes --title "$GITHUB_REF_NAME" draftcat-*.gz SHA256SUMS

image:
runs-on: ubuntu-latest
steps:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ state.db-shm
state.db-wal
aiops-architecture-diagram.py
draftyard
/npm/bin/
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,17 @@ However your agent runs, draftcat sits between it and your customer systems as a

## Quickstart

Install the native binary through npm (Node.js 18 or newer):

```bash
npm install -g draftcat
draftcat --help
```

The installer downloads the matching Linux, macOS, or Windows binary and verifies it against the checksums attached to the GitHub release. No Go toolchain is required.

Or build from source:

```bash
git clone https://github.com/renezander030/draftcat.git && cd draftcat
cp secrets.yaml.example secrets.yaml # operator IDs + API keys
Expand Down
26 changes: 26 additions & 0 deletions npm/draftcat.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#!/usr/bin/env node

"use strict";

const { spawnSync } = require("node:child_process");
const fs = require("node:fs");
const path = require("node:path");

const executable = process.platform === "win32" ? "draftcat.exe" : "draftcat";
const binary = path.join(__dirname, "bin", executable);

if (!fs.existsSync(binary)) {
console.error("Draftcat is not installed. Reinstall the package to download its binary.");
process.exit(1);
}

const result = spawnSync(binary, process.argv.slice(2), { stdio: "inherit" });
if (result.error) {
console.error(`Unable to start Draftcat: ${result.error.message}`);
process.exit(1);
}
if (result.signal) {
process.kill(process.pid, result.signal);
} else {
process.exit(result.status === null ? 1 : result.status);
}
118 changes: 118 additions & 0 deletions npm/install.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
#!/usr/bin/env node

"use strict";

const crypto = require("node:crypto");
const fs = require("node:fs");
const https = require("node:https");
const path = require("node:path");
const zlib = require("node:zlib");
const { version } = require("../package.json");

const repository = "renezander030/draftcat";
const maximumDownloadBytes = 128 * 1024 * 1024;
const supported = new Set([
"darwin-arm64",
"darwin-x64",
"linux-arm64",
"linux-x64",
"win32-arm64",
"win32-x64",
]);

function artifactFor(platform, arch, releaseVersion = version) {
const target = `${platform}-${arch}`;
if (!supported.has(target)) {
throw new Error(`Unsupported platform: ${target}`);
}
const extension = platform === "win32" ? ".exe" : "";
return `draftcat-v${releaseVersion}-${target}${extension}.gz`;
}

function parseChecksums(contents) {
const checksums = new Map();
for (const line of contents.trim().split(/\r?\n/)) {
const match = /^([a-f0-9]{64})\s+\*?(.+)$/.exec(line.trim());
if (match) {
checksums.set(match[2], match[1]);
}
}
return checksums;
}

function download(url, redirects = 0) {
if (redirects > 5) {
return Promise.reject(new Error("Too many redirects while downloading Draftcat"));
}
return new Promise((resolve, reject) => {
const request = https.get(url, {
headers: { "User-Agent": `draftcat-npm/${version}` },
}, (response) => {
if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
response.resume();
const next = new URL(response.headers.location, url);
if (next.protocol !== "https:") {
reject(new Error("Refusing a non-HTTPS release redirect"));
return;
}
download(next, redirects + 1).then(resolve, reject);
return;
}
if (response.statusCode !== 200) {
response.resume();
reject(new Error(`Download failed with HTTP ${response.statusCode}`));
return;
}
const chunks = [];
let size = 0;
response.on("data", (chunk) => {
size += chunk.length;
if (size > maximumDownloadBytes) {
request.destroy(new Error("Draftcat release asset is unexpectedly large"));
return;
}
chunks.push(chunk);
});
response.on("end", () => resolve(Buffer.concat(chunks)));
});
request.on("error", reject);
request.setTimeout(30_000, () => request.destroy(new Error("Draftcat download timed out")));
});
}

async function install() {
const artifact = artifactFor(process.platform, process.arch);
const releaseBase = `https://github.com/${repository}/releases/download/v${version}`;
const [archive, checksumFile] = await Promise.all([
download(`${releaseBase}/${artifact}`),
download(`${releaseBase}/SHA256SUMS`),
]);

const expected = parseChecksums(checksumFile.toString("utf8")).get(artifact);
if (!expected) {
throw new Error(`No checksum was published for ${artifact}`);
}
const actual = crypto.createHash("sha256").update(archive).digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(actual), Buffer.from(expected))) {
throw new Error(`Checksum verification failed for ${artifact}`);
}

const executable = process.platform === "win32" ? "draftcat.exe" : "draftcat";
const binaryDirectory = path.join(__dirname, "bin");
const destination = path.join(binaryDirectory, executable);
fs.mkdirSync(binaryDirectory, { recursive: true });
fs.writeFileSync(destination, zlib.gunzipSync(archive), { mode: 0o755 });
if (process.platform !== "win32") {
fs.chmodSync(destination, 0o755);
}
console.log(`Installed Draftcat v${version} for ${process.platform}-${process.arch}`);
}

if (require.main === module) {
install().catch((error) => {
console.error(`Unable to install Draftcat: ${error.message}`);
process.exit(1);
});
}

module.exports = { artifactFor, parseChecksums };
24 changes: 24 additions & 0 deletions npm/install.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"use strict";

const assert = require("node:assert/strict");
const test = require("node:test");
const { artifactFor, parseChecksums } = require("./install");

test("maps Node targets to release assets", () => {
assert.equal(artifactFor("linux", "x64", "0.7.0"), "draftcat-v0.7.0-linux-x64.gz");
assert.equal(artifactFor("darwin", "arm64", "0.7.0"), "draftcat-v0.7.0-darwin-arm64.gz");
assert.equal(artifactFor("win32", "x64", "0.7.0"), "draftcat-v0.7.0-win32-x64.exe.gz");
});

test("rejects unsupported targets", () => {
assert.throws(() => artifactFor("freebsd", "x64"), /Unsupported platform/);
assert.throws(() => artifactFor("linux", "ia32"), /Unsupported platform/);
});

test("reads GNU and binary-style checksum lines", () => {
const a = "a".repeat(64);
const b = "b".repeat(64);
const checksums = parseChecksums(`${a} draftcat-a.gz\n${b} *draftcat-b.gz\n`);
assert.equal(checksums.get("draftcat-a.gz"), a);
assert.equal(checksums.get("draftcat-b.gz"), b);
});
29 changes: 29 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

50 changes: 50 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
{
"name": "draftcat",
"version": "0.7.0",
"description": "Governed AI pipelines with human approval gates",
"license": "MIT",
"author": "Rene Zander",
"homepage": "https://github.com/renezander030/draftcat#readme",
"repository": {
"type": "git",
"url": "git+https://github.com/renezander030/draftcat.git"
},
"bugs": {
"url": "https://github.com/renezander030/draftcat/issues"
},
"keywords": [
"ai",
"approval",
"governance",
"human-in-the-loop",
"llm"
],
"bin": {
"draftcat": "npm/draftcat.js"
},
"files": [
"npm/draftcat.js",
"npm/install.js",
"README.md",
"LICENSE"
],
"scripts": {
"postinstall": "node npm/install.js",
"test": "node --test npm/*.test.js"
},
"engines": {
"node": ">=18"
},
"os": [
"darwin",
"linux",
"win32"
],
"cpu": [
"x64",
"arm64"
],
"publishConfig": {
"access": "public"
}
}
Loading