@bakapiano/ccsm 0.22.6 → 0.22.8
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 +521 -540
- package/README.md +186 -189
- package/bin/ccsm.js +235 -235
- package/lib/cliActivity.js +36 -139
- package/lib/codexSeed.js +126 -183
- package/lib/config.js +277 -274
- package/lib/devices.js +229 -229
- package/lib/folders.js +124 -124
- package/lib/persistedSessions.js +179 -139
- package/lib/tunnel.js +621 -621
- package/lib/webTerminal.js +225 -225
- package/lib/winPath.js +1 -1
- 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 +154 -154
- 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 +546 -546
- package/public/css/tokens.css +81 -81
- package/public/css/wco.css +196 -196
- package/public/css/widgets.css +2347 -2725
- package/public/index.html +152 -152
- package/public/js/api.js +349 -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 +28 -0
- 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 +730 -713
- package/public/js/pages/LaunchPage.js +403 -421
- package/public/js/pages/RemotePage.js +743 -743
- package/public/js/pages/SessionsPage.js +54 -54
- package/public/js/state.js +335 -335
- package/public/js/util.js +1 -1
- 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 +1748 -1817
- package/lib/localCliSessions.js +0 -519
- package/public/js/components/AdoptModal.js +0 -261
- package/public/manifest.webmanifest +0 -25
- package/public/setup/index.html +0 -567
package/lib/config.js
CHANGED
|
@@ -1,274 +1,277 @@
|
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
function
|
|
113
|
-
if (!
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
if (existing.
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
...rest
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
if (normalized.
|
|
216
|
-
normalized.
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
}
|
|
257
|
-
if (partial && partial.
|
|
258
|
-
merged.
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
+
resumeLatestArgs: ['--continue'],
|
|
30
|
+
resumePickerArgs: ['--resume'],
|
|
31
|
+
shell: 'direct',
|
|
32
|
+
type: 'claude',
|
|
33
|
+
builtin: true,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: 'codex',
|
|
37
|
+
name: 'OpenAI Codex',
|
|
38
|
+
command: 'codex',
|
|
39
|
+
args: [],
|
|
40
|
+
resumeLatestArgs: ['resume', '--last'],
|
|
41
|
+
resumePickerArgs: ['resume'],
|
|
42
|
+
shell: 'direct',
|
|
43
|
+
type: 'codex',
|
|
44
|
+
builtin: true,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: 'copilot',
|
|
48
|
+
name: 'GitHub Copilot',
|
|
49
|
+
command: 'copilot',
|
|
50
|
+
args: [],
|
|
51
|
+
resumeLatestArgs: ['--continue'],
|
|
52
|
+
resumePickerArgs: ['--resume'],
|
|
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
|
+
// How a stopped session is reattached for its cwd: latest asks the CLI
|
|
63
|
+
// for the most recent session in that folder; picker opens the CLI's
|
|
64
|
+
// interactive resume picker for that folder.
|
|
65
|
+
resumeMode: 'latest',
|
|
66
|
+
// Repos available for cloning into a fresh workspace at launch time.
|
|
67
|
+
// { name: 'foo', url: 'https://github.com/me/foo.git', defaultSelected: true }
|
|
68
|
+
repos: [],
|
|
69
|
+
// Pluggable CLIs. Add custom wrappers or self-hosted
|
|
70
|
+
// proxies by appending an entry. defaultCliId picks one for the
|
|
71
|
+
// Launch button when the user doesn't override.
|
|
72
|
+
clis: DEFAULT_CLIS,
|
|
73
|
+
defaultCliId: 'claude',
|
|
74
|
+
// External editor command for the "Open in editor" session action.
|
|
75
|
+
// Spawned as `<editor> "<cwd>"`; default `code` = VS Code (whose Source
|
|
76
|
+
// Control panel doubles as the review-changes view once the folder's
|
|
77
|
+
// open). Point it at `cursor`, `code-insiders`, `subl`, … as desired.
|
|
78
|
+
editor: 'code',
|
|
79
|
+
// Devtunnel state. tunnelId holds the persistent (named) tunnel
|
|
80
|
+
// ccsm minted via `devtunnel create` on first Start. Reusing it
|
|
81
|
+
// across host restarts keeps the public URL — and therefore the
|
|
82
|
+
// remote browsers' approval records — stable. `devtunnel delete <id>`
|
|
83
|
+
// is invoked when the user explicitly rotates via the Reset button.
|
|
84
|
+
devtunnel: { tunnelId: null },
|
|
85
|
+
// Provider-agnostic tunnel prefs. When autoStart is on, the backend
|
|
86
|
+
// brings the tunnel up during its own startup (server.js boot hook) —
|
|
87
|
+
// NOT an OS-level autostart. token is persisted so share URLs survive
|
|
88
|
+
// a backend restart; it's written ONLY while autoStart is on and is
|
|
89
|
+
// stripped from /api/config so remote devices can't read it. provider
|
|
90
|
+
// is 'devtunnel' | 'cloudflared'.
|
|
91
|
+
tunnel: { autoStart: false, provider: null, token: null },
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
function ensureDataDir() {
|
|
95
|
+
if (!fsSync.existsSync(DATA_DIR)) {
|
|
96
|
+
fsSync.mkdirSync(DATA_DIR, { recursive: true });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isArgArray(v) {
|
|
101
|
+
return Array.isArray(v);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function cloneArgs(v) {
|
|
105
|
+
return Array.isArray(v) ? [...v] : [];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function needsBackfill(v) {
|
|
109
|
+
return !Array.isArray(v) || v.length === 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function migratePickerArgsFromLegacyResumeIdArgs(args) {
|
|
113
|
+
if (!Array.isArray(args)) return [];
|
|
114
|
+
const clean = args.map((a) => String(a));
|
|
115
|
+
if (clean.length === 2 && clean[1].includes('<id>')) {
|
|
116
|
+
if (clean[0] === '--resume' || clean[0] === 'resume') return [clean[0]];
|
|
117
|
+
}
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// If we find a legacy <repo>/data dir from before the home-dir move AND
|
|
122
|
+
// no ~/.ccsm yet, copy across. Idempotent — only fires when DATA_DIR is
|
|
123
|
+
// empty so existing users with both dirs aren't clobbered.
|
|
124
|
+
function migrateLegacyDataIfNeeded() {
|
|
125
|
+
if (!fsSync.existsSync(LEGACY_DATA_DIR)) return;
|
|
126
|
+
if (LEGACY_DATA_DIR === DATA_DIR) return;
|
|
127
|
+
ensureDataDir();
|
|
128
|
+
const dataEmpty = fsSync.readdirSync(DATA_DIR).length === 0;
|
|
129
|
+
if (!dataEmpty) return;
|
|
130
|
+
try {
|
|
131
|
+
fsSync.cpSync(LEGACY_DATA_DIR, DATA_DIR, { recursive: true });
|
|
132
|
+
console.log(`[ccsm] migrated legacy data: ${LEGACY_DATA_DIR} → ${DATA_DIR}`);
|
|
133
|
+
} catch (e) {
|
|
134
|
+
console.error('[ccsm] legacy migration failed:', e.message);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
migrateLegacyDataIfNeeded();
|
|
139
|
+
|
|
140
|
+
// Strip dropped v0.x keys + clamp shape of survivors. Returns a fresh
|
|
141
|
+
// object so callers don't mutate DEFAULTS.
|
|
142
|
+
function mergeWithDefaults(partial) {
|
|
143
|
+
const out = { ...DEFAULTS, ...partial };
|
|
144
|
+
// Deep-merge devtunnel + tunnel so a partial save (just .tunnelId, or
|
|
145
|
+
// just .autoStart) doesn't wipe sibling keys.
|
|
146
|
+
out.devtunnel = { ...DEFAULTS.devtunnel, ...(partial?.devtunnel || {}) };
|
|
147
|
+
out.tunnel = { ...DEFAULTS.tunnel, ...(partial?.tunnel || {}) };
|
|
148
|
+
// Drop v0.x keys that the new architecture doesn't use.
|
|
149
|
+
delete out.terminal;
|
|
150
|
+
delete out.commandShell;
|
|
151
|
+
delete out.claudeCommand;
|
|
152
|
+
delete out.defaultTerminalMode;
|
|
153
|
+
delete out.autoFocusOnLaunch;
|
|
154
|
+
delete out.focusMovesToCenter;
|
|
155
|
+
delete out.snapshotIntervalMs;
|
|
156
|
+
delete out.snapshotHistoryKeep;
|
|
157
|
+
delete out.autoOpenBrowser;
|
|
158
|
+
delete out.browserMode;
|
|
159
|
+
delete out.finderPrompt;
|
|
160
|
+
delete out.reserveWorkspacesForStoppedSessions;
|
|
161
|
+
|
|
162
|
+
if (!Array.isArray(out.repos)) out.repos = DEFAULTS.repos;
|
|
163
|
+
if (!Array.isArray(out.clis)) out.clis = [];
|
|
164
|
+
if (typeof out.editor !== 'string') out.editor = DEFAULTS.editor;
|
|
165
|
+
out.resumeMode = out.resumeMode === 'picker' ? 'picker' : 'latest';
|
|
166
|
+
// Always inject builtin CLIs (claude, codex, copilot) if they're missing or were
|
|
167
|
+
// deleted from a saved config — they're managed by ccsm, the user can
|
|
168
|
+
// tweak args/shell but can't remove them. Preserves any user
|
|
169
|
+
// customisation on existing builtin entries.
|
|
170
|
+
for (const def of DEFAULT_CLIS) {
|
|
171
|
+
const existing = out.clis.find((c) => c.id === def.id);
|
|
172
|
+
if (existing) {
|
|
173
|
+
existing.builtin = true;
|
|
174
|
+
// Backfill the canonical folder-level resume templates for built-ins.
|
|
175
|
+
// These are integration args, not regular user runtime args.
|
|
176
|
+
if (needsBackfill(existing.resumeLatestArgs)) existing.resumeLatestArgs = cloneArgs(def.resumeLatestArgs);
|
|
177
|
+
if (needsBackfill(existing.resumePickerArgs)) existing.resumePickerArgs = cloneArgs(def.resumePickerArgs);
|
|
178
|
+
delete existing.newSessionIdArgs;
|
|
179
|
+
delete existing.resumeIdArgs;
|
|
180
|
+
delete existing.resumeArgs;
|
|
181
|
+
if (!existing.type) existing.type = def.type;
|
|
182
|
+
} else {
|
|
183
|
+
out.clis.unshift({ ...def });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// Normalize per-CLI fields.
|
|
187
|
+
out.clis = out.clis.map((c) => {
|
|
188
|
+
const {
|
|
189
|
+
installed,
|
|
190
|
+
installPath,
|
|
191
|
+
resumeArgs,
|
|
192
|
+
resumeIdArgs,
|
|
193
|
+
newSessionIdArgs,
|
|
194
|
+
...rest
|
|
195
|
+
} = c; // strip computed probe fields + legacy session-id resume fields
|
|
196
|
+
const normalized = {
|
|
197
|
+
...rest,
|
|
198
|
+
args: isArgArray(rest.args) ? rest.args : [],
|
|
199
|
+
resumeLatestArgs: isArgArray(rest.resumeLatestArgs)
|
|
200
|
+
? rest.resumeLatestArgs
|
|
201
|
+
: (isArgArray(resumeArgs) ? resumeArgs : []),
|
|
202
|
+
resumePickerArgs: isArgArray(rest.resumePickerArgs)
|
|
203
|
+
? rest.resumePickerArgs
|
|
204
|
+
: migratePickerArgsFromLegacyResumeIdArgs(resumeIdArgs),
|
|
205
|
+
shell: ['direct', 'pwsh', 'cmd'].includes(rest.shell) ? rest.shell : 'direct',
|
|
206
|
+
type: ['claude', 'codex', 'copilot', 'other'].includes(rest.type) ? rest.type : 'other',
|
|
207
|
+
builtin: !!rest.builtin,
|
|
208
|
+
};
|
|
209
|
+
// Type-based fallback for non-builtin CLIs (wrappers that just call
|
|
210
|
+
// a known CLI under the hood). If the resume templates are blank, use
|
|
211
|
+
// the same folder-level resume commands as the matching built-in.
|
|
212
|
+
if (!normalized.builtin && normalized.type !== 'other') {
|
|
213
|
+
const template = DEFAULT_CLIS.find((d) => d.type === normalized.type);
|
|
214
|
+
if (template) {
|
|
215
|
+
if (normalized.resumeLatestArgs.length === 0) {
|
|
216
|
+
normalized.resumeLatestArgs = cloneArgs(template.resumeLatestArgs);
|
|
217
|
+
}
|
|
218
|
+
if (normalized.resumePickerArgs.length === 0) {
|
|
219
|
+
normalized.resumePickerArgs = cloneArgs(template.resumePickerArgs);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return normalized;
|
|
224
|
+
});
|
|
225
|
+
// Make sure defaultCliId points at an actual CLI; fall back to first.
|
|
226
|
+
if (!out.clis.find((c) => c.id === out.defaultCliId)) {
|
|
227
|
+
out.defaultCliId = out.clis[0].id;
|
|
228
|
+
}
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function loadConfig() {
|
|
233
|
+
ensureDataDir();
|
|
234
|
+
try {
|
|
235
|
+
const raw = await fs.readFile(CONFIG_PATH, 'utf8');
|
|
236
|
+
return mergeWithDefaults(JSON.parse(raw));
|
|
237
|
+
} catch (e) {
|
|
238
|
+
if (e.code === 'ENOENT') {
|
|
239
|
+
const cfg = { ...DEFAULTS };
|
|
240
|
+
await atomicWriteJson(CONFIG_PATH, cfg);
|
|
241
|
+
return cfg;
|
|
242
|
+
}
|
|
243
|
+
throw e;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function saveConfig(partial) {
|
|
248
|
+
ensureDataDir();
|
|
249
|
+
return withFileLock(CONFIG_PATH, async () => {
|
|
250
|
+
const current = await loadConfig();
|
|
251
|
+
// mergeWithDefaults re-merges nested objects (devtunnel, tunnel)
|
|
252
|
+
// against DEFAULTS only, so a partial save like
|
|
253
|
+
// saveConfig({ tunnel: { autoStart: true } }) would reset the
|
|
254
|
+
// sibling token/provider back to defaults. Pre-merge the nested
|
|
255
|
+
// blocks against `current` so a partial update preserves siblings.
|
|
256
|
+
const merged = { ...current, ...partial };
|
|
257
|
+
if (partial && partial.devtunnel) {
|
|
258
|
+
merged.devtunnel = { ...current.devtunnel, ...partial.devtunnel };
|
|
259
|
+
}
|
|
260
|
+
if (partial && partial.tunnel) {
|
|
261
|
+
merged.tunnel = { ...current.tunnel, ...partial.tunnel };
|
|
262
|
+
}
|
|
263
|
+
const next = mergeWithDefaults(merged);
|
|
264
|
+
await atomicWriteJson(CONFIG_PATH, next);
|
|
265
|
+
return next;
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
module.exports = {
|
|
270
|
+
loadConfig,
|
|
271
|
+
saveConfig,
|
|
272
|
+
DATA_DIR,
|
|
273
|
+
CONFIG_PATH,
|
|
274
|
+
LEGACY_DATA_DIR,
|
|
275
|
+
DEFAULTS,
|
|
276
|
+
DEFAULT_CLIS,
|
|
277
|
+
};
|