shraga 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -86,9 +86,59 @@ The core is deliberately small and grows through documented seams (see
86
86
  | **Route extensions** (`data/extensions/*.ext.ts`) | Drop-in public routes (webhooks, OAuth callbacks) per deployment. |
87
87
  | **`SHRAGA_OVERLAY`** | Load a whole external add-on module at startup (the overlay tier above). |
88
88
 
89
- ## Quickstart
89
+ ## Use as a library
90
+
91
+ Shraga's first-class surface is the `createShraga` factory (the package `main`/`exports`). You
92
+ `import` it, wire your registrations against the same seams the built-ins use, then own the
93
+ lifecycle — `start()` returns a handle you can `stop()`. The CLI and the run-from-source entry are
94
+ thin wrappers over this exact call (`createShraga(fromEnv()).start()`).
95
+
96
+ ```ts
97
+ import { createShraga } from 'shraga';
98
+
99
+ const shraga = createShraga({
100
+ port: 3032,
101
+ dataDir: './data',
102
+ authProvider: 'local', // 'local' (default) | 'firebase'
103
+ });
104
+
105
+ // Pre-start registration (chainable). See "The extension seams" in AGENTS.md.
106
+ shraga
107
+ .registerFeature(myFeature) // server routes/WS/consumers
108
+ .registerEngine(myEngine) // a pluggable agent runtime
109
+ .registerExtension(register) // same shape as a data/extensions/*.ext.ts default export
110
+ .registerWebhook({ source: 'stripe', verify }) // public POST /api/webhooks/stripe → typed event
111
+ .on('stripe', (payload, evt) => { /* handle the event */ });
112
+
113
+ const handle = await shraga.start();
114
+ console.log(`listening on ${handle.url}`); // { app, server, port, url, emitEvent, on, ... }
115
+
116
+ // later:
117
+ await handle.stop(); // drains and closes without exiting the process
118
+ ```
119
+
120
+ `ShragaOptions` is typed; anything not modelled is still reachable via `env` (Shraga is heavily
121
+ env-driven), applied before boot:
122
+
123
+ ```ts
124
+ createShraga({ env: { ANTHROPIC_API_KEY: '…', SHRAGA_FEAT_WORKSPACE: '1' } });
125
+ ```
126
+
127
+ **`ServerHandle`** (returned by `start()`): `{ app, server, port, url, emitEvent, on,
128
+ registerExtension, registerWebhook, stop }`.
129
+
130
+ **Runtime plug-and-play is opt-in.** `registerFeature`/`registerEngine`/`registerExtension`/
131
+ `registerWebhook`/`on` are meant to run **before** `start()`. If you need to register *after* boot,
132
+ set `runtimeRegistration: true` (or `SHRAGA_RUNTIME_REGISTRATION=1`) — then the `ServerHandle`'s
133
+ `registerExtension`/`registerWebhook`/`on` mount onto the live extension Router / event bus.
134
+ Default is **off**, and those handle methods throw until you enable it. Only extensions, webhooks
135
+ and event subscriptions are runtime-registerable — features and engines mount at boot and are never
136
+ runtime-registerable, on or off.
137
+
138
+ ## Quickstart (CLI)
90
139
 
91
- Requires [Bun](https://bun.sh) ≥ 1.0.
140
+ Requires [Bun](https://bun.sh) ≥ 1.0. Env-configured, no code — this is the `createShraga(fromEnv())`
141
+ tier.
92
142
 
93
143
  **Fastest — run from npm:**
94
144
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shraga",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "The teammate you delegate coding to — a self-hostable, multi-user AI coding agent web UI (Claude Code, with a pluggable engine seam).",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
package/src/index.ts CHANGED
@@ -56,6 +56,11 @@ export class ShragaOptions {
56
56
  /** Install process SIGTERM/SIGINT handlers (default true — the standalone server/CLI wants them).
57
57
  * A library embedder owning its own lifecycle sets false and uses stop(). */
58
58
  installSignalHandlers?: boolean = true;
59
+ /** OPT-IN runtime plug-and-play (default false). When true, the ServerHandle returned by start()
60
+ * can register extensions/webhooks and subscribe to events AFTER boot (they mount on the live
61
+ * extension Router / event bus). When false, those handle methods throw. Pre-start registration is
62
+ * unaffected either way. Features & engines are NEVER runtime-registerable (they mount at boot). */
63
+ runtimeRegistration?: boolean = false;
59
64
  /** Arbitrary extra environment to apply before boot (e.g. ANTHROPIC_API_KEY, SHRAGA_FEAT_*). */
60
65
  env?: Record<string, string>;
61
66
  }
@@ -103,6 +108,7 @@ class Shraga implements ShragaInstance {
103
108
  if (o.authProvider != null) process.env.AUTH_PROVIDER = o.authProvider;
104
109
  if (o.passive != null) process.env.SHRAGA_PASSIVE = o.passive ? '1' : '0';
105
110
  if (o.installSignalHandlers === false) process.env.SHRAGA_INSTALL_SIGNALS = '0';
111
+ if (o.runtimeRegistration != null) process.env.SHRAGA_RUNTIME_REGISTRATION = o.runtimeRegistration ? '1' : '0';
106
112
  for (const [k, v] of Object.entries(o.env ?? {})) process.env[k] = v;
107
113
  }
108
114
 
@@ -163,10 +169,12 @@ export function createShraga(options?: Partial<ShragaOptions>): ShragaInstance {
163
169
  * run-from-source entry and the CLI pass this straight into createShraga. */
164
170
  export function fromEnv(): Partial<ShragaOptions> {
165
171
  const passive = process.env.SHRAGA_PASSIVE ?? process.env.UNCLAW_PASSIVE;
172
+ const runtimeReg = process.env.SHRAGA_RUNTIME_REGISTRATION;
166
173
  return {
167
174
  port: process.env.PORT ? Number(process.env.PORT) : undefined,
168
175
  dataDir: process.env.DATA_DIR || undefined,
169
176
  authProvider: (process.env.AUTH_PROVIDER as 'local' | 'firebase' | undefined) || undefined,
170
177
  passive: passive === '1' || passive === 'true' ? true : undefined,
178
+ runtimeRegistration: runtimeReg === '1' || runtimeReg === 'true' ? true : undefined,
171
179
  };
172
180
  }
@@ -22,6 +22,7 @@ import { requireAuth, verifyBearer, AUTH_PROVIDER, localLogin, addLocalUser, loc
22
22
  import { getMcpConfig, getRawMcpConfig, getResolvedMcpConfig, getGlobalMcpConfig, saveMcpConfig, maskEnvValues, mergeWithOriginal, type McpConfig } from './mcp.ts';
23
23
  import { streamChat, consumeStream, getAgentConfig, saveAgentConfig, type AgentConfig, type PermissionHandler, type QuestionHandler, type QuestionAnswers, type AttachmentMeta, type WsEvent } from './claude.ts';
24
24
  import { mountFeatures, registerFeature, resumeFeatureSession, collectFeatureFlags, collectSidecarRoutes } from './features.ts';
25
+ import { registerSpaCatchAll } from './spa-catchall.ts';
25
26
  import { slackFeature } from './slack/feature.ts';
26
27
  import { dataPath } from './paths.ts';
27
28
  import { getAllSessions, getSession, getSessionHistory, upsertSession, appendMessage, saveConversation, loadConversation, setSessionDirectives, getAutoApprove, setAutoApprove, getSessionsByScheduleId, getSessionsVisibleTo, isSessionVisibleTo, setRunStatus, incrementRetryCount, resetRetryCount, getRunningSessions, updateScheduledSessionStatus, setShuttingDown, backfillSessionVisibility, writePartial, readPartial, clearPartial, registerLivePartial, unregisterLivePartial, readLivePartial, acquireSessionLock, releaseSessionLock, replaceSessionLock, isSessionLocked, getSessionAbortController, forkSession, generateSessionTitle, type ConvBlock, type ConvMessage, type SessionMeta } from './sessions.ts';
@@ -62,6 +63,8 @@ import type { Server as HttpServer } from 'node:http';
62
63
  import type { Express } from 'express';
63
64
  import { emitEvent } from './events/bus.ts';
64
65
  import type { ExtRegisterFn } from './extensions.ts';
66
+ import type { WebhookOptions } from './events/webhook.ts';
67
+ import type { ShragaEvent, PayloadOf } from './events/types.ts';
65
68
 
66
69
  export interface BootRegistrations {
67
70
  features?: ServerFeature[];
@@ -77,6 +80,16 @@ export interface ServerHandle {
77
80
  url: string;
78
81
  /** Publish an event onto the in-process bus (same fn extensions get as ctx.emitEvent). */
79
82
  emitEvent: typeof emitEvent;
83
+ /** Register an extension AFTER start() — mounts onto the live extension Router (before the SPA
84
+ * catch-all), the same seam file-based *.ext.ts drop-ins hot-load through. OPT-IN: throws unless
85
+ * ShragaOptions.runtimeRegistration is enabled. */
86
+ registerExtension: (fn: ExtRegisterFn) => Promise<void>;
87
+ /** Declare a verified vendor webhook AFTER start() (sugar over registerExtension — a webhook IS an
88
+ * extension). OPT-IN: throws unless runtimeRegistration is enabled. */
89
+ registerWebhook: <K extends string>(opts: WebhookOptions<K>) => Promise<void>;
90
+ /** Subscribe to a typed event source AFTER start(). Returns an unsubscribe fn. OPT-IN: throws
91
+ * unless runtimeRegistration is enabled. */
92
+ on: <K extends string>(source: K, handler: (payload: PayloadOf<K>, evt: ShragaEvent<K>) => void) => () => void;
80
93
  /** Drain in-flight streams, stop consumers, close the server. Does NOT exit the process. */
81
94
  stop: () => Promise<void>;
82
95
  }
@@ -723,9 +736,12 @@ await loadExtensions(app);
723
736
 
724
737
  if (existsSync(distPath)) app.use(express.static(distPath));
725
738
 
726
- // NOTE: the SPA catch-all (`app.get('*')`) is registered LATER — after mountFeatures() — so that
727
- // feature-contributed routes (incl. GET) are matched before falling through to index.html. Registering
728
- // it here would shadow every feature GET route (Express matches '*' first). See registerSpaCatchAll().
739
+ // The SPA catch-all (`app.get('*')`) is registered LATER — after mountFeatures() — via
740
+ // registerSpaCatchAll(), so feature/extension GET routes are matched before falling through to
741
+ // index.html. It is re-registered on passive→active promotion (mountFeatures runs again there),
742
+ // each call splicing out the prior catch-all layer so it stays truly LAST in the router stack.
743
+ // In dev (no dist/) it is skipped entirely — Vite serves the SPA and a catch-all would 404-shadow it.
744
+ // Implementation + predicate live in ./spa-catchall.ts (unit-tested there).
729
745
 
730
746
  // ── WebSocket + Server ───────────────────────────────────────────────────────
731
747
 
@@ -946,6 +962,9 @@ mountFeatures({ app, requireAuth, broadcast, passive: PASSIVE });
946
962
  // Fold in feature-contributed sidecar WS proxy routes (the core names none; each add-on adds its own).
947
963
  Object.assign(WS_PROXY_ROUTES, collectSidecarRoutes());
948
964
 
965
+ // SPA fallback — MUST be the last GET route so it never shadows real API/feature/extension routes.
966
+ registerSpaCatchAll(app, distPath);
967
+
949
968
  // ── Runtime promotion (blue-green flip) ──────────────────────────────────────
950
969
  // A passive instance can be promoted to active once traffic has been flipped to it:
951
970
  // starts every consumer/writer that passive boot skipped. One-way; idempotent-guarded.
@@ -958,6 +977,8 @@ async function activateConsumers() {
958
977
  scheduler.start(broadcast);
959
978
  startEventDispatcher();
960
979
  mountFeatures({ app, requireAuth, broadcast, passive: false });
980
+ // Re-place the SPA catch-all AFTER the promotion's feature mount so newly-added GET routes win.
981
+ registerSpaCatchAll(app, distPath);
961
982
  startSidecars().catch(err => console.error('[sidecar] startup error:', err));
962
983
  recoverInterruptedSessions().catch(err => console.error('[recovery] failed:', err));
963
984
  }
@@ -1767,12 +1788,27 @@ await new Promise<void>((resolve) => {
1767
1788
  });
1768
1789
  });
1769
1790
 
1791
+ // OPT-IN post-start plug-and-play. Only extensions/webhooks/events are runtime-safe (they mount on
1792
+ // the persistent extRouter / in-process bus); features & engines mount at boot and are NOT re-entrant.
1793
+ const RT_FLAG = process.env.SHRAGA_RUNTIME_REGISTRATION;
1794
+ const RUNTIME_REG = RT_FLAG === '1' || RT_FLAG === 'true';
1795
+ const runtimeGuard = (what: string) => {
1796
+ if (!RUNTIME_REG) throw new Error(
1797
+ `[shraga] ${what}() at runtime is disabled. Enable it with createShraga({ runtimeRegistration: true }) ` +
1798
+ `(or SHRAGA_RUNTIME_REGISTRATION=1) before start().`,
1799
+ );
1800
+ };
1801
+
1770
1802
  return {
1771
1803
  app,
1772
1804
  server,
1773
1805
  port: PORT,
1774
1806
  url: `http://localhost:${PORT}`,
1775
1807
  emitEvent,
1808
+ registerExtension: (fn) => { runtimeGuard('registerExtension'); return registerExtension(fn); },
1809
+ // A webhook is just an extension that mounts a verified route on the extension Router — reuse the seam.
1810
+ registerWebhook: (opts) => { runtimeGuard('registerWebhook'); return registerExtension((_r, ctx) => { ctx.registerWebhook(opts); }); },
1811
+ on: (source, handler) => { runtimeGuard('on'); return subscribeEvent(source, handler); },
1776
1812
  stop: () => gracefulShutdown('stop', { exit: false }),
1777
1813
  };
1778
1814
 
@@ -15,6 +15,14 @@ type Listener = (evt: ShragaEvent) => void;
15
15
 
16
16
  const listeners = new Set<Listener>();
17
17
 
18
+ /** Clear all subscribers. For tests: `listeners` is a process-global, so a test file that boots a
19
+ * server (or its on() subscribers) leaks listeners into a later file's emit. Reset between files
20
+ * that share bus state to stay hermetic — bun's cross-file order is not stable across platforms.
21
+ * Mirrors clearTurnContext()/`__resetExtensionsForTest()`. Test-only. */
22
+ export function __resetEventBusForTest(): void {
23
+ listeners.clear();
24
+ }
25
+
18
26
  /** Subscribe to ALL events. Returns an unsubscribe fn. The dispatcher uses this. */
19
27
  export function subscribeEvents(fn: Listener): () => void {
20
28
  listeners.add(fn);
@@ -49,6 +49,20 @@ let extRouter: ExpressRouter | null = null;
49
49
  let ctx: ExtensionContext | null = null;
50
50
  const pendingProgrammatic: ExtRegisterFn[] = [];
51
51
 
52
+ /** Reset the module-global router/ctx/registry. For tests: `extRouter`, `ctx` and `loaded` are
53
+ * process-globals, so a test file that boots a second createShraga server in the same bun process
54
+ * inherits the PRIOR file's router — and a pre-start `registerExtension`/`registerWebhook` (which
55
+ * mounts immediately once `extRouter && ctx` are set, see registerExtension) then lands on the DEAD
56
+ * router instead of queueing for the new one → the new server 404s. bun's cross-file order is not
57
+ * stable across platforms (a Linux CI order that macOS never hits), so a test that boots a server
58
+ * must reset first to be hermetic. Mirrors clearTurnContext() in turn-context.ts. Test-only. */
59
+ export function __resetExtensionsForTest(): void {
60
+ extRouter = null;
61
+ ctx = null;
62
+ loaded.clear();
63
+ pendingProgrammatic.length = 0;
64
+ }
65
+
52
66
  async function runProgrammatic(fn: ExtRegisterFn): Promise<void> {
53
67
  try {
54
68
  await fn(extRouter!, ctx!);
@@ -0,0 +1,41 @@
1
+ import path from 'path';
2
+ import { existsSync } from 'fs';
3
+ import type express from 'express';
4
+
5
+ // Non-page prefixes that must fall through to a real 404 (JSON/API/transport), never the SPA shell.
6
+ function isNonPagePath(p: string): boolean {
7
+ return p.startsWith('/api/') || p.startsWith('/mcp') || p.startsWith('/uploads') ||
8
+ p.startsWith('/internal/') || p.startsWith('/.well-known');
9
+ }
10
+
11
+ /**
12
+ * Register the SPA catch-all (`app.get('*')`) as the LAST GET route so it serves the built
13
+ * index.html for client routes/deep links (/oauth/authorize, /cli-auth, /session/x, …) while
14
+ * letting unmatched API/MCP/upload/internal/oauth-metadata paths fall through to a real 404.
15
+ *
16
+ * Idempotent + re-placeable: each call splices out any prior catch-all layer, so it can be called
17
+ * again after a later mountFeatures() (passive→active promotion) and still sit behind those routes.
18
+ * In dev (no built dist) it is a no-op — Vite serves the SPA and a catch-all would 404-shadow it.
19
+ *
20
+ * EXPRESS 4 COUPLING: uses the bare `app.get('*')` route and Express's private `_router.stack`.
21
+ * A future express@5 bump breaks this LOUDLY (path-to-regexp v8 rejects `'*'`), not silently —
22
+ * re-verify the promotion-path ordering if express is ever upgraded.
23
+ */
24
+ export function registerSpaCatchAll(app: express.Express, distPath: string): void {
25
+ if (!existsSync(distPath)) return;
26
+ const indexHtml = path.join(distPath, 'index.html');
27
+ // For app.get(), the layer's `handle` is the Route's dispatcher — our tagged handler lives inside
28
+ // `layer.route.stack[*].handle`. Match on that so re-registration removes the stale catch-all.
29
+ const stack = (app as any)._router?.stack as Array<{ route?: { stack?: Array<{ handle?: { __spaCatchAll?: boolean } }> } }> | undefined;
30
+ if (stack) {
31
+ for (let i = stack.length - 1; i >= 0; i--) {
32
+ if (stack[i]?.route?.stack?.some((h) => h?.handle?.__spaCatchAll)) stack.splice(i, 1);
33
+ }
34
+ }
35
+ const handler: express.RequestHandler = (req, res, next) => {
36
+ if (isNonPagePath(req.path)) return next();
37
+ res.sendFile(indexHtml);
38
+ };
39
+ (handler as { __spaCatchAll?: boolean }).__spaCatchAll = true;
40
+ app.get('*', handler);
41
+ }