cchost 0.4.4 → 0.6.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.
- package/CHANGELOG.md +112 -0
- package/README.md +114 -44
- package/bin/cc.js +6 -1
- package/lib/actions.js +290 -0
- package/lib/banner.js +3 -1
- package/lib/ccforward.js +77 -0
- package/lib/cli.js +363 -104
- package/lib/color.js +3 -1
- package/lib/copy.js +43 -41
- package/lib/doctor.js +211 -68
- package/lib/env.js +65 -9
- package/lib/profiles.js +47 -14
- package/lib/share.js +124 -6
- package/lib/spawn.js +2 -1
- package/lib/ui/login.html +117 -0
- package/lib/ui/page.html +1171 -0
- package/lib/ui/server.js +384 -0
- package/lib/ui/tokens.css +80 -0
- package/package.json +3 -2
package/lib/profiles.js
CHANGED
|
@@ -102,20 +102,30 @@ function addProfile(name) {
|
|
|
102
102
|
return { dir };
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
|
|
105
|
+
// Single read/parse of a profile's .claude.json. Missing or malformed JSON is
|
|
106
|
+
// treated as absent state so one bad profile cannot break `cchost list`.
|
|
107
|
+
function readClaudeJson(dir) {
|
|
106
108
|
try {
|
|
107
109
|
const raw = fs.readFileSync(path.join(dir, '.claude.json'), 'utf8');
|
|
108
|
-
if (!raw.trim()) return
|
|
110
|
+
if (!raw.trim()) return null;
|
|
109
111
|
const data = JSON.parse(raw);
|
|
110
|
-
|
|
111
|
-
return data.oauthAccount.emailAddress;
|
|
112
|
-
}
|
|
113
|
-
return '';
|
|
112
|
+
return data && typeof data === 'object' && !Array.isArray(data) ? data : null;
|
|
114
113
|
} catch {
|
|
115
|
-
return
|
|
114
|
+
return null;
|
|
116
115
|
}
|
|
117
116
|
}
|
|
118
117
|
|
|
118
|
+
function emailFromClaude(data) {
|
|
119
|
+
if (data && data.oauthAccount && typeof data.oauthAccount.emailAddress === 'string') {
|
|
120
|
+
return data.oauthAccount.emailAddress;
|
|
121
|
+
}
|
|
122
|
+
return '';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function readEmail(dir) {
|
|
126
|
+
return emailFromClaude(readClaudeJson(dir));
|
|
127
|
+
}
|
|
128
|
+
|
|
119
129
|
function hasCredentials(dir) {
|
|
120
130
|
try {
|
|
121
131
|
return fs.existsSync(path.join(dir, '.credentials.json'));
|
|
@@ -124,7 +134,33 @@ function hasCredentials(dir) {
|
|
|
124
134
|
}
|
|
125
135
|
}
|
|
126
136
|
|
|
127
|
-
|
|
137
|
+
// Where this profile's Claude Code credentials live:
|
|
138
|
+
// 'file' — .credentials.json in the profile (Windows/Linux)
|
|
139
|
+
// 'keychain' — inferred from .claude.json on macOS; the token is in a
|
|
140
|
+
// Keychain entry namespaced by the profile's exact path
|
|
141
|
+
// 'none' — no sign of a login
|
|
142
|
+
function credentialSourceFrom(hasFile, data, platform) {
|
|
143
|
+
if (hasFile) return 'file';
|
|
144
|
+
if (platform === 'darwin' && data && data.oauthAccount != null) return 'keychain';
|
|
145
|
+
return 'none';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function credentialSource(dir, platform = process.platform) {
|
|
149
|
+
return credentialSourceFrom(hasCredentials(dir), readClaudeJson(dir), platform);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Everything `cchost list` needs about one profile, read from one parse.
|
|
153
|
+
function readProfile(dir, platform = process.platform) {
|
|
154
|
+
const data = readClaudeJson(dir);
|
|
155
|
+
const credentialSource = credentialSourceFrom(hasCredentials(dir), data, platform);
|
|
156
|
+
return {
|
|
157
|
+
loggedIn: credentialSource !== 'none',
|
|
158
|
+
credentialSource,
|
|
159
|
+
email: emailFromClaude(data),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function listProfiles(platform = process.platform) {
|
|
128
164
|
const base = baseDir();
|
|
129
165
|
if (!fs.existsSync(base)) return [];
|
|
130
166
|
let entries;
|
|
@@ -138,12 +174,7 @@ function listProfiles() {
|
|
|
138
174
|
if (!ent.isDirectory()) continue;
|
|
139
175
|
const name = ent.name;
|
|
140
176
|
const dir = path.join(base, name);
|
|
141
|
-
out.push({
|
|
142
|
-
name,
|
|
143
|
-
dir,
|
|
144
|
-
loggedIn: hasCredentials(dir),
|
|
145
|
-
email: readEmail(dir),
|
|
146
|
-
});
|
|
177
|
+
out.push({ name, dir, ...readProfile(dir, platform) });
|
|
147
178
|
}
|
|
148
179
|
out.sort((a, b) => a.name.localeCompare(b.name));
|
|
149
180
|
return out;
|
|
@@ -159,6 +190,8 @@ module.exports = {
|
|
|
159
190
|
ensureProfileDir,
|
|
160
191
|
readEmail,
|
|
161
192
|
hasCredentials,
|
|
193
|
+
credentialSource,
|
|
194
|
+
readProfile,
|
|
162
195
|
listProfiles,
|
|
163
196
|
addProfile,
|
|
164
197
|
};
|
package/lib/share.js
CHANGED
|
@@ -180,6 +180,76 @@ function createResourceLink(target, link, resource) {
|
|
|
180
180
|
}
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
// <profile>/.cc-private.json records resources the user made private on
|
|
184
|
+
// purpose (`cchost add --private`, `cchost unlink`), so an empty or absent
|
|
185
|
+
// private resource is not mistaken for a missing link and re-shared by
|
|
186
|
+
// `cchost doctor --fix`. `cchost link` removes entries. Never shared.
|
|
187
|
+
const PRIVATE_FILE = '.cc-private.json';
|
|
188
|
+
|
|
189
|
+
function readPrivate(profileDir) {
|
|
190
|
+
try {
|
|
191
|
+
const data = JSON.parse(fs.readFileSync(path.join(profileDir, PRIVATE_FILE), 'utf8'));
|
|
192
|
+
const list = data && Array.isArray(data.private) ? data.private : [];
|
|
193
|
+
return new Set(list.filter((r) => SHARED_RESOURCES.includes(r)));
|
|
194
|
+
} catch {
|
|
195
|
+
return new Set();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function writePrivate(profileDir, set) {
|
|
200
|
+
const p = path.join(profileDir, PRIVATE_FILE);
|
|
201
|
+
const list = SHARED_RESOURCES.filter((r) => set.has(r));
|
|
202
|
+
if (list.length === 0) {
|
|
203
|
+
fs.rmSync(p, { force: true });
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
fs.writeFileSync(p, JSON.stringify({ private: list }, null, 2) + '\n');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function markPrivate(profileDir, resources) {
|
|
210
|
+
const set = readPrivate(profileDir);
|
|
211
|
+
const before = set.size;
|
|
212
|
+
for (const r of resources) set.add(r);
|
|
213
|
+
if (set.size !== before) writePrivate(profileDir, set);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function unmarkPrivate(profileDir, resources) {
|
|
217
|
+
const set = readPrivate(profileDir);
|
|
218
|
+
let changed = false;
|
|
219
|
+
for (const r of resources) changed = set.delete(r) || changed;
|
|
220
|
+
if (changed) writePrivate(profileDir, set);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function isMarkedPrivate(profileDir, resource) {
|
|
224
|
+
return readPrivate(profileDir).has(resource);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Where one resource of a profile currently stands. Returns one of:
|
|
228
|
+
// 'shared' — linked to ~/.claude/ (symlink, junction, or hard link)
|
|
229
|
+
// 'private' — a real file/dir with content, or one recorded as private
|
|
230
|
+
// on purpose in .cc-private.json (even if empty or absent)
|
|
231
|
+
// 'empty' — a real but empty file/dir (link would replace it)
|
|
232
|
+
// 'missing' — nothing at the path
|
|
233
|
+
// 'foreign-link' — a symlink pointing somewhere other than ~/.claude/
|
|
234
|
+
function resourceState(profileDir, resource) {
|
|
235
|
+
const p = path.join(profileDir, resource);
|
|
236
|
+
if (isSharedResource(profileDir, resource)) return 'shared';
|
|
237
|
+
if (isLink(p)) return 'foreign-link';
|
|
238
|
+
if (isMarkedPrivate(profileDir, resource)) return 'private';
|
|
239
|
+
let st;
|
|
240
|
+
try {
|
|
241
|
+
st = fs.statSync(p);
|
|
242
|
+
} catch {
|
|
243
|
+
return 'missing';
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
const hasContent = st.isDirectory() ? fs.readdirSync(p).length > 0 : st.size > 0;
|
|
247
|
+
return hasContent ? 'private' : 'empty';
|
|
248
|
+
} catch {
|
|
249
|
+
return 'private';
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
183
253
|
// Link each requested resource of a profile into the shared pool (~/.claude/).
|
|
184
254
|
// Each resource is attempted independently — one failure doesn't abort the
|
|
185
255
|
// rest. Returns [{ resource, status, ... }]; status ∈
|
|
@@ -188,18 +258,21 @@ function createResourceLink(target, link, resource) {
|
|
|
188
258
|
// 'refused' is the v0.4.1 behavior when the profile holds private content at
|
|
189
259
|
// the resource path: we do NOT auto-move it into the pool (was the v0.4.0
|
|
190
260
|
// behavior for dirs). The user must move/remove it manually.
|
|
261
|
+
//
|
|
262
|
+
// opts.dryRun reports the same statuses without touching the filesystem.
|
|
191
263
|
function shareLink(profileName, opts = {}) {
|
|
264
|
+
const dryRun = Boolean(opts.dryRun);
|
|
192
265
|
profiles.validateProfileName(profileName);
|
|
193
266
|
const pdir = profiles.profileDir(profileName);
|
|
194
267
|
if (!fs.existsSync(pdir)) {
|
|
195
268
|
const err = new Error(
|
|
196
|
-
`Profile "${profileName}" does not exist. Create it with \`
|
|
269
|
+
`Profile "${profileName}" does not exist. Create it with \`cchost add ${profileName}\` first.`,
|
|
197
270
|
);
|
|
198
271
|
err.exitCode = 1;
|
|
199
272
|
throw err;
|
|
200
273
|
}
|
|
201
274
|
const subset = resolveSubset(opts);
|
|
202
|
-
ensurePoolResources(subset);
|
|
275
|
+
if (!dryRun) ensurePoolResources(subset);
|
|
203
276
|
const pool = poolDir();
|
|
204
277
|
const legacyPool = profiles.sharedDir();
|
|
205
278
|
const results = [];
|
|
@@ -217,13 +290,22 @@ function shareLink(profileName, opts = {}) {
|
|
|
217
290
|
}
|
|
218
291
|
if (resolved === path.join(legacyPool, resource)) {
|
|
219
292
|
// Legacy v0.3 pointer — silently relink to the new pool location.
|
|
293
|
+
if (dryRun) {
|
|
294
|
+
results.push({ resource, status: 'linked', relinkedFromLegacy: true });
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
220
297
|
removeLink(link);
|
|
221
298
|
const r = createResourceLink(target, link, resource);
|
|
222
299
|
results.push({ resource, status: 'linked', relinkedFromLegacy: true, kind: r.kind });
|
|
223
300
|
continue;
|
|
224
301
|
}
|
|
225
|
-
// Points somewhere unexpected — don't touch it
|
|
226
|
-
|
|
302
|
+
// Points somewhere unexpected — don't touch it, but report it as not
|
|
303
|
+
// shared so callers don't claim the resource is linked.
|
|
304
|
+
results.push({
|
|
305
|
+
resource,
|
|
306
|
+
status: 'foreign-link',
|
|
307
|
+
message: `symlink points to ${resolved}, not ${target} — left untouched`,
|
|
308
|
+
});
|
|
227
309
|
continue;
|
|
228
310
|
}
|
|
229
311
|
|
|
@@ -246,20 +328,34 @@ function shareLink(profileName, opts = {}) {
|
|
|
246
328
|
status: 'refused',
|
|
247
329
|
message:
|
|
248
330
|
`private content exists at ${link} — move or remove it manually, ` +
|
|
249
|
-
`then re-run \`
|
|
331
|
+
`then re-run \`cchost link ${profileName}\``,
|
|
250
332
|
});
|
|
251
333
|
continue;
|
|
252
334
|
}
|
|
253
335
|
// Empty private dir — safe to remove and replace with a symlink.
|
|
254
|
-
|
|
336
|
+
if (!dryRun) {
|
|
337
|
+
try { fs.rmdirSync(link); } catch { /* fall through; create will fail clearly */ }
|
|
338
|
+
}
|
|
255
339
|
}
|
|
256
340
|
|
|
341
|
+
if (dryRun) {
|
|
342
|
+
results.push({ resource, status: 'linked' });
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
257
345
|
const r = createResourceLink(target, link, resource);
|
|
258
346
|
results.push({ resource, status: 'linked', kind: r.kind });
|
|
259
347
|
} catch (err) {
|
|
260
348
|
results.push({ resource, status: 'failed', message: err.message });
|
|
261
349
|
}
|
|
262
350
|
}
|
|
351
|
+
if (!dryRun) {
|
|
352
|
+
unmarkPrivate(
|
|
353
|
+
pdir,
|
|
354
|
+
results
|
|
355
|
+
.filter((r) => r.status === 'linked' || r.status === 'already-linked')
|
|
356
|
+
.map((r) => r.resource),
|
|
357
|
+
);
|
|
358
|
+
}
|
|
263
359
|
return results;
|
|
264
360
|
}
|
|
265
361
|
|
|
@@ -268,7 +364,12 @@ function shareLink(profileName, opts = {}) {
|
|
|
268
364
|
// collisions, defensive) so the profile keeps its current view.
|
|
269
365
|
// Returns [{ resource, status, ... }]; status ∈
|
|
270
366
|
// 'unlinked' | 'not-linked' | 'failed'
|
|
367
|
+
//
|
|
368
|
+
// opts.dryRun reports the same statuses without touching the filesystem; dir
|
|
369
|
+
// results then carry `items`, the number of top-level entries that would be
|
|
370
|
+
// copied into the profile.
|
|
271
371
|
function shareUnlink(profileName, opts = {}) {
|
|
372
|
+
const dryRun = Boolean(opts.dryRun);
|
|
272
373
|
profiles.validateProfileName(profileName);
|
|
273
374
|
const pdir = profiles.profileDir(profileName);
|
|
274
375
|
if (!fs.existsSync(pdir)) {
|
|
@@ -290,6 +391,15 @@ function shareUnlink(profileName, opts = {}) {
|
|
|
290
391
|
continue;
|
|
291
392
|
}
|
|
292
393
|
|
|
394
|
+
if (dryRun) {
|
|
395
|
+
const r = { resource, status: 'unlinked' };
|
|
396
|
+
if (!isFileResource(resource)) {
|
|
397
|
+
try { r.items = fs.readdirSync(target).length; } catch { r.items = 0; }
|
|
398
|
+
}
|
|
399
|
+
results.push(r);
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
|
|
293
403
|
// Read pool content BEFORE removing the link (in case the link is the
|
|
294
404
|
// only path to it... well, not really — target is reachable directly —
|
|
295
405
|
// but reading first means we can fail without leaving a deleted link).
|
|
@@ -329,6 +439,9 @@ function shareUnlink(profileName, opts = {}) {
|
|
|
329
439
|
results.push({ resource, status: 'failed', message: err.message });
|
|
330
440
|
}
|
|
331
441
|
}
|
|
442
|
+
if (!dryRun) {
|
|
443
|
+
markPrivate(pdir, results.filter((r) => r.status === 'unlinked').map((r) => r.resource));
|
|
444
|
+
}
|
|
332
445
|
return results;
|
|
333
446
|
}
|
|
334
447
|
|
|
@@ -783,6 +896,11 @@ module.exports = {
|
|
|
783
896
|
isFileResource,
|
|
784
897
|
isLink,
|
|
785
898
|
isSharedResource,
|
|
899
|
+
resourceState,
|
|
900
|
+
PRIVATE_FILE,
|
|
901
|
+
markPrivate,
|
|
902
|
+
unmarkPrivate,
|
|
903
|
+
isMarkedPrivate,
|
|
786
904
|
removeLink,
|
|
787
905
|
shareLink,
|
|
788
906
|
shareUnlink,
|
package/lib/spawn.js
CHANGED
|
@@ -155,7 +155,7 @@ function platformSpawn(bin, args, opts) {
|
|
|
155
155
|
return spawn(bin, args, opts);
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
-
function spawnClaude({ args = [], profileDir = null, extraEnv = {} } = {}) {
|
|
158
|
+
function spawnClaude({ args = [], profileDir = null, extraEnv = {}, unsetEnv = [] } = {}) {
|
|
159
159
|
const bin = findClaude();
|
|
160
160
|
if (!bin) {
|
|
161
161
|
const err = new Error(
|
|
@@ -169,6 +169,7 @@ function spawnClaude({ args = [], profileDir = null, extraEnv = {} } = {}) {
|
|
|
169
169
|
warnIfOld(bin);
|
|
170
170
|
|
|
171
171
|
const childEnv = { ...process.env, ...extraEnv };
|
|
172
|
+
for (const name of unsetEnv) delete childEnv[name];
|
|
172
173
|
if (profileDir) {
|
|
173
174
|
childEnv.CLAUDE_CONFIG_DIR = profileDir;
|
|
174
175
|
} else {
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<meta name="color-scheme" content="light dark">
|
|
7
|
+
<link rel="icon" href="data:,">
|
|
8
|
+
<title>Sign in · cchost</title>
|
|
9
|
+
<style nonce="__NONCE__">
|
|
10
|
+
/*__TOKENS__*/
|
|
11
|
+
* { box-sizing: border-box; }
|
|
12
|
+
html, body { margin: 0; min-height: 100%; }
|
|
13
|
+
body {
|
|
14
|
+
min-height: 100vh; display: grid; place-items: center; padding: 24px 16px;
|
|
15
|
+
background: var(--bg); color: var(--text); font: 14px/1.5 var(--sans);
|
|
16
|
+
-webkit-font-smoothing: antialiased;
|
|
17
|
+
}
|
|
18
|
+
::selection { background: color-mix(in srgb, var(--primary) 28%, transparent); }
|
|
19
|
+
.card {
|
|
20
|
+
width: min(380px, 100%); display: grid; gap: 20px; padding: 28px;
|
|
21
|
+
background: var(--surface); border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow-sm);
|
|
22
|
+
animation: enter 420ms var(--ease) both;
|
|
23
|
+
}
|
|
24
|
+
@keyframes enter { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
|
|
25
|
+
.brand { display: flex; align-items: center; gap: 10px; font-weight: 650; font-size: 15px; }
|
|
26
|
+
.brand svg { width: 22px; height: 22px; }
|
|
27
|
+
.brand .mark { fill: var(--primary); opacity: 0.9; }
|
|
28
|
+
.brand small { margin-left: auto; color: var(--text-3); font-weight: 500; font-size: 12px; }
|
|
29
|
+
h1 { margin: 0; font-size: 17px; font-weight: 650; letter-spacing: -0.01em; }
|
|
30
|
+
.lede { margin: 4px 0 0; color: var(--text-2); font-size: 13px; }
|
|
31
|
+
form { display: grid; gap: 14px; }
|
|
32
|
+
label { display: grid; gap: 6px; font-size: 13px; font-weight: 550; }
|
|
33
|
+
.input {
|
|
34
|
+
height: 36px; padding: 0 10px; border-radius: 6px; border: 1px solid var(--border-strong);
|
|
35
|
+
background: var(--surface); color: var(--text); font: inherit; font-weight: 400;
|
|
36
|
+
transition: border-color 120ms, box-shadow 120ms;
|
|
37
|
+
}
|
|
38
|
+
.input:focus-visible { outline: none; border-color: var(--focus); box-shadow: 0 0 0 3px color-mix(in srgb, var(--focus) 25%, transparent); }
|
|
39
|
+
.input[aria-invalid="true"] { border-color: var(--err); }
|
|
40
|
+
.btn {
|
|
41
|
+
height: 36px; border-radius: 6px; border: 1px solid var(--primary); background: var(--primary); color: var(--primary-fg);
|
|
42
|
+
font: inherit; font-weight: 550; cursor: pointer; transition: background 120ms, transform 120ms;
|
|
43
|
+
}
|
|
44
|
+
.btn:hover { background: var(--primary-hover); }
|
|
45
|
+
.btn:active { transform: translateY(1px); }
|
|
46
|
+
.btn:disabled { opacity: 0.6; cursor: progress; }
|
|
47
|
+
.btn:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
|
48
|
+
.err { min-height: 20px; margin: 0; color: var(--err-fg); font-size: 13px; }
|
|
49
|
+
.err.shake { animation: shake 320ms var(--ease); }
|
|
50
|
+
@keyframes shake { 0%, 100% { transform: none; } 25% { transform: translateX(-4px); } 75% { transform: translateX(4px); } }
|
|
51
|
+
.hint { margin: 0; padding-top: 16px; border-top: 1px solid var(--border); color: var(--text-3); font-size: 12.5px; }
|
|
52
|
+
code { font-family: var(--mono); font-size: 12px; color: var(--text-2); }
|
|
53
|
+
@media (prefers-reduced-motion: reduce) {
|
|
54
|
+
.card, .err.shake { animation: none; }
|
|
55
|
+
}
|
|
56
|
+
</style>
|
|
57
|
+
</head>
|
|
58
|
+
<body>
|
|
59
|
+
<main class="card">
|
|
60
|
+
<div class="brand">
|
|
61
|
+
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="2.5" y="6.5" width="12" height="12" rx="3" fill="none" stroke="currentColor" stroke-width="1.75"/><rect class="mark" x="9.5" y="2.5" width="12" height="12" rx="3"/></svg>
|
|
62
|
+
cchost <small>v__VERSION__</small>
|
|
63
|
+
</div>
|
|
64
|
+
<div>
|
|
65
|
+
<h1>Sign in</h1>
|
|
66
|
+
<p class="lede" id="lede">Use the username and password printed in the terminal where you ran <code>cchost ui</code>.</p>
|
|
67
|
+
</div>
|
|
68
|
+
<form id="form" novalidate>
|
|
69
|
+
<label>Username<input class="input" id="user" name="user" autocomplete="username" spellcheck="false" value="cchost" required></label>
|
|
70
|
+
<label>Password<input class="input" id="pass" name="pass" type="password" autocomplete="current-password" spellcheck="false" required autofocus aria-describedby="err"></label>
|
|
71
|
+
<p class="err" id="err" role="alert"></p>
|
|
72
|
+
<button class="btn" id="go" type="submit">Sign in</button>
|
|
73
|
+
</form>
|
|
74
|
+
<p class="hint">The password changes every time <code>cchost ui</code> starts, and only this machine can reach this page.</p>
|
|
75
|
+
</main>
|
|
76
|
+
<script nonce="__NONCE__">
|
|
77
|
+
(() => {
|
|
78
|
+
'use strict';
|
|
79
|
+
const $ = (id) => document.getElementById(id);
|
|
80
|
+
try {
|
|
81
|
+
const t = localStorage.getItem('cchost-theme');
|
|
82
|
+
if (t === 'light' || t === 'dark') document.documentElement.setAttribute('data-theme', t);
|
|
83
|
+
} catch { /* storage blocked */ }
|
|
84
|
+
if (new URLSearchParams(location.search).has('expired')) {
|
|
85
|
+
$('lede').textContent = 'That sign-in link was already used or has expired. Sign in with the password from the terminal.';
|
|
86
|
+
history.replaceState(null, '', '/');
|
|
87
|
+
}
|
|
88
|
+
$('form').addEventListener('submit', async (ev) => {
|
|
89
|
+
ev.preventDefault();
|
|
90
|
+
const err = $('err');
|
|
91
|
+
err.textContent = '';
|
|
92
|
+
err.classList.remove('shake');
|
|
93
|
+
$('go').disabled = true;
|
|
94
|
+
$('go').textContent = 'Signing in…';
|
|
95
|
+
try {
|
|
96
|
+
const res = await fetch('/api/login', {
|
|
97
|
+
method: 'POST',
|
|
98
|
+
headers: { 'Content-Type': 'application/json', 'X-CCHost': '1' },
|
|
99
|
+
body: JSON.stringify({ user: $('user').value, pass: $('pass').value }),
|
|
100
|
+
});
|
|
101
|
+
if (res.ok) { location.replace('/'); return; }
|
|
102
|
+
const json = await res.json().catch(() => ({}));
|
|
103
|
+
err.textContent = json.error || `Sign-in failed (${res.status}).`;
|
|
104
|
+
} catch {
|
|
105
|
+
err.textContent = 'Cannot reach cchost ui — is it still running?';
|
|
106
|
+
}
|
|
107
|
+
void err.offsetWidth;
|
|
108
|
+
err.classList.add('shake');
|
|
109
|
+
$('pass').setAttribute('aria-invalid', 'true');
|
|
110
|
+
$('pass').select();
|
|
111
|
+
$('go').disabled = false;
|
|
112
|
+
$('go').textContent = 'Sign in';
|
|
113
|
+
});
|
|
114
|
+
})();
|
|
115
|
+
</script>
|
|
116
|
+
</body>
|
|
117
|
+
</html>
|