@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.
Files changed (276) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +144 -0
  3. package/bin/lanes +42 -0
  4. package/instructions/agents/lanes-link-scout.md +73 -0
  5. package/instructions/skills/lanes-link/SKILL.md +187 -0
  6. package/package.json +95 -0
  7. package/src/audit/chain.ts +278 -0
  8. package/src/audit/conformance.ts +374 -0
  9. package/src/audit/fanout.ts +97 -0
  10. package/src/audit/index.ts +218 -0
  11. package/src/audit/stdout.ts +60 -0
  12. package/src/auth/index.ts +220 -0
  13. package/src/auth/oauth/metadata.ts +75 -0
  14. package/src/auth/oauth/server.ts +385 -0
  15. package/src/auth/oauth/store.ts +215 -0
  16. package/src/auth/oidc.ts +206 -0
  17. package/src/auth/remote.ts +72 -0
  18. package/src/cli/argv.ts +94 -0
  19. package/src/cli/callback-page.ts +256 -0
  20. package/src/cli/commands/connect/accounts.ts +94 -0
  21. package/src/cli/commands/connect/authorise.ts +298 -0
  22. package/src/cli/commands/connect/client.ts +284 -0
  23. package/src/cli/commands/connect/index.ts +398 -0
  24. package/src/cli/commands/connect/outcome.ts +119 -0
  25. package/src/cli/commands/connect/requirements.ts +103 -0
  26. package/src/cli/commands/connect/scopes-gate.ts +146 -0
  27. package/src/cli/commands/connect/settle.ts +136 -0
  28. package/src/cli/commands/connect/setup.ts +276 -0
  29. package/src/cli/commands/mcp/assets.ts +189 -0
  30. package/src/cli/commands/mcp/harnesses.ts +143 -0
  31. package/src/cli/commands/mcp/list.ts +70 -0
  32. package/src/cli/commands/mcp/register.ts +199 -0
  33. package/src/cli/commands/mcp/stdio.ts +57 -0
  34. package/src/cli/commands/mcp.ts +22 -0
  35. package/src/cli/commands/operate/attach.ts +121 -0
  36. package/src/cli/commands/operate/audit.ts +119 -0
  37. package/src/cli/commands/operate/inspect.ts +355 -0
  38. package/src/cli/commands/operate/outputs.ts +206 -0
  39. package/src/cli/commands/operate/policy.ts +80 -0
  40. package/src/cli/commands/operate/serve.ts +55 -0
  41. package/src/cli/commands/operate/status.ts +133 -0
  42. package/src/cli/commands/operate/token.ts +70 -0
  43. package/src/cli/commands/operate.ts +27 -0
  44. package/src/cli/commands/owner/memory.ts +110 -0
  45. package/src/cli/commands/owner/shared.ts +125 -0
  46. package/src/cli/commands/owner/skills.ts +92 -0
  47. package/src/cli/commands/owner/vault.ts +140 -0
  48. package/src/cli/commands/owner.ts +46 -0
  49. package/src/cli/commands/profile/removal.ts +278 -0
  50. package/src/cli/commands/profile/remove.ts +315 -0
  51. package/src/cli/commands/profile.ts +156 -0
  52. package/src/cli/commands/secrets.ts +176 -0
  53. package/src/cli/commands/setup.ts +150 -0
  54. package/src/cli/commands/target.ts +310 -0
  55. package/src/cli/config-edit.ts +397 -0
  56. package/src/cli/endpoint-url.ts +77 -0
  57. package/src/cli/identity.ts +109 -0
  58. package/src/cli/lanes.ts +78 -0
  59. package/src/cli/main.ts +333 -0
  60. package/src/cli/oauth-error.ts +13 -0
  61. package/src/cli/oauth-exchange.ts +146 -0
  62. package/src/cli/oauth.ts +354 -0
  63. package/src/cli/output.ts +184 -0
  64. package/src/cli/prompt.ts +180 -0
  65. package/src/cli/publish.ts +185 -0
  66. package/src/cli/runtime/discovery.ts +123 -0
  67. package/src/cli/runtime/open.ts +342 -0
  68. package/src/cli/runtime/registry.ts +185 -0
  69. package/src/cli/runtime/select.ts +124 -0
  70. package/src/cli/runtime.ts +34 -0
  71. package/src/cli/scopes.ts +63 -0
  72. package/src/cli/usage.ts +111 -0
  73. package/src/cli/version.ts +25 -0
  74. package/src/connectivity/auth/README.md +36 -0
  75. package/src/connectivity/auth/api-key/index.ts +43 -0
  76. package/src/connectivity/auth/authorize.ts +49 -0
  77. package/src/connectivity/auth/basic/index.ts +68 -0
  78. package/src/connectivity/auth/bearer/index.ts +13 -0
  79. package/src/connectivity/auth/credential.ts +19 -0
  80. package/src/connectivity/auth/header/index.ts +18 -0
  81. package/src/connectivity/auth/index.ts +35 -0
  82. package/src/connectivity/auth/none/index.ts +12 -0
  83. package/src/connectivity/auth/oauth-authcode/broker.ts +261 -0
  84. package/src/connectivity/auth/oauth-authcode/index.ts +64 -0
  85. package/src/connectivity/auth/oauth-authcode/provider.ts +279 -0
  86. package/src/connectivity/auth/oauth-authcode/refresh.ts +118 -0
  87. package/src/connectivity/auth/resolve.ts +61 -0
  88. package/src/connectivity/auth/strategy/index.ts +14 -0
  89. package/src/connectivity/capability.ts +164 -0
  90. package/src/connectivity/connector.ts +175 -0
  91. package/src/connectivity/context.ts +77 -0
  92. package/src/connectivity/index.ts +94 -0
  93. package/src/connectivity/mail/attachments.ts +368 -0
  94. package/src/connectivity/mail/compose.ts +73 -0
  95. package/src/connectivity/mail/index.ts +40 -0
  96. package/src/connectivity/mail/message.ts +82 -0
  97. package/src/connectivity/mail/nodemailer.d.ts +83 -0
  98. package/src/connectivity/mail/staging.ts +143 -0
  99. package/src/connectivity/mail/url.ts +301 -0
  100. package/src/connectivity/manifest/auth.ts +135 -0
  101. package/src/connectivity/manifest/bundles.ts +23 -0
  102. package/src/connectivity/manifest/connector.ts +168 -0
  103. package/src/connectivity/manifest/credential-ref.ts +73 -0
  104. package/src/connectivity/manifest/identity.ts +43 -0
  105. package/src/connectivity/manifest/index.ts +46 -0
  106. package/src/connectivity/manifest/primitives.ts +20 -0
  107. package/src/connectivity/manifest/provider.ts +208 -0
  108. package/src/connectivity/manifest/requirements.ts +146 -0
  109. package/src/connectivity/manifest/setup.ts +55 -0
  110. package/src/connectivity/provider.ts +163 -0
  111. package/src/connectivity/transports/README.md +33 -0
  112. package/src/connectivity/transports/composite/index.ts +68 -0
  113. package/src/connectivity/transports/dav/calendar.ts +217 -0
  114. package/src/connectivity/transports/dav/capabilities.ts +151 -0
  115. package/src/connectivity/transports/dav/client.ts +226 -0
  116. package/src/connectivity/transports/dav/contacts.ts +75 -0
  117. package/src/connectivity/transports/dav/ical.ts +412 -0
  118. package/src/connectivity/transports/dav/index.ts +143 -0
  119. package/src/connectivity/transports/dav/operations.ts +43 -0
  120. package/src/connectivity/transports/dav/request.ts +161 -0
  121. package/src/connectivity/transports/dav/xml.ts +123 -0
  122. package/src/connectivity/transports/factory.ts +181 -0
  123. package/src/connectivity/transports/fs/capabilities.ts +88 -0
  124. package/src/connectivity/transports/fs/commands.ts +258 -0
  125. package/src/connectivity/transports/fs/index.ts +121 -0
  126. package/src/connectivity/transports/fs/operations.ts +45 -0
  127. package/src/connectivity/transports/fs/paths.ts +120 -0
  128. package/src/connectivity/transports/fs/result.ts +12 -0
  129. package/src/connectivity/transports/http/index.ts +255 -0
  130. package/src/connectivity/transports/imap/attachment.ts +166 -0
  131. package/src/connectivity/transports/imap/capabilities.ts +158 -0
  132. package/src/connectivity/transports/imap/client.ts +398 -0
  133. package/src/connectivity/transports/imap/commands.ts +385 -0
  134. package/src/connectivity/transports/imap/index.ts +152 -0
  135. package/src/connectivity/transports/imap/operations.ts +64 -0
  136. package/src/connectivity/transports/imap/parse.ts +130 -0
  137. package/src/connectivity/transports/imap/parser.ts +272 -0
  138. package/src/connectivity/transports/imap/result.ts +15 -0
  139. package/src/connectivity/transports/imap/send.ts +92 -0
  140. package/src/connectivity/transports/imap/socket.ts +111 -0
  141. package/src/connectivity/transports/imap/utf7.ts +136 -0
  142. package/src/connectivity/transports/index.ts +20 -0
  143. package/src/connectivity/transports/local/index.ts +173 -0
  144. package/src/connectivity/transports/mcp/index.ts +215 -0
  145. package/src/deployments/README.md +63 -0
  146. package/src/deployments/adapters/audit-blob.ts +203 -0
  147. package/src/deployments/adapters/filesystem.ts +184 -0
  148. package/src/deployments/adapters/gcp-secret-manager.ts +492 -0
  149. package/src/deployments/adapters/gcs.ts +191 -0
  150. package/src/deployments/adapters/otlp.ts +128 -0
  151. package/src/deployments/adapters/s3.ts +195 -0
  152. package/src/deployments/azure/README.md +21 -0
  153. package/src/deployments/bootstrap.ts +177 -0
  154. package/src/deployments/deploy.ts +290 -0
  155. package/src/deployments/driver.ts +157 -0
  156. package/src/deployments/drivers.ts +35 -0
  157. package/src/deployments/gcp/Dockerfile +70 -0
  158. package/src/deployments/gcp/cloudbuild.yaml +31 -0
  159. package/src/deployments/gcp/driver.ts +175 -0
  160. package/src/deployments/gcp/gcloud.ts +178 -0
  161. package/src/deployments/gcp/provision.ts +290 -0
  162. package/src/deployments/gcp/survey.ts +319 -0
  163. package/src/deployments/local/README.md +12 -0
  164. package/src/deployments/prepare.ts +257 -0
  165. package/src/deployments/steps.ts +137 -0
  166. package/src/deployments/target.ts +295 -0
  167. package/src/deployments/upload.ts +207 -0
  168. package/src/dispatch/context.ts +195 -0
  169. package/src/dispatch/dispatch.ts +350 -0
  170. package/src/dispatch/index.ts +32 -0
  171. package/src/dispatch/staging.ts +102 -0
  172. package/src/policy/index.ts +179 -0
  173. package/src/policy/limits.ts +77 -0
  174. package/src/profile/authorization.ts +81 -0
  175. package/src/profile/files.ts +71 -0
  176. package/src/profile/index.ts +76 -0
  177. package/src/profile/layout.ts +123 -0
  178. package/src/profile/load.ts +199 -0
  179. package/src/profile/primitives.ts +45 -0
  180. package/src/profile/schema.ts +347 -0
  181. package/src/profile/secret-detection.ts +162 -0
  182. package/src/profile/targets.ts +152 -0
  183. package/src/profile/workspace.ts +262 -0
  184. package/src/providers/custom/index.ts +21 -0
  185. package/src/providers/custom/load.ts +115 -0
  186. package/src/providers/custom/template.ts +156 -0
  187. package/src/providers/example/provider.ts +207 -0
  188. package/src/providers/google/calendar/index.ts +66 -0
  189. package/src/providers/google/calendar/redact.ts +40 -0
  190. package/src/providers/google/contacts/index.ts +50 -0
  191. package/src/providers/google/contacts/redact.ts +21 -0
  192. package/src/providers/google/docs/index.ts +45 -0
  193. package/src/providers/google/drive/hints.ts +28 -0
  194. package/src/providers/google/drive/index.ts +34 -0
  195. package/src/providers/google/drive/redact.ts +39 -0
  196. package/src/providers/google/drive-mcp/index.ts +21 -0
  197. package/src/providers/google/gmail/api.ts +42 -0
  198. package/src/providers/google/gmail/attachment.ts +142 -0
  199. package/src/providers/google/gmail/hints.ts +55 -0
  200. package/src/providers/google/gmail/index.ts +112 -0
  201. package/src/providers/google/gmail/redact.ts +56 -0
  202. package/src/providers/google/gmail/send.ts +365 -0
  203. package/src/providers/google/gmail-mcp/index.ts +35 -0
  204. package/src/providers/google/index.ts +10 -0
  205. package/src/providers/google/shared/oauth.ts +122 -0
  206. package/src/providers/google/shared/scopes.ts +99 -0
  207. package/src/providers/google/shared/setup.ts +80 -0
  208. package/src/providers/google/sheets/hints.ts +45 -0
  209. package/src/providers/google/sheets/index.ts +70 -0
  210. package/src/providers/google/sheets/redact.ts +45 -0
  211. package/src/providers/google/specs/calendar.v3.json +1829 -0
  212. package/src/providers/google/specs/docs.v1.json +381 -0
  213. package/src/providers/google/specs/drive.v3.json +2208 -0
  214. package/src/providers/google/specs/gmail.v1.json +2578 -0
  215. package/src/providers/google/specs/people.v1.json +506 -0
  216. package/src/providers/google/specs/sheets.v4.json +1269 -0
  217. package/src/providers/google/specs/tasks.v1.json +840 -0
  218. package/src/providers/google/specs/vendor.ts +661 -0
  219. package/src/providers/google/tasks/index.ts +53 -0
  220. package/src/providers/google/tasks/redact.ts +34 -0
  221. package/src/providers/harness.ts +95 -0
  222. package/src/providers/icloud/calendar/index.ts +27 -0
  223. package/src/providers/icloud/contacts/index.ts +17 -0
  224. package/src/providers/icloud/drive/index.ts +47 -0
  225. package/src/providers/icloud/index.ts +8 -0
  226. package/src/providers/icloud/mail/index.ts +37 -0
  227. package/src/providers/icloud/shared/setup.ts +66 -0
  228. package/src/providers/index.ts +93 -0
  229. package/src/providers/linear/index.ts +11 -0
  230. package/src/providers/linear/scopes.ts +7 -0
  231. package/src/providers/memory/provider.ts +429 -0
  232. package/src/providers/notion/index.ts +19 -0
  233. package/src/providers/owner.ts +49 -0
  234. package/src/providers/scopes.ts +26 -0
  235. package/src/providers/setup/plan.ts +141 -0
  236. package/src/providers/setup/provider.ts +323 -0
  237. package/src/providers/shared/frontmatter.ts +119 -0
  238. package/src/providers/skills/provider.ts +283 -0
  239. package/src/providers/skills/store.ts +252 -0
  240. package/src/providers/vault/provider.ts +194 -0
  241. package/src/registry/index.ts +36 -0
  242. package/src/registry/policy-bridge.ts +32 -0
  243. package/src/registry/reconcile.ts +313 -0
  244. package/src/registry/registry.ts +240 -0
  245. package/src/secrets/document.ts +293 -0
  246. package/src/secrets/index.ts +154 -0
  247. package/src/secrets/system.ts +173 -0
  248. package/src/secrets/vault.ts +336 -0
  249. package/src/server/attachments.ts +197 -0
  250. package/src/server/container.ts +96 -0
  251. package/src/server/edge.ts +53 -0
  252. package/src/server/endpoint.ts +352 -0
  253. package/src/server/generations.ts +362 -0
  254. package/src/server/harness.ts +400 -0
  255. package/src/server/index.ts +331 -0
  256. package/src/server/logging.ts +41 -0
  257. package/src/server/mcp/build.ts +68 -0
  258. package/src/server/mcp/icon.ts +145 -0
  259. package/src/server/mcp/index.ts +32 -0
  260. package/src/server/mcp/instructions.ts +245 -0
  261. package/src/server/mcp/naming.ts +39 -0
  262. package/src/server/mcp/prompts.ts +78 -0
  263. package/src/server/mcp/resources.ts +106 -0
  264. package/src/server/mcp/routing.ts +117 -0
  265. package/src/server/mcp/schema.ts +78 -0
  266. package/src/server/mcp/tools.ts +186 -0
  267. package/src/server/mcp/visibility.ts +132 -0
  268. package/src/server/oauth.ts +222 -0
  269. package/src/server/rebinding.ts +53 -0
  270. package/src/server/stdio.ts +192 -0
  271. package/src/stores/blobs/conformance.ts +223 -0
  272. package/src/stores/blobs/index.ts +125 -0
  273. package/src/stores/blobs/testing.ts +49 -0
  274. package/src/stores/state/index.ts +247 -0
  275. package/src/stores/state/keys.ts +68 -0
  276. package/src/stores/state/testing.ts +41 -0
@@ -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}` : 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}`;