brep-io-kernel 1.0.30 → 1.0.31

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.
@@ -0,0 +1,1177 @@
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
+ <title>GitHub CDN-only Repo File Editor (Device Flow)</title>
7
+ <style>
8
+ :root {
9
+ color-scheme: dark;
10
+ --bg: #0b0f14;
11
+ --panel: #0f1620;
12
+ --panel2: #0c121a;
13
+ --text: #e6edf3;
14
+ --muted: #9aa6b2;
15
+ --border: #243041;
16
+ --accent: #4cc2ff;
17
+ --danger: #ff5c5c;
18
+ --ok: #45d483;
19
+ --warn: #ffd65a;
20
+ --shadow: 0 10px 30px rgba(0,0,0,.35);
21
+ --radius: 12px;
22
+ --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
23
+ --sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji","Segoe UI Emoji";
24
+ }
25
+ * { box-sizing: border-box; }
26
+ body {
27
+ margin: 0;
28
+ background: radial-gradient(1200px 800px at 20% 0%, #0f1a2a 0%, var(--bg) 55%);
29
+ color: var(--text);
30
+ font-family: var(--sans);
31
+ }
32
+ header {
33
+ padding: 18px 20px;
34
+ border-bottom: 1px solid var(--border);
35
+ background: rgba(10, 15, 22, .75);
36
+ backdrop-filter: blur(8px);
37
+ position: sticky;
38
+ top: 0;
39
+ z-index: 10;
40
+ }
41
+ header h1 {
42
+ margin: 0;
43
+ font-size: 16px;
44
+ letter-spacing: .2px;
45
+ font-weight: 650;
46
+ }
47
+ header .sub {
48
+ margin-top: 6px;
49
+ font-size: 12px;
50
+ color: var(--muted);
51
+ }
52
+ main {
53
+ padding: 18px 20px 26px;
54
+ max-width: 1200px;
55
+ margin: 0 auto;
56
+ }
57
+ .grid {
58
+ display: grid;
59
+ grid-template-columns: 420px 1fr;
60
+ gap: 14px;
61
+ align-items: start;
62
+ }
63
+ @media (max-width: 980px) {
64
+ .grid { grid-template-columns: 1fr; }
65
+ }
66
+ .card {
67
+ background: linear-gradient(180deg, rgba(255,255,255,.03), rgba(255,255,255,.01));
68
+ border: 1px solid var(--border);
69
+ border-radius: var(--radius);
70
+ box-shadow: var(--shadow);
71
+ overflow: hidden;
72
+ }
73
+ .card .hd {
74
+ padding: 12px 14px;
75
+ border-bottom: 1px solid var(--border);
76
+ display: flex;
77
+ align-items: center;
78
+ justify-content: space-between;
79
+ gap: 10px;
80
+ background: rgba(0,0,0,.15);
81
+ }
82
+ .card .hd h2 {
83
+ margin: 0;
84
+ font-size: 13px;
85
+ font-weight: 650;
86
+ letter-spacing: .2px;
87
+ }
88
+ .card .bd { padding: 12px 14px; }
89
+ .row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
90
+ .row > * { flex: 0 0 auto; }
91
+ label {
92
+ font-size: 12px;
93
+ color: var(--muted);
94
+ display: block;
95
+ margin-bottom: 6px;
96
+ }
97
+ input, select, button, textarea {
98
+ font: inherit;
99
+ color: var(--text);
100
+ }
101
+ input[type="text"], input[type="password"], select, textarea {
102
+ width: 100%;
103
+ background: rgba(0,0,0,.25);
104
+ border: 1px solid var(--border);
105
+ border-radius: 10px;
106
+ padding: 10px 10px;
107
+ outline: none;
108
+ }
109
+ input[type="text"]:focus, input[type="password"]:focus, select:focus, textarea:focus {
110
+ border-color: rgba(76,194,255,.6);
111
+ box-shadow: 0 0 0 3px rgba(76,194,255,.15);
112
+ }
113
+ textarea {
114
+ font-family: var(--mono);
115
+ font-size: 12.5px;
116
+ line-height: 1.45;
117
+ min-height: 460px;
118
+ resize: vertical;
119
+ }
120
+ button {
121
+ border: 1px solid var(--border);
122
+ background: rgba(76,194,255,.12);
123
+ border-radius: 10px;
124
+ padding: 9px 10px;
125
+ cursor: pointer;
126
+ transition: transform .04s ease, background .15s ease, border-color .15s ease;
127
+ user-select: none;
128
+ font-weight: 650;
129
+ font-size: 12px;
130
+ }
131
+ button:hover { background: rgba(76,194,255,.18); border-color: rgba(76,194,255,.35); }
132
+ button:active { transform: translateY(1px); }
133
+ button[disabled] {
134
+ opacity: .55;
135
+ cursor: not-allowed;
136
+ transform: none;
137
+ }
138
+ .btn-ghost { background: rgba(255,255,255,.04); }
139
+ .btn-danger { background: rgba(255,92,92,.12); }
140
+ .btn-danger:hover { background: rgba(255,92,92,.18); border-color: rgba(255,92,92,.35); }
141
+ .pill {
142
+ font-size: 11px;
143
+ padding: 4px 8px;
144
+ border-radius: 999px;
145
+ border: 1px solid var(--border);
146
+ background: rgba(255,255,255,.04);
147
+ color: var(--muted);
148
+ display: inline-flex;
149
+ gap: 6px;
150
+ align-items: center;
151
+ white-space: nowrap;
152
+ }
153
+ .pill.ok { color: var(--ok); border-color: rgba(69,212,131,.35); background: rgba(69,212,131,.08); }
154
+ .pill.warn { color: var(--warn); border-color: rgba(255,214,90,.35); background: rgba(255,214,90,.08); }
155
+ .pill.bad { color: var(--danger); border-color: rgba(255,92,92,.35); background: rgba(255,92,92,.08); }
156
+
157
+ .status {
158
+ margin-top: 10px;
159
+ padding: 10px 10px;
160
+ border-radius: 10px;
161
+ border: 1px solid var(--border);
162
+ background: rgba(0,0,0,.18);
163
+ font-size: 12px;
164
+ color: var(--muted);
165
+ white-space: pre-wrap;
166
+ font-family: var(--mono);
167
+ }
168
+
169
+ .list {
170
+ margin-top: 10px;
171
+ border: 1px solid var(--border);
172
+ border-radius: 10px;
173
+ overflow: hidden;
174
+ background: rgba(0,0,0,.18);
175
+ }
176
+ .list .item {
177
+ display: flex;
178
+ gap: 10px;
179
+ align-items: center;
180
+ justify-content: space-between;
181
+ padding: 10px 10px;
182
+ border-bottom: 1px solid rgba(255,255,255,.05);
183
+ cursor: pointer;
184
+ }
185
+ .list .item:last-child { border-bottom: 0; }
186
+ .list .item:hover { background: rgba(76,194,255,.06); }
187
+ .left {
188
+ display: flex;
189
+ gap: 10px;
190
+ align-items: center;
191
+ min-width: 0;
192
+ }
193
+ .icon {
194
+ width: 26px; height: 26px;
195
+ border-radius: 8px;
196
+ border: 1px solid var(--border);
197
+ display: grid;
198
+ place-items: center;
199
+ background: rgba(255,255,255,.04);
200
+ flex: 0 0 auto;
201
+ font-family: var(--mono);
202
+ font-size: 12px;
203
+ color: var(--muted);
204
+ }
205
+ .name {
206
+ font-size: 12.5px;
207
+ font-weight: 650;
208
+ min-width: 0;
209
+ overflow: hidden;
210
+ text-overflow: ellipsis;
211
+ white-space: nowrap;
212
+ }
213
+ .meta {
214
+ font-size: 11px;
215
+ color: var(--muted);
216
+ font-family: var(--mono);
217
+ white-space: nowrap;
218
+ }
219
+
220
+ .breadcrumb {
221
+ display: flex;
222
+ gap: 6px;
223
+ align-items: center;
224
+ flex-wrap: wrap;
225
+ margin-top: 8px;
226
+ font-size: 12px;
227
+ color: var(--muted);
228
+ }
229
+ .crumb {
230
+ cursor: pointer;
231
+ padding: 4px 8px;
232
+ border-radius: 999px;
233
+ border: 1px solid rgba(255,255,255,.07);
234
+ background: rgba(255,255,255,.03);
235
+ }
236
+ .crumb:hover { border-color: rgba(76,194,255,.35); }
237
+
238
+ .split {
239
+ display: grid;
240
+ grid-template-columns: 1fr 1fr;
241
+ gap: 10px;
242
+ }
243
+ @media (max-width: 980px) { .split { grid-template-columns: 1fr; } }
244
+
245
+ .small { font-size: 12px; color: var(--muted); line-height: 1.4; }
246
+ .kbd {
247
+ font-family: var(--mono);
248
+ padding: 2px 6px;
249
+ border-radius: 6px;
250
+ background: rgba(255,255,255,.06);
251
+ border: 1px solid rgba(255,255,255,.08);
252
+ color: var(--text);
253
+ font-size: 11px;
254
+ }
255
+ </style>
256
+ </head>
257
+
258
+ <body>
259
+ <header>
260
+ <h1>GitHub CDN-only Repo File Editor (OAuth Device Flow)</h1>
261
+ <div class="sub">
262
+ No backend. Uses GitHub Device Flow for auth, then the REST Contents API to list/read/write files.
263
+ </div>
264
+ </header>
265
+
266
+ <main>
267
+ <div class="grid">
268
+ <!-- LEFT: Auth + Repo + Browser -->
269
+ <section class="card">
270
+ <div class="hd">
271
+ <h2>1) Authenticate</h2>
272
+ <span id="authPill" class="pill">signed out</span>
273
+ </div>
274
+ <div class="bd">
275
+ <div class="small">
276
+ Create a GitHub <span class="kbd">OAuth App</span>, copy its <span class="kbd">Client ID</span>,
277
+ and (in the app settings) enable <span class="kbd">Device Flow</span>.
278
+ </div>
279
+
280
+ <div style="height:10px"></div>
281
+
282
+ <div class="split">
283
+ <div>
284
+ <label for="clientId">OAuth App Client ID</label>
285
+ <input id="clientId" type="text" spellcheck="false" placeholder="Iv1.••••••••••••••••" />
286
+ </div>
287
+ <div>
288
+ <label for="scope">Scope</label>
289
+ <select id="scope">
290
+ <option value="repo">repo (read/write private & public repos)</option>
291
+ <option value="public_repo">public_repo (write public repos only)</option>
292
+ <option value="repo read:user">repo + read:user</option>
293
+ <option value="public_repo read:user">public_repo + read:user</option>
294
+ </select>
295
+ </div>
296
+ </div>
297
+
298
+ <div style="height:10px"></div>
299
+
300
+ <div class="row">
301
+ <button id="btnStartAuth">Start sign-in</button>
302
+ <button id="btnOpenVerify" class="btn-ghost" disabled>Open GitHub verify page</button>
303
+ <button id="btnCopyCode" class="btn-ghost" disabled>Copy code</button>
304
+ <button id="btnSignOut" class="btn-danger" disabled>Sign out</button>
305
+ </div>
306
+
307
+ <div id="authInfo" class="status" style="display:none"></div>
308
+
309
+ <div style="height:14px"></div>
310
+
311
+ <div class="row" style="justify-content:space-between">
312
+ <h2 style="margin:0;font-size:13px;font-weight:650;">2) Select repo</h2>
313
+ <span id="userBadge" class="pill" style="display:none"></span>
314
+ </div>
315
+
316
+ <div style="height:8px"></div>
317
+
318
+ <label for="repoSelect">Repositories (you have access to)</label>
319
+ <select id="repoSelect" disabled>
320
+ <option value="">— sign in first —</option>
321
+ </select>
322
+
323
+ <div style="height:12px"></div>
324
+
325
+ <div class="row" style="justify-content:space-between; align-items:flex-end">
326
+ <div>
327
+ <h2 style="margin:0;font-size:13px;font-weight:650;">3) Browse files</h2>
328
+ <div class="small">Click folders to navigate; click a file to load into the editor.</div>
329
+ </div>
330
+ <button id="btnRefreshFiles" class="btn-ghost" disabled>Refresh</button>
331
+ </div>
332
+
333
+ <div id="breadcrumb" class="breadcrumb" style="display:none"></div>
334
+ <div id="fileList" class="list" style="display:none"></div>
335
+
336
+ <div id="leftStatus" class="status" style="margin-top:10px; display:none"></div>
337
+ </div>
338
+ </section>
339
+
340
+ <!-- RIGHT: Editor -->
341
+ <section class="card">
342
+ <div class="hd">
343
+ <h2>4) Edit & write back</h2>
344
+ <span id="filePill" class="pill">no file loaded</span>
345
+ </div>
346
+ <div class="bd">
347
+ <div class="split">
348
+ <div>
349
+ <label for="currentFile">Loaded file</label>
350
+ <input id="currentFile" type="text" readonly value="—" />
351
+ </div>
352
+ <div>
353
+ <label for="branchInput">Branch (optional)</label>
354
+ <input id="branchInput" type="text" spellcheck="false" placeholder="(defaults to repo default branch)" />
355
+ </div>
356
+ </div>
357
+
358
+ <div style="height:10px"></div>
359
+
360
+ <label for="editor">File contents</label>
361
+ <textarea id="editor" spellcheck="false" disabled placeholder="Load a file to edit it…"></textarea>
362
+
363
+ <div style="height:10px"></div>
364
+
365
+ <div class="split">
366
+ <div>
367
+ <label for="commitMsg">Commit message</label>
368
+ <input id="commitMsg" type="text" spellcheck="false" placeholder="Update <path>" />
369
+ </div>
370
+ <div>
371
+ <label>&nbsp;</label>
372
+ <div class="row" style="justify-content:flex-end">
373
+ <button id="btnReload" class="btn-ghost" disabled>Reload file</button>
374
+ <button id="btnSave" disabled>Save to repo</button>
375
+ </div>
376
+ </div>
377
+ </div>
378
+
379
+ <div id="rightStatus" class="status" style="margin-top:10px; display:none"></div>
380
+
381
+ <div class="small" style="margin-top:10px">
382
+ Tip: if you get a <span class="kbd">409</span> on save, the file changed upstream—reload, merge, and save again.
383
+ </div>
384
+ </div>
385
+ </section>
386
+ </div>
387
+ </main>
388
+
389
+ <script>
390
+ /**
391
+ * CDN-only GitHub file editor using OAuth Device Flow + REST Contents API.
392
+ * No backend; token is acquired and used in-browser.
393
+ *
394
+ * Endpoints used:
395
+ * - https://github.com/login/device/code
396
+ * - https://github.com/login/oauth/access_token
397
+ * - https://api.github.com/user
398
+ * - https://api.github.com/user/repos
399
+ * - https://api.github.com/repos/{owner}/{repo}
400
+ * - https://api.github.com/repos/{owner}/{repo}/contents/{path}
401
+ */
402
+
403
+ const GH = {
404
+ deviceCodeUrl: "https://github.com/login/device/code",
405
+ accessTokenUrl: "https://github.com/login/oauth/access_token",
406
+ apiBase: "https://api.github.com",
407
+ apiVersion: "2022-11-28",
408
+ };
409
+
410
+ const els = {
411
+ authPill: document.getElementById("authPill"),
412
+ clientId: document.getElementById("clientId"),
413
+ scope: document.getElementById("scope"),
414
+ btnStartAuth: document.getElementById("btnStartAuth"),
415
+ btnOpenVerify: document.getElementById("btnOpenVerify"),
416
+ btnCopyCode: document.getElementById("btnCopyCode"),
417
+ btnSignOut: document.getElementById("btnSignOut"),
418
+ authInfo: document.getElementById("authInfo"),
419
+
420
+ userBadge: document.getElementById("userBadge"),
421
+ repoSelect: document.getElementById("repoSelect"),
422
+ btnRefreshFiles: document.getElementById("btnRefreshFiles"),
423
+
424
+ breadcrumb: document.getElementById("breadcrumb"),
425
+ fileList: document.getElementById("fileList"),
426
+ leftStatus: document.getElementById("leftStatus"),
427
+
428
+ filePill: document.getElementById("filePill"),
429
+ currentFile: document.getElementById("currentFile"),
430
+ branchInput: document.getElementById("branchInput"),
431
+ editor: document.getElementById("editor"),
432
+ commitMsg: document.getElementById("commitMsg"),
433
+ btnReload: document.getElementById("btnReload"),
434
+ btnSave: document.getElementById("btnSave"),
435
+ rightStatus: document.getElementById("rightStatus"),
436
+ };
437
+
438
+ // ---------- Small utils ----------
439
+
440
+ function showStatus(el, text) {
441
+ el.style.display = "block";
442
+ el.textContent = text;
443
+ }
444
+
445
+ function hideStatus(el) {
446
+ el.style.display = "none";
447
+ el.textContent = "";
448
+ }
449
+
450
+ function setPill(pillEl, { text, kind }) {
451
+ pillEl.textContent = text;
452
+ pillEl.className = "pill" + (kind ? (" " + kind) : "");
453
+ }
454
+
455
+ function formUrlEncode(obj) {
456
+ return Object.entries(obj)
457
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
458
+ .join("&");
459
+ }
460
+
461
+ function base64EncodeUtf8(str) {
462
+ const bytes = new TextEncoder().encode(str);
463
+ let binary = "";
464
+ for (const b of bytes) binary += String.fromCharCode(b);
465
+ return btoa(binary);
466
+ }
467
+
468
+ function base64DecodeUtf8(b64) {
469
+ const binary = atob(b64);
470
+ const bytes = new Uint8Array([...binary].map(ch => ch.charCodeAt(0)));
471
+ return new TextDecoder().decode(bytes);
472
+ }
473
+
474
+ function authHeaders(token) {
475
+ return {
476
+ "Accept": "application/vnd.github+json",
477
+ "Authorization": `Bearer ${token}`,
478
+ "X-GitHub-Api-Version": GH.apiVersion,
479
+ };
480
+ }
481
+
482
+ function safeJoinPath(a, b) {
483
+ if (!a) return b || "";
484
+ if (!b) return a;
485
+ return a.replace(/\/+$/,"") + "/" + b.replace(/^\/+/,"");
486
+ }
487
+
488
+ function parseRepoFullName(full) {
489
+ // "owner/repo"
490
+ const [owner, repo] = String(full).split("/");
491
+ if (!owner || !repo) return null;
492
+ return { owner, repo };
493
+ }
494
+
495
+ async function ghFetchJson(url, token, opts = {}) {
496
+ const res = await fetch(url, {
497
+ ...opts,
498
+ headers: {
499
+ ...(opts.headers || {}),
500
+ ...authHeaders(token),
501
+ }
502
+ });
503
+
504
+ // Handle rate limit / errors with useful info.
505
+ if (!res.ok) {
506
+ const text = await res.text();
507
+ const msg = [
508
+ `GitHub request failed: ${res.status} ${res.statusText}`,
509
+ `URL: ${url}`,
510
+ text ? `Body: ${text}` : "",
511
+ ].filter(Boolean).join("\n");
512
+ throw new Error(msg);
513
+ }
514
+
515
+ // Some responses are empty; most here are JSON.
516
+ const ct = res.headers.get("content-type") || "";
517
+ if (ct.includes("application/json")) return await res.json();
518
+ return await res.text();
519
+ }
520
+
521
+ async function ghFetchAllPages(url, token) {
522
+ // GitHub uses Link header pagination for /user/repos.
523
+ // We'll follow rel="next" until done.
524
+ let out = [];
525
+ let next = url;
526
+
527
+ while (next) {
528
+ const res = await fetch(next, { headers: authHeaders(token) });
529
+ if (!res.ok) {
530
+ const text = await res.text();
531
+ throw new Error(`GitHub request failed: ${res.status}\n${text}`);
532
+ }
533
+ const data = await res.json();
534
+ out = out.concat(data);
535
+
536
+ const link = res.headers.get("link");
537
+ next = null;
538
+ if (link) {
539
+ const parts = link.split(",").map(s => s.trim());
540
+ for (const p of parts) {
541
+ const m = p.match(/<([^>]+)>;\s*rel="([^"]+)"/);
542
+ if (m && m[2] === "next") next = m[1];
543
+ }
544
+ }
545
+ }
546
+
547
+ return out;
548
+ }
549
+
550
+ // ---------- App state ----------
551
+
552
+ const state = {
553
+ token: null,
554
+ user: null,
555
+
556
+ // repo selection
557
+ repoFull: null, // "owner/repo"
558
+ repo: null, // { owner, repo }
559
+ defaultBranch: null,
560
+
561
+ // browsing
562
+ currentPath: "", // directory path ("" for root)
563
+ currentRef: null, // branch override
564
+
565
+ // file loaded
566
+ loadedFile: {
567
+ path: null,
568
+ sha: null,
569
+ text: null,
570
+ },
571
+
572
+ // device flow info
573
+ device: null,
574
+ pollAbort: null,
575
+ };
576
+
577
+ function resetRepoAndFileUI() {
578
+ els.repoSelect.innerHTML = `<option value="">— select a repo —</option>`;
579
+ els.repoSelect.disabled = true;
580
+
581
+ els.breadcrumb.style.display = "none";
582
+ els.fileList.style.display = "none";
583
+ els.fileList.innerHTML = "";
584
+ hideStatus(els.leftStatus);
585
+
586
+ state.repoFull = null;
587
+ state.repo = null;
588
+ state.defaultBranch = null;
589
+ state.currentPath = "";
590
+ state.currentRef = null;
591
+
592
+ clearLoadedFile();
593
+ }
594
+
595
+ function clearLoadedFile() {
596
+ state.loadedFile = { path: null, sha: null, text: null };
597
+ els.currentFile.value = "—";
598
+ els.editor.value = "";
599
+ els.editor.disabled = true;
600
+ els.btnReload.disabled = true;
601
+ els.btnSave.disabled = true;
602
+ els.commitMsg.value = "";
603
+ setPill(els.filePill, { text: "no file loaded" });
604
+ hideStatus(els.rightStatus);
605
+ }
606
+
607
+ // ---------- Device flow auth ----------
608
+
609
+ async function startDeviceFlow(clientId, scope) {
610
+ const res = await fetch(GH.deviceCodeUrl, {
611
+ method: "POST",
612
+ headers: {
613
+ "Accept": "application/json",
614
+ "Content-Type": "application/x-www-form-urlencoded",
615
+ },
616
+ body: formUrlEncode({ client_id: clientId, scope }),
617
+ });
618
+
619
+ if (!res.ok) {
620
+ const text = await res.text();
621
+ throw new Error(`Device flow init failed (${res.status}): ${text}`);
622
+ }
623
+ return await res.json();
624
+ }
625
+
626
+ async function pollForAccessToken({ clientId, deviceCode, intervalSec, expiresInSec, signal }) {
627
+ const startedAt = Date.now();
628
+ let waitMs = Math.max(1, intervalSec) * 1000;
629
+
630
+ while (true) {
631
+ if (signal?.aborted) throw new Error("Auth polling aborted.");
632
+ const elapsed = (Date.now() - startedAt) / 1000;
633
+ if (elapsed > expiresInSec) throw new Error("Device code expired before authorization completed.");
634
+
635
+ await new Promise((r) => setTimeout(r, waitMs));
636
+ if (signal?.aborted) throw new Error("Auth polling aborted.");
637
+
638
+ const res = await fetch(GH.accessTokenUrl, {
639
+ method: "POST",
640
+ headers: {
641
+ "Accept": "application/json",
642
+ "Content-Type": "application/x-www-form-urlencoded",
643
+ },
644
+ body: formUrlEncode({
645
+ client_id: clientId,
646
+ device_code: deviceCode,
647
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
648
+ }),
649
+ });
650
+
651
+ if (!res.ok) {
652
+ const text = await res.text();
653
+ throw new Error(`Token poll failed (${res.status}): ${text}`);
654
+ }
655
+
656
+ const data = await res.json();
657
+
658
+ if (data.access_token) return data;
659
+
660
+ // Pending / slow-down / other errors
661
+ if (data.error === "authorization_pending") continue;
662
+
663
+ if (data.error === "slow_down") {
664
+ waitMs += 5000;
665
+ continue;
666
+ }
667
+
668
+ throw new Error(`Token poll error: ${data.error || JSON.stringify(data)}`);
669
+ }
670
+ }
671
+
672
+ async function postSignInSetup() {
673
+ // Load user identity
674
+ const user = await ghFetchJson(`${GH.apiBase}/user`, state.token);
675
+ state.user = user;
676
+
677
+ els.userBadge.style.display = "inline-flex";
678
+ els.userBadge.textContent = `@${user.login}`;
679
+ setPill(els.authPill, { text: "signed in", kind: "ok" });
680
+
681
+ // Enable repo selection
682
+ els.repoSelect.disabled = false;
683
+ els.btnSignOut.disabled = false;
684
+
685
+ // Load repos
686
+ await loadReposIntoSelect();
687
+
688
+ // UI
689
+ els.btnStartAuth.disabled = false;
690
+ els.btnOpenVerify.disabled = true;
691
+ els.btnCopyCode.disabled = true;
692
+ state.device = null;
693
+ hideStatus(els.authInfo);
694
+ }
695
+
696
+ async function loadReposIntoSelect() {
697
+ showStatus(els.leftStatus, "Loading repositories…");
698
+ els.repoSelect.disabled = true;
699
+ els.repoSelect.innerHTML = `<option value="">Loading…</option>`;
700
+
701
+ // /user/repos lists repos the user has access to. We'll pull all pages.
702
+ const url = `${GH.apiBase}/user/repos?per_page=100&sort=updated&direction=desc`;
703
+ const repos = await ghFetchAllPages(url, state.token);
704
+
705
+ // Keep it simple: include all repos returned.
706
+ // If you only want ones the user can push to, filter on repo.permissions.push.
707
+ const options = repos
708
+ .map(r => ({
709
+ full_name: r.full_name,
710
+ private: r.private,
711
+ push: r.permissions && r.permissions.push,
712
+ default_branch: r.default_branch,
713
+ }))
714
+ .sort((a,b) => a.full_name.localeCompare(b.full_name));
715
+
716
+ els.repoSelect.innerHTML = `<option value="">— select a repo —</option>` + options.map(o => {
717
+ const tags = [
718
+ o.private ? "private" : "public",
719
+ o.push ? "push" : "no-push",
720
+ ].join(", ");
721
+ return `<option value="${escapeHtml(o.full_name)}">${escapeHtml(o.full_name)} (${tags})</option>`;
722
+ }).join("");
723
+
724
+ els.repoSelect.disabled = false;
725
+ hideStatus(els.leftStatus);
726
+ }
727
+
728
+ // ---------- Repo & file browsing ----------
729
+
730
+ async function onRepoSelected(repoFull) {
731
+ clearLoadedFile();
732
+ hideStatus(els.leftStatus);
733
+
734
+ if (!repoFull) {
735
+ state.repoFull = null;
736
+ state.repo = null;
737
+ state.defaultBranch = null;
738
+ state.currentPath = "";
739
+ state.currentRef = null;
740
+ els.breadcrumb.style.display = "none";
741
+ els.fileList.style.display = "none";
742
+ els.btnRefreshFiles.disabled = true;
743
+ return;
744
+ }
745
+
746
+ const parsed = parseRepoFullName(repoFull);
747
+ if (!parsed) return;
748
+
749
+ state.repoFull = repoFull;
750
+ state.repo = parsed;
751
+
752
+ // Read repo metadata to get default branch, etc.
753
+ showStatus(els.leftStatus, `Loading repo metadata for ${repoFull}…`);
754
+ const repo = await ghFetchJson(`${GH.apiBase}/repos/${parsed.owner}/${parsed.repo}`, state.token);
755
+ state.defaultBranch = repo.default_branch || "main";
756
+
757
+ // Set branch input default (empty means: use default branch)
758
+ els.branchInput.value = "";
759
+ state.currentRef = null;
760
+
761
+ // Start browsing at root
762
+ state.currentPath = "";
763
+ els.btnRefreshFiles.disabled = false;
764
+
765
+ await refreshFileList();
766
+ }
767
+
768
+ async function refreshFileList() {
769
+ if (!state.repo) return;
770
+
771
+ const ref = (els.branchInput.value || "").trim() || state.defaultBranch;
772
+ state.currentRef = ref;
773
+
774
+ const { owner, repo } = state.repo;
775
+ const path = state.currentPath; // "" means root
776
+
777
+ showStatus(els.leftStatus, `Listing contents: ${owner}/${repo}/${path || "(root)"} @ ${ref}…`);
778
+
779
+ // Contents API:
780
+ // GET /repos/{owner}/{repo}/contents/{path}?ref=branch
781
+ // For root, GitHub expects /contents/ (no path), but /contents/ works with empty.
782
+ const url = new URL(`${GH.apiBase}/repos/${owner}/${repo}/contents/${path}`);
783
+ url.searchParams.set("ref", ref);
784
+
785
+ let items;
786
+ try {
787
+ items = await ghFetchJson(url.toString(), state.token);
788
+ } catch (e) {
789
+ // If the path points to a file, GitHub returns an object not array.
790
+ // We only browse directories, so handle gracefully.
791
+ showStatus(els.leftStatus, String(e.message || e));
792
+ els.fileList.style.display = "none";
793
+ els.breadcrumb.style.display = "none";
794
+ return;
795
+ }
796
+
797
+ if (!Array.isArray(items)) {
798
+ showStatus(els.leftStatus, "This path is not a directory.");
799
+ return;
800
+ }
801
+
802
+ // Sort: dirs first, then files, alphabetical.
803
+ items.sort((a,b) => {
804
+ if (a.type !== b.type) return a.type === "dir" ? -1 : 1;
805
+ return a.name.localeCompare(b.name);
806
+ });
807
+
808
+ renderBreadcrumb(path);
809
+ renderFileList(items);
810
+
811
+ hideStatus(els.leftStatus);
812
+ }
813
+
814
+ function renderBreadcrumb(path) {
815
+ const parts = path ? path.split("/").filter(Boolean) : [];
816
+ els.breadcrumb.style.display = "flex";
817
+ els.breadcrumb.innerHTML = "";
818
+
819
+ const root = document.createElement("span");
820
+ root.className = "crumb";
821
+ root.textContent = "root";
822
+ root.addEventListener("click", async () => {
823
+ state.currentPath = "";
824
+ await refreshFileList();
825
+ });
826
+ els.breadcrumb.appendChild(root);
827
+
828
+ let acc = "";
829
+ for (const part of parts) {
830
+ const sep = document.createElement("span");
831
+ sep.textContent = "›";
832
+ sep.style.opacity = "0.7";
833
+ els.breadcrumb.appendChild(sep);
834
+
835
+ acc = safeJoinPath(acc, part);
836
+ const c = document.createElement("span");
837
+ c.className = "crumb";
838
+ c.textContent = part;
839
+ c.addEventListener("click", async () => {
840
+ state.currentPath = acc;
841
+ await refreshFileList();
842
+ });
843
+ els.breadcrumb.appendChild(c);
844
+ }
845
+ }
846
+
847
+ function renderFileList(items) {
848
+ els.fileList.style.display = "block";
849
+ els.fileList.innerHTML = "";
850
+
851
+ for (const it of items) {
852
+ const row = document.createElement("div");
853
+ row.className = "item";
854
+
855
+ const left = document.createElement("div");
856
+ left.className = "left";
857
+
858
+ const icon = document.createElement("div");
859
+ icon.className = "icon";
860
+ icon.textContent = it.type === "dir" ? "DIR" : "FILE";
861
+
862
+ const name = document.createElement("div");
863
+ name.className = "name";
864
+ name.textContent = it.name;
865
+
866
+ left.appendChild(icon);
867
+ left.appendChild(name);
868
+
869
+ const meta = document.createElement("div");
870
+ meta.className = "meta";
871
+ meta.textContent = it.type === "dir" ? "" : (it.size != null ? `${it.size} B` : "");
872
+
873
+ row.appendChild(left);
874
+ row.appendChild(meta);
875
+
876
+ row.addEventListener("click", async () => {
877
+ hideStatus(els.rightStatus);
878
+
879
+ if (it.type === "dir") {
880
+ state.currentPath = it.path;
881
+ await refreshFileList();
882
+ } else if (it.type === "file") {
883
+ await loadFile(it.path);
884
+ } else {
885
+ showStatus(els.leftStatus, `Unsupported item type: ${it.type}`);
886
+ }
887
+ });
888
+
889
+ els.fileList.appendChild(row);
890
+ }
891
+ }
892
+
893
+ // ---------- File read/write ----------
894
+
895
+ async function loadFile(filePath) {
896
+ if (!state.repo) return;
897
+
898
+ const ref = state.currentRef || state.defaultBranch;
899
+ const { owner, repo } = state.repo;
900
+
901
+ setPill(els.filePill, { text: "loading…", kind: "warn" });
902
+ els.btnReload.disabled = true;
903
+ els.btnSave.disabled = true;
904
+ els.editor.disabled = true;
905
+
906
+ showStatus(els.rightStatus, `Loading file: ${filePath}\nRef: ${ref}`);
907
+
908
+ const url = new URL(`${GH.apiBase}/repos/${owner}/${repo}/contents/${filePath}`);
909
+ url.searchParams.set("ref", ref);
910
+
911
+ const data = await ghFetchJson(url.toString(), state.token);
912
+ if (!data || data.type !== "file") throw new Error("Selected path is not a file.");
913
+
914
+ const b64 = String(data.content || "").replace(/\n/g, "");
915
+ const text = base64DecodeUtf8(b64);
916
+
917
+ state.loadedFile = {
918
+ path: data.path,
919
+ sha: data.sha,
920
+ text,
921
+ };
922
+
923
+ els.currentFile.value = data.path;
924
+ els.editor.value = text;
925
+ els.editor.disabled = false;
926
+ els.btnReload.disabled = false;
927
+ els.btnSave.disabled = false;
928
+
929
+ // default commit message
930
+ els.commitMsg.value = `Update ${data.path}`;
931
+
932
+ setPill(els.filePill, { text: "loaded", kind: "ok" });
933
+ showStatus(els.rightStatus,
934
+ `Loaded: ${data.path}\nsha: ${data.sha}\nRef: ${ref}\n\nEdit and click "Save to repo" to commit changes.`
935
+ );
936
+ }
937
+
938
+ async function saveFile() {
939
+ if (!state.repo || !state.loadedFile.path) return;
940
+
941
+ const ref = state.currentRef || state.defaultBranch;
942
+ const { owner, repo } = state.repo;
943
+
944
+ const path = state.loadedFile.path;
945
+ const sha = state.loadedFile.sha;
946
+ const message = (els.commitMsg.value || "").trim() || `Update ${path}`;
947
+ const text = els.editor.value;
948
+
949
+ setPill(els.filePill, { text: "saving…", kind: "warn" });
950
+ els.btnSave.disabled = true;
951
+
952
+ showStatus(els.rightStatus,
953
+ `Saving: ${path}\nBranch: ${ref}\n\nCommit message: ${message}\n\n(If this takes a moment, that's normal.)`
954
+ );
955
+
956
+ const url = `${GH.apiBase}/repos/${owner}/${repo}/contents/${path}`;
957
+ const body = {
958
+ message,
959
+ content: base64EncodeUtf8(text),
960
+ branch: ref,
961
+ sha, // required for update
962
+ };
963
+
964
+ const res = await fetch(url, {
965
+ method: "PUT",
966
+ headers: {
967
+ ...authHeaders(state.token),
968
+ "Content-Type": "application/json",
969
+ },
970
+ body: JSON.stringify(body),
971
+ });
972
+
973
+ if (!res.ok) {
974
+ const errText = await res.text();
975
+ setPill(els.filePill, { text: "save failed", kind: "bad" });
976
+ els.btnSave.disabled = false;
977
+
978
+ showStatus(els.rightStatus,
979
+ `Save failed: ${res.status} ${res.statusText}\nURL: ${url}\n\n${errText}\n\nTip: if it's a 409, reload the file (someone else updated it).`
980
+ );
981
+ return;
982
+ }
983
+
984
+ const data = await res.json();
985
+ // Update sha to latest content sha
986
+ const newSha = data?.content?.sha || data?.content?.sha;
987
+ if (newSha) state.loadedFile.sha = newSha;
988
+
989
+ setPill(els.filePill, { text: "saved", kind: "ok" });
990
+ els.btnSave.disabled = false;
991
+
992
+ const commitSha = data?.commit?.sha || "(unknown)";
993
+ showStatus(els.rightStatus,
994
+ `Saved successfully.\n\nPath: ${path}\nNew content sha: ${state.loadedFile.sha}\nCommit sha: ${commitSha}\nBranch: ${ref}`
995
+ );
996
+ }
997
+
998
+ async function reloadFile() {
999
+ if (!state.loadedFile.path) return;
1000
+ await loadFile(state.loadedFile.path);
1001
+ }
1002
+
1003
+ // ---------- Event wiring ----------
1004
+
1005
+ els.btnStartAuth.addEventListener("click", async () => {
1006
+ try {
1007
+ hideStatus(els.authInfo);
1008
+ hideStatus(els.leftStatus);
1009
+ hideStatus(els.rightStatus);
1010
+
1011
+ const clientId = (els.clientId.value || "").trim();
1012
+ const scope = els.scope.value;
1013
+
1014
+ if (!clientId) {
1015
+ showStatus(els.authInfo, "Enter your OAuth App Client ID first.");
1016
+ return;
1017
+ }
1018
+
1019
+ // Reset any existing session
1020
+ signOut({ quiet: true });
1021
+
1022
+ els.btnStartAuth.disabled = true;
1023
+ setPill(els.authPill, { text: "auth in progress", kind: "warn" });
1024
+
1025
+ const device = await startDeviceFlow(clientId, scope);
1026
+ state.device = device;
1027
+
1028
+ // Show the user code & link
1029
+ const msg =
1030
+ `1) Open: ${device.verification_uri}\n` +
1031
+ `2) Enter code: ${device.user_code}\n` +
1032
+ `3) Come back here—this page will finish sign-in automatically.\n\n` +
1033
+ `Expires in: ${device.expires_in}s | Poll interval: ${device.interval}s`;
1034
+ showStatus(els.authInfo, msg);
1035
+
1036
+ els.btnOpenVerify.disabled = false;
1037
+ els.btnCopyCode.disabled = false;
1038
+
1039
+ // Start polling
1040
+ state.pollAbort = new AbortController();
1041
+
1042
+ showStatus(els.authInfo, msg + "\n\nWaiting for authorization…");
1043
+
1044
+ const tokenData = await pollForAccessToken({
1045
+ clientId,
1046
+ deviceCode: device.device_code,
1047
+ intervalSec: device.interval,
1048
+ expiresInSec: device.expires_in,
1049
+ signal: state.pollAbort.signal,
1050
+ });
1051
+
1052
+ state.token = tokenData.access_token;
1053
+
1054
+ showStatus(els.authInfo, msg + "\n\nAuthorized. Fetching user + repos…");
1055
+
1056
+ await postSignInSetup();
1057
+ } catch (e) {
1058
+ console.error(e);
1059
+ setPill(els.authPill, { text: "sign-in failed", kind: "bad" });
1060
+ showStatus(els.authInfo, String(e.message || e));
1061
+ els.btnStartAuth.disabled = false;
1062
+ els.btnOpenVerify.disabled = true;
1063
+ els.btnCopyCode.disabled = true;
1064
+ }
1065
+ });
1066
+
1067
+ els.btnOpenVerify.addEventListener("click", () => {
1068
+ if (!state.device?.verification_uri) return;
1069
+ window.open(state.device.verification_uri, "_blank", "noopener,noreferrer");
1070
+ });
1071
+
1072
+ els.btnCopyCode.addEventListener("click", async () => {
1073
+ if (!state.device?.user_code) return;
1074
+ try {
1075
+ await navigator.clipboard.writeText(state.device.user_code);
1076
+ showStatus(els.authInfo, els.authInfo.textContent + "\n\nCopied code to clipboard.");
1077
+ } catch {
1078
+ showStatus(els.authInfo, els.authInfo.textContent + "\n\nClipboard copy failed. Please copy manually.");
1079
+ }
1080
+ });
1081
+
1082
+ els.btnSignOut.addEventListener("click", () => signOut({ quiet: false }));
1083
+
1084
+ function signOut({ quiet }) {
1085
+ // Abort polling if active
1086
+ try { state.pollAbort?.abort(); } catch {}
1087
+ state.pollAbort = null;
1088
+ state.device = null;
1089
+
1090
+ state.token = null;
1091
+ state.user = null;
1092
+
1093
+ setPill(els.authPill, { text: "signed out" });
1094
+ els.userBadge.style.display = "none";
1095
+ els.userBadge.textContent = "";
1096
+
1097
+ els.btnSignOut.disabled = true;
1098
+ els.btnStartAuth.disabled = false;
1099
+ els.btnOpenVerify.disabled = true;
1100
+ els.btnCopyCode.disabled = true;
1101
+
1102
+ resetRepoAndFileUI();
1103
+
1104
+ if (!quiet) {
1105
+ showStatus(els.authInfo, "Signed out. Token cleared from memory.");
1106
+ els.authInfo.style.display = "block";
1107
+ }
1108
+ }
1109
+
1110
+ els.repoSelect.addEventListener("change", async (e) => {
1111
+ try {
1112
+ if (!state.token) return;
1113
+ await onRepoSelected(e.target.value);
1114
+ } catch (err) {
1115
+ console.error(err);
1116
+ showStatus(els.leftStatus, String(err.message || err));
1117
+ }
1118
+ });
1119
+
1120
+ els.btnRefreshFiles.addEventListener("click", async () => {
1121
+ try {
1122
+ await refreshFileList();
1123
+ } catch (err) {
1124
+ console.error(err);
1125
+ showStatus(els.leftStatus, String(err.message || err));
1126
+ }
1127
+ });
1128
+
1129
+ els.branchInput.addEventListener("keydown", async (e) => {
1130
+ if (e.key === "Enter") {
1131
+ try {
1132
+ clearLoadedFile();
1133
+ await refreshFileList();
1134
+ } catch (err) {
1135
+ console.error(err);
1136
+ showStatus(els.leftStatus, String(err.message || err));
1137
+ }
1138
+ }
1139
+ });
1140
+
1141
+ els.btnReload.addEventListener("click", async () => {
1142
+ try {
1143
+ await reloadFile();
1144
+ } catch (err) {
1145
+ console.error(err);
1146
+ setPill(els.filePill, { text: "reload failed", kind: "bad" });
1147
+ showStatus(els.rightStatus, String(err.message || err));
1148
+ }
1149
+ });
1150
+
1151
+ els.btnSave.addEventListener("click", async () => {
1152
+ try {
1153
+ await saveFile();
1154
+ } catch (err) {
1155
+ console.error(err);
1156
+ setPill(els.filePill, { text: "save failed", kind: "bad" });
1157
+ els.btnSave.disabled = false;
1158
+ showStatus(els.rightStatus, String(err.message || err));
1159
+ }
1160
+ });
1161
+
1162
+ // ---------- HTML escape for option labels ----------
1163
+ function escapeHtml(s) {
1164
+ return String(s)
1165
+ .replaceAll("&", "&amp;")
1166
+ .replaceAll("<", "&lt;")
1167
+ .replaceAll(">", "&gt;")
1168
+ .replaceAll('"', "&quot;")
1169
+ .replaceAll("'", "&#039;");
1170
+ }
1171
+
1172
+ // Initial UI state
1173
+ resetRepoAndFileUI();
1174
+ clearLoadedFile();
1175
+ </script>
1176
+ </body>
1177
+ </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brep-io-kernel",
3
- "version": "1.0.30",
3
+ "version": "1.0.31",
4
4
  "scripts": {
5
5
  "dev": "pnpm generateLicenses && pnpm build:kernel && vite --host 0.0.0.0",
6
6
  "build": "pnpm generateLicenses && vite build",