@lanes-sh/link 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -9
- package/instructions/agents/lanes-link-scout.md +14 -3
- package/instructions/skills/lanes-link/SKILL.md +80 -3
- package/package.json +2 -2
- package/src/cli/argv.ts +7 -0
- package/src/cli/commands/connect/index.ts +9 -6
- package/src/cli/commands/connection.ts +298 -0
- package/src/cli/commands/mcp/list.ts +123 -29
- package/src/cli/commands/operate/inspect.ts +37 -20
- package/src/cli/commands/operate/serve.ts +21 -0
- package/src/cli/commands/owner/assets.ts +132 -0
- package/src/cli/commands/owner/shared.ts +28 -4
- package/src/cli/commands/owner/tasks.ts +194 -0
- package/src/cli/commands/owner.ts +9 -4
- package/src/cli/config-edit.ts +33 -7
- package/src/cli/config-repair.ts +115 -11
- package/src/cli/dispatch-owner.ts +49 -8
- package/src/cli/lanes.ts +1 -1
- package/src/cli/main.ts +26 -3
- package/src/cli/provider-marks.ts +1 -1
- package/src/cli/runtime/registry.ts +10 -2
- package/src/cli/selection.ts +14 -0
- package/src/cli/usage.ts +18 -2
- package/src/connectivity/mail/attachments.ts +5 -1
- package/src/connectivity/mail/index.ts +6 -1
- package/src/connectivity/manifest/provider.ts +15 -2
- package/src/deployments/deploy.ts +3 -2
- package/src/deployments/prepare.ts +1 -1
- package/src/deployments/servable.ts +1 -1
- package/src/deployments/upload.ts +0 -53
- package/src/profile/load.ts +46 -0
- package/src/providers/assets/provider.ts +337 -0
- package/src/providers/assets/store.ts +167 -0
- package/src/providers/bunq/hints.ts +3 -1
- package/src/providers/bunq/redact.ts +13 -2
- package/src/providers/bunq/specs/bunq.v1.json +20 -1
- package/src/providers/bunq/specs/vendor.ts +59 -1
- package/src/providers/google/index.ts +1 -1
- package/src/providers/google/tasks/index.ts +3 -3
- package/src/providers/google/tasks/redact.ts +21 -11
- package/src/providers/index.ts +3 -3
- package/src/providers/owner.ts +39 -19
- package/src/providers/setup/plan.ts +17 -1
- package/src/providers/shared/vendor-operations.ts +81 -0
- package/src/providers/tasks/provider.ts +370 -0
- package/src/providers/tasks/store.ts +248 -0
- package/src/server/mcp/build.ts +1 -1
- package/src/server/mcp/instructions.ts +67 -8
package/README.md
CHANGED
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
[](LICENSE)
|
|
5
5
|
[](https://github.com/lanes-sh/link/actions/workflows/ci.yml)
|
|
6
6
|
|
|
7
|
-
**One secure endpoint between your AI agents and all your connections, memory,
|
|
7
|
+
**One secure endpoint between your AI agents and all your connections, memory, tasks, files, and secrets.**
|
|
8
8
|
|
|
9
|
-
Connect your mail, calendar, files, and notes once, and add the memory and
|
|
10
|
-
have. Every agent you use — Claude, ChatGPT, and anything else that speaks MCP — reaches them
|
|
9
|
+
Connect your mail, calendar, files, and notes once, and add the memory, tasks, and procedures that
|
|
10
|
+
only you have. Every agent you use — Claude, ChatGPT, and anything else that speaks MCP — reaches them
|
|
11
11
|
through a single MCP endpoint that you own and run. Open source, self-hostable, no vendor sitting in
|
|
12
12
|
the middle of your data.
|
|
13
13
|
|
|
@@ -38,6 +38,9 @@ the middle of your data.
|
|
|
38
38
|
- **One set of notes behind every agent.** What you have Claude Code write down, claude.ai reads
|
|
39
39
|
back later — your accumulated context follows you between tools instead of being trapped in
|
|
40
40
|
whichever one recorded it.
|
|
41
|
+
- **A to-do list your agents share.** "Remind me to chase the invoice" lands somewhere with a
|
|
42
|
+
status, so whichever agent you are talking to next knows it is still open — and knows when it
|
|
43
|
+
is done.
|
|
41
44
|
- **Your own procedures, followed rather than guessed.** How a standup update reads, where an
|
|
42
45
|
invoice gets filed, who gets cc'd on a contract — written down once, and every agent you use
|
|
43
46
|
follows the same one.
|
|
@@ -62,9 +65,9 @@ ok registered lanes-link with Claude Code (user scope)
|
|
|
62
65
|
ok registered lanes-link with Codex
|
|
63
66
|
```
|
|
64
67
|
|
|
65
|
-
Your agents can now use it. Memory, skills, and the vault hold your own material
|
|
66
|
-
account, so
|
|
67
|
-
calendar are the next step. **[Full quickstart →](docs/quickstart.md)**
|
|
68
|
+
Your agents can now use it. Memory, tasks, files, skills, and the vault hold your own material
|
|
69
|
+
rather than an account, so they are already there — nothing to connect, no credentials, no browser.
|
|
70
|
+
Mail and calendar are the next step. **[Full quickstart →](docs/quickstart.md)**
|
|
68
71
|
|
|
69
72
|
## What your agent gets
|
|
70
73
|
|
|
@@ -72,11 +75,19 @@ calendar are the next step. **[Full quickstart →](docs/quickstart.md)**
|
|
|
72
75
|
|---|---|---|
|
|
73
76
|
| **Connections** | your external accounts — mail, calendar, files, issues | `lanes link connect` |
|
|
74
77
|
| **Memory** | what you want remembered between sessions | `lanes link memory` |
|
|
78
|
+
| **Tasks** | what you have to do, each with a status | `lanes link tasks` |
|
|
79
|
+
| **Assets** | files you want kept, by name | `lanes link assets` |
|
|
75
80
|
| **Skills** | your own procedures, handed to an agent as instructions | `lanes link skills` |
|
|
76
81
|
| **Vault** | passwords and API keys, released only where you allow it | `lanes link vault` |
|
|
77
82
|
|
|
78
|
-
|
|
79
|
-
|
|
83
|
+
The first five arrive switched on: they hold your own material rather than an account, so there was
|
|
84
|
+
never anything to authorise. Memory, tasks, and skills are plain Markdown files and an asset is
|
|
85
|
+
stored under its own filename, so a text editor and an agent reach the same bytes. Every one of them
|
|
86
|
+
belongs to a single profile: what you add under `work` is invisible under `personal`.
|
|
87
|
+
|
|
88
|
+
Which store a thing goes in is the one thing worth knowing. **Memory is what is true, tasks is what
|
|
89
|
+
is to be done, assets is a file.** "Remember to chase the invoice" is a task — filed as memory it
|
|
90
|
+
becomes a note nothing can ever close. Your agents are told this too.
|
|
80
91
|
|
|
81
92
|
Keep those two in a private GitHub repository instead of on this machine, and get history, diffs,
|
|
82
93
|
and the same notes from anywhere you run this:
|
|
@@ -100,7 +111,7 @@ One command per account. Run it again to add a second mailbox, a second calendar
|
|
|
100
111
|
| Google Sheets | `lanes link connect sheets` |
|
|
101
112
|
| Google Docs | `lanes link connect docs` |
|
|
102
113
|
| Google Calendar | `lanes link connect calendar` |
|
|
103
|
-
| Google Tasks | `lanes link connect
|
|
114
|
+
| Google Tasks | `lanes link connect google_tasks` |
|
|
104
115
|
| Google Contacts | `lanes link connect contacts` |
|
|
105
116
|
| iCloud Mail | `lanes link connect icloud_mail` |
|
|
106
117
|
| iCloud Calendar | `lanes link connect icloud_calendar` |
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: lanes-link-scout
|
|
3
|
-
description: Use when answering a question needs a wide read across someone's Lanes Link context — searching their memory,
|
|
3
|
+
description: Use when answering a question needs a wide read across someone's Lanes Link context — searching their memory, tasks, files, mail, and issues for the same thing and reporting back what is there. Good for "have I discussed X anywhere", "find everything about this client", "what do we already know about Y". Not for acting on what it finds: this agent reads and reports, and every write stays in the main thread.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Lanes Link scout
|
|
@@ -30,11 +30,22 @@ that answers the question, not the surrounding thread.
|
|
|
30
30
|
Ask the endpoint what it has rather than assuming. Its instructions name the
|
|
31
31
|
profiles and connections that are actually reachable.
|
|
32
32
|
|
|
33
|
-
**Start with memory
|
|
34
|
-
distillation, and
|
|
33
|
+
**Start with memory, then tasks.** Both are already the owner's own
|
|
34
|
+
distillation, and both are small enough to search several ways cheaply. Substring
|
|
35
35
|
matching, not ranked relevance — so try the obvious phrasing, then a synonym,
|
|
36
36
|
then a narrower fragment, before concluding something is not there.
|
|
37
37
|
|
|
38
|
+
Tasks are worth the second look rather than an afterthought: an open one is often
|
|
39
|
+
the actual answer to "what do we already know about X", and it carries something
|
|
40
|
+
memory cannot — that the matter is unfinished. Say so when it is, and say which
|
|
41
|
+
status: *blocked since June* is a different answer from *open*. `tasks.list`
|
|
42
|
+
hides finished work by default, so ask for `done` when the question is historical.
|
|
43
|
+
|
|
44
|
+
**Assets are a listing, not a corpus.** `assets.list` tells you a file exists,
|
|
45
|
+
its type and its size; only a text one reads back. Report that a document is
|
|
46
|
+
there and let the main thread decide what to do with it — do not try to get at
|
|
47
|
+
the contents of a binary, and never ask for it as base64.
|
|
48
|
+
|
|
38
49
|
**Then the accounts,** narrowing by whatever the provider gives you — a date
|
|
39
50
|
range, a label, a folder. A broad query returning two hundred results and a
|
|
40
51
|
narrow one returning none are both failures; move between them deliberately
|
|
@@ -40,9 +40,10 @@ failure. Four levels:
|
|
|
40
40
|
`lanes link deploy` and `lanes link sync targets` act on one endpoint serving
|
|
41
41
|
every profile that declares that target. `--profile` is accepted and *narrows*
|
|
42
42
|
the answer; it does not choose the subject.
|
|
43
|
-
- **Both.** Everything acting on one account
|
|
44
|
-
|
|
45
|
-
`lanes link
|
|
43
|
+
- **Both.** Everything acting on one account, and everything reaching the
|
|
44
|
+
owner's own stores: `lanes link connect`, `lanes link token rotate`,
|
|
45
|
+
`lanes link secrets set`, `lanes link policy allow`, `lanes link memory list`,
|
|
46
|
+
`lanes link tasks list`, `lanes link assets list`, `lanes link mcp add`.
|
|
46
47
|
|
|
47
48
|
`lanes link profile add` and `lanes link profile remove` **reject** `--profile`.
|
|
48
49
|
Both name their profile positionally, so a flag naming a second one could only
|
|
@@ -55,6 +56,30 @@ A `connection` names an account within that profile. One profile may hold
|
|
|
55
56
|
several of the same kind, and naming a connection belonging to a *different*
|
|
56
57
|
profile is refused rather than guessed at.
|
|
57
58
|
|
|
59
|
+
## Which store a thing goes in
|
|
60
|
+
|
|
61
|
+
Three of them hold what the owner keeps, and they divide by what a thing *is*.
|
|
62
|
+
Getting this wrong is the most common way to be unhelpful here, because nothing
|
|
63
|
+
refuses it — the write succeeds, in the wrong place, and stays there.
|
|
64
|
+
|
|
65
|
+
- **memory** — what is *true*. A fact, a preference, a decision, how something
|
|
66
|
+
works. It has no state, because a fact does not finish.
|
|
67
|
+
- **tasks** — what is to be *done*. It carries a status, so it can be closed.
|
|
68
|
+
- **assets** — a *file*. Bytes, kept under a name.
|
|
69
|
+
|
|
70
|
+
**"Remember to…" is a task, not a memory entry.** So are "add a todo", "don't
|
|
71
|
+
let me forget", "put this on my list", and anything with a deadline in it. Filed
|
|
72
|
+
as memory it becomes a note that nothing can ever close, and it will be read
|
|
73
|
+
back to them as a fact forever. The give-away is a verb: *chase the invoice* is
|
|
74
|
+
a task, *invoices are paid on the 1st* is memory.
|
|
75
|
+
|
|
76
|
+
The reverse matters too. **A fact with no action in it is not a task.** Filing
|
|
77
|
+
one as a task puts something on a list that can never legitimately be marked
|
|
78
|
+
done.
|
|
79
|
+
|
|
80
|
+
And **a procedure is neither** — see the next section. If they say "always do X
|
|
81
|
+
when Y", that is a skill they should write, not a memory entry describing it.
|
|
82
|
+
|
|
58
83
|
## Reach for memory before answering from nothing
|
|
59
84
|
|
|
60
85
|
`memory.search` before concluding you do not know something about this person or
|
|
@@ -66,6 +91,51 @@ every later session, including to a different agent, so **write when you are
|
|
|
66
91
|
asked to remember something, not as a habit.** The owner reaches the same
|
|
67
92
|
entries with `lanes link memory list --profile <name> --target <name>` and a text editor.
|
|
68
93
|
|
|
94
|
+
## Tasks have a status, so finish them rather than deleting them
|
|
95
|
+
|
|
96
|
+
`tasks.list` answers what is outstanding. It shows `in_progress`, `open` and
|
|
97
|
+
`blocked` and hides the rest, so a listing is what is left to do rather than
|
|
98
|
+
everything that ever was — name a status to see more.
|
|
99
|
+
|
|
100
|
+
Six of them, and the two that are easy to confuse are worth learning:
|
|
101
|
+
|
|
102
|
+
| | |
|
|
103
|
+
|---|---|
|
|
104
|
+
| `in_progress` | started |
|
|
105
|
+
| `open` | not started |
|
|
106
|
+
| `blocked` | waiting on something that is not the owner |
|
|
107
|
+
| `muted` | deliberately not being surfaced — they asked not to be reminded |
|
|
108
|
+
| `done` | finished |
|
|
109
|
+
| `dropped` | decided against, which is not the same as finished |
|
|
110
|
+
|
|
111
|
+
**Closing a task is `tasks.update` with a status, never `tasks.remove`.** The
|
|
112
|
+
record of having done it is the useful half, and it is what stops the same thing
|
|
113
|
+
being suggested again next week. Remove is for something recorded by mistake.
|
|
114
|
+
|
|
115
|
+
Do not mute a task on your own initiative. It means "stop telling me about
|
|
116
|
+
this", which is the owner's judgement, not yours.
|
|
117
|
+
|
|
118
|
+
They manage these with `lanes link tasks list --profile <name> --target <name>`.
|
|
119
|
+
|
|
120
|
+
## Assets are files kept by name
|
|
121
|
+
|
|
122
|
+
Storing one names a source and the endpoint reads the bytes — the same five
|
|
123
|
+
sources the attachments section below describes, and the same rule: **never
|
|
124
|
+
encode a file into the call.** The name is the address; there is no id and no
|
|
125
|
+
description, so what a file is *for* belongs in memory, next to its name.
|
|
126
|
+
|
|
127
|
+
Reading gives you text when the file is text. Anything else comes back described
|
|
128
|
+
— name, type, size, digest — and that is not a refusal to work around. There is
|
|
129
|
+
no form of a read that hands you a PDF, and a megabyte of base64 in the
|
|
130
|
+
conversation would not help you if there were.
|
|
131
|
+
|
|
132
|
+
To attach a stored file to something you are sending, ask the owner to run
|
|
133
|
+
`lanes link attach <file> --profile <name> --target <name> --connection <provider>.<account>`,
|
|
134
|
+
which prints a handle the send tools take. An asset's own store is not reachable
|
|
135
|
+
from a mailbox's, deliberately.
|
|
136
|
+
|
|
137
|
+
They manage these with `lanes link assets list --profile <name> --target <name>`.
|
|
138
|
+
|
|
69
139
|
## Skills are theirs, not yours
|
|
70
140
|
|
|
71
141
|
A skill is a procedure the owner wrote down. They arrive as prompts — slash
|
|
@@ -164,6 +234,13 @@ before suggesting that anything be connected. It names the accounts reachable in
|
|
|
164
234
|
profile and the providers that are not connected yet. `setup_provider` then gives
|
|
165
235
|
one provider's console steps, the values it will ask for, and the exact command.
|
|
166
236
|
|
|
237
|
+
**Memory, tasks, assets, skills and the vault need no setup at all.** They hold
|
|
238
|
+
the owner's own material rather than an account, so a profile arrives with all
|
|
239
|
+
five already reachable — there is no command to run and nothing to connect. If
|
|
240
|
+
one is missing, it was switched off with a `deny`, which is their decision; do not
|
|
241
|
+
offer to connect it. What setup is for is accounts: mail, calendar, files,
|
|
242
|
+
issues.
|
|
243
|
+
|
|
167
244
|
**Take the command from `setup_provider`; never compose one yourself.** It carries
|
|
168
245
|
the right profile and, where the provider stores a credential per account, the
|
|
169
246
|
`--id` it needs. A command you assembled is one the owner pastes and watches fail.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lanes-sh/link",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "A self-hostable MCP gateway for all your connections, memory,
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "A self-hostable MCP gateway for all your connections, memory, tasks, files, and secrets",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://lanes.sh/link",
|
|
7
7
|
"repository": {
|
package/src/cli/argv.ts
CHANGED
|
@@ -117,6 +117,13 @@ export function ownerFlags(flags: Flags): OwnerFlags {
|
|
|
117
117
|
tag: text(flags, 'tag'),
|
|
118
118
|
description: text(flags, 'description'),
|
|
119
119
|
file: text(flags, 'file'),
|
|
120
|
+
status: text(flags, 'status'),
|
|
121
|
+
// `text` keeps an empty string, which matters for exactly this flag:
|
|
122
|
+
// `--due ""` is how `tasks update` clears a date, and `undefined` is how it
|
|
123
|
+
// leaves one alone. The two are different intentions.
|
|
124
|
+
due: text(flags, 'due'),
|
|
125
|
+
name: text(flags, 'name'),
|
|
126
|
+
contentType: text(flags, 'content-type'),
|
|
120
127
|
yes: flags['yes'] === true,
|
|
121
128
|
};
|
|
122
129
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { credentialRefForConnection, WRITE_BUNDLE } from '#connectivity';
|
|
2
2
|
import { ConfigDocument } from '../../config-edit.ts';
|
|
3
|
-
import {
|
|
3
|
+
import { ensureOwnerLayer, repaired } from '../../config-repair.ts';
|
|
4
4
|
import { emit, print } from '../../output.ts';
|
|
5
5
|
import { nonInteractivePrompter, terminalPrompter, type Prompter } from '../../prompt.ts';
|
|
6
6
|
import { openRuntime, type GlobalFlags } from '../../runtime.ts';
|
|
@@ -334,26 +334,29 @@ export async function runConnect(
|
|
|
334
334
|
// 5. Grant it — one rule per provider; `grant.ts` says why not per capability.
|
|
335
335
|
const granted = grantProvider(document, runtime.config.policy.allow, providerId);
|
|
336
336
|
|
|
337
|
-
// 6. Repair the
|
|
337
|
+
// 6. Repair the owner layer if this profile predates it.
|
|
338
338
|
//
|
|
339
339
|
// Connecting is the moment it matters: the operator is adding something
|
|
340
340
|
// an agent will be asked about, and a profile with no `setup` row serves
|
|
341
341
|
// no `setup_overview` — so the agent has nothing to read and invents a
|
|
342
342
|
// command instead. `doctor` reports this, but a deployed operator never
|
|
343
|
-
// runs it, which is how it stayed broken.
|
|
343
|
+
// runs it, which is how it stayed broken. The same argument now covers
|
|
344
|
+
// memory, tasks, assets, skills and the vault (ADR-050): a profile
|
|
345
|
+
// written before they were default has no rows for them, and none of them
|
|
346
|
+
// reaches an account, so there is nothing for the operator to decide.
|
|
344
347
|
// Each half goes to the field that is for it. `emit` serialises both
|
|
345
348
|
// verbatim under `--json`: `changes` is a list of config edits, so a
|
|
346
349
|
// sentence in it is something a caller counting edits has to recognise
|
|
347
350
|
// and skip, and `granted` is the field that answers "what did this widen"
|
|
348
351
|
// — an audit reading it would have missed `setup.*` entirely.
|
|
349
|
-
const repair =
|
|
352
|
+
const repair = ensureOwnerLayer(document);
|
|
350
353
|
changes.push(...repair.changes);
|
|
351
354
|
granted.push(...repair.granted);
|
|
352
355
|
|
|
353
356
|
// The explanation is prose, so it goes where prose goes. Without it the
|
|
354
|
-
// operator has
|
|
357
|
+
// operator has a block of lines naming providers they never asked for.
|
|
355
358
|
const notes = repaired(repair)
|
|
356
|
-
? ['that is
|
|
359
|
+
? ['that is your own memory, tasks, assets, skills and vault — no account, nothing stored until you use them']
|
|
357
360
|
: [];
|
|
358
361
|
|
|
359
362
|
if (changes.length === 0 && granted.length === 0) {
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { RESERVED_PROVIDER_IDS } from '#connectivity';
|
|
2
|
+
import { credentialRefFor } from '#registry';
|
|
3
|
+
import type { ConnectionConfig, Config, Resolution } from '#profile';
|
|
4
|
+
import { ConfigDocument } from '../config-edit.ts';
|
|
5
|
+
import { announce, emit, ok, print, style, warn } from '../output.ts';
|
|
6
|
+
import { openRuntime, type GlobalFlags } from '../runtime.ts';
|
|
7
|
+
import { nextAfterEdit, publishProfileEdit } from '../publish.ts';
|
|
8
|
+
import { confirm, isInteractive } from '../prompt.ts';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `lanes link disconnect` and `lanes link relabel` — the two edits to an
|
|
12
|
+
* existing connection that were not possible without opening the YAML.
|
|
13
|
+
*
|
|
14
|
+
* Both are control-plane commands under ADR-007: they write the profile config,
|
|
15
|
+
* and `disconnect` also deletes from the target's credential store. Neither is
|
|
16
|
+
* reachable through MCP, for the same reason `connect` is not.
|
|
17
|
+
*
|
|
18
|
+
* `disconnect` is the counterpart to `connect`, and deliberately not "delete":
|
|
19
|
+
* the state record is left alone. Reconcile marks an undeclared connection
|
|
20
|
+
* `disabled` rather than deleting it so the audit log keeps meaning something,
|
|
21
|
+
* and a command that reached past that to erase the record would be undoing the
|
|
22
|
+
* one guarantee the audit log offers.
|
|
23
|
+
*
|
|
24
|
+
* Each command is a data function plus a printing wrapper, the shape
|
|
25
|
+
* `commands/identity.ts` uses: `--json` needs the facts without the rendering.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** Where a connection is, and what it says, before anything is changed. */
|
|
29
|
+
interface Located {
|
|
30
|
+
readonly index: number;
|
|
31
|
+
readonly connection: ConnectionConfig;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface Disconnected {
|
|
35
|
+
readonly profile: string;
|
|
36
|
+
readonly target: string;
|
|
37
|
+
readonly key: string;
|
|
38
|
+
readonly account: string;
|
|
39
|
+
/**
|
|
40
|
+
* The credential reference this removed, if it removed one. `null` when the
|
|
41
|
+
* provider has none (the owner layer), when `--keep-credential` was passed, or
|
|
42
|
+
* when a sibling connection still resolves to the same reference.
|
|
43
|
+
*/
|
|
44
|
+
readonly credential: string | null;
|
|
45
|
+
/** Set when the credential was left because something else still needs it. */
|
|
46
|
+
readonly credentialSharedWith: readonly string[];
|
|
47
|
+
readonly remaining: number;
|
|
48
|
+
readonly published: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface Relabelled {
|
|
52
|
+
readonly profile: string;
|
|
53
|
+
readonly target: string;
|
|
54
|
+
readonly key: string;
|
|
55
|
+
readonly from: string;
|
|
56
|
+
readonly to: string;
|
|
57
|
+
readonly published: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface DisconnectFlags extends GlobalFlags {
|
|
61
|
+
readonly yes?: boolean | undefined;
|
|
62
|
+
readonly keepCredential?: boolean | undefined;
|
|
63
|
+
readonly json?: boolean | undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface RelabelFlags extends GlobalFlags {
|
|
67
|
+
readonly json?: boolean | undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Find the one connection a key names.
|
|
72
|
+
*
|
|
73
|
+
* The key must be exact — `gmail.main`, never `gmail`. `connect` accepts the
|
|
74
|
+
* bare provider because it can then create an account and choose the id; there
|
|
75
|
+
* is nothing to choose here, and a bare `gmail` with two accounts declared would
|
|
76
|
+
* be a command guessing which one to throw away.
|
|
77
|
+
*/
|
|
78
|
+
function locate(config: Config, key: string, profile: string): Located {
|
|
79
|
+
if (!key.includes('.')) {
|
|
80
|
+
const matches = config.connections.filter((one) => one.provider === key);
|
|
81
|
+
throw new Error(
|
|
82
|
+
`"${key}" names a provider, not a connection.\n` +
|
|
83
|
+
(matches.length > 0
|
|
84
|
+
? ` This profile declares ${matches.map((one) => `${one.provider}.${one.id}`).join(', ')}.\n`
|
|
85
|
+
: '') +
|
|
86
|
+
` Run: lanes link status --profile ${profile}`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const index = config.connections.findIndex((one) => `${one.provider}.${one.id}` === key);
|
|
91
|
+
if (index === -1) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`Profile "${profile}" does not declare "${key}".\n` +
|
|
94
|
+
` Run: lanes link status --profile ${profile}`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { index, connection: config.connections[index] as ConnectionConfig };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Refuse for a reserved provider, and say what to do instead.
|
|
103
|
+
*
|
|
104
|
+
* `memory`, `skills`, `vault`, `setup` and `identity` are not accounts — they are
|
|
105
|
+
* what the profile is *for*, they hold no credential, and each is granted by a
|
|
106
|
+
* policy line this command does not touch. Removing the connection alone would
|
|
107
|
+
* leave the policy granting `memory.*` against nothing: a config that is wrong
|
|
108
|
+
* rather than merely untidy, which is the same reason `knowledge use` takes its
|
|
109
|
+
* own block back. Hand-editing is the honest path and the file says so.
|
|
110
|
+
*/
|
|
111
|
+
function refuseReserved(key: string, provider: string, path: string): void {
|
|
112
|
+
if (!RESERVED_PROVIDER_IDS.includes(provider)) return;
|
|
113
|
+
throw new Error(
|
|
114
|
+
`"${key}" is part of what this profile is, not an account it holds.\n` +
|
|
115
|
+
` ${provider} keeps no credential, and its policy grant is a separate line this command does not touch.\n` +
|
|
116
|
+
` To remove it, delete both from ${path} by hand.`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Which other connections resolve to the same credential reference.
|
|
122
|
+
*
|
|
123
|
+
* The check exists because a reference is not always per connection. An OAuth
|
|
124
|
+
* provider derives `<provider>/<id>` and is safe, but a manifest declaring
|
|
125
|
+
* `credential_ref: mything/api_key` shares one reference across every connection
|
|
126
|
+
* of that provider — so deleting it while a sibling still resolves to it would
|
|
127
|
+
* take that sibling's credential with it, and the sibling would report
|
|
128
|
+
* `unauthorized` for a `connect` nobody ran.
|
|
129
|
+
*/
|
|
130
|
+
export function connectionsSharingCredential(
|
|
131
|
+
config: Config,
|
|
132
|
+
ref: string,
|
|
133
|
+
exceptIndex: number,
|
|
134
|
+
manifestFor: (provider: string) => Parameters<typeof credentialRefFor>[1],
|
|
135
|
+
): string[] {
|
|
136
|
+
return config.connections
|
|
137
|
+
.filter((one, i) => i !== exceptIndex && credentialRefFor(one, manifestFor(one.provider)) === ref)
|
|
138
|
+
.map((one) => `${one.provider}.${one.id}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function removeConnection(
|
|
142
|
+
key: string,
|
|
143
|
+
flags: DisconnectFlags,
|
|
144
|
+
): Promise<{ resolution: Resolution; disconnected: Disconnected } | null> {
|
|
145
|
+
const runtime = await openRuntime(flags);
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
const { resolution, config, target } = runtime;
|
|
149
|
+
const located = locate(config, key, resolution.profile);
|
|
150
|
+
const document = await ConfigDocument.open(resolution.workspaceRoot, resolution.profile);
|
|
151
|
+
|
|
152
|
+
refuseReserved(key, located.connection.provider, document.path);
|
|
153
|
+
|
|
154
|
+
if (!(await confirmed(key, flags))) return null;
|
|
155
|
+
|
|
156
|
+
const manifestFor = (provider: string) => runtime.registry.get(provider)?.manifest;
|
|
157
|
+
const ref = credentialRefFor(located.connection, manifestFor(located.connection.provider));
|
|
158
|
+
const shared = ref ? connectionsSharingCredential(config, ref, located.index, manifestFor) : [];
|
|
159
|
+
|
|
160
|
+
// The config edit first. If deleting the credential fails, a connection left
|
|
161
|
+
// declared with no credential reports `unauthorized`, which is recoverable by
|
|
162
|
+
// running `connect`. The reverse — credential gone, declaration kept, edit
|
|
163
|
+
// failed — is the same state, so ordering costs nothing either way; doing the
|
|
164
|
+
// edit first means the file is right even if the store is unreachable.
|
|
165
|
+
document.removeFrom(['connections'], located.index);
|
|
166
|
+
await document.save();
|
|
167
|
+
|
|
168
|
+
let removed: string | null = null;
|
|
169
|
+
if (ref && shared.length === 0 && flags.keepCredential !== true) {
|
|
170
|
+
await runtime.credentials.delete(ref);
|
|
171
|
+
removed = ref;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
resolution,
|
|
176
|
+
disconnected: {
|
|
177
|
+
profile: resolution.profile,
|
|
178
|
+
target,
|
|
179
|
+
key,
|
|
180
|
+
account: located.connection.account,
|
|
181
|
+
credential: removed,
|
|
182
|
+
credentialSharedWith: shared,
|
|
183
|
+
remaining: config.connections.length - 1,
|
|
184
|
+
published: nextAfterEdit(await publishProfileEdit({ resolution, config, target })),
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
} finally {
|
|
188
|
+
await runtime.close();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** A plain y/N. Not `profile remove`'s type-the-name, which guards the
|
|
193
|
+
* destruction of whole stores; this takes back one authorisation that `connect`
|
|
194
|
+
* can grant again. */
|
|
195
|
+
async function confirmed(key: string, flags: DisconnectFlags): Promise<boolean> {
|
|
196
|
+
if (flags.yes === true) return true;
|
|
197
|
+
if (!isInteractive()) {
|
|
198
|
+
throw new Error(
|
|
199
|
+
`Disconnecting "${key}" deletes its credential, and stdin is not a terminal, so there is nobody to ask.\n` +
|
|
200
|
+
` Pass --yes to proceed.`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
// Defaults to no: this deletes a credential, and a stray return should not.
|
|
204
|
+
return confirm(`Disconnect ${key} and delete its credential?`, false);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export async function disconnect(key: string | undefined, flags: DisconnectFlags): Promise<void> {
|
|
208
|
+
if (!key) throw new Error('Which connection? Run: lanes link status');
|
|
209
|
+
|
|
210
|
+
const outcome = await removeConnection(key, flags);
|
|
211
|
+
if (outcome === null) {
|
|
212
|
+
print(warn('nothing was changed'));
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const { resolution, disconnected: result } = outcome;
|
|
216
|
+
|
|
217
|
+
return emit(flags.json, result, () => {
|
|
218
|
+
announce(resolution);
|
|
219
|
+
print(ok(`disconnected ${style.bold(result.key)}${result.account ? ` (${result.account})` : ''}`));
|
|
220
|
+
|
|
221
|
+
if (result.credential) {
|
|
222
|
+
print(` credential ${style.dim(result.credential)} deleted`);
|
|
223
|
+
} else if (result.credentialSharedWith.length > 0) {
|
|
224
|
+
// Named rather than silent: the operator asked for a credential to go and
|
|
225
|
+
// it did not, and the reason is a fact about their config.
|
|
226
|
+
print(
|
|
227
|
+
` credential kept — ${result.credentialSharedWith.join(', ')} still ${
|
|
228
|
+
result.credentialSharedWith.length === 1 ? 'resolves' : 'resolve'
|
|
229
|
+
} to it`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
print(style.dim(` ${result.remaining} connection(s) left in this profile.`));
|
|
234
|
+
// The state record survives on purpose, and the next reconcile is what marks
|
|
235
|
+
// it disabled. Saying so stops "it is still in `status`" reading as a failure.
|
|
236
|
+
print(
|
|
237
|
+
style.dim(
|
|
238
|
+
' The state record stays until the next reconcile, which marks it disabled rather than deleting it.',
|
|
239
|
+
),
|
|
240
|
+
);
|
|
241
|
+
if (result.published) print(style.dim(` ${result.published}`));
|
|
242
|
+
})
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export async function renameConnection(
|
|
246
|
+
key: string,
|
|
247
|
+
account: string,
|
|
248
|
+
flags: RelabelFlags,
|
|
249
|
+
): Promise<{ resolution: Resolution; relabelled: Relabelled }> {
|
|
250
|
+
const runtime = await openRuntime(flags);
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
const { resolution, config, target } = runtime;
|
|
254
|
+
const located = locate(config, key, resolution.profile);
|
|
255
|
+
const document = await ConfigDocument.open(resolution.workspaceRoot, resolution.profile);
|
|
256
|
+
|
|
257
|
+
document.setIn(['connections', located.index, 'account'], account);
|
|
258
|
+
await document.save();
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
resolution,
|
|
262
|
+
relabelled: {
|
|
263
|
+
profile: resolution.profile,
|
|
264
|
+
target,
|
|
265
|
+
key,
|
|
266
|
+
from: located.connection.account,
|
|
267
|
+
to: account,
|
|
268
|
+
published: nextAfterEdit(await publishProfileEdit({ resolution, config, target })),
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
} finally {
|
|
272
|
+
await runtime.close();
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export async function relabel(
|
|
277
|
+
key: string | undefined,
|
|
278
|
+
account: string | undefined,
|
|
279
|
+
flags: RelabelFlags,
|
|
280
|
+
): Promise<void> {
|
|
281
|
+
if (!key) throw new Error('Which connection? Run: lanes link status');
|
|
282
|
+
if (!account) throw new Error(`What should ${key} be called? Run: lanes link relabel ${key} "New name"`);
|
|
283
|
+
|
|
284
|
+
const { resolution, relabelled: result } = await renameConnection(key, account, flags);
|
|
285
|
+
|
|
286
|
+
return emit(flags.json, result, () => {
|
|
287
|
+
announce(resolution);
|
|
288
|
+
print(ok(`${style.bold(result.key)} is now ${style.bold(result.to)}`));
|
|
289
|
+
if (result.from) print(` was ${style.dim(result.from)}`);
|
|
290
|
+
// The label is a display name in two places, and only one of them changed.
|
|
291
|
+
print(
|
|
292
|
+
style.dim(
|
|
293
|
+
' The state store keeps the old name until the next reconcile, which updates it.',
|
|
294
|
+
),
|
|
295
|
+
);
|
|
296
|
+
if (result.published) print(style.dim(` ${result.published}`));
|
|
297
|
+
})
|
|
298
|
+
}
|