@bakapiano/ccsm 0.14.0 → 0.15.0

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 (52) hide show
  1. package/CLAUDE.md +474 -475
  2. package/README.md +189 -190
  3. package/bin/ccsm.js +194 -194
  4. package/lib/cliActivity.js +118 -0
  5. package/lib/codexSeed.js +147 -0
  6. package/lib/config.js +211 -188
  7. package/lib/folders.js +105 -105
  8. package/lib/localCliSessions.js +489 -489
  9. package/lib/persistedSessions.js +144 -142
  10. package/lib/webTerminal.js +224 -224
  11. package/lib/workspace.js +230 -230
  12. package/package.json +57 -57
  13. package/public/css/base.css +99 -99
  14. package/public/css/cards.css +183 -183
  15. package/public/css/feedback.css +303 -303
  16. package/public/css/forms.css +405 -405
  17. package/public/css/layout.css +160 -160
  18. package/public/css/modal.css +190 -190
  19. package/public/css/responsive.css +10 -10
  20. package/public/css/sidebar.css +613 -608
  21. package/public/css/terminals.css +294 -294
  22. package/public/css/tokens.css +81 -81
  23. package/public/css/wco.css +98 -98
  24. package/public/css/widgets.css +1628 -1628
  25. package/public/index.html +111 -105
  26. package/public/js/api.js +296 -280
  27. package/public/js/components/AdoptModal.js +343 -343
  28. package/public/js/components/App.js +35 -35
  29. package/public/js/components/DirectoryPicker.js +203 -203
  30. package/public/js/components/EntityFormModal.js +141 -141
  31. package/public/js/components/Modal.js +51 -51
  32. package/public/js/components/OfflineBanner.js +93 -93
  33. package/public/js/components/PageTitleBar.js +13 -13
  34. package/public/js/components/Picker.js +179 -179
  35. package/public/js/components/Popover.js +55 -55
  36. package/public/js/components/Sidebar.js +299 -299
  37. package/public/js/components/TerminalView.js +314 -314
  38. package/public/js/components/useDragSort.js +67 -67
  39. package/public/js/dialog.js +67 -67
  40. package/public/js/icons.js +177 -177
  41. package/public/js/main.js +132 -132
  42. package/public/js/pages/AboutPage.js +165 -165
  43. package/public/js/pages/ConfigurePage.js +505 -475
  44. package/public/js/pages/LaunchPage.js +369 -369
  45. package/public/js/pages/SessionsPage.js +101 -97
  46. package/public/js/state.js +231 -231
  47. package/scripts/dev.js +44 -11
  48. package/scripts/install.js +158 -158
  49. package/scripts/restart-helper.js +91 -0
  50. package/server.js +1278 -1254
  51. package/lib/cliSessionWatcher.js +0 -275
  52. package/public/manifest.webmanifest +0 -15
