@lanes-sh/link 0.7.2 → 0.8.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 (156) hide show
  1. package/README.md +20 -10
  2. package/instructions/agents/lanes-link-scout.md +2 -2
  3. package/instructions/skills/lanes-link/SKILL.md +136 -61
  4. package/package.json +2 -1
  5. package/src/audit/index.ts +8 -1
  6. package/src/auth/index.ts +58 -2
  7. package/src/auth/lanes/assertion.ts +256 -0
  8. package/src/auth/lanes/callback.ts +135 -0
  9. package/src/auth/lanes/federation.ts +50 -0
  10. package/src/auth/lanes/login.ts +294 -0
  11. package/src/auth/lanes/members.ts +103 -0
  12. package/src/auth/lanes/session.ts +97 -0
  13. package/src/auth/oauth/grant.ts +183 -0
  14. package/src/auth/oauth/result.ts +27 -0
  15. package/src/auth/oauth/server.ts +176 -203
  16. package/src/auth/oauth/store.ts +65 -0
  17. package/src/auth/remote.ts +32 -9
  18. package/src/cli/accepts.ts +108 -0
  19. package/src/cli/argv.ts +57 -3
  20. package/src/cli/audit-change.ts +140 -0
  21. package/src/cli/callback-page.ts +36 -115
  22. package/src/cli/commands/auth-dispatch.ts +48 -0
  23. package/src/cli/commands/auth.ts +229 -0
  24. package/src/cli/commands/connect/accounts.ts +4 -4
  25. package/src/cli/commands/connect/authorise.ts +4 -4
  26. package/src/cli/commands/connect/bind-credential.ts +2 -1
  27. package/src/cli/commands/connect/custom/index.ts +1 -1
  28. package/src/cli/commands/connect/custom/write.ts +2 -2
  29. package/src/cli/commands/connect/grant.ts +29 -14
  30. package/src/cli/commands/connect/index.ts +88 -87
  31. package/src/cli/commands/connect/options.ts +83 -0
  32. package/src/cli/commands/connect/registration.ts +50 -0
  33. package/src/cli/commands/connect/requirements.ts +1 -1
  34. package/src/cli/commands/connect/settle.ts +4 -2
  35. package/src/cli/commands/connect/target-note.ts +7 -2
  36. package/src/cli/commands/connect/unknown.ts +1 -1
  37. package/src/cli/commands/connect/variables.ts +3 -2
  38. package/src/cli/commands/connection-list.ts +116 -0
  39. package/src/cli/commands/connection.ts +182 -165
  40. package/src/cli/commands/grant.ts +140 -0
  41. package/src/cli/commands/identity.ts +21 -9
  42. package/src/cli/commands/knowledge/index.ts +46 -79
  43. package/src/cli/commands/knowledge/migrate.ts +74 -13
  44. package/src/cli/commands/knowledge/show.ts +92 -0
  45. package/src/cli/commands/knowledge.ts +2 -1
  46. package/src/cli/commands/mcp/harnesses.ts +30 -8
  47. package/src/cli/commands/mcp/onboarding.ts +86 -0
  48. package/src/cli/commands/mcp/register.ts +16 -2
  49. package/src/cli/commands/mcp.ts +1 -0
  50. package/src/cli/commands/members.ts +288 -0
  51. package/src/cli/commands/operate/attach.ts +3 -3
  52. package/src/cli/commands/operate/audit.ts +11 -7
  53. package/src/cli/commands/operate/auth.ts +28 -11
  54. package/src/cli/commands/operate/findings.ts +2 -1
  55. package/src/cli/commands/operate/inspect.ts +37 -19
  56. package/src/cli/commands/operate/migrate.ts +29 -12
  57. package/src/cli/commands/operate/outputs.ts +3 -3
  58. package/src/cli/commands/operate/pair-certificate.ts +141 -0
  59. package/src/cli/commands/operate/pair.ts +324 -0
  60. package/src/cli/commands/operate/policy.ts +73 -22
  61. package/src/cli/commands/operate/serve.ts +52 -4
  62. package/src/cli/commands/operate/status.ts +18 -10
  63. package/src/cli/commands/operate/tools.ts +2 -2
  64. package/src/cli/commands/operate.ts +2 -0
  65. package/src/cli/commands/owner/shared.ts +13 -2
  66. package/src/cli/commands/owner/skills.ts +28 -8
  67. package/src/cli/commands/profile/removal.ts +79 -76
  68. package/src/cli/commands/profile/remove.ts +16 -1
  69. package/src/cli/commands/profile.ts +46 -10
  70. package/src/cli/commands/relabel.ts +112 -0
  71. package/src/cli/commands/secrets.ts +34 -12
  72. package/src/cli/commands/set-workspace.ts +96 -0
  73. package/src/cli/commands/setup.ts +2 -2
  74. package/src/cli/commands/sync.ts +8 -8
  75. package/src/cli/commands/target.ts +9 -7
  76. package/src/cli/commands/update.ts +58 -17
  77. package/src/cli/config-edit.ts +75 -140
  78. package/src/cli/config-migrate.ts +82 -64
  79. package/src/cli/config-repair.ts +89 -32
  80. package/src/cli/config-templates.ts +198 -0
  81. package/src/cli/contract3-data.ts +328 -0
  82. package/src/cli/contract3-shape.ts +186 -0
  83. package/src/cli/contract3.ts +282 -0
  84. package/src/cli/endpoint-url.ts +1 -1
  85. package/src/cli/lanes.ts +25 -1
  86. package/src/cli/main.ts +89 -14
  87. package/src/cli/migrate-plan.ts +12 -6
  88. package/src/cli/output.ts +34 -1
  89. package/src/cli/publish.ts +5 -2
  90. package/src/cli/runtime/open.ts +63 -98
  91. package/src/cli/runtime/registry.ts +6 -7
  92. package/src/cli/runtime/stores.ts +53 -0
  93. package/src/cli/runtime/types.ts +106 -0
  94. package/src/cli/runtime/vault.ts +19 -4
  95. package/src/cli/runtime/workspace.ts +60 -0
  96. package/src/cli/runtime.ts +2 -1
  97. package/src/cli/selection-require.ts +44 -13
  98. package/src/cli/selection.ts +127 -145
  99. package/src/cli/usage.ts +34 -18
  100. package/src/cli/workspace-migrate.ts +125 -16
  101. package/src/connectivity/manifest/provider.ts +3 -1
  102. package/src/connectivity/manifest/requirements.ts +1 -1
  103. package/src/deployments/bind.ts +1 -1
  104. package/src/deployments/deploy.ts +36 -27
  105. package/src/deployments/gcp/bucket.ts +18 -7
  106. package/src/deployments/gcp/provision.ts +7 -7
  107. package/src/deployments/prepare.ts +72 -24
  108. package/src/deployments/record.ts +1 -1
  109. package/src/deployments/report.ts +2 -2
  110. package/src/deployments/serving.ts +15 -74
  111. package/src/deployments/target.ts +15 -15
  112. package/src/deployments/upload.ts +46 -22
  113. package/src/dispatch/deps.ts +88 -0
  114. package/src/dispatch/dispatch.ts +21 -62
  115. package/src/policy/index.ts +47 -15
  116. package/src/profile/connections.ts +183 -0
  117. package/src/profile/deployments.ts +3 -3
  118. package/src/profile/index.ts +30 -5
  119. package/src/profile/layout.ts +86 -89
  120. package/src/profile/load.ts +80 -47
  121. package/src/profile/pairing.ts +32 -0
  122. package/src/profile/primitives.ts +35 -1
  123. package/src/profile/registry.ts +6 -6
  124. package/src/profile/schema.ts +172 -21
  125. package/src/profile/targets.ts +21 -9
  126. package/src/profile/testing.ts +69 -2
  127. package/src/profile/workspace.ts +58 -3
  128. package/src/providers/custom/index.ts +1 -1
  129. package/src/providers/custom/load.ts +2 -3
  130. package/src/providers/identity/provider.ts +1 -1
  131. package/src/providers/memory/provider.ts +20 -2
  132. package/src/providers/setup/plan.ts +1 -1
  133. package/src/providers/slack/index.ts +2 -2
  134. package/src/registry/policy-bridge.ts +33 -11
  135. package/src/registry/reconcile.ts +4 -4
  136. package/src/server/authorization.ts +94 -0
  137. package/src/server/edge.ts +14 -1
  138. package/src/server/endpoint.ts +85 -104
  139. package/src/server/generation.ts +10 -1
  140. package/src/server/harness.ts +71 -13
  141. package/src/server/index.ts +31 -0
  142. package/src/server/mcp/build.ts +20 -1
  143. package/src/server/mcp/client-info.ts +54 -0
  144. package/src/server/mcp/guide.ts +120 -0
  145. package/src/server/mcp/instructions.ts +1 -1
  146. package/src/server/mcp/prompts.ts +7 -3
  147. package/src/server/mcp/resources.ts +16 -8
  148. package/src/server/mcp/tools.ts +9 -3
  149. package/src/server/mcp/visibility.ts +18 -3
  150. package/src/server/oauth.ts +29 -109
  151. package/src/server/read/credential.ts +134 -0
  152. package/src/server/read/deployed.ts +56 -0
  153. package/src/server/read/listener.ts +54 -0
  154. package/src/server/read/open.ts +101 -0
  155. package/src/server/read/routes.ts +247 -0
  156. package/src/server/read/state.ts +171 -0
