feat(cli): M-Tool 1-3 — CLI, templates, Gitea publishing pipeline
Some checks failed
Test / test (push) Has been cancelled

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-11 00:38:00 +02:00
parent c95824f95f
commit 518dc68c4f
29 changed files with 1263 additions and 15 deletions

View File

@@ -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<void> {
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.`);
}
}

View File

@@ -0,0 +1,70 @@
import { tryLoadConfig } from '../config.js';
import { existsSync } from 'fs';
/**
* Diagnose common setup issues.
*/
export async function doctorCommand(): Promise<void> {
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;
}
}

View File

@@ -0,0 +1,35 @@
import { createShade } from '@shade/sdk';
import { loadConfig } from '../config.js';
export async function fingerprintCommand(address?: string): Promise<void> {
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();
}
}

View File

@@ -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<void> {
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<string, string>,
): 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);
}
}
}

View File

@@ -0,0 +1,79 @@
import { createShade } from '@shade/sdk';
import { loadConfig } from '../config.js';
export async function peerAddCommand(address: string): Promise<void> {
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<void> {
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<void> {
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<void> {
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();
}
}

View File

@@ -0,0 +1,23 @@
import { createShade } from '@shade/sdk';
import { loadConfig } from '../config.js';
export async function publishCommand(): Promise<void> {
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();
}
}

View File

@@ -0,0 +1,29 @@
import { createShade } from '@shade/sdk';
import { loadConfig } from '../config.js';
export async function rotateCommand(opts: { identity?: boolean } = {}): Promise<void> {
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();
}
}