@lanes-sh/link 0.5.3 → 0.6.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 (59) hide show
  1. package/instructions/agents/lanes-link-scout.md +1 -1
  2. package/instructions/skills/lanes-link/SKILL.md +33 -29
  3. package/package.json +2 -1
  4. package/src/cli/commands/connect/custom/index.ts +2 -0
  5. package/src/cli/commands/connect/custom/spec.ts +1 -0
  6. package/src/cli/commands/connect/declare.ts +76 -0
  7. package/src/cli/commands/connect/family.ts +19 -3
  8. package/src/cli/commands/connect/index.ts +23 -24
  9. package/src/cli/commands/connect/outcome.ts +8 -0
  10. package/src/cli/commands/connect/settle.ts +94 -22
  11. package/src/cli/commands/connect/target-note.ts +2 -2
  12. package/src/cli/commands/connection.ts +18 -7
  13. package/src/cli/commands/knowledge/index.ts +14 -34
  14. package/src/cli/commands/mcp/register.ts +1 -1
  15. package/src/cli/commands/operate/dashboard.ts +2 -2
  16. package/src/cli/commands/operate/inspect.ts +5 -1
  17. package/src/cli/commands/operate/migrate.ts +85 -1
  18. package/src/cli/commands/operate/outputs.ts +7 -22
  19. package/src/cli/commands/operate/status.ts +95 -70
  20. package/src/cli/commands/operate/tools.ts +1 -1
  21. package/src/cli/commands/profile/removal.ts +36 -25
  22. package/src/cli/commands/profile/remove.ts +5 -2
  23. package/src/cli/commands/profile.ts +56 -40
  24. package/src/cli/commands/sync.ts +94 -162
  25. package/src/cli/commands/target.ts +115 -74
  26. package/src/cli/commands/update.ts +56 -1
  27. package/src/cli/config-edit.ts +47 -16
  28. package/src/cli/dashboard-page.ts +10 -1
  29. package/src/cli/endpoint-url.ts +3 -3
  30. package/src/cli/main.ts +11 -7
  31. package/src/cli/publish.ts +4 -2
  32. package/src/cli/runtime/open.ts +20 -10
  33. package/src/cli/runtime/select.ts +69 -22
  34. package/src/cli/selection-require.ts +79 -0
  35. package/src/cli/selection.ts +45 -92
  36. package/src/cli/usage.ts +3 -0
  37. package/src/cli/workspace-migrate.ts +385 -0
  38. package/src/deployments/bootstrap.ts +31 -11
  39. package/src/deployments/deploy.ts +54 -27
  40. package/src/deployments/knowledge.ts +5 -2
  41. package/src/deployments/prepare.ts +3 -1
  42. package/src/deployments/serving.ts +19 -15
  43. package/src/deployments/upload.ts +10 -1
  44. package/src/profile/deployments.ts +64 -53
  45. package/src/profile/index.ts +22 -6
  46. package/src/profile/legacy.ts +92 -0
  47. package/src/profile/load.ts +12 -28
  48. package/src/profile/registry.ts +182 -0
  49. package/src/profile/schema.ts +171 -110
  50. package/src/profile/targets.ts +62 -91
  51. package/src/profile/testing.ts +78 -0
  52. package/src/profile/workspace.ts +11 -25
  53. package/src/providers/setup/provider.ts +10 -2
  54. package/src/server/dashboard.ts +5 -1
  55. package/src/server/harness.ts +1 -6
  56. package/src/cli/commands/profile/declare.ts +0 -154
  57. package/src/deployments/servable.ts +0 -82
  58. package/src/deployments/sync-apply.ts +0 -330
  59. package/src/deployments/sync.ts +0 -164
@@ -68,7 +68,7 @@ stops a write is policy on the endpoint:
68
68
 
69
69
  ```console
70
70
  $ lanes link policy deny memory.write --profile <name> --target <name>
71
- $ lanes link policy list --profile <name>
71
+ $ lanes link policy list --profile <name> --target <name>
72
72
  ```
73
73
 
74
74
  If you are running against a profile that grants writes, that is the owner's
@@ -22,7 +22,9 @@ and personal apart; they share no database and no credential store.
22
22
  **Ask which profile is meant when it is ambiguous. Do not default to whichever
23
23
  is listed first.** Quietly picking one crosses the line the profile exists to
