@lanes-sh/link 0.2.1 → 0.3.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 +21 -8
- package/instructions/skills/lanes-link/SKILL.md +59 -14
- package/package.json +1 -1
- package/src/auth/index.ts +3 -1
- package/src/auth/oauth/metadata.ts +83 -9
- package/src/auth/oauth/redirects.ts +70 -0
- package/src/auth/oauth/server.ts +49 -69
- package/src/auth/oauth/store.ts +19 -5
- package/src/cli/argv.ts +50 -0
- package/src/cli/brand.ts +178 -0
- package/src/cli/callback-page.ts +108 -128
- package/src/cli/commands/connect/accounts.ts +5 -0
- package/src/cli/commands/connect/assertion.ts +187 -0
- package/src/cli/commands/connect/authorise.ts +61 -17
- package/src/cli/commands/connect/client.ts +37 -9
- package/src/cli/commands/connect/discover.ts +94 -0
- package/src/cli/commands/connect/family.ts +72 -0
- package/src/cli/commands/connect/index.ts +113 -115
- package/src/cli/commands/connect/method.ts +237 -0
- package/src/cli/commands/connect/outcome.ts +42 -1
- package/src/cli/commands/connect/pasted-token.ts +66 -0
- package/src/cli/commands/connect/requirements.ts +60 -8
- package/src/cli/commands/connect/setup.ts +16 -5
- package/src/cli/commands/connect/target-note.ts +34 -0
- package/src/cli/commands/identity.ts +258 -0
- package/src/cli/commands/knowledge/index.ts +390 -0
- package/src/cli/commands/knowledge/migrate.ts +180 -0
- package/src/cli/commands/knowledge/setup.ts +144 -0
- package/src/cli/commands/knowledge.ts +10 -0
- package/src/cli/commands/mcp/harnesses.ts +16 -2
- package/src/cli/commands/mcp/register.ts +9 -1
- package/src/cli/commands/mcp/stdio.ts +21 -0
- package/src/cli/commands/operate/dashboard.ts +107 -0
- package/src/cli/commands/operate/findings.ts +151 -0
- package/src/cli/commands/operate/inspect.ts +56 -158
- package/src/cli/commands/operate/outputs.ts +38 -11
- package/src/cli/commands/operate/serve.ts +3 -0
- package/src/cli/commands/operate/token.ts +1 -1
- package/src/cli/commands/operate.ts +2 -0
- package/src/cli/commands/profile/declare.ts +154 -0
- package/src/cli/commands/profile/removal.ts +17 -0
- package/src/cli/commands/profile.ts +83 -35
- package/src/cli/commands/setup.ts +22 -6
- package/src/cli/commands/target.ts +65 -83
- package/src/cli/config-edit.ts +48 -144
- package/src/cli/config-repair.ts +186 -0
- package/src/cli/dashboard-page.ts +284 -0
- package/src/cli/dashboard-shell.ts +125 -0
- package/src/cli/identity.ts +12 -1
- package/src/cli/main.ts +68 -4
- package/src/cli/oauth-callback.ts +187 -0
- package/src/cli/oauth-exchange.ts +57 -15
- package/src/cli/oauth.ts +67 -177
- package/src/cli/output.ts +21 -5
- package/src/cli/provider-marks.ts +45 -0
- package/src/cli/runtime/open.ts +67 -49
- package/src/cli/runtime/registry.ts +60 -2
- package/src/cli/runtime/select.ts +26 -13
- package/src/cli/runtime/vault.ts +61 -0
- package/src/cli/runtime.ts +2 -1
- package/src/cli/selection.ts +357 -0
- package/src/cli/usage.ts +32 -9
- package/src/connectivity/auth/README.md +7 -1
- package/src/connectivity/auth/basic/index.ts +1 -1
- package/src/connectivity/auth/index.ts +14 -0
- package/src/connectivity/auth/oauth-authcode/broker.ts +26 -0
- package/src/connectivity/auth/oauth-authcode/index.ts +16 -2
- package/src/connectivity/auth/oauth-authcode/provider.ts +1 -1
- package/src/connectivity/auth/oauth-authcode/refresh.ts +3 -3
- package/src/connectivity/auth/oauth-jwt/README.md +33 -0
- package/src/connectivity/auth/oauth-jwt/index.ts +237 -0
- package/src/connectivity/auth/oauth-jwt/key.ts +148 -0
- package/src/connectivity/auth/resolve.ts +1 -1
- package/src/connectivity/auth/token.ts +11 -0
- package/src/connectivity/index.ts +2 -0
- package/src/connectivity/manifest/auth.ts +99 -2
- package/src/connectivity/manifest/identity.ts +12 -0
- package/src/connectivity/manifest/index.ts +3 -1
- package/src/connectivity/manifest/provider.ts +37 -8
- package/src/connectivity/manifest/requirements.ts +109 -6
- package/src/deployments/adapters/filesystem.ts +10 -1
- package/src/deployments/adapters/github-api.ts +106 -0
- package/src/deployments/adapters/github-commit.ts +103 -0
- package/src/deployments/adapters/github-repo.ts +356 -0
- package/src/deployments/adapters/github-testing.ts +258 -0
- package/src/deployments/adapters/github.ts +125 -0
- package/src/deployments/deploy.ts +48 -19
- package/src/deployments/driver.ts +8 -1
- package/src/deployments/gcp/driver.ts +9 -1
- package/src/deployments/gcp/survey.ts +3 -0
- package/src/deployments/knowledge.ts +119 -0
- package/src/deployments/prepare.ts +2 -2
- package/src/deployments/servable.ts +81 -0
- package/src/deployments/target.ts +3 -2
- package/src/deployments/upload.ts +2 -1
- package/src/dispatch/dispatch.ts +1 -1
- package/src/profile/authorization.ts +13 -4
- package/src/profile/identity.ts +60 -0
- package/src/profile/index.ts +15 -5
- package/src/profile/knowledge.ts +124 -0
- package/src/profile/load.ts +17 -5
- package/src/profile/primitives.ts +24 -1
- package/src/profile/schema.ts +72 -4
- package/src/profile/targets.ts +74 -114
- package/src/profile/workspace.ts +71 -84
- package/src/providers/google/calendar/index.ts +2 -0
- package/src/providers/google/contacts/index.ts +2 -0
- package/src/providers/google/docs/index.ts +2 -0
- package/src/providers/google/drive/index.ts +2 -0
- package/src/providers/google/gmail/index.ts +2 -0
- package/src/providers/google/gmail-imap/index.ts +125 -0
- package/src/providers/google/index.ts +2 -1
- package/src/providers/google/shared/oauth.ts +18 -6
- package/src/providers/google/shared/service-account.ts +110 -0
- package/src/providers/google/shared/setup.ts +5 -2
- package/src/providers/google/sheets/index.ts +2 -0
- package/src/providers/google/tasks/index.ts +2 -0
- package/src/providers/identity/provider.ts +166 -0
- package/src/providers/index.ts +3 -0
- package/src/providers/owner.ts +10 -2
- package/src/providers/scopes.ts +2 -0
- package/src/providers/setup/plan.ts +31 -9
- package/src/providers/setup/provider.ts +23 -0
- package/src/providers/slack/index.ts +81 -33
- package/src/providers/slack/oauth.ts +103 -0
- package/src/providers/slack/scopes.ts +37 -0
- package/src/server/container.ts +18 -1
- package/src/server/cors.ts +252 -0
- package/src/server/dashboard.ts +208 -0
- package/src/server/endpoint.ts +57 -4
- package/src/server/generation.ts +1 -0
- package/src/server/generations.ts +13 -2
- package/src/server/harness.ts +20 -3
- package/src/server/index.ts +76 -12
- package/src/server/mcp/build.ts +1 -1
- package/src/server/mcp/index.ts +1 -0
- package/src/server/mcp/instructions.ts +62 -5
- package/src/server/mcp/visibility.ts +42 -0
- package/src/stores/blobs/route.ts +123 -0
package/src/cli/argv.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { GlobalFlags } from './runtime.ts';
|
|
2
2
|
import type { OwnerFlags } from './commands/owner.ts';
|
|
3
|
+
import type { KnowledgeFlags } from './commands/knowledge.ts';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Turning argv into a command path and a flag bag.
|
|
@@ -63,6 +64,32 @@ export function text(flags: Flags, name: string): string | undefined {
|
|
|
63
64
|
return typeof value === 'string' ? value : undefined;
|
|
64
65
|
}
|
|
65
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Every value given for a flag, in order.
|
|
69
|
+
*
|
|
70
|
+
* `profile add --target local --target cloud` is the one command where a flag
|
|
71
|
+
* legitimately repeats: it is declaring a list, not selecting one thing. The
|
|
72
|
+
* parser keeps the last value in `flags`, so this re-reads argv rather than
|
|
73
|
+
* changing that shape for every other flag's sake.
|
|
74
|
+
*/
|
|
75
|
+
export function all(argv: readonly string[], name: string): string[] {
|
|
76
|
+
const values: string[] = [];
|
|
77
|
+
|
|
78
|
+
for (let i = 0; i < argv.length; i++) {
|
|
79
|
+
const token = argv[i]!;
|
|
80
|
+
if (token === `--${name}`) {
|
|
81
|
+
const next = argv[i + 1];
|
|
82
|
+
if (next !== undefined && !next.startsWith('--')) values.push(next);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (token.startsWith(`--${name}=`)) values.push(token.slice(name.length + 3));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Comma-separated is the other spelling people reach for, and refusing it
|
|
89
|
+
// would be a refusal with no reason behind it.
|
|
90
|
+
return values.flatMap((value) => value.split(',').map((part) => part.trim())).filter(Boolean);
|
|
91
|
+
}
|
|
92
|
+
|
|
66
93
|
/** The flags every command accepts. */
|
|
67
94
|
export function globalFlags(flags: Flags): GlobalFlags {
|
|
68
95
|
return {
|
|
@@ -92,3 +119,26 @@ export function ownerFlags(flags: Flags): OwnerFlags {
|
|
|
92
119
|
yes: flags['yes'] === true,
|
|
93
120
|
};
|
|
94
121
|
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* `lanes link knowledge`'s flags.
|
|
125
|
+
*
|
|
126
|
+
* Here for the reason `ownerFlags` is: the kebab-case spellings belong in the
|
|
127
|
+
* one place that parses argv, so `--allow-public` is written once rather than
|
|
128
|
+
* once per reader.
|
|
129
|
+
*/
|
|
130
|
+
export function knowledgeFlags(flags: Flags): KnowledgeFlags {
|
|
131
|
+
return {
|
|
132
|
+
...globalFlags(flags),
|
|
133
|
+
repo: text(flags, 'repo'),
|
|
134
|
+
branch: text(flags, 'branch'),
|
|
135
|
+
path: text(flags, 'path'),
|
|
136
|
+
// `--migrate`, `--no-migrate`, or neither — see `KnowledgeFlags.migrate`.
|
|
137
|
+
migrate: flags['migrate'] === true ? true : flags['no-migrate'] === true ? false : undefined,
|
|
138
|
+
keep: flags['keep'] === true,
|
|
139
|
+
allowPublic: flags['allow-public'] === true,
|
|
140
|
+
replace: flags['replace'] === true,
|
|
141
|
+
yes: flags['yes'] === true,
|
|
142
|
+
json: flags['json'] === true,
|
|
143
|
+
};
|
|
144
|
+
}
|
package/src/cli/brand.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Lanes design tokens, and the pieces every page this repository serves is
|
|
3
|
+
* built from.
|
|
4
|
+
*
|
|
5
|
+
* Three surfaces render HTML — the authorization consent screen, the page a
|
|
6
|
+
* connect flow lands on, and the dashboard — and until this file existed each
|
|
7
|
+
* carried its own approximation of the brand. They had drifted: two greens and
|
|
8
|
+
* two ambers that are not tokens at all, nine different alphas standing in for
|
|
9
|
+
* one border colour, a destructive red with a dark variant the token does not
|
|
10
|
+
* have, and the system font stack where Geist belongs.
|
|
11
|
+
*
|
|
12
|
+
* Values are transcribed from https://lanes.sh/design/foundations. The rules
|
|
13
|
+
* they express, which the CSS below encodes rather than restates:
|
|
14
|
+
*
|
|
15
|
+
* - **Gold is the only accent.** "Gold for positive, neutral tokens otherwise."
|
|
16
|
+
* That is why there is no green here: a connection that works is `positive`,
|
|
17
|
+
* which is gold, and everything else is one of two neutrals. `--destructive`
|
|
18
|
+
* is for errors, not for a state that merely needs attention.
|
|
19
|
+
* - **Two weights, 400 and 500.** Nothing else is available in the loaded faces,
|
|
20
|
+
* so a heavier rule would silently synthesise.
|
|
21
|
+
* - **Lora heads, Geist speaks, Geist Mono for code and eyebrow labels.**
|
|
22
|
+
*
|
|
23
|
+
* `--border`, `--accent-gold` and `--destructive` are constant across themes.
|
|
24
|
+
* Only the five neutrals swap, which is why the dark block below is five lines
|
|
25
|
+
* rather than a second stylesheet.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Escape text for an HTML body.
|
|
30
|
+
*
|
|
31
|
+
* `escapeXml` in `connectivity/transports/dav/xml.ts` would do, and `cli` is
|
|
32
|
+
* allowed to import `connectivity` — but a page renderer reaching into a DAV
|
|
33
|
+
* transport for a string function is a dependency nobody would defend. Five
|
|
34
|
+
* lines is cheaper than the coupling.
|
|
35
|
+
*
|
|
36
|
+
* Load-bearing: these pages render a provider's `manifest.name`, and a custom
|
|
37
|
+
* provider supplies that from a YAML file of its own.
|
|
38
|
+
*/
|
|
39
|
+
export function escapeHtml(value: string): string {
|
|
40
|
+
return value
|
|
41
|
+
.replace(/&/g, '&')
|
|
42
|
+
.replace(/</g, '<')
|
|
43
|
+
.replace(/>/g, '>')
|
|
44
|
+
.replace(/"/g, '"')
|
|
45
|
+
.replace(/'/g, ''');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* One request for all three faces.
|
|
50
|
+
*
|
|
51
|
+
* `preconnect` to both hosts because the stylesheet and the font files it names
|
|
52
|
+
* are served from different origins, and the second lookup is otherwise not
|
|
53
|
+
* started until the first has parsed.
|
|
54
|
+
*/
|
|
55
|
+
export const FONTS =
|
|
56
|
+
'<link rel="preconnect" href="https://fonts.googleapis.com">\n' +
|
|
57
|
+
'<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>\n' +
|
|
58
|
+
'<link href="https://fonts.googleapis.com/css2?family=Geist+Mono:wght@400;500' +
|
|
59
|
+
'&family=Geist:wght@400;500&family=Lora:wght@400;500&display=swap" rel="stylesheet">';
|
|
60
|
+
|
|
61
|
+
/** The one-row footer, identical to the one the Lanes API's own pages carry. */
|
|
62
|
+
export const FOOTER =
|
|
63
|
+
'<div class="footer"><p>Your unfair advantage in parallel AI coding. ' +
|
|
64
|
+
'<a href="https://lanes.sh/">lanes.sh</a></p></div>';
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The tokens, and the base every page shares.
|
|
68
|
+
*
|
|
69
|
+
* The background is **painted**, which reverses what these pages used to do.
|
|
70
|
+
* The old note read "nothing is painted" — a transparent body under
|
|
71
|
+
* `color-scheme: light dark` so the browser supplied its own canvas — and it
|
|
72
|
+
* was written to fix a hardcoded `#0d1117` that showed as a black rectangle on
|
|
73
|
+
* a light machine. The design system settles that differently: it names a
|
|
74
|
+
* background for each mode, so the fix is to paint the right one rather than
|
|
75
|
+
* none. `color-scheme` stays, because form controls and scrollbars still need
|
|
76
|
+
* to know which mode they are in.
|
|
77
|
+
*/
|
|
78
|
+
export const TOKENS = `
|
|
79
|
+
:root {
|
|
80
|
+
color-scheme: light dark;
|
|
81
|
+
--background: #EBEAE7;
|
|
82
|
+
--card: #F0EFEC;
|
|
83
|
+
--muted: #E2E1DD;
|
|
84
|
+
--foreground: #171717;
|
|
85
|
+
--muted-foreground: #202329;
|
|
86
|
+
--border: rgba(120,113,108,0.2);
|
|
87
|
+
--accent-gold: #A1845A;
|
|
88
|
+
--destructive: #A06060;
|
|
89
|
+
/* The two tints the badge variants need. Spelled as rgba rather than
|
|
90
|
+
color-mix because the consent screen opens in whatever browser a phone
|
|
91
|
+
happens to use, and both accents are constant across themes anyway. */
|
|
92
|
+
--gold-fill: rgba(161,132,90,0.1);
|
|
93
|
+
--gold-ring: rgba(161,132,90,0.25);
|
|
94
|
+
--destructive-fill: rgba(160,96,96,0.1);
|
|
95
|
+
--destructive-ring: rgba(160,96,96,0.25);
|
|
96
|
+
--sans: "Geist", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
97
|
+
--serif: "Lora", Georgia, "Times New Roman", serif;
|
|
98
|
+
--mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
99
|
+
}
|
|
100
|
+
@media (prefers-color-scheme: dark) {
|
|
101
|
+
:root {
|
|
102
|
+
--background: #171717;
|
|
103
|
+
--card: #292524;
|
|
104
|
+
--muted: #202329;
|
|
105
|
+
--foreground: #EBEAE7;
|
|
106
|
+
--muted-foreground: #A8A29E;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
* { box-sizing: border-box; }
|
|
110
|
+
body { margin: 0; font-family: var(--sans); font-weight: 400;
|
|
111
|
+
background: var(--background); color: var(--foreground); }
|
|
112
|
+
h1, h2, h3 { font-family: var(--serif); font-weight: 500; letter-spacing: -0.01em; margin: 0; }
|
|
113
|
+
h1 { font-size: 28px; line-height: 1.3; }
|
|
114
|
+
code, .mono { font-family: var(--mono); }
|
|
115
|
+
a { color: inherit; }
|
|
116
|
+
|
|
117
|
+
/* Eyebrow: 11px, uppercase, wide tracking, muted — and mono, which is where the
|
|
118
|
+
design system puts label type. */
|
|
119
|
+
.eyebrow { font-family: var(--mono); font-size: 11px; font-weight: 400;
|
|
120
|
+
letter-spacing: 0.09em; text-transform: uppercase; color: var(--muted-foreground);
|
|
121
|
+
opacity: 0.75; }
|
|
122
|
+
|
|
123
|
+
/* Surfaces are dashed and 10px; controls are 6px; pills are round. */
|
|
124
|
+
.surface { background: var(--card); border: 1px dashed var(--border); border-radius: 10px; }
|
|
125
|
+
|
|
126
|
+
/* The four badge variants, as the design system defines them: gold for
|
|
127
|
+
positive, neutral tokens otherwise, destructive for an error only. The ring
|
|
128
|
+
is an inset shadow so it costs no layout, exactly as \`ring-inset\` does. */
|
|
129
|
+
.pill { display: inline-flex; align-items: center; flex: none; white-space: nowrap;
|
|
130
|
+
font-size: 11px; font-weight: 500; letter-spacing: 0.02em;
|
|
131
|
+
padding: 2px 8px; border-radius: 999px; box-shadow: inset 0 0 0 1px var(--border); }
|
|
132
|
+
.pill.positive { background: var(--gold-fill); color: var(--accent-gold);
|
|
133
|
+
box-shadow: inset 0 0 0 1px var(--gold-ring); }
|
|
134
|
+
.pill.neutral { background: var(--muted); color: var(--muted-foreground); }
|
|
135
|
+
.pill.quiet { background: transparent; color: var(--muted-foreground); opacity: 0.7; }
|
|
136
|
+
.pill.negative { background: var(--destructive-fill); color: var(--destructive);
|
|
137
|
+
box-shadow: inset 0 0 0 1px var(--destructive-ring); }
|
|
138
|
+
|
|
139
|
+
/* Ghost is the variant every button on these pages is: muted until it is
|
|
140
|
+
wanted, and never gold, because gold is reserved for saying a thing is good. */
|
|
141
|
+
.btn { font: inherit; font-size: 12px; font-weight: 500; line-height: 1;
|
|
142
|
+
padding: 6px 9px; border-radius: 6px; cursor: pointer;
|
|
143
|
+
background: transparent; color: var(--muted-foreground);
|
|
144
|
+
border: 1px solid var(--border); }
|
|
145
|
+
.btn:hover, .btn:focus-visible { color: var(--foreground); background: var(--muted); }
|
|
146
|
+
|
|
147
|
+
.footer { margin: 40px 0 0; text-align: center; }
|
|
148
|
+
.footer p { margin: 0; font-size: 12px; line-height: 1.5; color: var(--muted-foreground); opacity: 0.75; }
|
|
149
|
+
.footer a { text-decoration: underline; }
|
|
150
|
+
`.trim();
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* What every page these modules serve loads, and nothing else.
|
|
154
|
+
*
|
|
155
|
+
* The font stylesheet and the faces it names are the only two origins any of
|
|
156
|
+
* them reaches; `default-src 'none'` closes the rest. No `script-src`, because
|
|
157
|
+
* these pages have no script — the dashboard, which has one listener, extends
|
|
158
|
+
* this rather than replacing it.
|
|
159
|
+
*/
|
|
160
|
+
export const PAGE_CSP =
|
|
161
|
+
"frame-ancestors 'none'; default-src 'none'; " +
|
|
162
|
+
"style-src 'unsafe-inline' https://fonts.googleapis.com; " +
|
|
163
|
+
'font-src https://fonts.gstatic.com';
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Headers every page here answers with.
|
|
167
|
+
*
|
|
168
|
+
* `frame-ancestors` in both spellings: one of these pages asks for the endpoint
|
|
169
|
+
* token and another lists the owner's accounts, and framing either is the cheap
|
|
170
|
+
* half of a UI-redress attack. The URLs carry a `client_id`, a `redirect_uri`,
|
|
171
|
+
* or a one-time key, none of which belongs in a Referer.
|
|
172
|
+
*/
|
|
173
|
+
export const PAGE_HEADERS: Readonly<Record<string, string>> = {
|
|
174
|
+
'content-type': 'text/html; charset=utf-8',
|
|
175
|
+
'content-security-policy': PAGE_CSP,
|
|
176
|
+
'x-frame-options': 'DENY',
|
|
177
|
+
'referrer-policy': 'no-referrer',
|
|
178
|
+
};
|
package/src/cli/callback-page.ts
CHANGED
|
@@ -1,32 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
2
|
+
* The two pages a browser reaches on its way through an authorization.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* with opacity, and the shared one-row footer.
|
|
4
|
+
* `approvalPage` is the consent screen a remote MCP client stops at;
|
|
5
|
+
* `completionPage` is where a provider connect flow lands. For a few seconds
|
|
6
|
+
* each of them is the whole product, so both are the product's own page: the
|
|
7
|
+
* dashed card, the Lora heading, and the shared one-row footer that the Lanes
|
|
8
|
+
* API serves after a workspace invite or an email verification.
|
|
10
9
|
*
|
|
11
|
-
* Two decisions
|
|
10
|
+
* Everything visual now comes from `brand.ts`. Two decisions this file used to
|
|
11
|
+
* record have been settled the other way by the design system, and are worth
|
|
12
|
+
* naming because both look like regressions otherwise:
|
|
12
13
|
*
|
|
13
|
-
* - **
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* a
|
|
17
|
-
*
|
|
18
|
-
* - **
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* check on success and the red heading on failure, and nothing else does.
|
|
14
|
+
* - **The canvas is painted.** This used to be transparent under
|
|
15
|
+
* `color-scheme: light dark`, so the browser supplied its own — a fix for a
|
|
16
|
+
* hardcoded `#0d1117` that read as a black rectangle on a light machine. The
|
|
17
|
+
* system names a background per mode, so the fix is now to paint the right
|
|
18
|
+
* one. `color-scheme` stays, for form controls and scrollbars.
|
|
19
|
+
* - **The success mark is gold, not green.** It was lucide's `check` at
|
|
20
|
+
* `#059669`, chosen to match the tick the Lanes app puts against a connected
|
|
21
|
+
* integration. The design system is explicit that gold is the only accent and
|
|
22
|
+
* "gold for positive, neutral tokens otherwise", so the green was the odd one
|
|
23
|
+
* out rather than the match it was meant to be.
|
|
24
24
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* authorisation is sometimes in.
|
|
25
|
+
* What has not changed is that colour is reserved for status and spent nowhere
|
|
26
|
+
* else — not on emphasis, and not on the heading.
|
|
28
27
|
*/
|
|
29
28
|
|
|
29
|
+
import { escapeHtml, FONTS, FOOTER, PAGE_CSP, PAGE_HEADERS, TOKENS } from './brand.ts';
|
|
30
|
+
|
|
30
31
|
export interface CallbackPage {
|
|
31
32
|
/** The focal line, set in Lora. A provider name, or the outcome itself. */
|
|
32
33
|
readonly heading: string;
|
|
@@ -38,69 +39,29 @@ export interface CallbackPage {
|
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* `escapeXml` in `connectivity/transports/dav/xml.ts` would do, and `cli` is
|
|
44
|
-
* allowed to import `connectivity` — but a page renderer reaching into a DAV
|
|
45
|
-
* transport for a string function is a dependency nobody would defend. Five
|
|
46
|
-
* lines is cheaper than the coupling.
|
|
47
|
-
*
|
|
48
|
-
* This is load-bearing: `heading` carries a provider's `manifest.name`, and a
|
|
49
|
-
* custom provider supplies that from a YAML file of its own.
|
|
50
|
-
*/
|
|
51
|
-
function escapeHtml(value: string): string {
|
|
52
|
-
return value
|
|
53
|
-
.replace(/&/g, '&')
|
|
54
|
-
.replace(/</g, '<')
|
|
55
|
-
.replace(/>/g, '>')
|
|
56
|
-
.replace(/"/g, '"')
|
|
57
|
-
.replace(/'/g, ''');
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Harmonised with the Lanes API's public pages, which are themselves harmonised
|
|
62
|
-
* with the transactional emails — same dashed card, same Lora, same footer, so
|
|
63
|
-
* the end of a connect flow reads as the same product as the invite that
|
|
64
|
-
* preceded it.
|
|
42
|
+
* The card, centred. Everything else is `brand.ts`.
|
|
65
43
|
*/
|
|
66
44
|
const STYLE = `
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
70
|
-
display: flex; align-items: center; justify-content: center;
|
|
71
|
-
min-height: 100vh; margin: 0; background: transparent; padding: 24px; }
|
|
45
|
+
body { display: flex; align-items: center; justify-content: center;
|
|
46
|
+
min-height: 100vh; padding: 24px; }
|
|
72
47
|
.wrap { width: 100%; max-width: 460px; text-align: center; }
|
|
73
|
-
.card {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
.
|
|
79
|
-
.icon { display: block; margin: 0 auto 18px; color: #059669; }
|
|
80
|
-
.footer { margin: 24px 0 0; }
|
|
81
|
-
.footer p { margin: 0; font-size: 12px; line-height: 1.5; opacity: 0.5; }
|
|
82
|
-
.footer a { color: inherit; text-decoration: underline; }
|
|
83
|
-
.err h1 { color: #A06060; }
|
|
84
|
-
@media (prefers-color-scheme: dark) {
|
|
85
|
-
.err h1 { color: #C08080; }
|
|
86
|
-
.icon { color: #34D399; }
|
|
87
|
-
}
|
|
48
|
+
.card { padding: 48px 36px; }
|
|
49
|
+
.label { margin: 0 0 6px; font-size: 15px; font-weight: 500; color: var(--muted-foreground); }
|
|
50
|
+
h1 { margin: 0 0 18px; }
|
|
51
|
+
.detail { margin: 0; font-size: 15px; line-height: 1.6; color: var(--muted-foreground); }
|
|
52
|
+
.icon { display: block; margin: 0 auto 18px; color: var(--accent-gold); }
|
|
53
|
+
.err h1 { color: var(--destructive); }
|
|
88
54
|
`.trim();
|
|
89
55
|
|
|
90
|
-
/** The one-row footer below the card, identical to the one the API pages carry. */
|
|
91
|
-
const FOOTER =
|
|
92
|
-
'<div class="footer"><p>Your unfair advantage in parallel AI coding. ' +
|
|
93
|
-
'<a href="https://lanes.sh/">lanes.sh</a></p></div>';
|
|
94
|
-
|
|
95
56
|
/**
|
|
96
57
|
* The success mark, above the label.
|
|
97
58
|
*
|
|
98
|
-
*
|
|
99
|
-
* the
|
|
100
|
-
*
|
|
59
|
+
* lucide's `check`, in `--accent-gold` — which is what "gold for positive"
|
|
60
|
+
* means when the positive thing is a connection that now works. It carries no
|
|
61
|
+
* colour of its own; `.icon` sets it, so it follows the token if the token moves.
|
|
101
62
|
*
|
|
102
|
-
* `aria-hidden` because the word underneath is the announcement; a screen
|
|
103
|
-
*
|
|
63
|
+
* `aria-hidden` because the word underneath is the announcement; a screen reader
|
|
64
|
+
* hearing "Connected" does not also need to hear about a tick.
|
|
104
65
|
*/
|
|
105
66
|
const ICON =
|
|
106
67
|
'<svg class="icon" xmlns="http://www.w3.org/2000/svg" width="28" height="28" ' +
|
|
@@ -158,49 +119,70 @@ ${hidden}
|
|
|
158
119
|
<input class="field" type="password" name="token" placeholder="Endpoint token" autocomplete="off" autofocus required>
|
|
159
120
|
<button class="go" type="submit">Approve</button>
|
|
160
121
|
</form>
|
|
161
|
-
<p class="
|
|
122
|
+
<p class="small"><code>lanes link outputs --show --target ${escapeHtml(page.target)}</code></p>`;
|
|
162
123
|
|
|
163
|
-
return
|
|
124
|
+
return shell(body, 'Authorise', page.retry ? 401 : 200, '', SUBMIT_SPINNER);
|
|
164
125
|
}
|
|
165
126
|
|
|
166
|
-
|
|
127
|
+
/**
|
|
128
|
+
* What runs while the approval is in flight.
|
|
129
|
+
*
|
|
130
|
+
* Approving is a round trip to an authorization server, and until it returns the
|
|
131
|
+
* page looks exactly as it did before the click — so the honest reading is that
|
|
132
|
+
* nothing happened, and the second click is the one that produces a duplicated
|
|
133
|
+
* request. The button disables itself, which is the part that matters; the
|
|
134
|
+
* spinner is what says why.
|
|
135
|
+
*
|
|
136
|
+
* The cost is real and worth naming: this is the one page that asks for the
|
|
137
|
+
* endpoint token, and an inline listener means its policy admits inline script.
|
|
138
|
+
* It is the minimum that does the job — one listener, no interpolation, nothing
|
|
139
|
+
* read from the page — and the alternative, a static file, is an asset pipeline
|
|
140
|
+
* this repository does not have.
|
|
141
|
+
*/
|
|
142
|
+
const SUBMIT_SPINNER = `
|
|
143
|
+
document.querySelector('form').addEventListener('submit', function (event) {
|
|
144
|
+
var button = event.currentTarget.querySelector('.go');
|
|
145
|
+
button.classList.add('busy');
|
|
146
|
+
button.disabled = true;
|
|
147
|
+
});
|
|
148
|
+
`.trim();
|
|
149
|
+
|
|
150
|
+
function shell(
|
|
151
|
+
inner: string,
|
|
152
|
+
title: string,
|
|
153
|
+
status: number,
|
|
154
|
+
cardClass = '',
|
|
155
|
+
script = '',
|
|
156
|
+
): Response {
|
|
167
157
|
return new Response(
|
|
168
158
|
`<!doctype html>
|
|
169
159
|
<html lang="en">
|
|
170
160
|
<head>
|
|
171
161
|
<meta charset="utf-8">
|
|
172
162
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
173
|
-
|
|
174
|
-
<link href="https://fonts.googleapis.com/css2?family=Lora:wght@400;500&display=swap" rel="stylesheet">
|
|
163
|
+
${FONTS}
|
|
175
164
|
<title>${escapeHtml(title)}</title>
|
|
176
165
|
<style>
|
|
166
|
+
${TOKENS}
|
|
177
167
|
${STYLE}
|
|
178
168
|
${FORM_STYLE}
|
|
179
169
|
</style>
|
|
180
170
|
</head>
|
|
181
171
|
<body>
|
|
182
172
|
<div class="wrap">
|
|
183
|
-
<div class="card">
|
|
173
|
+
<div class="card surface${cardClass}">
|
|
184
174
|
${inner}
|
|
185
175
|
</div>
|
|
186
176
|
${FOOTER}
|
|
187
177
|
</div>
|
|
178
|
+
${script ? `<script>\n${script}\n</script>` : ''}
|
|
188
179
|
</body>
|
|
189
180
|
</html>`,
|
|
190
181
|
{
|
|
191
182
|
status,
|
|
192
183
|
headers: {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
// Framing it is the cheap half of a UI-redress attack, and nothing here
|
|
196
|
-
// is ever meant to be embedded — so refuse it in both spellings, since
|
|
197
|
-
// `frame-ancestors` is the one that is actually specified and
|
|
198
|
-
// `X-Frame-Options` is the one older browsers obey.
|
|
199
|
-
'content-security-policy': "frame-ancestors 'none'",
|
|
200
|
-
'x-frame-options': 'DENY',
|
|
201
|
-
// The URL carries a `client_id` and a `redirect_uri`; neither belongs in
|
|
202
|
-
// a Referer sent to whatever the page links out to.
|
|
203
|
-
'referrer-policy': 'no-referrer',
|
|
184
|
+
...PAGE_HEADERS,
|
|
185
|
+
...(script ? { 'content-security-policy': `${PAGE_CSP}; script-src 'unsafe-inline'` } : {}),
|
|
204
186
|
},
|
|
205
187
|
},
|
|
206
188
|
);
|
|
@@ -208,17 +190,30 @@ ${FOOTER}
|
|
|
208
190
|
|
|
209
191
|
const FORM_STYLE = `
|
|
210
192
|
.field { width: 100%; margin: 20px 0 12px; padding: 11px 13px; font: inherit; font-size: 15px;
|
|
211
|
-
color: inherit; background:
|
|
212
|
-
border-radius:
|
|
213
|
-
.field:focus { outline: none; border-color:
|
|
193
|
+
color: inherit; background: var(--background); border: 1px solid var(--border);
|
|
194
|
+
border-radius: 6px; }
|
|
195
|
+
.field:focus { outline: none; border-color: var(--accent-gold); }
|
|
214
196
|
.go { width: 100%; padding: 11px 13px; font: inherit; font-size: 15px; font-weight: 500;
|
|
215
|
-
color:
|
|
216
|
-
border-radius:
|
|
217
|
-
.go:hover {
|
|
218
|
-
.small { margin-top:
|
|
197
|
+
color: var(--foreground); background: transparent; border: 1px solid var(--border);
|
|
198
|
+
border-radius: 6px; cursor: pointer; }
|
|
199
|
+
.go:hover { background: var(--muted); }
|
|
200
|
+
.small { margin-top: 16px; font-size: 12px; color: var(--muted-foreground); opacity: 0.8; }
|
|
219
201
|
.small code { font-size: 12px; }
|
|
220
|
-
.err-text { color:
|
|
221
|
-
|
|
202
|
+
.err-text { color: var(--destructive); }
|
|
203
|
+
|
|
204
|
+
/* The button, mid-flight. The label goes transparent rather than away, so the
|
|
205
|
+
button keeps the width it had and the card does not reflow under the cursor. */
|
|
206
|
+
.go.busy { color: transparent; position: relative; pointer-events: none; }
|
|
207
|
+
.go.busy::after { content: ''; position: absolute; inset: 0; margin: auto;
|
|
208
|
+
width: 15px; height: 15px; border-radius: 50%;
|
|
209
|
+
border: 2px solid var(--border); border-top-color: var(--foreground);
|
|
210
|
+
animation: spin 0.6s linear infinite; }
|
|
211
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
212
|
+
/* Monochrome deliberately: gold says a thing turned out well, and a request in
|
|
213
|
+
flight has not turned out yet. */
|
|
214
|
+
@media (prefers-reduced-motion: reduce) {
|
|
215
|
+
.go.busy::after { animation-duration: 2.4s; }
|
|
216
|
+
}
|
|
222
217
|
`.trim();
|
|
223
218
|
|
|
224
219
|
export function completionPage(page: CallbackPage): Response {
|
|
@@ -226,31 +221,16 @@ export function completionPage(page: CallbackPage): Response {
|
|
|
226
221
|
|
|
227
222
|
// The tab strip truncates to a few characters, and the word that has to
|
|
228
223
|
// survive that is the outcome — not the name of what was connected.
|
|
229
|
-
const title =
|
|
224
|
+
const title = label ?? heading;
|
|
230
225
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
${STYLE}
|
|
242
|
-
</style>
|
|
243
|
-
</head>
|
|
244
|
-
<body>
|
|
245
|
-
<div class="wrap">
|
|
246
|
-
<div class="card${ok ? '' : ' err'}">
|
|
247
|
-
${ok ? `${ICON}\n` : ''}${label ? `<p class="label">${escapeHtml(label)}</p>\n` : ''}<h1>${escapeHtml(heading)}</h1>
|
|
248
|
-
<p class="detail">${escapeHtml(detail)}</p>
|
|
249
|
-
</div>
|
|
250
|
-
${FOOTER}
|
|
251
|
-
</div>
|
|
252
|
-
</body>
|
|
253
|
-
</html>`,
|
|
254
|
-
{ status: ok ? 200 : 400, headers: { 'content-type': 'text/html; charset=utf-8' } },
|
|
255
|
-
);
|
|
226
|
+
const body =
|
|
227
|
+
`${ok ? `${ICON}\n` : ''}` +
|
|
228
|
+
`${label ? `<p class="label">${escapeHtml(label)}</p>\n` : ''}` +
|
|
229
|
+
`<h1>${escapeHtml(heading)}</h1>` +
|
|
230
|
+
`<p class="detail">${escapeHtml(detail)}</p>`;
|
|
231
|
+
|
|
232
|
+
// Through the same shell as the consent screen, rather than a second copy of
|
|
233
|
+
// the document. They were already meant to be one page; only the error class
|
|
234
|
+
// on the card differs.
|
|
235
|
+
return shell(body, title, ok ? 200 : 400, ok ? '' : ' err');
|
|
256
236
|
}
|
|
@@ -92,3 +92,8 @@ export async function moveCredential(
|
|
|
92
92
|
await credentials.set(to, value);
|
|
93
93
|
await credentials.delete(from);
|
|
94
94
|
}
|
|
95
|
+
|
|
96
|
+
/** What a spec that named an account rather than a provider turned out to mean. */
|
|
97
|
+
export function familyNote(providerId: string, family: readonly string[]): string {
|
|
98
|
+
return `${providerId} is ${family.length} services on one account: ${family.join(', ')}`;
|
|
99
|
+
}
|