cc-viewer 1.8.19 → 1.8.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/dist/assets/{App-C9Tx6Iqw.js → App-CesjoFSv.js} +2 -2
- package/dist/assets/{MdxEditorPanel-DU_5CRtW.js → MdxEditorPanel-Dsnv2wIw.js} +1 -1
- package/dist/assets/{Mobile--EECC49-.js → Mobile-bvGZ392d.js} +1 -1
- package/dist/assets/{ProxyStatsModal-DJu0xq3Z.js → ProxyStatsModal-HFk51cI8.js} +1 -1
- package/dist/assets/{index-CJdRSHTP.js → index-DanpP6qP.js} +2 -2
- package/dist/assets/{seqResourceLoaders-C5UX6ecI.js → seqResourceLoaders-ByHQMNIi.js} +2 -2
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/interceptor.js +73 -11
- package/server/lib/ask/ask-store.js +7 -3
- package/server/lib/async-file-lock.js +29 -5
- package/server/lib/auth.js +71 -54
- package/server/lib/config-backup.js +8 -5
- package/server/lib/credential-access.js +233 -0
- package/server/lib/credential-migrate.js +147 -0
- package/server/lib/credential-store.js +120 -0
- package/server/lib/credential-vault.js +112 -0
- package/server/lib/file-access-policy.js +16 -0
- package/server/lib/im/im-config.js +81 -31
- package/server/lib/im-deny.js +22 -8
- package/server/lib/json-store.js +264 -0
- package/server/lib/prefs-store.js +8 -33
- package/server/lib/session-pin-store.js +7 -14
- package/server/pty-manager.js +10 -4
- package/server/routes/auth.js +21 -1
- package/server/routes/preferences.js +58 -30
- package/server/server.js +8 -0
- package/server/workspace-registry.js +27 -35
package/server/routes/auth.js
CHANGED
|
@@ -39,10 +39,13 @@ function buildState(deps, isLocal, isAdmin) {
|
|
|
39
39
|
enabled: s.effective.enabled,
|
|
40
40
|
isAdmin,
|
|
41
41
|
password: isLocal ? s.effective.password : null,
|
|
42
|
+
// Surface the fail-closed signal so the admin UI can disable the password field and explain
|
|
43
|
+
// why a save would be refused (see the 409 PASSWORD_UNREADABLE branch in authConfigPost).
|
|
44
|
+
passwordUnreadable: !!s.effective.passwordUnreadable,
|
|
42
45
|
scope: s.scope, // 'project' | 'global' — which one is in effect
|
|
43
46
|
hasProjectOverride: s.hasProjectOverride,
|
|
44
47
|
projectDir: s.projectDir, // null when not project-scoped (non-CLI mode)
|
|
45
|
-
global: { enabled: s.global.enabled, password: isLocal ? s.global.password : null },
|
|
48
|
+
global: { enabled: s.global.enabled, password: isLocal ? s.global.password : null, passwordUnreadable: !!s.global.passwordUnreadable },
|
|
46
49
|
};
|
|
47
50
|
}
|
|
48
51
|
|
|
@@ -117,6 +120,23 @@ function authConfigPost(req, res, parsedUrl, isLocal, deps) {
|
|
|
117
120
|
const cur = scope === 'global'
|
|
118
121
|
? state.global
|
|
119
122
|
: (state.hasProjectOverride ? state.effective : { enabled: false, password: '' });
|
|
123
|
+
// Fail-closed: the current scope's vault password exists but is UNREADABLE (lost master.key /
|
|
124
|
+
// tampered ciphertext). cur.password has collapsed to '' for the gate, but the real ciphertext
|
|
125
|
+
// must NOT be destroyed. Refuse any save that would overwrite/clear it — including the
|
|
126
|
+
// auto-generate-on-enable path (next.password is '' here) and any edit-and-save (which would
|
|
127
|
+
// delete/replace the unreadable entry). The admin recovers by restoring master.key, not by a
|
|
128
|
+
// save that silently wipes the previous secret.
|
|
129
|
+
// Read the flag from the SAME scope object as `cur` — a global-unreadable password must NOT
|
|
130
|
+
// refuse a save whose target is a fresh project override (a different vault ref).
|
|
131
|
+
const curUnreadable = !!cur.passwordUnreadable;
|
|
132
|
+
if (curUnreadable) {
|
|
133
|
+
res.writeHead(409, JSON_HEADERS);
|
|
134
|
+
res.end(JSON.stringify({
|
|
135
|
+
error: 'LAN password is stored in the credential vault but is currently unreadable (lost master.key or corrupt credentials.json). Refusing to overwrite it. Restore master.key to recover, or clear the vault entry first.',
|
|
136
|
+
code: 'PASSWORD_UNREADABLE',
|
|
137
|
+
}));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
120
140
|
const next = { enabled: cur.enabled, password: cur.password };
|
|
121
141
|
if (typeof incoming.enabled === 'boolean') next.enabled = incoming.enabled;
|
|
122
142
|
const passwordProvided = typeof incoming.password === 'string';
|
|
@@ -3,7 +3,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
|
3
3
|
import { join, dirname } from 'node:path';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { LOG_DIR, setLogDir, getClaudeConfigDir, discoverClaudeExecutables, resolveExplicitClaudePath, CLAUDE_EXECUTABLE_PREF_KEY } from '../../findcc.js';
|
|
6
|
-
import { PROFILE_PATH, _defaultConfig, getActiveProfileId, getStoredRoles, isOfficialDefaultEndpoint, setActiveProfileForWorkspace, _loadProxyProfile, RETRY_CONFIG_PATH, _retryConfigState, _loadRetryConfigState } from '../interceptor.js';
|
|
6
|
+
import { PROFILE_PATH, _defaultConfig, getActiveProfileId, getStoredRoles, isOfficialDefaultEndpoint, setActiveProfileForWorkspace, _loadProxyProfile, hydrateProfilesWithApiKeys, RETRY_CONFIG_PATH, _retryConfigState, _loadRetryConfigState } from '../interceptor.js';
|
|
7
7
|
import { migrateProxyProfileList, isValidRoleValue, PROXY_ROLE_KEYS } from '../lib/interceptor-core.js';
|
|
8
8
|
import { DEFAULT_RETRY_CONFIG, validateRetryConfig, resolveRetryConfig } from '../lib/proxy/proxy-retry.js';
|
|
9
9
|
import { discoverCcSwitchProviders, mergeImportedProfiles } from '../lib/ccswitch-import.js';
|
|
@@ -15,6 +15,8 @@ import { inspectShellHook } from '../lib/shell-hook-inspect.js';
|
|
|
15
15
|
import { sendEventToClients } from '../lib/log-watcher.js';
|
|
16
16
|
import { listPlatforms } from '../lib/im/im-config.js';
|
|
17
17
|
import { mutatePrefs, applyPrefsPatch, readPrefsRaw } from '../lib/prefs-store.js';
|
|
18
|
+
import { mutateJsonSync } from '../lib/json-store.js';
|
|
19
|
+
import { persistProfilesApiKeys } from '../lib/credential-access.js';
|
|
18
20
|
import { isAdminReq } from '../lib/is-admin.js';
|
|
19
21
|
import {
|
|
20
22
|
getCurrentProjectKey, getCurrentProjectName, hasFork, listForks, resolveScoped,
|
|
@@ -163,12 +165,18 @@ function preferencesPost(req, res, parsedUrl, isLocal, deps) {
|
|
|
163
165
|
// 切日志目录:把旧文件的完整内容(含 auth 密码 / prefsByProject forks / 其它偏好)带到新位置,
|
|
164
166
|
// 避免切目录后密码与各项目 fork 凭空消失;并把写目标在 setLogDir 前后固定为"新文件",让本次
|
|
165
167
|
// 合并只跑在一个文件 / 一把锁上(不再因 LOG_DIR 中途漂移而劈裂进程内锁队列)。
|
|
168
|
+
// logDir 是机器全局、可改变进程数据根的操作(移动 vault 根会影响 LAN 门与代理注入),
|
|
169
|
+
// 因此只允许 admin(本机 loopback 或已鉴权远程 admin)触发——未鉴权远程/项目客户端直接剥掉。
|
|
166
170
|
let targetFile = deps.getPrefsFile();
|
|
167
171
|
let carried = null;
|
|
168
172
|
if (incoming.logDir && typeof incoming.logDir === 'string') {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
173
|
+
if (!isAdminReq(req, isLocal)) {
|
|
174
|
+
delete incoming.logDir; // non-admin cannot move the data root (would open the LAN gate)
|
|
175
|
+
} else {
|
|
176
|
+
carried = readPrefsRaw(targetFile); // 旧文件全量(锁外读,迁移属罕见 admin 操作)
|
|
177
|
+
setLogDir(incoming.logDir);
|
|
178
|
+
targetFile = deps.getPrefsFile();
|
|
179
|
+
}
|
|
172
180
|
}
|
|
173
181
|
// 全局写入:locked + atomic(prefs-store),与 fork 写共用同一把锁,避免并发写互相覆盖含密码的
|
|
174
182
|
// preferences.json。Deep-merge approvalModal 的逻辑下沉到 applyPrefsPatch,与 /api/project-prefs
|
|
@@ -310,14 +318,28 @@ function proxyProfilesGet(req, res, parsedUrl, isLocal, deps) {
|
|
|
310
318
|
if (Array.isArray(data.profiles)) {
|
|
311
319
|
const { profiles: migrated, changed } = migrateProxyProfileList(data.profiles);
|
|
312
320
|
if (changed) {
|
|
313
|
-
data = { ...data, profiles: migrated };
|
|
314
321
|
try {
|
|
315
|
-
|
|
316
|
-
|
|
322
|
+
// Write the migration back while pulling apiKeys into the vault (so the old plaintext is
|
|
323
|
+
// never re-persisted). Read-merge-write INSIDE the lock (mutateJsonSync mutates in place
|
|
324
|
+
// and the kernel writes back the same reference), mutexed with the startup credential-strip
|
|
325
|
+
// and proxyProfilesPost.
|
|
326
|
+
mutateJsonSync(PROFILE_PATH, (current) => {
|
|
327
|
+
const base = (current && typeof current === 'object' && !Array.isArray(current)) ? current : {};
|
|
328
|
+
const stripped = persistProfilesApiKeys(migrated, null, { isMaskedFn: () => false });
|
|
329
|
+
base.profiles = stripped;
|
|
330
|
+
return base;
|
|
331
|
+
}, { mode: 0o600, strictCorrupt: true, fallback: {} });
|
|
332
|
+
data = { ...data, profiles: migrated };
|
|
317
333
|
_loadProxyProfile();
|
|
318
334
|
} catch { /* 迁移落盘失败不阻塞 GET;下次仍会尝试 */ }
|
|
319
335
|
}
|
|
320
336
|
}
|
|
337
|
+
// profile.json no longer carries apiKeys (they live in the credential vault) — decrypt-backfill
|
|
338
|
+
// them onto the list for serving. The local admin gets plaintext to view/copy; a remote caller
|
|
339
|
+
// is masked by deps.maskProfiles below (same as before).
|
|
340
|
+
if (Array.isArray(data.profiles)) {
|
|
341
|
+
data = { ...data, profiles: hydrateProfilesWithApiKeys(data.profiles) };
|
|
342
|
+
}
|
|
321
343
|
// 用 interceptor.getActiveProfileId() 返回 effective active(workspace > profile.json.active > 'max')
|
|
322
344
|
const effectiveActive = getActiveProfileId();
|
|
323
345
|
// 本机(127.0.0.1)= admin:下发明文 profile.apiKey 供本人在编辑表单(👁 折叠)里查阅/复制;已授权
|
|
@@ -353,22 +375,21 @@ function proxyProfilesPost(req, res, parsedUrl, isLocal, deps) {
|
|
|
353
375
|
if (!incoming.profiles.some(p => p.id === 'max')) {
|
|
354
376
|
incoming.profiles = [{ id: 'max', name: 'Default' }, ...(incoming.profiles || [])];
|
|
355
377
|
}
|
|
356
|
-
//
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
writeFileSync(PROFILE_PATH, JSON.stringify(toWrite, null, 2), { mode: 0o600 });
|
|
378
|
+
// Only the profiles list is written to profile.json; `active` is no longer persisted here
|
|
379
|
+
// (avoids cross-process clobbering). The existing file's `active` field is preserved so an
|
|
380
|
+
// older ccv or a hand editor can still fall back to it. Read-merge-write INSIDE the lock
|
|
381
|
+
// (mutateJsonSync), mutexed with the startup credential-strip and setActiveProfileForWorkspace —
|
|
382
|
+
// an unlocked snapshot would let persistProfilesApiKeys' deletion loop remove concurrently-added
|
|
383
|
+
// vault entries and overwrite the whole list.
|
|
384
|
+
// apiKey 抽出到凭证 vault(密文存 credentials.json),profile.json 不再携带 key。
|
|
385
|
+
// masked 回显 → 保留该 profile 的既有 vault 条目(不动、不回写 sentinel);
|
|
386
|
+
// 新明文 → 写 vault;空 → 删该条目;被移除的 profile(全量替换端点)→ 删其 vault 条目。
|
|
387
|
+
mutateJsonSync(PROFILE_PATH, (current) => {
|
|
388
|
+
const existing = (current && typeof current === 'object' && !Array.isArray(current)) ? current : {};
|
|
389
|
+
const strippedProfiles = persistProfilesApiKeys(incoming.profiles, existing.profiles, { isMaskedFn: deps.isMasked });
|
|
390
|
+
existing.profiles = strippedProfiles;
|
|
391
|
+
return existing;
|
|
392
|
+
}, { mode: 0o600, strictCorrupt: true, fallback: {} });
|
|
372
393
|
// 角色分配校验:只认 subagent/teammate 两个 key(roles.main 之类的杂键直接丢弃);
|
|
373
394
|
// 值必须是 follow / max / 入参 profiles 里存在的 id(按将落盘的列表校验,而非旧文件),
|
|
374
395
|
// 非法值归 'follow'(宽容风格,不 400)。被删 profile 的悬空角色由读取时归 follow 兜底。
|
|
@@ -541,13 +562,20 @@ async function ccswitchImportPost(req, res, _parsedUrl, isLocal, deps) {
|
|
|
541
562
|
}
|
|
542
563
|
existing = parsed;
|
|
543
564
|
}
|
|
544
|
-
// merge
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
//
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
565
|
+
// Persist: locked read-merge-write (mutateJsonSync), mutexed with the other profile.json
|
|
566
|
+
// writers. The merge MUST be recomputed inside the lock against the CURRENT on-disk list —
|
|
567
|
+
// computing it outside from a pre-lock snapshot and overwriting the whole list would erase a
|
|
568
|
+
// proxy-profiles POST that lands in that window. apiKeys are pulled into the vault; merge
|
|
569
|
+
// semantics never delete existing vault entries (no existingProfiles passed). cc-switch imports
|
|
570
|
+
// fresh plaintext keys (not a masked echo), so isMaskedFn is always false. `merged` is read
|
|
571
|
+
// back out of the lock for the response counts.
|
|
572
|
+
let merged = null;
|
|
573
|
+
mutateJsonSync(PROFILE_PATH, (current) => {
|
|
574
|
+
const base = (current && typeof current === 'object' && !Array.isArray(current)) ? current : {};
|
|
575
|
+
merged = mergeImportedProfiles(base.profiles || [], result.profiles);
|
|
576
|
+
base.profiles = persistProfilesApiKeys(merged.profiles, null, { isMaskedFn: () => false });
|
|
577
|
+
return base;
|
|
578
|
+
}, { mode: 0o600, strictCorrupt: true, fallback: {} });
|
|
551
579
|
// active 处理:setActive=true 且 cc-switch 有 current → 切换;否则保持现状
|
|
552
580
|
let activeChanged = false;
|
|
553
581
|
if (setActive && result.currentId) {
|
package/server/server.js
CHANGED
|
@@ -118,6 +118,7 @@ import { applyTaskEvent, resetTasks, getTaskSnapshot, shouldResetTasks } from '.
|
|
|
118
118
|
import { createImLogWatcher } from './lib/im/im-log-watcher.js';
|
|
119
119
|
import { unwatchAllWorkflows } from './lib/workflow-watcher.js';
|
|
120
120
|
import { backupConfigs } from './lib/config-backup.js';
|
|
121
|
+
import { migrateCredentialsToVault } from './lib/credential-migrate.js';
|
|
121
122
|
import { normalizeBasePath, validateBasePath, stripBasePath } from './lib/base-path.js';
|
|
122
123
|
import { createHardenedCleanup } from './lib/term-signals.js';
|
|
123
124
|
import { createBackpressureGate } from './lib/ws-backpressure.js';
|
|
@@ -785,6 +786,7 @@ async function handleRequest(req, res) {
|
|
|
785
786
|
accessToken: ACCESS_TOKEN,
|
|
786
787
|
enabled: authConfig.enabled,
|
|
787
788
|
password: authConfig.password,
|
|
789
|
+
passwordUnreadable: authConfig.passwordUnreadable,
|
|
788
790
|
wantsHtml,
|
|
789
791
|
});
|
|
790
792
|
// Remote-admin elevation (container/cloud deploy): an authenticated remote caller is
|
|
@@ -990,6 +992,11 @@ export async function startViewer() {
|
|
|
990
992
|
// (滚动留 10 份)。2026-06-06 事故:配置随 LOG_DIR 整树丢失后无处可恢复。fire-and-forget。
|
|
991
993
|
setImmediate(() => { try { backupConfigs(); } catch { /* ignore */ } });
|
|
992
994
|
|
|
995
|
+
// 启动期一次性凭证迁移:preferences/profile 里的明文/base64 密钥 → credentials.json(AES-256-GCM)。
|
|
996
|
+
// 必须在 backupConfigs 之后挂(先留一份迁移前备份),幂等(逐字段写 vault→读回比对→剔旧字段),
|
|
997
|
+
// 失败字段保留原值不丢,下次启动重试。fire-and-forget,不阻塞启动。
|
|
998
|
+
setImmediate(() => { try { migrateCredentialsToVault(); } catch { /* ignore */ } });
|
|
999
|
+
|
|
993
1000
|
// 启动时清理磁盘上 ASK_HOOK_TIMEOUT_MS 之前的 ask 条目(兜底防泄漏)。
|
|
994
1001
|
// 内存 Map 不 hydrate:旧 res 已死、新 ask-bridge 重连同 toolUseId 会自动复用槽位
|
|
995
1002
|
// (server.js 已有"旧 res 已断 → 复用"分支),无需在这里主动重建内存态。
|
|
@@ -1364,6 +1371,7 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
1364
1371
|
accessToken: ACCESS_TOKEN,
|
|
1365
1372
|
enabled: authConfig.enabled,
|
|
1366
1373
|
password: authConfig.password,
|
|
1374
|
+
passwordUnreadable: authConfig.passwordUnreadable,
|
|
1367
1375
|
wantsHtml: false,
|
|
1368
1376
|
});
|
|
1369
1377
|
if (wsAuth.action !== 'allow') {
|
|
@@ -1,42 +1,24 @@
|
|
|
1
1
|
// Workspace Registry - 工作区持久化管理
|
|
2
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from 'node:fs';
|
|
3
2
|
import { readdir, stat } from 'node:fs/promises';
|
|
4
|
-
import {
|
|
5
|
-
import { withFileLockAsync } from './lib/async-file-lock.js';
|
|
3
|
+
import { mutateJson, readJsonSafe, writeJsonAtomic } from './lib/json-store.js';
|
|
6
4
|
import { dirSizeSync } from './lib/v2/layout.js';
|
|
7
5
|
import { isDiscardableSession } from './lib/v2/session-select.js';
|
|
8
|
-
import { join, basename, resolve } from 'node:path';
|
|
6
|
+
import { join, basename, resolve, dirname } from 'node:path';
|
|
9
7
|
import { randomBytes } from 'node:crypto';
|
|
10
8
|
import { LOG_DIR } from '../findcc.js';
|
|
11
9
|
|
|
12
10
|
// 动态获取(LOG_DIR 可能在运行时被 setLogDir 修改)
|
|
13
11
|
function getWorkspacesFile() { return join(LOG_DIR, 'workspaces.json'); }
|
|
14
|
-
function getLockFile() { return join(LOG_DIR, 'workspaces.lock'); }
|
|
15
12
|
|
|
16
13
|
export function loadWorkspaces() {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const data = JSON.parse(readFileSync(getWorkspacesFile(), 'utf-8'));
|
|
20
|
-
return Array.isArray(data.workspaces) ? data.workspaces : [];
|
|
21
|
-
} catch {
|
|
22
|
-
return [];
|
|
23
|
-
}
|
|
14
|
+
const data = readJsonSafe(getWorkspacesFile(), {});
|
|
15
|
+
return Array.isArray(data.workspaces) ? data.workspaces : [];
|
|
24
16
|
}
|
|
25
17
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
writeFileSync(tmpFile, JSON.stringify({ workspaces: list }, null, 2));
|
|
31
|
-
|
|
32
|
-
// Windows 上 renameSync 可能会因为目标文件存在或被占用而失败。统一走 server/lib/file-api.js
|
|
33
|
-
// renameSyncWithRetry helper(同款重试策略,跟 interceptor / log-management 一致)。
|
|
34
|
-
renameSyncWithRetry(tmpFile, getWorkspacesFile());
|
|
35
|
-
} catch (err) {
|
|
36
|
-
console.error('[CC Viewer] Failed to save workspaces:', err.message);
|
|
37
|
-
// 尝试清理临时文件
|
|
38
|
-
try { unlinkSync(tmpFile); } catch { }
|
|
39
|
-
}
|
|
18
|
+
// Full-list rewrite on every mutation; non-secret so no 0600 (mode:false → umask).
|
|
19
|
+
// Atomicity + cross-process mutex come from mutateJson below.
|
|
20
|
+
function _saveWorkspaces(list) {
|
|
21
|
+
return { workspaces: list };
|
|
40
22
|
}
|
|
41
23
|
|
|
42
24
|
// 失效 file-access-policy 的 allowlist roots 缓存。lazy import 避免循环依赖。
|
|
@@ -47,10 +29,10 @@ function _invalidatePolicyCache() {
|
|
|
47
29
|
}
|
|
48
30
|
|
|
49
31
|
export async function registerWorkspace(absolutePath) {
|
|
50
|
-
const result = await
|
|
32
|
+
const result = await mutateJson(getWorkspacesFile(), (data) => {
|
|
51
33
|
const resolvedPath = resolve(absolutePath);
|
|
52
34
|
const projectName = basename(resolvedPath).replace(/[^a-zA-Z0-9_\-\.]/g, '_');
|
|
53
|
-
const list =
|
|
35
|
+
const list = Array.isArray(data.workspaces) ? data.workspaces : [];
|
|
54
36
|
// Windows NTFS 不分大小写——`C:\App` 跟 `c:\app` 是同目录但 `===` 视为不同。
|
|
55
37
|
// 仅 Win 下小写化比较;POSIX 保持原样不引入回归。
|
|
56
38
|
const pathEq = (a, b) => process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
@@ -58,7 +40,7 @@ export async function registerWorkspace(absolutePath) {
|
|
|
58
40
|
if (existing) {
|
|
59
41
|
existing.lastUsed = new Date().toISOString();
|
|
60
42
|
existing.projectName = projectName;
|
|
61
|
-
|
|
43
|
+
data.workspaces = list;
|
|
62
44
|
return existing;
|
|
63
45
|
}
|
|
64
46
|
const now = new Date().toISOString();
|
|
@@ -70,27 +52,37 @@ export async function registerWorkspace(absolutePath) {
|
|
|
70
52
|
createdAt: now,
|
|
71
53
|
};
|
|
72
54
|
list.push(entry);
|
|
73
|
-
|
|
55
|
+
data.workspaces = list;
|
|
74
56
|
return entry;
|
|
75
|
-
}, { ensureDir: LOG_DIR });
|
|
57
|
+
}, { mode: false, fallback: {}, ensureDir: LOG_DIR });
|
|
76
58
|
_invalidatePolicyCache();
|
|
77
59
|
return result;
|
|
78
60
|
}
|
|
79
61
|
|
|
80
62
|
export async function removeWorkspace(id) {
|
|
81
|
-
const result = await
|
|
82
|
-
const list =
|
|
63
|
+
const result = await mutateJson(getWorkspacesFile(), (data) => {
|
|
64
|
+
const list = Array.isArray(data.workspaces) ? data.workspaces : [];
|
|
83
65
|
const filtered = list.filter(w => w.id !== id);
|
|
84
66
|
if (filtered.length !== list.length) {
|
|
85
|
-
|
|
67
|
+
data.workspaces = filtered;
|
|
86
68
|
return true;
|
|
87
69
|
}
|
|
88
70
|
return false;
|
|
89
|
-
}, { ensureDir: LOG_DIR });
|
|
71
|
+
}, { mode: false, fallback: {}, ensureDir: LOG_DIR });
|
|
90
72
|
if (result) _invalidatePolicyCache();
|
|
91
73
|
return result;
|
|
92
74
|
}
|
|
93
75
|
|
|
76
|
+
// Legacy sync save entry kept for any direct caller: writes the full list atomically via the
|
|
77
|
+
// kernel (non-secret → umask). mkdirSync is handled inside writeJsonAtomic.
|
|
78
|
+
export function saveWorkspaces(list) {
|
|
79
|
+
try {
|
|
80
|
+
writeJsonAtomic(getWorkspacesFile(), _saveWorkspaces(list), { mode: false });
|
|
81
|
+
} catch (err) {
|
|
82
|
+
console.error('[CC Viewer] Failed to save workspaces:', err.message);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
94
86
|
export async function getWorkspaces() {
|
|
95
87
|
const list = loadWorkspaces();
|
|
96
88
|
const enriched = await Promise.all(list.map(async (w) => {
|