@lanes-sh/link 0.1.2 → 0.2.1

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 CHANGED
@@ -1,5 +1,9 @@
1
1
  # Lanes Link
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/%40lanes-sh%2Flink?style=flat-square&color=black&label=npm)](https://www.npmjs.com/package/@lanes-sh/link)
4
+ [![license Apache-2.0](https://img.shields.io/github/license/lanes-sh/link?style=flat-square&color=black)](LICENSE)
5
+ [![ci](https://img.shields.io/github/actions/workflow/status/lanes-sh/link/ci.yml?branch=main&style=flat-square&label=ci)](https://github.com/lanes-sh/link/actions/workflows/ci.yml)
6
+
3
7
  **One secure endpoint between your AI agents and all your connections, memory, skills, and secrets.**
4
8
 
5
9
  Connect your mail, calendar, files, and notes once, and add the memory and skills that only you
@@ -93,13 +97,17 @@ One command per account. Run it again to add a second mailbox, a second calendar
93
97
  | iCloud Drive | `lanes link connect icloud_drive` |
94
98
  | Notion | `lanes link connect notion` |
95
99
  | Linear | `lanes link connect linear` |
100
+ | GitHub | `lanes link connect github` |
101
+ | Slack | `lanes link connect slack` |
96
102
  | Gmail (Google MCP) | `lanes link connect gmail_mcp` |
97
103
  | Drive (Google MCP) | `lanes link connect drive_mcp` |
98
104
 
99
- Two things worth knowing up front: `lanes link connect icloud` sets up Mail, Calendar, and Contacts
100
- together, because one app-specific password covers all three. And Google needs no OAuth client of
101
- your own: `lanes link connect gmail` authorises against the one Lanes operates, so there is no
102
- Cloud console to visit. Add `--own-client` if you would rather register your own.
105
+ Three things worth knowing up front. `lanes link connect icloud` sets up Mail, Calendar, and
106
+ Contacts together, because one app-specific password covers all three. Google needs no OAuth client
107
+ of your own: `lanes link connect gmail` authorises against the one Lanes operates, so there is no
108
+ Cloud console to visit add `--own-client` if you would rather register your own. And GitHub and
109
+ Slack take a token you paste rather than a browser sign-in, because neither will register a client
110
+ for us; for Slack that means creating a Slack app once, which is the one console visit left here.
103
111
 
104
112
  Full guide — what each one gives your agent, what it needs, and adding your own:
105
113
  **[docs/connect.md](docs/connect.md)**.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lanes-sh/link",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
4
  "description": "A self-hostable MCP gateway for all your connections, memory, skills, and secrets",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://lanes.sh/link",
@@ -21,7 +21,14 @@ import { ensureOAuthApp } from './setup.ts';
21
21
  * nothing and the manifest has to name its endpoints.
22
22
  */
23
23
 
24
- export function oauthProviderFor(
24
+ /**
25
+ * No longer exported. `#cli` used to reach for this to answer "what token does
26
+ * this manifest authenticate with", which is a question the auth component
27
+ * owns — and answering it here is what let a `bearer` manifest slip through
28
+ * returning null. `bearerTokenAsStored` is the answer now; this builds the
29
+ * provider for the browser flow below, which is the one thing it is for.
30
+ */
31
+ function oauthProviderFor(
25
32
  manifest: ProviderManifest,
26
33
  connectionId: string,
27
34
  credentials: SecretStore,
@@ -1,4 +1,5 @@
1
1
  import { createMcpConnector } from '#connectivity/transports';
2
+ import { bearerTokenAsStored } from '#connectivity/auth/index.ts';
2
3
  import type { DiscoveredCapability } from '#connectivity';
3
4
  import { credentialRefForConnection, WRITE_BUNDLE } from '#connectivity';
4
5
  import { ConfigDocument, ensureSetupConnection, repaired } from '../../config-edit.ts';
@@ -6,7 +7,7 @@ import { emit, print, progress, style } from '../../output.ts';
6
7
  import { nonInteractivePrompter, terminalPrompter, type Prompter } from '../../prompt.ts';
7
8
  import { openRuntime, type GlobalFlags } from '../../runtime.ts';
8
9
  import { credentialApp, matchesRule, moveCredential, siblingAccountId } from './accounts.ts';
9
- import { authorise, oauthProviderFor } from './authorise.ts';
10
+ import { authorise } from './authorise.ts';
10
11
  import { preflight } from './requirements.ts';
11
12
  import { ALREADY, NOTHING, renderOutcome, type ConnectOutcome } from './outcome.ts';
12
13
  import { nextAfterEdit, publishRuntimeEdit } from '#cli/publish.ts';
@@ -266,17 +267,15 @@ async function runConnect(target: string, options: ConnectOptions): Promise<Conn
266
267
 
267
268
  // MCP is the one kind that does not use the runtime's connector here: it
268
269
  // wants the token exactly as just written, without the refresh machinery
269
- // that `resolveUpstreamToken` wraps around it. Every other kind carries
270
- // whatever credential it needs from the factory.
270
+ // that `bearerToken` wraps around it. Every other kind carries whatever
271
+ // credential it needs from the factory.
271
272
  const connector =
272
273
  manifest.connector.kind === 'mcp'
273
274
  ? createMcpConnector({
274
275
  endpoint: manifest.connector.endpoint,
275
- accessToken: async () => {
276
- const provider = oauthProviderFor(manifest, connectionId, runtime.credentials);
277
- const tokens = (await provider.tokens()) as { access_token?: string } | undefined;
278
- return tokens?.access_token ?? null;
279
- },
276
+ ...(manifest.connector.headers ? { headers: manifest.connector.headers } : {}),
277
+ accessToken: () =>
278
+ bearerTokenAsStored(manifest, connectionId, runtime.credentials),
280
279
  })
281
280
  : runtime.connectorFor(providerId, connectionId);
282
281
 
@@ -1,4 +1,5 @@
1
1
  import { createMcpConnector } from '#connectivity/transports';
2
+ import { bearerTokenAsStored } from '#connectivity/auth/index.ts';
2
3
  import type { SecretStore } from '#secrets';
3
4
  import type { Config } from '#profile';
4
5
  import type { AnyConnector, ProviderManifest } from '#connectivity';
@@ -6,7 +7,6 @@ import { idFromAccount, resolveAccount } from '../../identity.ts';
6
7
  import { style } from '../../output.ts';
7
8
  import { terminalPrompter, type Prompter } from '../../prompt.ts';
8
9
  import { accountSiblings } from './accounts.ts';
9
- import { oauthProviderFor } from './authorise.ts';
10
10
 
11
11
  /**
12
12
  * Settle which connection this is, and whose account it belongs to.
@@ -49,12 +49,10 @@ export async function settleIdentity(input: {
49
49
  }
50
50
 
51
51
  if (!account) {
52
+ const token = () => bearerTokenAsStored(manifest, provisionalId, runtime.credentials);
53
+
52
54
  account = await resolveAccount(manifest, {
53
- accessToken: async () => {
54
- const provider = oauthProviderFor(manifest, provisionalId, runtime.credentials);
55
- const tokens = (await provider.tokens()) as { access_token?: string } | undefined;
56
- return tokens?.access_token ?? null;
57
- },
55
+ accessToken: token,
58
56
  // A protocol that authenticates by username has nothing to GET and no
59
57
  // tool to call — it knows, once the server has accepted the login.
60
58
  identify: async () =>
@@ -62,13 +60,18 @@ export async function settleIdentity(input: {
62
60
  ...(manifest.connector.kind === 'mcp'
63
61
  ? {
64
62
  callTool: async (name: string, args: Record<string, unknown>) => {
63
+ // Cast for the same reason `endpoint` already was: the
64
+ // narrowing that reached this branch does not survive into the
65
+ // closure. Named field by field rather than spread, so a future
66
+ // connector field does not silently become a transport option.
67
+ const mcp = manifest.connector as {
68
+ endpoint: string;
69
+ headers?: Record<string, string>;
70
+ };
65
71
  const connector = createMcpConnector({
66
- endpoint: (manifest.connector as { endpoint: string }).endpoint,
67
- accessToken: async () => {
68
- const provider = oauthProviderFor(manifest, provisionalId, runtime.credentials);
69
- const tokens = (await provider.tokens()) as { access_token?: string } | undefined;
70
- return tokens?.access_token ?? null;
71
- },
72
+ endpoint: mcp.endpoint,
73
+ ...(mcp.headers ? { headers: mcp.headers } : {}),
74
+ accessToken: token,
72
75
  });
73
76
  return connector.invoke({ name, inputSchema: {}, description: '' } as never, args, {
74
77
  manifest,
@@ -10,6 +10,7 @@ import {
10
10
  toPolicyDocument,
11
11
  } from '#registry';
12
12
  import { announce, emit, fail, ok, print, warn } from '../../output.ts';
13
+ import { staleNudge } from '../../release.ts';
13
14
  import { capabilityDiff, discoveryProbe } from '../../runtime/discovery.ts';
14
15
  import { openRuntime, resolveProfile, type GlobalFlags } from '../../runtime.ts';
15
16
 
@@ -189,6 +190,12 @@ export async function doctor(flags: DoctorFlags): Promise<void> {
189
190
  });
190
191
  }
191
192
 
193
+ // Every finding above is about this profile; this one is about the binary
194
+ // reading it. Silent when the registry cannot be reached — `doctor` is
195
+ // expected to work on a plane, and "could not check" is not a finding.
196
+ const stale = await staleNudge();
197
+ if (stale !== null) warnings.push({ kind: 'stale_release', message: stale });
198
+
192
199
  await reportCapabilityDrift(runtime, (message) =>
193
200
  warnings.push({ kind: 'capability_drift', message }),
194
201
  );
@@ -1,6 +1,7 @@
1
1
  import { startEndpoint } from '#server/endpoint.ts';
2
2
  import { streamLogger } from '#server/logging.ts';
3
3
  import { announce, ok, print, style, warn } from '../../output.ts';
4
+ import { staleNudge } from '../../release.ts';
4
5
  import { resolveProfile, type GlobalFlags } from '../../runtime.ts';
5
6
 
6
7
  /** `lanes link start` — reconcile, then serve every profile on one endpoint. */
@@ -41,6 +42,12 @@ export async function start(
41
42
 
42
43
  print(ok(`serving ${style.bold(endpoint.url)}`));
43
44
  print(style.dim(` profiles: ${endpoint.profiles.join(', ')}`));
45
+
46
+ // Last, not first: the endpoint is what someone ran this for, and a version
47
+ // note in front of it would be the first thing they read and the least useful.
48
+ const stale = await staleNudge();
49
+ if (stale !== null) print(warn(stale));
50
+
44
51
  print(style.dim('Ctrl-C to stop.'));
45
52
 
46
53
  const shutdown = async (): Promise<void> => {
@@ -0,0 +1,255 @@
1
+ import { homedir } from 'node:os';
2
+ import { join, sep } from 'node:path';
3
+ import { installRoot } from '#profile';
4
+ import { emit, fail, ok, print, printErr, progress, style, warn } from '../output.ts';
5
+ import { PACKAGE, release, type ReleaseState } from '../release.ts';
6
+ import { version } from '../version.ts';
7
+
8
+ /**
9
+ * `lanes link update` — install the newer release, or say why it will not.
10
+ *
11
+ * There is no build step and no compiled artifact, so updating means exactly
12
+ * one thing: replace the installed package directory with a newer tarball from
13
+ * the registry. `bin/lanes` resolves its own symlink chain and execs Bun on the
14
+ * `src/` inside that directory, so the shipped source *is* the running code and
15
+ * the symlink on the PATH never has to move.
16
+ *
17
+ * Bun is the only installer this drives. `bun install -g @lanes-sh/link` is the
18
+ * only install documented, `engines.bun` requires it, and the shim refuses to
19
+ * run without it — so inferring a package manager would be machinery serving a
20
+ * case nobody is told to create. The case that does exist is handled below
21
+ * rather than ignored: an `npm i -g` install updated with Bun gets a second
22
+ * copy somewhere else on the PATH, which this detects and reports instead of
23
+ * doing quietly.
24
+ *
25
+ * Nothing here is control plane — it touches the install, not a profile — so it
26
+ * resolves no profile and no target, and is the second command after `version`
27
+ * that prints no `announce` line.
28
+ */
29
+
30
+ export interface UpdateFlags {
31
+ /** Report and exit without installing anything. */
32
+ readonly check?: boolean | undefined;
33
+ readonly json?: boolean | undefined;
34
+ }
35
+
36
+ /**
37
+ * What `update` would do, and why.
38
+ *
39
+ * `'checkout'` is a refusal: `bun link` puts a checkout on the same PATH entry
40
+ * a published install would occupy, so installing from the registry there would
41
+ * leave two copies of this CLI and no indication of which one answers. `git
42
+ * pull` is the update in a checkout, and saying so is more useful than doing
43
+ * something surprising.
44
+ */
45
+ export type UpdateAction = 'install' | 'current' | 'ahead' | 'checkout' | 'unknown';
46
+
47
+ export interface UpdateDecision {
48
+ readonly action: UpdateAction;
49
+ /** The argv to run, or `null` when nothing should be run. */
50
+ readonly install: readonly string[] | null;
51
+ readonly message: string;
52
+ /** Something true and unwelcome about this install, if there is anything. */
53
+ readonly warning: string | null;
54
+ }
55
+
56
+ export interface UpdateInput {
57
+ readonly installed: string;
58
+ readonly latest: string | null;
59
+ readonly state: ReleaseState;
60
+ /** Where this CLI is installed — `installRoot()`, not the workspace. */
61
+ readonly root: string;
62
+ /** Where Bun keeps global installs, so a copy landing elsewhere is visible. */
63
+ readonly bunGlobal: string;
64
+ }
65
+
66
+ /**
67
+ * The whole decision, as a function of five strings.
68
+ *
69
+ * Split from the spawn because the alternative is a command whose only test is
70
+ * one that replaces the copy of this CLI on the machine running the suite. Every
71
+ * branch below is reachable from `update.test.ts` with no network and no
72
+ * subprocess — including the stale branch, which the checkout this is written in
73
+ * can never reach on its own, being by definition the newest thing there is.
74
+ */
75
+ export function updatePlan(input: UpdateInput): UpdateDecision {
76
+ const { installed, latest, state, root, bunGlobal } = input;
77
+
78
+ // A published install lives under `node_modules`; a checkout does not. Cheaper
79
+ // and steadier than looking for `.git`, which a tarball could carry and a
80
+ // shallow export could lack.
81
+ const published = root.split(sep).includes('node_modules');
82
+
83
+ if (!published) {
84
+ return {
85
+ action: 'checkout',
86
+ install: null,
87
+ message: `${root} is a checkout, not an install — git pull is the update here`,
88
+ warning: null,
89
+ };
90
+ }
91
+
92
+ if (state === 'unknown') {
93
+ return {
94
+ action: 'unknown',
95
+ install: null,
96
+ message:
97
+ latest === null
98
+ ? `could not reach the registry — ${installed} is installed`
99
+ : `cannot compare ${installed} against ${latest}`,
100
+ warning: null,
101
+ };
102
+ }
103
+
104
+ if (state === 'ahead') {
105
+ return {
106
+ action: 'ahead',
107
+ install: null,
108
+ message: `${installed} is installed, ahead of the published ${latest ?? 'release'}`,
109
+ warning: null,
110
+ };
111
+ }
112
+
113
+ if (state === 'current') {
114
+ return { action: 'current', install: null, message: `${installed} is current`, warning: null };
115
+ }
116
+
117
+ // Installed by npm, updated by Bun: `bun install -g` writes into its own
118
+ // global prefix and leaves the npm copy where it is, so both are on the PATH
119
+ // and its order decides which one answers. Worth saying before, not after.
120
+ const elsewhere = !root.startsWith(bunGlobal + sep);
121
+
122
+ return {
123
+ action: 'install',
124
+ install: ['install', '-g', PACKAGE],
125
+ message: `${installed} installed, ${latest} available`,
126
+ warning: elsewhere
127
+ ? `this copy is at ${root}, which is not under ${bunGlobal} — ` +
128
+ 'Bun will install a second copy there rather than replace this one, ' +
129
+ 'and your PATH order decides which one answers'
130
+ : null,
131
+ };
132
+ }
133
+
134
+ /** Where Bun keeps global installs, honouring `BUN_INSTALL`. */
135
+ export function bunGlobalRoot(env: Record<string, string | undefined> = process.env): string {
136
+ return join(env['BUN_INSTALL'] ?? join(homedir(), '.bun'), 'install', 'global');
137
+ }
138
+
139
+ export async function update(flags: UpdateFlags): Promise<void> {
140
+ const current = await release();
141
+ const root = installRoot(import.meta.dir);
142
+ const decision = updatePlan({
143
+ installed: current.installed,
144
+ latest: current.latest,
145
+ state: current.state,
146
+ root,
147
+ bunGlobal: bunGlobalRoot(),
148
+ });
149
+
150
+ // A gate wants a non-zero exit for the one state that needs action. An
151
+ // unreachable registry is not that state — failing a build because a network
152
+ // was down would make this the flakiest check in it.
153
+ if (flags.check === true && decision.action === 'install') process.exitCode = 1;
154
+
155
+ const report = {
156
+ installed: current.installed,
157
+ latest: current.latest,
158
+ state: current.state,
159
+ action: decision.action,
160
+ root,
161
+ ...(decision.install !== null ? { install: `bun ${decision.install.join(' ')}` } : {}),
162
+ ...(decision.warning !== null ? { warning: decision.warning } : {}),
163
+ };
164
+
165
+ if (flags.check === true || decision.action !== 'install') {
166
+ return emit(flags.json, report, () => {
167
+ if (decision.action === 'install') {
168
+ print(warn(decision.message));
169
+ if (decision.warning !== null) print(style.dim(` ${decision.warning}`));
170
+ print(style.dim(' run: lanes link update'));
171
+ return;
172
+ }
173
+
174
+ // Green for the two states that need nothing. A refusal and an
175
+ // unreachable registry are neither wrong nor fine, and `ok` would claim
176
+ // the second of those.
177
+ if (decision.action === 'current' || decision.action === 'ahead') {
178
+ print(ok(decision.message));
179
+ return;
180
+ }
181
+
182
+ print(style.dim(decision.message));
183
+ });
184
+ }
185
+
186
+ // Stderr, both of them. What this command produces is the version change, and
187
+ // with `--json` that is a document — a line of prose in front of it corrupts
188
+ // it for whatever is parsing, which is the whole reason `emit` exists.
189
+ if (decision.warning !== null) progress(warn(decision.warning));
190
+ progress(style.dim(`bun ${decision.install!.join(' ')}`));
191
+
192
+ const installed = await runInstall(decision.install!, flags.json === true);
193
+ if (!installed) {
194
+ printErr(fail('the install did not complete — nothing was changed'));
195
+ process.exitCode = 1;
196
+ return;
197
+ }
198
+
199
+ // Read the version back off disk rather than trusting the exit code. `version()`
200
+ // reads `package.json` from the install root at call time, so this is the one
201
+ // question worth asking after a successful install: did *this* copy change?
202
+ // Unchanged after a clean install is the second-copy case above, seen from the
203
+ // other side.
204
+ const landed = version();
205
+
206
+ return emit(
207
+ flags.json,
208
+ {
209
+ ...report,
210
+ // The action was `install`; this is what came of it. Inventing a third
211
+ // action value would describe an outcome as a decision.
212
+ result: landed === current.installed ? 'unchanged' : 'installed',
213
+ installed: landed,
214
+ previous: current.installed,
215
+ },
216
+ () => {
217
+ if (landed === current.installed) {
218
+ print(warn(`bun reported success, but ${root} is still ${landed}`));
219
+ print(style.dim(' the copy it installed is somewhere else on your PATH'));
220
+ print(style.dim(' check with: which -a lanes'));
221
+ return;
222
+ }
223
+
224
+ print(ok(`${current.installed} → ${style.bold(landed)}`));
225
+ print(style.dim(' a running endpoint serves the old code until it is restarted'));
226
+ },
227
+ );
228
+ }
229
+
230
+ /**
231
+ * Hand the install to Bun and let it own the terminal.
232
+ *
233
+ * `process.execPath` rather than `Bun.which('bun')`: this process is already
234
+ * running under the Bun that should do the installing, and a PATH lookup can
235
+ * find a different one — which would resolve the dependency set with a
236
+ * different resolver than the one that will run the result.
237
+ *
238
+ * Output is inherited rather than captured. Bun prints its own progress and its
239
+ * own errors, and paraphrasing a package manager's failure is how a report ends
240
+ * up less useful than the thing it replaced. Its stdout is dropped under
241
+ * `--json` for the same reason the lines above go to stderr: the document on
242
+ * stdout has to be the only thing on stdout. Its stderr is kept either way,
243
+ * because a failure is worth reading in both modes.
244
+ */
245
+ async function runInstall(argv: readonly string[], json: boolean): Promise<boolean> {
246
+ try {
247
+ const child = Bun.spawn([process.execPath, ...argv], {
248
+ stdout: json ? 'ignore' : 'inherit',
249
+ stderr: 'inherit',
250
+ });
251
+ return (await child.exited) === 0;
252
+ } catch {
253
+ return false;
254
+ }
255
+ }
package/src/cli/main.ts CHANGED
@@ -38,6 +38,7 @@ import {
38
38
  vaultRemove,
39
39
  vaultSet,
40
40
  } from './commands/owner.ts';
41
+ import { update } from './commands/update.ts';
41
42
  import { globalFlags, ownerFlags, parseArgv, text } from './argv.ts';
42
43
  import { PROGRAM, USAGE } from './usage.ts';
43
44
  import { version } from './version.ts';
@@ -335,6 +336,9 @@ export async function run(argv: readonly string[]): Promise<void> {
335
336
  print(version());
336
337
  return;
337
338
 
339
+ case 'update':
340
+ return update({ check: flags['check'] === true, json });
341
+
338
342
  default:
339
343
  throw new Error(`Unknown command "${first}". Run: ${PROGRAM} help`);
340
344
  }
@@ -0,0 +1,111 @@
1
+ import { version } from './version.ts';
2
+
3
+ /**
4
+ * Whether a newer release than this one has been published.
5
+ *
6
+ * `version.ts` answers which release is installed, which stopped being the
7
+ * whole question when this started shipping from npm: two machines can now sit
8
+ * a release apart with nothing on either to say so, and the only upgrade
9
+ * affordance in the tree was a `contract` mismatch telling someone to "Upgrade
10
+ * lanes-link" without naming a command.
11
+ *
12
+ * Every function here degrades to `null` or `'unknown'` rather than throwing. A
13
+ * version check is never the reason a command fails — `doctor`, `start`, and
14
+ * `deploy` each print one line from this and must all work on a plane.
15
+ */
16
+
17
+ /** The npm package this CLI ships as, and the only thing `update` will install. */
18
+ export const PACKAGE = '@lanes-sh/link';
19
+
20
+ /**
21
+ * The dist-tags document, not the packument.
22
+ *
23
+ * `registry.npmjs.org/<name>` carries every version ever published with its
24
+ * full manifest — hundreds of kilobytes to answer a question whose answer is
25
+ * eighteen bytes. This endpoint returns `{"latest":"0.2.0"}` and nothing else.
26
+ */
27
+ const DIST_TAGS = `https://registry.npmjs.org/-/package/${encodeURIComponent(PACKAGE)}/dist-tags`;
28
+
29
+ /**
30
+ * The same budget `endpointHealth` gives its probe.
31
+ *
32
+ * Long enough for a warm connection, short enough that a command which only
33
+ * mentions staleness in passing does not appear to hang on a captive-portal
34
+ * network that accepts the connection and then says nothing.
35
+ */
36
+ const PROBE_TIMEOUT_MS = 700;
37
+
38
+ export type ReleaseState = 'current' | 'stale' | 'ahead' | 'unknown';
39
+
40
+ export interface Release {
41
+ readonly installed: string;
42
+ /** `null` when the registry could not be reached, or answered something else. */
43
+ readonly latest: string | null;
44
+ readonly state: ReleaseState;
45
+ }
46
+
47
+ /**
48
+ * How the installed version stands against the published one.
49
+ *
50
+ * `'ahead'` is not a mistake: a contributor running from a checkout is usually
51
+ * a version ahead of the registry, and telling them they are behind would be
52
+ * both wrong and the thing they see most often.
53
+ *
54
+ * Pure, and separate from the fetch, so every branch is testable without a
55
+ * network — which is the only way the stale path gets covered at all, given the
56
+ * checkout this is written in is by definition current.
57
+ */
58
+ export function releaseState(installed: string, latest: string | null): ReleaseState {
59
+ if (latest === null) return 'unknown';
60
+
61
+ try {
62
+ const order = Bun.semver.order(installed, latest);
63
+ return order === 0 ? 'current' : order < 0 ? 'stale' : 'ahead';
64
+ } catch {
65
+ // `Bun.semver.order` throws on anything it cannot parse rather than
66
+ // ordering it arbitrarily. A registry that answers with something other
67
+ // than a version, or a hand-edited `package.json`, is an unknown state and
68
+ // not a reason to claim either answer.
69
+ return 'unknown';
70
+ }
71
+ }
72
+
73
+ /** What the registry calls `latest`, or `null` if it did not say. */
74
+ export async function latestRelease(): Promise<string | null> {
75
+ try {
76
+ const response = await fetch(DIST_TAGS, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
77
+ if (!response.ok) return null;
78
+
79
+ const body = (await response.json()) as { latest?: unknown };
80
+ return typeof body.latest === 'string' ? body.latest : null;
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
85
+
86
+ /** The installed version, the published one, and how they stand. */
87
+ export async function release(): Promise<Release> {
88
+ const installed = version();
89
+ const latest = await latestRelease();
90
+
91
+ return { installed, latest, state: releaseState(installed, latest) };
92
+ }
93
+
94
+ /**
95
+ * The one line `doctor`, `start`, and `deploy` print when this install is behind.
96
+ *
97
+ * One string in one place, because three commands saying it three ways is how
98
+ * two of them end up naming a command that has been renamed. `null` for every
99
+ * state but `'stale'`: nothing is worth saying about an install that is current,
100
+ * and an unreachable registry is not news.
101
+ */
102
+ export function staleLine(current: Release): string | null {
103
+ if (current.state !== 'stale') return null;
104
+
105
+ return `${current.installed} is installed, ${current.latest} is out — run: lanes link update`;
106
+ }
107
+
108
+ /** `staleLine` over a fresh probe, for a caller that has no `Release` in hand. */
109
+ export async function staleNudge(): Promise<string | null> {
110
+ return staleLine(await release());
111
+ }
package/src/cli/usage.ts CHANGED
@@ -90,6 +90,7 @@ ${style.bold('Inspection')}
90
90
  ${PROGRAM} audit verify has anything in the log been altered or removed
91
91
  ${PROGRAM} config show
92
92
  ${PROGRAM} version which release this is — same as lanes --version
93
+ ${PROGRAM} update [--check] [--json] install the newer release, or say what is available
93
94
 
94
95
  ${style.bold('Attachments')}
95
96
  ${PROGRAM} attach <file> --connection <provider>.<account>
@@ -3,7 +3,8 @@
3
3
  One folder per method. Each owns both halves of its job: `resolve*` turns the
4
4
  stored secret into a `ResolvedCredential`, and `attach*` puts that shape on an
5
5
  outbound request. `resolve.ts` and `authorize.ts` are the only files that know
6
- the whole set.
6
+ the whole set — plus `token.ts`, which answers the narrower question a
7
+ transport asks when it has a token to send and no request to attach it to.
7
8
 
8
9
  | Folder | `auth.kind` | What is stored |
9
10
  |---|---|---|
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * One folder per method, each owning both halves of its job: turning the stored
5
5
  * secret into a resolved shape (`resolve*`) and putting that shape on an
6
- * outbound request (`attach*`). The two dispatchers here are the only files
7
- * that know the whole set.
6
+ * outbound request (`attach*`). The dispatchers here are the only files that
7
+ * know the whole set: `resolve.ts` and `authorize.ts` for anything HTTP-shaped,
8
+ * and `token.ts` for a transport that takes a bare token instead of a request.
8
9
  *
9
10
  * This is the axis the manifest's `auth:` block selects, and it is deliberately
10
11
  * independent of `../transports/` — which is why iCloud can speak IMAP with a
@@ -15,6 +16,7 @@
15
16
  export { credentialResolver, type ResolvedCredential } from './resolve.ts';
16
17
  export { requestAuthorizer } from './authorize.ts';
17
18
  export { basicCredential } from './basic/index.ts';
19
+ export { bearerToken, bearerTokenAsStored } from './token.ts';
18
20
  export {
19
21
  CredentialOAuthProvider,
20
22
  clearUpstreamTokens,
@@ -0,0 +1,93 @@
1
+ import { type ProviderManifest } from '#connectivity';
2
+ import type { ProviderRegistry } from '#registry';
3
+ import type { SecretStore } from '#secrets';
4
+ import { credentialResolver } from './resolve.ts';
5
+ import { CredentialOAuthProvider } from './oauth-authcode/provider.ts';
6
+
7
+ /**
8
+ * The bearer token for one connection, whichever way the provider came by it.
9
+ *
10
+ * An mcp connector sends `Authorization: Bearer <token>` and nothing else, so
11
+ * it takes a token rather than a `Request` to authorise — the same shape
12
+ * `basicCredential` has for IMAP and DAV, and for the same reason: a transport
13
+ * that has no `Request` to hand an authorizer gets its credential as a bound
14
+ * closure instead.
15
+ *
16
+ * What lives here that does not live in `./bearer/` is the *dispatch*. Two
17
+ * unrelated arrangements produce a bearer token — an OAuth access token
18
+ * exchanged on every use, and a long-lived one the operator pasted — and the
19
+ * caller does not care which, only the manifest does. Putting that choice in
20
+ * the bearer folder would make the folder that owns one method know about
21
+ * another; putting it in `oauth-authcode/` would make the OAuth folder answer
22
+ * for a token no OAuth flow ever produced.
23
+ *
24
+ * Before this existed, every caller asked `CredentialOAuthProvider` for the
25
+ * token, and a manifest declaring `bearer` got `null` back rather than an
26
+ * error: the provider's tokens ref is `<provider>/<connection>`, byte-identical
27
+ * to the ref a pasted token derives, so it read the token, failed to parse it
28
+ * as a JSON blob, and returned undefined by design. The connection then went
29
+ * upstream with no `Authorization` header at all.
30
+ */
31
+
32
+ /** One manifest, dressed as a registry, so the resolver needs no lookup. */
33
+ const only = (manifest: ProviderManifest): ProviderRegistry =>
34
+ ({ manifest: () => manifest }) as unknown as ProviderRegistry;
35
+
36
+ /**
37
+ * Throws where the manifest declares a credential and none is stored:
38
+ * `credentialResolver` already says which ref is empty and which command fills
39
+ * it, which beats a `null` that reaches the server as a missing header.
40
+ */
41
+ export async function bearerToken(
42
+ manifest: ProviderManifest,
43
+ connectionId: string,
44
+ secrets: SecretStore,
45
+ ): Promise<string | null> {
46
+ const resolved = await credentialResolver(only(manifest), secrets)(manifest.id, connectionId);
47
+
48
+ switch (resolved.kind) {
49
+ case 'none':
50
+ return null;
51
+ case 'oauth':
52
+ return resolved.accessToken;
53
+ case 'bearer':
54
+ return resolved.token;
55
+ default:
56
+ // Unreachable: `defineProvider` refuses every other kind on an mcp
57
+ // connector. Kept so the guard and this switch cannot drift apart
58
+ // silently — if one is ever relaxed, the other says so.
59
+ throw new Error(
60
+ `Provider "${manifest.id}" resolves to a "${resolved.kind}" credential, which cannot be sent as a bearer token.`,
61
+ );
62
+ }
63
+ }
64
+
65
+ /**
66
+ * The same token, read exactly as stored, without the refresh machinery.
67
+ *
68
+ * For `connect`: it has just written the token and wants that one, not a
69
+ * refreshed one. Going through `bearerToken` there would populate the process's
70
+ * access-token cache under the provisional connection id — `linear.pending`,
71
+ * a key naming a connection that will not exist a moment later — and could
72
+ * spend a network round trip re-exchanging a token written seconds ago.
73
+ *
74
+ * Identical to `bearerToken` for every non-OAuth kind, because a stored token
75
+ * has no other reading.
76
+ */
77
+ export async function bearerTokenAsStored(
78
+ manifest: ProviderManifest,
79
+ connectionId: string,
80
+ secrets: SecretStore,
81
+ ): Promise<string | null> {
82
+ if (manifest.auth.kind !== 'oauth') return bearerToken(manifest, connectionId, secrets);
83
+
84
+ const provider = new CredentialOAuthProvider({
85
+ manifest,
86
+ connectionId,
87
+ credentials: secrets,
88
+ scopes: manifest.auth.scopes,
89
+ });
90
+
91
+ const tokens = (await provider.tokens()) as { access_token?: string } | undefined;
92
+ return tokens?.access_token ?? null;
93
+ }
@@ -17,6 +17,21 @@ import { z } from 'zod';
17
17
  export const mcpConnectorSchema = z.object({
18
18
  kind: z.literal('mcp'),
19
19
  endpoint: z.url(),
20
+ /**
21
+ * Sent on every request to this server, discovery included.
22
+ *
23
+ * The `http` connector filters what it exposes with `operations` above,
24
+ * because it reads a document listing everything the API can do. An mcp
25
+ * server decides that for itself and answers `tools/list` with whatever it
26
+ * chose — so where a vendor makes the choice configurable, the configuration
27
+ * is a header they define. GitHub's `X-MCP-Toolsets` is the case in hand: the
28
+ * default is broad and `all` is broader, and this is the only way to ask for
29
+ * less.
30
+ *
31
+ * Never `Authorization` — that one belongs to `auth:`, and the check in
32
+ * `./provider.ts` refuses it rather than letting the two disagree silently.
33
+ */
34
+ headers: z.record(z.string(), z.string()).optional(),
20
35
  });
21
36
 
22
37
  /**
@@ -196,6 +196,45 @@ export function defineProvider(input: unknown): ProviderManifest {
196
196
  );
197
197
  }
198
198
 
199
+ if (manifest.connector.kind === 'mcp') {
200
+ const auth = manifest.auth;
201
+
202
+ // The transport sends exactly one header, `Authorization: Bearer <token>`,
203
+ // because that is what the MCP specification says a client sends. Every
204
+ // other token kind puts the secret somewhere the transport has nowhere to
205
+ // put it: `api_key` in a query string or a named header, `header` under a
206
+ // name of its own, `basic` in a different scheme entirely. Such a manifest
207
+ // validates and then connects *unauthenticated* — no error, an empty tool
208
+ // list, and nothing to read that says why.
209
+ if (auth.kind !== 'none' && auth.kind !== 'oauth' && auth.kind !== 'bearer') {
210
+ throw new Error(
211
+ `Provider "${manifest.id}": an mcp connector authenticates with "Authorization: Bearer", so its auth must be "none", "oauth", or "bearer" — not "${auth.kind}". There is nowhere else on the request for the transport to put a credential.`,
212
+ );
213
+ }
214
+
215
+ // Same failure, one field further in. `bearer` may rename its header, and
216
+ // `resolveBearer` honours that — but the mcp transport does not read the
217
+ // resolved credential at all, only the token, so a renamed header would be
218
+ // silently ignored and the token sent under `Authorization` regardless.
219
+ if (auth.kind === 'bearer' && auth.header) {
220
+ throw new Error(
221
+ `Provider "${manifest.id}": an mcp connector always sends its token as "Authorization: Bearer", so auth.header ("${auth.header}") cannot be honoured. Remove it, or reach this service with an http connector.`,
222
+ );
223
+ }
224
+
225
+ // The third spelling of the same collision. Connector headers are for what
226
+ // the *server* offers as configuration; the credential is the auth block's,
227
+ // and a manifest setting both would have one quietly overwrite the other
228
+ // depending on which the transport merged last.
229
+ for (const name of Object.keys(manifest.connector.headers ?? {})) {
230
+ if (name.toLowerCase() === 'authorization') {
231
+ throw new Error(
232
+ `Provider "${manifest.id}": connector.headers may not set "${name}" — the credential comes from auth, and setting both would leave which one is sent up to merge order.`,
233
+ );
234
+ }
235
+ }
236
+ }
237
+
199
238
  const names = new Set<string>();
200
239
  for (const bundle of manifest.bundles ?? []) {
201
240
  if (names.has(bundle.name)) {
@@ -2,7 +2,7 @@ import type { AnyConnector, ProviderDefinition, ProviderManifest } from '#connec
2
2
  import type { SecretStore } from '#secrets';
3
3
  import type { ProviderRegistry } from '#registry';
4
4
  import { basicCredential } from '#connectivity/auth/basic/index.ts';
5
- import { resolveUpstreamToken } from '#connectivity/auth/oauth-authcode/index.ts';
5
+ import { bearerToken } from '#connectivity/auth/token.ts';
6
6
  import { createCompositeConnector } from './composite/index.ts';
7
7
  import { createDavConnector } from './dav/index.ts';
8
8
  import { createFsConnector } from './fs/index.ts';
@@ -114,7 +114,11 @@ function build(
114
114
  case 'mcp':
115
115
  return createMcpConnector({
116
116
  endpoint: manifest.connector.endpoint,
117
- accessToken: () => resolveUpstreamToken(manifest, connectionId, options.credentials),
117
+ ...(manifest.connector.headers ? { headers: manifest.connector.headers } : {}),
118
+ // Not `resolveUpstreamToken` directly: that one answers only for OAuth,
119
+ // and returns null for a provider whose token the operator pasted —
120
+ // which reaches the server as a missing header rather than an error.
121
+ accessToken: () => bearerToken(manifest, connectionId, options.credentials),
118
122
  });
119
123
 
120
124
  case 'imap':
@@ -24,6 +24,8 @@ export interface McpConnectorOptions {
24
24
  readonly endpoint: string;
25
25
  /** Supplies the bearer token for an upstream call; refreshes if needed. */
26
26
  readonly accessToken: () => Promise<string | null>;
27
+ /** Whatever the manifest's connector declares, sent on every request. */
28
+ readonly headers?: Record<string, string> | undefined;
27
29
  readonly fetch?: typeof globalThis.fetch;
28
30
  }
29
31
 
@@ -127,7 +129,14 @@ export function createMcpConnector(options: McpConnectorOptions): Connector {
127
129
 
128
130
  const transport = new StreamableHTTPClientTransport(new URL(options.endpoint), {
129
131
  requestInit: {
130
- headers: token ? { authorization: `Bearer ${token}` } : {},
132
+ // The declared headers first, so the credential cannot be displaced by
133
+ // one. `defineProvider` already refuses a declared `Authorization`, and
134
+ // this order means a manifest loaded some other way fails safe rather
135
+ // than sending someone else's header in its place.
136
+ headers: {
137
+ ...(options.headers ?? {}),
138
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
139
+ },
131
140
  },
132
141
  ...(options.fetch ? { fetch: options.fetch } : {}),
133
142
  } as never);
@@ -1,5 +1,6 @@
1
1
  import { ConfigError, resolveDeployTarget, type DeployConfig } from '#profile';
2
2
  import { announce, fail, heading, ok, print, style, warn } from '#cli/output.ts';
3
+ import { staleNudge } from '#cli/release.ts';
3
4
  import { confirm, isInteractive } from '#cli/prompt.ts';
4
5
  import { openSecretStoreFor, resolveProfile, type GlobalFlags } from '#cli/runtime.ts';
5
6
  import { resolveTarget, vaultEnv } from './bootstrap.ts';
@@ -57,6 +58,12 @@ export async function deploy(flags: DeployFlags): Promise<void> {
57
58
  // rejected on boot should be rejected here, not after a five-minute build.
58
59
  print(ok(`${resolution.profilePath} is valid`));
59
60
 
61
+ // The CLI planning this rollout, not the image it will build. An old one
62
+ // plans an old rollout, and a build is the most expensive place to find that
63
+ // out.
64
+ const stale = await staleNudge();
65
+ if (stale !== null) print(warn(stale));
66
+
60
67
  const declared = await resolveTarget({
61
68
  config,
62
69
  profilePath: resolution.profilePath,
@@ -0,0 +1,68 @@
1
+ import { defineProvider } from '#connectivity';
2
+ import { GITHUB_REDACT } from './redact.ts';
3
+
4
+ /**
5
+ * GitHub, through the server GitHub runs.
6
+ *
7
+ * Not OAuth, and not for want of trying. GitHub's remote MCP server does not
8
+ * offer Dynamic Client Registration — the thing that makes Notion and Linear
9
+ * cost fifteen lines — so there is no client to register ourselves as. A client
10
+ * of the operator's own is the documented fallback everywhere else, and it
11
+ * fails here on a detail: an OAuth App matches its callback URL exactly,
12
+ * including the port, and `connect` listens on a port the kernel picks. What is
13
+ * left is the credential GitHub does issue for exactly this — a token you
14
+ * generate once and paste. See ADR-033.
15
+ *
16
+ * The toolsets header is the whole reason `connector.headers` exists. GitHub
17
+ * serves a different tool list per toolset and `all` is far more than an agent
18
+ * reasons over; this asks for the ones an agent working in a repository
19
+ * actually uses. It is one string to change, and `docs/detailed/setup/github.md`
20
+ * records the read-only variant for someone who wants a narrower connection.
21
+ */
22
+ export const github = defineProvider({
23
+ id: 'github',
24
+ name: 'GitHub',
25
+ description: 'Repositories, issues, pull requests, and workflow runs, via GitHub\'s official MCP server.',
26
+ connector: {
27
+ kind: 'mcp',
28
+ endpoint: 'https://api.githubcopilot.com/mcp/',
29
+ headers: { 'X-MCP-Toolsets': 'context,repos,issues,pull_requests,actions,labels' },
30
+ },
31
+ auth: { kind: 'bearer' },
32
+ // GitHub's own MCP server answers `get_me`, so a tool identity would work.
33
+ // This asks the REST API instead, for one reason: it is a plain GET that
34
+ // costs no MCP handshake, and `connect` runs it before discovery — so when
35
+ // the token is wrong, the thing that fails is the cheap call rather than the
36
+ // expensive one.
37
+ identity: { kind: 'http', url: 'https://api.github.com/user', field: 'login' },
38
+ redact: GITHUB_REDACT,
39
+ setup: {
40
+ summary:
41
+ 'GitHub issues a fine-grained personal access token for this. There is no OAuth app to register: ' +
42
+ 'GitHub\'s MCP server does not support the dynamic registration Notion and Linear use, and an OAuth ' +
43
+ 'app of your own would need a fixed callback port, which this CLI does not have. You are asked once.',
44
+ docs: 'docs/detailed/setup/github.md',
45
+ docs_url: 'https://github.com/settings/personal-access-tokens',
46
+ steps: [
47
+ 'Open https://github.com/settings/personal-access-tokens and choose "Generate new token".',
48
+ 'Name it "Lanes Link" — the name is how you revoke this one later without touching your other tokens — and set an expiry you are willing to renew.',
49
+ 'Resource owner: yourself, or the organisation whose repositories you want reachable. An organisation may require an owner to approve the token before it works.',
50
+ 'Repository access: only the repositories you want an agent to see. "All repositories" is the setting people regret.',
51
+ 'Permissions, matching the toolsets this connects: Contents (read), Metadata (read, added for you), Issues (read and write), Pull requests (read and write), Actions (read). Add Administration or Workflows only if you know you need them.',
52
+ 'Generate, then copy the token. GitHub shows it once, and it starts with github_pat_.',
53
+ 'When it expires, generate another and run: lanes link connect github --replace.',
54
+ ],
55
+ troubleshooting:
56
+ 'GitHub refused the token. The usual causes are an expired token, a repository the token was not granted, ' +
57
+ 'or an organisation token still waiting on an owner\'s approval. Generate a new one at ' +
58
+ 'https://github.com/settings/personal-access-tokens and re-run: lanes link connect github --replace.',
59
+ prompts: [
60
+ {
61
+ key: 'token',
62
+ label: 'GitHub personal access token',
63
+ secret: true,
64
+ scope: 'connection' as const,
65
+ },
66
+ ],
67
+ },
68
+ });
@@ -0,0 +1,109 @@
1
+ /**
2
+ * What survives into the audit log when GitHub is written to.
3
+ *
4
+ * The default withholds every value, which is right for a server whose
5
+ * capabilities we did not author and cannot know the shape of. It is wrong for
6
+ * the writes: "an issue was edited" without saying which issue, in which
7
+ * repository, or into what state is a record of nothing.
8
+ *
9
+ * The line drawn is the one Gmail and Drive draw. Identifiers and flags are
10
+ * kept — `owner`, `repo`, the number, the method, the state, the labels and
11
+ * reviewers, the merge method. The user's own words are withheld: `title`,
12
+ * `body`, `commit_message`. A log that said which pull request was merged and
13
+ * by what method has answered the question; one that also quoted the commit
14
+ * message has started keeping a copy of the work.
15
+ *
16
+ * `assignees` and `reviewers` are kept, and that departs from withholding
17
+ * people the way `drive.permissions.create` keeps `emailAddress`: assigning
18
+ * somebody *is* the change, so a log that cannot say to whom has failed at its
19
+ * one question.
20
+ *
21
+ * Two caveats worth stating rather than discovering.
22
+ *
23
+ * A proxied server's capabilities are discovered, not declared, so the argument
24
+ * names below come from GitHub's published tool documentation rather than from
25
+ * a schema in this repository. `cli/tools.test.ts` checks these names for every
26
+ * `http` provider and cannot check them here — there is nothing local to check
27
+ * against. A name that is wrong, or that GitHub renames later, fails the way
28
+ * that test exists to prevent: silently, with the value withheld and the log
29
+ * reading exactly as it does when redaction is working. `lanes link doctor`
30
+ * reporting capability drift is the signal that this list wants re-reading.
31
+ *
32
+ * Reads are absent on purpose. `search_issues` takes a `query`, which is a
33
+ * question somebody asked rather than a record of something that happened —
34
+ * the same ground Gmail withholds `q` on.
35
+ */
36
+ export const GITHUB_REDACT: Record<string, string[]> = {
37
+ // `method` is the verb — create, update, close — and without it the entry
38
+ // says an issue was written to and not what was done to it.
39
+ issue_write: [
40
+ 'owner',
41
+ 'repo',
42
+ 'issue_number',
43
+ 'method',
44
+ 'state',
45
+ 'state_reason',
46
+ 'labels',
47
+ 'assignees',
48
+ 'milestone',
49
+ 'type',
50
+ 'duplicate_of',
51
+ ],
52
+ add_issue_comment: ['owner', 'repo', 'issue_number', 'comment_id', 'reaction'],
53
+ sub_issue_write: [
54
+ 'owner',
55
+ 'repo',
56
+ 'issue_number',
57
+ 'method',
58
+ 'sub_issue_id',
59
+ 'replace_parent',
60
+ 'after_id',
61
+ 'before_id',
62
+ ],
63
+ // No `title`: a branch name says which change this is, and the title is the
64
+ // author's summary of it.
65
+ create_pull_request: [
66
+ 'owner',
67
+ 'repo',
68
+ 'head',
69
+ 'base',
70
+ 'draft',
71
+ 'reviewers',
72
+ 'maintainer_can_modify',
73
+ ],
74
+ update_pull_request: [
75
+ 'owner',
76
+ 'repo',
77
+ 'pullNumber',
78
+ 'base',
79
+ 'state',
80
+ 'draft',
81
+ 'reviewers',
82
+ 'maintainer_can_modify',
83
+ ],
84
+ merge_pull_request: ['owner', 'repo', 'pullNumber', 'merge_method'],
85
+ // `event` is the one that matters: approving is a different act from
86
+ // commenting, and this is the only place that distinction is recorded.
87
+ pull_request_review_write: [
88
+ 'owner',
89
+ 'repo',
90
+ 'pullNumber',
91
+ 'method',
92
+ 'event',
93
+ 'commitID',
94
+ 'threadId',
95
+ ],
96
+ add_comment_to_pending_review: [
97
+ 'owner',
98
+ 'repo',
99
+ 'pullNumber',
100
+ 'path',
101
+ 'line',
102
+ 'startLine',
103
+ 'side',
104
+ 'startSide',
105
+ 'subjectType',
106
+ ],
107
+ add_reply_to_pull_request_comment: ['owner', 'repo', 'pullNumber', 'commentId', 'reaction'],
108
+ update_pull_request_branch: ['owner', 'repo', 'pullNumber', 'expectedHeadSha'],
109
+ };
@@ -2,6 +2,7 @@ import type { ProviderDefinition, ProviderManifest } from '#connectivity';
2
2
  import { calendar } from './google/calendar/index.ts';
3
3
  import { contacts } from './google/contacts/index.ts';
4
4
  import { docs } from './google/docs/index.ts';
5
+ import { github } from './github/index.ts';
5
6
  import { drive } from './google/drive/index.ts';
6
7
  import { driveMcp } from './google/drive-mcp/index.ts';
7
8
  import { gmail } from './google/gmail/index.ts';
@@ -14,6 +15,7 @@ import { icloudDrive } from './icloud/drive/index.ts';
14
15
  import { icloudMail } from './icloud/mail/index.ts';
15
16
  import { linear } from './linear/index.ts';
16
17
  import { notion } from './notion/index.ts';
18
+ import { slack } from './slack/index.ts';
17
19
 
18
20
  /**
19
21
  * Every provider, in one list.
@@ -48,6 +50,8 @@ import { notion } from './notion/index.ts';
48
50
  export const PROVIDERS: readonly (ProviderManifest | ProviderDefinition)[] = [
49
51
  notion,
50
52
  linear,
53
+ github,
54
+ slack,
51
55
  gmail,
52
56
  drive,
53
57
  sheets,
@@ -88,6 +92,8 @@ export {
88
92
  tasks,
89
93
  } from './google/index.ts';
90
94
  export { icloudCalendar, icloudContacts, icloudDrive, icloudMail } from './icloud/index.ts';
95
+ export { github } from './github/index.ts';
91
96
  export { linear } from './linear/index.ts';
92
97
  export { notion } from './notion/index.ts';
98
+ export { slack } from './slack/index.ts';
93
99
  export { SCOPE_MEANINGS, type ScopeMeaning } from './scopes.ts';
@@ -0,0 +1,75 @@
1
+ import { defineProvider } from '#connectivity';
2
+ import { SLACK_REDACT } from './redact.ts';
3
+
4
+ /**
5
+ * Slack, through the server Slack runs.
6
+ *
7
+ * The only provider here whose vendor has closed every door but one. Slack's
8
+ * MCP server does not offer Dynamic Client Registration — their documentation
9
+ * says so outright — and a client of your own cannot work either: Slack
10
+ * requires an HTTPS redirect URI, and `connect` listens on `http://127.0.0.1`
11
+ * on a port the kernel picks. There is no proxy, tunnel, or flag that makes a
12
+ * loopback listener HTTPS. A broker would answer it and `defineProvider`
13
+ * refuses one on an mcp connector, because the SDK owns that exchange.
14
+ *
15
+ * What is left is the user token the Slack app mints when you install it, sent
16
+ * as `Authorization: Bearer`. Slack supports that path deliberately; it is the
17
+ * arrangement their own docs describe for a client that cannot register. See
18
+ * ADR-033.
19
+ *
20
+ * Unlike GitHub, this does cost a console visit — creating a Slack app is the
21
+ * only way to get a user token at all, and no amount of implementation work on
22
+ * this side removes it. The setup block is therefore longer than any other here
23
+ * except Google's, and that is the honest shape of it.
24
+ */
25
+ export const slack = defineProvider({
26
+ id: 'slack',
27
+ name: 'Slack',
28
+ description: 'Messages, threads, channels, files, and canvases, via Slack\'s official MCP server.',
29
+ connector: { kind: 'mcp', endpoint: 'https://mcp.slack.com/mcp' },
30
+ auth: { kind: 'bearer' },
31
+ /**
32
+ * The person, not the workspace, and the distinction is load-bearing.
33
+ *
34
+ * `settleIdentity` matches a resolved account against existing connections
35
+ * to decide whether this is a reconnect or a new account. Labelled by
36
+ * workspace, a second person's token in the same workspace would look like a
37
+ * reconnect of the first and overwrite their credential. `auth.test` returns
38
+ * both; `user` is the one that is unique per token.
39
+ *
40
+ * Slack answers a bad token with HTTP 200 and `{ok: false}`, so a wrong token
41
+ * reaches `connect`'s "which account is this?" fallback rather than a clear
42
+ * refusal. Discovery fails loudly one step later, which is where the real
43
+ * error is.
44
+ */
45
+ identity: { kind: 'http', url: 'https://slack.com/api/auth.test', field: 'user' },
46
+ redact: SLACK_REDACT,
47
+ setup: {
48
+ summary:
49
+ 'Slack needs an app of its own — there is no personal access token and no way to register ' +
50
+ 'automatically, because Slack requires an HTTPS callback and this CLI listens on localhost. ' +
51
+ 'You create the app once, install it to your workspace, and paste the user token it mints.',
52
+ docs: 'docs/detailed/setup/slack.md',
53
+ docs_url: 'https://api.slack.com/apps',
54
+ steps: [
55
+ 'Open https://api.slack.com/apps and choose "Create New App" → "From scratch". Name it "Lanes Link" and pick the workspace.',
56
+ 'Open "OAuth & Permissions" and scroll to "Scopes". Add these under USER TOKEN SCOPES — not Bot Token Scopes; the MCP server reads the user token: search:read.public, search:read.private, search:read.im, search:read.mpim, search:read.users, search:read.files, channels:history, groups:history, im:history, mpim:history, channels:read, groups:read, mpim:read, users:read, chat:write, files:read.',
57
+ 'For reactions, canvases, or creating channels, add reactions:write, canvases:read, canvases:write, or channels:write as well. Those tools are listed either way and fail at call time without the scope.',
58
+ 'Scroll up and choose "Install to Workspace", then approve. A Slack admin may have to approve it for you.',
59
+ 'Copy the "User OAuth Token" from the same page. It starts with xoxp- — not the bot token, which starts with xoxb- and will not work here.',
60
+ 'The token does not expire unless you enable token rotation on the app. If you rotate or reinstall, run: lanes link connect slack --replace.',
61
+ ],
62
+ troubleshooting:
63
+ 'Slack refused the token. The usual causes are a bot token (xoxb-) pasted where the user token (xoxp-) belongs, ' +
64
+ 'a scope missing from USER TOKEN SCOPES, or an app that was reinstalled since — reinstalling mints a new token. ' +
65
+ 'Copy the User OAuth Token from https://api.slack.com/apps and re-run: lanes link connect slack --replace.',
66
+ prompts: [
67
+ {
68
+ key: 'token',
69
+ label: 'Slack user OAuth token (xoxp-…)',
70
+ secret: true,
71
+ scope: 'connection' as const,
72
+ },
73
+ ],
74
+ },
75
+ });
@@ -0,0 +1,50 @@
1
+ /**
2
+ * What survives into the audit log when Slack is written to.
3
+ *
4
+ * The line is the one Gmail draws for mail, because it is the same object: a
5
+ * message someone wrote. Where it went is recorded, what it said is not.
6
+ * `channel_id` is an identifier, `thread_ts` says which conversation, and
7
+ * `message` is the whole of the content — a log that quoted it would be a
8
+ * second copy of everybody's Slack, held somewhere nobody expects one.
9
+ *
10
+ * Keys are the *shortened* names. `shortenName` strips a redundant provider
11
+ * prefix, so the upstream `slack_send_message` is `send_message` here, in
12
+ * policy, and in the audit log. Keying this block on the upstream name would
13
+ * match nothing and withhold everything, silently.
14
+ *
15
+ * The same caveat as GitHub's, and it is worth repeating rather than
16
+ * cross-referencing: a proxied server's capabilities are discovered, not
17
+ * declared, so `cli/tools.test.ts` cannot check these names against a local
18
+ * schema the way it does for every `http` provider. They were read off the
19
+ * tool schemas Slack's server actually publishes rather than guessed, but a
20
+ * rename upstream fails the way that test exists to prevent — the value is
21
+ * withheld and the log reads exactly as it does when redaction is working.
22
+ * `lanes link doctor` reporting capability drift is the signal to re-read this.
23
+ *
24
+ * Some entries name tools the default scope set cannot call. That is
25
+ * deliberate: Slack lists every tool regardless of scope and refuses at call
26
+ * time, so someone who later adds `reactions:write` or `canvases:write` finds
27
+ * the log already correct rather than discovering it is not.
28
+ */
29
+ export const SLACK_REDACT: Record<string, string[]> = {
30
+ // Everything except the message. `reply_broadcast` is kept because "replied
31
+ // in a thread" and "replied in a thread and pushed it to the channel" are
32
+ // different acts, and only this argument distinguishes them.
33
+ send_message: ['channel_id', 'thread_ts', 'reply_broadcast', 'unfurl_app_links', 'draft_id'],
34
+ send_message_draft: ['channel_id', 'thread_ts'],
35
+ schedule_message: ['channel_id', 'post_at', 'thread_ts', 'reply_broadcast'],
36
+ // The emoji is kept. It is a name from a fixed vocabulary rather than
37
+ // something anyone typed — the same reading that lets Gmail keep label ids —
38
+ // and an entry saying a message was reacted to without saying how records
39
+ // nothing anyone would look for.
40
+ add_reaction: ['channel_id', 'message_ts', 'emoji'],
41
+ // Nothing. A canvas has no identifier until Slack answers with one, so both
42
+ // arguments are the document: `title` is the author's words and `content` is
43
+ // the whole of it. This is `gmail.send_message`'s position, reached the same
44
+ // way — there is no identifier here to keep, so keeping anything would mean
45
+ // keeping content.
46
+ create_canvas: [],
47
+ // `sections` is withheld along with `content`: each entry carries its own
48
+ // markdown, so keeping the array would keep the document a second time.
49
+ update_canvas: ['canvas_id', 'action', 'section_id'],
50
+ };