ccdeck 3.43.4 → 3.45.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/README.md +79 -18
- package/THIRD_PARTY_NOTICES.md +70 -7
- package/bin/cli/commands.js +613 -0
- package/bin/cli/help.js +26 -3
- package/bin/cli/one-shot.js +2 -2
- package/bin/cli/picker.js +86 -0
- package/bin/cli/startup.js +13 -0
- package/bin/deck.js +16 -1
- package/dist/web/assets/BrowserWatchModal-Cbzf3TWa.js +1 -0
- package/dist/web/assets/IntegrationModal-BnnBr0a3.js +5 -0
- package/dist/web/assets/RadioPanel-BIqrMLQN.js +1 -0
- package/dist/web/assets/TrafficRadar-D1gZCIwX.js +8 -0
- package/dist/web/assets/UsageHistoryModal-DAhplVju.js +1 -0
- package/dist/web/assets/app-BW3NYUYB.js +318 -0
- package/dist/web/assets/app-C5RY_wZ4.css +1 -0
- package/dist/web/assets/{index-Ch4ks76J.js → index-r8Ymr47Q.js} +1 -1
- package/dist/web/index.html +3 -3
- package/package.json +2 -1
- package/release-notes.json +52 -0
- package/src/server/account-notify.mjs +268 -9
- package/src/server/account-pick.mjs +160 -0
- package/src/server/account-projects.mjs +55 -15
- package/src/server/account-routes.mjs +47 -4
- package/src/server/account-watch.mjs +107 -4
- package/src/server/activity.mjs +3 -2
- package/src/server/args.mjs +112 -2
- package/src/server/autoswitch-failover.mjs +138 -0
- package/src/server/claude-accounts.mjs +127 -16
- package/src/server/claude-dir.mjs +142 -5
- package/src/server/claude-identity.mjs +23 -0
- package/src/server/cli-lines.mjs +224 -0
- package/src/server/codex-cost-usage.mjs +67 -0
- package/src/server/codex-enrichment.mjs +53 -9
- package/src/server/codex-review-policy.d.mts +4 -0
- package/src/server/codex-review-policy.mjs +11 -0
- package/src/server/codex-skills.mjs +146 -0
- package/src/server/codex-watch.mjs +5 -2
- package/src/server/cswap-auto-choice.mjs +53 -0
- package/src/server/cswap-auto-loop.mjs +100 -9
- package/src/server/deck-client.mjs +112 -0
- package/src/server/deck-prefs.mjs +26 -5
- package/src/server/event-pipeline.mjs +19 -3
- package/src/server/event-routes.mjs +15 -0
- package/src/server/exec.mjs +6 -2
- package/src/server/feature-use.mjs +11 -1
- package/src/server/forge-http.mjs +514 -0
- package/src/server/forge-public-fetch.mjs +228 -0
- package/src/server/forge-routes.mjs +154 -0
- package/src/server/github-api.mjs +1369 -0
- package/src/server/gitlab-api.mjs +1271 -0
- package/src/server/index.mjs +23 -3
- package/src/server/installer.mjs +149 -8
- package/src/server/integrations/cache.mjs +42 -0
- package/src/server/integrations/errors.mjs +167 -0
- package/src/server/integrations/forge.mjs +76 -0
- package/src/server/integrations/jira.mjs +1026 -0
- package/src/server/integrations/providers.mjs +11 -0
- package/src/server/integrations/proxy.mjs +314 -0
- package/src/server/integrations/registry.mjs +142 -0
- package/src/server/integrations/routes.mjs +498 -0
- package/src/server/integrations/store.mjs +226 -0
- package/src/server/lan-deck.mjs +7 -0
- package/src/server/lan-elsewhere.d.mts +30 -0
- package/src/server/lan-elsewhere.mjs +137 -0
- package/src/server/lan-engine.mjs +2 -2
- package/src/server/lan-sync.mjs +3 -3
- package/src/server/lan-wire.mjs +3 -3
- package/src/server/login-guard.mjs +162 -0
- package/src/server/login-service.mjs +3 -0
- package/src/server/org-telemetry.mjs +173 -0
- package/src/server/pinned-build.mjs +8 -0
- package/src/server/prefs-routes.mjs +4 -0
- package/src/server/request-gates.mjs +26 -0
- package/src/server/session-read-gate.mjs +5 -3
- package/src/server/session-summary.mjs +281 -0
- package/src/server/skill-hook.mjs +162 -0
- package/src/server/stop-continue-gate.d.mts +8 -0
- package/src/server/stop-continue-gate.mjs +77 -0
- package/src/server/stop-continue.mjs +544 -0
- package/src/server/stop-failure.d.mts +10 -0
- package/src/server/stop-failure.mjs +97 -0
- package/src/server/swap-log.mjs +19 -10
- package/src/server/tool-audit-report.mjs +5 -3
- package/src/server/tool-audit-routes.mjs +5 -3
- package/src/server/tool-audit-skills.mjs +82 -0
- package/src/server/tool-audit.mjs +53 -11
- package/src/server/traffic-radar-capture.mjs +10 -1
- package/src/server/traffic-radar-monitor.mjs +4 -2
- package/src/server/traffic-radar-routes.mjs +13 -0
- package/src/server/traffic-radar-service.mjs +133 -0
- package/src/server/usage-day.mjs +3 -1
- package/dist/web/assets/BrowserWatchModal-nOxhGOO4.js +0 -1
- package/dist/web/assets/RadioPanel-BLfpQ1Iw.js +0 -1
- package/dist/web/assets/TrafficRadar-BFrObpjW.js +0 -1
- package/dist/web/assets/UsageHistoryModal-DCnFf1vi.js +0 -1
- package/dist/web/assets/app-BiJ8TfUl.css +0 -1
- package/dist/web/assets/app-DBsNB-2y.js +0 -312
package/src/server/index.mjs
CHANGED
|
@@ -19,7 +19,7 @@ import { challengeDeck, challengeProof, isProcessAlive } from "./deck-probe.mjs"
|
|
|
19
19
|
export { challengeDeck, challengeProof, isProcessAlive };
|
|
20
20
|
// The gates in front of the route table — see src/server/request-gates.mjs,
|
|
21
21
|
// which also holds the per-process token the strictest of them checks.
|
|
22
|
-
import {
|
|
22
|
+
import { OPEN_MUTATIONS, isAuthorizedDataRead, isAuthorizedMutation, isGuardedRead, isTrustedMutation, isTrustedRead } from "./request-gates.mjs";
|
|
23
23
|
import { trafficCapture } from "./traffic-radar-capture.mjs";
|
|
24
24
|
import { trafficRadar } from "./traffic-radar.mjs";
|
|
25
25
|
import { handleTrafficCapture, isAuthorizedTrafficIngest } from "./traffic-radar-routes.mjs";
|
|
@@ -29,6 +29,8 @@ export { MAX_BUFFER, MAX_BUFFER_CHARS, MAX_RING_ENTRIES, payloadChars } from "./
|
|
|
29
29
|
// The ring itself — what it holds, how it numbers them, and the eviction that
|
|
30
30
|
// keeps those bounds — private behind its operations. See event-ring.mjs.
|
|
31
31
|
import { eventsSince } from "./event-ring.mjs";
|
|
32
|
+
// The same ring folded to one row per session, for `ccdeck sessions` (#2183).
|
|
33
|
+
import { summarizeSessions } from "./session-summary.mjs";
|
|
32
34
|
// Exported from this file before they moved, and still.
|
|
33
35
|
export { eventBufferStats, eventsSince } from "./event-ring.mjs";
|
|
34
36
|
// The one door every event comes through, and what it does to each on the way
|
|
@@ -36,6 +38,7 @@ export { eventBufferStats, eventsSince } from "./event-ring.mjs";
|
|
|
36
38
|
// routes below: it connects pushEvent to event-sink.mjs as it loads, and the
|
|
37
39
|
// boot replay pushes through that sink before any route has run.
|
|
38
40
|
import "./event-pipeline.mjs";
|
|
41
|
+
import { stopCodexEnrichment } from "./codex-enrichment.mjs";
|
|
39
42
|
// POST /api/event, GET /events and GET /api/events — see event-routes.mjs.
|
|
40
43
|
import { handleEventIngest, handleSse, writeJsonArray } from "./event-routes.mjs";
|
|
41
44
|
// Exported from this file before it moved, and still.
|
|
@@ -159,6 +162,7 @@ import { send, sendInternalError } from "./http-io.mjs";
|
|
|
159
162
|
export { sendInternalError };
|
|
160
163
|
// The accounts surface's routes — see account-routes.mjs. The boot wires the
|
|
161
164
|
// stale-copy repair and starts auto-switch through the same module.
|
|
165
|
+
import { handleStopContinue, handleStopContinueRead } from "./stop-continue.mjs";
|
|
162
166
|
import { cswapAutoModule, getProjectRollup, handleAccountLoginState, handleAccountProjects, handleClaudeAccountAdmin, handleClaudeAccountSwitch, handleClaudeAccounts, handleCswapAuto, handleCswapAutoAction, wireAccountOrigins, wireStaleCopyRepair } from "./account-routes.mjs";
|
|
163
167
|
// Browser Watch's three routes — see browser-watch-routes.mjs.
|
|
164
168
|
import { handleBrowserWatch, handleBrowserWatchDismiss, handleBrowserWatchSettings } from "./browser-watch-routes.mjs";
|
|
@@ -169,6 +173,9 @@ import { startToolAudit } from "./tool-audit.mjs";
|
|
|
169
173
|
import { handleToolAudit } from "./tool-audit-routes.mjs";
|
|
170
174
|
import { startQuotaHistory } from "./quota-history.mjs";
|
|
171
175
|
import { handleCcusage, handleCcusageSession, handleCodexQuota, handleCodexUsage, handleProviderStatus, handleQuota, handleQuotaHistory } from "./usage-routes.mjs";
|
|
176
|
+
// Connected trackers (#2178) and each provider's own reads. See
|
|
177
|
+
// integrations/routes.mjs; nothing in it is shared with paired decks.
|
|
178
|
+
import { handleIntegrations } from "./integrations/routes.mjs";
|
|
172
179
|
// The port fallback, one listen attempt, and the words for a failed one — see
|
|
173
180
|
// listen.mjs. The loop that uses them is startServer's.
|
|
174
181
|
import { listenFailure, portRetryable, randomPort, tryListen } from "./listen.mjs";
|
|
@@ -297,7 +304,7 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
297
304
|
// gate above was written for a sandboxed subprocess with loopback egress,
|
|
298
305
|
// and that caller was reading the ring through a GET the whole time.
|
|
299
306
|
if ((req.method === "GET" || req.method === "HEAD")
|
|
300
|
-
&&
|
|
307
|
+
&& isGuardedRead(url.pathname) && !isAuthorizedDataRead(req)) {
|
|
301
308
|
return send(res, 401, { error: "unauthenticated" });
|
|
302
309
|
}
|
|
303
310
|
|
|
@@ -389,6 +396,10 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
389
396
|
if (req.method === "POST" && url.pathname === "/api/claude-accounts/switch") return guard(handleClaudeAccountSwitch(req, res), res);
|
|
390
397
|
if (req.method === "GET" && url.pathname === "/api/claude-accounts/login") return guard(handleAccountLoginState(req, res), res);
|
|
391
398
|
if (req.method === "POST" && url.pathname === "/api/claude-accounts/admin") return guard(handleClaudeAccountAdmin(req, res), res);
|
|
399
|
+
// Continue a session whose turn stopped on a login or a usage limit (#2193):
|
|
400
|
+
// the gate's evidence and each session's outcome, and the press itself.
|
|
401
|
+
if (req.method === "GET" && url.pathname === "/api/stop-continue") return guard(handleStopContinueRead(req, res), res);
|
|
402
|
+
if (req.method === "POST" && url.pathname === "/api/stop-continue") return guard(handleStopContinue(req, res), res);
|
|
392
403
|
if (req.method === "GET" && url.pathname === "/api/cswap-auto") return guard(handleCswapAuto(req, res), res);
|
|
393
404
|
if (req.method === "POST" && url.pathname === "/api/cswap-auto") return guard(handleCswapAutoAction(req, res), res);
|
|
394
405
|
if (req.method === "GET" && url.pathname === "/api/claude-fm") return guard(handleClaudeFm(req, res), res);
|
|
@@ -401,6 +412,7 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
401
412
|
if (req.method === "GET" && url.pathname === "/api/best-of-nostalgia") return guard(handleBestOfNostalgia(req, res), res);
|
|
402
413
|
if (req.method === "GET" && url.pathname === "/api/good-life-radio") return guard(handleGoodLifeRadio(req, res), res);
|
|
403
414
|
if (req.method === "GET" && url.pathname === "/api/cafe-music-bgm") return guard(handleCafeMusicBgm(req, res), res);
|
|
415
|
+
if (url.pathname === "/api/integrations" || url.pathname.startsWith("/api/integrations/")) return guard(handleIntegrations(req, res, url), res);
|
|
404
416
|
|
|
405
417
|
// Through writeJsonArray rather than `send`, and through `guard` like every
|
|
406
418
|
// route above it: this is the one answer whose size is the ring's size, and
|
|
@@ -408,6 +420,11 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
408
420
|
if (req.method === "GET" && url.pathname === "/api/events") {
|
|
409
421
|
return guard(writeJsonArray(res, eventsSince(url.searchParams.get("since") ?? 0)), res);
|
|
410
422
|
}
|
|
423
|
+
// GET /api/sessions — which sessions this deck is watching and which of
|
|
424
|
+
// them waits on a human, without the ring itself. See session-summary.mjs.
|
|
425
|
+
if (req.method === "GET" && url.pathname === "/api/sessions") {
|
|
426
|
+
return send(res, 200, { ok: true, now: Date.now(), sessions: summarizeSessions(eventsSince(0)) });
|
|
427
|
+
}
|
|
411
428
|
|
|
412
429
|
// GET /api/clear — what a POST to this path would do, and to whose log.
|
|
413
430
|
// See handleClearPreview.
|
|
@@ -514,7 +531,10 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
514
531
|
void trafficCapture.resume(boundPort).catch(() => {});
|
|
515
532
|
server.once("close", () => trafficCapture.dispose());
|
|
516
533
|
// Codex has no working hooks on Windows — tail its rollout files instead.
|
|
517
|
-
if (codex)
|
|
534
|
+
if (codex) {
|
|
535
|
+
startCodexWatcher(workspace);
|
|
536
|
+
server.once("close", stopCodexEnrichment);
|
|
537
|
+
}
|
|
518
538
|
if (claude) {
|
|
519
539
|
const recovery = createClaudeRecovery({ workspace });
|
|
520
540
|
const recover = () => { void recovery.scan().catch(() => {}); };
|
package/src/server/installer.mjs
CHANGED
|
@@ -6,16 +6,21 @@
|
|
|
6
6
|
// running server still sees both CLIs. Re-runs are safe; entries are tagged
|
|
7
7
|
// with __agent-dag and de-duped.
|
|
8
8
|
import { hookRuntime } from "./app-host.mjs";
|
|
9
|
-
import { readFile, mkdir } from "node:fs/promises";
|
|
9
|
+
import { readFile, mkdir, unlink } from "node:fs/promises";
|
|
10
10
|
import { existsSync } from "node:fs";
|
|
11
11
|
import { join, resolve, dirname } from "node:path";
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
13
|
-
import { claudeConfigDir } from "./claude-dir.mjs";
|
|
13
|
+
import { claudeCodeVersions, claudeConfigDir, claudeVersionAtLeast } from "./claude-dir.mjs";
|
|
14
|
+
// The first Claude Code that knows StopFailure — see stop-failure.mjs.
|
|
15
|
+
import { STOP_FAILURE, STOP_FAILURE_SINCE } from "./stop-failure.mjs";
|
|
14
16
|
import { CODEX_HOME } from "./codex-dir.mjs";
|
|
15
17
|
import { shellQuoteArg } from "./exec.mjs";
|
|
16
18
|
// The read-before-rewrite and the atomic replace every settings writer goes
|
|
17
19
|
// through — see atomic-write.mjs.
|
|
18
|
-
import { readSettingsForWrite, writeFileAtomic } from "./atomic-write.mjs";
|
|
20
|
+
import { readSettingsForWrite, writeFileAtomic, createTemp, renameWithRetry } from "./atomic-write.mjs";
|
|
21
|
+
// Skill activity's typed-command entry, its opt-in and its version gate — see
|
|
22
|
+
// skill-hook.mjs for why it is not in CLAUDE_EVENTS.
|
|
23
|
+
import { USER_PROMPT_EXPANSION, skillHookDecision } from "./skill-hook.mjs";
|
|
19
24
|
// The discovery dir every deck registers in and every hook reads — see
|
|
20
25
|
// discovery.mjs, which keeps this deck's record there.
|
|
21
26
|
import { AGENT_DAG_DIR } from "./discovery.mjs";
|
|
@@ -45,11 +50,46 @@ const CLAUDE_EVENTS = [
|
|
|
45
50
|
"Notification",
|
|
46
51
|
];
|
|
47
52
|
|
|
53
|
+
/**
|
|
54
|
+
* The events Claude Code added after the ones above, each beside the first
|
|
55
|
+
* version that knows it — added only where EVERY Claude Code the deck finds on
|
|
56
|
+
* the machine is at least that version.
|
|
57
|
+
*
|
|
58
|
+
* Not an event the older CLI would merely ignore. Until 2.1.101 an event name
|
|
59
|
+
* Claude Code did not recognise made it ignore the whole settings.json: the
|
|
60
|
+
* user's permissions, env and hooks, and every one of ours with them (Claude
|
|
61
|
+
* Code's changelog, 2.1.101). StopFailure arrived in 2.1.78, so a machine still
|
|
62
|
+
* on anything before that would lose its settings to the entry meant to tell it
|
|
63
|
+
* more.
|
|
64
|
+
*
|
|
65
|
+
* So the version is read off disk (claudeCodeVersions — never a `claude
|
|
66
|
+
* --version`, which is seconds at boot), and a machine where the deck can find
|
|
67
|
+
* no version gets none of these: the deck it had before, and a line in the
|
|
68
|
+
* README saying why. Every boot reinstalls, so a Claude Code updated past the
|
|
69
|
+
* gate picks the entry up on the deck's next start — and one rolled back below
|
|
70
|
+
* it has the entry taken back out, the same way.
|
|
71
|
+
*/
|
|
72
|
+
const VERSIONED_CLAUDE_EVENTS = [
|
|
73
|
+
{ event: STOP_FAILURE, since: STOP_FAILURE_SINCE },
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
/** Which of `versioned` this machine's Claude Code knows: those whose `since`
|
|
77
|
+
* every found version is at least, and none when no version was found. */
|
|
78
|
+
function eventsKnownTo(versions, versioned = VERSIONED_CLAUDE_EVENTS) {
|
|
79
|
+
const found = Array.isArray(versions) ? versions : [];
|
|
80
|
+
if (found.length === 0) return [];
|
|
81
|
+
return versioned.filter(v => found.every(ver => claudeVersionAtLeast(ver, v.since))).map(v => v.event);
|
|
82
|
+
}
|
|
83
|
+
|
|
48
84
|
const PROVIDERS = {
|
|
49
85
|
claude: {
|
|
50
86
|
settingsPath: join(CLAUDE_DIR, "settings.json"),
|
|
51
87
|
hookInstallDir: join(CLAUDE_DIR, "agent-dag"),
|
|
52
88
|
events: CLAUDE_EVENTS,
|
|
89
|
+
versioned: VERSIONED_CLAUDE_EVENTS,
|
|
90
|
+
// UserPromptExpansion, for Skill activity (#2181): opt-in, and gated on
|
|
91
|
+
// every Claude Code found being 2.1.116 or newer (skill-hook.mjs).
|
|
92
|
+
skillHook: true,
|
|
53
93
|
ensureDir: CLAUDE_DIR,
|
|
54
94
|
},
|
|
55
95
|
// Uninstall-only. Codex hooks do not fire reliably on Windows, so the deck
|
|
@@ -336,8 +376,82 @@ async function soundHookRetirement(provider, cfg, current) {
|
|
|
336
376
|
return { retire, completeSoundHookRetirement };
|
|
337
377
|
}
|
|
338
378
|
|
|
339
|
-
/**
|
|
340
|
-
|
|
379
|
+
/**
|
|
380
|
+
* Where the settings.json bytes from before the UserPromptExpansion entry was
|
|
381
|
+
* first added are kept: beside hook.js, in the directory that is ours. Written
|
|
382
|
+
* once per enabling — the pass that adds the entry where none of ours was —
|
|
383
|
+
* with mode 0600, because settings.json can hold API keys in its `env`.
|
|
384
|
+
* `--uninstall` leaves it, as it leaves hook.js: it is the user's own data.
|
|
385
|
+
*/
|
|
386
|
+
export const SKILL_HOOK_BACKUP = "settings.before-skill-hook.json";
|
|
387
|
+
|
|
388
|
+
/** Whether a group list holds a forwarder of ours. */
|
|
389
|
+
function holdsOurs(groups) {
|
|
390
|
+
return Array.isArray(groups) && groups.some(isOurEntry);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Whether the entry can be added without guessing: `hooks` absent or a plain
|
|
394
|
+
* object, and any UserPromptExpansion already there a list. Anything else is
|
|
395
|
+
* a file shaped in a way this deck did not write and Claude Code may not read,
|
|
396
|
+
* and adding to it is not a decision to take on the user's behalf. */
|
|
397
|
+
function skillHookFits(settings) {
|
|
398
|
+
const hooks = settings.hooks;
|
|
399
|
+
if (hooks === undefined) return true;
|
|
400
|
+
if (!hooks || typeof hooks !== "object" || Array.isArray(hooks)) return false;
|
|
401
|
+
const now = hooks[USER_PROMPT_EXPANSION];
|
|
402
|
+
return now === undefined || Array.isArray(now);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** The backup, through a temp file and a rename like every settings write,
|
|
406
|
+
* created 0600 and kept 0600. */
|
|
407
|
+
async function writeBackup(path, text) {
|
|
408
|
+
const { tmp, handle } = await createTemp(path, { mode: 0o600 });
|
|
409
|
+
try {
|
|
410
|
+
try {
|
|
411
|
+
await handle.writeFile(text, "utf8");
|
|
412
|
+
await handle.sync();
|
|
413
|
+
} finally {
|
|
414
|
+
await handle.close();
|
|
415
|
+
}
|
|
416
|
+
await renameWithRetry(tmp, path);
|
|
417
|
+
} catch (err) {
|
|
418
|
+
await unlink(tmp).catch(() => {});
|
|
419
|
+
throw err;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Whether this pass adds Skill activity's entry: the gate's decision
|
|
425
|
+
* (skill-hook.mjs), then two refusals of the installer's own — a settings file
|
|
426
|
+
* whose hooks are not in a shape to add to, and a backup that could not be
|
|
427
|
+
* written. Either leaves the entry out; neither stops the rest of the install.
|
|
428
|
+
* `options` is the suite's: skillHookDecision's options, in place of this
|
|
429
|
+
* machine's environment and disk.
|
|
430
|
+
*/
|
|
431
|
+
async function decideSkillHook(options, settings, before, backupPath) {
|
|
432
|
+
const decision = await skillHookDecision(options ?? {});
|
|
433
|
+
if (!decision.install) return decision;
|
|
434
|
+
if (!skillHookFits(settings)) return { ...decision, install: false, state: "skipped", reason: "settings-shape" };
|
|
435
|
+
// The bytes the write is about to replace, kept before anything is changed,
|
|
436
|
+
// the first time the entry goes in. Nothing to keep when there was no file.
|
|
437
|
+
if (before !== null && !holdsOurs(settings.hooks?.[USER_PROMPT_EXPANSION])) {
|
|
438
|
+
try {
|
|
439
|
+
await writeBackup(backupPath, before);
|
|
440
|
+
} catch {
|
|
441
|
+
return { ...decision, install: false, state: "skipped", reason: "backup-failed" };
|
|
442
|
+
}
|
|
443
|
+
return { ...decision, backup: backupPath };
|
|
444
|
+
}
|
|
445
|
+
return decision;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** Install hooks for a single provider. Returns {settingsPath, hookPath, events, changed}.
|
|
449
|
+
* `claudeVersions` is the suite's: the Claude Code versions to gate the
|
|
450
|
+
* versioned events on, in place of the ones on this machine's disk.
|
|
451
|
+
* `skillHook` is the suite's too: skillHookDecision's options (the opt-in,
|
|
452
|
+
* the installs found, the probe), in place of this machine's. The answer
|
|
453
|
+
* carries the decision as `skillHook` for the claude provider. */
|
|
454
|
+
export async function installHooks({ provider = "claude", beforeWrite = null, claudeVersions = null, skillHook = null } = {}) {
|
|
341
455
|
const cfg = PROVIDERS[provider];
|
|
342
456
|
if (!cfg) throw new Error(`unknown provider: ${provider}`);
|
|
343
457
|
// An uninstall-only provider has no event list. Saying so beats the
|
|
@@ -357,13 +471,38 @@ export async function installHooks({ provider = "claude", beforeWrite = null } =
|
|
|
357
471
|
// Discovery dir is shared across providers — always make sure it exists.
|
|
358
472
|
await ensureDir(AGENT_DAG_DIR);
|
|
359
473
|
|
|
474
|
+
// Decided before `hooks` is touched, so the shape check sees the file as read.
|
|
475
|
+
const skill = cfg.skillHook
|
|
476
|
+
? await decideSkillHook(skillHook, current, before, join(cfg.hookInstallDir, SKILL_HOOK_BACKUP))
|
|
477
|
+
: null;
|
|
478
|
+
|
|
360
479
|
current.hooks = current.hooks ?? {};
|
|
361
480
|
|
|
362
|
-
|
|
481
|
+
const versioned = cfg.versioned ?? [];
|
|
482
|
+
const known = versioned.length ? eventsKnownTo(claudeVersions ?? claudeCodeVersions(), versioned) : [];
|
|
483
|
+
if (skill?.install) known.push(USER_PROMPT_EXPANSION);
|
|
484
|
+
// Every event whose entry is ours only while a gate says so.
|
|
485
|
+
const gated = [...versioned.map(v => v.event), ...(skill ? [USER_PROMPT_EXPANSION] : [])];
|
|
486
|
+
const events = [...cfg.events, ...known];
|
|
487
|
+
for (const evt of events) {
|
|
363
488
|
const cleaned = dedupeOurEntries(current.hooks[evt]);
|
|
364
489
|
cleaned.push(buildHookEntry(command));
|
|
365
490
|
current.hooks[evt] = cleaned;
|
|
366
491
|
}
|
|
492
|
+
// A gated event this machine's Claude Code does not know — or, for Skill
|
|
493
|
+
// activity's, one nobody asked for — loses our entry, and the key goes with
|
|
494
|
+
// it when nothing of the user's is left under it: that key is the thing an
|
|
495
|
+
// older Claude Code cannot read.
|
|
496
|
+
// A value under that key that is not a list, or a list with nothing of ours
|
|
497
|
+
// in it, is left exactly as it is: it is not this deck's to delete.
|
|
498
|
+
for (const event of gated) {
|
|
499
|
+
const was = current.hooks[event];
|
|
500
|
+
if (known.includes(event) || !Array.isArray(was)) continue;
|
|
501
|
+
const cleaned = dedupeOurEntries(was);
|
|
502
|
+
if (cleaned.length === was.length && cleaned.every((g, i) => g === was[i])) continue;
|
|
503
|
+
if (cleaned.length === 0) delete current.hooks[event];
|
|
504
|
+
else current.hooks[event] = cleaned;
|
|
505
|
+
}
|
|
367
506
|
|
|
368
507
|
// Retiring the finish-sound hook rides in here, on this read and this write,
|
|
369
508
|
// and this is the seam it needs rather than a convenient one. #704 moved the
|
|
@@ -421,8 +560,10 @@ export async function installHooks({ provider = "claude", beforeWrite = null } =
|
|
|
421
560
|
// and from the park that was its only other copy.
|
|
422
561
|
if (!(await unchangedSince(cfg.settingsPath, before))) {
|
|
423
562
|
return {
|
|
424
|
-
settingsPath: cfg.settingsPath, hookPath, events
|
|
563
|
+
settingsPath: cfg.settingsPath, hookPath, events, provider,
|
|
425
564
|
changed: false, raced: true, retire: { ...retire, pending: false },
|
|
565
|
+
// Nothing was written, so an entry decided on is not on disk.
|
|
566
|
+
...(skill ? { skillHook: skill.install ? { ...skill, install: false, state: "skipped", reason: "raced" } : skill } : {}),
|
|
426
567
|
};
|
|
427
568
|
}
|
|
428
569
|
await writeFileAtomic(cfg.settingsPath, next);
|
|
@@ -431,7 +572,7 @@ export async function installHooks({ provider = "claude", beforeWrite = null } =
|
|
|
431
572
|
// is what a live session's cached command still names until the new entry is
|
|
432
573
|
// on disk, and deleting it early turns a stale sound into a missing module.
|
|
433
574
|
if (retire.pending) await completeSoundHookRetirement(retire, current);
|
|
434
|
-
return { settingsPath: cfg.settingsPath, hookPath, events
|
|
575
|
+
return { settingsPath: cfg.settingsPath, hookPath, events, provider, changed, retire, ...(skill ? { skillHook: skill } : {}) };
|
|
435
576
|
}
|
|
436
577
|
|
|
437
578
|
/**
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// A provider client's memory (#2178): the slow, rarely changing reads — a
|
|
2
|
+
// board list, a field map, a server's time-tracking settings — kept for a
|
|
3
|
+
// while. One per client, and the core keeps one client per connection and
|
|
4
|
+
// makes a new one whenever the connection changes (routes.mjs), so nothing
|
|
5
|
+
// cached here outlives the token or the address it was read with.
|
|
6
|
+
|
|
7
|
+
/** A bounded key/value memory with per-read ages. */
|
|
8
|
+
export function createCache({ max = 500 } = {}) {
|
|
9
|
+
const entries = new Map();
|
|
10
|
+
const set = (key, value) => {
|
|
11
|
+
entries.delete(key);
|
|
12
|
+
entries.set(key, { at: Date.now(), value });
|
|
13
|
+
// A bound, not a policy: a provider caching per board per page is still
|
|
14
|
+
// a few hundred entries.
|
|
15
|
+
while (entries.size > max) entries.delete(entries.keys().next().value);
|
|
16
|
+
return value;
|
|
17
|
+
};
|
|
18
|
+
const peek = (key, ttlMs) => {
|
|
19
|
+
const hit = entries.get(key);
|
|
20
|
+
return hit && Date.now() - hit.at < ttlMs ? hit.value : undefined;
|
|
21
|
+
};
|
|
22
|
+
/** Loads in flight, so two panels asking at once share one read. */
|
|
23
|
+
const pending = new Map();
|
|
24
|
+
return {
|
|
25
|
+
/** The value under `key` younger than `ttlMs`, or a fresh one from `load`. */
|
|
26
|
+
async remember(key, ttlMs, load) {
|
|
27
|
+
const hit = peek(key, ttlMs);
|
|
28
|
+
if (hit !== undefined) return hit;
|
|
29
|
+
if (pending.has(key)) return pending.get(key);
|
|
30
|
+
const promise = (async () => {
|
|
31
|
+
try { return set(key, await load()); } finally { pending.delete(key); }
|
|
32
|
+
})();
|
|
33
|
+
pending.set(key, promise);
|
|
34
|
+
return promise;
|
|
35
|
+
},
|
|
36
|
+
/** The value under `key` younger than `ttlMs`, or undefined. */
|
|
37
|
+
peek,
|
|
38
|
+
set,
|
|
39
|
+
forget(key) { entries.delete(key); },
|
|
40
|
+
clear() { entries.clear(); },
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// What can go wrong between the deck and a tracker it is connected to (#2178),
|
|
2
|
+
// as a short list of kinds the page can act on rather than a status code it
|
|
3
|
+
// would have to interpret.
|
|
4
|
+
//
|
|
5
|
+
// One vocabulary for every provider: Jira today, GitHub and GitLab next. A
|
|
6
|
+
// provider maps its own answers onto these kinds, and the page draws one state
|
|
7
|
+
// per kind — "Jira no longer accepts this token" with a Reconnect, "try again in
|
|
8
|
+
// 30 s", "this server is older than 8.14" — without knowing which tracker said
|
|
9
|
+
// it. The message is ours, written for the person reading the panel; a remote
|
|
10
|
+
// body is never passed through whole, so nothing the tracker echoes (a header, a
|
|
11
|
+
// URL with a query) can ride out on an error.
|
|
12
|
+
import { cooldownFromHeader } from "../quota-oauth.mjs";
|
|
13
|
+
|
|
14
|
+
/** Every kind an integration error can be, and the status the deck answers
|
|
15
|
+
* the page with for it — the contract every provider shares (#2178, #2179):
|
|
16
|
+
* 401 for a refused or expired token, 403 for an account that may not read
|
|
17
|
+
* it, 404 for what is not there, 429 for a rate limit, and 502 for every way
|
|
18
|
+
* the tracker itself failed. `invalid` is the deck's own: a form, or a
|
|
19
|
+
* parameter, that is not the shape it must be (400). `unavailable` is the
|
|
20
|
+
* deck's too: the provider for this kind is not on this deck, or is not
|
|
21
|
+
* answering it (503) — nothing is wrong with the connection or the token,
|
|
22
|
+
* and a page that does not know the kind reads it as `upstream`. */
|
|
23
|
+
export const ERROR_STATUS = Object.freeze({
|
|
24
|
+
auth: 401,
|
|
25
|
+
expired: 401,
|
|
26
|
+
permission: 403,
|
|
27
|
+
"not-found": 404,
|
|
28
|
+
"rate-limit": 429,
|
|
29
|
+
unreachable: 502,
|
|
30
|
+
tls: 502,
|
|
31
|
+
incompatible: 502,
|
|
32
|
+
upstream: 502,
|
|
33
|
+
invalid: 400,
|
|
34
|
+
unavailable: 503,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/** Whether a thrown thing carries one of the kinds — an IntegrationError, or a
|
|
38
|
+
* provider's own typed error (#2179's ForgeError) with the same fields. */
|
|
39
|
+
export function isTypedError(err) {
|
|
40
|
+
return Boolean(err && typeof err === "object" && typeof err.kind === "string" && Object.hasOwn(ERROR_STATUS, err.kind)
|
|
41
|
+
&& typeof err.message === "string");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class IntegrationError extends Error {
|
|
45
|
+
/**
|
|
46
|
+
* @param {keyof typeof ERROR_STATUS} kind
|
|
47
|
+
* @param {string} message what the panel says, in the deck's own words
|
|
48
|
+
* @param {{ status?: number, retryAfter?: number, field?: string, detail?: string }} [extra]
|
|
49
|
+
* status is the tracker's own HTTP status, when it answered one;
|
|
50
|
+
* retryAfter in seconds for a rate limit; field names the form input a bad
|
|
51
|
+
* value came from; detail is a short remote reason safe to show (a JQL
|
|
52
|
+
* error Jira wrote, never a header or a token)
|
|
53
|
+
*/
|
|
54
|
+
constructor(kind, message, extra = {}) {
|
|
55
|
+
super(message);
|
|
56
|
+
this.name = "IntegrationError";
|
|
57
|
+
this.kind = Object.hasOwn(ERROR_STATUS, kind) ? kind : "upstream";
|
|
58
|
+
if (Number.isInteger(extra.status) && extra.status >= 100 && extra.status < 600) this.status = extra.status;
|
|
59
|
+
if (Number.isFinite(extra.retryAfter)) this.retryAfter = Math.max(1, Math.round(extra.retryAfter));
|
|
60
|
+
if (typeof extra.field === "string") this.field = extra.field;
|
|
61
|
+
if (typeof extra.detail === "string" && extra.detail) this.detail = extra.detail.slice(0, 300);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function integrationError(kind, message, extra) {
|
|
66
|
+
return new IntegrationError(kind, message, extra);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The JSON body an error is answered with — the one envelope every provider
|
|
70
|
+
* answers with: `{ ok: false, error: { kind, message, status?, retryAfter? } }`,
|
|
71
|
+
* plus `field` and `detail` when the deck has them. Nothing else of the error
|
|
72
|
+
* goes out: not its stack, not a cause, not anything the tracker echoed. */
|
|
73
|
+
export function errorBody(err) {
|
|
74
|
+
const e = isTypedError(err) ? err : new IntegrationError("upstream", "The deck failed while reading the tracker.");
|
|
75
|
+
const retryAfter = Number.isFinite(e.retryAfter) ? Math.max(1, Math.round(e.retryAfter)) : null;
|
|
76
|
+
return {
|
|
77
|
+
ok: false,
|
|
78
|
+
error: {
|
|
79
|
+
kind: e.kind,
|
|
80
|
+
message: String(e.message).slice(0, 500),
|
|
81
|
+
...(Number.isInteger(e.status) ? { status: e.status } : {}),
|
|
82
|
+
...(retryAfter ? { retryAfter } : {}),
|
|
83
|
+
...(typeof e.field === "string" && e.field ? { field: e.field.slice(0, 40) } : {}),
|
|
84
|
+
...(typeof e.detail === "string" && e.detail ? { detail: e.detail.slice(0, 300) } : {}),
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function errorStatus(err) {
|
|
90
|
+
return isTypedError(err) ? ERROR_STATUS[err.kind] : 500;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** A thrown error as a route answers it, `{ status, body }`: a provider's
|
|
94
|
+
* `routes` catches and returns this rather than spelling the envelope. An
|
|
95
|
+
* error that carries no kind is a bug of ours, a 500 that says nothing more. */
|
|
96
|
+
export function errorResponse(err) {
|
|
97
|
+
return { status: errorStatus(err), body: errorBody(err) };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Seconds to wait, from a Retry-After header: the deck's own floor and ceiling
|
|
101
|
+
* (cooldownFromHeader), because a remote header is not entitled to decide
|
|
102
|
+
* either. 30 s when the header is missing or unreadable. */
|
|
103
|
+
export function retryAfterSeconds(raw) {
|
|
104
|
+
return Math.round(cooldownFromHeader(raw, 30_000, 5_000, 3600_000) / 1000);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The node:tls verification codes a fetch through undici surfaces as
|
|
108
|
+
* `err.cause.code`. A certificate the deck cannot verify is a `tls` error
|
|
109
|
+
* with its own message — there is deliberately no way to ignore one. */
|
|
110
|
+
const TLS_CODES = new Set([
|
|
111
|
+
"CERT_HAS_EXPIRED", "CERT_NOT_YET_VALID", "CERT_REVOKED", "CERT_UNTRUSTED", "CERT_REJECTED",
|
|
112
|
+
"DEPTH_ZERO_SELF_SIGNED_CERT", "SELF_SIGNED_CERT_IN_CHAIN", "UNABLE_TO_VERIFY_LEAF_SIGNATURE",
|
|
113
|
+
"UNABLE_TO_GET_ISSUER_CERT", "UNABLE_TO_GET_ISSUER_CERT_LOCALLY", "ERR_TLS_CERT_ALTNAME_INVALID",
|
|
114
|
+
"HOSTNAME_MISMATCH", "ERR_SSL_WRONG_VERSION_NUMBER", "EPROTO", "ERR_TLS_HANDSHAKE_TIMEOUT",
|
|
115
|
+
]);
|
|
116
|
+
|
|
117
|
+
/** A fetch that threw before any answer came back, as an IntegrationError. */
|
|
118
|
+
export function errorFromFetchFailure(err, label = "the tracker") {
|
|
119
|
+
if (err instanceof IntegrationError) return err;
|
|
120
|
+
const cause = err?.cause ?? {};
|
|
121
|
+
const code = String(cause.code ?? err?.code ?? "");
|
|
122
|
+
if (TLS_CODES.has(code) || /certificate|self[- ]signed|SSL routines|tls/i.test(String(cause.message ?? ""))) {
|
|
123
|
+
return new IntegrationError("tls",
|
|
124
|
+
`The deck could not verify ${label}'s HTTPS certificate (${code || "certificate error"}). ` +
|
|
125
|
+
"Check that the address is right and that the server's certificate is valid for it.");
|
|
126
|
+
}
|
|
127
|
+
if (err?.name === "TimeoutError" || err?.name === "AbortError" || code === "UND_ERR_CONNECT_TIMEOUT" || code === "ETIMEDOUT") {
|
|
128
|
+
return new IntegrationError("unreachable", `${capital(label)} did not answer in time.`);
|
|
129
|
+
}
|
|
130
|
+
if (code === "ENOTFOUND" || code === "EAI_AGAIN") {
|
|
131
|
+
return new IntegrationError("unreachable", `The deck could not find ${label}'s address. Check the URL and your network.`);
|
|
132
|
+
}
|
|
133
|
+
return new IntegrationError("unreachable",
|
|
134
|
+
`The deck could not reach ${label}${code ? ` (${code})` : ""}. Check the URL, your network or VPN.`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** An HTTP answer that was not a success, as an IntegrationError. `phase` is
|
|
138
|
+
* "validate" while a form is being checked and "use" afterwards: a token
|
|
139
|
+
* refused while it is being entered is a wrong token, and refused after it
|
|
140
|
+
* has worked is an expired or revoked one. */
|
|
141
|
+
export function errorFromStatus(status, headers, { phase = "use", label = "the tracker", detail } = {}) {
|
|
142
|
+
const get = name => (typeof headers?.get === "function" ? headers.get(name) : headers?.[name]) ?? null;
|
|
143
|
+
if (status === 401) {
|
|
144
|
+
return phase === "validate"
|
|
145
|
+
? new IntegrationError("auth", `${capital(label)} did not accept these credentials.`, { status })
|
|
146
|
+
: new IntegrationError("expired", `${capital(label)} no longer accepts the saved token. It may have expired or been revoked — reconnect with a new one.`, { status });
|
|
147
|
+
}
|
|
148
|
+
if (status === 403) {
|
|
149
|
+
return new IntegrationError("permission", `${capital(label)} refused: the account or token is not allowed to read this.`, { status, detail });
|
|
150
|
+
}
|
|
151
|
+
if (status === 429) {
|
|
152
|
+
const retryAfter = retryAfterSeconds(get("retry-after") ?? get("beta-retry-after"));
|
|
153
|
+
return new IntegrationError("rate-limit", `${capital(label)} is limiting requests. Try again in ${retryAfter} s.`, { status, retryAfter });
|
|
154
|
+
}
|
|
155
|
+
if (status === 404) return new IntegrationError("not-found", `${capital(label)} has no such page or record, or this account cannot see it.`, { status, detail });
|
|
156
|
+
if (status === 400) return new IntegrationError("upstream", `${capital(label)} refused the request.`, { status, detail });
|
|
157
|
+
if (status === 502 || status === 503 || status === 504) {
|
|
158
|
+
const wait = get("retry-after");
|
|
159
|
+
return new IntegrationError("unreachable", `${capital(label)} is not answering right now (${status}). Try again shortly.`,
|
|
160
|
+
{ status, ...(wait ? { retryAfter: retryAfterSeconds(wait) } : {}) });
|
|
161
|
+
}
|
|
162
|
+
return new IntegrationError("upstream", `${capital(label)} answered ${status}.`, { status, detail });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function capital(text) {
|
|
166
|
+
return text ? text[0].toUpperCase() + text.slice(1) : text;
|
|
167
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// The GitHub and GitLab providers (#2179), for the integrations core's table;
|
|
2
|
+
// registry.mjs has the contract.
|
|
3
|
+
//
|
|
4
|
+
// Both are the same shape: a client from github-api.mjs or gitlab-api.mjs made
|
|
5
|
+
// for one connection — which reads, keeps its short caches, and fetches the
|
|
6
|
+
// images the panel draws — and the reads in forge-routes.mjs under
|
|
7
|
+
// /api/integrations/<kind>/<connId>/. What differs is where the forge is:
|
|
8
|
+
// GitHub is github.com (an Enterprise Server address can come later; the
|
|
9
|
+
// client already takes one), GitLab is gitlab.com or the instance the user
|
|
10
|
+
// typed, under its path if it has one.
|
|
11
|
+
import { ForgeError } from "../forge-http.mjs";
|
|
12
|
+
import { forgeRead } from "../forge-routes.mjs";
|
|
13
|
+
import { createGithubClient } from "../github-api.mjs";
|
|
14
|
+
import { createGitlabClient } from "../gitlab-api.mjs";
|
|
15
|
+
import { integrationError } from "./errors.mjs";
|
|
16
|
+
import { normaliseBaseUrl } from "./proxy.mjs";
|
|
17
|
+
|
|
18
|
+
const GITHUB = "https://github.com";
|
|
19
|
+
const GITLAB = "https://gitlab.com";
|
|
20
|
+
|
|
21
|
+
/** The token typed, or the saved one when an edit left the field blank. */
|
|
22
|
+
function tokenFrom(input, existing) {
|
|
23
|
+
const typed = typeof input?.token === "string" ? input.token.trim() : "";
|
|
24
|
+
const secret = typed || existing?.secret || null;
|
|
25
|
+
if (!secret) throw integrationError("invalid", "Enter a token.", { field: "token" });
|
|
26
|
+
return secret;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The client, with validate() in the core's terms: a token refused while a
|
|
30
|
+
* form is checked is a wrong one (auth); refused on Test, after it was
|
|
31
|
+
* saved, it has expired or been revoked. */
|
|
32
|
+
function withPhase(client) {
|
|
33
|
+
return {
|
|
34
|
+
...client,
|
|
35
|
+
async validate({ phase = "validate" } = {}) {
|
|
36
|
+
try {
|
|
37
|
+
return await client.validate();
|
|
38
|
+
} catch (err) {
|
|
39
|
+
if (phase === "use" && err?.kind === "auth") throw new ForgeError("expired", err.message, { status: err.status });
|
|
40
|
+
throw err;
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const reads = (client, method, subpath, params) => forgeRead(client, method, subpath, params);
|
|
47
|
+
|
|
48
|
+
export const githubProvider = {
|
|
49
|
+
kind: "github",
|
|
50
|
+
label: "GitHub",
|
|
51
|
+
createClient: (conn, secret, { fetchImpl } = {}) =>
|
|
52
|
+
withPhase(createGithubClient({ baseUrl: conn.baseUrl || GITHUB, token: secret, fetchImpl })),
|
|
53
|
+
routes: reads,
|
|
54
|
+
parseInput: (input, existing) => ({ baseUrl: GITHUB, secret: tokenFrom(input, existing), meta: {} }),
|
|
55
|
+
// Two GitHub connections are two accounts; the login tells them apart.
|
|
56
|
+
defaultName: checked => checked?.account?.login || "GitHub",
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const gitlabProvider = {
|
|
60
|
+
kind: "gitlab",
|
|
61
|
+
label: "GitLab",
|
|
62
|
+
createClient: (conn, secret, { fetchImpl } = {}) =>
|
|
63
|
+
withPhase(createGitlabClient({ baseUrl: conn.baseUrl || GITLAB, token: secret, fetchImpl })),
|
|
64
|
+
routes: reads,
|
|
65
|
+
parseInput: (input, existing) => ({
|
|
66
|
+
baseUrl: input?.where === "self"
|
|
67
|
+
? normaliseBaseUrl(input?.baseUrl, { field: "baseUrl", label: "your GitLab's address" })
|
|
68
|
+
: GITLAB,
|
|
69
|
+
secret: tokenFrom(input, existing),
|
|
70
|
+
meta: {},
|
|
71
|
+
}),
|
|
72
|
+
// Two GitLab connections are usually two instances; the host tells them apart.
|
|
73
|
+
defaultName: checked => {
|
|
74
|
+
try { return new URL(checked.baseUrl).host; } catch { return "GitLab"; }
|
|
75
|
+
},
|
|
76
|
+
};
|