@zzusp/ccsm 1.0.0 → 1.0.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.
Files changed (83) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +236 -232
  3. package/dist/assets/DiskUsage-BY6XwffG.js +2 -0
  4. package/dist/assets/DiskUsage-BY6XwffG.js.map +1 -0
  5. package/dist/assets/{ImportPage-b8NORa8b.js → ImportPage-Cwq5bx7G.js} +2 -2
  6. package/dist/assets/ImportPage-Cwq5bx7G.js.map +1 -0
  7. package/dist/assets/MarkdownContent-BFu7Nkk_.js +2 -0
  8. package/dist/assets/MarkdownContent-BFu7Nkk_.js.map +1 -0
  9. package/dist/assets/{ProjectMemory-aSV8UzQ9.js → ProjectMemory-CcE3KbUK.js} +2 -2
  10. package/dist/assets/ProjectMemory-CcE3KbUK.js.map +1 -0
  11. package/dist/assets/{charts-A5eNHLjX.js → charts-jxJqXXUr.js} +2 -2
  12. package/dist/assets/{charts-A5eNHLjX.js.map → charts-jxJqXXUr.js.map} +1 -1
  13. package/dist/assets/index-CrWxV6sb.css +1 -0
  14. package/dist/assets/index-DTbWl1jb.js +11 -0
  15. package/dist/assets/index-DTbWl1jb.js.map +1 -0
  16. package/dist/assets/markdown-Bag5rX3T.js +30 -0
  17. package/dist/assets/markdown-Bag5rX3T.js.map +1 -0
  18. package/dist/assets/{query-C1K1uQRu.js → query-CS7JQ86v.js} +2 -2
  19. package/dist/assets/{query-C1K1uQRu.js.map → query-CS7JQ86v.js.map} +1 -1
  20. package/dist/assets/{react-W0jzChlo.js → react-CPkiFScu.js} +10 -10
  21. package/dist/assets/{react-W0jzChlo.js.map → react-CPkiFScu.js.map} +1 -1
  22. package/dist/assets/{router-DfbutHY3.js → router-DwaHAh1G.js} +2 -2
  23. package/dist/assets/{router-DfbutHY3.js.map → router-DwaHAh1G.js.map} +1 -1
  24. package/dist/assets/vendor-Cs8vYp-N.js +27 -0
  25. package/dist/assets/vendor-Cs8vYp-N.js.map +1 -0
  26. package/dist/favicon.svg +7 -7
  27. package/dist/index.html +30 -30
  28. package/package.json +24 -11
  29. package/server/index.ts +4 -0
  30. package/server/lib/active-sessions.test.ts +119 -0
  31. package/server/lib/active-sessions.ts +95 -95
  32. package/server/lib/bundle.test.ts +182 -0
  33. package/server/lib/bundle.ts +86 -86
  34. package/server/lib/claude-paths.test.ts +126 -0
  35. package/server/lib/claude-paths.ts +43 -36
  36. package/server/lib/cleanup-suggestions.ts +131 -0
  37. package/server/lib/constants.ts +8 -7
  38. package/server/lib/delete-project.ts +100 -100
  39. package/server/lib/delete.test.ts +244 -0
  40. package/server/lib/delete.ts +192 -203
  41. package/server/lib/disk-usage.ts +81 -83
  42. package/server/lib/encode-cwd.ts +24 -24
  43. package/server/lib/export-bundle.ts +236 -236
  44. package/server/lib/export-import-bundle.test.ts +337 -0
  45. package/server/lib/fs-size.ts +38 -38
  46. package/server/lib/import-bundle.ts +488 -488
  47. package/server/lib/load-memory.ts +120 -120
  48. package/server/lib/load-session.ts +209 -209
  49. package/server/lib/modified-files.test.ts +280 -0
  50. package/server/lib/modified-files.ts +228 -0
  51. package/server/lib/open-folder.ts +47 -40
  52. package/server/lib/parse-jsonl.ts +160 -107
  53. package/server/lib/port.ts +23 -23
  54. package/server/lib/safe-id.test.ts +41 -0
  55. package/server/lib/safe-id.ts +6 -6
  56. package/server/lib/safe-remove.test.ts +73 -0
  57. package/server/lib/safe-remove.ts +25 -0
  58. package/server/lib/scan.ts +289 -183
  59. package/server/lib/search-all.ts +130 -130
  60. package/server/lib/search-session.ts +203 -203
  61. package/server/lib/system-tags.ts +20 -20
  62. package/server/lib/update.ts +67 -0
  63. package/server/lib/version.test.ts +39 -0
  64. package/server/lib/version.ts +117 -0
  65. package/server/routes/disk-cleanup.ts +54 -0
  66. package/server/routes/disk.ts +9 -9
  67. package/server/routes/import.ts +87 -87
  68. package/server/routes/projects.ts +104 -104
  69. package/server/routes/search.ts +79 -79
  70. package/server/routes/sessions.ts +130 -81
  71. package/server/routes/version.ts +34 -0
  72. package/server/types.ts +1 -1
  73. package/shared/constants.ts +7 -2
  74. package/shared/types.ts +513 -359
  75. package/dist/assets/DiskUsage-Bq4VaoUA.js +0 -2
  76. package/dist/assets/DiskUsage-Bq4VaoUA.js.map +0 -1
  77. package/dist/assets/ImportPage-b8NORa8b.js.map +0 -1
  78. package/dist/assets/ProjectMemory-aSV8UzQ9.js.map +0 -1
  79. package/dist/assets/index-DLATR3tZ.js +0 -5
  80. package/dist/assets/index-DLATR3tZ.js.map +0 -1
  81. package/dist/assets/index-DLDtbkux.css +0 -1
  82. package/dist/assets/vendor-CH80ylbS.js +0 -19
  83. package/dist/assets/vendor-CH80ylbS.js.map +0 -1
