@bakapiano/ccsm 0.22.2 → 0.22.4
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/CLAUDE.md +538 -538
- package/README.md +189 -189
- package/bin/ccsm.js +235 -235
- package/lib/cliActivity.js +139 -139
- package/lib/codexSeed.js +183 -183
- package/lib/config.js +274 -274
- package/lib/devices.js +229 -229
- package/lib/folders.js +124 -124
- package/lib/localCliSessions.js +519 -519
- package/lib/persistedSessions.js +129 -129
- package/lib/tunnel.js +621 -621
- package/lib/webTerminal.js +233 -231
- package/lib/workspace.js +233 -233
- package/package.json +57 -57
- package/public/css/base.css +99 -99
- package/public/css/cards.css +183 -183
- package/public/css/feedback.css +504 -504
- package/public/css/forms.css +453 -453
- package/public/css/layout.css +176 -176
- package/public/css/modal.css +190 -190
- package/public/css/responsive.css +176 -176
- package/public/css/sidebar.css +707 -707
- package/public/css/terminals.css +592 -592
- package/public/css/tokens.css +81 -81
- package/public/css/wco.css +196 -196
- package/public/css/widgets.css +2725 -2725
- package/public/index.html +152 -152
- package/public/js/api.js +371 -371
- package/public/js/backend.js +149 -149
- package/public/js/components/App.js +73 -73
- package/public/js/components/DirectoryPicker.js +203 -203
- package/public/js/components/EntityFormModal.js +153 -153
- package/public/js/components/Modal.js +57 -57
- package/public/js/components/OfflineBanner.js +67 -67
- package/public/js/components/PageTitleBar.js +13 -13
- package/public/js/components/PendingApprovalOverlay.js +128 -128
- package/public/js/components/Picker.js +179 -179
- package/public/js/components/Popover.js +55 -55
- package/public/js/components/RestartOverlay.js +36 -36
- package/public/js/components/Sidebar.js +380 -380
- package/public/js/components/TerminalInstance.js +187 -15
- package/public/js/components/TerminalResizeDebouncer.js +126 -0
- package/public/js/components/XtermTerminal.js +148 -14
- package/public/js/components/useDragSort.js +67 -67
- package/public/js/dialog.js +67 -67
- package/public/js/icons.js +212 -212
- package/public/js/main.js +296 -296
- package/public/js/pages/AboutPage.js +90 -90
- package/public/js/pages/ConfigurePage.js +713 -713
- package/public/js/pages/LaunchPage.js +421 -421
- package/public/js/pages/RemotePage.js +743 -743
- package/public/js/pages/SessionsPage.js +100 -100
- package/public/js/state.js +335 -335
- package/public/manifest.webmanifest +25 -0
- package/public/setup/index.html +567 -0
- package/scripts/dev.js +149 -149
- package/scripts/install.js +153 -153
- package/scripts/restart-helper.js +96 -96
- package/scripts/upgrade-helper.js +687 -687
- package/server.js +1807 -1807
package/lib/config.js
CHANGED
|
@@ -1,274 +1,274 @@
|
|
|
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
|
-
// copilot has no `--resume <id>` (space) flag — that parses as `--resume`
|
|
60
|
-
// (valueless → session picker) plus a stray positional arg, which hangs on
|
|
61
|
-
// a blank screen. `--session-id <id>` both creates AND resumes a session by
|
|
62
|
-
// id (`copilot --help`: "Resume an existing session or task by ID"), so we
|
|
63
|
-
// use it for both new and resume.
|
|
64
|
-
newSessionIdArgs: ['--session-id', '<id>'],
|
|
65
|
-
resumeIdArgs: ['--session-id', '<id>'],
|
|
66
|
-
shell: 'direct',
|
|
67
|
-
type: 'copilot',
|
|
68
|
-
builtin: true,
|
|
69
|
-
},
|
|
70
|
-
];
|
|
71
|
-
|
|
72
|
-
const DEFAULTS = {
|
|
73
|
-
port: 7777,
|
|
74
|
-
workDir: path.join(os.homedir(), 'ccsm-workspaces'),
|
|
75
|
-
// Repos available for cloning into a fresh workspace at launch time.
|
|
76
|
-
// { name: 'foo', url: 'https://github.com/me/foo.git', defaultSelected: true }
|
|
77
|
-
repos: [],
|
|
78
|
-
// Pluggable CLIs. Add custom wrappers or self-hosted
|
|
79
|
-
// proxies by appending an entry. defaultCliId picks one for the
|
|
80
|
-
// Launch button when the user doesn't override.
|
|
81
|
-
clis: DEFAULT_CLIS,
|
|
82
|
-
defaultCliId: 'claude',
|
|
83
|
-
// External editor command for the "Open in editor" session action.
|
|
84
|
-
// Spawned as `<editor> "<cwd>"`; default `code` = VS Code (whose Source
|
|
85
|
-
// Control panel doubles as the review-changes view once the folder's
|
|
86
|
-
// open). Point it at `cursor`, `code-insiders`, `subl`, … as desired.
|
|
87
|
-
editor: 'code',
|
|
88
|
-
// Devtunnel state. tunnelId holds the persistent (named) tunnel
|
|
89
|
-
// ccsm minted via `devtunnel create` on first Start. Reusing it
|
|
90
|
-
// across host restarts keeps the public URL — and therefore the
|
|
91
|
-
// remote browsers' approval records — stable. `devtunnel delete <id>`
|
|
92
|
-
// is invoked when the user explicitly rotates via the Reset button.
|
|
93
|
-
devtunnel: { tunnelId: null },
|
|
94
|
-
// Provider-agnostic tunnel prefs. When autoStart is on, the backend
|
|
95
|
-
// brings the tunnel up during its own startup (server.js boot hook) —
|
|
96
|
-
// NOT an OS-level autostart. token is persisted so share URLs survive
|
|
97
|
-
// a backend restart; it's written ONLY while autoStart is on and is
|
|
98
|
-
// stripped from /api/config so remote devices can't read it. provider
|
|
99
|
-
// is 'devtunnel' | 'cloudflared'.
|
|
100
|
-
tunnel: { autoStart: false, provider: null, token: null },
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
function ensureDataDir() {
|
|
104
|
-
if (!fsSync.existsSync(DATA_DIR)) {
|
|
105
|
-
fsSync.mkdirSync(DATA_DIR, { recursive: true });
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// If we find a legacy <repo>/data dir from before the home-dir move AND
|
|
110
|
-
// no ~/.ccsm yet, copy across. Idempotent — only fires when DATA_DIR is
|
|
111
|
-
// empty so existing users with both dirs aren't clobbered.
|
|
112
|
-
function migrateLegacyDataIfNeeded() {
|
|
113
|
-
if (!fsSync.existsSync(LEGACY_DATA_DIR)) return;
|
|
114
|
-
if (LEGACY_DATA_DIR === DATA_DIR) return;
|
|
115
|
-
ensureDataDir();
|
|
116
|
-
const dataEmpty = fsSync.readdirSync(DATA_DIR).length === 0;
|
|
117
|
-
if (!dataEmpty) return;
|
|
118
|
-
try {
|
|
119
|
-
fsSync.cpSync(LEGACY_DATA_DIR, DATA_DIR, { recursive: true });
|
|
120
|
-
console.log(`[ccsm] migrated legacy data: ${LEGACY_DATA_DIR} → ${DATA_DIR}`);
|
|
121
|
-
} catch (e) {
|
|
122
|
-
console.error('[ccsm] legacy migration failed:', e.message);
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
migrateLegacyDataIfNeeded();
|
|
127
|
-
|
|
128
|
-
// Strip dropped v0.x keys + clamp shape of survivors. Returns a fresh
|
|
129
|
-
// object so callers don't mutate DEFAULTS.
|
|
130
|
-
function mergeWithDefaults(partial) {
|
|
131
|
-
const out = { ...DEFAULTS, ...partial };
|
|
132
|
-
// Deep-merge devtunnel + tunnel so a partial save (just .tunnelId, or
|
|
133
|
-
// just .autoStart) doesn't wipe sibling keys.
|
|
134
|
-
out.devtunnel = { ...DEFAULTS.devtunnel, ...(partial?.devtunnel || {}) };
|
|
135
|
-
out.tunnel = { ...DEFAULTS.tunnel, ...(partial?.tunnel || {}) };
|
|
136
|
-
// Drop v0.x keys that the new architecture doesn't use.
|
|
137
|
-
delete out.terminal;
|
|
138
|
-
delete out.commandShell;
|
|
139
|
-
delete out.claudeCommand;
|
|
140
|
-
delete out.defaultTerminalMode;
|
|
141
|
-
delete out.autoFocusOnLaunch;
|
|
142
|
-
delete out.focusMovesToCenter;
|
|
143
|
-
delete out.snapshotIntervalMs;
|
|
144
|
-
delete out.snapshotHistoryKeep;
|
|
145
|
-
delete out.autoOpenBrowser;
|
|
146
|
-
delete out.browserMode;
|
|
147
|
-
delete out.finderPrompt;
|
|
148
|
-
|
|
149
|
-
if (!Array.isArray(out.repos)) out.repos = DEFAULTS.repos;
|
|
150
|
-
if (!Array.isArray(out.clis)) out.clis = [];
|
|
151
|
-
if (typeof out.editor !== 'string') out.editor = DEFAULTS.editor;
|
|
152
|
-
// Always inject builtin CLIs (claude, codex) if they're missing or were
|
|
153
|
-
// deleted from a saved config — they're managed by ccsm, the user can
|
|
154
|
-
// tweak args/shell but can't remove them. Preserves any user
|
|
155
|
-
// customisation on existing builtin entries.
|
|
156
|
-
for (const def of DEFAULT_CLIS) {
|
|
157
|
-
const existing = out.clis.find((c) => c.id === def.id);
|
|
158
|
-
if (existing) {
|
|
159
|
-
existing.builtin = true;
|
|
160
|
-
// Backfill defaults from the built-in template for any field the
|
|
161
|
-
// user's saved copy is missing OR has as an empty array. Empty
|
|
162
|
-
// arrays matter because users upgrading from a pre-0.15 config
|
|
163
|
-
// never wrote `newSessionIdArgs` (didn't exist), AND a partial
|
|
164
|
-
// 0.14→0.15 dev iteration shipped codex with `[]`. Treat both
|
|
165
|
-
// the same: a builtin with no template means "use the canonical
|
|
166
|
-
// one ccsm now knows about", since these fields are the
|
|
167
|
-
// integration contract with the upstream CLI — not user knobs.
|
|
168
|
-
const needsBackfill = (v) => v == null || (Array.isArray(v) && v.length === 0);
|
|
169
|
-
if (needsBackfill(existing.resumeIdArgs)) existing.resumeIdArgs = def.resumeIdArgs;
|
|
170
|
-
if (needsBackfill(existing.newSessionIdArgs)) existing.newSessionIdArgs = def.newSessionIdArgs;
|
|
171
|
-
// Heal the old broken copilot resume flag: `--resume <id>` (space form)
|
|
172
|
-
// isn't valid copilot syntax — it parses as a valueless `--resume`
|
|
173
|
-
// (session picker) plus a stray positional, hanging on a blank screen.
|
|
174
|
-
// The builtin now uses `--session-id <id>`. Migrate configs still
|
|
175
|
-
// carrying the old default; genuine user customisations are left alone.
|
|
176
|
-
if (existing.id === 'copilot'
|
|
177
|
-
&& Array.isArray(existing.resumeIdArgs)
|
|
178
|
-
&& existing.resumeIdArgs.join(' ') === '--resume <id>') {
|
|
179
|
-
existing.resumeIdArgs = def.resumeIdArgs;
|
|
180
|
-
}
|
|
181
|
-
// Drop the v0.x `resumeArgs` fallback — every builtin now has a
|
|
182
|
-
// pre-assigned UUID (claude/copilot via flag, codex via seed) so
|
|
183
|
-
// resumeIdArgs always applies on resume; the field is dead weight.
|
|
184
|
-
delete existing.resumeArgs;
|
|
185
|
-
if (!existing.type) existing.type = def.type;
|
|
186
|
-
} else {
|
|
187
|
-
out.clis.unshift({ ...def });
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
// Normalize per-CLI fields.
|
|
191
|
-
out.clis = out.clis.map((c) => {
|
|
192
|
-
const { installed, installPath, resumeArgs, ...rest } = c; // strip computed probe fields + v0.x resumeArgs
|
|
193
|
-
const normalized = {
|
|
194
|
-
...rest,
|
|
195
|
-
args: Array.isArray(rest.args) ? rest.args : [],
|
|
196
|
-
resumeIdArgs: Array.isArray(rest.resumeIdArgs) ? rest.resumeIdArgs : [],
|
|
197
|
-
newSessionIdArgs: Array.isArray(rest.newSessionIdArgs) ? rest.newSessionIdArgs : [],
|
|
198
|
-
shell: ['direct', 'pwsh', 'cmd'].includes(rest.shell) ? rest.shell : 'direct',
|
|
199
|
-
type: ['claude', 'codex', 'copilot', 'other'].includes(rest.type) ? rest.type : 'other',
|
|
200
|
-
builtin: !!rest.builtin,
|
|
201
|
-
};
|
|
202
|
-
// Type-based fallback for non-builtin CLIs (wrappers that
|
|
203
|
-
// just call claude under the hood). If user picked
|
|
204
|
-
// type='claude' but left newSessionIdArgs / resumeIdArgs blank,
|
|
205
|
-
// assume they want the same args claude / copilot / codex use
|
|
206
|
-
// canonically — without this the wrapped CLI gets spawned with
|
|
207
|
-
// no UUID and ccsm can never recapture the upstream session.
|
|
208
|
-
// Builtins are already handled by the loop above with `def`.
|
|
209
|
-
if (!normalized.builtin && normalized.type !== 'other') {
|
|
210
|
-
const template = DEFAULT_CLIS.find((d) => d.type === normalized.type);
|
|
211
|
-
if (template) {
|
|
212
|
-
if (normalized.newSessionIdArgs.length === 0) {
|
|
213
|
-
normalized.newSessionIdArgs = [...template.newSessionIdArgs];
|
|
214
|
-
}
|
|
215
|
-
if (normalized.resumeIdArgs.length === 0) {
|
|
216
|
-
normalized.resumeIdArgs = [...template.resumeIdArgs];
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
return normalized;
|
|
221
|
-
});
|
|
222
|
-
// Make sure defaultCliId points at an actual CLI; fall back to first.
|
|
223
|
-
if (!out.clis.find((c) => c.id === out.defaultCliId)) {
|
|
224
|
-
out.defaultCliId = out.clis[0].id;
|
|
225
|
-
}
|
|
226
|
-
return out;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
async function loadConfig() {
|
|
230
|
-
ensureDataDir();
|
|
231
|
-
try {
|
|
232
|
-
const raw = await fs.readFile(CONFIG_PATH, 'utf8');
|
|
233
|
-
return mergeWithDefaults(JSON.parse(raw));
|
|
234
|
-
} catch (e) {
|
|
235
|
-
if (e.code === 'ENOENT') {
|
|
236
|
-
const cfg = { ...DEFAULTS };
|
|
237
|
-
await atomicWriteJson(CONFIG_PATH, cfg);
|
|
238
|
-
return cfg;
|
|
239
|
-
}
|
|
240
|
-
throw e;
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
async function saveConfig(partial) {
|
|
245
|
-
ensureDataDir();
|
|
246
|
-
return withFileLock(CONFIG_PATH, async () => {
|
|
247
|
-
const current = await loadConfig();
|
|
248
|
-
// mergeWithDefaults re-merges nested objects (devtunnel, tunnel)
|
|
249
|
-
// against DEFAULTS only, so a partial save like
|
|
250
|
-
// saveConfig({ tunnel: { autoStart: true } }) would reset the
|
|
251
|
-
// sibling token/provider back to defaults. Pre-merge the nested
|
|
252
|
-
// blocks against `current` so a partial update preserves siblings.
|
|
253
|
-
const merged = { ...current, ...partial };
|
|
254
|
-
if (partial && partial.devtunnel) {
|
|
255
|
-
merged.devtunnel = { ...current.devtunnel, ...partial.devtunnel };
|
|
256
|
-
}
|
|
257
|
-
if (partial && partial.tunnel) {
|
|
258
|
-
merged.tunnel = { ...current.tunnel, ...partial.tunnel };
|
|
259
|
-
}
|
|
260
|
-
const next = mergeWithDefaults(merged);
|
|
261
|
-
await atomicWriteJson(CONFIG_PATH, next);
|
|
262
|
-
return next;
|
|
263
|
-
});
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
module.exports = {
|
|
267
|
-
loadConfig,
|
|
268
|
-
saveConfig,
|
|
269
|
-
DATA_DIR,
|
|
270
|
-
CONFIG_PATH,
|
|
271
|
-
LEGACY_DATA_DIR,
|
|
272
|
-
DEFAULTS,
|
|
273
|
-
DEFAULT_CLIS,
|
|
274
|
-
};
|
|
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
|
+
// copilot has no `--resume <id>` (space) flag — that parses as `--resume`
|
|
60
|
+
// (valueless → session picker) plus a stray positional arg, which hangs on
|
|
61
|
+
// a blank screen. `--session-id <id>` both creates AND resumes a session by
|
|
62
|
+
// id (`copilot --help`: "Resume an existing session or task by ID"), so we
|
|
63
|
+
// use it for both new and resume.
|
|
64
|
+
newSessionIdArgs: ['--session-id', '<id>'],
|
|
65
|
+
resumeIdArgs: ['--session-id', '<id>'],
|
|
66
|
+
shell: 'direct',
|
|
67
|
+
type: 'copilot',
|
|
68
|
+
builtin: true,
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
const DEFAULTS = {
|
|
73
|
+
port: 7777,
|
|
74
|
+
workDir: path.join(os.homedir(), 'ccsm-workspaces'),
|
|
75
|
+
// Repos available for cloning into a fresh workspace at launch time.
|
|
76
|
+
// { name: 'foo', url: 'https://github.com/me/foo.git', defaultSelected: true }
|
|
77
|
+
repos: [],
|
|
78
|
+
// Pluggable CLIs. Add custom wrappers or self-hosted
|
|
79
|
+
// proxies by appending an entry. defaultCliId picks one for the
|
|
80
|
+
// Launch button when the user doesn't override.
|
|
81
|
+
clis: DEFAULT_CLIS,
|
|
82
|
+
defaultCliId: 'claude',
|
|
83
|
+
// External editor command for the "Open in editor" session action.
|
|
84
|
+
// Spawned as `<editor> "<cwd>"`; default `code` = VS Code (whose Source
|
|
85
|
+
// Control panel doubles as the review-changes view once the folder's
|
|
86
|
+
// open). Point it at `cursor`, `code-insiders`, `subl`, … as desired.
|
|
87
|
+
editor: 'code',
|
|
88
|
+
// Devtunnel state. tunnelId holds the persistent (named) tunnel
|
|
89
|
+
// ccsm minted via `devtunnel create` on first Start. Reusing it
|
|
90
|
+
// across host restarts keeps the public URL — and therefore the
|
|
91
|
+
// remote browsers' approval records — stable. `devtunnel delete <id>`
|
|
92
|
+
// is invoked when the user explicitly rotates via the Reset button.
|
|
93
|
+
devtunnel: { tunnelId: null },
|
|
94
|
+
// Provider-agnostic tunnel prefs. When autoStart is on, the backend
|
|
95
|
+
// brings the tunnel up during its own startup (server.js boot hook) —
|
|
96
|
+
// NOT an OS-level autostart. token is persisted so share URLs survive
|
|
97
|
+
// a backend restart; it's written ONLY while autoStart is on and is
|
|
98
|
+
// stripped from /api/config so remote devices can't read it. provider
|
|
99
|
+
// is 'devtunnel' | 'cloudflared'.
|
|
100
|
+
tunnel: { autoStart: false, provider: null, token: null },
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
function ensureDataDir() {
|
|
104
|
+
if (!fsSync.existsSync(DATA_DIR)) {
|
|
105
|
+
fsSync.mkdirSync(DATA_DIR, { recursive: true });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// If we find a legacy <repo>/data dir from before the home-dir move AND
|
|
110
|
+
// no ~/.ccsm yet, copy across. Idempotent — only fires when DATA_DIR is
|
|
111
|
+
// empty so existing users with both dirs aren't clobbered.
|
|
112
|
+
function migrateLegacyDataIfNeeded() {
|
|
113
|
+
if (!fsSync.existsSync(LEGACY_DATA_DIR)) return;
|
|
114
|
+
if (LEGACY_DATA_DIR === DATA_DIR) return;
|
|
115
|
+
ensureDataDir();
|
|
116
|
+
const dataEmpty = fsSync.readdirSync(DATA_DIR).length === 0;
|
|
117
|
+
if (!dataEmpty) return;
|
|
118
|
+
try {
|
|
119
|
+
fsSync.cpSync(LEGACY_DATA_DIR, DATA_DIR, { recursive: true });
|
|
120
|
+
console.log(`[ccsm] migrated legacy data: ${LEGACY_DATA_DIR} → ${DATA_DIR}`);
|
|
121
|
+
} catch (e) {
|
|
122
|
+
console.error('[ccsm] legacy migration failed:', e.message);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
migrateLegacyDataIfNeeded();
|
|
127
|
+
|
|
128
|
+
// Strip dropped v0.x keys + clamp shape of survivors. Returns a fresh
|
|
129
|
+
// object so callers don't mutate DEFAULTS.
|
|
130
|
+
function mergeWithDefaults(partial) {
|
|
131
|
+
const out = { ...DEFAULTS, ...partial };
|
|
132
|
+
// Deep-merge devtunnel + tunnel so a partial save (just .tunnelId, or
|
|
133
|
+
// just .autoStart) doesn't wipe sibling keys.
|
|
134
|
+
out.devtunnel = { ...DEFAULTS.devtunnel, ...(partial?.devtunnel || {}) };
|
|
135
|
+
out.tunnel = { ...DEFAULTS.tunnel, ...(partial?.tunnel || {}) };
|
|
136
|
+
// Drop v0.x keys that the new architecture doesn't use.
|
|
137
|
+
delete out.terminal;
|
|
138
|
+
delete out.commandShell;
|
|
139
|
+
delete out.claudeCommand;
|
|
140
|
+
delete out.defaultTerminalMode;
|
|
141
|
+
delete out.autoFocusOnLaunch;
|
|
142
|
+
delete out.focusMovesToCenter;
|
|
143
|
+
delete out.snapshotIntervalMs;
|
|
144
|
+
delete out.snapshotHistoryKeep;
|
|
145
|
+
delete out.autoOpenBrowser;
|
|
146
|
+
delete out.browserMode;
|
|
147
|
+
delete out.finderPrompt;
|
|
148
|
+
|
|
149
|
+
if (!Array.isArray(out.repos)) out.repos = DEFAULTS.repos;
|
|
150
|
+
if (!Array.isArray(out.clis)) out.clis = [];
|
|
151
|
+
if (typeof out.editor !== 'string') out.editor = DEFAULTS.editor;
|
|
152
|
+
// Always inject builtin CLIs (claude, codex) if they're missing or were
|
|
153
|
+
// deleted from a saved config — they're managed by ccsm, the user can
|
|
154
|
+
// tweak args/shell but can't remove them. Preserves any user
|
|
155
|
+
// customisation on existing builtin entries.
|
|
156
|
+
for (const def of DEFAULT_CLIS) {
|
|
157
|
+
const existing = out.clis.find((c) => c.id === def.id);
|
|
158
|
+
if (existing) {
|
|
159
|
+
existing.builtin = true;
|
|
160
|
+
// Backfill defaults from the built-in template for any field the
|
|
161
|
+
// user's saved copy is missing OR has as an empty array. Empty
|
|
162
|
+
// arrays matter because users upgrading from a pre-0.15 config
|
|
163
|
+
// never wrote `newSessionIdArgs` (didn't exist), AND a partial
|
|
164
|
+
// 0.14→0.15 dev iteration shipped codex with `[]`. Treat both
|
|
165
|
+
// the same: a builtin with no template means "use the canonical
|
|
166
|
+
// one ccsm now knows about", since these fields are the
|
|
167
|
+
// integration contract with the upstream CLI — not user knobs.
|
|
168
|
+
const needsBackfill = (v) => v == null || (Array.isArray(v) && v.length === 0);
|
|
169
|
+
if (needsBackfill(existing.resumeIdArgs)) existing.resumeIdArgs = def.resumeIdArgs;
|
|
170
|
+
if (needsBackfill(existing.newSessionIdArgs)) existing.newSessionIdArgs = def.newSessionIdArgs;
|
|
171
|
+
// Heal the old broken copilot resume flag: `--resume <id>` (space form)
|
|
172
|
+
// isn't valid copilot syntax — it parses as a valueless `--resume`
|
|
173
|
+
// (session picker) plus a stray positional, hanging on a blank screen.
|
|
174
|
+
// The builtin now uses `--session-id <id>`. Migrate configs still
|
|
175
|
+
// carrying the old default; genuine user customisations are left alone.
|
|
176
|
+
if (existing.id === 'copilot'
|
|
177
|
+
&& Array.isArray(existing.resumeIdArgs)
|
|
178
|
+
&& existing.resumeIdArgs.join(' ') === '--resume <id>') {
|
|
179
|
+
existing.resumeIdArgs = def.resumeIdArgs;
|
|
180
|
+
}
|
|
181
|
+
// Drop the v0.x `resumeArgs` fallback — every builtin now has a
|
|
182
|
+
// pre-assigned UUID (claude/copilot via flag, codex via seed) so
|
|
183
|
+
// resumeIdArgs always applies on resume; the field is dead weight.
|
|
184
|
+
delete existing.resumeArgs;
|
|
185
|
+
if (!existing.type) existing.type = def.type;
|
|
186
|
+
} else {
|
|
187
|
+
out.clis.unshift({ ...def });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
// Normalize per-CLI fields.
|
|
191
|
+
out.clis = out.clis.map((c) => {
|
|
192
|
+
const { installed, installPath, resumeArgs, ...rest } = c; // strip computed probe fields + v0.x resumeArgs
|
|
193
|
+
const normalized = {
|
|
194
|
+
...rest,
|
|
195
|
+
args: Array.isArray(rest.args) ? rest.args : [],
|
|
196
|
+
resumeIdArgs: Array.isArray(rest.resumeIdArgs) ? rest.resumeIdArgs : [],
|
|
197
|
+
newSessionIdArgs: Array.isArray(rest.newSessionIdArgs) ? rest.newSessionIdArgs : [],
|
|
198
|
+
shell: ['direct', 'pwsh', 'cmd'].includes(rest.shell) ? rest.shell : 'direct',
|
|
199
|
+
type: ['claude', 'codex', 'copilot', 'other'].includes(rest.type) ? rest.type : 'other',
|
|
200
|
+
builtin: !!rest.builtin,
|
|
201
|
+
};
|
|
202
|
+
// Type-based fallback for non-builtin CLIs (wrappers that
|
|
203
|
+
// just call claude under the hood). If user picked
|
|
204
|
+
// type='claude' but left newSessionIdArgs / resumeIdArgs blank,
|
|
205
|
+
// assume they want the same args claude / copilot / codex use
|
|
206
|
+
// canonically — without this the wrapped CLI gets spawned with
|
|
207
|
+
// no UUID and ccsm can never recapture the upstream session.
|
|
208
|
+
// Builtins are already handled by the loop above with `def`.
|
|
209
|
+
if (!normalized.builtin && normalized.type !== 'other') {
|
|
210
|
+
const template = DEFAULT_CLIS.find((d) => d.type === normalized.type);
|
|
211
|
+
if (template) {
|
|
212
|
+
if (normalized.newSessionIdArgs.length === 0) {
|
|
213
|
+
normalized.newSessionIdArgs = [...template.newSessionIdArgs];
|
|
214
|
+
}
|
|
215
|
+
if (normalized.resumeIdArgs.length === 0) {
|
|
216
|
+
normalized.resumeIdArgs = [...template.resumeIdArgs];
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return normalized;
|
|
221
|
+
});
|
|
222
|
+
// Make sure defaultCliId points at an actual CLI; fall back to first.
|
|
223
|
+
if (!out.clis.find((c) => c.id === out.defaultCliId)) {
|
|
224
|
+
out.defaultCliId = out.clis[0].id;
|
|
225
|
+
}
|
|
226
|
+
return out;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function loadConfig() {
|
|
230
|
+
ensureDataDir();
|
|
231
|
+
try {
|
|
232
|
+
const raw = await fs.readFile(CONFIG_PATH, 'utf8');
|
|
233
|
+
return mergeWithDefaults(JSON.parse(raw));
|
|
234
|
+
} catch (e) {
|
|
235
|
+
if (e.code === 'ENOENT') {
|
|
236
|
+
const cfg = { ...DEFAULTS };
|
|
237
|
+
await atomicWriteJson(CONFIG_PATH, cfg);
|
|
238
|
+
return cfg;
|
|
239
|
+
}
|
|
240
|
+
throw e;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function saveConfig(partial) {
|
|
245
|
+
ensureDataDir();
|
|
246
|
+
return withFileLock(CONFIG_PATH, async () => {
|
|
247
|
+
const current = await loadConfig();
|
|
248
|
+
// mergeWithDefaults re-merges nested objects (devtunnel, tunnel)
|
|
249
|
+
// against DEFAULTS only, so a partial save like
|
|
250
|
+
// saveConfig({ tunnel: { autoStart: true } }) would reset the
|
|
251
|
+
// sibling token/provider back to defaults. Pre-merge the nested
|
|
252
|
+
// blocks against `current` so a partial update preserves siblings.
|
|
253
|
+
const merged = { ...current, ...partial };
|
|
254
|
+
if (partial && partial.devtunnel) {
|
|
255
|
+
merged.devtunnel = { ...current.devtunnel, ...partial.devtunnel };
|
|
256
|
+
}
|
|
257
|
+
if (partial && partial.tunnel) {
|
|
258
|
+
merged.tunnel = { ...current.tunnel, ...partial.tunnel };
|
|
259
|
+
}
|
|
260
|
+
const next = mergeWithDefaults(merged);
|
|
261
|
+
await atomicWriteJson(CONFIG_PATH, next);
|
|
262
|
+
return next;
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
module.exports = {
|
|
267
|
+
loadConfig,
|
|
268
|
+
saveConfig,
|
|
269
|
+
DATA_DIR,
|
|
270
|
+
CONFIG_PATH,
|
|
271
|
+
LEGACY_DATA_DIR,
|
|
272
|
+
DEFAULTS,
|
|
273
|
+
DEFAULT_CLIS,
|
|
274
|
+
};
|