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