package/lib/config.js CHANGED
@@ -1,188 +1,211 @@
1
- 'use strict';
2
-
3
- const fs = require('node:fs/promises');
4
- const fsSync = require('node:fs');
5
- const path = require('node:path');
6
- const os = require('node:os');
7
- const { atomicWriteJson, withFileLock } = require('./atomicJson');
8
-
9
- // Data dir lives under ~/.ccsm by default so config survives across upgrades
10
- // (incl. running from a new npx checkout). Override with CCSM_HOME if you
11
- // want a different location.
12
- const DATA_DIR = process.env.CCSM_HOME || path.join(os.homedir(), '.ccsm');
13
- const CONFIG_PATH = path.join(DATA_DIR, 'config.json');
14
-
15
- const LEGACY_DATA_DIR = path.join(__dirname, '..', 'data');
16
-
17
- // v1.0 — wt / system-terminal launch path removed. Sessions are always
18
- // in-page web terminals managed by ccsm. CLI is pluggable: configure one
19
- // or more entries under `clis` (claude, codex, custom wrappers), pick a
20
- // default. Old config keys (`terminal`, `commandShell`, `claudeCommand`,
21
- // `defaultTerminalMode`, `autoFocusOnLaunch`, `focusMovesToCenter`,
22
- // `snapshot*`) are silently dropped on load.
23
- const DEFAULT_CLIS = [
24
- {
25
- id: 'claude',
26
- name: 'Claude Code',
27
- command: 'claude',
28
- args: [],
29
- resumeArgs: ['--continue'],
30
- resumeIdArgs: ['--resume', '<id>'],
31
- shell: 'direct',
32
- type: 'claude',
33
- builtin: true,
34
- },
35
- {
36
- id: 'codex',
37
- name: 'OpenAI Codex',
38
- command: 'codex',
39
- args: [],
40
- resumeArgs: ['resume', '--last'],
41
- resumeIdArgs: ['resume', '<id>'],
42
- shell: 'direct',
43
- type: 'codex',
44
- builtin: true,
45
- },
46
- {
47
- id: 'copilot',
48
- name: 'GitHub Copilot',
49
- command: 'copilot',
50
- args: [],
51
- resumeArgs: ['--continue'],
52
- resumeIdArgs: ['--resume', '<id>'],
53
- shell: 'direct',
54
- type: 'copilot',
55
- builtin: true,
56
- },
57
- ];
58
-
59
- const DEFAULTS = {
60
- port: 7777,
61
- workDir: path.join(os.homedir(), 'ccsm-workspaces'),
62
- // Repos available for cloning into a fresh workspace at launch time.
63
- // { name: 'foo', url: 'https://github.com/me/foo.git', defaultSelected: true }
64
- repos: [],
65
- // Pluggable CLIs. Add wrappers like `ccp` (gc2cc) or self-hosted
66
- // proxies by appending an entry. defaultCliId picks one for the
67
- // Launch button when the user doesn't override.
68
- clis: DEFAULT_CLIS,
69
- defaultCliId: 'claude',
70
- };
71
-
72
- function ensureDataDir() {
73
- if (!fsSync.existsSync(DATA_DIR)) {
74
- fsSync.mkdirSync(DATA_DIR, { recursive: true });
75
- }
76
- }
77
-
78
- // If we find a legacy <repo>/data dir from before the home-dir move AND
79
- // no ~/.ccsm yet, copy across. Idempotent — only fires when DATA_DIR is
80
- // empty so existing users with both dirs aren't clobbered.
81
- function migrateLegacyDataIfNeeded() {
82
- if (!fsSync.existsSync(LEGACY_DATA_DIR)) return;
83
- if (LEGACY_DATA_DIR === DATA_DIR) return;
84
- ensureDataDir();
85
- const dataEmpty = fsSync.readdirSync(DATA_DIR).length === 0;
86
- if (!dataEmpty) return;
87
- try {
88
- fsSync.cpSync(LEGACY_DATA_DIR, DATA_DIR, { recursive: true });
89
- console.log(`[ccsm] migrated legacy data: ${LEGACY_DATA_DIR} → ${DATA_DIR}`);
90
- } catch (e) {
91
- console.error('[ccsm] legacy migration failed:', e.message);
92
- }
93
- }
94
-
95
- migrateLegacyDataIfNeeded();
96
-
97
- // Strip dropped v0.x keys + clamp shape of survivors. Returns a fresh
98
- // object so callers don't mutate DEFAULTS.
99
- function mergeWithDefaults(partial) {
100
- const out = { ...DEFAULTS, ...partial };
101
- // Drop v0.x keys that the new architecture doesn't use.
102
- delete out.terminal;
103
- delete out.commandShell;
104
- delete out.claudeCommand;
105
- delete out.defaultTerminalMode;
106
- delete out.autoFocusOnLaunch;
107
- delete out.focusMovesToCenter;
108
- delete out.snapshotIntervalMs;
109
- delete out.snapshotHistoryKeep;
110
- delete out.autoOpenBrowser;
111
- delete out.browserMode;
112
- delete out.finderPrompt;
113
-
114
- if (!Array.isArray(out.repos)) out.repos = DEFAULTS.repos;
115
- if (!Array.isArray(out.clis)) out.clis = [];
116
- // Always inject builtin CLIs (claude, codex) if they're missing or were
117
- // deleted from a saved config — they're managed by ccsm, the user can
118
- // tweak args/shell but can't remove them. Preserves any user
119
- // customisation on existing builtin entries.
120
- for (const def of DEFAULT_CLIS) {
121
- const existing = out.clis.find((c) => c.id === def.id);
122
- if (existing) {
123
- existing.builtin = true;
124
- // Backfill defaults from the built-in template for any field the
125
- // user's saved copy is missingkeeps old configs aligned with new
126
- // schema additions (resumeArgs, type, etc.) without clobbering the
127
- // user's customisations.
128
- if (existing.resumeArgs == null) existing.resumeArgs = def.resumeArgs;
129
- if (existing.resumeIdArgs == null) existing.resumeIdArgs = def.resumeIdArgs;
130
- if (!existing.type) existing.type = def.type;
131
- } else {
132
- out.clis.unshift({ ...def });
133
- }
134
- }
135
- // Normalize per-CLI fields.
136
- out.clis = out.clis.map((c) => {
137
- const { installed, installPath, ...rest } = c; // strip computed probe fields
138
- return {
139
- ...rest,
140
- args: Array.isArray(rest.args) ? rest.args : [],
141
- resumeArgs: Array.isArray(rest.resumeArgs) ? rest.resumeArgs : [],
142
- resumeIdArgs: Array.isArray(rest.resumeIdArgs) ? rest.resumeIdArgs : [],
143
- shell: ['direct', 'pwsh', 'cmd'].includes(rest.shell) ? rest.shell : 'direct',
144
- type: ['claude', 'codex', 'copilot', 'other'].includes(rest.type) ? rest.type : 'other',
145
- builtin: !!rest.builtin,
146
- };
147
- });
148
- // Make sure defaultCliId points at an actual CLI; fall back to first.
149
- if (!out.clis.find((c) => c.id === out.defaultCliId)) {
150
- out.defaultCliId = out.clis[0].id;
151
- }
152
- return out;
153
- }
154
-
155
- async function loadConfig() {
156
- ensureDataDir();
157
- try {
158
- const raw = await fs.readFile(CONFIG_PATH, 'utf8');
159
- return mergeWithDefaults(JSON.parse(raw));
160
- } catch (e) {
161
- if (e.code === 'ENOENT') {
162
- const cfg = { ...DEFAULTS };
163
- await atomicWriteJson(CONFIG_PATH, cfg);
164
- return cfg;
165
- }
166
- throw e;
167
- }
168
- }
169
-
170
- async function saveConfig(partial) {
171
- ensureDataDir();
172
- return withFileLock(CONFIG_PATH, async () => {
173
- const current = await loadConfig();
174
- const next = mergeWithDefaults({ ...current, ...partial });
175
- await atomicWriteJson(CONFIG_PATH, next);
176
- return next;
177
- });
178
- }
179
-
180
- module.exports = {
181
- loadConfig,
182
- saveConfig,
183
- DATA_DIR,
184
- CONFIG_PATH,
185
- LEGACY_DATA_DIR,
186
- DEFAULTS,
187
- DEFAULT_CLIS,
188
- };
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs/promises');
4
+ const fsSync = require('node:fs');
5
+ const path = require('node:path');
6
+ const os = require('node:os');
7
+ const { atomicWriteJson, withFileLock } = require('./atomicJson');
8
+
9
+ // Data dir lives under ~/.ccsm by default so config survives across upgrades
10
+ // (incl. running from a new npx checkout). Override with CCSM_HOME if you
11
+ // want a different location.
12
+ const DATA_DIR = process.env.CCSM_HOME || path.join(os.homedir(), '.ccsm');
13
+ const CONFIG_PATH = path.join(DATA_DIR, 'config.json');
14
+
15
+ const LEGACY_DATA_DIR = path.join(__dirname, '..', 'data');
16
+
17
+ // v1.0 — wt / system-terminal launch path removed. Sessions are always
18
+ // in-page web terminals managed by ccsm. CLI is pluggable: configure one
19
+ // or more entries under `clis` (claude, codex, custom wrappers), pick a
20
+ // default. Old config keys (`terminal`, `commandShell`, `claudeCommand`,
21
+ // `defaultTerminalMode`, `autoFocusOnLaunch`, `focusMovesToCenter`,
22
+ // `snapshot*`) are silently dropped on load.
23
+ const DEFAULT_CLIS = [
24
+ {
25
+ id: 'claude',
26
+ name: 'Claude Code',
27
+ command: 'claude',
28
+ args: [],
29
+ // Pre-assign the upstream session UUID when starting a NEW session.
30
+ // ccsm picks the UUID, passes it via these args, and stores it on
31
+ // the record immediately — no need to poll the transcript dir later.
32
+ newSessionIdArgs: ['--session-id', '<id>'],
33
+ resumeIdArgs: ['--resume', '<id>'],
34
+ shell: 'direct',
35
+ type: 'claude',
36
+ builtin: true,
37
+ },
38
+ {
39
+ id: 'codex',
40
+ name: 'OpenAI Codex',
41
+ command: 'codex',
42
+ args: [],
43
+ // codex has no "use this UUID for a new session" flag, but we work
44
+ // around it: ccsm seeds a fake rollout file at the right path with
45
+ // our chosen UUID (lib/codexSeed.js), then spawns `codex resume <id>`
46
+ // so the first launch *is* a resume against our seed. From then on
47
+ // codex appends to the same file and resume-by-id keeps working.
48
+ newSessionIdArgs: ['resume', '<id>'],
49
+ resumeIdArgs: ['resume', '<id>'],
50
+ shell: 'direct',
51
+ type: 'codex',
52
+ builtin: true,
53
+ },
54
+ {
55
+ id: 'copilot',
56
+ name: 'GitHub Copilot',
57
+ command: 'copilot',
58
+ args: [],
59
+ newSessionIdArgs: ['--session-id', '<id>'],
60
+ resumeIdArgs: ['--resume', '<id>'],
61
+ shell: 'direct',
62
+ type: 'copilot',
63
+ builtin: true,
64
+ },
65
+ ];
66
+
67
+ const DEFAULTS = {
68
+ port: 7777,
69
+ workDir: path.join(os.homedir(), 'ccsm-workspaces'),
70
+ // Repos available for cloning into a fresh workspace at launch time.
71
+ // { name: 'foo', url: 'https://github.com/me/foo.git', defaultSelected: true }
72
+ repos: [],
73
+ // Pluggable CLIs. Add wrappers like `ccp` (gc2cc) or self-hosted
74
+ // proxies by appending an entry. defaultCliId picks one for the
75
+ // Launch button when the user doesn't override.
76
+ clis: DEFAULT_CLIS,
77
+ defaultCliId: 'claude',
78
+ };
79
+
80
+ function ensureDataDir() {
81
+ if (!fsSync.existsSync(DATA_DIR)) {
82
+ fsSync.mkdirSync(DATA_DIR, { recursive: true });
83
+ }
84
+ }
85
+
86
+ // If we find a legacy <repo>/data dir from before the home-dir move AND
87
+ // no ~/.ccsm yet, copy across. Idempotent — only fires when DATA_DIR is
88
+ // empty so existing users with both dirs aren't clobbered.
89
+ function migrateLegacyDataIfNeeded() {
90
+ if (!fsSync.existsSync(LEGACY_DATA_DIR)) return;
91
+ if (LEGACY_DATA_DIR === DATA_DIR) return;
92
+ ensureDataDir();
93
+ const dataEmpty = fsSync.readdirSync(DATA_DIR).length === 0;
94
+ if (!dataEmpty) return;
95
+ try {
96
+ fsSync.cpSync(LEGACY_DATA_DIR, DATA_DIR, { recursive: true });
97
+ console.log(`[ccsm] migrated legacy data: ${LEGACY_DATA_DIR} ${DATA_DIR}`);
98
+ } catch (e) {
99
+ console.error('[ccsm] legacy migration failed:', e.message);
100
+ }
101
+ }
102
+
103
+ migrateLegacyDataIfNeeded();
104
+
105
+ // Strip dropped v0.x keys + clamp shape of survivors. Returns a fresh
106
+ // object so callers don't mutate DEFAULTS.
107
+ function mergeWithDefaults(partial) {
108
+ const out = { ...DEFAULTS, ...partial };
109
+ // Drop v0.x keys that the new architecture doesn't use.
110
+ delete out.terminal;
111
+ delete out.commandShell;
112
+ delete out.claudeCommand;
113
+ delete out.defaultTerminalMode;
114
+ delete out.autoFocusOnLaunch;
115
+ delete out.focusMovesToCenter;
116
+ delete out.snapshotIntervalMs;
117
+ delete out.snapshotHistoryKeep;
118
+ delete out.autoOpenBrowser;
119
+ delete out.browserMode;
120
+ delete out.finderPrompt;
121
+
122
+ if (!Array.isArray(out.repos)) out.repos = DEFAULTS.repos;
123
+ if (!Array.isArray(out.clis)) out.clis = [];
124
+ // Always inject builtin CLIs (claude, codex) if they're missing or were
125
+ // deleted from a saved configthey're managed by ccsm, the user can
126
+ // tweak args/shell but can't remove them. Preserves any user
127
+ // customisation on existing builtin entries.
128
+ for (const def of DEFAULT_CLIS) {
129
+ const existing = out.clis.find((c) => c.id === def.id);
130
+ if (existing) {
131
+ existing.builtin = true;
132
+ // Backfill defaults from the built-in template for any field the
133
+ // user's saved copy is missing — keeps old configs aligned with new
134
+ // schema additions (resumeIdArgs, type, etc.) without clobbering
135
+ // the user's customisations.
136
+ if (existing.resumeIdArgs == null) existing.resumeIdArgs = def.resumeIdArgs;
137
+ if (existing.newSessionIdArgs == null) existing.newSessionIdArgs = def.newSessionIdArgs;
138
+ // Drop the v0.x `resumeArgs` fallback — all builtins now have
139
+ // pre-assigned UUIDs (claude/copilot via flag, codex via seed) so
140
+ // resumeIdArgs always applies on resume.
141
+ delete existing.resumeArgs;
142
+ // Special-case codex: an unreleased earlier iteration of this
143
+ // schema shipped `newSessionIdArgs: []` for codex. The seeded-file
144
+ // trick (lib/codexSeed) now lets us pre-assign, so backfill the
145
+ // ['resume','<id>'] template over an empty array too.
146
+ if (existing.id === 'codex'
147
+ && Array.isArray(existing.newSessionIdArgs)
148
+ && existing.newSessionIdArgs.length === 0
149
+ && Array.isArray(def.newSessionIdArgs)
150
+ && def.newSessionIdArgs.length > 0) {
151
+ existing.newSessionIdArgs = def.newSessionIdArgs;
152
+ }
153
+ if (!existing.type) existing.type = def.type;
154
+ } else {
155
+ out.clis.unshift({ ...def });
156
+ }
157
+ }
158
+ // Normalize per-CLI fields.
159
+ out.clis = out.clis.map((c) => {
160
+ const { installed, installPath, resumeArgs, ...rest } = c; // strip computed probe fields + v0.x resumeArgs
161
+ return {
162
+ ...rest,
163
+ args: Array.isArray(rest.args) ? rest.args : [],
164
+ resumeIdArgs: Array.isArray(rest.resumeIdArgs) ? rest.resumeIdArgs : [],
165
+ newSessionIdArgs: Array.isArray(rest.newSessionIdArgs) ? rest.newSessionIdArgs : [],
166
+ shell: ['direct', 'pwsh', 'cmd'].includes(rest.shell) ? rest.shell : 'direct',
167
+ type: ['claude', 'codex', 'copilot', 'other'].includes(rest.type) ? rest.type : 'other',
168
+ builtin: !!rest.builtin,
169
+ };
170
+ });
171
+ // Make sure defaultCliId points at an actual CLI; fall back to first.
172
+ if (!out.clis.find((c) => c.id === out.defaultCliId)) {
173
+ out.defaultCliId = out.clis[0].id;
174
+ }
175
+ return out;
176
+ }
177
+
178
+ async function loadConfig() {
179
+ ensureDataDir();
180
+ try {
181
+ const raw = await fs.readFile(CONFIG_PATH, 'utf8');
182
+ return mergeWithDefaults(JSON.parse(raw));
183
+ } catch (e) {
184
+ if (e.code === 'ENOENT') {
185
+ const cfg = { ...DEFAULTS };
186
+ await atomicWriteJson(CONFIG_PATH, cfg);
187
+ return cfg;
188
+ }
189
+ throw e;
190
+ }
191
+ }
192
+
193
+ async function saveConfig(partial) {
194
+ ensureDataDir();
195
+ return withFileLock(CONFIG_PATH, async () => {
196
+ const current = await loadConfig();
197
+ const next = mergeWithDefaults({ ...current, ...partial });
198
+ await atomicWriteJson(CONFIG_PATH, next);
199
+ return next;
200
+ });
201
+ }
202
+
203
+ module.exports = {
204
+ loadConfig,
205
+ saveConfig,
206
+ DATA_DIR,
207
+ CONFIG_PATH,
208
+ LEGACY_DATA_DIR,
209
+ DEFAULTS,
210
+ DEFAULT_CLIS,
211
+ };
package/lib/folders.js CHANGED
@@ -1,105 +1,105 @@
1
- 'use strict';
2
-
3
- // User-curated folders. Sessions reference these by id. Order is
4
- // user-controlled (drag-reorder in sidebar). The store is a flat list
5
- // in $DATA_DIR/folders.json:
6
- // [{ id, name, order, createdAt }]
7
- //
8
- // Top-level "Unsorted" is implicit — sessions with folderId === null
9
- // render under it. The user can't delete or rename it; we just synthesise
10
- // the bucket in the frontend.
11
-
12
- const path = require('node:path');
13
- const fs = require('node:fs/promises');
14
- const { DATA_DIR } = require('./config');
15
- const { atomicWriteJson, withFileLock } = require('./atomicJson');
16
-
17
- const FILE = path.join(DATA_DIR, 'folders.json');
18
-
19
- async function loadAll() {
20
- try {
21
- const raw = await fs.readFile(FILE, 'utf8');
22
- const j = JSON.parse(raw);
23
- return Array.isArray(j) ? j : [];
24
- } catch (e) {
25
- if (e.code === 'ENOENT') return [];
26
- throw e;
27
- }
28
- }
29
-
30
- async function saveAll(list) {
31
- await atomicWriteJson(FILE, list);
32
- }
33
-
34
- function genId() {
35
- return 'folder-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
36
- }
37
-
38
- async function create({ name }) {
39
- if (!name || typeof name !== 'string') throw new Error('name required');
40
- return withFileLock(FILE, async () => {
41
- const list = await loadAll();
42
- const entry = {
43
- id: genId(),
44
- name: name.trim(),
45
- order: list.length,
46
- createdAt: Date.now(),
47
- };
48
- list.push(entry);
49
- await saveAll(list);
50
- return entry;
51
- });
52
- }
53
-
54
- async function update(id, patch) {
55
- return withFileLock(FILE, async () => {
56
- const list = await loadAll();
57
- const idx = list.findIndex((f) => f.id === id);
58
- if (idx < 0) return null;
59
- // Allow rename + reorder, ignore other keys.
60
- const allowed = {};
61
- if (typeof patch.name === 'string') allowed.name = patch.name.trim();
62
- if (typeof patch.order === 'number') allowed.order = patch.order;
63
- list[idx] = { ...list[idx], ...allowed };
64
- await saveAll(list);
65
- return list[idx];
66
- });
67
- }
68
-
69
- async function remove(id) {
70
- return withFileLock(FILE, async () => {
71
- const list = await loadAll();
72
- const idx = list.findIndex((f) => f.id === id);
73
- if (idx < 0) return false;
74
- list.splice(idx, 1);
75
- await saveAll(list);
76
- return true;
77
- });
78
- }
79
-
80
- async function reorder(idsInOrder) {
81
- if (!Array.isArray(idsInOrder)) throw new Error('idsInOrder must be array');
82
- return withFileLock(FILE, async () => {
83
- const list = await loadAll();
84
- const byId = new Map(list.map((f) => [f.id, f]));
85
- const next = [];
86
- idsInOrder.forEach((id, i) => {
87
- const f = byId.get(id);
88
- if (f) {
89
- f.order = i;
90
- next.push(f);
91
- byId.delete(id);
92
- }
93
- });
94
- // Append any folders not mentioned in the new order, preserving original
95
- // relative order. Prevents accidentally dropping folders.
96
- for (const f of byId.values()) {
97
- f.order = next.length;
98
- next.push(f);
99
- }
100
- await saveAll(next);
101
- return next;
102
- });
103
- }
104
-
105
- module.exports = { loadAll, create, update, remove, reorder, FILE };
1
+ 'use strict';
2
+
3
+ // User-curated folders. Sessions reference these by id. Order is
4
+ // user-controlled (drag-reorder in sidebar). The store is a flat list
5
+ // in $DATA_DIR/folders.json:
6
+ // [{ id, name, order, createdAt }]
7
+ //
8
+ // Top-level "Unsorted" is implicit — sessions with folderId === null
9
+ // render under it. The user can't delete or rename it; we just synthesise
10
+ // the bucket in the frontend.
11
+
12
+ const path = require('node:path');
13
+ const fs = require('node:fs/promises');
14
+ const { DATA_DIR } = require('./config');
15
+ const { atomicWriteJson, withFileLock } = require('./atomicJson');
16
+
17
+ const FILE = path.join(DATA_DIR, 'folders.json');
18
+
19
+ async function loadAll() {
20
+ try {
21
+ const raw = await fs.readFile(FILE, 'utf8');
22
+ const j = JSON.parse(raw);
23
+ return Array.isArray(j) ? j : [];
24
+ } catch (e) {
25
+ if (e.code === 'ENOENT') return [];
26
+ throw e;
27
+ }
28
+ }
29
+
30
+ async function saveAll(list) {
31
+ await atomicWriteJson(FILE, list);
32
+ }
33
+
34
+ function genId() {
35
+ return 'folder-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
36
+ }
37
+
38
+ async function create({ name }) {
39
+ if (!name || typeof name !== 'string') throw new Error('name required');
40
+ return withFileLock(FILE, async () => {
41
+ const list = await loadAll();
42
+ const entry = {
43
+ id: genId(),
44
+ name: name.trim(),
45
+ order: list.length,
46
+ createdAt: Date.now(),
47
+ };
48
+ list.push(entry);
49
+ await saveAll(list);
50
+ return entry;
51
+ });
52
+ }
53
+
54
+ async function update(id, patch) {
55
+ return withFileLock(FILE, async () => {
56
+ const list = await loadAll();
57
+ const idx = list.findIndex((f) => f.id === id);
58
+ if (idx < 0) return null;
59
+ // Allow rename + reorder, ignore other keys.
60
+ const allowed = {};
61
+ if (typeof patch.name === 'string') allowed.name = patch.name.trim();
62
+ if (typeof patch.order === 'number') allowed.order = patch.order;
63
+ list[idx] = { ...list[idx], ...allowed };
64
+ await saveAll(list);
65
+ return list[idx];
66
+ });
67
+ }
68
+
69
+ async function remove(id) {
70
+ return withFileLock(FILE, async () => {
71
+ const list = await loadAll();
72
+ const idx = list.findIndex((f) => f.id === id);
73
+ if (idx < 0) return false;
74
+ list.splice(idx, 1);
75
+ await saveAll(list);
76
+ return true;
77
+ });
78
+ }
79
+
80
+ async function reorder(idsInOrder) {
81
+ if (!Array.isArray(idsInOrder)) throw new Error('idsInOrder must be array');
82
+ return withFileLock(FILE, async () => {
83
+ const list = await loadAll();
84
+ const byId = new Map(list.map((f) => [f.id, f]));
85
+ const next = [];
86
+ idsInOrder.forEach((id, i) => {
87
+ const f = byId.get(id);
88
+ if (f) {
89
+ f.order = i;
90
+ next.push(f);
91
+ byId.delete(id);
92
+ }
93
+ });
94
+ // Append any folders not mentioned in the new order, preserving original
95
+ // relative order. Prevents accidentally dropping folders.
96
+ for (const f of byId.values()) {
97
+ f.order = next.length;
98
+ next.push(f);
99
+ }
100
+ await saveAll(next);
101
+ return next;
102
+ });
103
+ }
104
+
105
+ module.exports = { loadAll, create, update, remove, reorder, FILE };