@bakapiano/ccsm 0.10.3 → 0.12.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 (51) hide show
  1. package/CLAUDE.md +475 -475
  2. package/README.md +190 -190
  3. package/bin/ccsm.js +194 -194
  4. package/lib/atomicJson.js +48 -0
  5. package/lib/cliSessionWatcher.js +249 -249
  6. package/lib/config.js +188 -185
  7. package/lib/folders.js +105 -96
  8. package/lib/jsonStore.js +15 -10
  9. package/lib/localCliSessions.js +489 -177
  10. package/lib/persistedSessions.js +142 -134
  11. package/lib/webTerminal.js +208 -208
  12. package/lib/workspace.js +230 -255
  13. package/package.json +57 -57
  14. package/public/css/base.css +99 -99
  15. package/public/css/cards.css +183 -183
  16. package/public/css/feedback.css +303 -303
  17. package/public/css/forms.css +405 -405
  18. package/public/css/layout.css +160 -160
  19. package/public/css/modal.css +190 -183
  20. package/public/css/responsive.css +10 -10
  21. package/public/css/sidebar.css +608 -601
  22. package/public/css/terminals.css +294 -294
  23. package/public/css/tokens.css +81 -79
  24. package/public/css/wco.css +98 -98
  25. package/public/css/widgets.css +1596 -1375
  26. package/public/index.html +105 -103
  27. package/public/js/api.js +272 -260
  28. package/public/js/components/AdoptModal.js +343 -171
  29. package/public/js/components/App.js +35 -35
  30. package/public/js/components/DirectoryPicker.js +203 -203
  31. package/public/js/components/EntityFormModal.js +105 -105
  32. package/public/js/components/Modal.js +51 -51
  33. package/public/js/components/OfflineBanner.js +93 -93
  34. package/public/js/components/PageTitleBar.js +13 -13
  35. package/public/js/components/Picker.js +179 -179
  36. package/public/js/components/Popover.js +55 -55
  37. package/public/js/components/Sidebar.js +341 -270
  38. package/public/js/components/TerminalView.js +298 -298
  39. package/public/js/components/useDragSort.js +67 -67
  40. package/public/js/dialog.js +67 -67
  41. package/public/js/icons.js +177 -177
  42. package/public/js/main.js +132 -140
  43. package/public/js/pages/AboutPage.js +165 -165
  44. package/public/js/pages/ConfigurePage.js +475 -487
  45. package/public/js/pages/LaunchPage.js +369 -369
  46. package/public/js/pages/SessionsPage.js +97 -97
  47. package/public/js/state.js +231 -231
  48. package/public/manifest.webmanifest +15 -15
  49. package/scripts/dev.js +59 -0
  50. package/scripts/install.js +137 -137
  51. package/server.js +1147 -1117
package/lib/config.js CHANGED
@@ -1,185 +1,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
-
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
+ 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 missing keeps 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
+ };
package/lib/folders.js CHANGED
@@ -1,96 +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
-
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
+ 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 };
package/lib/jsonStore.js CHANGED
@@ -12,6 +12,7 @@
12
12
 
13
13
  const fs = require('node:fs/promises');
14
14
  const path = require('node:path');
15
+ const { atomicWriteJson, withFileLock } = require('./atomicJson');
15
16
 
16
17
  function createKeyedJsonStore({ dataDir, filename, transformValue = (v) => v }) {
17
18
  const filePath = path.join(dataDir, filename);
@@ -28,25 +29,29 @@ function createKeyedJsonStore({ dataDir, filename, transformValue = (v) => v })
28
29
  }
29
30
 
30
31
  async function save(map) {
31
- await fs.writeFile(filePath, JSON.stringify(map, null, 2));
32
+ await atomicWriteJson(filePath, map);
32
33
  }
33
34
 
34
35
  async function set(key, value) {
35
36
  if (!key) throw new Error('set: key required');
36
37
  const next = transformValue(value, key);
37
38
  if (next == null) return remove(key);
38
- const map = await load();
39
- map[key] = next;
40
- await save(map);
41
- return next;
39
+ return withFileLock(filePath, async () => {
40
+ const map = await load();
41
+ map[key] = next;
42
+ await save(map);
43
+ return next;
44
+ });
42
45
  }
43
46
 
44
47
  async function remove(key) {
45
- const map = await load();
46
- if (!(key in map)) return false;
47
- delete map[key];
48
- await save(map);
49
- return true;
48
+ return withFileLock(filePath, async () => {
49
+ const map = await load();
50
+ if (!(key in map)) return false;
51
+ delete map[key];
52
+ await save(map);
53
+ return true;
54
+ });
50
55
  }
51
56
 
52
57
  async function list() {