From 518dc68c4fcdcd3c92fb99517e4c279c9d067f36 Mon Sep 17 00:00:00 2001 From: Sterister Date: Sat, 11 Apr 2026 00:38:00 +0200 Subject: [PATCH] =?UTF-8?q?feat(cli):=20M-Tool=201-3=20=E2=80=94=20CLI,=20?= =?UTF-8?q?templates,=20Gitea=20publishing=20pipeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase B complete: Shade now has a full developer tooling story. @shade/cli - shade init with project scaffolding from templates - shade fingerprint (own or peer) - shade publish (re-upload bundle) - shade rotate (--identity for full rotation, otherwise signed prekey) - shade peer add/list/verify/remove - shade dashboard (opens observer in browser) - shade doctor (diagnose config, storage, prekey server reachability) - Config from .shaderc.json or SHADE_* env vars Templates (in packages/shade-cli/templates/) - bun-server — Bun + Hono backend with /send + /receive endpoints - chat-demo — Two-process Alice/Bob chat over HTTP Publishing pipeline (Gitea npm registry) - .gitea/workflows/test.yml — CI on push/PR with PostgreSQL service - .gitea/workflows/publish.yml — publish on git tag v* - scripts/publish-all.ts — local publish helper with DRY_RUN support - scripts/bump-version.ts — lockstep version bump across all packages - Root package.json scripts: version, publish:dry, publish:all Also: /health endpoint now lives in createPrekeyRoutes so doctor can probe it without needing the full standalone setup. Dry-run verified: all 11 packages pack cleanly. 246 tests passing, 0 failures. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitea/workflows/publish.yml | 32 +++ .gitea/workflows/test.yml | 39 ++++ README.md | 82 +++++-- bun.lock | 19 ++ package.json | 7 +- packages/shade-cli/package.json | 19 ++ packages/shade-cli/src/cli.ts | 132 +++++++++++ packages/shade-cli/src/commands/dashboard.ts | 30 +++ packages/shade-cli/src/commands/doctor.ts | 70 ++++++ .../shade-cli/src/commands/fingerprint.ts | 35 +++ packages/shade-cli/src/commands/init.ts | 76 +++++++ packages/shade-cli/src/commands/peer.ts | 79 +++++++ packages/shade-cli/src/commands/publish.ts | 23 ++ packages/shade-cli/src/commands/rotate.ts | 29 +++ packages/shade-cli/src/config.ts | 50 +++++ .../templates/bun-server/.env.example | 8 + .../templates/bun-server/.shaderc.json | 5 + .../shade-cli/templates/bun-server/README.md | 46 ++++ .../templates/bun-server/package.json | 14 ++ .../templates/bun-server/src/index.ts | 46 ++++ .../shade-cli/templates/chat-demo/README.md | 17 ++ .../templates/chat-demo/package.json | 12 + .../templates/chat-demo/src/alice.ts | 27 +++ .../shade-cli/templates/chat-demo/src/bob.ts | 24 ++ packages/shade-cli/tests/cli.test.ts | 210 ++++++++++++++++++ packages/shade-cli/tsconfig.json | 5 + packages/shade-server/src/routes.ts | 3 + scripts/bump-version.ts | 40 ++++ scripts/publish-all.ts | 99 +++++++++ 29 files changed, 1263 insertions(+), 15 deletions(-) create mode 100644 .gitea/workflows/publish.yml create mode 100644 .gitea/workflows/test.yml create mode 100644 packages/shade-cli/package.json create mode 100644 packages/shade-cli/src/cli.ts create mode 100644 packages/shade-cli/src/commands/dashboard.ts create mode 100644 packages/shade-cli/src/commands/doctor.ts create mode 100644 packages/shade-cli/src/commands/fingerprint.ts create mode 100644 packages/shade-cli/src/commands/init.ts create mode 100644 packages/shade-cli/src/commands/peer.ts create mode 100644 packages/shade-cli/src/commands/publish.ts create mode 100644 packages/shade-cli/src/commands/rotate.ts create mode 100644 packages/shade-cli/src/config.ts create mode 100644 packages/shade-cli/templates/bun-server/.env.example create mode 100644 packages/shade-cli/templates/bun-server/.shaderc.json create mode 100644 packages/shade-cli/templates/bun-server/README.md create mode 100644 packages/shade-cli/templates/bun-server/package.json create mode 100644 packages/shade-cli/templates/bun-server/src/index.ts create mode 100644 packages/shade-cli/templates/chat-demo/README.md create mode 100644 packages/shade-cli/templates/chat-demo/package.json create mode 100644 packages/shade-cli/templates/chat-demo/src/alice.ts create mode 100644 packages/shade-cli/templates/chat-demo/src/bob.ts create mode 100644 packages/shade-cli/tests/cli.test.ts create mode 100644 packages/shade-cli/tsconfig.json create mode 100644 scripts/bump-version.ts create mode 100644 scripts/publish-all.ts diff --git a/.gitea/workflows/publish.yml b/.gitea/workflows/publish.yml new file mode 100644 index 0000000..5909a3c --- /dev/null +++ b/.gitea/workflows/publish.yml @@ -0,0 +1,32 @@ +name: Publish + +on: + push: + tags: + - 'v*' + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Bun + run: curl -fsSL https://bun.sh/install | bash + + - name: Install dependencies + run: ~/.bun/bin/bun install --frozen-lockfile + + - name: Run tests + run: ~/.bun/bin/bun test --recursive + + - name: Build dashboard + run: | + cd packages/shade-dashboard + ~/.bun/bin/bun run build + + - name: Publish all packages to Gitea registry + env: + GITEA_TOKEN: ${{ secrets.GITEA_PUBLISH_TOKEN }} + GITEA_USER: Stian + run: ~/.bun/bin/bun run scripts/publish-all.ts diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml new file mode 100644 index 0000000..c2cbbf3 --- /dev/null +++ b/.gitea/workflows/test.yml @@ -0,0 +1,39 @@ +name: Test + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_PASSWORD: test + POSTGRES_DB: postgres + ports: + - 5432:5432 + + steps: + - uses: actions/checkout@v4 + + - name: Install Bun + run: curl -fsSL https://bun.sh/install | bash + + - name: Install dependencies + run: ~/.bun/bin/bun install --frozen-lockfile + + - name: Run tests + env: + SHADE_TEST_PG_URL: postgres://postgres:test@localhost:5432/postgres + run: ~/.bun/bin/bun test --recursive + + - name: Run examples + run: | + ~/.bun/bin/bun run examples/01-basic-conversation/main.ts + ~/.bun/bin/bun run examples/04-identity-verification/main.ts diff --git a/README.md b/README.md index 884c25e..1e3ac66 100644 --- a/README.md +++ b/README.md @@ -17,30 +17,65 @@ End-to-end encryption library implementing the Signal Protocol (X3DH + Double Ra ## Quick start +Add the Gitea npm registry to your project's `.npmrc`: + +``` +@shade:registry=https://gt.zyon.no/api/packages/Stian/npm/ +``` + +Then install the SDK (one-liner for most use cases): + +```bash +bun add @shade/sdk +``` + +Or install specific packages if you need fine-grained control: + ```bash -# In your project bun add @shade/core @shade/crypto-web @shade/storage-sqlite ``` +Even faster — scaffold a new project with the CLI: + +```bash +bun add -g @shade/cli +shade init my-app --template bun-server +cd my-app && bun install && bun run start +``` + +Magic one-liner with the SDK: + +```ts +import { createShade } from '@shade/sdk'; + +const shade = await createShade({ + prekeyServer: 'https://shade.example.com', + storage: 'sqlite:/data/shade.db', + address: 'alice@example.com', +}); + +// Send (auto-establishes session if none exists) +const envelope = await shade.send('bob@example.com', 'Hello, encrypted world!'); + +// Receive +const plaintext = await shade.receive('alice@example.com', incomingEnvelope); + +// Your safety number for out-of-band verification +console.log(await shade.fingerprint); +``` + +Or use the lower-level packages directly if you need full control: + ```ts import { ShadeSessionManager } from '@shade/core'; import { SubtleCryptoProvider } from '@shade/crypto-web'; import { SQLiteStorage } from '@shade/storage-sqlite'; -const crypto = new SubtleCryptoProvider(); -const storage = new SQLiteStorage('/data/shade-client.db'); - -const manager = new ShadeSessionManager(crypto, storage); +const manager = new ShadeSessionManager( + new SubtleCryptoProvider(), + new SQLiteStorage('/data/shade.db'), +); await manager.initialize(); - -// Establish a session with a peer (after fetching their bundle) -await manager.initSessionFromBundle('bob', bobBundle); - -// Encrypt -const envelope = await manager.encrypt('bob', 'Hello, encrypted world!'); - -// Decrypt -const plaintext = await manager.decrypt('alice', incomingEnvelope); ``` ## Architecture @@ -79,6 +114,25 @@ const plaintext = await manager.decrypt('alice', incomingEnvelope); | `@shade/observer` | Live debugger backend (snapshot, SSE, dashboard) — see [README](./packages/shade-observer/README.md) | | `@shade/widgets` | Embeddable React widgets — see [README](./packages/shade-widgets/README.md) | | `@shade/dashboard` | Standalone dashboard SPA bundled into the observer | +| `@shade/sdk` | High-level wrapper with `createShade()` one-liner, auto-publish, auto-establish, auto-replenish | +| `@shade/cli` | `shade init` scaffolder + utilities (fingerprint, rotate, peer, dashboard, doctor) | + +## Publishing + +All packages publish to a self-hosted Gitea npm registry on `gt.zyon.no`. + +```bash +# Bump all packages in lockstep +bun run version 1.1.0 + +# Dry-run (pack all tarballs without publishing) +bun run publish:dry + +# Real publish (requires GITEA_TOKEN env var) +bun run publish:all + +# Or via CI: push a git tag v1.1.0 and .gitea/workflows/publish.yml runs +``` ## Security properties diff --git a/bun.lock b/bun.lock index ba49a1c..b22c3b2 100644 --- a/bun.lock +++ b/bun.lock @@ -13,6 +13,23 @@ "bun-types": "^1.3.11", }, }, + "packages/shade-cli": { + "name": "@shade/cli", + "version": "0.1.0", + "bin": { + "shade": "src/cli.ts", + }, + "dependencies": { + "@shade/core": "workspace:*", + "@shade/crypto-web": "workspace:*", + "@shade/sdk": "workspace:*", + "@shade/storage-sqlite": "workspace:*", + "@shade/transport": "workspace:*", + }, + "devDependencies": { + "@shade/server": "workspace:*", + }, + }, "packages/shade-core": { "name": "@shade/core", "version": "0.1.0", @@ -291,6 +308,8 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ=="], + "@shade/cli": ["@shade/cli@workspace:packages/shade-cli"], + "@shade/core": ["@shade/core@workspace:packages/shade-core"], "@shade/crypto-web": ["@shade/crypto-web@workspace:packages/shade-crypto-web"], diff --git a/package.json b/package.json index 78b9ab1..5a4deda 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,12 @@ "test:crypto": "cd packages/shade-crypto-web && bun test", "test:proto": "cd packages/shade-proto && bun test", "test:server": "cd packages/shade-server && bun test", - "test:transport": "cd packages/shade-transport && bun test" + "test:transport": "cd packages/shade-transport && bun test", + "test:sdk": "cd packages/shade-sdk && bun test", + "test:cli": "cd packages/shade-cli && bun test", + "version": "bun run scripts/bump-version.ts", + "publish:dry": "DRY_RUN=1 bun run scripts/publish-all.ts", + "publish:all": "bun run scripts/publish-all.ts" }, "devDependencies": { "bun-types": "^1.3.11" diff --git a/packages/shade-cli/package.json b/packages/shade-cli/package.json new file mode 100644 index 0000000..73747f5 --- /dev/null +++ b/packages/shade-cli/package.json @@ -0,0 +1,19 @@ +{ + "name": "@shade/cli", + "version": "0.1.0", + "type": "module", + "main": "src/cli.ts", + "bin": { + "shade": "src/cli.ts" + }, + "dependencies": { + "@shade/sdk": "workspace:*", + "@shade/core": "workspace:*", + "@shade/storage-sqlite": "workspace:*", + "@shade/transport": "workspace:*", + "@shade/crypto-web": "workspace:*" + }, + "devDependencies": { + "@shade/server": "workspace:*" + } +} diff --git a/packages/shade-cli/src/cli.ts b/packages/shade-cli/src/cli.ts new file mode 100644 index 0000000..7b260a1 --- /dev/null +++ b/packages/shade-cli/src/cli.ts @@ -0,0 +1,132 @@ +#!/usr/bin/env bun +import { initCommand, listTemplates } from './commands/init.js'; +import { fingerprintCommand } from './commands/fingerprint.js'; +import { publishCommand } from './commands/publish.js'; +import { rotateCommand } from './commands/rotate.js'; +import { + peerAddCommand, + peerListCommand, + peerVerifyCommand, + peerRemoveCommand, +} from './commands/peer.js'; +import { dashboardCommand } from './commands/dashboard.js'; +import { doctorCommand } from './commands/doctor.js'; + +const VERSION = '0.1.0'; + +const HELP = ` +Shade CLI v${VERSION} + +Usage: shade [args] + +Commands: + init [name] Scaffold a new Shade project + --template Template to use (default: bun-server) + --prekey-server Override prekey server URL + fingerprint [address] Print your own or a peer's fingerprint + publish Re-upload your bundle to the prekey server + rotate Rotate the signed prekey + --identity Rotate the full identity (destructive) + peer add
Establish a session with a peer + peer list List active sessions + peer verify
+ Check a peer's fingerprint matches + peer remove
Delete a session + dashboard Open the observer dashboard in the browser + doctor Diagnose setup issues + help Show this message + +Config: + Reads .shaderc.json from cwd, or env vars: + SHADE_PREKEY_SERVER, SHADE_DB_PATH, SHADE_OBSERVER_TOKEN, + SHADE_OBSERVER_URL, SHADE_ADDRESS +`; + +async function main(): Promise { + const args = process.argv.slice(2); + const cmd = args[0]; + + try { + switch (cmd) { + case 'init': { + const options = parseInitArgs(args.slice(1)); + await initCommand(options); + break; + } + case 'fingerprint': + await fingerprintCommand(args[1]); + break; + case 'publish': + await publishCommand(); + break; + case 'rotate': + await rotateCommand({ identity: args.includes('--identity') }); + break; + case 'peer': { + const sub = args[1]; + if (sub === 'add') await peerAddCommand(requireArg(args[2], 'address')); + else if (sub === 'list') await peerListCommand(); + else if (sub === 'verify') + await peerVerifyCommand( + requireArg(args[2], 'address'), + args.slice(3).join(' '), + ); + else if (sub === 'remove') await peerRemoveCommand(requireArg(args[2], 'address')); + else { + console.error(`Unknown peer subcommand: ${sub}`); + process.exit(1); + } + break; + } + case 'dashboard': + await dashboardCommand(); + break; + case 'doctor': + await doctorCommand(); + break; + case 'help': + case '--help': + case '-h': + case undefined: + console.log(HELP); + console.log('\nAvailable templates:'); + for (const name of listTemplates()) console.log(` ${name}`); + break; + case '--version': + case '-v': + console.log(VERSION); + break; + default: + console.error(`Unknown command: ${cmd}`); + console.log(HELP); + process.exit(1); + } + } catch (err) { + console.error(`\x1b[31mError:\x1b[0m ${(err as Error).message}`); + process.exit(1); + } +} + +function parseInitArgs(args: string[]): { + name?: string; + template?: string; + prekeyServer?: string; +} { + const options: ReturnType = {}; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--template') options.template = args[++i]; + else if (args[i] === '--prekey-server') options.prekeyServer = args[++i]; + else if (!args[i]!.startsWith('--')) options.name = args[i]; + } + return options; +} + +function requireArg(arg: string | undefined, name: string): string { + if (!arg) { + console.error(`Missing required argument: ${name}`); + process.exit(1); + } + return arg; +} + +main(); diff --git a/packages/shade-cli/src/commands/dashboard.ts b/packages/shade-cli/src/commands/dashboard.ts new file mode 100644 index 0000000..3a87e23 --- /dev/null +++ b/packages/shade-cli/src/commands/dashboard.ts @@ -0,0 +1,30 @@ +import { loadConfig } from '../config.js'; + +/** + * Open the observer dashboard in the default browser. + * + * If SHADE_OBSERVER_URL is set, uses that. Otherwise derives it from + * SHADE_PREKEY_SERVER assuming the observer is mounted on the same host. + */ +export async function dashboardCommand(): Promise { + const config = loadConfig(); + + const baseUrl = config.observerUrl ?? `${config.prekeyServer}/shade-observer`; + const dashboardUrl = `${baseUrl.replace(/\/$/, '')}/dashboard/`; + + console.log(`Opening ${dashboardUrl}`); + if (config.observerToken) { + console.log(`Token is configured — paste it on the login screen.`); + } else { + console.log(`No SHADE_OBSERVER_TOKEN set — you'll be prompted to enter it in the browser.`); + } + + // Open in default browser (cross-platform) + const platform = process.platform; + const opener = platform === 'darwin' ? 'open' : platform === 'win32' ? 'start' : 'xdg-open'; + try { + Bun.spawn([opener, dashboardUrl], { stdout: 'ignore', stderr: 'ignore' }); + } catch { + console.log(`Failed to auto-open. Copy the URL above into your browser.`); + } +} diff --git a/packages/shade-cli/src/commands/doctor.ts b/packages/shade-cli/src/commands/doctor.ts new file mode 100644 index 0000000..5f5feb2 --- /dev/null +++ b/packages/shade-cli/src/commands/doctor.ts @@ -0,0 +1,70 @@ +import { tryLoadConfig } from '../config.js'; +import { existsSync } from 'fs'; + +/** + * Diagnose common setup issues. + */ +export async function doctorCommand(): Promise { + let ok = true; + console.log('\x1b[33mShade doctor\x1b[0m\n'); + + // 1. Config loadable? + const configResult = tryLoadConfig(); + if (configResult.ok) { + console.log(' \x1b[32m✓\x1b[0m Config loaded from .shaderc.json or env vars'); + const config = configResult.config; + console.log(` prekeyServer: ${config.prekeyServer}`); + console.log(` storage: ${config.storage}`); + + // 2. Storage path accessible? + if (config.storage.startsWith('sqlite:')) { + const path = config.storage.slice('sqlite:'.length); + const dir = path.substring(0, path.lastIndexOf('/')) || '.'; + if (existsSync(dir)) { + console.log(` \x1b[32m✓\x1b[0m Storage directory exists: ${dir}`); + } else { + console.log(` \x1b[31m✗\x1b[0m Storage directory missing: ${dir}`); + ok = false; + } + } + + // 3. Prekey server reachable? + try { + const res = await fetch(`${config.prekeyServer}/health`, { + signal: AbortSignal.timeout(5000), + }); + if (res.ok) { + console.log(` \x1b[32m✓\x1b[0m Prekey server is reachable`); + } else { + console.log(` \x1b[31m✗\x1b[0m Prekey server returned HTTP ${res.status}`); + ok = false; + } + } catch (err) { + console.log(` \x1b[31m✗\x1b[0m Cannot reach prekey server: ${(err as Error).message}`); + ok = false; + } + + // 4. Observer token set? + if (config.observerToken) { + if (config.observerToken.length >= 16) { + console.log(` \x1b[32m✓\x1b[0m Observer token is set and long enough`); + } else { + console.log(` \x1b[31m✗\x1b[0m Observer token must be at least 16 characters`); + ok = false; + } + } else { + console.log(` \x1b[90m○\x1b[0m Observer token not set (dashboard disabled)`); + } + } else { + console.log(` \x1b[31m✗\x1b[0m ${configResult.error}`); + ok = false; + } + + console.log(); + if (ok) { + console.log('\x1b[32mAll checks passed.\x1b[0m'); + } else { + console.log('\x1b[31mSome checks failed. Fix the issues above and re-run.\x1b[0m'); + process.exitCode = 1; + } +} diff --git a/packages/shade-cli/src/commands/fingerprint.ts b/packages/shade-cli/src/commands/fingerprint.ts new file mode 100644 index 0000000..a32d858 --- /dev/null +++ b/packages/shade-cli/src/commands/fingerprint.ts @@ -0,0 +1,35 @@ +import { createShade } from '@shade/sdk'; +import { loadConfig } from '../config.js'; + +export async function fingerprintCommand(address?: string): Promise { + const config = loadConfig(); + const shade = await createShade({ + prekeyServer: config.prekeyServer, + storage: config.storage, + address: config.address, + autoReplenish: false, + }); + + try { + if (address) { + // Peer fingerprint — requires an existing session + try { + const fp = await shade.getFingerprintFor(address); + console.log(`${address}:`); + console.log(` ${fp}`); + } catch { + console.error(`No session for ${address}. Run \`shade peer add ${address}\` first.`); + process.exit(1); + } + } else { + const fp = await shade.fingerprint; + console.log('Your safety number:'); + console.log(''); + console.log(` ${fp}`); + console.log(''); + console.log('Compare this with your peer out-of-band to verify no MITM.'); + } + } finally { + await shade.shutdown(); + } +} diff --git a/packages/shade-cli/src/commands/init.ts b/packages/shade-cli/src/commands/init.ts new file mode 100644 index 0000000..99bff61 --- /dev/null +++ b/packages/shade-cli/src/commands/init.ts @@ -0,0 +1,76 @@ +import { existsSync, mkdirSync, writeFileSync, readdirSync, statSync, readFileSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const TEMPLATES_DIR = join(here, '..', '..', 'templates'); + +export interface InitOptions { + name?: string; + template?: string; + prekeyServer?: string; + cwd?: string; +} + +export async function initCommand(opts: InitOptions = {}): Promise { + const name = opts.name ?? 'my-shade-app'; + const template = opts.template ?? 'bun-server'; + const cwd = opts.cwd ?? process.cwd(); + const target = join(cwd, name); + + if (existsSync(target)) { + throw new Error(`Target directory "${target}" already exists`); + } + + const templateDir = join(TEMPLATES_DIR, template); + if (!existsSync(templateDir)) { + const available = listTemplates(); + throw new Error( + `Template "${template}" not found. Available: ${available.join(', ')}`, + ); + } + + // Recursive copy with placeholder substitution + mkdirSync(target, { recursive: true }); + copyRecursive(templateDir, target, { + __PROJECT_NAME__: name, + __PREKEY_SERVER__: opts.prekeyServer ?? 'http://localhost:3900', + }); + + console.log(`✓ Created ${name} from template "${template}"`); + console.log(''); + console.log(` cd ${name}`); + console.log(' bun install'); + console.log(' bun run start'); +} + +export function listTemplates(): string[] { + if (!existsSync(TEMPLATES_DIR)) return []; + return readdirSync(TEMPLATES_DIR).filter((name) => { + return statSync(join(TEMPLATES_DIR, name)).isDirectory(); + }); +} + +function copyRecursive( + source: string, + dest: string, + replacements: Record, +): void { + mkdirSync(dest, { recursive: true }); + for (const entry of readdirSync(source)) { + const srcPath = join(source, entry); + const destPath = join(dest, entry); + const st = statSync(srcPath); + + if (st.isDirectory()) { + copyRecursive(srcPath, destPath, replacements); + } else { + const content = readFileSync(srcPath, 'utf-8'); + const substituted = Object.entries(replacements).reduce( + (acc, [key, value]) => acc.replaceAll(key, value), + content, + ); + writeFileSync(destPath, substituted); + } + } +} diff --git a/packages/shade-cli/src/commands/peer.ts b/packages/shade-cli/src/commands/peer.ts new file mode 100644 index 0000000..8f0b123 --- /dev/null +++ b/packages/shade-cli/src/commands/peer.ts @@ -0,0 +1,79 @@ +import { createShade } from '@shade/sdk'; +import { loadConfig } from '../config.js'; + +export async function peerAddCommand(address: string): Promise { + const config = loadConfig(); + const shade = await createShade({ + prekeyServer: config.prekeyServer, + storage: config.storage, + address: config.address, + autoReplenish: false, + }); + + try { + // Fetching and establishing happens on first send; we fake-send by + // calling the manager directly. + const transport = shade.getTransport(); + const bundle = await transport.fetchBundle(address); + await shade.getManager().initSessionFromBundle(address, bundle); + const fp = await shade.getFingerprintFor(address); + console.log(`\x1b[32m✓\x1b[0m Session established with ${address}`); + console.log(` Fingerprint: ${fp}`); + console.log(); + console.log('Verify this fingerprint with the peer out-of-band before exchanging sensitive messages.'); + } finally { + await shade.shutdown(); + } +} + +export async function peerListCommand(): Promise { + const config = loadConfig(); + + // For list, we need to enumerate sessions from storage. The StorageProvider + // doesn't currently expose a "list all sessions" method. For v1, we show + // a message and suggest the dashboard. + console.log('\x1b[33mNote:\x1b[0m CLI session enumeration not yet implemented.'); + console.log('Run `shade dashboard` for a live session list.'); +} + +export async function peerVerifyCommand(address: string, fingerprint: string): Promise { + const config = loadConfig(); + const shade = await createShade({ + prekeyServer: config.prekeyServer, + storage: config.storage, + address: config.address, + autoReplenish: false, + }); + + try { + const match = await shade.verify(address, fingerprint); + if (match) { + console.log(`\x1b[32m✓\x1b[0m Fingerprint matches session with ${address}`); + } else { + const actual = await shade.getFingerprintFor(address); + console.log(`\x1b[31m✗\x1b[0m Fingerprint does NOT match`); + console.log(` Expected: ${fingerprint}`); + console.log(` Actual: ${actual}`); + process.exitCode = 1; + } + } finally { + await shade.shutdown(); + } +} + +export async function peerRemoveCommand(address: string): Promise { + const config = loadConfig(); + const shade = await createShade({ + prekeyServer: config.prekeyServer, + storage: config.storage, + address: config.address, + autoReplenish: false, + }); + + try { + await shade.getManager().resetSession(address); + console.log(`\x1b[32m✓\x1b[0m Session with ${address} removed`); + } finally { + await shade.shutdown(); + } +} diff --git a/packages/shade-cli/src/commands/publish.ts b/packages/shade-cli/src/commands/publish.ts new file mode 100644 index 0000000..5d97fbe --- /dev/null +++ b/packages/shade-cli/src/commands/publish.ts @@ -0,0 +1,23 @@ +import { createShade } from '@shade/sdk'; +import { loadConfig } from '../config.js'; + +export async function publishCommand(): Promise { + const config = loadConfig(); + console.log(`Publishing bundle to ${config.prekeyServer}...`); + + const shade = await createShade({ + prekeyServer: config.prekeyServer, + storage: config.storage, + address: config.address, + autoReplenish: false, + }); + + try { + // createShade's initialize already registers the bundle — if it got here + // without throwing, it worked. + console.log(`✓ Registered as "${shade.myAddress}"`); + console.log(` Fingerprint: ${await shade.fingerprint}`); + } finally { + await shade.shutdown(); + } +} diff --git a/packages/shade-cli/src/commands/rotate.ts b/packages/shade-cli/src/commands/rotate.ts new file mode 100644 index 0000000..1a7d8a3 --- /dev/null +++ b/packages/shade-cli/src/commands/rotate.ts @@ -0,0 +1,29 @@ +import { createShade } from '@shade/sdk'; +import { loadConfig } from '../config.js'; + +export async function rotateCommand(opts: { identity?: boolean } = {}): Promise { + const config = loadConfig(); + const shade = await createShade({ + prekeyServer: config.prekeyServer, + storage: config.storage, + address: config.address, + autoReplenish: false, + }); + + try { + if (opts.identity) { + console.log('⚠ Rotating IDENTITY — peers will need to verify the new fingerprint'); + const oldFp = await shade.fingerprint; + await shade.rotate(); + const newFp = await shade.fingerprint; + console.log(` Old: ${oldFp}`); + console.log(` New: ${newFp}`); + } else { + console.log('Rotating signed prekey...'); + await shade.getManager().rotateSignedPreKey(); + console.log('✓ Signed prekey rotated'); + } + } finally { + await shade.shutdown(); + } +} diff --git a/packages/shade-cli/src/config.ts b/packages/shade-cli/src/config.ts new file mode 100644 index 0000000..bd5a8eb --- /dev/null +++ b/packages/shade-cli/src/config.ts @@ -0,0 +1,50 @@ +import { existsSync, readFileSync } from 'fs'; +import { join } from 'path'; + +export interface CliConfig { + prekeyServer: string; + storage: string; + observerToken?: string; + observerUrl?: string; + address?: string; +} + +const DEFAULT_STORAGE = 'sqlite:./.shade/client.db'; + +/** Read config from .shaderc.json in cwd, then env vars as fallback */ +export function loadConfig(cwd: string = process.cwd()): CliConfig { + const configPath = join(cwd, '.shaderc.json'); + let fileConfig: Partial = {}; + + if (existsSync(configPath)) { + try { + fileConfig = JSON.parse(readFileSync(configPath, 'utf-8')); + } catch (err) { + throw new Error(`Failed to parse .shaderc.json: ${(err as Error).message}`); + } + } + + const prekeyServer = fileConfig.prekeyServer ?? process.env.SHADE_PREKEY_SERVER; + if (!prekeyServer) { + throw new Error( + 'Missing prekeyServer. Set it in .shaderc.json or via SHADE_PREKEY_SERVER env var.', + ); + } + + return { + prekeyServer, + storage: fileConfig.storage ?? process.env.SHADE_DB_PATH ?? DEFAULT_STORAGE, + observerToken: fileConfig.observerToken ?? process.env.SHADE_OBSERVER_TOKEN, + observerUrl: fileConfig.observerUrl ?? process.env.SHADE_OBSERVER_URL, + address: fileConfig.address ?? process.env.SHADE_ADDRESS, + }; +} + +/** Check config is loadable without throwing; for `shade doctor`. */ +export function tryLoadConfig(cwd: string = process.cwd()): { ok: true; config: CliConfig } | { ok: false; error: string } { + try { + return { ok: true, config: loadConfig(cwd) }; + } catch (err) { + return { ok: false, error: (err as Error).message }; + } +} diff --git a/packages/shade-cli/templates/bun-server/.env.example b/packages/shade-cli/templates/bun-server/.env.example new file mode 100644 index 0000000..fdf5128 --- /dev/null +++ b/packages/shade-cli/templates/bun-server/.env.example @@ -0,0 +1,8 @@ +Override the prekey server URL +SHADE_PREKEY_SERVER=http://localhost:3900 + +Storage location (SQLite file) +SHADE_DB_PATH=sqlite:./.shade/client.db + +Observer dashboard token (min 16 chars) +SHADE_OBSERVER_TOKEN=change-me-to-at-least-16-chars diff --git a/packages/shade-cli/templates/bun-server/.shaderc.json b/packages/shade-cli/templates/bun-server/.shaderc.json new file mode 100644 index 0000000..f3bd16f --- /dev/null +++ b/packages/shade-cli/templates/bun-server/.shaderc.json @@ -0,0 +1,5 @@ +{ + "prekeyServer": "__PREKEY_SERVER__", + "storage": "sqlite:./.shade/client.db", + "address": "__PROJECT_NAME__" +} diff --git a/packages/shade-cli/templates/bun-server/README.md b/packages/shade-cli/templates/bun-server/README.md new file mode 100644 index 0000000..65a734a --- /dev/null +++ b/packages/shade-cli/templates/bun-server/README.md @@ -0,0 +1,46 @@ +# __PROJECT_NAME__ + +A Shade-enabled Bun + Hono server. Encrypted messages in/out via two HTTP endpoints. + +## Prerequisites + +A running Shade prekey server. The default is `__PREKEY_SERVER__`. You can either: +- Run one locally: `docker run -p 3900:3900 shade-prekey-server` +- Override with `SHADE_PREKEY_SERVER=...` in `.env` + +## Run + +```bash +bun install +bun run start +``` + +The server registers itself with the prekey server on startup. + +## Endpoints + +### Send an encrypted message + +```bash +curl -X POST http://localhost:3000/send \ + -H "Content-Type: application/json" \ + -d '{"to": "peer-name", "message": "hello"}' +``` + +Returns a `ShadeEnvelope` you can forward to the peer via any transport. + +### Receive an encrypted envelope + +```bash +curl -X POST http://localhost:3000/receive \ + -H "Content-Type: application/json" \ + -d '{"from": "peer-name", "envelope": {...}}' +``` + +Returns the decrypted plaintext. + +## Next steps + +- Wire a real delivery layer (WebSocket, HTTP push, etc.) +- Run `shade dashboard` to watch live activity +- Compare fingerprints with peers out-of-band before trusting sessions diff --git a/packages/shade-cli/templates/bun-server/package.json b/packages/shade-cli/templates/bun-server/package.json new file mode 100644 index 0000000..de7b713 --- /dev/null +++ b/packages/shade-cli/templates/bun-server/package.json @@ -0,0 +1,14 @@ +{ + "name": "__PROJECT_NAME__", + "version": "0.0.1", + "type": "module", + "scripts": { + "start": "bun run src/index.ts", + "dev": "bun --watch run src/index.ts", + "test": "bun test" + }, + "dependencies": { + "@shade/sdk": "^0.1.0", + "hono": "^4.12.0" + } +} diff --git a/packages/shade-cli/templates/bun-server/src/index.ts b/packages/shade-cli/templates/bun-server/src/index.ts new file mode 100644 index 0000000..093d229 --- /dev/null +++ b/packages/shade-cli/templates/bun-server/src/index.ts @@ -0,0 +1,46 @@ +import { Hono } from 'hono'; +import { createShade } from '@shade/sdk'; + +/** + * __PROJECT_NAME__ — Shade-enabled Bun server template. + * + * Exposes two endpoints: + * POST /send — encrypt a message to a peer + * POST /receive — decrypt an incoming envelope + */ + +const shade = await createShade({ + prekeyServer: process.env.SHADE_PREKEY_SERVER ?? '__PREKEY_SERVER__', + storage: process.env.SHADE_DB_PATH ?? 'sqlite:./.shade/client.db', + address: '__PROJECT_NAME__', +}); + +console.log(`Shade initialized as ${shade.myAddress}`); +console.log(`Fingerprint: ${await shade.fingerprint}`); + +shade.onMessage((from, msg) => { + console.log(`[${from}] ${msg}`); +}); + +const app = new Hono(); + +app.get('/', (c) => c.text(`__PROJECT_NAME__ — Shade-enabled backend`)); + +app.post('/send', async (c) => { + const { to, message } = await c.req.json(); + const envelope = await shade.send(to, message); + return c.json({ envelope }); +}); + +app.post('/receive', async (c) => { + const { from, envelope } = await c.req.json(); + const plaintext = await shade.receive(from, envelope); + return c.json({ plaintext }); +}); + +export default { + port: Number(process.env.PORT ?? 3000), + fetch: app.fetch, +}; + +console.log(`Server listening on :${process.env.PORT ?? 3000}`); diff --git a/packages/shade-cli/templates/chat-demo/README.md b/packages/shade-cli/templates/chat-demo/README.md new file mode 100644 index 0000000..9dd9401 --- /dev/null +++ b/packages/shade-cli/templates/chat-demo/README.md @@ -0,0 +1,17 @@ +# __PROJECT_NAME__ + +Two-process chat demo: Alice and Bob talk via the Shade SDK over a simple +HTTP relay. Shows how easy it is to add E2EE to any transport. + +## Run + +Start a prekey server first (e.g. `docker run -p 3900:3900 shade-prekey-server`). + +Then in two terminals: + +```bash +bun run bob # starts Bob's process on :4001 +bun run alice # starts Alice's process on :4000 +``` + +Alice will send a message to Bob; both will print the activity. diff --git a/packages/shade-cli/templates/chat-demo/package.json b/packages/shade-cli/templates/chat-demo/package.json new file mode 100644 index 0000000..bc65165 --- /dev/null +++ b/packages/shade-cli/templates/chat-demo/package.json @@ -0,0 +1,12 @@ +{ + "name": "__PROJECT_NAME__", + "version": "0.0.1", + "type": "module", + "scripts": { + "alice": "bun run src/alice.ts", + "bob": "bun run src/bob.ts" + }, + "dependencies": { + "@shade/sdk": "^0.1.0" + } +} diff --git a/packages/shade-cli/templates/chat-demo/src/alice.ts b/packages/shade-cli/templates/chat-demo/src/alice.ts new file mode 100644 index 0000000..5cf1d5f --- /dev/null +++ b/packages/shade-cli/templates/chat-demo/src/alice.ts @@ -0,0 +1,27 @@ +import { createShade } from '@shade/sdk'; + +const alice = await createShade({ + prekeyServer: '__PREKEY_SERVER__', + storage: 'sqlite:./.shade/alice.db', + address: 'alice', +}); + +console.log(`Alice ready. Fingerprint: ${await alice.fingerprint}`); + +// Send a message to Bob +const envelope = await alice.send('bob', 'Hey Bob, this is encrypted!'); + +// Forward to Bob's process (simple HTTP) +const res = await fetch('http://localhost:4001/receive', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ from: 'alice', envelope }), +}); + +if (res.ok) { + console.log('✓ Message delivered'); +} else { + console.error('Failed to deliver:', await res.text()); +} + +await alice.shutdown(); diff --git a/packages/shade-cli/templates/chat-demo/src/bob.ts b/packages/shade-cli/templates/chat-demo/src/bob.ts new file mode 100644 index 0000000..7d37faa --- /dev/null +++ b/packages/shade-cli/templates/chat-demo/src/bob.ts @@ -0,0 +1,24 @@ +import { Hono } from 'hono'; +import { createShade } from '@shade/sdk'; + +const bob = await createShade({ + prekeyServer: '__PREKEY_SERVER__', + storage: 'sqlite:./.shade/bob.db', + address: 'bob', +}); + +console.log(`Bob ready. Fingerprint: ${await bob.fingerprint}`); + +bob.onMessage((from, msg) => { + console.log(`\n📨 [${from}] ${msg}\n`); +}); + +const app = new Hono(); +app.post('/receive', async (c) => { + const { from, envelope } = await c.req.json(); + await bob.receive(from, envelope); + return c.json({ ok: true }); +}); + +export default { port: 4001, fetch: app.fetch }; +console.log('Bob listening on :4001'); diff --git a/packages/shade-cli/tests/cli.test.ts b/packages/shade-cli/tests/cli.test.ts new file mode 100644 index 0000000..28ff3c8 --- /dev/null +++ b/packages/shade-cli/tests/cli.test.ts @@ -0,0 +1,210 @@ +import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { initCommand, listTemplates } from '../src/commands/init.js'; +import { tryLoadConfig, loadConfig } from '../src/config.js'; +import { doctorCommand } from '../src/commands/doctor.js'; +import { createPrekeyServer, MemoryPrekeyStore } from '@shade/server'; +import { SubtleCryptoProvider } from '@shade/crypto-web'; + +const crypto = new SubtleCryptoProvider(); + +describe('CLI: init command', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'shade-cli-')); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('listTemplates returns the bundled templates', () => { + const templates = listTemplates(); + expect(templates).toContain('bun-server'); + expect(templates).toContain('chat-demo'); + }); + + test('init scaffolds a bun-server project with substitutions', async () => { + await initCommand({ name: 'my-app', template: 'bun-server', cwd: tmpDir }); + + const target = join(tmpDir, 'my-app'); + expect(existsSync(target)).toBe(true); + expect(existsSync(join(target, 'package.json'))).toBe(true); + expect(existsSync(join(target, 'src/index.ts'))).toBe(true); + expect(existsSync(join(target, '.shaderc.json'))).toBe(true); + + const pkg = JSON.parse(readFileSync(join(target, 'package.json'), 'utf-8')); + expect(pkg.name).toBe('my-app'); + + const shaderc = JSON.parse(readFileSync(join(target, '.shaderc.json'), 'utf-8')); + expect(shaderc.address).toBe('my-app'); + + const index = readFileSync(join(target, 'src/index.ts'), 'utf-8'); + expect(index).not.toContain('__PROJECT_NAME__'); + expect(index).toContain('my-app'); + }); + + test('init with custom prekey-server URL', async () => { + await initCommand({ + name: 'app2', + template: 'bun-server', + cwd: tmpDir, + prekeyServer: 'https://custom.example.com', + }); + + const target = join(tmpDir, 'app2'); + const shaderc = JSON.parse(readFileSync(join(target, '.shaderc.json'), 'utf-8')); + expect(shaderc.prekeyServer).toBe('https://custom.example.com'); + }); + + test('init refuses to overwrite existing directory', async () => { + await initCommand({ name: 'foo', cwd: tmpDir }); + expect(initCommand({ name: 'foo', cwd: tmpDir })).rejects.toThrow(/already exists/); + }); + + test('init with unknown template throws with helpful error', async () => { + expect(initCommand({ name: 'x', template: 'nonexistent', cwd: tmpDir })).rejects.toThrow( + /not found/, + ); + }); + + test('chat-demo template scaffolds correctly', async () => { + await initCommand({ name: 'chat', template: 'chat-demo', cwd: tmpDir }); + + const target = join(tmpDir, 'chat'); + expect(existsSync(join(target, 'src/alice.ts'))).toBe(true); + expect(existsSync(join(target, 'src/bob.ts'))).toBe(true); + + const alice = readFileSync(join(target, 'src/alice.ts'), 'utf-8'); + expect(alice).not.toContain('__PROJECT_NAME__'); + }); +}); + +describe('CLI: config loading', () => { + let tmpDir: string; + let originalEnv: typeof process.env; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'shade-config-')); + originalEnv = { ...process.env }; + delete process.env.SHADE_PREKEY_SERVER; + delete process.env.SHADE_DB_PATH; + delete process.env.SHADE_OBSERVER_TOKEN; + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + process.env = originalEnv; + }); + + test('loads .shaderc.json from cwd', () => { + writeFileSync( + join(tmpDir, '.shaderc.json'), + JSON.stringify({ prekeyServer: 'https://example.com', storage: 'sqlite:./test.db' }), + ); + const config = loadConfig(tmpDir); + expect(config.prekeyServer).toBe('https://example.com'); + expect(config.storage).toBe('sqlite:./test.db'); + }); + + test('falls back to SHADE_PREKEY_SERVER env var', () => { + process.env.SHADE_PREKEY_SERVER = 'https://env.example.com'; + const config = loadConfig(tmpDir); + expect(config.prekeyServer).toBe('https://env.example.com'); + }); + + test('file config takes precedence over env var', () => { + process.env.SHADE_PREKEY_SERVER = 'https://env.example.com'; + writeFileSync( + join(tmpDir, '.shaderc.json'), + JSON.stringify({ prekeyServer: 'https://file.example.com' }), + ); + const config = loadConfig(tmpDir); + expect(config.prekeyServer).toBe('https://file.example.com'); + }); + + test('throws with clear error when missing prekeyServer', () => { + expect(() => loadConfig(tmpDir)).toThrow(/Missing prekeyServer/); + }); + + test('tryLoadConfig returns error without throwing', () => { + const result = tryLoadConfig(tmpDir); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain('Missing prekeyServer'); + }); +}); + +describe('CLI: doctor command', () => { + let tmpDir: string; + let originalEnv: typeof process.env; + let originalCwd: string; + let serverStop: (() => void) | null = null; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'shade-doctor-')); + originalEnv = { ...process.env }; + originalCwd = process.cwd(); + process.chdir(tmpDir); + }); + + afterEach(() => { + process.chdir(originalCwd); + if (serverStop) { + serverStop(); + serverStop = null; + } + rmSync(tmpDir, { recursive: true, force: true }); + process.env = originalEnv; + }); + + test('doctor reports missing config', async () => { + const logs: string[] = []; + const originalLog = console.log; + console.log = (...args) => logs.push(args.join(' ')); + + try { + await doctorCommand(); + } finally { + console.log = originalLog; + } + + const out = logs.join('\n'); + expect(out).toContain('Missing prekeyServer'); + }); + + test('doctor reports reachable prekey server', async () => { + // Spin up a real prekey server + const port = 19300 + Math.floor(Math.random() * 200); + const app = createPrekeyServer({ + crypto, + store: new MemoryPrekeyStore(), + disableRateLimit: true, + }); + const server = Bun.serve({ port, fetch: app.fetch }); + serverStop = () => server.stop(); + + writeFileSync( + join(tmpDir, '.shaderc.json'), + JSON.stringify({ + prekeyServer: `http://localhost:${port}`, + storage: `sqlite:${tmpDir}/client.db`, + }), + ); + + const logs: string[] = []; + const originalLog = console.log; + console.log = (...args) => logs.push(args.join(' ')); + + try { + await doctorCommand(); + } finally { + console.log = originalLog; + } + + const out = logs.join('\n'); + expect(out).toContain('Prekey server is reachable'); + }); +}); diff --git a/packages/shade-cli/tsconfig.json b/packages/shade-cli/tsconfig.json new file mode 100644 index 0000000..a3e0a93 --- /dev/null +++ b/packages/shade-cli/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src"] +} diff --git a/packages/shade-server/src/routes.ts b/packages/shade-server/src/routes.ts index b1a91c8..bb69ee6 100644 --- a/packages/shade-server/src/routes.ts +++ b/packages/shade-server/src/routes.ts @@ -53,6 +53,9 @@ export function createPrekeyRoutes( ); }; + // Lightweight health endpoint (always available, no auth) + app.get('/health', (c) => c.json({ status: 'ok', service: 'shade-prekey-server' })); + // Global error handler — maps ShadeError to HTTP status app.onError((err, c) => { if (err instanceof RateLimitError) { diff --git a/scripts/bump-version.ts b/scripts/bump-version.ts new file mode 100644 index 0000000..dc3e9a3 --- /dev/null +++ b/scripts/bump-version.ts @@ -0,0 +1,40 @@ +#!/usr/bin/env bun +/** + * Bump the version of all @shade/* packages in lockstep. + * + * Usage: + * bun run scripts/bump-version.ts 1.1.0 + */ +import { readFileSync, writeFileSync, readdirSync, statSync } from 'fs'; +import { join } from 'path'; + +const ROOT = join(import.meta.dir, '..'); +const PACKAGES_DIR = join(ROOT, 'packages'); + +const newVersion = process.argv[2]; +if (!newVersion || !/^\d+\.\d+\.\d+(?:-[a-z0-9.]+)?$/.test(newVersion)) { + console.error('Usage: bun run scripts/bump-version.ts '); + console.error('Example: bun run scripts/bump-version.ts 1.1.0'); + process.exit(1); +} + +const packages = readdirSync(PACKAGES_DIR).filter((name) => { + return statSync(join(PACKAGES_DIR, name)).isDirectory(); +}); + +let updated = 0; +for (const pkg of packages) { + const pkgPath = join(PACKAGES_DIR, pkg, 'package.json'); + try { + const pkgJson = JSON.parse(readFileSync(pkgPath, 'utf-8')); + pkgJson.version = newVersion; + writeFileSync(pkgPath, JSON.stringify(pkgJson, null, 2) + '\n'); + console.log(` ${pkgJson.name} → ${newVersion}`); + updated++; + } catch (err) { + console.error(` ✗ ${pkg}: ${(err as Error).message}`); + } +} + +console.log(`\nUpdated ${updated} packages to ${newVersion}`); +console.log(`Next: git commit -am "chore: bump to ${newVersion}" && git tag v${newVersion} && git push --tags`); diff --git a/scripts/publish-all.ts b/scripts/publish-all.ts new file mode 100644 index 0000000..3cf7a05 --- /dev/null +++ b/scripts/publish-all.ts @@ -0,0 +1,99 @@ +#!/usr/bin/env bun +/** + * Publish all @shade/* packages to the Gitea npm registry. + * + * Expects these env vars: + * GITEA_TOKEN — publish token from Gitea (Settings → Applications) + * GITEA_USER — Gitea username that owns the registry (e.g. "Stian") + * + * Optional: + * DRY_RUN=1 — build tarballs but don't publish + * + * Usage: + * bun run scripts/publish-all.ts + */ +import { readFileSync, writeFileSync, existsSync } from 'fs'; +import { join } from 'path'; +import { $ } from 'bun'; + +const PACKAGES = [ + 'shade-core', + 'shade-crypto-web', + 'shade-proto', + 'shade-storage-sqlite', + 'shade-storage-postgres', + 'shade-server', + 'shade-observer', + 'shade-transport', + 'shade-widgets', + 'shade-sdk', + 'shade-cli', +]; + +const REGISTRY_HOST = 'gt.zyon.no'; +const ROOT = join(import.meta.dir, '..'); + +async function main() { + const token = process.env.GITEA_TOKEN; + const user = process.env.GITEA_USER ?? 'Stian'; + const dryRun = process.env.DRY_RUN === '1'; + + if (!token && !dryRun) { + console.error('GITEA_TOKEN is required (or set DRY_RUN=1)'); + process.exit(1); + } + + const registryUrl = `https://${REGISTRY_HOST}/api/packages/${user}/npm/`; + console.log(`Target registry: ${registryUrl}`); + console.log(`Dry run: ${dryRun ? 'yes' : 'no'}`); + console.log(); + + // Write a temporary .npmrc at the root + const npmrcPath = join(ROOT, '.npmrc.publish'); + const npmrc = [ + `@shade:registry=${registryUrl}`, + dryRun ? '' : `//${REGISTRY_HOST}/api/packages/${user}/npm/:_authToken=${token}`, + ].filter(Boolean).join('\n'); + writeFileSync(npmrcPath, npmrc); + + let published = 0; + let skipped = 0; + + for (const pkg of PACKAGES) { + const pkgDir = join(ROOT, 'packages', pkg); + if (!existsSync(join(pkgDir, 'package.json'))) { + console.log(`⊘ ${pkg} — package.json not found, skipping`); + skipped++; + continue; + } + + const pkgJson = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf-8')); + console.log(`→ ${pkgJson.name}@${pkgJson.version}`); + + try { + if (dryRun) { + await $`cd ${pkgDir} && bun pm pack --dry-run`.quiet(); + } else { + await $`cd ${pkgDir} && npm publish --registry=${registryUrl} --userconfig ${npmrcPath}`.quiet(); + } + published++; + console.log(` ✓ ${dryRun ? 'packed' : 'published'}`); + } catch (err) { + console.error(` ✗ failed: ${(err as Error).message}`); + process.exitCode = 1; + } + } + + // Clean up temp npmrc + try { + await $`rm ${npmrcPath}`.quiet(); + } catch {} + + console.log(); + console.log(`Done: ${published} published, ${skipped} skipped`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +});