@lanes-sh/link 0.1.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/LICENSE +202 -0
- package/README.md +144 -0
- package/bin/lanes +42 -0
- package/instructions/agents/lanes-link-scout.md +73 -0
- package/instructions/skills/lanes-link/SKILL.md +187 -0
- package/package.json +95 -0
- package/src/audit/chain.ts +278 -0
- package/src/audit/conformance.ts +374 -0
- package/src/audit/fanout.ts +97 -0
- package/src/audit/index.ts +218 -0
- package/src/audit/stdout.ts +60 -0
- package/src/auth/index.ts +220 -0
- package/src/auth/oauth/metadata.ts +75 -0
- package/src/auth/oauth/server.ts +385 -0
- package/src/auth/oauth/store.ts +215 -0
- package/src/auth/oidc.ts +206 -0
- package/src/auth/remote.ts +72 -0
- package/src/cli/argv.ts +94 -0
- package/src/cli/callback-page.ts +256 -0
- package/src/cli/commands/connect/accounts.ts +94 -0
- package/src/cli/commands/connect/authorise.ts +298 -0
- package/src/cli/commands/connect/client.ts +284 -0
- package/src/cli/commands/connect/index.ts +398 -0
- package/src/cli/commands/connect/outcome.ts +119 -0
- package/src/cli/commands/connect/requirements.ts +103 -0
- package/src/cli/commands/connect/scopes-gate.ts +146 -0
- package/src/cli/commands/connect/settle.ts +136 -0
- package/src/cli/commands/connect/setup.ts +276 -0
- package/src/cli/commands/mcp/assets.ts +189 -0
- package/src/cli/commands/mcp/harnesses.ts +143 -0
- package/src/cli/commands/mcp/list.ts +70 -0
- package/src/cli/commands/mcp/register.ts +199 -0
- package/src/cli/commands/mcp/stdio.ts +57 -0
- package/src/cli/commands/mcp.ts +22 -0
- package/src/cli/commands/operate/attach.ts +121 -0
- package/src/cli/commands/operate/audit.ts +119 -0
- package/src/cli/commands/operate/inspect.ts +355 -0
- package/src/cli/commands/operate/outputs.ts +206 -0
- package/src/cli/commands/operate/policy.ts +80 -0
- package/src/cli/commands/operate/serve.ts +55 -0
- package/src/cli/commands/operate/status.ts +133 -0
- package/src/cli/commands/operate/token.ts +70 -0
- package/src/cli/commands/operate.ts +27 -0
- package/src/cli/commands/owner/memory.ts +110 -0
- package/src/cli/commands/owner/shared.ts +125 -0
- package/src/cli/commands/owner/skills.ts +92 -0
- package/src/cli/commands/owner/vault.ts +140 -0
- package/src/cli/commands/owner.ts +46 -0
- package/src/cli/commands/profile/removal.ts +278 -0
- package/src/cli/commands/profile/remove.ts +315 -0
- package/src/cli/commands/profile.ts +156 -0
- package/src/cli/commands/secrets.ts +176 -0
- package/src/cli/commands/setup.ts +150 -0
- package/src/cli/commands/target.ts +310 -0
- package/src/cli/config-edit.ts +397 -0
- package/src/cli/endpoint-url.ts +77 -0
- package/src/cli/identity.ts +109 -0
- package/src/cli/lanes.ts +78 -0
- package/src/cli/main.ts +333 -0
- package/src/cli/oauth-error.ts +13 -0
- package/src/cli/oauth-exchange.ts +146 -0
- package/src/cli/oauth.ts +354 -0
- package/src/cli/output.ts +184 -0
- package/src/cli/prompt.ts +180 -0
- package/src/cli/publish.ts +185 -0
- package/src/cli/runtime/discovery.ts +123 -0
- package/src/cli/runtime/open.ts +342 -0
- package/src/cli/runtime/registry.ts +185 -0
- package/src/cli/runtime/select.ts +124 -0
- package/src/cli/runtime.ts +34 -0
- package/src/cli/scopes.ts +63 -0
- package/src/cli/usage.ts +111 -0
- package/src/cli/version.ts +25 -0
- package/src/connectivity/auth/README.md +36 -0
- package/src/connectivity/auth/api-key/index.ts +43 -0
- package/src/connectivity/auth/authorize.ts +49 -0
- package/src/connectivity/auth/basic/index.ts +68 -0
- package/src/connectivity/auth/bearer/index.ts +13 -0
- package/src/connectivity/auth/credential.ts +19 -0
- package/src/connectivity/auth/header/index.ts +18 -0
- package/src/connectivity/auth/index.ts +35 -0
- package/src/connectivity/auth/none/index.ts +12 -0
- package/src/connectivity/auth/oauth-authcode/broker.ts +261 -0
- package/src/connectivity/auth/oauth-authcode/index.ts +64 -0
- package/src/connectivity/auth/oauth-authcode/provider.ts +279 -0
- package/src/connectivity/auth/oauth-authcode/refresh.ts +118 -0
- package/src/connectivity/auth/resolve.ts +61 -0
- package/src/connectivity/auth/strategy/index.ts +14 -0
- package/src/connectivity/capability.ts +164 -0
- package/src/connectivity/connector.ts +175 -0
- package/src/connectivity/context.ts +77 -0
- package/src/connectivity/index.ts +94 -0
- package/src/connectivity/mail/attachments.ts +368 -0
- package/src/connectivity/mail/compose.ts +73 -0
- package/src/connectivity/mail/index.ts +40 -0
- package/src/connectivity/mail/message.ts +82 -0
- package/src/connectivity/mail/nodemailer.d.ts +83 -0
- package/src/connectivity/mail/staging.ts +143 -0
- package/src/connectivity/mail/url.ts +301 -0
- package/src/connectivity/manifest/auth.ts +135 -0
- package/src/connectivity/manifest/bundles.ts +23 -0
- package/src/connectivity/manifest/connector.ts +168 -0
- package/src/connectivity/manifest/credential-ref.ts +73 -0
- package/src/connectivity/manifest/identity.ts +43 -0
- package/src/connectivity/manifest/index.ts +46 -0
- package/src/connectivity/manifest/primitives.ts +20 -0
- package/src/connectivity/manifest/provider.ts +208 -0
- package/src/connectivity/manifest/requirements.ts +146 -0
- package/src/connectivity/manifest/setup.ts +55 -0
- package/src/connectivity/provider.ts +163 -0
- package/src/connectivity/transports/README.md +33 -0
- package/src/connectivity/transports/composite/index.ts +68 -0
- package/src/connectivity/transports/dav/calendar.ts +217 -0
- package/src/connectivity/transports/dav/capabilities.ts +151 -0
- package/src/connectivity/transports/dav/client.ts +226 -0
- package/src/connectivity/transports/dav/contacts.ts +75 -0
- package/src/connectivity/transports/dav/ical.ts +412 -0
- package/src/connectivity/transports/dav/index.ts +143 -0
- package/src/connectivity/transports/dav/operations.ts +43 -0
- package/src/connectivity/transports/dav/request.ts +161 -0
- package/src/connectivity/transports/dav/xml.ts +123 -0
- package/src/connectivity/transports/factory.ts +181 -0
- package/src/connectivity/transports/fs/capabilities.ts +88 -0
- package/src/connectivity/transports/fs/commands.ts +258 -0
- package/src/connectivity/transports/fs/index.ts +121 -0
- package/src/connectivity/transports/fs/operations.ts +45 -0
- package/src/connectivity/transports/fs/paths.ts +120 -0
- package/src/connectivity/transports/fs/result.ts +12 -0
- package/src/connectivity/transports/http/index.ts +255 -0
- package/src/connectivity/transports/imap/attachment.ts +166 -0
- package/src/connectivity/transports/imap/capabilities.ts +158 -0
- package/src/connectivity/transports/imap/client.ts +398 -0
- package/src/connectivity/transports/imap/commands.ts +385 -0
- package/src/connectivity/transports/imap/index.ts +152 -0
- package/src/connectivity/transports/imap/operations.ts +64 -0
- package/src/connectivity/transports/imap/parse.ts +130 -0
- package/src/connectivity/transports/imap/parser.ts +272 -0
- package/src/connectivity/transports/imap/result.ts +15 -0
- package/src/connectivity/transports/imap/send.ts +92 -0
- package/src/connectivity/transports/imap/socket.ts +111 -0
- package/src/connectivity/transports/imap/utf7.ts +136 -0
- package/src/connectivity/transports/index.ts +20 -0
- package/src/connectivity/transports/local/index.ts +173 -0
- package/src/connectivity/transports/mcp/index.ts +215 -0
- package/src/deployments/README.md +63 -0
- package/src/deployments/adapters/audit-blob.ts +203 -0
- package/src/deployments/adapters/filesystem.ts +184 -0
- package/src/deployments/adapters/gcp-secret-manager.ts +492 -0
- package/src/deployments/adapters/gcs.ts +191 -0
- package/src/deployments/adapters/otlp.ts +128 -0
- package/src/deployments/adapters/s3.ts +195 -0
- package/src/deployments/azure/README.md +21 -0
- package/src/deployments/bootstrap.ts +177 -0
- package/src/deployments/deploy.ts +290 -0
- package/src/deployments/driver.ts +157 -0
- package/src/deployments/drivers.ts +35 -0
- package/src/deployments/gcp/Dockerfile +70 -0
- package/src/deployments/gcp/cloudbuild.yaml +31 -0
- package/src/deployments/gcp/driver.ts +175 -0
- package/src/deployments/gcp/gcloud.ts +178 -0
- package/src/deployments/gcp/provision.ts +290 -0
- package/src/deployments/gcp/survey.ts +319 -0
- package/src/deployments/local/README.md +12 -0
- package/src/deployments/prepare.ts +257 -0
- package/src/deployments/steps.ts +137 -0
- package/src/deployments/target.ts +295 -0
- package/src/deployments/upload.ts +207 -0
- package/src/dispatch/context.ts +195 -0
- package/src/dispatch/dispatch.ts +350 -0
- package/src/dispatch/index.ts +32 -0
- package/src/dispatch/staging.ts +102 -0
- package/src/policy/index.ts +179 -0
- package/src/policy/limits.ts +77 -0
- package/src/profile/authorization.ts +81 -0
- package/src/profile/files.ts +71 -0
- package/src/profile/index.ts +76 -0
- package/src/profile/layout.ts +123 -0
- package/src/profile/load.ts +199 -0
- package/src/profile/primitives.ts +45 -0
- package/src/profile/schema.ts +347 -0
- package/src/profile/secret-detection.ts +162 -0
- package/src/profile/targets.ts +152 -0
- package/src/profile/workspace.ts +262 -0
- package/src/providers/custom/index.ts +21 -0
- package/src/providers/custom/load.ts +115 -0
- package/src/providers/custom/template.ts +156 -0
- package/src/providers/example/provider.ts +207 -0
- package/src/providers/google/calendar/index.ts +66 -0
- package/src/providers/google/calendar/redact.ts +40 -0
- package/src/providers/google/contacts/index.ts +50 -0
- package/src/providers/google/contacts/redact.ts +21 -0
- package/src/providers/google/docs/index.ts +45 -0
- package/src/providers/google/drive/hints.ts +28 -0
- package/src/providers/google/drive/index.ts +34 -0
- package/src/providers/google/drive/redact.ts +39 -0
- package/src/providers/google/drive-mcp/index.ts +21 -0
- package/src/providers/google/gmail/api.ts +42 -0
- package/src/providers/google/gmail/attachment.ts +142 -0
- package/src/providers/google/gmail/hints.ts +55 -0
- package/src/providers/google/gmail/index.ts +112 -0
- package/src/providers/google/gmail/redact.ts +56 -0
- package/src/providers/google/gmail/send.ts +365 -0
- package/src/providers/google/gmail-mcp/index.ts +35 -0
- package/src/providers/google/index.ts +10 -0
- package/src/providers/google/shared/oauth.ts +122 -0
- package/src/providers/google/shared/scopes.ts +99 -0
- package/src/providers/google/shared/setup.ts +80 -0
- package/src/providers/google/sheets/hints.ts +45 -0
- package/src/providers/google/sheets/index.ts +70 -0
- package/src/providers/google/sheets/redact.ts +45 -0
- package/src/providers/google/specs/calendar.v3.json +1829 -0
- package/src/providers/google/specs/docs.v1.json +381 -0
- package/src/providers/google/specs/drive.v3.json +2208 -0
- package/src/providers/google/specs/gmail.v1.json +2578 -0
- package/src/providers/google/specs/people.v1.json +506 -0
- package/src/providers/google/specs/sheets.v4.json +1269 -0
- package/src/providers/google/specs/tasks.v1.json +840 -0
- package/src/providers/google/specs/vendor.ts +661 -0
- package/src/providers/google/tasks/index.ts +53 -0
- package/src/providers/google/tasks/redact.ts +34 -0
- package/src/providers/harness.ts +95 -0
- package/src/providers/icloud/calendar/index.ts +27 -0
- package/src/providers/icloud/contacts/index.ts +17 -0
- package/src/providers/icloud/drive/index.ts +47 -0
- package/src/providers/icloud/index.ts +8 -0
- package/src/providers/icloud/mail/index.ts +37 -0
- package/src/providers/icloud/shared/setup.ts +66 -0
- package/src/providers/index.ts +93 -0
- package/src/providers/linear/index.ts +11 -0
- package/src/providers/linear/scopes.ts +7 -0
- package/src/providers/memory/provider.ts +429 -0
- package/src/providers/notion/index.ts +19 -0
- package/src/providers/owner.ts +49 -0
- package/src/providers/scopes.ts +26 -0
- package/src/providers/setup/plan.ts +141 -0
- package/src/providers/setup/provider.ts +323 -0
- package/src/providers/shared/frontmatter.ts +119 -0
- package/src/providers/skills/provider.ts +283 -0
- package/src/providers/skills/store.ts +252 -0
- package/src/providers/vault/provider.ts +194 -0
- package/src/registry/index.ts +36 -0
- package/src/registry/policy-bridge.ts +32 -0
- package/src/registry/reconcile.ts +313 -0
- package/src/registry/registry.ts +240 -0
- package/src/secrets/document.ts +293 -0
- package/src/secrets/index.ts +154 -0
- package/src/secrets/system.ts +173 -0
- package/src/secrets/vault.ts +336 -0
- package/src/server/attachments.ts +197 -0
- package/src/server/container.ts +96 -0
- package/src/server/edge.ts +53 -0
- package/src/server/endpoint.ts +352 -0
- package/src/server/generations.ts +362 -0
- package/src/server/harness.ts +400 -0
- package/src/server/index.ts +331 -0
- package/src/server/logging.ts +41 -0
- package/src/server/mcp/build.ts +68 -0
- package/src/server/mcp/icon.ts +145 -0
- package/src/server/mcp/index.ts +32 -0
- package/src/server/mcp/instructions.ts +245 -0
- package/src/server/mcp/naming.ts +39 -0
- package/src/server/mcp/prompts.ts +78 -0
- package/src/server/mcp/resources.ts +106 -0
- package/src/server/mcp/routing.ts +117 -0
- package/src/server/mcp/schema.ts +78 -0
- package/src/server/mcp/tools.ts +186 -0
- package/src/server/mcp/visibility.ts +132 -0
- package/src/server/oauth.ts +222 -0
- package/src/server/rebinding.ts +53 -0
- package/src/server/stdio.ts +192 -0
- package/src/stores/blobs/conformance.ts +223 -0
- package/src/stores/blobs/index.ts +125 -0
- package/src/stores/blobs/testing.ts +49 -0
- package/src/stores/state/index.ts +247 -0
- package/src/stores/state/keys.ts +68 -0
- package/src/stores/state/testing.ts +41 -0
package/src/cli/oauth.ts
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { completionPage } from './callback-page.ts';
|
|
3
|
+
import { OAuthError } from './oauth-error.ts';
|
|
4
|
+
import { directExchange, type ExchangeCode, type OAuthTokens } from './oauth-exchange.ts';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The OAuth authorization-code exchange, run entirely from the CLI.
|
|
8
|
+
*
|
|
9
|
+
* **The server never participates in this** — ADR-005. The CLI opens a
|
|
10
|
+
* temporary listener on loopback, receives the code there, exchanges it, and
|
|
11
|
+
* writes the refresh token to whichever credential store the config names. That
|
|
12
|
+
* works identically whether the instance is local or on Cloud Run, and it means
|
|
13
|
+
* the deployment needs no public callback URL, no domain verification, and no
|
|
14
|
+
* inbound path at all.
|
|
15
|
+
*
|
|
16
|
+
* PKCE is used even though a Desktop-app client also sends a client secret.
|
|
17
|
+
* Google only recommends it, but a "secret" shipped to an installed app is not
|
|
18
|
+
* meaningfully secret, and PKCE is what actually stops an intercepted
|
|
19
|
+
* authorization code from being redeemed by someone else.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export interface OAuthFlowOptions {
|
|
23
|
+
readonly authorizeUrl: string;
|
|
24
|
+
/** Where a client this machine holds redeems the code. Unused with `exchange`. */
|
|
25
|
+
readonly tokenUrl?: string;
|
|
26
|
+
readonly clientId: string;
|
|
27
|
+
/** Unused with `exchange` — a brokered flow has no secret to hold. */
|
|
28
|
+
readonly clientSecret?: string;
|
|
29
|
+
readonly scopes: readonly string[];
|
|
30
|
+
/**
|
|
31
|
+
* How the code becomes a token. Defaults to posting to `tokenUrl` with the
|
|
32
|
+
* client above; a brokered provider supplies one that posts to its broker.
|
|
33
|
+
*
|
|
34
|
+
* Everything before this point is identical either way, which is the whole
|
|
35
|
+
* reason it is a parameter rather than a branch: the listener, PKCE, the
|
|
36
|
+
* state check, and the browser cannot drift apart between the two paths.
|
|
37
|
+
*/
|
|
38
|
+
readonly exchange?: ExchangeCode;
|
|
39
|
+
readonly authorizeParams?: Readonly<Record<string, string>>;
|
|
40
|
+
/** What the completion page names as connected. A provider's display name. */
|
|
41
|
+
readonly connectionLabel?: string;
|
|
42
|
+
/** How long to wait for the operator to finish in the browser. */
|
|
43
|
+
readonly timeoutMs?: number;
|
|
44
|
+
readonly openBrowser?: (url: string) => void;
|
|
45
|
+
readonly onPrompt?: (url: string) => void;
|
|
46
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Re-exported so the flow stays one import for its callers even though the
|
|
50
|
+
// exchange half now lives next door.
|
|
51
|
+
export { OAuthError };
|
|
52
|
+
export type { ExchangeCode, OAuthTokens };
|
|
53
|
+
|
|
54
|
+
const base64url = (input: Buffer): string => input.toString('base64url');
|
|
55
|
+
|
|
56
|
+
/** RFC 7636: 43–128 characters of unreserved charset. 32 random bytes gives 43. */
|
|
57
|
+
export function createPkcePair(): { verifier: string; challenge: string } {
|
|
58
|
+
const verifier = base64url(randomBytes(32));
|
|
59
|
+
const challenge = base64url(createHash('sha256').update(verifier).digest());
|
|
60
|
+
return { verifier, challenge };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Compare the returned state in constant time — it is a CSRF defence. */
|
|
64
|
+
function stateMatches(expected: string, received: string): boolean {
|
|
65
|
+
const a = createHash('sha256').update(expected).digest();
|
|
66
|
+
const b = createHash('sha256').update(received).digest();
|
|
67
|
+
return timingSafeEqual(a, b);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function defaultOpenBrowser(url: string): void {
|
|
71
|
+
const command =
|
|
72
|
+
process.platform === 'darwin'
|
|
73
|
+
? ['open', url]
|
|
74
|
+
: process.platform === 'win32'
|
|
75
|
+
? ['cmd', '/c', 'start', '', url]
|
|
76
|
+
: ['xdg-open', url];
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
// An argument array, never a shell string: the URL contains query
|
|
80
|
+
// parameters and must not be word-split or interpreted.
|
|
81
|
+
// unref: the parent must not wait on a browser that outlives the command.
|
|
82
|
+
Bun.spawn(command, { stdout: 'ignore', stderr: 'ignore' }).unref();
|
|
83
|
+
} catch {
|
|
84
|
+
// Headless or no handler — the caller prints the URL either way.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* What the browser is left looking at, in the two shapes it comes in.
|
|
90
|
+
*
|
|
91
|
+
* The label is what the caller knows and this file cannot: which provider the
|
|
92
|
+
* grant was for. Given one, the page names it under "Connected" the way the
|
|
93
|
+
* invite page names the workspace; without one it says only that the flow
|
|
94
|
+
* finished, which is what every path said before.
|
|
95
|
+
*/
|
|
96
|
+
const connectedPage = (label?: string): Response =>
|
|
97
|
+
completionPage({
|
|
98
|
+
...(label ? { label: 'Connected', heading: label } : { heading: 'Connected' }),
|
|
99
|
+
detail: 'You can close this tab and return to the terminal.',
|
|
100
|
+
ok: true,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const failedPage = (detail: string): Response =>
|
|
104
|
+
completionPage({ heading: 'Authorization failed', detail, ok: false });
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Run the flow and return the tokens.
|
|
108
|
+
*
|
|
109
|
+
* The listener binds to 127.0.0.1 on a port the OS picks, serves exactly one
|
|
110
|
+
* callback, and shuts down before this resolves — it exists for the duration of
|
|
111
|
+
* one consent and no longer.
|
|
112
|
+
*/
|
|
113
|
+
export async function runOAuthFlow(options: OAuthFlowOptions): Promise<OAuthTokens> {
|
|
114
|
+
const { verifier, challenge } = createPkcePair();
|
|
115
|
+
const state = base64url(randomBytes(24));
|
|
116
|
+
const timeoutMs = options.timeoutMs ?? 5 * 60_000;
|
|
117
|
+
|
|
118
|
+
let resolveCode: (code: string) => void;
|
|
119
|
+
let rejectCode: (error: Error) => void;
|
|
120
|
+
const codePromise = new Promise<string>((resolve, reject) => {
|
|
121
|
+
resolveCode = resolve;
|
|
122
|
+
rejectCode = reject;
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const server = Bun.serve({
|
|
126
|
+
hostname: '127.0.0.1',
|
|
127
|
+
port: 0, // let the OS choose; Google's Desktop client type accepts any loopback port
|
|
128
|
+
fetch(request) {
|
|
129
|
+
const url = new URL(request.url);
|
|
130
|
+
if (url.pathname !== '/callback') return new Response('Not found', { status: 404 });
|
|
131
|
+
|
|
132
|
+
const error = url.searchParams.get('error');
|
|
133
|
+
if (error) {
|
|
134
|
+
rejectCode(
|
|
135
|
+
new OAuthError(
|
|
136
|
+
error === 'access_denied'
|
|
137
|
+
? 'Authorization was declined in the browser.'
|
|
138
|
+
: `Authorization failed: ${error}`,
|
|
139
|
+
),
|
|
140
|
+
);
|
|
141
|
+
return failedPage('You can close this tab.');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const returnedState = url.searchParams.get('state');
|
|
145
|
+
if (!returnedState || !stateMatches(state, returnedState)) {
|
|
146
|
+
// A mismatched state means this callback did not come from the request
|
|
147
|
+
// we started. Refuse it rather than redeeming whatever code it carries.
|
|
148
|
+
rejectCode(new OAuthError('State mismatch — ignoring an unexpected callback.'));
|
|
149
|
+
return failedPage('Unexpected callback.');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const code = url.searchParams.get('code');
|
|
153
|
+
if (!code) {
|
|
154
|
+
rejectCode(new OAuthError('The callback carried no authorization code.'));
|
|
155
|
+
return failedPage('No code returned.');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
resolveCode(code);
|
|
159
|
+
return connectedPage(options.connectionLabel);
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const redirectUri = `http://127.0.0.1:${server.port}/callback`;
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
const authorizeUrl = new URL(options.authorizeUrl);
|
|
167
|
+
authorizeUrl.searchParams.set('client_id', options.clientId);
|
|
168
|
+
authorizeUrl.searchParams.set('redirect_uri', redirectUri);
|
|
169
|
+
authorizeUrl.searchParams.set('response_type', 'code');
|
|
170
|
+
authorizeUrl.searchParams.set('scope', options.scopes.join(' '));
|
|
171
|
+
authorizeUrl.searchParams.set('state', state);
|
|
172
|
+
authorizeUrl.searchParams.set('code_challenge', challenge);
|
|
173
|
+
authorizeUrl.searchParams.set('code_challenge_method', 'S256');
|
|
174
|
+
for (const [key, value] of Object.entries(options.authorizeParams ?? {})) {
|
|
175
|
+
authorizeUrl.searchParams.set(key, value);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
options.onPrompt?.(authorizeUrl.href);
|
|
179
|
+
(options.openBrowser ?? defaultOpenBrowser)(authorizeUrl.href);
|
|
180
|
+
|
|
181
|
+
const code = await withTimeout(
|
|
182
|
+
codePromise,
|
|
183
|
+
timeoutMs,
|
|
184
|
+
`Timed out after ${Math.round(timeoutMs / 1000)}s waiting for the browser.`,
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
const exchange = options.exchange ?? defaultExchange(options);
|
|
188
|
+
return await exchange({
|
|
189
|
+
code,
|
|
190
|
+
redirectUri,
|
|
191
|
+
codeVerifier: verifier,
|
|
192
|
+
scopes: options.scopes,
|
|
193
|
+
});
|
|
194
|
+
} finally {
|
|
195
|
+
await shutdown(server);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Close the listener without cutting off the response in flight.
|
|
201
|
+
*
|
|
202
|
+
* Forcing the socket shut the instant the code is read leaves the operator
|
|
203
|
+
* looking at a connection error on a flow that in fact succeeded. So: stop
|
|
204
|
+
* gracefully, wait for the in-flight callback to drain, and force only if
|
|
205
|
+
* graceful genuinely did not finish — a keep-alive connection must not be able
|
|
206
|
+
* to hold the CLI open forever either.
|
|
207
|
+
*/
|
|
208
|
+
async function shutdown(server: { stop(force?: boolean): Promise<void> }): Promise<void> {
|
|
209
|
+
let drained = false;
|
|
210
|
+
const graceful = server.stop().then(() => {
|
|
211
|
+
drained = true;
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
await withTimeout(graceful, 2_000).catch(() => {});
|
|
215
|
+
if (!drained) await server.stop(true);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Race a promise against a deadline, and **clear the timer either way**.
|
|
220
|
+
*
|
|
221
|
+
* A bare `Promise.race` with `setTimeout` leaks: when the real promise wins,
|
|
222
|
+
* the timer is still pending, and a pending timer keeps the event loop alive
|
|
223
|
+
* for its full duration. With a five-minute OAuth deadline that turns a
|
|
224
|
+
* finished `connect` into a terminal that prints "Next: lanes link start" and
|
|
225
|
+
* then sits there for five minutes — which is exactly what it did.
|
|
226
|
+
*
|
|
227
|
+
* Rejects with `OAuthError(message)` if given one, otherwise resolves to
|
|
228
|
+
* undefined at the deadline, which is what the shutdown path wants.
|
|
229
|
+
*/
|
|
230
|
+
async function withTimeout<T>(promise: Promise<T>, ms: number, message?: string): Promise<T> {
|
|
231
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
return await Promise.race([
|
|
235
|
+
promise,
|
|
236
|
+
new Promise<T>((resolve, reject) => {
|
|
237
|
+
timer = setTimeout(
|
|
238
|
+
() => (message ? reject(new OAuthError(message)) : resolve(undefined as T)),
|
|
239
|
+
ms,
|
|
240
|
+
);
|
|
241
|
+
}),
|
|
242
|
+
]);
|
|
243
|
+
} finally {
|
|
244
|
+
if (timer) clearTimeout(timer);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function defaultExchange(options: OAuthFlowOptions): ExchangeCode {
|
|
249
|
+
if (!options.tokenUrl || !options.clientSecret) {
|
|
250
|
+
throw new OAuthError(
|
|
251
|
+
'runOAuthFlow needs either a tokenUrl and clientSecret to redeem the code with, or an exchange to redeem it through.',
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
return directExchange({
|
|
255
|
+
tokenUrl: options.tokenUrl,
|
|
256
|
+
clientId: options.clientId,
|
|
257
|
+
clientSecret: options.clientSecret,
|
|
258
|
+
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Capture exactly one OAuth callback on loopback.
|
|
265
|
+
*
|
|
266
|
+
* Split out from `runOAuthFlow` so the SDK can drive the protocol — discovery,
|
|
267
|
+
* registration, PKCE, exchange — while we supply only the redirect target. The
|
|
268
|
+
* listener exists for the duration of one consent and no longer.
|
|
269
|
+
*/
|
|
270
|
+
export interface CallbackCapture {
|
|
271
|
+
readonly redirectUri: string;
|
|
272
|
+
/**
|
|
273
|
+
* The `state` this listener will accept, for the provider to put on the
|
|
274
|
+
* authorization URL. The SDK asks its provider for one and sends whatever it
|
|
275
|
+
* gets; nothing generates it for us.
|
|
276
|
+
*/
|
|
277
|
+
readonly state: string;
|
|
278
|
+
wait(timeoutMs?: number): Promise<{ code: string; iss?: string }>;
|
|
279
|
+
close(): Promise<void>;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function captureOAuthCallback(options: { label?: string } = {}): CallbackCapture {
|
|
283
|
+
const state = base64url(randomBytes(24));
|
|
284
|
+
let resolveCode: (value: { code: string; iss?: string }) => void;
|
|
285
|
+
let rejectCode: (error: Error) => void;
|
|
286
|
+
|
|
287
|
+
const received = new Promise<{ code: string; iss?: string }>((resolve, reject) => {
|
|
288
|
+
resolveCode = resolve;
|
|
289
|
+
rejectCode = reject;
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const server = Bun.serve({
|
|
293
|
+
hostname: '127.0.0.1',
|
|
294
|
+
port: 0,
|
|
295
|
+
fetch(request) {
|
|
296
|
+
const url = new URL(request.url);
|
|
297
|
+
if (url.pathname !== '/callback') return new Response('Not found', { status: 404 });
|
|
298
|
+
|
|
299
|
+
const error = url.searchParams.get('error');
|
|
300
|
+
if (error) {
|
|
301
|
+
rejectCode(
|
|
302
|
+
new OAuthError(
|
|
303
|
+
error === 'access_denied'
|
|
304
|
+
? 'Authorization was declined in the browser.'
|
|
305
|
+
: `Authorization failed: ${error}`,
|
|
306
|
+
),
|
|
307
|
+
);
|
|
308
|
+
return failedPage('You can close this tab.');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const code = url.searchParams.get('code');
|
|
312
|
+
if (!code) {
|
|
313
|
+
rejectCode(new OAuthError('The callback carried no authorization code.'));
|
|
314
|
+
return failedPage('No code returned.');
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Checked here because nothing else checks it. The SDK's own docs say it
|
|
318
|
+
// does not validate `state`, and it only sends one if the provider hands
|
|
319
|
+
// it over — so this listener mints the value, `CredentialOAuthProvider`
|
|
320
|
+
// returns it from `state()`, and the two are compared on the way back.
|
|
321
|
+
//
|
|
322
|
+
// Without it this resolves on the first `/callback?code=` to reach the
|
|
323
|
+
// port, whoever sent it: any local process, or a page the operator has
|
|
324
|
+
// open, can sweep loopback during the five-minute window. PKCE usually
|
|
325
|
+
// turns that into a failed exchange, but a manifest is free to name an
|
|
326
|
+
// authorization server that does not enforce it, and there the code
|
|
327
|
+
// would be redeemed and the connection bound to someone else's account.
|
|
328
|
+
const returnedState = url.searchParams.get('state');
|
|
329
|
+
if (!returnedState || !stateMatches(state, returnedState)) {
|
|
330
|
+
rejectCode(new OAuthError('State mismatch — ignoring an unexpected callback.'));
|
|
331
|
+
return failedPage('Unexpected callback.');
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const iss = url.searchParams.get('iss');
|
|
335
|
+
resolveCode({ code, ...(iss ? { iss } : {}) });
|
|
336
|
+
return connectedPage(options.label);
|
|
337
|
+
},
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
redirectUri: `http://127.0.0.1:${server.port}/callback`,
|
|
342
|
+
state,
|
|
343
|
+
|
|
344
|
+
async wait(timeoutMs = 5 * 60_000) {
|
|
345
|
+
return withTimeout(
|
|
346
|
+
received,
|
|
347
|
+
timeoutMs,
|
|
348
|
+
`Timed out after ${Math.round(timeoutMs / 1000)}s waiting for the browser.`,
|
|
349
|
+
);
|
|
350
|
+
},
|
|
351
|
+
|
|
352
|
+
close: () => shutdown(server),
|
|
353
|
+
};
|
|
354
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import type { Resolution } from '#profile';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Terminal output.
|
|
5
|
+
*
|
|
6
|
+
* Everything a command produces goes to stdout; diagnostics go to stderr. That
|
|
7
|
+
* split matters because `lanes link token show` and `lanes link outputs` print values people
|
|
8
|
+
* pipe into other tools, and a log line interleaved into a token corrupts it.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const isTTY = process.stdout.isTTY === true && !process.env['NO_COLOR'];
|
|
12
|
+
|
|
13
|
+
const paint = (code: string) => (text: string) => (isTTY ? `[${code}m${text}[0m` : text);
|
|
14
|
+
|
|
15
|
+
export const style = {
|
|
16
|
+
bold: paint('1'),
|
|
17
|
+
dim: paint('2'),
|
|
18
|
+
green: paint('32'),
|
|
19
|
+
yellow: paint('33'),
|
|
20
|
+
red: paint('31'),
|
|
21
|
+
cyan: paint('36'),
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function print(line = ''): void {
|
|
25
|
+
process.stdout.write(`${line}\n`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function printErr(line: string): void {
|
|
29
|
+
process.stderr.write(`${line}\n`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A "what is happening now" line — setup instructions, a wait, a step done.
|
|
34
|
+
*
|
|
35
|
+
* Goes to stderr because it is not what the command produces: a person reading
|
|
36
|
+
* a terminal sees it exactly as before, and `--json` keeps a parseable stdout
|
|
37
|
+
* without every intermediate note having to know whether anyone is parsing.
|
|
38
|
+
* The split at the top of this file already said this; `connect` was the one
|
|
39
|
+
* command narrating on the channel it also returns its result on.
|
|
40
|
+
*/
|
|
41
|
+
export function progress(line = ''): void {
|
|
42
|
+
process.stderr.write(`${line}\n`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Say what is being waited on, and keep saying it until it arrives.
|
|
47
|
+
*
|
|
48
|
+
* Shelling out to `gcloud` costs seconds, and a prompt that is *about* to be
|
|
49
|
+
* printed looks exactly like a prompt that is waiting for you. An operator
|
|
50
|
+
* pressed return into that silence, the keystroke was buffered by the terminal,
|
|
51
|
+
* and the next question — the one they never saw — consumed it as an answer.
|
|
52
|
+
* A survey that writes its answers to a config file cannot afford that.
|
|
53
|
+
*
|
|
54
|
+
* It animates, which is the part that does the work. A static line saying what
|
|
55
|
+
* is happening is invisible when the wait is short and indistinguishable from a
|
|
56
|
+
* frozen terminal when it is long — the two cases it exists for. Motion is the
|
|
57
|
+
* only thing on a terminal that says "still going" rather than "still here",
|
|
58
|
+
* and after three seconds it starts counting, because by then the useful
|
|
59
|
+
* question is how long rather than whether.
|
|
60
|
+
*
|
|
61
|
+
* Erased on a TTY so the finished output reads as if the wait never happened,
|
|
62
|
+
* and left in place otherwise: in a log or a pipe, "checking…" with no matching
|
|
63
|
+
* line after it is the only record that the step was reached at all.
|
|
64
|
+
*/
|
|
65
|
+
const SPINNER = ['\u280b', '\u2819', '\u2839', '\u2838', '\u283c', '\u2834', '\u2826', '\u2827', '\u2807', '\u280f'];
|
|
66
|
+
|
|
67
|
+
/** Carriage return, then erase to end of line. */
|
|
68
|
+
const CLEAR = '\r\u001b[2K';
|
|
69
|
+
|
|
70
|
+
export async function waiting<T>(label: string, work: () => Promise<T>): Promise<T> {
|
|
71
|
+
if (!isTTY) {
|
|
72
|
+
progress(style.dim(` ${label}\u2026`));
|
|
73
|
+
return work();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const started = Date.now();
|
|
77
|
+
let frame = 0;
|
|
78
|
+
|
|
79
|
+
const draw = (): void => {
|
|
80
|
+
const seconds = Math.floor((Date.now() - started) / 1000);
|
|
81
|
+
// Only once it has been long enough to be worth wondering about. A counter
|
|
82
|
+
// that appears immediately reads as a countdown to something.
|
|
83
|
+
const elapsed = seconds >= 3 ? ` (${seconds}s)` : '';
|
|
84
|
+
const mark = SPINNER[frame % SPINNER.length];
|
|
85
|
+
|
|
86
|
+
process.stderr.write(`${CLEAR}${style.dim(` ${mark} ${label}\u2026${elapsed}`)}`);
|
|
87
|
+
frame += 1;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
draw();
|
|
91
|
+
const ticker = setInterval(draw, 90);
|
|
92
|
+
// A wait must never be why a process stays alive. If the work settles and
|
|
93
|
+
// something later throws past the `finally`, a referenced interval leaves the
|
|
94
|
+
// CLI hanging with nothing on screen to explain it.
|
|
95
|
+
ticker.unref?.();
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
return await work();
|
|
99
|
+
} finally {
|
|
100
|
+
clearInterval(ticker);
|
|
101
|
+
process.stderr.write(CLEAR);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The line every command prints before acting, read-only commands included.
|
|
107
|
+
*
|
|
108
|
+
* This is the primary guard against operating on the wrong instance, and it
|
|
109
|
+
* costs one line. It names where each value came from, because "profile: work"
|
|
110
|
+
* is much less useful than knowing it came from an environment variable you
|
|
111
|
+
* forgot you exported.
|
|
112
|
+
*/
|
|
113
|
+
export function announce(resolution: Resolution): void {
|
|
114
|
+
print(
|
|
115
|
+
style.dim(
|
|
116
|
+
`profile ${style.bold(resolution.profile)} (${resolution.profileSource}) ` +
|
|
117
|
+
`target ${style.bold(resolution.target)} (${resolution.targetSource}) ` +
|
|
118
|
+
`${resolution.workspaceRoot}`,
|
|
119
|
+
),
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Print a machine-readable result, or fall through to the human rendering.
|
|
125
|
+
*
|
|
126
|
+
* The early return is the whole point. `announce` is the first thing every
|
|
127
|
+
* command prints — including read-only ones, deliberately — and a line of prose
|
|
128
|
+
* in front of a JSON document corrupts it for whatever is parsing. `outputs`
|
|
129
|
+
* got that right by hand; putting the guard here means the next `--json`
|
|
130
|
+
* command cannot get it wrong, and there is one place to look when one does.
|
|
131
|
+
*
|
|
132
|
+
* The payload is built before this is called rather than inside `render`, so
|
|
133
|
+
* both renderings describe the same snapshot.
|
|
134
|
+
*/
|
|
135
|
+
export function emit(
|
|
136
|
+
json: boolean | undefined,
|
|
137
|
+
value: unknown,
|
|
138
|
+
render: () => void | Promise<void>,
|
|
139
|
+
): void | Promise<void> {
|
|
140
|
+
if (json === true) {
|
|
141
|
+
print(JSON.stringify(value, null, 2));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return render();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function heading(text: string): void {
|
|
149
|
+
print();
|
|
150
|
+
print(style.bold(text));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function table(rows: ReadonlyArray<readonly string[]>): void {
|
|
154
|
+
if (rows.length === 0) return;
|
|
155
|
+
|
|
156
|
+
const widths: number[] = [];
|
|
157
|
+
for (const row of rows) {
|
|
158
|
+
row.forEach((cell, index) => {
|
|
159
|
+
widths[index] = Math.max(widths[index] ?? 0, stripAnsi(cell).length);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
for (const row of rows) {
|
|
164
|
+
print(
|
|
165
|
+
row
|
|
166
|
+
.map((cell, index) =>
|
|
167
|
+
index === row.length - 1
|
|
168
|
+
? cell
|
|
169
|
+
: cell + ' '.repeat((widths[index] ?? 0) - stripAnsi(cell).length),
|
|
170
|
+
)
|
|
171
|
+
.join(' ')
|
|
172
|
+
.trimEnd(),
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function stripAnsi(text: string): string {
|
|
178
|
+
// eslint-disable-next-line no-control-regex
|
|
179
|
+
return text.replace(/\[[0-9;]*m/g, '');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export const ok = (text: string) => `${style.green('ok')} ${text}`;
|
|
183
|
+
export const warn = (text: string) => `${style.yellow('warn')} ${text}`;
|
|
184
|
+
export const fail = (text: string) => `${style.red('fail')} ${text}`;
|