24
24
  draw. There is no "current profile" to switch — the choice is made per call, and
25
- `lanes link profile list` shows what exists.
25
+ `lanes link profile list --target <name>` shows what exists *in that target*. A
26
+ profile lives in exactly one, so `personal` on `local` and `personal` on `cloud`
27
+ are two profiles that share a name rather than one profile in two places.
26
28
 
27
29
  **What a command must be told is never inferred — but it is not always both.**
28
30
  Nothing resolves from an environment variable or a config default, so a command
@@ -30,12 +32,11 @@ missing what it needs refuses rather than acting somewhere else. Passing a flag
30
32
  command does not read is refused too, which makes "add both to be safe" its own
31
33
  failure. Four levels:
32
34
 
33
- - **Neither.** `lanes link profile list`, `lanes link mcp list`,
35
+ - **Neither.** `lanes link target list`, `lanes link mcp list`,
34
36
  `lanes link version`.
35
- - **`--profile` alone.** `lanes link check`, `lanes link config show`,
36
- `lanes link policy list`, `lanes link target list --profile <name>`,
37
- `lanes link identity list`. Each is target-independent one declaration in the
38
- YAML that applies wherever the profile runs.
37
+ - **`--target` alone.** `lanes link profile list`, `lanes link profile add`,
38
+ `lanes link profile remove`, `lanes link target show`. A profile lives inside
39
+ one target's workspace, so listing or creating one names which workspace.
39
40
  - **`--target`, with the profiles derived from it.** `lanes link status`,
40
41
  `lanes link deploy` and `lanes link sync targets` act on one endpoint serving
41
42
  every profile that declares that target. `--profile` is accepted and *narrows*
@@ -222,7 +223,7 @@ granted, and retrying will not reveal it. A call that *is* refused was refused b
222
223
  policy on purpose.
223
224
 
224
225
  Report it plainly and let the owner decide whether to widen the grant —
225
- `lanes link policy list --profile <name>` shows the rules, `lanes link policy allow <capability> --profile <name> --target <name>`
226
+ `lanes link policy list --profile <name> --target <name>` shows the rules, `lanes link policy allow <capability> --profile <name> --target <name>`
226
227
  changes them, and that is their call, not yours. **Do not look for another route
227
228
  to the same data.** Every call is audited either way; `lanes link audit tail --profile <name> --target <name>`
228
229
  shows what was attempted, refusals included.
@@ -299,10 +300,12 @@ key on — do not treat the absence of JSON as a failure. `status`, `doctor`,
299
300
  `config show`, `audit tail` and every `mcp` subcommand do not.
300
301
 
