sticky-note-cli 3.1.9 → 3.1.11

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/bin/cli.js CHANGED
@@ -2983,6 +2983,23 @@ function cmdGc() {
2983
2983
  // DEPLOY-BACKEND command (V3)
2984
2984
  // ──────────────────────────────────────────────
2985
2985
 
2986
+ // Derive a safe Worker name from the git remote or cwd.
2987
+ // e.g. "BandaruDheeraj/sticky-note" → "sticky-dheeraj-sticky-note"
2988
+ function deriveWorkerName() {
2989
+ try {
2990
+ const remote = execSync("git remote get-url origin", { encoding: "utf-8", stdio: "pipe" }).trim();
2991
+ const match = remote.match(/[:/]([^/]+)\/([^/]+?)(?:\.git)?$/);
2992
+ if (match) {
2993
+ const [, owner, repo] = match;
2994
+ const slug = `${owner}-${repo}`.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").slice(0, 50);
2995
+ return `sticky-${slug}`;
2996
+ }
2997
+ } catch (_) {}
2998
+ // Fallback to cwd folder name
2999
+ const folder = path.basename(process.cwd()).toLowerCase().replace(/[^a-z0-9-]/g, "-").slice(0, 40);
3000
+ return `sticky-${folder}`;
3001
+ }
3002
+
2986
3003
  // Shared provisioning logic used by both cmdInit and cmdDeployBackend.
2987
3004
  // Returns null if wrangler is missing.
2988
3005
  // Returns { workerUrl, apiKey, secretSet } on success.
@@ -3040,11 +3057,12 @@ function provisionCloudBackend() {
3040
3057
  print(" [OK] wrangler.toml updated with namespace ID");
3041
3058
  }
3042
3059
 
3043
- // Step 2: Deploy Worker
3044
- print(" Step 2: Deploying Worker...");
3060
+ // Step 2: Deploy Worker (name derived from git repo for unique URL)
3061
+ const workerName = deriveWorkerName();
3062
+ print(` Step 2: Deploying Worker as "${workerName}"...`);
3045
3063
  let deployResult;
3046
3064
  try {
3047
- deployResult = execSync("wrangler deploy", {
3065
+ deployResult = execSync(`wrangler deploy --name ${workerName}`, {
3048
3066
  cwd: serverDir,
3049
3067
  encoding: "utf-8",
3050
3068
  stdio: ["pipe", "pipe", "pipe"],
@@ -3067,7 +3085,7 @@ function provisionCloudBackend() {
3067
3085
  print(" Step 3: Setting API key on Worker...");
3068
3086
  let secretSet = false;
3069
3087
  try {
3070
- execSync("wrangler secret put STICKY_API_KEY", {
3088
+ execSync(`wrangler secret put STICKY_API_KEY --name ${workerName}`, {
3071
3089
  cwd: serverDir,
3072
3090
  input: apiKey,
3073
3091
  encoding: "utf-8",
@@ -3077,7 +3095,7 @@ function provisionCloudBackend() {
3077
3095
  secretSet = true;
3078
3096
  } catch (err) {
3079
3097
  print(" [WARN] Could not set STICKY_API_KEY automatically: " + (err.message || err));
3080
- if (workerUrl) print(` Set it manually: echo '${apiKey}' | wrangler secret put STICKY_API_KEY`);
3098
+ if (workerUrl) print(` Set it manually: echo '${apiKey}' | wrangler secret put STICKY_API_KEY --name ${workerName}`);
3081
3099
  }
3082
3100
 
3083
3101
  // Write .env.sticky
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sticky-note-cli",
3
- "version": "3.1.9",
3
+ "version": "3.1.11",
4
4
  "description": "Human-to-human handoff for AI coding assistants. Git-backed shared memory for Claude Code and Copilot CLI.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -25,7 +25,8 @@
25
25
  },
26
26
  "files": [
27
27
  "bin/",
28
- "templates/"
28
+ "templates/",
29
+ "sticky-server/"
29
30
  ],
30
31
  "engines": {
31
32
  "node": ">=16.0.0"
@@ -0,0 +1,31 @@
1
+ GNU AFFERO GENERAL PUBLIC LICENSE
2
+ Version 3, 19 November 2007
3
+
4
+ Copyright (C) 2026 Dheeraj Bandaru
5
+
6
+ This program is free software: you can redistribute it and/or modify
7
+ it under the terms of the GNU Affero General Public License as published
8
+ by the Free Software Foundation, either version 3 of the License, or
9
+ (at your option) any later version.
10
+
11
+ This program is distributed in the hope that it will be useful,
12
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ GNU Affero General Public License for more details.
15
+
16
+ You should have received a copy of the GNU Affero General Public License
17
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
18
+
19
+ Additional permission under GNU AGPL version 3 section 7:
20
+
21
+ If you modify this Program, or any covered work, by linking or combining
22
+ it with other code, such combination is not considered a "covered work"
23
+ under the AGPL, provided that the other code is licensed under a
24
+ permissive license (MIT, BSD, Apache 2.0, or similar).
25
+
26
+ ---
27
+
28
+ The client layer of Sticky Note (bin/, templates/, hooks) is licensed
29
+ under the MIT License. See the root LICENSE file.
30
+
31
+ Only the sticky-server/ directory is covered by this AGPL license.
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Cloudflare KV adapter for Sticky Note V3.
3
+ *
4
+ * KV key schema (all prefixed by project):
5
+ * {project}:thread:{uuid} → thread JSON
6
+ * {project}:thread-index → JSON array of thread UUIDs
7
+ * {project}:audit:{timestamp}:{uuid} → audit record JSON
8
+ * {project}:audit-index → JSON array of audit keys
9
+ * {project}:presence:{user} → presence record JSON
10
+ * {project}:presence-index → JSON array of usernames
11
+ * {project}:config → config JSON
12
+ */
13
+
14
+ // ── Threads ──────────────────────────────────────────────
15
+
16
+ async function getThreads(kv, project, filters = {}) {
17
+ const indexKey = `${project}:thread-index`;
18
+ const index = JSON.parse((await kv.get(indexKey)) || "[]");
19
+
20
+ if (index.length === 0) return [];
21
+
22
+ const threads = await Promise.all(
23
+ index.map(async (id) => {
24
+ const raw = await kv.get(`${project}:thread:${id}`);
25
+ return raw ? JSON.parse(raw) : null;
26
+ })
27
+ );
28
+
29
+ let result = threads.filter(Boolean);
30
+
31
+ if (filters.status) {
32
+ result = result.filter((t) => t.status === filters.status);
33
+ }
34
+ if (filters.q) {
35
+ const q = filters.q.toLowerCase();
36
+ result = result.filter(
37
+ (t) =>
38
+ (t.narrative || "").toLowerCase().includes(q) ||
39
+ (t.handoff_summary || "").toLowerCase().includes(q) ||
40
+ (t.last_note || "").toLowerCase().includes(q) ||
41
+ (t.files_touched || []).some((f) => f.toLowerCase().includes(q))
42
+ );
43
+ }
44
+
45
+ return result;
46
+ }
47
+
48
+ async function getThread(kv, project, id) {
49
+ const raw = await kv.get(`${project}:thread:${id}`);
50
+ return raw ? JSON.parse(raw) : null;
51
+ }
52
+
53
+ async function putThread(kv, project, thread) {
54
+ const id = thread.id;
55
+ await kv.put(`${project}:thread:${id}`, JSON.stringify(thread));
56
+
57
+ // Update index
58
+ const indexKey = `${project}:thread-index`;
59
+ const index = JSON.parse((await kv.get(indexKey)) || "[]");
60
+ if (!index.includes(id)) {
61
+ index.push(id);
62
+ await kv.put(indexKey, JSON.stringify(index));
63
+ }
64
+ }
65
+
66
+ async function deleteThread(kv, project, id) {
67
+ // Tombstone: keep minimal record
68
+ const existing = await getThread(kv, project, id);
69
+ if (existing) {
70
+ const tombstone = {
71
+ id: existing.id,
72
+ user: existing.user,
73
+ status: "expired",
74
+ closed_at: existing.closed_at || new Date().toISOString(),
75
+ created_at: existing.created_at,
76
+ };
77
+ await kv.put(`${project}:thread:${id}`, JSON.stringify(tombstone));
78
+ }
79
+ }
80
+
81
+ // ── Audit ────────────────────────────────────────────────
82
+
83
+ async function appendAudit(kv, project, record) {
84
+ const ts = record.timestamp || new Date().toISOString();
85
+ const uid = crypto.randomUUID();
86
+ const key = `${project}:audit:${ts}:${uid}`;
87
+ await kv.put(key, JSON.stringify(record));
88
+
89
+ const indexKey = `${project}:audit-index`;
90
+ const index = JSON.parse((await kv.get(indexKey)) || "[]");
91
+ index.push(key);
92
+
93
+ // Cap at 10,000 entries; trim oldest on overflow
94
+ if (index.length > 10000) {
95
+ const removed = index.splice(0, index.length - 10000);
96
+ await Promise.all(removed.map((k) => kv.delete(k)));
97
+ }
98
+ await kv.put(indexKey, JSON.stringify(index));
99
+ }
100
+
101
+ async function queryAudit(kv, project, filters = {}) {
102
+ const indexKey = `${project}:audit-index`;
103
+ const index = JSON.parse((await kv.get(indexKey)) || "[]");
104
+
105
+ if (index.length === 0) return [];
106
+
107
+ const records = await Promise.all(
108
+ index.map(async (key) => {
109
+ const raw = await kv.get(key);
110
+ return raw ? JSON.parse(raw) : null;
111
+ })
112
+ );
113
+
114
+ let result = records.filter(Boolean);
115
+
116
+ if (filters.user) {
117
+ result = result.filter((r) => r.user === filters.user);
118
+ }
119
+ if (filters.file) {
120
+ result = result.filter((r) => (r.file || "").includes(filters.file));
121
+ }
122
+ if (filters.tool) {
123
+ result = result.filter((r) => r.tool === filters.tool);
124
+ }
125
+ if (filters.since) {
126
+ const since = new Date(filters.since).getTime();
127
+ result = result.filter(
128
+ (r) => new Date(r.timestamp || 0).getTime() >= since
129
+ );
130
+ }
131
+
132
+ return result;
133
+ }
134
+
135
+ // ── Presence ─────────────────────────────────────────────
136
+
137
+ const PRESENCE_TTL_SECONDS = 15 * 60; // 15 minutes
138
+
139
+ async function getPresence(kv, project) {
140
+ const indexKey = `${project}:presence-index`;
141
+ const index = JSON.parse((await kv.get(indexKey)) || "[]");
142
+
143
+ if (index.length === 0) return [];
144
+
145
+ const records = await Promise.all(
146
+ index.map(async (user) => {
147
+ const raw = await kv.get(`${project}:presence:${user}`);
148
+ return raw ? JSON.parse(raw) : null;
149
+ })
150
+ );
151
+
152
+ return records.filter(Boolean);
153
+ }
154
+
155
+ async function upsertPresence(kv, project, record) {
156
+ const user = record.user;
157
+ await kv.put(`${project}:presence:${user}`, JSON.stringify(record), {
158
+ expirationTtl: PRESENCE_TTL_SECONDS,
159
+ });
160
+
161
+ const indexKey = `${project}:presence-index`;
162
+ const index = JSON.parse((await kv.get(indexKey)) || "[]");
163
+ if (!index.includes(user)) {
164
+ index.push(user);
165
+ await kv.put(indexKey, JSON.stringify(index));
166
+ }
167
+ }
168
+
169
+ async function deletePresence(kv, project, user) {
170
+ await kv.delete(`${project}:presence:${user}`);
171
+
172
+ const indexKey = `${project}:presence-index`;
173
+ const index = JSON.parse((await kv.get(indexKey)) || "[]");
174
+ const filtered = index.filter((u) => u !== user);
175
+ await kv.put(indexKey, JSON.stringify(filtered));
176
+ }
177
+
178
+ // ── Config ───────────────────────────────────────────────
179
+
180
+ async function getConfig(kv, project) {
181
+ const raw = await kv.get(`${project}:config`);
182
+ return raw ? JSON.parse(raw) : null;
183
+ }
184
+
185
+ async function putConfig(kv, project, config) {
186
+ await kv.put(`${project}:config`, JSON.stringify(config));
187
+ }
188
+
189
+ // ── Export ────────────────────────────────────────────────
190
+
191
+ export {
192
+ getThreads,
193
+ getThread,
194
+ putThread,
195
+ deleteThread,
196
+ appendAudit,
197
+ queryAudit,
198
+ getPresence,
199
+ upsertPresence,
200
+ deletePresence,
201
+ getConfig,
202
+ putConfig,
203
+ };
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Sticky Note V3 — Cloudflare Worker
3
+ *
4
+ * REST API backed by Cloudflare KV.
5
+ * Auth: X-Sticky-API-Key header matched against STICKY_API_KEY env var.
6
+ * Project routing: X-Sticky-Project header (auto-set by client hooks).
7
+ *
8
+ * Endpoints:
9
+ * POST /threads — create thread
10
+ * GET /threads — list threads (?status=, ?q=)
11
+ * GET /threads/:id — get thread
12
+ * PUT /threads/:id — update thread
13
+ * DELETE /threads/:id — tombstone thread
14
+ * GET /audit — query audit (?file=, ?user=, ?since=, ?tool=)
15
+ * POST /audit — append audit record
16
+ * GET /presence — list active developers
17
+ * POST /presence — heartbeat upsert
18
+ * DELETE /presence/:user — clear presence
19
+ * GET /config — get team config
20
+ * PUT /config — update team config
21
+ */
22
+
23
+ import * as adapter from "./adapters/cf-kv.js";
24
+
25
+ // ── Helpers ──────────────────────────────────────────────
26
+
27
+ function json(data, status = 200) {
28
+ return new Response(JSON.stringify(data), {
29
+ status,
30
+ headers: { "Content-Type": "application/json" },
31
+ });
32
+ }
33
+
34
+ function error(message, status = 400) {
35
+ return json({ error: message }, status);
36
+ }
37
+
38
+ function authenticate(request, env) {
39
+ const key = request.headers.get("X-Sticky-API-Key");
40
+ if (!env.STICKY_API_KEY) return true; // no key configured = open
41
+ return key === env.STICKY_API_KEY;
42
+ }
43
+
44
+ function getProject(request) {
45
+ return request.headers.get("X-Sticky-Project") || "default";
46
+ }
47
+
48
+ // ── Router ───────────────────────────────────────────────
49
+
50
+ function matchRoute(method, pathname) {
51
+ // Thread routes
52
+ if (method === "POST" && pathname === "/threads") return { handler: "createThread" };
53
+ if (method === "GET" && pathname === "/threads") return { handler: "listThreads" };
54
+ if (method === "GET" && pathname.startsWith("/threads/")) {
55
+ return { handler: "getThread", id: pathname.slice("/threads/".length) };
56
+ }
57
+ if (method === "PUT" && pathname.startsWith("/threads/")) {
58
+ return { handler: "updateThread", id: pathname.slice("/threads/".length) };
59
+ }
60
+ if (method === "DELETE" && pathname.startsWith("/threads/")) {
61
+ return { handler: "deleteThread", id: pathname.slice("/threads/".length) };
62
+ }
63
+
64
+ // Audit routes
65
+ if (method === "GET" && pathname === "/audit") return { handler: "queryAudit" };
66
+ if (method === "POST" && pathname === "/audit") return { handler: "appendAudit" };
67
+
68
+ // Presence routes
69
+ if (method === "GET" && pathname === "/presence") return { handler: "getPresence" };
70
+ if (method === "POST" && pathname === "/presence") return { handler: "upsertPresence" };
71
+ if (method === "DELETE" && pathname.startsWith("/presence/")) {
72
+ return { handler: "deletePresence", user: decodeURIComponent(pathname.slice("/presence/".length)) };
73
+ }
74
+
75
+ // Config routes
76
+ if (method === "GET" && pathname === "/config") return { handler: "getConfig" };
77
+ if (method === "PUT" && pathname === "/config") return { handler: "putConfig" };
78
+
79
+ // Health
80
+ if (method === "GET" && pathname === "/health") return { handler: "health" };
81
+
82
+ return null;
83
+ }
84
+
85
+ // ── Handlers ─────────────────────────────────────────────
86
+
87
+ const handlers = {
88
+ async health() {
89
+ return json({ status: "ok", version: "3.0.0" });
90
+ },
91
+
92
+ // ── Threads ──
93
+
94
+ async createThread(request, kv, project) {
95
+ const thread = await request.json();
96
+ if (!thread.id) return error("thread.id is required");
97
+ await adapter.putThread(kv, project, thread);
98
+ return json(thread, 201);
99
+ },
100
+
101
+ async listThreads(request, kv, project) {
102
+ const url = new URL(request.url);
103
+ const filters = {
104
+ status: url.searchParams.get("status") || undefined,
105
+ q: url.searchParams.get("q") || undefined,
106
+ };
107
+ const threads = await adapter.getThreads(kv, project, filters);
108
+ return json({ threads });
109
+ },
110
+
111
+ async getThread(_request, kv, project, { id }) {
112
+ const thread = await adapter.getThread(kv, project, id);
113
+ if (!thread) return error("Thread not found", 404);
114
+ return json(thread);
115
+ },
116
+
117
+ async updateThread(request, kv, project, { id }) {
118
+ const thread = await request.json();
119
+ thread.id = id;
120
+ await adapter.putThread(kv, project, thread);
121
+ return json(thread);
122
+ },
123
+
124
+ async deleteThread(_request, kv, project, { id }) {
125
+ await adapter.deleteThread(kv, project, id);
126
+ return json({ deleted: id });
127
+ },
128
+
129
+ // ── Audit ──
130
+
131
+ async queryAudit(request, kv, project) {
132
+ const url = new URL(request.url);
133
+ const filters = {
134
+ user: url.searchParams.get("user") || undefined,
135
+ file: url.searchParams.get("file") || undefined,
136
+ tool: url.searchParams.get("tool") || undefined,
137
+ since: url.searchParams.get("since") || undefined,
138
+ };
139
+ const records = await adapter.queryAudit(kv, project, filters);
140
+ return json({ audit: records });
141
+ },
142
+
143
+ async appendAudit(request, kv, project) {
144
+ const record = await request.json();
145
+ await adapter.appendAudit(kv, project, record);
146
+ return json({ ok: true }, 201);
147
+ },
148
+
149
+ // ── Presence ──
150
+
151
+ async getPresence(_request, kv, project) {
152
+ const records = await adapter.getPresence(kv, project);
153
+ return json({ presence: records });
154
+ },
155
+
156
+ async upsertPresence(request, kv, project) {
157
+ const record = await request.json();
158
+ if (!record.user) return error("record.user is required");
159
+ await adapter.upsertPresence(kv, project, record);
160
+ return json({ ok: true });
161
+ },
162
+
163
+ async deletePresence(_request, kv, project, { user }) {
164
+ await adapter.deletePresence(kv, project, user);
165
+ return json({ deleted: user });
166
+ },
167
+
168
+ // ── Config ──
169
+
170
+ async getConfig(_request, kv, project) {
171
+ const config = await adapter.getConfig(kv, project);
172
+ if (!config) return json({});
173
+ return json(config);
174
+ },
175
+
176
+ async putConfig(request, kv, project) {
177
+ const config = await request.json();
178
+ await adapter.putConfig(kv, project, config);
179
+ return json(config);
180
+ },
181
+ };
182
+
183
+ // ── Worker entry point ───────────────────────────────────
184
+
185
+ export default {
186
+ async fetch(request, env) {
187
+ // CORS preflight
188
+ if (request.method === "OPTIONS") {
189
+ return new Response(null, {
190
+ status: 204,
191
+ headers: {
192
+ "Access-Control-Allow-Origin": "*",
193
+ "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
194
+ "Access-Control-Allow-Headers": "Content-Type, X-Sticky-API-Key, X-Sticky-Project",
195
+ },
196
+ });
197
+ }
198
+
199
+ // Auth
200
+ if (!authenticate(request, env)) {
201
+ return error("Unauthorized", 401);
202
+ }
203
+
204
+ const url = new URL(request.url);
205
+ const route = matchRoute(request.method, url.pathname);
206
+
207
+ if (!route) {
208
+ return error("Not found", 404);
209
+ }
210
+
211
+ const project = getProject(request);
212
+ const kv = env.STICKY_KV;
213
+
214
+ try {
215
+ const handler = handlers[route.handler];
216
+ const response = await handler(request, kv, project, route);
217
+
218
+ // Add CORS headers to all responses
219
+ const headers = new Headers(response.headers);
220
+ headers.set("Access-Control-Allow-Origin", "*");
221
+ return new Response(response.body, {
222
+ status: response.status,
223
+ headers,
224
+ });
225
+ } catch (err) {
226
+ return error(`Internal error: ${err.message}`, 500);
227
+ }
228
+ },
229
+ };
@@ -0,0 +1,10 @@
1
+ name = "sticky-note-server"
2
+ main = "worker.js"
3
+ compatibility_date = "2024-09-01"
4
+
5
+ [vars]
6
+ ENVIRONMENT = "production"
7
+
8
+ [[kv_namespaces]]
9
+ binding = "STICKY_KV"
10
+ id = "PLACEHOLDER_KV_NAMESPACE_ID"