@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.
Files changed (48) hide show
  1. package/README.md +20 -9
  2. package/instructions/agents/lanes-link-scout.md +14 -3
  3. package/instructions/skills/lanes-link/SKILL.md +80 -3
  4. package/package.json +2 -2
  5. package/src/cli/argv.ts +7 -0
  6. package/src/cli/commands/connect/index.ts +9 -6
  7. package/src/cli/commands/connection.ts +298 -0
  8. package/src/cli/commands/mcp/list.ts +123 -29
  9. package/src/cli/commands/operate/inspect.ts +37 -20
  10. package/src/cli/commands/operate/serve.ts +21 -0
  11. package/src/cli/commands/owner/assets.ts +132 -0
  12. package/src/cli/commands/owner/shared.ts +28 -4
  13. package/src/cli/commands/owner/tasks.ts +194 -0
  14. package/src/cli/commands/owner.ts +9 -4
  15. package/src/cli/config-edit.ts +33 -7
  16. package/src/cli/config-repair.ts +115 -11
  17. package/src/cli/dispatch-owner.ts +49 -8
  18. package/src/cli/lanes.ts +1 -1
  19. package/src/cli/main.ts +26 -3
  20. package/src/cli/provider-marks.ts +1 -1
  21. package/src/cli/runtime/registry.ts +10 -2
  22. package/src/cli/selection.ts +14 -0
  23. package/src/cli/usage.ts +18 -2
  24. package/src/connectivity/mail/attachments.ts +5 -1
  25. package/src/connectivity/mail/index.ts +6 -1
  26. package/src/connectivity/manifest/provider.ts +15 -2
  27. package/src/deployments/deploy.ts +3 -2
  28. package/src/deployments/prepare.ts +1 -1
  29. package/src/deployments/servable.ts +1 -1
  30. package/src/deployments/upload.ts +0 -53
  31. package/src/profile/load.ts +46 -0
  32. package/src/providers/assets/provider.ts +337 -0
  33. package/src/providers/assets/store.ts +167 -0
  34. package/src/providers/bunq/hints.ts +3 -1
  35. package/src/providers/bunq/redact.ts +13 -2
  36. package/src/providers/bunq/specs/bunq.v1.json +20 -1
  37. package/src/providers/bunq/specs/vendor.ts +59 -1
  38. package/src/providers/google/index.ts +1 -1
  39. package/src/providers/google/tasks/index.ts +3 -3
  40. package/src/providers/google/tasks/redact.ts +21 -11
  41. package/src/providers/index.ts +3 -3
  42. package/src/providers/owner.ts +39 -19
  43. package/src/providers/setup/plan.ts +17 -1
  44. package/src/providers/shared/vendor-operations.ts +81 -0
  45. package/src/providers/tasks/provider.ts +370 -0
  46. package/src/providers/tasks/store.ts +248 -0
  47. package/src/server/mcp/build.ts +1 -1
  48. package/src/server/mcp/instructions.ts +67 -8
package/README.md CHANGED
@@ -4,10 +4,10 @@
4
4
  [![license Apache-2.0](https://img.shields.io/github/license/lanes-sh/link?style=flat-square&color=black)](LICENSE)
5
5
  [![ci](https://img.shields.io/github/actions/workflow/status/lanes-sh/link/ci.yml?branch=main&style=flat-square&label=ci)](https://github.com/lanes-sh/link/actions/workflows/ci.yml)
6
6
 
7
- **One secure endpoint between your AI agents and all your connections, memory, skills, and secrets.**
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 skills that only you
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 rather than an
66
- account, so switching them on costs nothing one command each, no credentials, no browser. Mail and
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 therenothing 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
- Memory and skills are plain Markdown files, so a text editor and an agent reach the same bytes. All
79
- four belong to one profile: what you add under `work` is invisible under `personal`.
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 tasks` |
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, mail, files, 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.
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.** It is the only source that is already the owner's own
34
- distillation, and it is small enough to search several ways cheaply. Substring
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: `lanes link connect`,
44
- `lanes link token rotate`, `lanes link secrets set`, `lanes link policy allow`,
45
- `lanes link memory list`, `lanes link mcp add`.
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.0",
4
- "description": "A self-hostable MCP gateway for all your connections, memory, skills, and secrets",
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 { ensureSetupConnection, repaired } from '../../config-repair.ts';
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 setup surface if this profile predates it.
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 = ensureSetupConnection(document);
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 two lines naming a provider they never asked for.
357
+ // operator has a block of lines naming providers they never asked for.
355
358
  const notes = repaired(repair)
356
- ? ['that is the setup surfaceit lets an agent see what is connected here']
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
+ }