patchwork-os 0.2.0-alpha.0 → 0.2.0-alpha.2

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.
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Gmail OAuth 2.0 connector.
3
+ *
4
+ * Handles:
5
+ * GET /connections/gmail/auth — redirect to Google consent screen
6
+ * GET /connections/gmail/callback — exchange code for tokens, store locally
7
+ * POST /connections/gmail/test — verify stored token works
8
+ * DELETE /connections/gmail — revoke + delete stored token
9
+ * GET /connections — list connector statuses
10
+ *
11
+ * Tokens stored at ~/.patchwork/tokens/gmail.json (mode 0600, never leaves machine).
12
+ * Client credentials read from env: GMAIL_CLIENT_ID, GMAIL_CLIENT_SECRET.
13
+ */
14
+ export interface GmailTokens {
15
+ access_token: string;
16
+ refresh_token?: string;
17
+ expiry_date?: number;
18
+ token_type?: string;
19
+ scope?: string;
20
+ }
21
+ export interface ConnectorStatus {
22
+ id: string;
23
+ status: "connected" | "disconnected";
24
+ lastSync?: string;
25
+ email?: string;
26
+ }
27
+ export declare function loadTokens(): GmailTokens | null;
28
+ /** Returns a valid access token, refreshing if needed. */
29
+ export declare function getValidAccessToken(): Promise<string>;
30
+ export interface ConnectorHandlerResult {
31
+ status: number;
32
+ body: string;
33
+ contentType?: string;
34
+ redirect?: string;
35
+ }
36
+ export declare function handleConnectionsList(): Promise<ConnectorHandlerResult>;
37
+ export declare function handleGmailAuthRedirect(): ConnectorHandlerResult;
38
+ export declare function handleGmailCallback(code: string | null, state: string | null, error: string | null): Promise<ConnectorHandlerResult>;
39
+ export declare function handleGmailTest(): Promise<ConnectorHandlerResult>;
40
+ export declare function handleGmailDisconnect(): Promise<ConnectorHandlerResult>;
@@ -0,0 +1,275 @@
1
+ /**
2
+ * Gmail OAuth 2.0 connector.
3
+ *
4
+ * Handles:
5
+ * GET /connections/gmail/auth — redirect to Google consent screen
6
+ * GET /connections/gmail/callback — exchange code for tokens, store locally
7
+ * POST /connections/gmail/test — verify stored token works
8
+ * DELETE /connections/gmail — revoke + delete stored token
9
+ * GET /connections — list connector statuses
10
+ *
11
+ * Tokens stored at ~/.patchwork/tokens/gmail.json (mode 0600, never leaves machine).
12
+ * Client credentials read from env: GMAIL_CLIENT_ID, GMAIL_CLIENT_SECRET.
13
+ */
14
+ import crypto from "node:crypto";
15
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, } from "node:fs";
16
+ import { homedir } from "node:os";
17
+ import path from "node:path";
18
+ const SCOPES = ["https://www.googleapis.com/auth/gmail.readonly"];
19
+ const REDIRECT_URI = "http://localhost:3100/connections/gmail/callback";
20
+ const TOKEN_PATH = path.join(homedir(), ".patchwork", "tokens", "gmail.json");
21
+ function clientId() {
22
+ return process.env.GMAIL_CLIENT_ID ?? "";
23
+ }
24
+ function clientSecret() {
25
+ return process.env.GMAIL_CLIENT_SECRET ?? "";
26
+ }
27
+ function isConfigured() {
28
+ return Boolean(clientId() && clientSecret());
29
+ }
30
+ // ── Token storage ─────────────────────────────────────────────────────────────
31
+ export function loadTokens() {
32
+ if (!existsSync(TOKEN_PATH))
33
+ return null;
34
+ try {
35
+ return JSON.parse(readFileSync(TOKEN_PATH, "utf-8"));
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ function saveTokens(tokens) {
42
+ mkdirSync(path.dirname(TOKEN_PATH), { recursive: true, mode: 0o700 });
43
+ writeFileSync(TOKEN_PATH, JSON.stringify(tokens, null, 2), { mode: 0o600 });
44
+ }
45
+ function deleteTokens() {
46
+ if (existsSync(TOKEN_PATH))
47
+ unlinkSync(TOKEN_PATH);
48
+ }
49
+ // ── OAuth helpers ─────────────────────────────────────────────────────────────
50
+ function buildAuthUrl(state) {
51
+ const params = new URLSearchParams({
52
+ client_id: clientId(),
53
+ redirect_uri: REDIRECT_URI,
54
+ response_type: "code",
55
+ scope: SCOPES.join(" "),
56
+ access_type: "offline",
57
+ prompt: "consent",
58
+ state,
59
+ });
60
+ return `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;
61
+ }
62
+ async function exchangeCode(code) {
63
+ const res = await fetch("https://oauth2.googleapis.com/token", {
64
+ method: "POST",
65
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
66
+ body: new URLSearchParams({
67
+ code,
68
+ client_id: clientId(),
69
+ client_secret: clientSecret(),
70
+ redirect_uri: REDIRECT_URI,
71
+ grant_type: "authorization_code",
72
+ }).toString(),
73
+ });
74
+ if (!res.ok) {
75
+ const body = await res.text();
76
+ throw new Error(`Token exchange failed: ${res.status} ${body}`);
77
+ }
78
+ const json = (await res.json());
79
+ return {
80
+ access_token: json.access_token,
81
+ refresh_token: json.refresh_token,
82
+ expiry_date: json.expires_in
83
+ ? Date.now() + json.expires_in * 1000
84
+ : undefined,
85
+ token_type: json.token_type,
86
+ scope: json.scope,
87
+ };
88
+ }
89
+ async function refreshAccessToken(tokens) {
90
+ if (!tokens.refresh_token)
91
+ throw new Error("No refresh token available");
92
+ const res = await fetch("https://oauth2.googleapis.com/token", {
93
+ method: "POST",
94
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
95
+ body: new URLSearchParams({
96
+ refresh_token: tokens.refresh_token,
97
+ client_id: clientId(),
98
+ client_secret: clientSecret(),
99
+ grant_type: "refresh_token",
100
+ }).toString(),
101
+ });
102
+ if (!res.ok) {
103
+ const body = await res.text();
104
+ throw new Error(`Token refresh failed: ${res.status} ${body}`);
105
+ }
106
+ const json = (await res.json());
107
+ const updated = {
108
+ ...tokens,
109
+ access_token: json.access_token,
110
+ expiry_date: json.expires_in
111
+ ? Date.now() + json.expires_in * 1000
112
+ : tokens.expiry_date,
113
+ };
114
+ saveTokens(updated);
115
+ return updated;
116
+ }
117
+ /** Returns a valid access token, refreshing if needed. */
118
+ export async function getValidAccessToken() {
119
+ let tokens = loadTokens();
120
+ if (!tokens)
121
+ throw new Error("Gmail not connected");
122
+ const bufferMs = 60_000;
123
+ if (tokens.expiry_date && Date.now() > tokens.expiry_date - bufferMs) {
124
+ tokens = await refreshAccessToken(tokens);
125
+ }
126
+ return tokens.access_token;
127
+ }
128
+ async function revokeToken(token) {
129
+ await fetch(`https://oauth2.googleapis.com/revoke?token=${encodeURIComponent(token)}`, { method: "POST" }).catch(() => { });
130
+ }
131
+ async function fetchUserEmail(accessToken) {
132
+ const res = await fetch("https://gmail.googleapis.com/gmail/v1/users/me/profile", { headers: { Authorization: `Bearer ${accessToken}` } });
133
+ if (!res.ok)
134
+ return "";
135
+ const json = (await res.json());
136
+ return json.emailAddress ?? "";
137
+ }
138
+ // ── State map (in-memory CSRF protection) ────────────────────────────────────
139
+ const pendingStates = new Set();
140
+ function generateState() {
141
+ const state = crypto.randomBytes(32).toString("hex");
142
+ pendingStates.add(state);
143
+ setTimeout(() => pendingStates.delete(state), 10 * 60 * 1000); // 10 min TTL
144
+ return state;
145
+ }
146
+ export async function handleConnectionsList() {
147
+ const tokens = loadTokens();
148
+ const { getStatus: getGitHubStatus } = await import("./github.js");
149
+ const gh = getGitHubStatus();
150
+ const connectors = [
151
+ {
152
+ id: "gmail",
153
+ status: tokens ? "connected" : "disconnected",
154
+ lastSync: tokens ? new Date().toISOString() : undefined,
155
+ },
156
+ {
157
+ id: "github",
158
+ status: gh.connected ? "connected" : "disconnected",
159
+ lastSync: gh.connected ? new Date().toISOString() : undefined,
160
+ },
161
+ ];
162
+ return {
163
+ status: 200,
164
+ contentType: "application/json",
165
+ body: JSON.stringify({ connectors }),
166
+ };
167
+ }
168
+ export function handleGmailAuthRedirect() {
169
+ if (!isConfigured()) {
170
+ return {
171
+ status: 503,
172
+ contentType: "application/json",
173
+ body: JSON.stringify({
174
+ error: "Gmail connector not configured. Set GMAIL_CLIENT_ID and GMAIL_CLIENT_SECRET.",
175
+ }),
176
+ };
177
+ }
178
+ const state = generateState();
179
+ const url = buildAuthUrl(state);
180
+ return { status: 302, redirect: url, body: "" };
181
+ }
182
+ export async function handleGmailCallback(code, state, error) {
183
+ if (error) {
184
+ return {
185
+ status: 400,
186
+ contentType: "text/html",
187
+ body: callbackHtml("Connection cancelled", `Google returned: ${error}`, false),
188
+ };
189
+ }
190
+ if (!code || !state || !pendingStates.has(state)) {
191
+ return {
192
+ status: 400,
193
+ contentType: "text/html",
194
+ body: callbackHtml("Invalid request", "Missing or expired state.", false),
195
+ };
196
+ }
197
+ pendingStates.delete(state);
198
+ try {
199
+ const tokens = await exchangeCode(code);
200
+ saveTokens(tokens);
201
+ const email = await fetchUserEmail(tokens.access_token);
202
+ return {
203
+ status: 200,
204
+ contentType: "text/html",
205
+ body: callbackHtml("Gmail connected", `Connected${email ? ` as ${email}` : ""}. You can close this tab.`, true),
206
+ };
207
+ }
208
+ catch (err) {
209
+ return {
210
+ status: 500,
211
+ contentType: "text/html",
212
+ body: callbackHtml("Connection failed", err instanceof Error ? err.message : String(err), false),
213
+ };
214
+ }
215
+ }
216
+ export async function handleGmailTest() {
217
+ try {
218
+ const token = await getValidAccessToken();
219
+ const email = await fetchUserEmail(token);
220
+ return {
221
+ status: 200,
222
+ contentType: "application/json",
223
+ body: JSON.stringify({ ok: true, email }),
224
+ };
225
+ }
226
+ catch (err) {
227
+ return {
228
+ status: 400,
229
+ contentType: "application/json",
230
+ body: JSON.stringify({
231
+ ok: false,
232
+ error: err instanceof Error ? err.message : String(err),
233
+ }),
234
+ };
235
+ }
236
+ }
237
+ export async function handleGmailDisconnect() {
238
+ const tokens = loadTokens();
239
+ if (tokens?.access_token) {
240
+ await revokeToken(tokens.access_token);
241
+ }
242
+ deleteTokens();
243
+ return {
244
+ status: 200,
245
+ contentType: "application/json",
246
+ body: JSON.stringify({ ok: true }),
247
+ };
248
+ }
249
+ // ── Callback HTML ─────────────────────────────────────────────────────────────
250
+ function callbackHtml(title, message, success) {
251
+ const color = success ? "#b8ff57" : "#ff5555";
252
+ return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
253
+ <title>${title} — Patchwork OS</title>
254
+ <style>
255
+ body { background: #040406; color: #e0e0e0; font-family: system-ui, sans-serif;
256
+ display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
257
+ .card { background: #0d0d14; border: 1px solid #1e1e2e; border-radius: 8px;
258
+ padding: 40px 48px; max-width: 420px; text-align: center; }
259
+ h1 { color: ${color}; font-size: 1.4rem; margin-bottom: 12px; }
260
+ p { color: #888; line-height: 1.6; }
261
+ a { color: ${color}; text-decoration: none; font-size: 0.9rem; }
262
+ </style>
263
+ </head>
264
+ <body><div class="card">
265
+ <h1>${title}</h1>
266
+ <p>${message}</p>
267
+ <br><a href="javascript:window.close()">Close this tab</a>
268
+ </div>
269
+ <script>
270
+ // Notify the opener tab that auth completed so it can poll.
271
+ if (window.opener) { try { window.opener.postMessage('patchwork:gmail:connected', 'http://localhost:3100'); } catch(_) {} }
272
+ </script>
273
+ </body></html>`;
274
+ }
275
+ //# sourceMappingURL=gmail.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"gmail.js","sourceRoot":"","sources":["../../src/connectors/gmail.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -1,4 +1,24 @@
1
1
  #!/usr/bin/env node
2
+ // Load .env from repo root if present (connector credentials, etc.).
3
+ // Uses Node 20.6+ native dotenv loader; falls back to manual parse for older Node.
4
+ {
5
+ const { fileURLToPath: _fileURLToPath } = await import("node:url");
6
+ const envPath = _fileURLToPath(new URL("../../.env", import.meta.url));
7
+ try {
8
+ const { readFileSync, existsSync } = await import("node:fs");
9
+ if (existsSync(envPath)) {
10
+ for (const line of readFileSync(envPath, "utf-8").split("\n")) {
11
+ const m = /^([A-Z_][A-Z0-9_]*)=(.*)$/.exec(line.trim());
12
+ if (m?.[1] && !process.env[m[1]]) {
13
+ process.env[m[1]] = m[2]?.replace(/^["']|["']$/g, "");
14
+ }
15
+ }
16
+ }
17
+ }
18
+ catch {
19
+ /* non-fatal */
20
+ }
21
+ }
2
22
  // Enable V8 compile cache for faster cold-start on repeated restarts (Node 22.8+).
3
23
  import nodeModule from "node:module";
4
24
  if (typeof nodeModule.enableCompileCache === "function") {
@@ -547,39 +567,99 @@ export function register(ctx) {
547
567
  process.stderr.write(` 3. Or add to your config: { "plugins": ["${outDir}"] }\n`);
548
568
  process.exit(0);
549
569
  }
550
- // Patchwork: `patchwork recipe run <name>` POSTs to a running bridge's
551
- // /recipes/run endpoint to enqueue the recipe via the Claude orchestrator.
570
+ // Patchwork: `patchwork recipe list`enumerate installed recipes.
571
+ if (process.argv[2] === "recipe" && process.argv[3] === "list") {
572
+ (async () => {
573
+ const { listYamlRecipes } = await import("./recipes/yamlRunner.js");
574
+ const recipesDir = path.join(os.homedir(), ".patchwork", "recipes");
575
+ const recipes = listYamlRecipes(recipesDir);
576
+ if (recipes.length === 0) {
577
+ process.stdout.write("No recipes installed. Run `patchwork-os patchwork-init` to install the starter set.\n");
578
+ }
579
+ else {
580
+ process.stdout.write(`Installed recipes (${recipes.length}):\n\n`);
581
+ for (const r of recipes) {
582
+ const desc = r.description ? ` ${r.description}` : "";
583
+ process.stdout.write(` ${r.name.padEnd(28)} [${r.trigger}]${desc}\n`);
584
+ }
585
+ process.stdout.write(`\nRun a recipe: patchwork-os recipe run <name>\n`);
586
+ }
587
+ process.exit(0);
588
+ })();
589
+ }
590
+ // Patchwork: `patchwork recipe run <name>` — runs a recipe locally or via
591
+ // a running bridge's /recipes/run endpoint if one is available.
552
592
  if (process.argv[2] === "recipe" && process.argv[3] === "run") {
553
- const name = process.argv[4];
593
+ const args = process.argv.slice(4);
594
+ const localFlag = args.includes("--local");
595
+ const name = args.find((a) => !a.startsWith("--"));
554
596
  if (!name) {
555
- process.stderr.write("Usage: patchwork recipe run <name>\n");
597
+ process.stderr.write("Usage: patchwork recipe run <name> [--local]\n");
556
598
  process.exit(1);
557
599
  }
558
600
  (async () => {
559
601
  try {
602
+ // Try bridge first (requires --claude-driver subprocess).
560
603
  const { findBridgeLock } = await import("./bridgeLockDiscovery.js");
561
- const lock = findBridgeLock();
562
- if (!lock) {
563
- process.stderr.write("Error: no running bridge found under ~/.claude/ide/. Start the bridge with --claude-driver subprocess first.\n");
564
- process.exit(1);
565
- return;
604
+ const lock = localFlag ? null : findBridgeLock();
605
+ if (lock) {
606
+ const res = await fetch(`http://127.0.0.1:${lock.port}/recipes/run`, {
607
+ method: "POST",
608
+ headers: {
609
+ Authorization: `Bearer ${lock.authToken}`,
610
+ "Content-Type": "application/json",
611
+ },
612
+ body: JSON.stringify({ name }),
613
+ });
614
+ const body = (await res.json());
615
+ if (!body.ok) {
616
+ // Fall through to local YAML runner if bridge doesn't know the recipe.
617
+ if (!(body.error ?? "").includes("not found")) {
618
+ process.stderr.write(`Error: ${body.error ?? "unknown"}\n`);
619
+ process.exit(1);
620
+ return;
621
+ }
622
+ // else: fall through to local runner below
623
+ }
624
+ else {
625
+ process.stdout.write(` ✓ enqueued recipe "${name}" as task ${(body.taskId ?? "").slice(0, 8)}\n` +
626
+ " Watch progress on the dashboard Tasks page or via listClaudeTasks.\n");
627
+ process.exit(0);
628
+ return;
629
+ }
566
630
  }
567
- const res = await fetch(`http://127.0.0.1:${lock.port}/recipes/run`, {
568
- method: "POST",
569
- headers: {
570
- Authorization: `Bearer ${lock.authToken}`,
571
- "Content-Type": "application/json",
572
- },
573
- body: JSON.stringify({ name }),
574
- });
575
- const body = (await res.json());
576
- if (!body.ok) {
577
- process.stderr.write(`Error: ${body.error ?? "unknown"}\n`);
631
+ // No bridge run locally using the YAML runner.
632
+ const { loadYamlRecipe, runYamlRecipe } = await import("./recipes/yamlRunner.js");
633
+ const recipesDir = path.join(os.homedir(), ".patchwork", "recipes");
634
+ const bundledDir = fileURLToPath(new URL("../templates/recipes", import.meta.url));
635
+ const candidates = [
636
+ path.join(recipesDir, `${name}.yaml`),
637
+ path.join(recipesDir, `${name}.yml`),
638
+ path.join(recipesDir, `${name}.json`),
639
+ path.join(bundledDir, `${name}.yaml`),
640
+ path.join(bundledDir, `${name}.yml`),
641
+ ];
642
+ let recipePath;
643
+ for (const c of candidates) {
644
+ if (existsSync(c)) {
645
+ recipePath = c;
646
+ break;
647
+ }
648
+ }
649
+ if (!recipePath) {
650
+ process.stderr.write(`Error: recipe "${name}" not found in ${recipesDir}\n` +
651
+ " Run `patchwork-os recipe list` to see available recipes.\n");
578
652
  process.exit(1);
579
653
  return;
580
654
  }
581
- process.stdout.write(` enqueued recipe "${name}" as task ${(body.taskId ?? "").slice(0, 8)}\n` +
582
- " Watch progress on the dashboard Tasks page or via listClaudeTasks.\n");
655
+ process.stdout.write(` Running recipe "${name}" locally…\n`);
656
+ const recipe = loadYamlRecipe(recipePath);
657
+ const workdir = lock?.workspace || process.cwd();
658
+ const result = await runYamlRecipe(recipe, { workdir });
659
+ process.stdout.write(` ✓ ${result.stepsRun} step(s) completed\n`);
660
+ if (result.outputs.length > 0) {
661
+ process.stdout.write(` Output written to:\n${result.outputs.map((o) => ` ${o}`).join("\n")}\n`);
662
+ }
583
663
  process.exit(0);
584
664
  }
585
665
  catch (err) {
@@ -1304,6 +1384,19 @@ Options:
1304
1384
  process.exit(0);
1305
1385
  }
1306
1386
  // F6: "Did you mean?" for unknown CLI subcommands
1387
+ // Patchwork: no-args → terminal dashboard (when invoked as patchwork-os or patchwork).
1388
+ {
1389
+ const binName = path.basename(process.argv[1] ?? "");
1390
+ const isPatchworkBin = binName === "patchwork-os" ||
1391
+ binName === "patchwork" ||
1392
+ binName === "patchwork.js";
1393
+ if (isPatchworkBin && !process.argv[2]) {
1394
+ (async () => {
1395
+ const { runDashboard } = await import("./commands/dashboard.js");
1396
+ await runDashboard();
1397
+ })();
1398
+ }
1399
+ }
1307
1400
  {
1308
1401
  const KNOWN_COMMANDS = [
1309
1402
  "init",
@@ -1319,6 +1412,7 @@ Options:
1319
1412
  "status",
1320
1413
  "shim",
1321
1414
  "recipe",
1415
+ "dashboard",
1322
1416
  ];
1323
1417
  const unknownSub = process.argv[2];
1324
1418
  if (unknownSub &&