@@ -1,20 +1,20 @@
1
- // Matches text that is purely system-injected wrapper content — local command
2
- // stdout/stderr replays, runtime system-reminder blocks, and caveat banners.
3
- // Slash-command invocations (`<command-name>` / `<command-message>` /
4
- // `<command-args>`) are user-driven and excluded — their `<command-args>` body
5
- // carries the user's actual prompt.
6
- export const SYSTEM_TAG_RE = /^\s*<(local-command|system-reminder|caveat)/i;
7
-
8
- // Slash-command records carry the user's actual prompt (if any) inside
9
- // <command-args>BODY</command-args>. Returns the trimmed args body when
10
- // meaningful; returns '' when the record is just a metadata invocation
11
- // (/clear, /model, /login with empty args, or legacy shapes that lack a
12
- // <command-args> tag entirely) so callers can skip the message. Returns the
13
- // input unchanged for non-slash-command text. `claude resume` applies the
14
- // same skip when picking its picker labels — without it, titles for older
15
- // sessions fall on raw XML wrapper text.
16
- export function pickTitleText(text: string): string {
17
- if (!/^\s*<command-(?:name|message|args)>/.test(text)) return text;
18
- const m = text.match(/<command-args>([\s\S]*?)<\/command-args>/);
19
- return (m?.[1] ?? '').trim();
20
- }
1
+ // Matches text that is purely system-injected wrapper content — local command
2
+ // stdout/stderr replays, runtime system-reminder blocks, and caveat banners.
3
+ // Slash-command invocations (`<command-name>` / `<command-message>` /
4
+ // `<command-args>`) are user-driven and excluded — their `<command-args>` body
5
+ // carries the user's actual prompt.
6
+ export const SYSTEM_TAG_RE = /^\s*<(local-command|system-reminder|caveat)/i;
7
+
8
+ // Slash-command records carry the user's actual prompt (if any) inside
9
+ // <command-args>BODY</command-args>. Returns the trimmed args body when
10
+ // meaningful; returns '' when the record is just a metadata invocation
11
+ // (/clear, /model, /login with empty args, or legacy shapes that lack a
12
+ // <command-args> tag entirely) so callers can skip the message. Returns the
13
+ // input unchanged for non-slash-command text. `claude resume` applies the
14
+ // same skip when picking its picker labels — without it, titles for older
15
+ // sessions fall on raw XML wrapper text.
16
+ export function pickTitleText(text: string): string {
17
+ if (!/^\s*<command-(?:name|message|args)>/.test(text)) return text;
18
+ const m = text.match(/<command-args>([\s\S]*?)<\/command-args>/);
19
+ return (m?.[1] ?? '').trim();
20
+ }
@@ -0,0 +1,67 @@
1
+ import { spawn } from 'node:child_process';
2
+ import type { VersionUpdateResult } from '../../shared/types.ts';
3
+ import { getCurrentVersion, PACKAGE_NAME } from './version.ts';
4
+
5
+ const MAX_OUTPUT = 64_000;
6
+
7
+ /**
8
+ * Run `npm install -g <PACKAGE_NAME>@latest` to self-update the global CLI.
9
+ *
10
+ * Fixed args, no user input → safe. Windows needs `shell:true` to invoke `npm.cmd`
11
+ * (Node refuses to spawn `.cmd` without a shell since CVE-2024-27980). A non-zero
12
+ * exit still resolves (ok:false) so the caller can surface npm's output instead of
13
+ * swallowing it. The running process keeps serving the OLD code until restarted.
14
+ */
15
+ export function runSelfUpdate(targetVersion: string | null): Promise<VersionUpdateResult> {
16
+ const fromVersion = getCurrentVersion();
17
+ const isWin = process.platform === 'win32';
18
+
19
+ return new Promise((resolve) => {
20
+ let output = '';
21
+ const append = (buf: Buffer) => {
22
+ output += buf.toString();
23
+ if (output.length > MAX_OUTPUT) output = output.slice(-MAX_OUTPUT);
24
+ };
25
+
26
+ let child: ReturnType<typeof spawn>;
27
+ try {
28
+ child = spawn('npm', ['install', '-g', `${PACKAGE_NAME}@latest`], {
29
+ shell: isWin,
30
+ windowsHide: true,
31
+ });
32
+ } catch (err) {
33
+ resolve({
34
+ ok: false,
35
+ fromVersion,
36
+ toVersion: null,
37
+ output: (err as Error).message,
38
+ restartRequired: false,
39
+ });
40
+ return;
41
+ }
42
+
43
+ child.stdout?.on('data', append);
44
+ child.stderr?.on('data', append);
45
+
46
+ child.on('error', (err) => {
47
+ resolve({
48
+ ok: false,
49
+ fromVersion,
50
+ toVersion: null,
51
+ output: `${output}\n${err.message}`.trim(),
52
+ restartRequired: false,
53
+ });
54
+ });
55
+
56
+ child.on('close', (code) => {
57
+ const ok = code === 0;
58
+ resolve({
59
+ ok,
60
+ fromVersion,
61
+ toVersion: ok ? targetVersion : null,
62
+ output: output.trim() || (ok ? 'updated' : `npm exited with code ${code}`),
63
+ restartRequired: ok,
64
+ });
65
+ });
66
+ });
67
+ }
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { compareSemver } from './version.ts';
3
+
4
+ // compareSemver 是 hasUpdate 判定的唯一依据:latest 比 current 新才提示更新。
5
+ // 这里钉死「更新/不更新/相等」三类边界,外加 `v` 前缀与 pre-release 排序。
6
+
7
+ describe('compareSemver', () => {
8
+ it('newer patch / minor / major → positive', () => {
9
+ expect(compareSemver('1.0.1', '1.0.0')).toBeGreaterThan(0);
10
+ expect(compareSemver('1.1.0', '1.0.9')).toBeGreaterThan(0);
11
+ expect(compareSemver('2.0.0', '1.9.9')).toBeGreaterThan(0);
12
+ });
13
+
14
+ it('older → negative', () => {
15
+ expect(compareSemver('1.0.0', '1.0.1')).toBeLessThan(0);
16
+ expect(compareSemver('1.0.9', '1.1.0')).toBeLessThan(0);
17
+ });
18
+
19
+ it('equal → 0, regardless of leading v', () => {
20
+ expect(compareSemver('1.2.3', '1.2.3')).toBe(0);
21
+ expect(compareSemver('v1.2.3', '1.2.3')).toBe(0);
22
+ expect(compareSemver('1.2.3', 'v1.2.3')).toBe(0);
23
+ });
24
+
25
+ it('plain release outranks a pre-release of the same core', () => {
26
+ expect(compareSemver('1.2.0', '1.2.0-rc.1')).toBeGreaterThan(0);
27
+ expect(compareSemver('1.2.0-rc.1', '1.2.0')).toBeLessThan(0);
28
+ });
29
+
30
+ it('does not flag an update for the same version (the v1.0.0 baseline)', () => {
31
+ // current == latest must yield hasUpdate=false in the route.
32
+ expect(compareSemver('1.0.0', '1.0.0') > 0).toBe(false);
33
+ });
34
+
35
+ it('treats missing patch as 0 (1.2 == 1.2.0)', () => {
36
+ expect(compareSemver('1.2', '1.2.0')).toBe(0);
37
+ expect(compareSemver('1.3', '1.2.0')).toBeGreaterThan(0);
38
+ });
39
+ });
@@ -0,0 +1,117 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import type { VersionInfo } from '../../shared/types.ts';
5
+
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
+ const PKG_PATH = path.resolve(__dirname, '..', '..', 'package.json');
8
+
9
+ const REPO = 'zzusp/claude-code-session';
10
+ export const REPOSITORY_URL = `https://github.com/${REPO}`;
11
+ export const PACKAGE_NAME = '@zzusp/ccsm';
12
+ const RELEASES_API = `https://api.github.com/repos/${REPO}/releases/latest`;
13
+
14
+ const CACHE_TTL_MS = 60 * 60 * 1000; // 1h — don't hammer GitHub on every page open
15
+ const FETCH_TIMEOUT_MS = 8000;
16
+
17
+ let cached: { at: number; info: VersionInfo } | null = null;
18
+
19
+ /** package.json `version` — the single source of truth, same as `ccsm --version`. */
20
+ export function getCurrentVersion(): string {
21
+ try {
22
+ const pkg = JSON.parse(fs.readFileSync(PKG_PATH, 'utf8')) as { version?: string };
23
+ return typeof pkg.version === 'string' ? pkg.version : '0.0.0';
24
+ } catch {
25
+ return '0.0.0';
26
+ }
27
+ }
28
+
29
+ interface ParsedVersion {
30
+ nums: [number, number, number];
31
+ /** Pre-release tag (everything after the first `-`); '' for a plain release. */
32
+ pre: string;
33
+ }
34
+
35
+ function parseVersion(v: string): ParsedVersion {
36
+ const clean = v.trim().replace(/^v/i, '');
37
+ const dash = clean.indexOf('-');
38
+ const core = dash < 0 ? clean : clean.slice(0, dash);
39
+ const pre = dash < 0 ? '' : clean.slice(dash + 1);
40
+ const parts = core.split('.').map((n) => parseInt(n, 10) || 0);
41
+ return { nums: [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0], pre };
42
+ }
43
+
44
+ /** Minimal semver compare: returns >0 if a is newer than b, <0 if older, 0 if equal. */
45
+ export function compareSemver(a: string, b: string): number {
46
+ const pa = parseVersion(a);
47
+ const pb = parseVersion(b);
48
+ const [a0, a1, a2] = pa.nums;
49
+ const [b0, b1, b2] = pb.nums;
50
+ if (a0 !== b0) return a0 - b0;
51
+ if (a1 !== b1) return a1 - b1;
52
+ if (a2 !== b2) return a2 - b2;
53
+ if (pa.pre === pb.pre) return 0;
54
+ // A plain release outranks any pre-release of the same core (1.2.0 > 1.2.0-rc.1).
55
+ if (pa.pre === '') return 1;
56
+ if (pb.pre === '') return -1;
57
+ return pa.pre < pb.pre ? -1 : 1;
58
+ }
59
+
60
+ interface GithubRelease {
61
+ tag_name?: string;
62
+ name?: string;
63
+ body?: string;
64
+ html_url?: string;
65
+ published_at?: string;
66
+ }
67
+
68
+ /**
69
+ * VersionInfo for the UI. Cached for an hour; pass `force` to bypass.
70
+ * A failed lookup is never cached — it degrades to current-version-only with
71
+ * `checkError` set, so a later open retries.
72
+ */
73
+ export async function getVersionInfo(force = false): Promise<VersionInfo> {
74
+ const current = getCurrentVersion();
75
+ if (!force && cached && Date.now() - cached.at < CACHE_TTL_MS) {
76
+ return { ...cached.info, current };
77
+ }
78
+
79
+ const info: VersionInfo = {
80
+ current,
81
+ latest: null,
82
+ hasUpdate: false,
83
+ releaseName: null,
84
+ releaseNotes: null,
85
+ releaseUrl: null,
86
+ publishedAt: null,
87
+ repositoryUrl: REPOSITORY_URL,
88
+ checkError: null,
89
+ };
90
+
91
+ try {
92
+ const res = await fetch(RELEASES_API, {
93
+ headers: {
94
+ accept: 'application/vnd.github+json',
95
+ 'user-agent': `ccsm/${current}`,
96
+ },
97
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
98
+ });
99
+ if (!res.ok) {
100
+ info.checkError = `GitHub API ${res.status}`;
101
+ return info;
102
+ }
103
+ const data = (await res.json()) as GithubRelease;
104
+ const latest = (data.tag_name ?? '').trim().replace(/^v/i, '') || null;
105
+ info.latest = latest;
106
+ info.releaseName = data.name?.trim() || data.tag_name || null;
107
+ info.releaseNotes = data.body ?? null;
108
+ info.releaseUrl = data.html_url ?? null;
109
+ info.publishedAt = data.published_at ?? null;
110
+ info.hasUpdate = latest !== null && compareSemver(latest, current) > 0;
111
+ cached = { at: Date.now(), info };
112
+ return info;
113
+ } catch (err) {
114
+ info.checkError = (err as Error).message || 'version check failed';
115
+ return info;
116
+ }
117
+ }
@@ -0,0 +1,54 @@
1
+ import { Hono } from 'hono';
2
+ import {
3
+ computeCleanupSuggestions,
4
+ deleteOrphan,
5
+ } from '../lib/cleanup-suggestions.ts';
6
+ import { isSafeId } from '../lib/safe-id.ts';
7
+ import type { DiskOrphanDeleteResult, DiskOrphanKind } from '../types.ts';
8
+
9
+ export const diskCleanupRoute = new Hono();
10
+
11
+ diskCleanupRoute.get('/suggestions', async (c) => {
12
+ const data = await computeCleanupSuggestions();
13
+ return c.json(data);
14
+ });
15
+
16
+ diskCleanupRoute.delete('/orphan/:kind/:sid', async (c) => {
17
+ if (!isAcceptableOrigin(c.req.header('origin'))) {
18
+ return c.json({ error: 'origin not allowed' }, 403);
19
+ }
20
+ const kindParam = c.req.param('kind');
21
+ const sid = c.req.param('sid');
22
+ if (!isOrphanKind(kindParam)) {
23
+ return c.json({ error: 'invalid kind' }, 400);
24
+ }
25
+ if (!isSafeId(sid)) {
26
+ return c.json({ error: 'invalid id' }, 400);
27
+ }
28
+ const result = deleteOrphan(kindParam, sid);
29
+ if (!result.ok) {
30
+ const status = result.reason === 'orphan no longer exists' ? 404 : 409;
31
+ return c.json({ error: result.reason }, status);
32
+ }
33
+ const payload: DiskOrphanDeleteResult = {
34
+ sessionId: sid,
35
+ kind: kindParam,
36
+ freedBytes: result.freedBytes,
37
+ };
38
+ return c.json(payload);
39
+ });
40
+
41
+ function isOrphanKind(v: string): v is DiskOrphanKind {
42
+ return v === 'file-history' || v === 'session-env';
43
+ }
44
+
45
+ function isAcceptableOrigin(origin: string | undefined): boolean {
46
+ if (!origin) return false;
47
+ try {
48
+ const url = new URL(origin);
49
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
50
+ return url.hostname === 'localhost' || url.hostname === '127.0.0.1';
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
@@ -1,9 +1,9 @@
1
- import { Hono } from 'hono';
2
- import { computeDiskUsage } from '../lib/disk-usage.ts';
3
-
4
- export const diskRoute = new Hono();
5
-
6
- diskRoute.get('/', async (c) => {
7
- const usage = await computeDiskUsage();
8
- return c.json(usage);
9
- });
1
+ import { Hono } from 'hono';
2
+ import { computeDiskUsage } from '../lib/disk-usage.ts';
3
+
4
+ export const diskRoute = new Hono();
5
+
6
+ diskRoute.get('/', async (c) => {
7
+ const usage = await computeDiskUsage();
8
+ return c.json(usage);
9
+ });
@@ -1,87 +1,87 @@
1
- import { Hono } from 'hono';
2
- import { commitImport, ImportError, previewImport } from '../lib/import-bundle.ts';
3
- import type { ImportCollisionPolicy } from '../types.ts';
4
-
5
- export const importRoute = new Hono();
6
-
7
- const POLICIES: ReadonlySet<ImportCollisionPolicy> = new Set([
8
- 'skip',
9
- 'overwrite-if-newer',
10
- 'keep-both',
11
- ]);
12
-
13
- importRoute.post('/preview', async (c) => {
14
- if (!isAcceptableOrigin(c.req.header('origin'))) {
15
- return c.json({ error: 'origin not allowed' }, 403);
16
- }
17
- let body: { bundleDir?: unknown; targetCwd?: unknown; collisionPolicy?: unknown };
18
- try {
19
- body = await c.req.json();
20
- } catch {
21
- return c.json({ error: 'invalid JSON body' }, 400);
22
- }
23
- if (typeof body.bundleDir !== 'string' || body.bundleDir.trim() === '') {
24
- return c.json({ error: 'bundleDir is required' }, 400);
25
- }
26
- const targetCwd =
27
- typeof body.targetCwd === 'string' && body.targetCwd.trim() !== '' ? body.targetCwd : undefined;
28
-
29
- try {
30
- const result = await previewImport({
31
- bundleDir: body.bundleDir,
32
- targetCwd,
33
- collisionPolicy: normalizePolicy(body.collisionPolicy),
34
- });
35
- return c.json(result);
36
- } catch (err) {
37
- if (err instanceof ImportError) return c.json({ error: err.message }, 400);
38
- throw err;
39
- }
40
- });
41
-
42
- importRoute.post('/', async (c) => {
43
- if (!isAcceptableOrigin(c.req.header('origin'))) {
44
- return c.json({ error: 'origin not allowed' }, 403);
45
- }
46
- let body: { bundleDir?: unknown; targetCwd?: unknown; collisionPolicy?: unknown };
47
- try {
48
- body = await c.req.json();
49
- } catch {
50
- return c.json({ error: 'invalid JSON body' }, 400);
51
- }
52
- if (typeof body.bundleDir !== 'string' || body.bundleDir.trim() === '') {
53
- return c.json({ error: 'bundleDir is required' }, 400);
54
- }
55
- if (typeof body.targetCwd !== 'string' || body.targetCwd.trim() === '') {
56
- return c.json({ error: 'targetCwd is required' }, 400);
57
- }
58
-
59
- try {
60
- const result = await commitImport({
61
- bundleDir: body.bundleDir,
62
- targetCwd: body.targetCwd,
63
- collisionPolicy: normalizePolicy(body.collisionPolicy),
64
- });
65
- return c.json(result);
66
- } catch (err) {
67
- if (err instanceof ImportError) return c.json({ error: err.message }, 400);
68
- throw err;
69
- }
70
- });
71
-
72
- function normalizePolicy(raw: unknown): ImportCollisionPolicy {
73
- return typeof raw === 'string' && POLICIES.has(raw as ImportCollisionPolicy)
74
- ? (raw as ImportCollisionPolicy)
75
- : 'skip';
76
- }
77
-
78
- function isAcceptableOrigin(origin: string | undefined): boolean {
79
- if (!origin) return false;
80
- try {
81
- const url = new URL(origin);
82
- if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
83
- return url.hostname === 'localhost' || url.hostname === '127.0.0.1';
84
- } catch {
85
- return false;
86
- }
87
- }
1
+ import { Hono } from 'hono';
2
+ import { commitImport, ImportError, previewImport } from '../lib/import-bundle.ts';
3
+ import type { ImportCollisionPolicy } from '../types.ts';
4
+
5
+ export const importRoute = new Hono();
6
+
7
+ const POLICIES: ReadonlySet<ImportCollisionPolicy> = new Set([
8
+ 'skip',
9
+ 'overwrite-if-newer',
10
+ 'keep-both',
11
+ ]);
12
+
13
+ importRoute.post('/preview', async (c) => {
14
+ if (!isAcceptableOrigin(c.req.header('origin'))) {
15
+ return c.json({ error: 'origin not allowed' }, 403);
16
+ }
17
+ let body: { bundleDir?: unknown; targetCwd?: unknown; collisionPolicy?: unknown };
18
+ try {
19
+ body = await c.req.json();
20
+ } catch {
21
+ return c.json({ error: 'invalid JSON body' }, 400);
22
+ }
23
+ if (typeof body.bundleDir !== 'string' || body.bundleDir.trim() === '') {
24
+ return c.json({ error: 'bundleDir is required' }, 400);
25
+ }
26
+ const targetCwd =
27
+ typeof body.targetCwd === 'string' && body.targetCwd.trim() !== '' ? body.targetCwd : undefined;
28
+
29
+ try {
30
+ const result = await previewImport({
31
+ bundleDir: body.bundleDir,
32
+ targetCwd,
33
+ collisionPolicy: normalizePolicy(body.collisionPolicy),
34
+ });
35
+ return c.json(result);
36
+ } catch (err) {
37
+ if (err instanceof ImportError) return c.json({ error: err.message }, 400);
38
+ throw err;
39
+ }
40
+ });
41
+
42
+ importRoute.post('/', async (c) => {
43
+ if (!isAcceptableOrigin(c.req.header('origin'))) {
44
+ return c.json({ error: 'origin not allowed' }, 403);
45
+ }
46
+ let body: { bundleDir?: unknown; targetCwd?: unknown; collisionPolicy?: unknown };
47
+ try {
48
+ body = await c.req.json();
49
+ } catch {
50
+ return c.json({ error: 'invalid JSON body' }, 400);
51
+ }
52
+ if (typeof body.bundleDir !== 'string' || body.bundleDir.trim() === '') {
53
+ return c.json({ error: 'bundleDir is required' }, 400);
54
+ }
55
+ if (typeof body.targetCwd !== 'string' || body.targetCwd.trim() === '') {
56
+ return c.json({ error: 'targetCwd is required' }, 400);
57
+ }
58
+
59
+ try {
60
+ const result = await commitImport({
61
+ bundleDir: body.bundleDir,
62
+ targetCwd: body.targetCwd,
63
+ collisionPolicy: normalizePolicy(body.collisionPolicy),
64
+ });
65
+ return c.json(result);
66
+ } catch (err) {
67
+ if (err instanceof ImportError) return c.json({ error: err.message }, 400);
68
+ throw err;
69
+ }
70
+ });
71
+
72
+ function normalizePolicy(raw: unknown): ImportCollisionPolicy {
73
+ return typeof raw === 'string' && POLICIES.has(raw as ImportCollisionPolicy)
74
+ ? (raw as ImportCollisionPolicy)
75
+ : 'skip';
76
+ }
77
+
78
+ function isAcceptableOrigin(origin: string | undefined): boolean {
79
+ if (!origin) return false;
80
+ try {
81
+ const url = new URL(origin);
82
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
83
+ return url.hostname === 'localhost' || url.hostname === '127.0.0.1';
84
+ } catch {
85
+ return false;
86
+ }
87
+ }