@@ -0,0 +1,83 @@
1
+ import type { GlobalFlags } from '../../runtime.ts';
2
+
3
+ /**
4
+ * What `lanes link connect` accepts, and what it hands back.
5
+ *
6
+ * Split out of `index.ts` so that file stays inside the size budget. The seam is
7
+ * the one the command already has: this is the shape of the request, and that is
8
+ * the six steps that carry it out.
9
+ */
10
+
11
+ /**
12
+ * `lanes link connect <provider>` — the one command that adds an account.
13
+ *
14
+ * The same command regardless of connectivity. A local provider declares a
15
+ * connection and stops; an MCP provider authorises, asks the upstream server
16
+ * what it exposes, and grants it. Core learns nothing about any vendor: the
17
+ * manifest says how to reach them and what to ask the operator for.
18
+ *
19
+ * The five numbered steps below are the whole command, and each one that grew
20
+ * past a paragraph moved out: `authorise.ts` gets the token, `setup.ts` asks the
21
+ * operator for what the vendor's console produced, `settle.ts` works out whose
22
+ * account it was, and `accounts.ts` knows which connections are siblings.
23
+ */
24
+
25
+ export interface ConnectOptions extends GlobalFlags {
26
+ readonly id?: string | undefined;
27
+ readonly displayName?: string | undefined;
28
+ /** `--set key=value`, repeatable: where this connection's service is. */
29
+ readonly set?: readonly string[] | string | undefined;
30
+ /**
31
+ * `--label`: what to call this connection, instead of being asked.
32
+ *
33
+ * Distinct from `--display-name`, which answers *whose account this is* for a
34
+ * provider that cannot report it. This one never touches the identity, so it
35
+ * is safe to pass anything a person would say out loud.
36
+ */
37
+ readonly label?: string | undefined;
38
+ /** Ask for the stored credential again — a key rotated, or a password revoked. */
39
+ readonly replace?: boolean | undefined;
40
+ /**
41
+ * Answer nothing from a terminal: resolve every declared value from the
42
+ * credential store, and refuse with instructions where one is missing.
43
+ */
44
+ readonly nonInteractive?: boolean | undefined;
45
+ /** The operator has already agreed to scopes broader than the provider needs. */
46
+ readonly acceptBroadScopes?: boolean | undefined;
47
+ /**
48
+ * Register an OAuth client of your own rather than using a hosted one.
49
+ *
50
+ * Sticky by consequence rather than by flag: it writes the `oauth_apps` entry,
51
+ * and a profile that declares one is never moved off it. So this is typed once
52
+ * and then forgotten, which is the right shape for a decision about a client
53
+ * that is shared by every connection of that vendor.
54
+ */
55
+ /**
56
+ * `--own-client`, the older spelling of one of the routes `--auth` now names.
57
+ *
58
+ * Kept because it is in scripts and in a year of documentation, and because
59
+ * it still says something true. It resolves to `--auth own_client`.
60
+ */
61
+ readonly ownClient?: boolean | undefined;
62
+ /**
63
+ * `--auth <method>`: which way in, for a provider that offers more than one.
64
+ *
65
+ * Unset means ask, where there is somebody to ask and something to ask
66
+ * about. It is not sticky the way `--own-client` is: `--own-client` writes an
67
+ * `oauth_apps` entry that every connection of the vendor then reads, whereas
68
+ * this decides one connection's credential and is recorded by that credential
69
+ * existing. Two accounts on the same profile may honestly differ.
70
+ */
71
+ readonly auth?: string | undefined;
72
+ /** Injected for tests. The broker is the only thing `connect` fetches. */
73
+ readonly fetch?: typeof globalThis.fetch | undefined;
74
+ readonly json?: boolean | undefined;
75
+ }
76
+
77
+
78
+ /**
79
+ * The connection id used before the provider has said whose account this is.
80
+ *
81
+ * It is a placeholder rather than a name, and `setup.ts` reads it as one: a
82
+ * credential filed under it belongs to a `connect` that did not finish.
83
+ */
@@ -0,0 +1,50 @@
1
+ import type { ProviderManifest } from '#connectivity';
2
+
3
+ /**
4
+ * Turn a refused registration into something an operator can act on.
5
+ *
6
+ * A provider declaring `registration: 'dynamic'` is promising that its
7
+ * authorization server will register a client on demand — that is the whole
8
+ * reason those providers need no setup. When the server refuses, the SDK throws
9
+ * the status and nothing else, and "Dynamic Client Registration rejected (HTTP
10
+ * 403): Forbidden" tells the reader neither what was being attempted nor that
11
+ * there is nothing wrong with their machine.
12
+ *
13
+ * It is a real state rather than a bug: a server may advertise a registration
14
+ * endpoint and gate it behind a plan, an allowlist, or a signed-in session.
15
+ * Figma's does, which is how this was found. What the operator needs to know is
16
+ * that the refusal came from the provider, that we sent nothing wrong, and
17
+ * where the answer lives — which is the provider's own documentation, because
18
+ * whatever unlocks it is theirs to state.
19
+ */
20
+ export async function registering<T>(
21
+ manifest: ProviderManifest,
22
+ serverUrl: string,
23
+ run: () => Promise<T>,
24
+ ): Promise<T> {
25
+ if (manifest.auth.kind !== 'oauth' || manifest.auth.registration !== 'dynamic') return run();
26
+
27
+ try {
28
+ return await run();
29
+ } catch (error) {
30
+ const message = error instanceof Error ? error.message : String(error);
31
+ if (!/registration/i.test(message)) throw error;
32
+
33
+ const host = new URL(serverUrl).host;
34
+ throw new Error(
35
+ `${manifest.name} refused to register this machine as a client.\n` +
36
+ ` ${message}\n` +
37
+ '\n' +
38
+ ` Nothing was written, and nothing about your setup is wrong: ${host}\n` +
39
+ ' advertises registration and then declined it. That is usually a plan, an\n' +
40
+ ' allowlist, or an account that has to be signed in first.\n' +
41
+ ` What unlocks it is theirs to say: ${docsFor(manifest) ?? host}`,
42
+ );
43
+ }
44
+ }
45
+
46
+ /** Where the provider tells you what it wants, when the manifest names it. */
47
+ function docsFor(manifest: ProviderManifest): string | undefined {
48
+ const setup = (manifest as { setup?: { docs_url?: unknown } }).setup;
49
+ return typeof setup?.docs_url === 'string' ? setup.docs_url : undefined;
50
+ }
@@ -102,7 +102,7 @@ export async function preflight(input: {
102
102
  }): Promise<Blocked | null> {
103
103
  const { manifest, connectionId, profile, target, spec } = input;
104
104
  const method = input.method ?? 'oauth';
105
- const rerun = `lanes link connect ${spec} --profile ${profile} --target ${target}`;
105
+ const rerun = `lanes link connect ${spec} --profile ${profile} --workspace ${target}`;
106
106
  const assertion = manifest.auth.kind === 'oauth' ? manifest.auth.assertion : undefined;
107
107
 
108
108
  if (method === 'assertion' && assertion) {
@@ -1,7 +1,7 @@
1
1
  import { createMcpConnector } from '#connectivity/transports';
2
2
  import { bearerTokenAsStored } from '#connectivity/auth/index.ts';
3
3
  import type { SecretStore } from '#secrets';
4
- import type { Config } from '#profile';
4
+ import type { ConnectionConfig, 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';
@@ -38,6 +38,8 @@ export async function settleIdentity(input: {
38
38
  label?: string | undefined;
39
39
  runtime: {
40
40
  config: Config;
41
+ /** Every account the workspace holds, for sibling matching (ADR-057). */
42
+ workspaceConnections: readonly ConnectionConfig[];
41
43
  credentials: SecretStore;
42
44
  registry: { manifest(id: string): ProviderManifest | undefined };
43
45
  connectorFor(providerId: string, connectionId: string): AnyConnector | undefined;
@@ -51,7 +53,7 @@ export async function settleIdentity(input: {
51
53
  // Siblings across the whole vendor account, not just this provider — so
52
54
  // connecting iCloud Calendar after iCloud Mail lands on the same id, and
53
55
  // therefore the same credential, rather than a second `will2`.
54
- const siblings = accountSiblings(manifest, runtime.config, runtime.registry);
56
+ const siblings = accountSiblings(manifest, runtime.workspaceConnections, runtime.registry);
55
57
 
56
58
  let account = input.account ?? null;
57
59
 
@@ -1,5 +1,5 @@
1
1
  import type { Resolution } from '#profile';
2
- import { announce } from '../../output.ts';
2
+ import { announce, announceWorkspace } from '../../output.ts';
3
3
 
4
4
  /**
5
5
  * The line `connect` prints before it acts.
@@ -23,6 +23,7 @@ import { announce } from '../../output.ts';
23
23
  export function announceConnectTarget(
24
24
  runtime: { readonly resolution: Resolution },
25
25
  json?: boolean | undefined,
26
+ granting = true,
26
27
  ): void {
27
28
  // `emit`'s early return only protects lines printed *at* the emit, and this
28
29
  // one has to precede the browser — so it carries its own guard, the one
@@ -30,5 +31,9 @@ export function announceConnectTarget(
30
31
  // beside `emit`: a line of prose in front of a JSON document corrupts it.
31
32
  if (json === true) return;
32
33
 
33
- announce(runtime.resolution);
34
+ // Without `--profile` this connects into the workspace and edits no profile,
35
+ // so naming the one a runtime happened to resolve would report a subject the
36
+ // command does not have.
37
+ if (granting) announce(runtime.resolution);
38
+ else announceWorkspace(runtime.resolution);
34
39
  }
@@ -28,7 +28,7 @@ export function unknownProvider(input: {
28
28
  const yours = available.filter((c) => c.origin === 'workspace').map((c) => c.manifest.id);
29
29
 
30
30
  const selection = `--profile ${input.profile} --target ${input.target}`;
31
- const directory = `${input.workspaceRoot}/${layout.providers(input.profile)}`;
31
+ const directory = `${input.workspaceRoot}/${layout.providers()}`;
32
32
 
33
33
  return new Error(
34
34
  `Unknown provider "${input.providerId}".\n` +
@@ -161,7 +161,8 @@ export async function resolveConnectionAddress(input: {
161
161
  readonly runtime: {
162
162
  readonly registry: ProviderRegistry;
163
163
  readonly credentials: SecretStore;
164
- readonly config: { readonly connections: readonly ConnectionRow[] };
164
+ /** Every account the workspace holds, for the address a sibling already gave (ADR-057). */
165
+ readonly workspaceConnections: readonly ConnectionRow[];
165
166
  connectorFor(providerId: string, connectionId: string): AnyConnector | undefined;
166
167
  };
167
168
  }): Promise<ResolvedAddress> {
@@ -178,7 +179,7 @@ export async function resolveConnectionAddress(input: {
178
179
  // the id is not settled yet, and a second Nextcloud on a *different* host is
179
180
  // rarer than reconnecting the one there is. It is offered, not imposed —
180
181
  // pressing Enter accepts it and typing replaces it.
181
- existing: previousAddress(runtime.config.connections, input.providerId, manifest),
182
+ existing: previousAddress(runtime.workspaceConnections, input.providerId, manifest),
182
183
  interactive: input.interactive,
183
184
  supplied: parseSet(input.set),
184
185
  });
@@ -0,0 +1,116 @@
1
+ import { RESERVED_PROVIDER_IDS } from '#connectivity';
2
+ import {
3
+ connectionRefOf,
4
+ listProfiles,
5
+ loadProfileConfig,
6
+ readConnections,
7
+ resolveTargetWorkspace,
8
+ resolveWorkspaceRoot,
9
+ } from '#profile';
10
+ import { emit, heading, print, style, table } from '../output.ts';
11
+ import type { GlobalFlags } from '../runtime.ts';
12
+
13
+ /**
14
+ * `lanes link connection list` — every account this workspace holds, and which
15
+ * profiles reach each.
16
+ *
17
+ * The listing that had nowhere to live under contract 2, because there was no
18
+ * such thing as "the workspace's connections": each profile had its own list
19
+ * and `status` printed one profile's. A connection belongs to the workspace now
20
+ * (ADR-057), so the question "what have I authorised" has one answer.
21
+ *
22
+ * **The profiles column is the useful half.** An account granted to nobody is
23
+ * connected and unreachable, which looks identical to a working one from
24
+ * anywhere else — and an account granted to more profiles than the operator
25
+ * remembers is how a disconnect surprises somebody. Both are visible here and
26
+ * nowhere else.
27
+ *
28
+ * Workspace-scoped, so it takes no `--profile`. Passing one would narrow the
29
+ * column rather than the rows, which is a different command.
30
+ */
31
+
32
+ export interface ConnectionSummary {
33
+ readonly key: string;
34
+ readonly provider: string;
35
+ readonly account: string;
36
+ readonly label: string | null;
37
+ /** Whether this is part of the owner layer rather than somebody's account. */
38
+ readonly builtIn: boolean;
39
+ /** Every profile in the workspace whose grants name it. */
40
+ readonly grantedTo: readonly string[];
41
+ }
42
+
43
+ export async function connectionList(flags: GlobalFlags & { json?: boolean }): Promise<void> {
44
+ const local = resolveWorkspaceRoot();
45
+ const root = await resolveTargetWorkspace(local, flags.target ?? 'local');
46
+
47
+ const held = (await readConnections(root)).connections;
48
+
49
+ // Read once, ahead of the loop. The alternative is a file read per connection
50
+ // per profile, which on a workspace with fifteen accounts and three profiles
51
+ // is forty-five reads of five files.
52
+ const grants = new Map<string, string[]>();
53
+ for (const name of await listProfiles(root)) {
54
+ try {
55
+ const { config } = await loadProfileConfig(root, name);
56
+ for (const grant of config.grants) {
57
+ grants.set(grant.connection, [...(grants.get(grant.connection) ?? []), name]);
58
+ }
59
+ } catch {
60
+ // A profile that will not load is `check`'s to report. Dropping it here
61
+ // makes this listing answer for the ones that do rather than failing
62
+ // wholesale on one broken file.
63
+ }
64
+ }
65
+
66
+ const summaries: ConnectionSummary[] = held.map((connection) => {
67
+ const key = connectionRefOf(connection);
68
+ return {
69
+ key,
70
+ provider: connection.provider,
71
+ account: connection.account,
72
+ label: connection.label ?? null,
73
+ builtIn: RESERVED_PROVIDER_IDS.includes(connection.provider),
74
+ grantedTo: grants.get(key) ?? [],
75
+ };
76
+ });
77
+
78
+ return emit(flags.json, { workspace: flags.target ?? 'local', connections: summaries }, () => {
79
+ print(style.dim(`workspace ${style.bold(flags.target ?? 'local')} ${root}`));
80
+
81
+ const accounts = summaries.filter((one) => !one.builtIn);
82
+ const builtIns = summaries.filter((one) => one.builtIn);
83
+
84
+ if (accounts.length === 0) {
85
+ print('');
86
+ print(style.dim('No accounts connected yet. Connect one with: lanes link connect <provider>'));
87
+ } else {
88
+ heading(`Accounts (${accounts.length})`);
89
+ table(accounts.map(row));
90
+ }
91
+
92
+ if (builtIns.length > 0) {
93
+ heading(`Your own material (${builtIns.length})`);
94
+ table(builtIns.map(row));
95
+ }
96
+
97
+ print('');
98
+ print(
99
+ style.dim(
100
+ 'A connection reaches a profile only where that profile grants it:\n' +
101
+ ' lanes link grant <provider>.<id> --profile <name>',
102
+ ),
103
+ );
104
+ });
105
+ }
106
+
107
+ function row(one: ConnectionSummary): string[] {
108
+ // "granted to nobody" is said rather than left blank, because a blank column
109
+ // reads as "not loaded yet" and this is a fact about the config.
110
+ const reach =
111
+ one.grantedTo.length === 0
112
+ ? style.dim('no profile grants it')
113
+ : one.grantedTo.join(', ');
114
+
115
+ return [` ${style.bold(one.key)}`, one.label ?? one.account, reach];
116
+ }