hotsheet 0.2.2 → 0.2.3
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 +51 -4
- package/dist/cli.js +781 -142
- package/dist/client/app.global.js +3 -3
- package/dist/client/styles.css +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -9,88 +9,32 @@ var __export = (target, all) => {
|
|
|
9
9
|
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
10
|
};
|
|
11
11
|
|
|
12
|
-
// src/gitignore.ts
|
|
13
|
-
var gitignore_exports = {};
|
|
14
|
-
__export(gitignore_exports, {
|
|
15
|
-
addHotsheetToGitignore: () => addHotsheetToGitignore,
|
|
16
|
-
ensureGitignore: () => ensureGitignore,
|
|
17
|
-
getGitRoot: () => getGitRoot,
|
|
18
|
-
isGitRepo: () => isGitRepo,
|
|
19
|
-
isHotsheetGitignored: () => isHotsheetGitignored
|
|
20
|
-
});
|
|
21
|
-
import { execSync } from "child_process";
|
|
22
|
-
import { appendFileSync, existsSync, readFileSync } from "fs";
|
|
23
|
-
import { join as join2 } from "path";
|
|
24
|
-
function isHotsheetGitignored(repoRoot) {
|
|
25
|
-
try {
|
|
26
|
-
execSync("git check-ignore -q .hotsheet", { cwd: repoRoot, stdio: "ignore" });
|
|
27
|
-
return true;
|
|
28
|
-
} catch {
|
|
29
|
-
return false;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
function isGitRepo(dir) {
|
|
33
|
-
try {
|
|
34
|
-
execSync("git rev-parse --is-inside-work-tree", { cwd: dir, stdio: "ignore" });
|
|
35
|
-
return true;
|
|
36
|
-
} catch {
|
|
37
|
-
return false;
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
function getGitRoot(dir) {
|
|
41
|
-
try {
|
|
42
|
-
return execSync("git rev-parse --show-toplevel", { cwd: dir, encoding: "utf-8" }).trim();
|
|
43
|
-
} catch {
|
|
44
|
-
return null;
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
function addHotsheetToGitignore(repoRoot) {
|
|
48
|
-
const gitignorePath = join2(repoRoot, ".gitignore");
|
|
49
|
-
if (existsSync(gitignorePath)) {
|
|
50
|
-
const content = readFileSync(gitignorePath, "utf-8");
|
|
51
|
-
if (content.includes(".hotsheet")) return;
|
|
52
|
-
const prefix = content.endsWith("\n") ? "" : "\n";
|
|
53
|
-
appendFileSync(gitignorePath, `${prefix}.hotsheet/
|
|
54
|
-
`);
|
|
55
|
-
} else {
|
|
56
|
-
appendFileSync(gitignorePath, ".hotsheet/\n");
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
function ensureGitignore(cwd) {
|
|
60
|
-
if (!isGitRepo(cwd)) return;
|
|
61
|
-
const gitRoot = getGitRoot(cwd);
|
|
62
|
-
if (gitRoot === null) return;
|
|
63
|
-
if (!isHotsheetGitignored(gitRoot)) {
|
|
64
|
-
addHotsheetToGitignore(gitRoot);
|
|
65
|
-
console.log(" Added .hotsheet/ to .gitignore");
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
var init_gitignore = __esm({
|
|
69
|
-
"src/gitignore.ts"() {
|
|
70
|
-
"use strict";
|
|
71
|
-
}
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
// src/cli.ts
|
|
75
|
-
import { mkdirSync as mkdirSync4 } from "fs";
|
|
76
|
-
import { tmpdir } from "os";
|
|
77
|
-
import { join as join7, resolve } from "path";
|
|
78
|
-
|
|
79
|
-
// src/cleanup.ts
|
|
80
|
-
import { rmSync as rmSync2 } from "fs";
|
|
81
|
-
|
|
82
12
|
// src/db/connection.ts
|
|
13
|
+
var connection_exports = {};
|
|
14
|
+
__export(connection_exports, {
|
|
15
|
+
adoptDb: () => adoptDb,
|
|
16
|
+
closeDb: () => closeDb,
|
|
17
|
+
getDb: () => getDb,
|
|
18
|
+
setDataDir: () => setDataDir
|
|
19
|
+
});
|
|
83
20
|
import { PGlite } from "@electric-sql/pglite";
|
|
84
21
|
import { mkdirSync, rmSync } from "fs";
|
|
85
22
|
import { join } from "path";
|
|
86
|
-
var db = null;
|
|
87
|
-
var currentDbPath = null;
|
|
88
23
|
function setDataDir(dataDir2) {
|
|
89
24
|
const dbDir = join(dataDir2, "db");
|
|
90
25
|
mkdirSync(dbDir, { recursive: true });
|
|
91
26
|
mkdirSync(join(dataDir2, "attachments"), { recursive: true });
|
|
92
27
|
currentDbPath = dbDir;
|
|
93
28
|
}
|
|
29
|
+
async function closeDb() {
|
|
30
|
+
if (db) {
|
|
31
|
+
await db.close();
|
|
32
|
+
db = null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function adoptDb(instance) {
|
|
36
|
+
db = instance;
|
|
37
|
+
}
|
|
94
38
|
async function getDb() {
|
|
95
39
|
if (db !== null) return db;
|
|
96
40
|
if (currentDbPath === null) throw new Error("Data directory not set. Call setDataDir() first.");
|
|
@@ -166,8 +110,318 @@ async function initSchema(db2) {
|
|
|
166
110
|
`).catch(() => {
|
|
167
111
|
});
|
|
168
112
|
}
|
|
113
|
+
var db, currentDbPath;
|
|
114
|
+
var init_connection = __esm({
|
|
115
|
+
"src/db/connection.ts"() {
|
|
116
|
+
"use strict";
|
|
117
|
+
db = null;
|
|
118
|
+
currentDbPath = null;
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// src/file-settings.ts
|
|
123
|
+
var file_settings_exports = {};
|
|
124
|
+
__export(file_settings_exports, {
|
|
125
|
+
getBackupDir: () => getBackupDir,
|
|
126
|
+
readFileSettings: () => readFileSettings,
|
|
127
|
+
writeFileSettings: () => writeFileSettings
|
|
128
|
+
});
|
|
129
|
+
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
130
|
+
import { join as join2 } from "path";
|
|
131
|
+
function settingsPath(dataDir2) {
|
|
132
|
+
return join2(dataDir2, "settings.json");
|
|
133
|
+
}
|
|
134
|
+
function readFileSettings(dataDir2) {
|
|
135
|
+
const path = settingsPath(dataDir2);
|
|
136
|
+
if (!existsSync(path)) return {};
|
|
137
|
+
try {
|
|
138
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
139
|
+
} catch {
|
|
140
|
+
return {};
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function writeFileSettings(dataDir2, updates) {
|
|
144
|
+
const current = readFileSettings(dataDir2);
|
|
145
|
+
const merged = { ...current, ...updates };
|
|
146
|
+
writeFileSync(settingsPath(dataDir2), JSON.stringify(merged, null, 2) + "\n", "utf-8");
|
|
147
|
+
return merged;
|
|
148
|
+
}
|
|
149
|
+
function getBackupDir(dataDir2) {
|
|
150
|
+
const settings = readFileSettings(dataDir2);
|
|
151
|
+
return settings.backupDir || join2(dataDir2, "backups");
|
|
152
|
+
}
|
|
153
|
+
var init_file_settings = __esm({
|
|
154
|
+
"src/file-settings.ts"() {
|
|
155
|
+
"use strict";
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// src/gitignore.ts
|
|
160
|
+
var gitignore_exports = {};
|
|
161
|
+
__export(gitignore_exports, {
|
|
162
|
+
addHotsheetToGitignore: () => addHotsheetToGitignore,
|
|
163
|
+
ensureGitignore: () => ensureGitignore,
|
|
164
|
+
getGitRoot: () => getGitRoot,
|
|
165
|
+
isGitRepo: () => isGitRepo,
|
|
166
|
+
isHotsheetGitignored: () => isHotsheetGitignored
|
|
167
|
+
});
|
|
168
|
+
import { execSync } from "child_process";
|
|
169
|
+
import { appendFileSync, existsSync as existsSync4, readFileSync as readFileSync4 } from "fs";
|
|
170
|
+
import { join as join5 } from "path";
|
|
171
|
+
function isHotsheetGitignored(repoRoot) {
|
|
172
|
+
try {
|
|
173
|
+
execSync("git check-ignore -q .hotsheet", { cwd: repoRoot, stdio: "ignore" });
|
|
174
|
+
return true;
|
|
175
|
+
} catch {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function isGitRepo(dir) {
|
|
180
|
+
try {
|
|
181
|
+
execSync("git rev-parse --is-inside-work-tree", { cwd: dir, stdio: "ignore" });
|
|
182
|
+
return true;
|
|
183
|
+
} catch {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
function getGitRoot(dir) {
|
|
188
|
+
try {
|
|
189
|
+
return execSync("git rev-parse --show-toplevel", { cwd: dir, encoding: "utf-8" }).trim();
|
|
190
|
+
} catch {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function addHotsheetToGitignore(repoRoot) {
|
|
195
|
+
const gitignorePath = join5(repoRoot, ".gitignore");
|
|
196
|
+
if (existsSync4(gitignorePath)) {
|
|
197
|
+
const content = readFileSync4(gitignorePath, "utf-8");
|
|
198
|
+
if (content.includes(".hotsheet")) return;
|
|
199
|
+
const prefix = content.endsWith("\n") ? "" : "\n";
|
|
200
|
+
appendFileSync(gitignorePath, `${prefix}.hotsheet/
|
|
201
|
+
`);
|
|
202
|
+
} else {
|
|
203
|
+
appendFileSync(gitignorePath, ".hotsheet/\n");
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
function ensureGitignore(cwd) {
|
|
207
|
+
if (!isGitRepo(cwd)) return;
|
|
208
|
+
const gitRoot = getGitRoot(cwd);
|
|
209
|
+
if (gitRoot === null) return;
|
|
210
|
+
if (!isHotsheetGitignored(gitRoot)) {
|
|
211
|
+
addHotsheetToGitignore(gitRoot);
|
|
212
|
+
console.log(" Added .hotsheet/ to .gitignore");
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
var init_gitignore = __esm({
|
|
216
|
+
"src/gitignore.ts"() {
|
|
217
|
+
"use strict";
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// src/cli.ts
|
|
222
|
+
import { mkdirSync as mkdirSync6 } from "fs";
|
|
223
|
+
import { tmpdir } from "os";
|
|
224
|
+
import { join as join11, resolve } from "path";
|
|
225
|
+
|
|
226
|
+
// src/backup.ts
|
|
227
|
+
init_connection();
|
|
228
|
+
init_file_settings();
|
|
229
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync2, readdirSync, readFileSync as readFileSync2, rmSync as rmSync2, statSync, writeFileSync as writeFileSync2 } from "fs";
|
|
230
|
+
import { join as join3 } from "path";
|
|
231
|
+
import { PGlite as PGlite2 } from "@electric-sql/pglite";
|
|
232
|
+
var TIERS = {
|
|
233
|
+
"5min": { intervalMs: 5 * 60 * 1e3, maxAge: 60 * 60 * 1e3, maxCount: 12 },
|
|
234
|
+
"hourly": { intervalMs: 60 * 60 * 1e3, maxAge: 12 * 60 * 60 * 1e3, maxCount: 12 },
|
|
235
|
+
"daily": { intervalMs: 24 * 60 * 60 * 1e3, maxAge: 7 * 24 * 60 * 60 * 1e3, maxCount: 7 }
|
|
236
|
+
};
|
|
237
|
+
var backupInProgress = false;
|
|
238
|
+
var previewDb = null;
|
|
239
|
+
var currentDataDir = null;
|
|
240
|
+
function backupsDir(dataDir2) {
|
|
241
|
+
return getBackupDir(dataDir2);
|
|
242
|
+
}
|
|
243
|
+
function tierDir(dataDir2, tier) {
|
|
244
|
+
return join3(backupsDir(dataDir2), tier);
|
|
245
|
+
}
|
|
246
|
+
function formatTimestamp(date) {
|
|
247
|
+
return date.toISOString().replace(/:/g, "-").replace(/\.\d+Z$/, "Z");
|
|
248
|
+
}
|
|
249
|
+
function parseTimestamp(filename) {
|
|
250
|
+
const match = filename.match(/^backup-(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})Z\.tar\.gz$/);
|
|
251
|
+
if (!match) return null;
|
|
252
|
+
const iso = `${match[1]}T${match[2]}:${match[3]}:${match[4]}Z`;
|
|
253
|
+
const d = new Date(iso);
|
|
254
|
+
return isNaN(d.getTime()) ? null : d;
|
|
255
|
+
}
|
|
256
|
+
async function createBackup(dataDir2, tier) {
|
|
257
|
+
if (backupInProgress) return null;
|
|
258
|
+
backupInProgress = true;
|
|
259
|
+
try {
|
|
260
|
+
const db2 = await getDb();
|
|
261
|
+
const dir = tierDir(dataDir2, tier);
|
|
262
|
+
mkdirSync2(dir, { recursive: true });
|
|
263
|
+
const blob = await db2.dumpDataDir("gzip");
|
|
264
|
+
const buffer = Buffer.from(await blob.arrayBuffer());
|
|
265
|
+
const now = /* @__PURE__ */ new Date();
|
|
266
|
+
const filename = `backup-${formatTimestamp(now)}.tar.gz`;
|
|
267
|
+
const filePath = join3(dir, filename);
|
|
268
|
+
writeFileSync2(filePath, buffer);
|
|
269
|
+
let ticketCount = 0;
|
|
270
|
+
try {
|
|
271
|
+
const result = await db2.query(`SELECT COUNT(*) as count FROM tickets WHERE status != 'deleted'`);
|
|
272
|
+
ticketCount = parseInt(result.rows[0]?.count || "0", 10);
|
|
273
|
+
} catch {
|
|
274
|
+
}
|
|
275
|
+
const info = {
|
|
276
|
+
tier,
|
|
277
|
+
filename,
|
|
278
|
+
createdAt: now.toISOString(),
|
|
279
|
+
ticketCount,
|
|
280
|
+
sizeBytes: buffer.length
|
|
281
|
+
};
|
|
282
|
+
pruneBackups(dataDir2, tier);
|
|
283
|
+
return info;
|
|
284
|
+
} catch (err) {
|
|
285
|
+
console.error(`Backup failed (${tier}):`, err);
|
|
286
|
+
return null;
|
|
287
|
+
} finally {
|
|
288
|
+
backupInProgress = false;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
function pruneBackups(dataDir2, tier) {
|
|
292
|
+
const dir = tierDir(dataDir2, tier);
|
|
293
|
+
if (!existsSync2(dir)) return;
|
|
294
|
+
const config = TIERS[tier];
|
|
295
|
+
const cutoff = Date.now() - config.maxAge;
|
|
296
|
+
const files = readdirSync(dir).filter((f) => f.endsWith(".tar.gz")).map((f) => ({ filename: f, date: parseTimestamp(f) })).filter((f) => f.date !== null).sort((a, b) => b.date.getTime() - a.date.getTime());
|
|
297
|
+
for (let i = 0; i < files.length; i++) {
|
|
298
|
+
if (i >= config.maxCount || files[i].date.getTime() < cutoff) {
|
|
299
|
+
try {
|
|
300
|
+
rmSync2(join3(dir, files[i].filename), { force: true });
|
|
301
|
+
} catch {
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
function listBackups(dataDir2) {
|
|
307
|
+
const backups = [];
|
|
308
|
+
for (const tier of Object.keys(TIERS)) {
|
|
309
|
+
const dir = tierDir(dataDir2, tier);
|
|
310
|
+
if (!existsSync2(dir)) continue;
|
|
311
|
+
for (const filename of readdirSync(dir)) {
|
|
312
|
+
if (!filename.endsWith(".tar.gz")) continue;
|
|
313
|
+
const date = parseTimestamp(filename);
|
|
314
|
+
if (!date) continue;
|
|
315
|
+
let sizeBytes = 0;
|
|
316
|
+
try {
|
|
317
|
+
sizeBytes = statSync(join3(dir, filename)).size;
|
|
318
|
+
} catch {
|
|
319
|
+
}
|
|
320
|
+
backups.push({
|
|
321
|
+
tier,
|
|
322
|
+
filename,
|
|
323
|
+
createdAt: date.toISOString(),
|
|
324
|
+
ticketCount: -1,
|
|
325
|
+
// Unknown without opening the backup
|
|
326
|
+
sizeBytes
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return backups.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
|
331
|
+
}
|
|
332
|
+
async function loadBackupForPreview(dataDir2, tier, filename) {
|
|
333
|
+
await cleanupPreview();
|
|
334
|
+
const filePath = join3(tierDir(dataDir2, tier), filename);
|
|
335
|
+
if (!existsSync2(filePath)) throw new Error("Backup file not found");
|
|
336
|
+
const buffer = readFileSync2(filePath);
|
|
337
|
+
const blob = new Blob([buffer]);
|
|
338
|
+
const previewDir = join3(backupsDir(dataDir2), "_preview");
|
|
339
|
+
mkdirSync2(previewDir, { recursive: true });
|
|
340
|
+
previewDb = new PGlite2(previewDir, { loadDataDir: blob });
|
|
341
|
+
await previewDb.waitReady;
|
|
342
|
+
const tickets = await previewDb.query(
|
|
343
|
+
`SELECT * FROM tickets WHERE status != 'deleted' ORDER BY created_at DESC`
|
|
344
|
+
);
|
|
345
|
+
const statsResult = await previewDb.query(`
|
|
346
|
+
SELECT
|
|
347
|
+
COUNT(*) FILTER (WHERE status != 'deleted') as total,
|
|
348
|
+
COUNT(*) FILTER (WHERE status IN ('not_started', 'started')) as open,
|
|
349
|
+
COUNT(*) FILTER (WHERE up_next = true AND status != 'deleted') as up_next
|
|
350
|
+
FROM tickets
|
|
351
|
+
`);
|
|
352
|
+
const row = statsResult.rows[0];
|
|
353
|
+
return {
|
|
354
|
+
tickets: tickets.rows,
|
|
355
|
+
stats: {
|
|
356
|
+
total: parseInt(row?.total || "0", 10),
|
|
357
|
+
open: parseInt(row?.open || "0", 10),
|
|
358
|
+
upNext: parseInt(row?.up_next || "0", 10)
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
async function cleanupPreview() {
|
|
363
|
+
if (previewDb) {
|
|
364
|
+
try {
|
|
365
|
+
await previewDb.close();
|
|
366
|
+
} catch {
|
|
367
|
+
}
|
|
368
|
+
previewDb = null;
|
|
369
|
+
}
|
|
370
|
+
if (currentDataDir) {
|
|
371
|
+
const previewDir = join3(backupsDir(currentDataDir), "_preview");
|
|
372
|
+
if (existsSync2(previewDir)) {
|
|
373
|
+
rmSync2(previewDir, { recursive: true, force: true });
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
async function restoreBackup(dataDir2, tier, filename) {
|
|
378
|
+
await cleanupPreview();
|
|
379
|
+
const filePath = join3(tierDir(dataDir2, tier), filename);
|
|
380
|
+
if (!existsSync2(filePath)) throw new Error("Backup file not found");
|
|
381
|
+
await createBackup(dataDir2, "5min");
|
|
382
|
+
const buffer = readFileSync2(filePath);
|
|
383
|
+
const blob = new Blob([buffer]);
|
|
384
|
+
await closeDb();
|
|
385
|
+
const dbDir = join3(dataDir2, "db");
|
|
386
|
+
rmSync2(dbDir, { recursive: true, force: true });
|
|
387
|
+
setDataDir(dataDir2);
|
|
388
|
+
const { getDb: reinitDb } = await Promise.resolve().then(() => (init_connection(), connection_exports));
|
|
389
|
+
const PGliteClass = (await import("@electric-sql/pglite")).PGlite;
|
|
390
|
+
const newDb = new PGliteClass(dbDir, { loadDataDir: blob });
|
|
391
|
+
await newDb.waitReady;
|
|
392
|
+
const { adoptDb: adoptDb2 } = await Promise.resolve().then(() => (init_connection(), connection_exports));
|
|
393
|
+
adoptDb2(newDb);
|
|
394
|
+
}
|
|
395
|
+
var fiveMinTimer = null;
|
|
396
|
+
function scheduleFiveMinBackup(dataDir2) {
|
|
397
|
+
if (fiveMinTimer) clearTimeout(fiveMinTimer);
|
|
398
|
+
fiveMinTimer = setTimeout(() => {
|
|
399
|
+
void createBackup(dataDir2, "5min").then(() => scheduleFiveMinBackup(dataDir2));
|
|
400
|
+
}, TIERS["5min"].intervalMs);
|
|
401
|
+
}
|
|
402
|
+
async function triggerManualBackup(dataDir2) {
|
|
403
|
+
const result = await createBackup(dataDir2, "5min");
|
|
404
|
+
if (result) scheduleFiveMinBackup(dataDir2);
|
|
405
|
+
return result;
|
|
406
|
+
}
|
|
407
|
+
function initBackupScheduler(dataDir2) {
|
|
408
|
+
currentDataDir = dataDir2;
|
|
409
|
+
const previewDir = join3(backupsDir(dataDir2), "_preview");
|
|
410
|
+
if (existsSync2(previewDir)) {
|
|
411
|
+
rmSync2(previewDir, { recursive: true, force: true });
|
|
412
|
+
}
|
|
413
|
+
setTimeout(() => {
|
|
414
|
+
void createBackup(dataDir2, "5min").then(() => scheduleFiveMinBackup(dataDir2));
|
|
415
|
+
}, 1e4);
|
|
416
|
+
setInterval(() => void createBackup(dataDir2, "hourly"), TIERS["hourly"].intervalMs);
|
|
417
|
+
setInterval(() => void createBackup(dataDir2, "daily"), TIERS["daily"].intervalMs);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// src/cleanup.ts
|
|
421
|
+
import { rmSync as rmSync3 } from "fs";
|
|
169
422
|
|
|
170
423
|
// src/db/queries.ts
|
|
424
|
+
init_connection();
|
|
171
425
|
function parseNotes(raw2) {
|
|
172
426
|
if (!raw2 || raw2 === "") return [];
|
|
173
427
|
try {
|
|
@@ -203,6 +457,10 @@ async function createTicket(title, defaults) {
|
|
|
203
457
|
cols.push("up_next");
|
|
204
458
|
vals.push(defaults.up_next);
|
|
205
459
|
}
|
|
460
|
+
if (defaults?.details !== void 0 && defaults.details !== "") {
|
|
461
|
+
cols.push("details");
|
|
462
|
+
vals.push(defaults.details);
|
|
463
|
+
}
|
|
206
464
|
const placeholders = vals.map((_, i) => `$${i + 1}`).join(", ");
|
|
207
465
|
const result = await db2.query(
|
|
208
466
|
`INSERT INTO tickets (${cols.join(", ")}) VALUES (${placeholders}) RETURNING *`,
|
|
@@ -461,7 +719,7 @@ async function cleanupAttachments() {
|
|
|
461
719
|
const attachments = await getAttachments(ticket.id);
|
|
462
720
|
for (const att of attachments) {
|
|
463
721
|
try {
|
|
464
|
-
|
|
722
|
+
rmSync3(att.stored_path, { force: true });
|
|
465
723
|
} catch {
|
|
466
724
|
}
|
|
467
725
|
}
|
|
@@ -476,7 +734,59 @@ async function cleanupAttachments() {
|
|
|
476
734
|
}
|
|
477
735
|
}
|
|
478
736
|
|
|
737
|
+
// src/cli.ts
|
|
738
|
+
init_connection();
|
|
739
|
+
|
|
740
|
+
// src/lock.ts
|
|
741
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3, rmSync as rmSync4, writeFileSync as writeFileSync3 } from "fs";
|
|
742
|
+
import { join as join4 } from "path";
|
|
743
|
+
var lockPath = null;
|
|
744
|
+
function acquireLock(dataDir2) {
|
|
745
|
+
lockPath = join4(dataDir2, "hotsheet.lock");
|
|
746
|
+
if (existsSync3(lockPath)) {
|
|
747
|
+
try {
|
|
748
|
+
const contents = JSON.parse(readFileSync3(lockPath, "utf-8"));
|
|
749
|
+
const pid = contents.pid;
|
|
750
|
+
try {
|
|
751
|
+
process.kill(pid, 0);
|
|
752
|
+
console.error(`
|
|
753
|
+
Error: Another Hot Sheet instance (PID ${pid}) is already using this data directory.`);
|
|
754
|
+
console.error(` Directory: ${dataDir2}`);
|
|
755
|
+
console.error(` Stop that instance first, or use --data-dir to point to a different location.
|
|
756
|
+
`);
|
|
757
|
+
process.exit(1);
|
|
758
|
+
} catch {
|
|
759
|
+
console.log(` Removing stale lock from PID ${pid}`);
|
|
760
|
+
rmSync4(lockPath, { force: true });
|
|
761
|
+
}
|
|
762
|
+
} catch {
|
|
763
|
+
rmSync4(lockPath, { force: true });
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
writeFileSync3(lockPath, JSON.stringify({ pid: process.pid, startedAt: (/* @__PURE__ */ new Date()).toISOString() }));
|
|
767
|
+
const cleanup = () => releaseLock();
|
|
768
|
+
process.on("exit", cleanup);
|
|
769
|
+
process.on("SIGINT", () => {
|
|
770
|
+
cleanup();
|
|
771
|
+
process.exit(0);
|
|
772
|
+
});
|
|
773
|
+
process.on("SIGTERM", () => {
|
|
774
|
+
cleanup();
|
|
775
|
+
process.exit(0);
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
function releaseLock() {
|
|
779
|
+
if (lockPath) {
|
|
780
|
+
try {
|
|
781
|
+
rmSync4(lockPath, { force: true });
|
|
782
|
+
} catch {
|
|
783
|
+
}
|
|
784
|
+
lockPath = null;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
479
788
|
// src/demo.ts
|
|
789
|
+
init_connection();
|
|
480
790
|
var DEMO_SCENARIOS = [
|
|
481
791
|
{ id: 1, label: "Main UI \u2014 all tickets with detail panel" },
|
|
482
792
|
{ id: 2, label: "Quick entry \u2014 bullet-list ticket creation" },
|
|
@@ -1246,19 +1556,19 @@ init_gitignore();
|
|
|
1246
1556
|
// src/server.ts
|
|
1247
1557
|
import { serve } from "@hono/node-server";
|
|
1248
1558
|
import { exec } from "child_process";
|
|
1249
|
-
import { existsSync as
|
|
1250
|
-
import { Hono as
|
|
1251
|
-
import { dirname, join as
|
|
1559
|
+
import { existsSync as existsSync7, readFileSync as readFileSync6 } from "fs";
|
|
1560
|
+
import { Hono as Hono4 } from "hono";
|
|
1561
|
+
import { dirname, join as join9 } from "path";
|
|
1252
1562
|
import { fileURLToPath } from "url";
|
|
1253
1563
|
|
|
1254
1564
|
// src/routes/api.ts
|
|
1255
|
-
import { existsSync as
|
|
1565
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync4, rmSync as rmSync5 } from "fs";
|
|
1256
1566
|
import { Hono } from "hono";
|
|
1257
|
-
import { basename, extname, join as
|
|
1567
|
+
import { basename, extname, join as join8, relative as relative2 } from "path";
|
|
1258
1568
|
|
|
1259
|
-
// src/
|
|
1260
|
-
import { writeFileSync } from "fs";
|
|
1261
|
-
import { join as
|
|
1569
|
+
// src/skills.ts
|
|
1570
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync3, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
|
|
1571
|
+
import { join as join6, relative } from "path";
|
|
1262
1572
|
|
|
1263
1573
|
// src/types.ts
|
|
1264
1574
|
var CATEGORY_DESCRIPTIONS = {
|
|
@@ -1270,7 +1580,251 @@ var CATEGORY_DESCRIPTIONS = {
|
|
|
1270
1580
|
investigation: "Items requiring research or analysis"
|
|
1271
1581
|
};
|
|
1272
1582
|
|
|
1583
|
+
// src/skills.ts
|
|
1584
|
+
var SKILL_VERSION = 1;
|
|
1585
|
+
var skillPort;
|
|
1586
|
+
var skillDataDir;
|
|
1587
|
+
function initSkills(port2, dataDir2) {
|
|
1588
|
+
skillPort = port2;
|
|
1589
|
+
skillDataDir = dataDir2;
|
|
1590
|
+
}
|
|
1591
|
+
var TICKET_SKILLS = [
|
|
1592
|
+
{ name: "hs-bug", category: "bug", label: "bug" },
|
|
1593
|
+
{ name: "hs-feature", category: "feature", label: "feature" },
|
|
1594
|
+
{ name: "hs-task", category: "task", label: "task" },
|
|
1595
|
+
{ name: "hs-issue", category: "issue", label: "issue" },
|
|
1596
|
+
{ name: "hs-investigation", category: "investigation", label: "investigation" },
|
|
1597
|
+
{ name: "hs-req-change", category: "requirement_change", label: "requirement change" }
|
|
1598
|
+
];
|
|
1599
|
+
function versionHeader() {
|
|
1600
|
+
return `<!-- hotsheet-skill-version: ${SKILL_VERSION} port: ${skillPort} -->`;
|
|
1601
|
+
}
|
|
1602
|
+
function parseVersionHeader(content) {
|
|
1603
|
+
const match = content.match(/<!-- hotsheet-skill-version: (\d+) port: (\d+) -->/);
|
|
1604
|
+
if (!match) return null;
|
|
1605
|
+
return { version: parseInt(match[1], 10), port: parseInt(match[2], 10) };
|
|
1606
|
+
}
|
|
1607
|
+
function updateFile(path, content) {
|
|
1608
|
+
if (existsSync5(path)) {
|
|
1609
|
+
const existing = readFileSync5(path, "utf-8");
|
|
1610
|
+
const header = parseVersionHeader(existing);
|
|
1611
|
+
if (header && header.version >= SKILL_VERSION && header.port === skillPort) {
|
|
1612
|
+
return false;
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
writeFileSync4(path, content, "utf-8");
|
|
1616
|
+
return true;
|
|
1617
|
+
}
|
|
1618
|
+
function ticketSkillBody(skill) {
|
|
1619
|
+
const desc = CATEGORY_DESCRIPTIONS[skill.category];
|
|
1620
|
+
return [
|
|
1621
|
+
`Create a new Hot Sheet **${skill.label}** ticket. ${desc}.`,
|
|
1622
|
+
"",
|
|
1623
|
+
"**Parsing the input:**",
|
|
1624
|
+
'- If the input starts with "next", "up next", or "do next" (case-insensitive), set `up_next` to `true` and use the remaining text as the title',
|
|
1625
|
+
"- Otherwise, use the entire input as the title",
|
|
1626
|
+
"",
|
|
1627
|
+
"**Create the ticket** by running:",
|
|
1628
|
+
"```bash",
|
|
1629
|
+
`curl -s -X POST http://localhost:${skillPort}/api/tickets \\`,
|
|
1630
|
+
' -H "Content-Type: application/json" \\',
|
|
1631
|
+
` -d '{"title": "<TITLE>", "defaults": {"category": "${skill.category}", "up_next": <true|false>}}'`,
|
|
1632
|
+
"```",
|
|
1633
|
+
"",
|
|
1634
|
+
"Report the created ticket number and title to the user."
|
|
1635
|
+
].join("\n");
|
|
1636
|
+
}
|
|
1637
|
+
function mainSkillBody() {
|
|
1638
|
+
const worklistRel = relative(process.cwd(), join6(skillDataDir, "worklist.md"));
|
|
1639
|
+
return [
|
|
1640
|
+
`Read \`${worklistRel}\` and work through the tickets in priority order.`,
|
|
1641
|
+
"",
|
|
1642
|
+
"For each ticket:",
|
|
1643
|
+
"1. Read the ticket details carefully",
|
|
1644
|
+
"2. Implement the work described",
|
|
1645
|
+
"3. When complete, mark it done via the Hot Sheet UI",
|
|
1646
|
+
"",
|
|
1647
|
+
"Work through them in order of priority, where reasonable."
|
|
1648
|
+
].join("\n");
|
|
1649
|
+
}
|
|
1650
|
+
var HOTSHEET_ALLOW_PATTERNS = [
|
|
1651
|
+
"Bash(curl * http://localhost:417*/api/*)",
|
|
1652
|
+
"Bash(curl * http://localhost:418*/api/*)"
|
|
1653
|
+
];
|
|
1654
|
+
var HOTSHEET_CURL_RE = /^Bash\(curl \* http:\/\/localhost:\d+\/api\/\*\)$|^Bash\(curl \* http:\/\/localhost:41[78]\*\/api\/\*\)$/;
|
|
1655
|
+
function ensureClaudePermissions(cwd) {
|
|
1656
|
+
if (skillPort < 4170 || skillPort > 4189) return false;
|
|
1657
|
+
const settingsPath2 = join6(cwd, ".claude", "settings.json");
|
|
1658
|
+
let settings = {};
|
|
1659
|
+
if (existsSync5(settingsPath2)) {
|
|
1660
|
+
try {
|
|
1661
|
+
settings = JSON.parse(readFileSync5(settingsPath2, "utf-8"));
|
|
1662
|
+
} catch {
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
if (!settings.permissions) settings.permissions = {};
|
|
1666
|
+
if (!settings.permissions.allow) settings.permissions.allow = [];
|
|
1667
|
+
const allow = settings.permissions.allow;
|
|
1668
|
+
if (HOTSHEET_ALLOW_PATTERNS.every((p) => allow.includes(p))) return false;
|
|
1669
|
+
settings.permissions.allow = allow.filter((p) => !HOTSHEET_CURL_RE.test(p));
|
|
1670
|
+
settings.permissions.allow.push(...HOTSHEET_ALLOW_PATTERNS);
|
|
1671
|
+
writeFileSync4(settingsPath2, JSON.stringify(settings, null, 2) + "\n", "utf-8");
|
|
1672
|
+
return true;
|
|
1673
|
+
}
|
|
1674
|
+
function ensureClaudeSkills(cwd) {
|
|
1675
|
+
let updated = false;
|
|
1676
|
+
const skillsDir = join6(cwd, ".claude", "skills");
|
|
1677
|
+
if (ensureClaudePermissions(cwd)) updated = true;
|
|
1678
|
+
const mainDir = join6(skillsDir, "hotsheet");
|
|
1679
|
+
mkdirSync3(mainDir, { recursive: true });
|
|
1680
|
+
const mainContent = [
|
|
1681
|
+
"---",
|
|
1682
|
+
"name: hotsheet",
|
|
1683
|
+
"description: Read the Hot Sheet worklist and work through the current priority items",
|
|
1684
|
+
"allowed-tools: Read, Grep, Glob, Edit, Write, Bash",
|
|
1685
|
+
"---",
|
|
1686
|
+
versionHeader(),
|
|
1687
|
+
"",
|
|
1688
|
+
mainSkillBody(),
|
|
1689
|
+
""
|
|
1690
|
+
].join("\n");
|
|
1691
|
+
if (updateFile(join6(mainDir, "SKILL.md"), mainContent)) updated = true;
|
|
1692
|
+
for (const skill of TICKET_SKILLS) {
|
|
1693
|
+
const dir = join6(skillsDir, skill.name);
|
|
1694
|
+
mkdirSync3(dir, { recursive: true });
|
|
1695
|
+
const content = [
|
|
1696
|
+
"---",
|
|
1697
|
+
`name: ${skill.name}`,
|
|
1698
|
+
`description: Create a new ${skill.label} ticket in Hot Sheet`,
|
|
1699
|
+
"allowed-tools: Bash",
|
|
1700
|
+
"---",
|
|
1701
|
+
versionHeader(),
|
|
1702
|
+
"",
|
|
1703
|
+
ticketSkillBody(skill),
|
|
1704
|
+
""
|
|
1705
|
+
].join("\n");
|
|
1706
|
+
if (updateFile(join6(dir, "SKILL.md"), content)) updated = true;
|
|
1707
|
+
}
|
|
1708
|
+
return updated;
|
|
1709
|
+
}
|
|
1710
|
+
function ensureCursorRules(cwd) {
|
|
1711
|
+
let updated = false;
|
|
1712
|
+
const rulesDir = join6(cwd, ".cursor", "rules");
|
|
1713
|
+
mkdirSync3(rulesDir, { recursive: true });
|
|
1714
|
+
const mainContent = [
|
|
1715
|
+
"---",
|
|
1716
|
+
"description: Read the Hot Sheet worklist and work through the current priority items",
|
|
1717
|
+
"alwaysApply: false",
|
|
1718
|
+
"---",
|
|
1719
|
+
versionHeader(),
|
|
1720
|
+
"",
|
|
1721
|
+
mainSkillBody(),
|
|
1722
|
+
""
|
|
1723
|
+
].join("\n");
|
|
1724
|
+
if (updateFile(join6(rulesDir, "hotsheet.mdc"), mainContent)) updated = true;
|
|
1725
|
+
for (const skill of TICKET_SKILLS) {
|
|
1726
|
+
const content = [
|
|
1727
|
+
"---",
|
|
1728
|
+
`description: Create a new ${skill.label} ticket in Hot Sheet`,
|
|
1729
|
+
"alwaysApply: false",
|
|
1730
|
+
"---",
|
|
1731
|
+
versionHeader(),
|
|
1732
|
+
"",
|
|
1733
|
+
ticketSkillBody(skill),
|
|
1734
|
+
""
|
|
1735
|
+
].join("\n");
|
|
1736
|
+
if (updateFile(join6(rulesDir, `${skill.name}.mdc`), content)) updated = true;
|
|
1737
|
+
}
|
|
1738
|
+
return updated;
|
|
1739
|
+
}
|
|
1740
|
+
function ensureCopilotPrompts(cwd) {
|
|
1741
|
+
let updated = false;
|
|
1742
|
+
const promptsDir = join6(cwd, ".github", "prompts");
|
|
1743
|
+
mkdirSync3(promptsDir, { recursive: true });
|
|
1744
|
+
const mainContent = [
|
|
1745
|
+
"---",
|
|
1746
|
+
"description: Read the Hot Sheet worklist and work through the current priority items",
|
|
1747
|
+
"---",
|
|
1748
|
+
versionHeader(),
|
|
1749
|
+
"",
|
|
1750
|
+
mainSkillBody(),
|
|
1751
|
+
""
|
|
1752
|
+
].join("\n");
|
|
1753
|
+
if (updateFile(join6(promptsDir, "hotsheet.prompt.md"), mainContent)) updated = true;
|
|
1754
|
+
for (const skill of TICKET_SKILLS) {
|
|
1755
|
+
const content = [
|
|
1756
|
+
"---",
|
|
1757
|
+
`description: Create a new ${skill.label} ticket in Hot Sheet`,
|
|
1758
|
+
"---",
|
|
1759
|
+
versionHeader(),
|
|
1760
|
+
"",
|
|
1761
|
+
ticketSkillBody(skill),
|
|
1762
|
+
""
|
|
1763
|
+
].join("\n");
|
|
1764
|
+
if (updateFile(join6(promptsDir, `${skill.name}.prompt.md`), content)) updated = true;
|
|
1765
|
+
}
|
|
1766
|
+
return updated;
|
|
1767
|
+
}
|
|
1768
|
+
function ensureWindsurfRules(cwd) {
|
|
1769
|
+
let updated = false;
|
|
1770
|
+
const rulesDir = join6(cwd, ".windsurf", "rules");
|
|
1771
|
+
mkdirSync3(rulesDir, { recursive: true });
|
|
1772
|
+
const mainContent = [
|
|
1773
|
+
"---",
|
|
1774
|
+
"trigger: manual",
|
|
1775
|
+
"description: Read the Hot Sheet worklist and work through the current priority items",
|
|
1776
|
+
"---",
|
|
1777
|
+
versionHeader(),
|
|
1778
|
+
"",
|
|
1779
|
+
mainSkillBody(),
|
|
1780
|
+
""
|
|
1781
|
+
].join("\n");
|
|
1782
|
+
if (updateFile(join6(rulesDir, "hotsheet.md"), mainContent)) updated = true;
|
|
1783
|
+
for (const skill of TICKET_SKILLS) {
|
|
1784
|
+
const content = [
|
|
1785
|
+
"---",
|
|
1786
|
+
"trigger: manual",
|
|
1787
|
+
`description: Create a new ${skill.label} ticket in Hot Sheet`,
|
|
1788
|
+
"---",
|
|
1789
|
+
versionHeader(),
|
|
1790
|
+
"",
|
|
1791
|
+
ticketSkillBody(skill),
|
|
1792
|
+
""
|
|
1793
|
+
].join("\n");
|
|
1794
|
+
if (updateFile(join6(rulesDir, `${skill.name}.md`), content)) updated = true;
|
|
1795
|
+
}
|
|
1796
|
+
return updated;
|
|
1797
|
+
}
|
|
1798
|
+
var pendingCreatedFlag = false;
|
|
1799
|
+
function ensureSkills() {
|
|
1800
|
+
const cwd = process.cwd();
|
|
1801
|
+
const platforms = [];
|
|
1802
|
+
if (existsSync5(join6(cwd, ".claude"))) {
|
|
1803
|
+
if (ensureClaudeSkills(cwd)) platforms.push("Claude Code");
|
|
1804
|
+
}
|
|
1805
|
+
if (existsSync5(join6(cwd, ".cursor"))) {
|
|
1806
|
+
if (ensureCursorRules(cwd)) platforms.push("Cursor");
|
|
1807
|
+
}
|
|
1808
|
+
if (existsSync5(join6(cwd, ".github", "prompts")) || existsSync5(join6(cwd, ".github", "copilot-instructions.md"))) {
|
|
1809
|
+
if (ensureCopilotPrompts(cwd)) platforms.push("GitHub Copilot");
|
|
1810
|
+
}
|
|
1811
|
+
if (existsSync5(join6(cwd, ".windsurf"))) {
|
|
1812
|
+
if (ensureWindsurfRules(cwd)) platforms.push("Windsurf");
|
|
1813
|
+
}
|
|
1814
|
+
if (platforms.length > 0) {
|
|
1815
|
+
pendingCreatedFlag = true;
|
|
1816
|
+
}
|
|
1817
|
+
return platforms;
|
|
1818
|
+
}
|
|
1819
|
+
function consumeSkillsCreatedFlag() {
|
|
1820
|
+
const result = pendingCreatedFlag;
|
|
1821
|
+
pendingCreatedFlag = false;
|
|
1822
|
+
return result;
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1273
1825
|
// src/sync/markdown.ts
|
|
1826
|
+
import { writeFileSync as writeFileSync5 } from "fs";
|
|
1827
|
+
import { join as join7 } from "path";
|
|
1274
1828
|
var dataDir;
|
|
1275
1829
|
var port;
|
|
1276
1830
|
var worklistTimeout = null;
|
|
@@ -1371,6 +1925,20 @@ async function syncWorklist() {
|
|
|
1371
1925
|
sections.push("- If an API call fails (e.g. connection refused, error response), log a visible warning to the user and continue your work. Do NOT silently skip status updates.");
|
|
1372
1926
|
sections.push('- Do NOT set tickets to "verified" \u2014 that status is reserved for human review.');
|
|
1373
1927
|
sections.push("");
|
|
1928
|
+
sections.push("## Creating Tickets");
|
|
1929
|
+
sections.push("");
|
|
1930
|
+
sections.push("You can create new tickets directly via the API. Use this strategically to:");
|
|
1931
|
+
sections.push("- Break up complex tasks into smaller, trackable sub-tickets");
|
|
1932
|
+
sections.push("- Flag implementation decisions that need human review");
|
|
1933
|
+
sections.push("- Record bugs or issues discovered while working");
|
|
1934
|
+
sections.push("- Create follow-up tasks for items outside the current scope");
|
|
1935
|
+
sections.push("");
|
|
1936
|
+
sections.push("To create a ticket:");
|
|
1937
|
+
sections.push(` \`curl -s -X POST http://localhost:${port}/api/tickets -H "Content-Type: application/json" -d '{"title": "Title", "defaults": {"category": "bug|feature|task|issue|investigation|requirement_change", "up_next": false}}'\``);
|
|
1938
|
+
sections.push("");
|
|
1939
|
+
sections.push('You can also include `"details"` in the defaults object for longer descriptions.');
|
|
1940
|
+
sections.push("Set `up_next: true` only for items that should be prioritized immediately.");
|
|
1941
|
+
sections.push("");
|
|
1374
1942
|
if (tickets.length === 0) {
|
|
1375
1943
|
sections.push("No items in the Up Next list.");
|
|
1376
1944
|
} else {
|
|
@@ -1387,7 +1955,7 @@ async function syncWorklist() {
|
|
|
1387
1955
|
sections.push(formatCategoryDescriptions(categories));
|
|
1388
1956
|
}
|
|
1389
1957
|
sections.push("");
|
|
1390
|
-
|
|
1958
|
+
writeFileSync5(join7(dataDir, "worklist.md"), sections.join("\n"), "utf-8");
|
|
1391
1959
|
} catch (err) {
|
|
1392
1960
|
console.error("Failed to sync worklist.md:", err);
|
|
1393
1961
|
}
|
|
@@ -1431,7 +1999,7 @@ async function syncOpenTickets() {
|
|
|
1431
1999
|
sections.push(formatCategoryDescriptions(categories));
|
|
1432
2000
|
}
|
|
1433
2001
|
sections.push("");
|
|
1434
|
-
|
|
2002
|
+
writeFileSync5(join7(dataDir, "open-tickets.md"), sections.join("\n"), "utf-8");
|
|
1435
2003
|
} catch (err) {
|
|
1436
2004
|
console.error("Failed to sync open-tickets.md:", err);
|
|
1437
2005
|
}
|
|
@@ -1518,7 +2086,7 @@ apiRoutes.delete("/tickets/:id/hard", async (c) => {
|
|
|
1518
2086
|
const attachments = await getAttachments(id);
|
|
1519
2087
|
for (const att of attachments) {
|
|
1520
2088
|
try {
|
|
1521
|
-
|
|
2089
|
+
rmSync5(att.stored_path, { force: true });
|
|
1522
2090
|
} catch {
|
|
1523
2091
|
}
|
|
1524
2092
|
}
|
|
@@ -1567,7 +2135,7 @@ apiRoutes.post("/trash/empty", async (c) => {
|
|
|
1567
2135
|
const attachments = await getAttachments(ticket.id);
|
|
1568
2136
|
for (const att of attachments) {
|
|
1569
2137
|
try {
|
|
1570
|
-
|
|
2138
|
+
rmSync5(att.stored_path, { force: true });
|
|
1571
2139
|
} catch {
|
|
1572
2140
|
}
|
|
1573
2141
|
}
|
|
@@ -1599,12 +2167,12 @@ apiRoutes.post("/tickets/:id/attachments", async (c) => {
|
|
|
1599
2167
|
const ext = extname(originalName);
|
|
1600
2168
|
const baseName = basename(originalName, ext);
|
|
1601
2169
|
const storedName = `${ticket.ticket_number}_${baseName}${ext}`;
|
|
1602
|
-
const attachDir =
|
|
1603
|
-
|
|
1604
|
-
const storedPath =
|
|
2170
|
+
const attachDir = join8(dataDir2, "attachments");
|
|
2171
|
+
mkdirSync4(attachDir, { recursive: true });
|
|
2172
|
+
const storedPath = join8(attachDir, storedName);
|
|
1605
2173
|
const buffer = Buffer.from(await file.arrayBuffer());
|
|
1606
|
-
const { writeFileSync:
|
|
1607
|
-
|
|
2174
|
+
const { writeFileSync: writeFileSync7 } = await import("fs");
|
|
2175
|
+
writeFileSync7(storedPath, buffer);
|
|
1608
2176
|
const attachment = await addAttachment(id, originalName, storedPath);
|
|
1609
2177
|
scheduleAllSync();
|
|
1610
2178
|
notifyChange();
|
|
@@ -1615,7 +2183,7 @@ apiRoutes.delete("/attachments/:id", async (c) => {
|
|
|
1615
2183
|
const attachment = await deleteAttachment(id);
|
|
1616
2184
|
if (!attachment) return c.json({ error: "Not found" }, 404);
|
|
1617
2185
|
try {
|
|
1618
|
-
|
|
2186
|
+
rmSync5(attachment.stored_path, { force: true });
|
|
1619
2187
|
} catch {
|
|
1620
2188
|
}
|
|
1621
2189
|
scheduleAllSync();
|
|
@@ -1625,12 +2193,12 @@ apiRoutes.delete("/attachments/:id", async (c) => {
|
|
|
1625
2193
|
apiRoutes.get("/attachments/file/*", async (c) => {
|
|
1626
2194
|
const filePath = c.req.path.replace("/api/attachments/file/", "");
|
|
1627
2195
|
const dataDir2 = c.get("dataDir");
|
|
1628
|
-
const fullPath =
|
|
1629
|
-
if (!
|
|
2196
|
+
const fullPath = join8(dataDir2, "attachments", filePath);
|
|
2197
|
+
if (!existsSync6(fullPath)) {
|
|
1630
2198
|
return c.json({ error: "File not found" }, 404);
|
|
1631
2199
|
}
|
|
1632
|
-
const { readFileSync:
|
|
1633
|
-
const content =
|
|
2200
|
+
const { readFileSync: readFileSync8 } = await import("fs");
|
|
2201
|
+
const content = readFileSync8(fullPath);
|
|
1634
2202
|
const ext = extname(fullPath).toLowerCase();
|
|
1635
2203
|
const mimeTypes = {
|
|
1636
2204
|
".png": "image/png",
|
|
@@ -1663,39 +2231,25 @@ apiRoutes.patch("/settings", async (c) => {
|
|
|
1663
2231
|
}
|
|
1664
2232
|
return c.json({ ok: true });
|
|
1665
2233
|
});
|
|
2234
|
+
apiRoutes.get("/file-settings", async (c) => {
|
|
2235
|
+
const { readFileSettings: readFileSettings2 } = await Promise.resolve().then(() => (init_file_settings(), file_settings_exports));
|
|
2236
|
+
const dataDir2 = c.get("dataDir");
|
|
2237
|
+
return c.json(readFileSettings2(dataDir2));
|
|
2238
|
+
});
|
|
2239
|
+
apiRoutes.patch("/file-settings", async (c) => {
|
|
2240
|
+
const { writeFileSettings: writeFileSettings2 } = await Promise.resolve().then(() => (init_file_settings(), file_settings_exports));
|
|
2241
|
+
const dataDir2 = c.get("dataDir");
|
|
2242
|
+
const body = await c.req.json();
|
|
2243
|
+
const updated = writeFileSettings2(dataDir2, body);
|
|
2244
|
+
return c.json(updated);
|
|
2245
|
+
});
|
|
1666
2246
|
apiRoutes.get("/worklist-info", (c) => {
|
|
1667
2247
|
const dataDir2 = c.get("dataDir");
|
|
1668
2248
|
const cwd = process.cwd();
|
|
1669
|
-
const worklistRel =
|
|
2249
|
+
const worklistRel = relative2(cwd, join8(dataDir2, "worklist.md"));
|
|
1670
2250
|
const prompt = `Read ${worklistRel} for current work items.`;
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
if (existsSync2(claudeDir)) {
|
|
1674
|
-
const skillDir = join4(claudeDir, "skills", "hotsheet");
|
|
1675
|
-
const skillFile = join4(skillDir, "SKILL.md");
|
|
1676
|
-
if (!existsSync2(skillFile)) {
|
|
1677
|
-
mkdirSync2(skillDir, { recursive: true });
|
|
1678
|
-
const skillContent = [
|
|
1679
|
-
"---",
|
|
1680
|
-
"name: hotsheet",
|
|
1681
|
-
"description: Read the Hot Sheet worklist and work through the current priority items",
|
|
1682
|
-
"allowed-tools: Read, Grep, Glob, Edit, Write, Bash",
|
|
1683
|
-
"---",
|
|
1684
|
-
"",
|
|
1685
|
-
`Read \`${worklistRel}\` and work through the tickets in priority order.`,
|
|
1686
|
-
"",
|
|
1687
|
-
"For each ticket:",
|
|
1688
|
-
"1. Read the ticket details carefully",
|
|
1689
|
-
"2. Implement the work described",
|
|
1690
|
-
"3. When complete, mark it done via the Hot Sheet UI",
|
|
1691
|
-
"",
|
|
1692
|
-
"Work through them in order of priority, where reasonable.",
|
|
1693
|
-
""
|
|
1694
|
-
].join("\n");
|
|
1695
|
-
writeFileSync2(skillFile, skillContent, "utf-8");
|
|
1696
|
-
skillCreated = true;
|
|
1697
|
-
}
|
|
1698
|
-
}
|
|
2251
|
+
ensureSkills();
|
|
2252
|
+
const skillCreated = consumeSkillsCreatedFlag();
|
|
1699
2253
|
return c.json({ prompt, skillCreated });
|
|
1700
2254
|
});
|
|
1701
2255
|
apiRoutes.get("/gitignore/status", async (c) => {
|
|
@@ -1710,8 +2264,58 @@ apiRoutes.post("/gitignore/add", async (c) => {
|
|
|
1710
2264
|
return c.json({ ok: true });
|
|
1711
2265
|
});
|
|
1712
2266
|
|
|
1713
|
-
// src/routes/
|
|
2267
|
+
// src/routes/backups.ts
|
|
1714
2268
|
import { Hono as Hono2 } from "hono";
|
|
2269
|
+
var backupRoutes = new Hono2();
|
|
2270
|
+
backupRoutes.get("/", (c) => {
|
|
2271
|
+
const dataDir2 = c.get("dataDir");
|
|
2272
|
+
const backups = listBackups(dataDir2);
|
|
2273
|
+
return c.json({ backups });
|
|
2274
|
+
});
|
|
2275
|
+
backupRoutes.post("/create", async (c) => {
|
|
2276
|
+
const dataDir2 = c.get("dataDir");
|
|
2277
|
+
const body = await c.req.json();
|
|
2278
|
+
const info = await createBackup(dataDir2, body.tier);
|
|
2279
|
+
if (!info) return c.json({ error: "Backup already in progress" }, 409);
|
|
2280
|
+
return c.json(info);
|
|
2281
|
+
});
|
|
2282
|
+
backupRoutes.post("/now", async (c) => {
|
|
2283
|
+
const dataDir2 = c.get("dataDir");
|
|
2284
|
+
const info = await triggerManualBackup(dataDir2);
|
|
2285
|
+
if (!info) return c.json({ error: "Backup already in progress" }, 409);
|
|
2286
|
+
return c.json(info);
|
|
2287
|
+
});
|
|
2288
|
+
backupRoutes.get("/preview/:tier/:filename", async (c) => {
|
|
2289
|
+
const dataDir2 = c.get("dataDir");
|
|
2290
|
+
const tier = c.req.param("tier");
|
|
2291
|
+
const filename = c.req.param("filename");
|
|
2292
|
+
try {
|
|
2293
|
+
const result = await loadBackupForPreview(dataDir2, tier, filename);
|
|
2294
|
+
return c.json(result);
|
|
2295
|
+
} catch (err) {
|
|
2296
|
+
const msg = err instanceof Error ? err.message : "Preview failed";
|
|
2297
|
+
return c.json({ error: msg }, 400);
|
|
2298
|
+
}
|
|
2299
|
+
});
|
|
2300
|
+
backupRoutes.post("/preview/cleanup", async (c) => {
|
|
2301
|
+
await cleanupPreview();
|
|
2302
|
+
return c.json({ ok: true });
|
|
2303
|
+
});
|
|
2304
|
+
backupRoutes.post("/restore", async (c) => {
|
|
2305
|
+
const dataDir2 = c.get("dataDir");
|
|
2306
|
+
const body = await c.req.json();
|
|
2307
|
+
try {
|
|
2308
|
+
await restoreBackup(dataDir2, body.tier, body.filename);
|
|
2309
|
+
scheduleAllSync();
|
|
2310
|
+
return c.json({ ok: true });
|
|
2311
|
+
} catch (err) {
|
|
2312
|
+
const msg = err instanceof Error ? err.message : "Restore failed";
|
|
2313
|
+
return c.json({ error: msg }, 500);
|
|
2314
|
+
}
|
|
2315
|
+
});
|
|
2316
|
+
|
|
2317
|
+
// src/routes/pages.tsx
|
|
2318
|
+
import { Hono as Hono3 } from "hono";
|
|
1715
2319
|
|
|
1716
2320
|
// src/utils/escapeHtml.ts
|
|
1717
2321
|
function escapeHtml(str) {
|
|
@@ -1799,7 +2403,7 @@ function Layout({ title, children }) {
|
|
|
1799
2403
|
}
|
|
1800
2404
|
|
|
1801
2405
|
// src/routes/pages.tsx
|
|
1802
|
-
var pageRoutes = new
|
|
2406
|
+
var pageRoutes = new Hono3();
|
|
1803
2407
|
pageRoutes.get("/", (c) => {
|
|
1804
2408
|
const html = /* @__PURE__ */ jsx(Layout, { title: "Hot Sheet", children: [
|
|
1805
2409
|
/* @__PURE__ */ jsx("div", { className: "app", children: [
|
|
@@ -1825,6 +2429,17 @@ pageRoutes.get("/", (c) => {
|
|
|
1825
2429
|
/* @__PURE__ */ jsx("button", { className: "settings-btn", id: "settings-btn", title: "Settings", children: raw("⚙") })
|
|
1826
2430
|
] })
|
|
1827
2431
|
] }),
|
|
2432
|
+
/* @__PURE__ */ jsx("div", { id: "backup-preview-banner", className: "backup-preview-banner", style: "display:none", children: [
|
|
2433
|
+
/* @__PURE__ */ jsx("span", { id: "backup-preview-label", children: "Previewing backup..." }),
|
|
2434
|
+
/* @__PURE__ */ jsx("div", { className: "backup-preview-actions", children: [
|
|
2435
|
+
/* @__PURE__ */ jsx("button", { id: "backup-restore-btn", className: "btn btn-sm btn-danger", children: "Restore This Backup" }),
|
|
2436
|
+
/* @__PURE__ */ jsx("button", { id: "backup-cancel-btn", className: "btn btn-sm", children: "Cancel Preview" })
|
|
2437
|
+
] })
|
|
2438
|
+
] }),
|
|
2439
|
+
/* @__PURE__ */ jsx("div", { id: "skills-banner", className: "skills-banner", style: "display:none", children: [
|
|
2440
|
+
/* @__PURE__ */ jsx("span", { children: "AI tool skills created. Restart your AI tool to use the new ticket creation skills (hs-bug, hs-feature, etc.)." }),
|
|
2441
|
+
/* @__PURE__ */ jsx("button", { id: "skills-banner-dismiss", className: "btn btn-sm", children: "Dismiss" })
|
|
2442
|
+
] }),
|
|
1828
2443
|
/* @__PURE__ */ jsx("div", { className: "app-body", children: [
|
|
1829
2444
|
/* @__PURE__ */ jsx("nav", { className: "sidebar", children: [
|
|
1830
2445
|
/* @__PURE__ */ jsx("div", { className: "sidebar-copy-prompt", id: "copy-prompt-section", style: "display:none", children: /* @__PURE__ */ jsx("button", { className: "copy-prompt-btn", id: "copy-prompt-btn", title: "Copy worklist prompt to clipboard", children: [
|
|
@@ -2025,6 +2640,18 @@ pageRoutes.get("/", (c) => {
|
|
|
2025
2640
|
/* @__PURE__ */ jsx("div", { className: "settings-field", children: [
|
|
2026
2641
|
/* @__PURE__ */ jsx("label", { children: "Auto-clear verified after (days)" }),
|
|
2027
2642
|
/* @__PURE__ */ jsx("input", { type: "number", id: "settings-verified-days", min: "1", value: "30" })
|
|
2643
|
+
] }),
|
|
2644
|
+
/* @__PURE__ */ jsx("div", { className: "settings-section", children: [
|
|
2645
|
+
/* @__PURE__ */ jsx("div", { className: "settings-section-header", children: [
|
|
2646
|
+
/* @__PURE__ */ jsx("h3", { children: "Database Backups" }),
|
|
2647
|
+
/* @__PURE__ */ jsx("button", { className: "btn btn-sm", id: "backup-now-btn", children: "Backup Now" })
|
|
2648
|
+
] }),
|
|
2649
|
+
/* @__PURE__ */ jsx("div", { className: "settings-field", children: [
|
|
2650
|
+
/* @__PURE__ */ jsx("label", { children: "Backup storage location" }),
|
|
2651
|
+
/* @__PURE__ */ jsx("input", { type: "text", id: "settings-backup-dir", placeholder: "Default: .hotsheet/backups" }),
|
|
2652
|
+
/* @__PURE__ */ jsx("span", { className: "settings-hint", id: "settings-backup-dir-hint", children: "Leave empty to use the default location inside the data directory." })
|
|
2653
|
+
] }),
|
|
2654
|
+
/* @__PURE__ */ jsx("div", { id: "backup-list", className: "backup-list", children: "Loading backups..." })
|
|
2028
2655
|
] })
|
|
2029
2656
|
] })
|
|
2030
2657
|
] }) })
|
|
@@ -2045,22 +2672,23 @@ function tryServe(fetch, port2) {
|
|
|
2045
2672
|
});
|
|
2046
2673
|
}
|
|
2047
2674
|
async function startServer(port2, dataDir2, options) {
|
|
2048
|
-
const app = new
|
|
2675
|
+
const app = new Hono4();
|
|
2049
2676
|
app.use("*", async (c, next) => {
|
|
2050
2677
|
c.set("dataDir", dataDir2);
|
|
2051
2678
|
await next();
|
|
2052
2679
|
});
|
|
2053
2680
|
const selfDir = dirname(fileURLToPath(import.meta.url));
|
|
2054
|
-
const distDir =
|
|
2681
|
+
const distDir = existsSync7(join9(selfDir, "client", "styles.css")) ? join9(selfDir, "client") : join9(selfDir, "..", "dist", "client");
|
|
2055
2682
|
app.get("/static/styles.css", (c) => {
|
|
2056
|
-
const css =
|
|
2683
|
+
const css = readFileSync6(join9(distDir, "styles.css"), "utf-8");
|
|
2057
2684
|
return c.text(css, 200, { "Content-Type": "text/css", "Cache-Control": "no-cache" });
|
|
2058
2685
|
});
|
|
2059
2686
|
app.get("/static/app.js", (c) => {
|
|
2060
|
-
const js =
|
|
2687
|
+
const js = readFileSync6(join9(distDir, "app.global.js"), "utf-8");
|
|
2061
2688
|
return c.text(js, 200, { "Content-Type": "application/javascript", "Cache-Control": "no-cache" });
|
|
2062
2689
|
});
|
|
2063
2690
|
app.route("/api", apiRoutes);
|
|
2691
|
+
app.route("/api/backups", backupRoutes);
|
|
2064
2692
|
app.route("/", pageRoutes);
|
|
2065
2693
|
let actualPort = port2;
|
|
2066
2694
|
for (let attempt = 0; attempt < 20; attempt++) {
|
|
@@ -2098,18 +2726,18 @@ async function startServer(port2, dataDir2, options) {
|
|
|
2098
2726
|
}
|
|
2099
2727
|
|
|
2100
2728
|
// src/update-check.ts
|
|
2101
|
-
import { existsSync as
|
|
2729
|
+
import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync7, writeFileSync as writeFileSync6 } from "fs";
|
|
2102
2730
|
import { get } from "https";
|
|
2103
2731
|
import { homedir } from "os";
|
|
2104
|
-
import { dirname as dirname2, join as
|
|
2732
|
+
import { dirname as dirname2, join as join10 } from "path";
|
|
2105
2733
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
2106
|
-
var DATA_DIR =
|
|
2107
|
-
var CHECK_FILE =
|
|
2734
|
+
var DATA_DIR = join10(homedir(), ".hotsheet");
|
|
2735
|
+
var CHECK_FILE = join10(DATA_DIR, "last-update-check");
|
|
2108
2736
|
var PACKAGE_NAME = "hotsheet";
|
|
2109
2737
|
function getCurrentVersion() {
|
|
2110
2738
|
try {
|
|
2111
2739
|
const dir = dirname2(fileURLToPath2(import.meta.url));
|
|
2112
|
-
const pkg = JSON.parse(
|
|
2740
|
+
const pkg = JSON.parse(readFileSync7(join10(dir, "..", "package.json"), "utf-8"));
|
|
2113
2741
|
return pkg.version;
|
|
2114
2742
|
} catch {
|
|
2115
2743
|
return "0.0.0";
|
|
@@ -2117,16 +2745,16 @@ function getCurrentVersion() {
|
|
|
2117
2745
|
}
|
|
2118
2746
|
function getLastCheckDate() {
|
|
2119
2747
|
try {
|
|
2120
|
-
if (
|
|
2121
|
-
return
|
|
2748
|
+
if (existsSync8(CHECK_FILE)) {
|
|
2749
|
+
return readFileSync7(CHECK_FILE, "utf-8").trim();
|
|
2122
2750
|
}
|
|
2123
2751
|
} catch {
|
|
2124
2752
|
}
|
|
2125
2753
|
return null;
|
|
2126
2754
|
}
|
|
2127
2755
|
function saveCheckDate() {
|
|
2128
|
-
|
|
2129
|
-
|
|
2756
|
+
mkdirSync5(DATA_DIR, { recursive: true });
|
|
2757
|
+
writeFileSync6(CHECK_FILE, (/* @__PURE__ */ new Date()).toISOString().slice(0, 10), "utf-8");
|
|
2130
2758
|
}
|
|
2131
2759
|
function isFirstUseToday() {
|
|
2132
2760
|
const last = getLastCheckDate();
|
|
@@ -2231,7 +2859,7 @@ Examples:
|
|
|
2231
2859
|
function parseArgs(argv) {
|
|
2232
2860
|
const args = argv.slice(2);
|
|
2233
2861
|
let port2 = 4174;
|
|
2234
|
-
let dataDir2 =
|
|
2862
|
+
let dataDir2 = join11(process.cwd(), ".hotsheet");
|
|
2235
2863
|
let demo = null;
|
|
2236
2864
|
let forceUpdateCheck = false;
|
|
2237
2865
|
let noOpen = false;
|
|
@@ -2298,13 +2926,14 @@ async function main() {
|
|
|
2298
2926
|
}
|
|
2299
2927
|
process.exit(1);
|
|
2300
2928
|
}
|
|
2301
|
-
dataDir2 =
|
|
2929
|
+
dataDir2 = join11(tmpdir(), `hotsheet-demo-${demo}-${Date.now()}`);
|
|
2302
2930
|
console.log(`
|
|
2303
2931
|
DEMO MODE: ${scenario.label}
|
|
2304
2932
|
`);
|
|
2305
2933
|
}
|
|
2306
|
-
|
|
2934
|
+
mkdirSync6(dataDir2, { recursive: true });
|
|
2307
2935
|
if (demo === null) {
|
|
2936
|
+
acquireLock(dataDir2);
|
|
2308
2937
|
ensureGitignore(process.cwd());
|
|
2309
2938
|
}
|
|
2310
2939
|
setDataDir(dataDir2);
|
|
@@ -2319,6 +2948,16 @@ async function main() {
|
|
|
2319
2948
|
const actualPort = await startServer(port2, dataDir2, { noOpen, strictPort });
|
|
2320
2949
|
initMarkdownSync(dataDir2, actualPort);
|
|
2321
2950
|
scheduleAllSync();
|
|
2951
|
+
initSkills(actualPort, dataDir2);
|
|
2952
|
+
const updatedPlatforms = ensureSkills();
|
|
2953
|
+
if (updatedPlatforms.length > 0) {
|
|
2954
|
+
console.log(`
|
|
2955
|
+
AI tool skills created/updated for: ${updatedPlatforms.join(", ")}`);
|
|
2956
|
+
console.log(" Restart your AI tool to pick up the new ticket creation skills.\n");
|
|
2957
|
+
}
|
|
2958
|
+
if (demo === null) {
|
|
2959
|
+
initBackupScheduler(dataDir2);
|
|
2960
|
+
}
|
|
2322
2961
|
}
|
|
2323
2962
|
main().catch((err) => {
|
|
2324
2963
|
console.error(err);
|