@lanes-sh/link 0.6.8 → 0.6.9
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 +5 -0
- package/package.json +1 -1
- package/src/cli/brand.ts +10 -9
- package/src/cli/commands/operate/auth.ts +333 -0
- package/src/cli/commands/operate/desktop.ts +220 -0
- package/src/cli/commands/operate/findings.ts +9 -38
- package/src/cli/commands/operate/inspect.ts +59 -40
- package/src/cli/commands/operate/serve.ts +0 -3
- package/src/cli/commands/operate.ts +5 -3
- package/src/cli/main.ts +19 -4
- package/src/cli/selection.ts +15 -4
- package/src/cli/usage.ts +5 -1
- package/src/connectivity/auth/index.ts +1 -0
- package/src/connectivity/auth/oauth-authcode/provider.ts +17 -1
- package/src/connectivity/auth/oauth-authcode/refresh.ts +43 -11
- package/src/connectivity/auth/oauth-jwt/index.ts +12 -1
- package/src/connectivity/auth/reauth.ts +48 -0
- package/src/server/cors.ts +3 -3
- package/src/server/endpoint.ts +0 -17
- package/src/server/harness.ts +0 -7
- package/src/server/index.ts +3 -33
- package/src/server/mcp/index.ts +0 -1
- package/src/server/mcp/visibility.ts +0 -33
- package/src/cli/commands/operate/dashboard.ts +0 -107
- package/src/cli/dashboard-page.ts +0 -293
- package/src/cli/dashboard-shell.ts +0 -125
- package/src/cli/provider-marks.ts +0 -45
- package/src/server/dashboard.ts +0 -212
package/src/server/index.ts
CHANGED
|
@@ -4,12 +4,6 @@ import { capabilityIdForToolName } from '#server/mcp';
|
|
|
4
4
|
import { ATTACHMENTS_PATH, stageAttachment } from './attachments.ts';
|
|
5
5
|
import { allowedHostnamesFor, rebindingRefusal } from './rebinding.ts';
|
|
6
6
|
import { ANY_ORIGIN, corsAware, type CorsPolicy } from './cors.ts';
|
|
7
|
-
import {
|
|
8
|
-
DASHBOARD_PATH,
|
|
9
|
-
dashboardSessions,
|
|
10
|
-
handleDashboard,
|
|
11
|
-
servesDashboard,
|
|
12
|
-
} from './dashboard.ts';
|
|
13
7
|
import type { Generation } from './generation.ts';
|
|
14
8
|
import type { Generations } from './generations.ts';
|
|
15
9
|
import { callerKey, failedAuthLimiter, FAILED_AUTH_PER_MINUTE, tooManyAttempts } from './edge.ts';
|
|
@@ -56,15 +50,6 @@ export interface ServerOptions {
|
|
|
56
50
|
readonly authorization?: AuthorizationSurface | undefined;
|
|
57
51
|
/** Hostnames this endpoint answers to. See `./rebinding.ts`. */
|
|
58
52
|
readonly allowedHostnames?: readonly string[] | undefined;
|
|
59
|
-
/**
|
|
60
|
-
* Serve the dashboard.
|
|
61
|
-
*
|
|
62
|
-
* Off unless asked for, and `serve()` withholds it anyway when the bind
|
|
63
|
-
* address is not loopback. `lanes link start` asks; `container.ts` does not.
|
|
64
|
-
* See `./dashboard.ts` for why a deployed instance has no browser-shaped door
|
|
65
|
-
* to put it behind.
|
|
66
|
-
*/
|
|
67
|
-
readonly dashboard?: boolean | undefined;
|
|
68
53
|
}
|
|
69
54
|
|
|
70
55
|
type RefusalReason = Extract<AuthOutcome, { ok: false }>['reason'];
|
|
@@ -126,7 +111,6 @@ export interface RequestHandler {
|
|
|
126
111
|
export function createRequestHandler(options: ServerOptions): RequestHandler {
|
|
127
112
|
let probedAt = 0;
|
|
128
113
|
const failedAuth = failedAuthLimiter();
|
|
129
|
-
const sessions = dashboardSessions();
|
|
130
114
|
|
|
131
115
|
/**
|
|
132
116
|
* Re-read the config because a call named a tool we do not serve.
|
|
@@ -186,19 +170,6 @@ export function createRequestHandler(options: ServerOptions): RequestHandler {
|
|
|
186
170
|
});
|
|
187
171
|
}
|
|
188
172
|
|
|
189
|
-
// Above the 404 gate and outside the bearer path below, because a
|
|
190
|
-
// top-level browser navigation carries no `Authorization` header — so it
|
|
191
|
-
// authenticates itself, against the same authenticator. Unset, this is
|
|
192
|
-
// never reached and `/dashboard` is a 404 like any other unknown path.
|
|
193
|
-
if (options.dashboard && url.pathname === DASHBOARD_PATH) {
|
|
194
|
-
return await handleDashboard(request, {
|
|
195
|
-
generations: options.generations,
|
|
196
|
-
authenticator: options.authenticator,
|
|
197
|
-
primary: options.primary,
|
|
198
|
-
sessions,
|
|
199
|
-
});
|
|
200
|
-
}
|
|
201
|
-
|
|
202
173
|
if (
|
|
203
174
|
url.pathname !== MCP_PATH &&
|
|
204
175
|
url.pathname !== ATTACHMENTS_PATH &&
|
|
@@ -369,15 +340,14 @@ export function serve(options: ServeOptions): RunningServer {
|
|
|
369
340
|
const allowedHostnames = options.allowedHostnames ?? allowedHostnamesFor(host, loopback);
|
|
370
341
|
|
|
371
342
|
// Cross-origin access, and its absence, are decided here for the same reason
|
|
372
|
-
// `allowedHostnames`
|
|
373
|
-
//
|
|
374
|
-
//
|
|
343
|
+
// `allowedHostnames` is: both are properties of what this is bound to. The
|
|
344
|
+
// two are mutually exclusive and the exclusion is the decision — see
|
|
345
|
+
// `./cors.ts`, and ADR-039.
|
|
375
346
|
const cors: CorsPolicy | undefined = loopback
|
|
376
347
|
? undefined
|
|
377
348
|
: { allowedOrigins: primary.config.auth.allowed_origins ?? [ANY_ORIGIN] };
|
|
378
349
|
const handler = createRequestHandler({
|
|
379
350
|
...options,
|
|
380
|
-
dashboard: servesDashboard(options.dashboard, loopback),
|
|
381
351
|
...(allowedHostnames ? { allowedHostnames } : {}),
|
|
382
352
|
});
|
|
383
353
|
|
package/src/server/mcp/index.ts
CHANGED
|
@@ -20,20 +20,6 @@ import { allowedConnections } from '#policy';
|
|
|
20
20
|
* still a leak.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
/**
|
|
24
|
-
* A connection's reconciled state, as much of it as a reader needs.
|
|
25
|
-
*
|
|
26
|
-
* Structural rather than the store's own `ConnectionRecord`: `server` does not
|
|
27
|
-
* import `stores` (`src/architecture.test.ts`), and what a surface that reports
|
|
28
|
-
* wants from a connection is its key and whether it is working — not the
|
|
29
|
-
* timestamps and credential expiry the repository keeps behind it.
|
|
30
|
-
*/
|
|
31
|
-
export interface ConnectionState {
|
|
32
|
-
readonly provider: string;
|
|
33
|
-
readonly id: string;
|
|
34
|
-
readonly status: string;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
23
|
/** Everything one profile contributes to the endpoint. */
|
|
38
24
|
export interface ProfileRuntime {
|
|
39
25
|
readonly config: Config;
|
|
@@ -49,25 +35,6 @@ export interface ProfileRuntime {
|
|
|
49
35
|
* how often to ask (ADR-014).
|
|
50
36
|
*/
|
|
51
37
|
refreshSkills?(): Promise<void>;
|
|
52
|
-
/**
|
|
53
|
-
* Which target's adapters this profile was opened against.
|
|
54
|
-
*
|
|
55
|
-
* Not derivable from `config`: a target is *selected* per run, and the config
|
|
56
|
-
* only says which one is the default. Optional for the same reason the one
|
|
57
|
-
* below is — a runtime built to answer "what is visible" was never opened
|
|
58
|
-
* against anything.
|
|
59
|
-
*/
|
|
60
|
-
readonly target?: string | undefined;
|
|
61
|
-
/**
|
|
62
|
-
* Reconciled connection state, for a surface that reports rather than
|
|
63
|
-
* dispatches.
|
|
64
|
-
*
|
|
65
|
-
* Optional exactly as `refreshSkills` is: only a served endpoint holds the
|
|
66
|
-
* state handle this reads through, and nothing on the dispatch path asks —
|
|
67
|
-
* a capability's visibility is decided by policy, not by whether the
|
|
68
|
-
* credential behind it currently works.
|
|
69
|
-
*/
|
|
70
|
-
connections?(): Promise<readonly ConnectionState[]>;
|
|
71
38
|
}
|
|
72
39
|
|
|
73
40
|
export interface BuildServerOptions {
|
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
import { deploymentIdentity, endpointHealth, localUrl } from '../../endpoint-url.ts';
|
|
2
|
-
import { defaultOpenBrowser } from '../../oauth.ts';
|
|
3
|
-
import { announce, ok, print, style, warn } from '../../output.ts';
|
|
4
|
-
import {
|
|
5
|
-
ensureProfileToken,
|
|
6
|
-
openRuntime,
|
|
7
|
-
resolveProfile,
|
|
8
|
-
type GlobalFlags,
|
|
9
|
-
} from '../../runtime.ts';
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* `lanes link dashboard` — open the page this endpoint serves.
|
|
13
|
-
*
|
|
14
|
-
* The command exists because the page cannot be reached without it. A browser
|
|
15
|
-
* navigating to `/dashboard` carries no `Authorization` header, so the token has
|
|
16
|
-
* to arrive some other way, and the only place it already exists is the
|
|
17
|
-
* credential store this command can read. It puts it in the URL once; the
|
|
18
|
-
* endpoint exchanges it for a session cookie and redirects to a URL without it.
|
|
19
|
-
*
|
|
20
|
-
* Local only, deliberately. See `#server/dashboard.ts` — ADR-018 leaves a
|
|
21
|
-
* deployed instance with no door a person at a browser can come through, and a
|
|
22
|
-
* command that opened a loopback URL for a target that is not there would be
|
|
23
|
-
* reporting success for a page nobody can load.
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
export interface DashboardFlags extends GlobalFlags {
|
|
27
|
-
/** Print the URL instead of opening it. */
|
|
28
|
-
readonly print?: boolean | undefined;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export async function dashboard(flags: DashboardFlags): Promise<void> {
|
|
32
|
-
// `resolveProfile` rather than `openRuntime`, and the order is the point.
|
|
33
|
-
// Opening a runtime opens that target's adapters, so a deployed target sends
|
|
34
|
-
// this command to Secret Manager and a bucket before it can say the one thing
|
|
35
|
-
// it needs to — that the dashboard is not there. The refusal below would
|
|
36
|
-
// still be correct and nobody would ever read it, because a storage 403
|
|
37
|
-
// arrives first. This is the seam `runtime.ts` documents `deploy` and
|
|
38
|
-
// `secrets push` stopping at, for the same reason.
|
|
39
|
-
const { resolution, config, target, resolved } = await resolveProfile(flags);
|
|
40
|
-
announce(resolution);
|
|
41
|
-
|
|
42
|
-
const deployed = deploymentIdentity(resolved?.declared.deploy);
|
|
43
|
-
if (deployed) {
|
|
44
|
-
throw new Error(
|
|
45
|
-
`Target "${target}" is deployed to ${deployed.platform}, and the dashboard is served only ` +
|
|
46
|
-
'by a local endpoint — a browser carries no bearer token, and Cloud Run\'s own gate ' +
|
|
47
|
-
'admits only a Google-signed identity token it cannot mint either (ADR-018).\n' +
|
|
48
|
-
`What a deployed target can answer: ` +
|
|
49
|
-
`lanes link status --profile ${resolution.profile} --target ${target}`,
|
|
50
|
-
);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const runtime = await openRuntime(flags);
|
|
54
|
-
|
|
55
|
-
try {
|
|
56
|
-
const { token } = await ensureProfileToken(runtime.credentials, runtime.config.auth.token_ref);
|
|
57
|
-
|
|
58
|
-
const url = new URL(localUrl(runtime.config));
|
|
59
|
-
url.pathname = '/dashboard';
|
|
60
|
-
url.searchParams.set('k', token);
|
|
61
|
-
// Named rather than left to the endpoint's primary, so the page opens on
|
|
62
|
-
// the profile this command resolved and printed above.
|
|
63
|
-
url.searchParams.set('profile', runtime.resolution.profile);
|
|
64
|
-
|
|
65
|
-
// The page is served by a running endpoint or by nothing. Saying so here
|
|
66
|
-
// beats opening a browser at a connection error.
|
|
67
|
-
const live = await endpointHealth(localUrl(runtime.config), token);
|
|
68
|
-
if (!live) {
|
|
69
|
-
// Both flags on both lines. They read as correct without them only
|
|
70
|
-
// because they were written while a missing one still resolved to the
|
|
71
|
-
// workspace default; with nothing to fall back on (ADR-037) each is a
|
|
72
|
-
// paste that refuses, and this one is printed at the moment somebody is
|
|
73
|
-
// least able to guess what it wanted.
|
|
74
|
-
const where = `--profile ${runtime.resolution.profile} --target ${target}`;
|
|
75
|
-
|
|
76
|
-
print(warn(`nothing is serving this port — run: lanes link start ${where}`));
|
|
77
|
-
print(style.dim(` then: lanes link dashboard ${where}`));
|
|
78
|
-
return;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
if (!live.profiles.includes(runtime.resolution.profile)) {
|
|
82
|
-
// `start --only` serves one profile. The URL would 404 on the rest, which
|
|
83
|
-
// is honest but unhelpful without being told why.
|
|
84
|
-
print(
|
|
85
|
-
warn(
|
|
86
|
-
`the endpoint on this port serves ${live.profiles.join(', ')} — ` +
|
|
87
|
-
`not "${runtime.resolution.profile}"`,
|
|
88
|
-
),
|
|
89
|
-
);
|
|
90
|
-
return;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
if (flags.print) {
|
|
94
|
-
// Printed, not opened: for a terminal on a machine with no browser, and
|
|
95
|
-
// for anyone who would rather see what they are about to open. It carries
|
|
96
|
-
// the token, which is why it is behind a flag rather than always shown.
|
|
97
|
-
print(url.href);
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
defaultOpenBrowser(url.href);
|
|
102
|
-
print(ok(`opened ${style.bold(`${url.origin}/dashboard`)}`));
|
|
103
|
-
print(style.dim(' the link carries a one-time key; the page swaps it for a session cookie.'));
|
|
104
|
-
} finally {
|
|
105
|
-
await runtime.close();
|
|
106
|
-
}
|
|
107
|
-
}
|
|
@@ -1,293 +0,0 @@
|
|
|
1
|
-
import { PROVIDER_MANIFESTS } from '#providers/index.ts';
|
|
2
|
-
import { planAll, type ProviderPlan } from '#providers/setup/plan.ts';
|
|
3
|
-
import { PROVIDER_MARKS } from './provider-marks.ts';
|
|
4
|
-
import { escapeHtml } from './brand.ts';
|
|
5
|
-
import { shell } from './dashboard-shell.ts';
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* The dashboard — one page answering what `status`, `setup plan` and
|
|
9
|
-
* `target list` answer between them.
|
|
10
|
-
*
|
|
11
|
-
* It renders and it does not act. Every state-changing thing on it is a command
|
|
12
|
-
* to paste, for two reasons that are not the same. Connecting is ADR-005: the
|
|
13
|
-
* consent belongs to whoever owns the browser, and the loopback listener that
|
|
14
|
-
* receives the code is the CLI's. Everything else is ADR-007: config is written
|
|
15
|
-
* from the control plane, and a page served by the endpoint is not it.
|
|
16
|
-
*
|
|
17
|
-
* So this is a reader, and the honest shape of a reader is a page that tells you
|
|
18
|
-
* the line to run. What it saves is not typing — it is knowing which line.
|
|
19
|
-
*
|
|
20
|
-
* The design is `callback-page.ts`'s, because that is the product's page and
|
|
21
|
-
* this is the same product: nothing painted (`color-scheme: light dark` over a
|
|
22
|
-
* transparent background, correct in either mode with no theme switch), Lora on
|
|
23
|
-
* the heading, and colour reserved for status rather than spent on emphasis.
|
|
24
|
-
* What differs is the width — a card centred at 460px is right for one sentence
|
|
25
|
-
* and wrong for four sections.
|
|
26
|
-
*/
|
|
27
|
-
|
|
28
|
-
export interface DashboardConnection {
|
|
29
|
-
/** `provider.id`, the key everything else addresses this by. */
|
|
30
|
-
readonly key: string;
|
|
31
|
-
readonly provider: string;
|
|
32
|
-
readonly account: string;
|
|
33
|
-
/** What the operator calls it, where that is not just the account. */
|
|
34
|
-
readonly label?: string | undefined;
|
|
35
|
-
/** `active`, `unauthorized`, `disabled`, or `not reconciled`. */
|
|
36
|
-
readonly state: string;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export interface DashboardView {
|
|
40
|
-
/** The profile being rendered, which `?profile=` may have chosen. */
|
|
41
|
-
readonly profile: string;
|
|
42
|
-
/** Every profile this endpoint serves, for the switcher. */
|
|
43
|
-
readonly profiles: readonly string[];
|
|
44
|
-
/** The target whose adapters are open. */
|
|
45
|
-
readonly target: string;
|
|
46
|
-
/** Every target the profile declares, live or not. */
|
|
47
|
-
readonly targets: readonly string[];
|
|
48
|
-
readonly connections: readonly DashboardConnection[];
|
|
49
|
-
/** `oauth_apps` keys, so a profile with its own client is described as having one. */
|
|
50
|
-
readonly ownClients: readonly string[];
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Every command this page renders, built in one place.
|
|
55
|
-
*
|
|
56
|
-
* `--profile` and `--target` on all of them, unconditionally. The shell a line
|
|
57
|
-
* is pasted into resolves both for itself — from `LANES_LINK_PROFILE` and
|
|
58
|
-
* `LANES_LINK_TARGET`, or from the workspace default — and a page that shows
|
|
59
|
-
* you one profile while handing you a command that silently acts on another is
|
|
60
|
-
* worse than one that shows nothing. It is also the rule `resolveSelection`
|
|
61
|
-
* already follows by refusing to guess.
|
|
62
|
-
*/
|
|
63
|
-
function command(view: DashboardView, rest: string): string {
|
|
64
|
-
return `lanes link ${rest} --profile ${view.profile} --target ${view.target}`;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* A command, shown in full.
|
|
69
|
-
*
|
|
70
|
-
* Kept for the two places where the command *is* the label — the sign-in page
|
|
71
|
-
* and the footer — because there is no provider name to carry it. Everywhere
|
|
72
|
-
* else the line is on the button, not on the page; see `copyButton`.
|
|
73
|
-
*/
|
|
74
|
-
function commandLine(line: string): string {
|
|
75
|
-
const text = escapeHtml(line);
|
|
76
|
-
return (
|
|
77
|
-
`<div class="cmd"><code>${text}</code>` +
|
|
78
|
-
`<button class="btn copy" type="button" data-copy="${text}" aria-label="Copy command">copy</button></div>`
|
|
79
|
-
);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* The command, on the button rather than beside it.
|
|
84
|
-
*
|
|
85
|
-
* What this trades away is worth naming: the line is no longer selectable by
|
|
86
|
-
* hand, so a browser with no clipboard API leaves no way to get it. That is
|
|
87
|
-
* narrower than it sounds — `navigator.clipboard` needs a secure context, and
|
|
88
|
-
* `http://127.0.0.1` is one by definition, which is the only address this page
|
|
89
|
-
* is ever served on. The `title` carries the text for anyone who wants to read
|
|
90
|
-
* before pasting, and `lanes link setup plan` prints the same lines.
|
|
91
|
-
*/
|
|
92
|
-
function copyButton(line: string, label: string): string {
|
|
93
|
-
const text = escapeHtml(line);
|
|
94
|
-
return (
|
|
95
|
-
`<button class="btn copy" type="button" data-copy="${text}" title="${text}" ` +
|
|
96
|
-
`aria-label="Copy the command that connects ${escapeHtml(label)}">copy</button>`
|
|
97
|
-
);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* The provider's mark, or letters standing in for one.
|
|
102
|
-
*
|
|
103
|
-
* `aria-hidden` on both: every caller puts the name beside it, and a screen
|
|
104
|
-
* reader announcing "GitHub, GitHub" is worse than one announcing it once.
|
|
105
|
-
*
|
|
106
|
-
* The fallback takes the first letter of the family and two of the member,
|
|
107
|
-
* because the plain two collapse a catalogue holding four `icloud_*` entries
|
|
108
|
-
* and two `*_mcp` ones into a handful of identical marks — `ICA` and `ICO`
|
|
109
|
-
* rather than `IC` twice.
|
|
110
|
-
*/
|
|
111
|
-
function mark(id: string): string {
|
|
112
|
-
const path = PROVIDER_MARKS[id];
|
|
113
|
-
if (path) {
|
|
114
|
-
return (
|
|
115
|
-
'<svg class="glyph" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">' +
|
|
116
|
-
`<path d="${path}"/></svg>`
|
|
117
|
-
);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const [family, member] = id.split('_');
|
|
121
|
-
const letters = member ? `${family![0]}${member.slice(0, 2)}` : id.slice(0, 2);
|
|
122
|
-
return `<span class="glyph letters" aria-hidden="true">${escapeHtml(letters.toUpperCase())}</span>`;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* The state, as one of the design system's badge variants.
|
|
127
|
-
*
|
|
128
|
-
* "Gold for positive, neutral tokens otherwise" — so `active` is the only gold
|
|
129
|
-
* one, and `unauthorized` is *neutral* rather than red. It is not an error: the
|
|
130
|
-
* credential is absent from this target's store, which is a thing to do, and
|
|
131
|
-
* `--destructive` is reserved for something having gone wrong. `disabled` is
|
|
132
|
-
* quieter still, being a row that config no longer declares.
|
|
133
|
-
*/
|
|
134
|
-
function statusPill(state: string): string {
|
|
135
|
-
const kind = state === 'active' ? 'positive' : state === 'disabled' ? 'quiet' : 'neutral';
|
|
136
|
-
return `<span class="pill ${kind}">${escapeHtml(state)}</span>`;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function connectionsSection(view: DashboardView): string {
|
|
140
|
-
if (view.connections.length === 0) {
|
|
141
|
-
return '<p class="empty">Nothing connected in this profile yet. Pick one below.</p>';
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
const rows = view.connections
|
|
145
|
-
.map((connection) => {
|
|
146
|
-
// A connection that is not active is one the reader can act on, and the
|
|
147
|
-
// action is the same `connect` that made it — re-running against an
|
|
148
|
-
// existing key repairs it rather than adding a second account. An active
|
|
149
|
-
// one has nothing to do, so it carries no button.
|
|
150
|
-
const repair =
|
|
151
|
-
connection.state === 'active'
|
|
152
|
-
? ''
|
|
153
|
-
: copyButton(command(view, `connect ${connection.key}`), connection.key);
|
|
154
|
-
|
|
155
|
-
return (
|
|
156
|
-
'<div class="row">' +
|
|
157
|
-
mark(connection.provider) +
|
|
158
|
-
`<code class="key">${escapeHtml(connection.key)}</code>` +
|
|
159
|
-
statusPill(connection.state) +
|
|
160
|
-
// The label reads, the account hovers: the row is one line wide and the
|
|
161
|
-
// address is the longer of the two, so putting both in it wraps every
|
|
162
|
-
// row to make one of them legible.
|
|
163
|
-
`<span class="account"${
|
|
164
|
-
connection.label && connection.label !== connection.account
|
|
165
|
-
? ` title="${escapeHtml(connection.account)}"`
|
|
166
|
-
: ''
|
|
167
|
-
}>${escapeHtml(connection.label ?? connection.account)}</span>` +
|
|
168
|
-
repair +
|
|
169
|
-
'</div>'
|
|
170
|
-
);
|
|
171
|
-
})
|
|
172
|
-
.join('\n');
|
|
173
|
-
|
|
174
|
-
return `<div class="rows">${rows}</div>`;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* One provider, one line: what it is called and the line that connects it.
|
|
179
|
-
*
|
|
180
|
-
* Everything else a card used to carry is gone — the description, whether it
|
|
181
|
-
* opens a browser, who operates its OAuth client. None of it is lost: it is all
|
|
182
|
-
* in `lanes link setup plan` and in the `setup_provider` capability, both of
|
|
183
|
-
* which exist to answer "what does connecting this involve" at the length that
|
|
184
|
-
* question deserves. This page answers a different one — what is there, and
|
|
185
|
-
* what would I paste — and a paragraph per provider buried it.
|
|
186
|
-
*
|
|
187
|
-
* `plan.command` is built by the same `planFor` those two render, so the line
|
|
188
|
-
* copied here and the line an agent suggests cannot drift.
|
|
189
|
-
*/
|
|
190
|
-
function providerCard(plan: ProviderPlan): string {
|
|
191
|
-
return (
|
|
192
|
-
'<div class="row">' +
|
|
193
|
-
mark(plan.id) +
|
|
194
|
-
`<span class="name">${escapeHtml(plan.name)}</span>` +
|
|
195
|
-
copyButton(plan.command, plan.name) +
|
|
196
|
-
'</div>'
|
|
197
|
-
);
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
function catalogue(view: DashboardView): { available: string; another: string } {
|
|
201
|
-
const plans = planAll(PROVIDER_MANIFESTS, {
|
|
202
|
-
profile: view.profile,
|
|
203
|
-
connections: view.connections.map((connection) => connection.key),
|
|
204
|
-
ownClients: view.ownClients,
|
|
205
|
-
target: view.target,
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
// The same split `setup_overview` makes: never connected is an invitation,
|
|
209
|
-
// and already connected is only an invitation when a second account means
|
|
210
|
-
// something (ADR — `multiAccount` is the credential test, not a preference).
|
|
211
|
-
const available = plans.filter((plan) => plan.connected.length === 0);
|
|
212
|
-
const more = plans.filter((plan) => plan.connected.length > 0 && plan.multiAccount);
|
|
213
|
-
|
|
214
|
-
return {
|
|
215
|
-
available: available.map(providerCard).join('\n'),
|
|
216
|
-
another: more.map(providerCard).join('\n'),
|
|
217
|
-
};
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
function switcher(view: DashboardView): string {
|
|
221
|
-
// Links, not a form: choosing a profile here changes what this page shows and
|
|
222
|
-
// nothing else. The workspace default is config, and config is written from
|
|
223
|
-
// the CLI.
|
|
224
|
-
const profiles = view.profiles
|
|
225
|
-
.map((name) => {
|
|
226
|
-
const current = name === view.profile;
|
|
227
|
-
const label = escapeHtml(name);
|
|
228
|
-
return current
|
|
229
|
-
? `<span class="chip on">${label}</span>`
|
|
230
|
-
: `<a class="chip" href="?profile=${encodeURIComponent(name)}">${label}</a>`;
|
|
231
|
-
})
|
|
232
|
-
.join('');
|
|
233
|
-
|
|
234
|
-
// Every declared target, with the one whose adapters are actually open marked.
|
|
235
|
-
// The others are real — they are where `--target` would write — but nothing on
|
|
236
|
-
// this page is reading from them.
|
|
237
|
-
const targets = view.targets
|
|
238
|
-
.map((name) =>
|
|
239
|
-
name === view.target
|
|
240
|
-
? `<span class="chip on">${escapeHtml(name)}</span>`
|
|
241
|
-
: `<span class="chip off" title="declared, but not the one this endpoint opened">${escapeHtml(name)}</span>`,
|
|
242
|
-
)
|
|
243
|
-
.join('');
|
|
244
|
-
|
|
245
|
-
return (
|
|
246
|
-
'<div class="switch">' +
|
|
247
|
-
`<div class="group"><span class="eyebrow">profile</span>${profiles}</div>` +
|
|
248
|
-
`<div class="group"><span class="eyebrow">target</span>${targets}</div>` +
|
|
249
|
-
'</div>'
|
|
250
|
-
);
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
export function dashboardPage(view: DashboardView): Response {
|
|
254
|
-
const { available, another } = catalogue(view);
|
|
255
|
-
|
|
256
|
-
const body =
|
|
257
|
-
`<h1>Lanes Link</h1>${switcher(view)}` +
|
|
258
|
-
`<h2 class="eyebrow">Connections</h2>${connectionsSection(view)}` +
|
|
259
|
-
(available ? `<h2 class="eyebrow">Available</h2><div class="rows">${available}</div>` : '') +
|
|
260
|
-
(another ? `<h2 class="eyebrow">Connect another account</h2><div class="rows">${another}</div>` : '') +
|
|
261
|
-
'<h2 class="eyebrow">Elsewhere</h2>' +
|
|
262
|
-
'<p class="empty">What is reachable, and what is wrong with it:</p>' +
|
|
263
|
-
commandLine(command(view, 'status')) +
|
|
264
|
-
commandLine(command(view, 'doctor'));
|
|
265
|
-
|
|
266
|
-
return shell(body, 'Lanes Link', 200);
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
/**
|
|
270
|
-
* What an unauthenticated browser gets.
|
|
271
|
-
*
|
|
272
|
-
* The narrow card rather than the wide page, because it is one sentence — and
|
|
273
|
-
* it names the command instead of asking for the token, since the command is
|
|
274
|
-
* what puts the token in the URL in the first place. A password field here
|
|
275
|
-
* would be a second way in to guard for no benefit: whoever can run the command
|
|
276
|
-
* is already whoever the token would prove them to be.
|
|
277
|
-
*
|
|
278
|
-
* The one command on this page carries neither `--profile` nor `--target`,
|
|
279
|
-
* against the rule every other command here follows. Both are names, and this
|
|
280
|
-
* page answers before authentication — ADR-018 stopped `/health` naming
|
|
281
|
-
* profiles to an anonymous caller for exactly that reason, and a 401 that
|
|
282
|
-
* recites the workspace's profile list would put it back.
|
|
283
|
-
*/
|
|
284
|
-
export function dashboardSignInPage(status: number): Response {
|
|
285
|
-
return shell(
|
|
286
|
-
'<div class="narrow"><h1>Not signed in</h1>' +
|
|
287
|
-
'<p class="desc">This page opens from the terminal that is serving it.</p>' +
|
|
288
|
-
commandLine('lanes link dashboard') +
|
|
289
|
-
'</div>',
|
|
290
|
-
'Lanes Link',
|
|
291
|
-
status,
|
|
292
|
-
);
|
|
293
|
-
}
|
|
@@ -1,125 +0,0 @@
|
|
|
1
|
-
import { escapeHtml, FONTS, FOOTER, PAGE_CSP, PAGE_HEADERS, TOKENS } from './brand.ts';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* The dashboard's chrome: its layout, its one listener, and the document they
|
|
5
|
-
* hang in.
|
|
6
|
-
*
|
|
7
|
-
* Split from `dashboard-page.ts` because the two answer different questions —
|
|
8
|
-
* that file decides what a row says, this one decides what a row looks like and
|
|
9
|
-
* what the response is allowed to load. Everything shared with the two
|
|
10
|
-
* authorization pages, which is every colour and every face, is in `brand.ts`;
|
|
11
|
-
* what is left here is the one thing this surface does not share with them, a
|
|
12
|
-
* wide list rather than a centred card.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
const STYLE = `
|
|
16
|
-
body { padding: 40px 24px; }
|
|
17
|
-
.wrap { width: 100%; max-width: 620px; margin: 0 auto; }
|
|
18
|
-
.narrow { text-align: center; padding: 32px 0; }
|
|
19
|
-
h1 { margin: 0 0 20px; }
|
|
20
|
-
h2 { margin: 30px 0 8px; }
|
|
21
|
-
.desc { margin: 0 0 12px; font-size: 14px; line-height: 1.6; color: var(--muted-foreground); }
|
|
22
|
-
.empty { margin: 0 0 12px; font-size: 14px; line-height: 1.6; color: var(--muted-foreground);
|
|
23
|
-
opacity: 0.8; }
|
|
24
|
-
|
|
25
|
-
/* Profile and target selectors. The current one is not gold: gold says a thing
|
|
26
|
-
is good, and which profile you are looking at is not a verdict. */
|
|
27
|
-
.switch { display: flex; flex-wrap: wrap; gap: 20px; padding-bottom: 4px; }
|
|
28
|
-
.group { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
|
29
|
-
.chip { display: inline-block; padding: 3px 10px; font-size: 13px; border-radius: 999px;
|
|
30
|
-
border: 1px solid var(--border); color: var(--muted-foreground); text-decoration: none; }
|
|
31
|
-
.chip:hover { color: var(--foreground); background: var(--muted); }
|
|
32
|
-
.chip.on { color: var(--foreground); background: var(--muted); font-weight: 500; }
|
|
33
|
-
.chip.off { border-style: dashed; opacity: 0.5; }
|
|
34
|
-
|
|
35
|
-
/* One line per item: mark, name, whatever qualifies it, then the button hard
|
|
36
|
-
right. \`min-width: 0\` on the row lets the name ellipsize rather than push
|
|
37
|
-
the button off the end. */
|
|
38
|
-
.rows { display: flex; flex-direction: column; }
|
|
39
|
-
.row { display: flex; align-items: center; gap: 10px; min-width: 0;
|
|
40
|
-
padding: 7px 10px 7px 4px; border-radius: 6px; }
|
|
41
|
-
.row:hover { background: var(--muted); }
|
|
42
|
-
.glyph { flex: none; display: inline-flex; align-items: center; justify-content: center;
|
|
43
|
-
width: 17px; height: 17px; margin: 0 3px; opacity: 0.85; }
|
|
44
|
-
/* The stand-in wants the box the real marks do not: bare letters read as a mark
|
|
45
|
-
that failed to load, where a brand mark reads as itself. */
|
|
46
|
-
.glyph.letters { width: 23px; height: 23px; margin: 0; border-radius: 6px; font-size: 8.5px;
|
|
47
|
-
font-weight: 500; letter-spacing: 0.02em; color: var(--muted-foreground);
|
|
48
|
-
border: 1px solid var(--border); opacity: 1; }
|
|
49
|
-
.name, .key { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
50
|
-
.name { font-size: 14px; }
|
|
51
|
-
.key { font-family: var(--mono); font-size: 13px; }
|
|
52
|
-
.account { font-size: 12.5px; color: var(--muted-foreground); opacity: 0.8; min-width: 0;
|
|
53
|
-
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
54
|
-
|
|
55
|
-
/* Always visible, never revealed on hover: the button is the only way to get the
|
|
56
|
-
command now that the line itself is not on the page, and an affordance you
|
|
57
|
-
have to find by sweeping the mouse is one most readers never find. */
|
|
58
|
-
.copy { margin-left: auto; opacity: 0.6; }
|
|
59
|
-
.row:hover .copy { opacity: 1; }
|
|
60
|
-
.copy:hover, .copy:focus-visible { opacity: 1; }
|
|
61
|
-
|
|
62
|
-
/* The two places the command is still the label, so it stays visible. */
|
|
63
|
-
.cmd { display: flex; align-items: center; gap: 8px; margin-top: 8px; padding: 8px 10px;
|
|
64
|
-
background: var(--card); border: 1px dashed var(--border); border-radius: 10px; }
|
|
65
|
-
.cmd code { flex: 1; font-size: 12.5px; line-height: 1.5; word-break: break-all; }
|
|
66
|
-
.cmd .copy { opacity: 0.8; margin-left: 0; }
|
|
67
|
-
`.trim();
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* The copy button, and nothing else.
|
|
71
|
-
*
|
|
72
|
-
* Inline because a separate file would be the first static asset this
|
|
73
|
-
* repository serves, and one listener is not worth that. It reads the command
|
|
74
|
-
* from a data attribute rather than from the DOM text so that what is copied is
|
|
75
|
-
* exactly what was rendered, whitespace included.
|
|
76
|
-
*/
|
|
77
|
-
const SCRIPT = `
|
|
78
|
-
document.addEventListener('click', function (event) {
|
|
79
|
-
var button = event.target.closest('.copy');
|
|
80
|
-
if (!button || !navigator.clipboard) return;
|
|
81
|
-
navigator.clipboard.writeText(button.dataset.copy).then(function () {
|
|
82
|
-
button.textContent = 'copied';
|
|
83
|
-
setTimeout(function () { button.textContent = 'copy'; }, 1200);
|
|
84
|
-
});
|
|
85
|
-
});
|
|
86
|
-
`.trim();
|
|
87
|
-
|
|
88
|
-
export function shell(inner: string, title: string, status: number): Response {
|
|
89
|
-
return new Response(
|
|
90
|
-
`<!doctype html>
|
|
91
|
-
<html lang="en">
|
|
92
|
-
<head>
|
|
93
|
-
<meta charset="utf-8">
|
|
94
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
95
|
-
${FONTS}
|
|
96
|
-
<title>${escapeHtml(title)}</title>
|
|
97
|
-
<style>
|
|
98
|
-
${TOKENS}
|
|
99
|
-
${STYLE}
|
|
100
|
-
</style>
|
|
101
|
-
</head>
|
|
102
|
-
<body>
|
|
103
|
-
<div class="wrap">
|
|
104
|
-
${inner}
|
|
105
|
-
${FOOTER}
|
|
106
|
-
</div>
|
|
107
|
-
<script>
|
|
108
|
-
${SCRIPT}
|
|
109
|
-
</script>
|
|
110
|
-
</body>
|
|
111
|
-
</html>`,
|
|
112
|
-
{
|
|
113
|
-
status,
|
|
114
|
-
headers: {
|
|
115
|
-
...PAGE_HEADERS,
|
|
116
|
-
// The shared policy plus the one thing this page has that the others do
|
|
117
|
-
// not: an inline listener, for the copy button.
|
|
118
|
-
'content-security-policy': `${PAGE_CSP}; script-src 'unsafe-inline'`,
|
|
119
|
-
// Connection keys and account names are not something to leave in a
|
|
120
|
-
// shared browser's back-forward cache.
|
|
121
|
-
'cache-control': 'no-store',
|
|
122
|
-
},
|
|
123
|
-
},
|
|
124
|
-
);
|
|
125
|
-
}
|