301
302
  **A profile is created and removed, never switched.** `lanes link profile add
302
- <name>` writes a new one and takes `--target <name>`, repeated once per target it
303
- should declare. `lanes link profile remove <name>` takes `--dry-run` and then
304
- `--yes`; given a `--target` it decommissions that one target's stores and leaves
305
- the profile file in place. Neither reads `--profile`.
303
+ <name> --target <name>` writes a new one *into that target's workspace* one
304
+ target, not a list, because a profile lives in exactly one.
305
+ `lanes link profile remove <name> --target <name>` takes `--dry-run` and then
306
+ `--yes`, and removes the profile itself along with its stores: the file is in
307
+ that workspace, so there is nowhere left for it to survive. Neither reads
308
+ `--profile`; both name the profile positionally.
306
309
 
307
310
  There is no current profile and no default target. `lanes link profile default`
308
311
  and `lanes link target use` are gone and now refuse with an explanation — if you
@@ -312,9 +315,9 @@ replacement to find. The choice is made per command, on purpose.
312
315
  ## Deploying, and what it decides
313
316
 
314
317
  `lanes link deploy` builds an image and rolls a revision. Its subject is a
315
- **target**, and the profiles behind it are every profile declaring that target,
316
- so one deploy serves all of them — there is no per-profile deploy to run and no
317
- reason to loop over them.
318
+ **target**, and the profiles behind it are every profile *in* that target's
319
+ workspace, so one deploy serves all of them — there is no per-profile deploy to
320
+ run and no reason to loop over them.
318
321
 
319
322
  **Always `--dry-run` first, and show what it printed.** It creates cloud
320
323
  resources that cost money, it implements no `--json` to inspect instead, and that
@@ -326,9 +329,9 @@ Two things it refuses to guess, and both are the owner's to answer:
326
329
  - **Whose bearer token opens the endpoint.** One token reaches every profile
327
330
  behind that target, so this decides who gets in. With several candidates and
328
331
  nothing recorded, it refuses and prints the command that names one.
329
- - **A first deploy.** A target no profile declares yet has no set to derive from,
330
- so `--profile` is required there. It may be repeated, and the first one named
331
- is the primary.
332
+ - **A first deploy.** A target that does not exist yet has no workspace to derive
333
+ a set from, so `--profile` is required there. It may be repeated, and the first
334
+ one named is the primary.
332
335
 
333
336
  **Never pass `--yes`, `--non-interactive`, `--access public` or
334
337
  `--service-account` yourself.** Each settles a question about who can reach their
@@ -338,22 +341,23 @@ this file already applies to `--accept-broad-scopes`.
338
341
  Deploying is how new code reaches the endpoint. It is not how an account gets
339
342
  connected and not how a config change lands; both of those publish themselves.
340
343
 
341
- ## When the workspace and the endpoint disagree
344
+ ## When a machine has lost track of a deployment
342
345
 
343
- A deployment records where it lives, and a workspace can lose that record a new
344
- machine, a reinstall, a profile file restored from something older. The endpoint
345
- is still serving; what went missing is the config that describes it. The symptom
346
- is a `lanes link status` that reports nothing deployed for a target you know is
347
- up.
346
+ A workspace holds a *pointer* to each target it does not itself hold, and a
347
+ machine can lose one — a new laptop, a reinstall, a workspace file restored from
348
+ something older. The endpoint is still serving; what went missing is the line
349
+ saying where it lives. The symptom is a `lanes link status` that reports nothing
350
+ for a target you know is up.
348
351
 
349
- `lanes link sync targets` reconciles the two. `--discover` looks for a deployment
350
- the workspace has no record of, `--from <location>` names one directly, and
351
- `--dry-run` reports what it would merge without merging it. Run the dry run and
352
+ `lanes link sync targets` adopts it. `--discover` looks for a deployment the
353
+ workspace has no pointer to, `--from <location>` names one directly, and
354
+ `--dry-run` reports what it would write without writing it. Run the dry run and
352
355
  show it.
353
356
 
354
- **`--prefer local` or `--prefer remote` is their answer, not yours.** It decides
355
- which side wins where the two disagree, and the losing value is the one nobody
356
- was asked about. Report what differs and let them pick.
357
+ **It cannot lose anything.** Adopting writes one line the pointer — and the
358
+ workspace at the other end stays authoritative for everything else. There is no
359
+ `--prefer` any more and passing one is refused: it chose a winner when a profile
360
+ existed in two copies that could disagree, and there is one copy now.
357
361
 
358
362
  ## Registering it, and re-registering it
359
363
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lanes-sh/link",
3
- "version": "0.5.3",
3
+ "version": "0.6.0",
4
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",
@@ -66,6 +66,7 @@
66
66
  "#dispatch": "./src/dispatch/index.ts",
67
67
  "#policy": "./src/policy/index.ts",
68
68
  "#profile": "./src/profile/index.ts",
69
+ "#profile/*": "./src/profile/*",
69
70
  "#providers/*": "./src/providers/*",
70
71
  "#registry": "./src/registry/index.ts",
71
72
  "#secrets": "./src/secrets/index.ts",
@@ -49,6 +49,7 @@ export interface ConnectCustomOptions extends GlobalFlags, CustomFlags {
49
49
  /** Forwarded to `connect` untouched. */
50
50
  readonly id?: string | undefined;
51
51
  readonly displayName?: string | undefined;
52
+ readonly label?: string | undefined;
52
53
  readonly replace?: boolean | undefined;
53
54
  readonly nonInteractive?: boolean | undefined;
54
55
  readonly acceptBroadScopes?: boolean | undefined;
@@ -170,6 +171,7 @@ export async function connectCustom(
170
171
  quiet: options.quiet ?? false,
171
172
  ...(options.id ? { id: options.id } : {}),
172
173
  ...(options.displayName ? { displayName: options.displayName } : {}),
174
+ ...(options.label ? { label: options.label } : {}),
173
175
  ...(options.replace ? { replace: options.replace } : {}),
174
176
  ...(options.nonInteractive ? { nonInteractive: options.nonInteractive } : {}),
175
177
  ...(options.acceptBroadScopes ? { acceptBroadScopes: options.acceptBroadScopes } : {}),
@@ -227,6 +227,7 @@ export const CONNECT_CUSTOM_FLAGS: readonly string[] = [
227
227
  // and a broker's, and a synthesized manifest never declares a broker.
228
228
  'id',
229
229
  'display-name',
230
+ 'label',
230
231
  'replace',
231
232
  'non-interactive',
232
233
  'accept-broad-scopes',
@@ -0,0 +1,76 @@
1
+ import type { ConnectionConfig } from '#profile';
2
+ import type { ConfigDocument } from '../../config-edit.ts';
3
+
4
+ /**
5
+ * Writing the connection row — the one edit `connect` makes to the profile.
6
+ *
7
+ * Its own file because it answers its own question: given an account, an id and
8
+ * a name, which lines of YAML change? The five numbered steps around it are
9
+ * about vendors, browsers and credential stores, and none of that reaches here.
10
+ *
11
+ * Two fields, and the difference between them is the whole subject. `account` is
12
+ * an identity the provider reported, and three things read it as one — the
13
+ * reconnect match in `settleIdentity`, the id derived from it, and the `From`
14
+ * header `gmail.send_message` writes. `label` is what the operator calls the
15
+ * same row, addressed by nothing and displayed everywhere.
16
+ */
17
+ export function declareConnection(input: {
18
+ readonly document: ConfigDocument;
19
+ /** The profile as it stands, which says whether this is an add or a repair. */
20
+ readonly connections: readonly ConnectionConfig[];
21
+ readonly providerId: string;
22
+ readonly connectionId: string;
23
+ readonly account: string;
24
+ readonly label: string;
25
+ /** Which route in, where the provider offered a choice. */
26
+ readonly method: string | undefined;
27
+ }): readonly string[] {
28
+ const { document, connections, providerId, connectionId, account, label, method } = input;
29
+
30
+ const key = `${providerId}.${connectionId}`;
31
+ const index = connections.findIndex((c) => `${c.provider}.${c.id}` === key);
32
+ const changes: string[] = [];
33
+
34
+ if (index === -1) {
35
+ // No `credential_ref`: it derives to `<provider>/<id>`, which is exactly
36
+ // where the OAuth provider already looks. Writing it would add a line per
37
+ // connection that can only ever agree or be a bug.
38
+ //
39
+ // No `label` either, when it is the account. Pressing Enter at the prompt is
40
+ // the common answer, and a line repeating the address above it is a line to
41
+ // read past forever.
42
+ document.addTo(['connections'], {
43
+ id: connectionId,
44
+ provider: providerId,
45
+ account,
46
+ ...(label === account ? {} : { label }),
47
+ });
48
+ changes.push(`connections += ${key} (${account})`);
49
+ return changes;
50
+ }
51
+
52
+ // A reconnect. The credential was just replaced; the declaration stays as it
53
+ // is, so re-running connect after an expiry is a no-op on the file rather than
54
+ // a second row.
55
+ const declared = connections[index];
56
+
57
+ if (declared?.account !== account) {
58
+ document.setIn(['connections', index, 'account'], account);
59
+ changes.push(`connections.${key}.account = ${account}`);
60
+ }
61
+
62
+ // Compared against what the row is *called*, which is the account until
63
+ // somebody names it otherwise. Without the fallback, every reconnect of an
64
+ // unlabelled connection writes a label that says what the line above it says.
65
+ if ((declared?.label ?? declared?.account) !== label) {
66
+ document.setIn(['connections', index, 'label'], label);
67
+ changes.push(`connections.${key}.label = ${label}`);
68
+ }
69
+
70
+ // Named where the provider offered a choice, because this is the line an
71
+ // operator reads to see that a re-connect swapped the route rather than
72
+ // refreshed it — and `--auth` reaches here having asked nothing. Unnamed for a
73
+ // provider with one way in, whose output is unchanged.
74
+ changes.push(`re-authorised ${key}${method ? ` with ${method}` : ''}`);
75
+ return changes;
76
+ }
@@ -48,8 +48,13 @@ export function familyMembers(registry: ProviderRegistry, name: string): readonl
48
48
  * the target belongs to the account, not to each service under it, so it is
49
49
  * printed once here and suppressed in every member. Three copies of it is three
50
50
  * times nothing new.
51
+ *
52
+ * The label travels on the same argument: it too belongs to the account, so the
53
+ * first member asks what to call it and the rest are told.
51
54
  */
52
- export async function connectFamily<Options extends { readonly id?: string | undefined }>(input: {
55
+ export async function connectFamily<
56
+ Options extends { readonly id?: string | undefined; readonly label?: string | undefined },
57
+ >(input: {
53
58
  readonly name: string;
54
59
  readonly members: readonly string[];
55
60
  readonly options: Options;
@@ -64,9 +69,20 @@ export async function connectFamily<Options extends { readonly id?: string | und
64
69
 
65
70
  progress(style.dim(familyNote(name, members)));
66
71
 
67
- const inherited = { ...options, id: options.id ?? namedId };
72
+ let inherited = { ...options, id: options.id ?? namedId };
68
73
  const outcomes: ConnectOutcome[] = [];
69
- for (const member of members) outcomes.push(await connect(member, inherited, true));
74
+
75
+ for (const member of members) {
76
+ const outcome = await connect(member, inherited, true);
77
+ outcomes.push(outcome);
78
+
79
+ // One account, one name. Without this, connecting iCloud asks what to call
80
+ // it three times — and three different answers is three rows that read as
81
+ // three accounts.
82
+ if (inherited.label === undefined && outcome.label !== undefined) {
83
+ inherited = { ...inherited, label: outcome.label };
84
+ }
85
+ }
70
86
 
71
87
  return familyOutcome(outcomes);
72
88
  }
@@ -6,6 +6,7 @@ import { nonInteractivePrompter, terminalPrompter, type Prompter } from '../../p
6
6
  import { openRuntime, type GlobalFlags } from '../../runtime.ts';
7
7
  import { moveCredential, siblingAccountId } from './accounts.ts';
8
8
  import { grantProvider } from './grant.ts';
9
+ import { declareConnection } from './declare.ts';
9
10
  import { discoverCapabilities } from './discover.ts';
10
11
  import { connectFamily, familyMembers } from './family.ts';
11
12
  import { authoriseWithKey } from './assertion.ts';
@@ -38,6 +39,14 @@ import { unknownProvider } from './unknown.ts';
38
39
  export interface ConnectOptions extends GlobalFlags {
39
40
  readonly id?: string | undefined;
40
41
  readonly displayName?: string | undefined;
42
+ /**
43
+ * `--label`: what to call this connection, instead of being asked.
44
+ *
45
+ * Distinct from `--display-name`, which answers *whose account this is* for a
46
+ * provider that cannot report it. This one never touches the identity, so it
47
+ * is safe to pass anything a person would say out loud.
48
+ */
49
+ readonly label?: string | undefined;
41
50
  /** Ask for the stored credential again — a key rotated, or a password revoked. */
42
51
  readonly replace?: boolean | undefined;
43
52
  /**
@@ -272,11 +281,12 @@ export async function runConnect(
272
281
  // adding a new one. Without it, a retried connect appends a second row
273
282
  // rather than repairing the first — which is how `main2` and `main3`
274
283
  // ended up in a config describing two mailboxes.
275
- const { connectionId, account } = await settleIdentity({
284
+ const { connectionId, account, label } = await settleIdentity({
276
285
  manifest,
277
286
  provisionalId,
278
287
  explicitId: named,
279
288
  account: options.displayName,
289
+ label: options.label,
280
290
  runtime,
281
291
  prompter,
282
292
  });
@@ -306,31 +316,18 @@ export async function runConnect(
306
316
  });
307
317
 
308
318
  // 4. Declare the connection, or update the one this account already has.
309
- const existingIndex = runtime.config.connections.findIndex(
310
- (c) => `${c.provider}.${c.id}` === connectionKey,
319
+ changes.push(
320
+ ...declareConnection({
321
+ document,
322
+ connections: runtime.config.connections,
323
+ providerId,
324
+ connectionId,
325
+ account,
326
+ label,
327
+ method: method.id,
328
+ }),
311
329
  );
312
330
 
313
- if (existingIndex === -1) {
314
- // No `credential_ref`: it derives to `<provider>/<id>`, which is exactly
315
- // where the OAuth provider already looks. Writing it would add a line per
316
- // connection that can only ever agree or be a bug.
317
- document.addTo(['connections'], { id: connectionId, provider: providerId, account });
318
- changes.push(`connections += ${connectionKey} (${account})`);
319
- } else {
320
- // A reconnect. The credential was just replaced above; the declaration
321
- // stays as it is, so re-running connect after an expiry is a no-op on the
322
- // file rather than a second row.
323
- if (runtime.config.connections[existingIndex]?.account !== account) {
324
- document.setIn(['connections', existingIndex, 'account'], account);
325
- changes.push(`connections.${connectionKey}.account = ${account}`);
326
- }
327
- // Named where the provider offered a choice, because this is the line an
328
- // operator reads to see that a re-connect swapped the route rather than
329
- // refreshed it — and `--auth` reaches here having asked nothing. Unnamed
330
- // for a provider with one way in, whose output is unchanged.
331
- changes.push(`re-authorised ${connectionKey}${method.id ? ` with ${method.id}` : ''}`);
332
- }
333
-
334
331
  // 5. Grant it — one rule per provider; `grant.ts` says why not per capability.
335
332
  const granted = grantProvider(document, runtime.config.policy.allow, providerId);
336
333
 
@@ -365,6 +362,7 @@ export async function runConnect(
365
362
  ok: true,
366
363
  key: connectionKey,
367
364
  account,
365
+ label,
368
366
  ...where(runtime),
369
367
  discovered: discovered.length,
370
368
  next: ALREADY,
@@ -381,6 +379,7 @@ export async function runConnect(
381
379
  ok: true,
382
380
  key: connectionKey,
383
381
  account,
382
+ label,
384
383
  ...where(runtime),
385
384
  changes,
386
385
  granted,
@@ -17,6 +17,14 @@ export interface ConnectOutcome {
17
17
  readonly ok: boolean;
18
18
  readonly key?: string;
19
19
  readonly account?: string;
20
+ /**
21
+ * What the connection is called, where that is not just the account.
22
+ *
23
+ * Reported so the family path can carry it: `connect icloud` is three runs on
24
+ * one account, and the name belongs to the account rather than to each
25
+ * service under it.
26
+ */
27
+ readonly label?: string;
20
28
  /** The profile written to, for a caller that cannot see the announce line. */
21
29
  readonly profile?: string;
22
30
  /** The target written to — which credential store now holds this account. */
@@ -5,7 +5,7 @@ import type { Config } from '#profile';
5
5
  import type { AnyConnector, ProviderManifest } from '#connectivity';
6
6
  import { idFromAccount, resolveAccount } from '../../identity.ts';
7
7
  import { style } from '../../output.ts';
8
- import { terminalPrompter, type Prompter } from '../../prompt.ts';
8
+ import { PromptCancelled, terminalPrompter, type Prompter } from '../../prompt.ts';
9
9
  import { accountSiblings } from './accounts.ts';
10
10
 
11
11
  /**
@@ -16,12 +16,26 @@ import { accountSiblings } from './accounts.ts';
16
16
  * connection already holds that account, we reuse *its* id, which is what turns
17
17
  * a re-run of `connect` into a repair rather than a duplicate. Only when
18
18
  * identity cannot be resolved at all do we ask.
19
+ *
20
+ * The *label* is settled last and separately, because it is the one thing here
21
+ * the provider cannot answer. `account` is load-bearing three times over — the
22
+ * reconnect match above, the id derived from it, and the `From` header
23
+ * `gmail.send_message` writes — so the operator's own words for a connection
24
+ * cannot be put there, and `label` is where they go instead.
19
25
  */
20
26
  export async function settleIdentity(input: {
21
27
  manifest: ProviderManifest;
22
28
  provisionalId: string;
23
29
  explicitId: string | undefined;
24
30
  account: string | undefined;
31
+ /**
32
+ * The label, where the caller already has one.
33
+ *
34
+ * `--label`, and the family path: `connect icloud` is three `connect` runs on
35
+ * one account, and asking what to call it once per service is asking the same
36
+ * question three times.
37
+ */
38
+ label?: string | undefined;
25
39
  runtime: {
26
40
  config: Config;
27
41
  credentials: SecretStore;
@@ -30,7 +44,7 @@ export async function settleIdentity(input: {
30
44
  authorizeRequest(providerId: string, connectionId: string, request: Request): Promise<Request>;
31
45
  };
32
46
  prompter?: Prompter;
33
- }): Promise<{ connectionId: string; account: string }> {
47
+ }): Promise<{ connectionId: string; account: string; label: string }> {
34
48
  const { manifest, provisionalId, explicitId, runtime } = input;
35
49
  const prompter = input.prompter ?? terminalPrompter;
36
50
 
@@ -112,6 +126,11 @@ export async function settleIdentity(input: {
112
126
  const unaccounted = !account && manifest.auth.kind === 'none';
113
127
  if (unaccounted) account = manifest.name;
114
128
 
129
+ // Whether the name we hold is one the operator has just typed, rather than one
130
+ // a provider reported. It settles the label below: a name someone chose a
131
+ // second ago does not need confirming against itself.
132
+ let typed = false;
133
+
115
134
  if (!account) {
116
135
  // Nothing to go on. Asking beats inventing `main2`, and the answer is the
117
136
  // one piece of information the file cannot reconstruct later — which is
@@ -125,32 +144,85 @@ export async function settleIdentity(input: {
125
144
  }
126
145
 
127
146
  account =
128
- (await prompter.ask(`Which account is this? ${style.dim('(label for this connection)')}`)) ||
147
+ (await prompter.ask(`Which account is this? ${style.dim('(the address or handle)')}`)) ||
129
148
  `${manifest.name} ${provisionalId}`;
149
+ typed = true;
130
150
  }
131
151
 
132
- if (explicitId) return { connectionId: explicitId, account };
152
+ const taken = siblings.map((candidate) => candidate.id);
133
153
 
134
- if (unaccounted) {
135
- return {
136
- connectionId: idFromAccount(
137
- 'main',
138
- siblings.map((candidate) => candidate.id),
139
- ),
140
- account: account!,
141
- };
142
- }
143
-
144
- const already = siblings.find(
145
- (candidate) => candidate.account.toLowerCase() === account.toLowerCase(),
146
- );
147
- if (already) return { connectionId: already.id, account };
154
+ const connectionId =
155
+ explicitId ??
156
+ (unaccounted
157
+ ? idFromAccount('main', taken)
158
+ : (siblings.find(
159
+ (candidate) => candidate.account.toLowerCase() === account!.toLowerCase(),
160
+ )?.id ?? idFromAccount(account, taken)));
148
161
 
149
162
  return {
150
- connectionId: idFromAccount(
151
- account,
152
- siblings.map((candidate) => candidate.id),
153
- ),
163
+ connectionId,
154
164
  account,
165
+ label: await settleLabel({
166
+ given: input.label,
167
+ // What the row this is about to land on is already called. Looked up
168
+ // across the whole vendor account rather than this provider alone, for the
169
+ // reason `accountSiblings` exists: `connect icloud_calendar` adopts iCloud
170
+ // Mail's id, and should adopt the name that goes with it too.
171
+ declared: siblings.find((candidate) => candidate.id === connectionId)?.label,
172
+ account,
173
+ typed,
174
+ prompter,
175
+ }),
155
176
  };
156
177
  }
178
+
179
+ /**
180
+ * What to call this connection, offering what it is already called.
181
+ *
182
+ * Asked on every interactive connect, not only where identity resolution failed.
183
+ * That was the old behaviour and it had the case exactly backwards: the run that
184
+ * could not name the account is the run where the operator has least to add,
185
+ * and the run that resolved `ada@example.com` — where they may well want "Work
186
+ * mail" — never asked at all.
187
+ *
188
+ * The suggestion is in the question and an empty answer takes it, so the cost of
189
+ * always asking is one keystroke. Nothing addresses a connection by its label,
190
+ * so there is no answer here that can break anything.
191
+ */
192
+ async function settleLabel(input: {
193
+ given: string | undefined;
194
+ declared: string | undefined;
195
+ account: string;
196
+ typed: boolean;
197
+ prompter: Prompter;
198
+ }): Promise<string> {
199
+ const { given, declared, account, typed, prompter } = input;
200
+
201
+ if (given) return given;
202
+
203
+ // A label already chosen wins over the account, so re-authorising an expired
204
+ // credential does not quietly undo the operator's own word for the row.
205
+ const suggestion = declared ?? account;
206
+
207
+ if (typed || !prompter.interactive) return suggestion;
208
+
209
+ try {
210
+ return (
211
+ (await prompter.ask(`What should this be called? ${style.dim(`[${suggestion}]`)}`)) ||
212
+ suggestion
213
+ );
214
+ } catch (refusal) {
215
+ // Ctrl-C is an answer: the operator stopped the command, and swallowing it
216
+ // here would finish a connect they interrupted.
217
+ if (refusal instanceof PromptCancelled) throw refusal;
218
+
219
+ // Anything else is `terminalPrompter` discovering there is no terminal —
220
+ // it reports itself interactive and finds out only when asked, so a piped
221
+ // `lanes link connect gmail` reaches this line having already opened a
222
+ // browser and stored a credential. Failing there for want of a display name
223
+ // would undo none of that. A label is worth having and never worth failing
224
+ // a connect over; the account above it is the identity, and that one still
225
+ // refuses rather than inventing a name.
226
+ return suggestion;
227
+ }
228
+ }
@@ -1,4 +1,4 @@
1
- import type { Config, Resolution } from '#profile';
1
+ import type { Resolution } from '#profile';
2
2
  import { announce } from '../../output.ts';
3
3
 
4
4
  /**
@@ -21,7 +21,7 @@ import { announce } from '../../output.ts';
21
21
  * is the same mistake caught where it is still cheap.
22
22
  */
23
23
  export function announceConnectTarget(
24
- runtime: { readonly resolution: Resolution; readonly config: Pick<Config, 'targets'> },
24
+ runtime: { readonly resolution: Resolution },
25
25
  json?: boolean | undefined,
26
26
  ): void {
27
27
  // `emit`'s early return only protects lines printed *at* the emit, and this
@@ -297,9 +297,18 @@ export async function disconnect(key: string | undefined, flags: DisconnectFlags
297
297
  })
298
298
  }
299
299
 
300
+ /**
301
+ * Rename a connection, writing `label` and never `account`.
302
+ *
303
+ * It wrote `account` until it was noticed that `account` is not a display name:
304
+ * `settleIdentity` matches on it to tell a repair from a new account,
305
+ * `idFromAccount` derives the id from it, and `gmail.send_message` writes it
306
+ * into a `From` header. Renaming through it therefore un-recognised the account
307
+ * it renamed — the next `connect` added a second row beside it.
308
+ */
300
309
  export async function renameConnection(
301
310
  key: string,
302
- account: string,
311
+ label: string,
303
312
  flags: RelabelFlags,
304
313
  ): Promise<{ resolution: Resolution; relabelled: Relabelled }> {
305
314
  const runtime = await openRuntime(flags);
@@ -309,7 +318,7 @@ export async function renameConnection(
309
318
  const located = locate(config, key, resolution.profile);
310
319
  const document = await ConfigDocument.open(resolution.workspaceRoot, resolution.profile);
311
320
 
312
- document.setIn(['connections', located.index, 'account'], account);
321
+ document.setIn(['connections', located.index, 'label'], label);
313
322
  await document.save();
314
323
 
315
324
  return {
@@ -318,8 +327,10 @@ export async function renameConnection(
318
327
  profile: resolution.profile,
319
328
  target,
320
329
  key,
321
- from: located.connection.account,
322
- to: account,
330
+ // What it was called a moment ago, which is the account only until the
331
+ // first rename.
332
+ from: located.connection.label ?? located.connection.account,
333
+ to: label,
323
334
  published: nextAfterEdit(await publishProfileEdit({ resolution, config, target })),
324
335
  },
325
336
  };
@@ -330,13 +341,13 @@ export async function renameConnection(
330
341
 
331
342
  export async function relabel(
332
343
  key: string | undefined,
333
- account: string | undefined,
344
+ label: string | undefined,
334
345
  flags: RelabelFlags,
335
346
  ): Promise<void> {
336
347
  if (!key) throw new Error('Which connection? Run: lanes link status');
337
- if (!account) throw new Error(`What should ${key} be called? Run: lanes link relabel ${key} "New name"`);
348
+ if (!label) throw new Error(`What should ${key} be called? Run: lanes link relabel ${key} "New name"`);
338
349
 
339
- const { resolution, relabelled: result } = await renameConnection(key, account, flags);
350
+ const { resolution, relabelled: result } = await renameConnection(key, label, flags);
340
351
 
341
352
  return emit(flags.json, result, () => {
342
353
  announce(resolution);