@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 +12 -4
- package/package.json +1 -1
- package/src/cli/commands/connect/authorise.ts +8 -1
- package/src/cli/commands/connect/index.ts +7 -8
- package/src/cli/commands/connect/settle.ts +15 -12
- package/src/cli/commands/operate/inspect.ts +7 -0
- package/src/cli/commands/operate/serve.ts +7 -0
- package/src/cli/commands/update.ts +255 -0
- package/src/cli/main.ts +4 -0
- package/src/cli/release.ts +111 -0
- package/src/cli/usage.ts +1 -0
- package/src/connectivity/auth/README.md +2 -1
- package/src/connectivity/auth/index.ts +4 -2
- package/src/connectivity/auth/token.ts +93 -0
- package/src/connectivity/manifest/connector.ts +15 -0
- package/src/connectivity/manifest/provider.ts +39 -0
- package/src/connectivity/transports/factory.ts +6 -2
- package/src/connectivity/transports/mcp/index.ts +10 -1
- package/src/deployments/deploy.ts +7 -0
- package/src/providers/github/index.ts +68 -0
- package/src/providers/github/redact.ts +109 -0
- package/src/providers/index.ts +6 -0
- package/src/providers/slack/index.ts +75 -0
- package/src/providers/slack/redact.ts +50 -0
package/README.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Lanes Link
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/@lanes-sh/link)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
[](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
|
-
|
|
100
|
-
together, because one app-specific password covers all three.
|
|
101
|
-
your own: `lanes link connect gmail` authorises against the one Lanes operates, so there is no
|
|
102
|
-
Cloud console to visit
|
|
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
|
@@ -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
|
-
|
|
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
|
|
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 `
|
|
270
|
-
//
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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:
|
|
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:
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
7
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
+
};
|
package/src/providers/index.ts
CHANGED
|
@@ -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
|
+
};
|