@lanes-sh/link 0.2.1 → 0.3.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 (139) hide show
  1. package/README.md +21 -8
  2. package/instructions/skills/lanes-link/SKILL.md +59 -14
  3. package/package.json +1 -1
  4. package/src/auth/index.ts +3 -1
  5. package/src/auth/oauth/metadata.ts +83 -9
  6. package/src/auth/oauth/redirects.ts +70 -0
  7. package/src/auth/oauth/server.ts +49 -69
  8. package/src/auth/oauth/store.ts +19 -5
  9. package/src/cli/argv.ts +50 -0
  10. package/src/cli/brand.ts +178 -0
  11. package/src/cli/callback-page.ts +108 -128
  12. package/src/cli/commands/connect/accounts.ts +5 -0
  13. package/src/cli/commands/connect/assertion.ts +187 -0
  14. package/src/cli/commands/connect/authorise.ts +61 -17
  15. package/src/cli/commands/connect/client.ts +37 -9
  16. package/src/cli/commands/connect/discover.ts +94 -0
  17. package/src/cli/commands/connect/family.ts +72 -0
  18. package/src/cli/commands/connect/index.ts +113 -115
  19. package/src/cli/commands/connect/method.ts +237 -0
  20. package/src/cli/commands/connect/outcome.ts +42 -1
  21. package/src/cli/commands/connect/pasted-token.ts +66 -0
  22. package/src/cli/commands/connect/requirements.ts +60 -8
  23. package/src/cli/commands/connect/setup.ts +16 -5
  24. package/src/cli/commands/connect/target-note.ts +34 -0
  25. package/src/cli/commands/identity.ts +258 -0
  26. package/src/cli/commands/knowledge/index.ts +390 -0
  27. package/src/cli/commands/knowledge/migrate.ts +180 -0
  28. package/src/cli/commands/knowledge/setup.ts +144 -0
  29. package/src/cli/commands/knowledge.ts +10 -0
  30. package/src/cli/commands/mcp/harnesses.ts +16 -2
  31. package/src/cli/commands/mcp/register.ts +9 -1
  32. package/src/cli/commands/mcp/stdio.ts +21 -0
  33. package/src/cli/commands/operate/dashboard.ts +107 -0
  34. package/src/cli/commands/operate/findings.ts +151 -0
  35. package/src/cli/commands/operate/inspect.ts +56 -158
  36. package/src/cli/commands/operate/outputs.ts +38 -11
  37. package/src/cli/commands/operate/serve.ts +3 -0
  38. package/src/cli/commands/operate/token.ts +1 -1
  39. package/src/cli/commands/operate.ts +2 -0
  40. package/src/cli/commands/profile/declare.ts +154 -0
  41. package/src/cli/commands/profile/removal.ts +17 -0
  42. package/src/cli/commands/profile.ts +83 -35
  43. package/src/cli/commands/setup.ts +22 -6
  44. package/src/cli/commands/target.ts +65 -83
  45. package/src/cli/config-edit.ts +48 -144
  46. package/src/cli/config-repair.ts +186 -0
  47. package/src/cli/dashboard-page.ts +284 -0
  48. package/src/cli/dashboard-shell.ts +125 -0
  49. package/src/cli/identity.ts +12 -1
  50. package/src/cli/main.ts +68 -4
  51. package/src/cli/oauth-callback.ts +187 -0
  52. package/src/cli/oauth-exchange.ts +57 -15
  53. package/src/cli/oauth.ts +67 -177
  54. package/src/cli/output.ts +21 -5
  55. package/src/cli/provider-marks.ts +45 -0
  56. package/src/cli/runtime/open.ts +67 -49
  57. package/src/cli/runtime/registry.ts +60 -2
  58. package/src/cli/runtime/select.ts +26 -13
  59. package/src/cli/runtime/vault.ts +61 -0
  60. package/src/cli/runtime.ts +2 -1
  61. package/src/cli/selection.ts +357 -0
  62. package/src/cli/usage.ts +32 -9
  63. package/src/connectivity/auth/README.md +7 -1
  64. package/src/connectivity/auth/basic/index.ts +1 -1
  65. package/src/connectivity/auth/index.ts +14 -0
  66. package/src/connectivity/auth/oauth-authcode/broker.ts +26 -0
  67. package/src/connectivity/auth/oauth-authcode/index.ts +16 -2
  68. package/src/connectivity/auth/oauth-authcode/provider.ts +1 -1
  69. package/src/connectivity/auth/oauth-authcode/refresh.ts +3 -3
  70. package/src/connectivity/auth/oauth-jwt/README.md +33 -0
  71. package/src/connectivity/auth/oauth-jwt/index.ts +237 -0
  72. package/src/connectivity/auth/oauth-jwt/key.ts +148 -0
  73. package/src/connectivity/auth/resolve.ts +1 -1
  74. package/src/connectivity/auth/token.ts +11 -0
  75. package/src/connectivity/index.ts +2 -0
  76. package/src/connectivity/manifest/auth.ts +99 -2
  77. package/src/connectivity/manifest/identity.ts +12 -0
  78. package/src/connectivity/manifest/index.ts +3 -1
  79. package/src/connectivity/manifest/provider.ts +37 -8
  80. package/src/connectivity/manifest/requirements.ts +109 -6
  81. package/src/deployments/adapters/filesystem.ts +10 -1
  82. package/src/deployments/adapters/github-api.ts +106 -0
  83. package/src/deployments/adapters/github-commit.ts +103 -0
  84. package/src/deployments/adapters/github-repo.ts +356 -0
  85. package/src/deployments/adapters/github-testing.ts +258 -0
  86. package/src/deployments/adapters/github.ts +125 -0
  87. package/src/deployments/deploy.ts +48 -19
  88. package/src/deployments/driver.ts +8 -1
  89. package/src/deployments/gcp/driver.ts +9 -1
  90. package/src/deployments/gcp/survey.ts +3 -0
  91. package/src/deployments/knowledge.ts +119 -0
  92. package/src/deployments/prepare.ts +2 -2
  93. package/src/deployments/servable.ts +81 -0
  94. package/src/deployments/target.ts +3 -2
  95. package/src/deployments/upload.ts +2 -1
  96. package/src/dispatch/dispatch.ts +1 -1
  97. package/src/profile/authorization.ts +13 -4
  98. package/src/profile/identity.ts +60 -0
  99. package/src/profile/index.ts +15 -5
  100. package/src/profile/knowledge.ts +124 -0
  101. package/src/profile/load.ts +17 -5
  102. package/src/profile/primitives.ts +24 -1
  103. package/src/profile/schema.ts +72 -4
  104. package/src/profile/targets.ts +74 -114
  105. package/src/profile/workspace.ts +71 -84
  106. package/src/providers/google/calendar/index.ts +2 -0
  107. package/src/providers/google/contacts/index.ts +2 -0
  108. package/src/providers/google/docs/index.ts +2 -0
  109. package/src/providers/google/drive/index.ts +2 -0
  110. package/src/providers/google/gmail/index.ts +2 -0
  111. package/src/providers/google/gmail-imap/index.ts +125 -0
  112. package/src/providers/google/index.ts +2 -1
  113. package/src/providers/google/shared/oauth.ts +18 -6
  114. package/src/providers/google/shared/service-account.ts +110 -0
  115. package/src/providers/google/shared/setup.ts +5 -2
  116. package/src/providers/google/sheets/index.ts +2 -0
  117. package/src/providers/google/tasks/index.ts +2 -0
  118. package/src/providers/identity/provider.ts +166 -0
  119. package/src/providers/index.ts +3 -0
  120. package/src/providers/owner.ts +10 -2
  121. package/src/providers/scopes.ts +2 -0
  122. package/src/providers/setup/plan.ts +31 -9
  123. package/src/providers/setup/provider.ts +23 -0
  124. package/src/providers/slack/index.ts +81 -33
  125. package/src/providers/slack/oauth.ts +103 -0
  126. package/src/providers/slack/scopes.ts +37 -0
  127. package/src/server/container.ts +18 -1
  128. package/src/server/cors.ts +252 -0
  129. package/src/server/dashboard.ts +208 -0
  130. package/src/server/endpoint.ts +57 -4
  131. package/src/server/generation.ts +1 -0
  132. package/src/server/generations.ts +13 -2
  133. package/src/server/harness.ts +20 -3
  134. package/src/server/index.ts +76 -12
  135. package/src/server/mcp/build.ts +1 -1
  136. package/src/server/mcp/index.ts +1 -0
  137. package/src/server/mcp/instructions.ts +62 -5
  138. package/src/server/mcp/visibility.ts +42 -0
  139. package/src/stores/blobs/route.ts +123 -0
@@ -0,0 +1,66 @@
1
+ import { PASTED } from '#connectivity/auth/index.ts';
2
+ import type { ProviderManifest } from '#connectivity';
3
+ import type { SecretStore } from '#secrets';
4
+ import { ok, progress } from '../../output.ts';
5
+ import { terminalPrompter, type Prompter } from '../../prompt.ts';
6
+ import { askForSetup } from './setup.ts';
7
+
8
+ /**
9
+ * `--auth pasted_token`: a credential the operator already holds.
10
+ *
11
+ * The escape hatch, and it exists because the flow above can be refused by
12
+ * somebody who is not in the room. A Slack workspace on Enterprise Grid
13
+ * requires an admin to approve an app before it can authenticate anyone, so an
14
+ * operator whose admin has not approved the Lanes app cannot connect at all —
15
+ * while a user token from an app their workspace already trusts works
16
+ * perfectly. Removing the paste would take Slack away from exactly the people
17
+ * who have the least ability to do anything about it.
18
+ *
19
+ * Written in the blob shape the OAuth path writes, into the same ref, which is
20
+ * what makes it cost nothing downstream: no refresh token means
21
+ * `upstreamAccessToken` hands the stored value back untouched, and no
22
+ * `expires_at` means nothing calls it stale. `auth.kind` stays `oauth` because
23
+ * it describes what the vendor offers, not how this one connection was filled.
24
+ */
25
+ export async function authorisePastedToken(input: {
26
+ manifest: ProviderManifest;
27
+ connectionId: string;
28
+ credentials: SecretStore;
29
+ prompter?: Prompter;
30
+ }): Promise<void> {
31
+ const { manifest, connectionId, credentials } = input;
32
+ const prompter = input.prompter ?? terminalPrompter;
33
+ if (manifest.auth.kind !== 'oauth') return;
34
+
35
+ const prompts = (manifest.setup?.prompts ?? []).filter((prompt) => prompt.scope === 'connection');
36
+ if (prompts.length === 0) {
37
+ throw new Error(
38
+ `${manifest.name} has no pasted-credential path: it does not describe a token to ask you ` +
39
+ 'for. Authorise in a browser instead — drop --auth, or pass --auth oauth.',
40
+ );
41
+ }
42
+
43
+ const ref = `${manifest.id}/${connectionId}`;
44
+ const answers = await askForSetup(
45
+ manifest,
46
+ prompts,
47
+ `Stored at ${ref}, in the credential store — never in config.`,
48
+ prompter,
49
+ );
50
+
51
+ await credentials.set(
52
+ ref,
53
+ JSON.stringify({
54
+ access_token: answers.get(prompts[0]!.key)!,
55
+ token_type: 'Bearer',
56
+ // What the token can do was decided wherever it was minted and cannot be
57
+ // read back, so this records what was asked for and no more. The scope
58
+ // gate the browser path runs has nothing to show here — recorded as a
59
+ // weaker guarantee in security.md rather than papered over.
60
+ scope: manifest.auth.scopes.join(' '),
61
+ authorized_via: PASTED,
62
+ }),
63
+ );
64
+
65
+ progress(ok('token stored'));
66
+ }
@@ -32,7 +32,12 @@ export async function missingRequirements(
32
32
  return missing;
33
33
  }
34
34
 
35
- export type BlockedReason = 'needs_id' | 'needs_browser' | 'missing_credentials';
35
+ export type BlockedReason =
36
+ | 'needs_id'
37
+ | 'needs_browser'
38
+ /** A question only a person can answer, on a run with nobody to ask. */
39
+ | 'needs_terminal'
40
+ | 'missing_credentials';
36
41
 
37
42
  export interface Blocked {
38
43
  readonly reason: BlockedReason;
@@ -60,14 +65,53 @@ export async function preflight(input: {
60
65
  readonly manifest: ProviderManifest;
61
66
  readonly connectionId: string | undefined;
62
67
  readonly profile: string;
63
- readonly credentials: { has(ref: string): Promise<boolean> };
64
- /** How the operator spelled the target — `icloud`, or `gmail.main`. */
68
+ /** Which target's credential store the values have to be in. */
65
69
  readonly target: string;
70
+ readonly credentials: { has(ref: string): Promise<boolean> };
71
+ /**
72
+ * How the operator spelled the provider — `icloud`, or `gmail.main`.
73
+ *
74
+ * Called `spec` and not `target`, which is what it was: this file holds the
75
+ * only two meanings of that word in one scope, and the Lanes target is the one
76
+ * that decides which credential store a suggested command writes into. A
77
+ * transposition here produces a command that runs and stores a credential
78
+ * somewhere nobody looks.
79
+ */
80
+ readonly spec: string;
81
+ /**
82
+ * Which way in the operator chose, for a provider offering two.
83
+ *
84
+ * The whole reason this parameter exists is that the OAuth refusal below is
85
+ * about a *browser*, and the key route opens none. Without it a scripted
86
+ * `connect --auth <key method>` would be turned away by a message describing
87
+ * a step it does not perform.
88
+ */
89
+ readonly method?: 'oauth' | 'assertion' | 'pasted';
66
90
  }): Promise<Blocked | null> {
67
- const { manifest, connectionId, profile, target } = input;
68
- const rerun = `lanes link connect ${target} --profile ${profile}`;
91
+ const { manifest, connectionId, profile, target, spec } = input;
92
+ const method = input.method ?? 'oauth';
93
+ const rerun = `lanes link connect ${spec} --profile ${profile} --target ${target}`;
94
+ const assertion = manifest.auth.kind === 'oauth' ? manifest.auth.assertion : undefined;
69
95
 
70
- if (manifest.auth.kind === 'oauth') {
96
+ if (method === 'assertion' && assertion) {
97
+ // The key can be placed ahead of time; who it acts as cannot. That value
98
+ // lives inside the pointer `connect` writes, so where it is mandatory this
99
+ // run has a question and nobody to ask — and refusing here is better than
100
+ // storing a credential that reads every mailbox as empty.
101
+ if (assertion.delegation === 'required') {
102
+ return {
103
+ reason: 'needs_terminal',
104
+ message:
105
+ `${manifest.name} can only reach an account by acting as someone, and who that is has ` +
106
+ `to be typed.\n Nothing was written. Run this in a terminal:`,
107
+ needs: [],
108
+ then: `${rerun} --auth ${assertion.method}`,
109
+ };
110
+ }
111
+ } else if (manifest.auth.kind === 'oauth' && method !== 'pasted') {
112
+ // `pasted` opens no browser, so the refusal below does not describe it. It
113
+ // needs a value instead, which *can* be placed ahead of time — so it falls
114
+ // through to the requirement check and gets the `secrets set` line.
71
115
  return {
72
116
  reason: 'needs_browser',
73
117
  message:
@@ -78,7 +122,12 @@ export async function preflight(input: {
78
122
  };
79
123
  }
80
124
 
81
- const { requirements, needsId } = setupRequirements(manifest, connectionId, profile);
125
+ const { requirements, needsId } = setupRequirements(
126
+ manifest,
127
+ connectionId,
128
+ { profile, target },
129
+ { method },
130
+ );
82
131
 
83
132
  if (needsId) {
84
133
  return {
@@ -98,6 +147,9 @@ export async function preflight(input: {
98
147
  reason: 'missing_credentials',
99
148
  message: `${manifest.name} needs ${missing.length} value(s) in the credential store first.`,
100
149
  needs: missing,
101
- then: `${rerun}${connectionId ? ` --id ${connectionId}` : ''} --non-interactive`,
150
+ then:
151
+ `${rerun}${connectionId ? ` --id ${connectionId}` : ''}` +
152
+ `${method === 'assertion' && assertion ? ` --auth ${assertion.method}` : ''}` +
153
+ `${method === 'pasted' ? ' --auth pasted_token' : ''} --non-interactive`,
102
154
  };
103
155
  }
@@ -1,5 +1,5 @@
1
1
  import type { SecretStore } from '#secrets';
2
- import type { ProviderManifest, SetupPrompt } from '#connectivity';
2
+ import type { ProviderManifest, SetupDeclaration, SetupPrompt } from '#connectivity';
3
3
  import { credentialRefForConnection } from '#connectivity';
4
4
  import { ConfigDocument } from '../../config-edit.ts';
5
5
  import { ok, progress, style } from '../../output.ts';
@@ -27,8 +27,18 @@ import { terminalPrompter, type Prompter } from '../../prompt.ts';
27
27
  * an unregistered scope is refused at consent, and a disabled API consents
28
28
  * cleanly and then 403s on every call.
29
29
  */
30
- export function printSetup(manifest: ProviderManifest, note: string): void {
31
- const setup = manifest.setup;
30
+ export function printSetup(
31
+ manifest: ProviderManifest,
32
+ note: string,
33
+ /**
34
+ * Which walkthrough. Defaults to the provider's own, and is passed explicitly
35
+ * by a provider that has more than one way in — those steps are a different
36
+ * console doing a different job, and printing the browser-flow instructions
37
+ * to someone who chose a key would be worse than printing nothing.
38
+ */
39
+ declaration: SetupDeclaration | undefined = manifest.setup,
40
+ ): void {
41
+ const setup = declaration;
32
42
  if (!setup) return;
33
43
 
34
44
  progress();
@@ -55,15 +65,16 @@ export async function askForSetup(
55
65
  prompts: readonly SetupPrompt[],
56
66
  note: string,
57
67
  prompter: Prompter = terminalPrompter,
68
+ declaration: SetupDeclaration | undefined = manifest.setup,
58
69
  ): Promise<Map<string, string>> {
59
- const setup = manifest.setup;
70
+ const setup = declaration;
60
71
  if (!setup) {
61
72
  throw new Error(
62
73
  `Provider "${manifest.id}" needs a credential but declares no setup, so there is no way to learn what to ask you for. Add a setup block to its manifest.`,
63
74
  );
64
75
  }
65
76
 
66
- printSetup(manifest, note);
77
+ printSetup(manifest, note, setup);
67
78
 
68
79
  const answers = new Map<string, string>();
69
80
  for (const prompt of prompts) {
@@ -0,0 +1,34 @@
1
+ import type { Config, Resolution } from '#profile';
2
+ import { announce } from '../../output.ts';
3
+
4
+ /**
5
+ * The line `connect` prints before it acts.
6
+ *
7
+ * Split from the five steps for the reason `outcome.ts` gives: the orchestration
8
+ * is about vendors and credentials, this is about what a caller is told. It buys
9
+ * the same thing too — no runtime, no config file and no credential store are
10
+ * needed to check that the line appears and that `--json` stays parseable.
11
+ *
12
+ * `connect` was the only mutating command that never said which target it wrote
13
+ * to, and the only one that writes a credential into a real store.
14
+ *
15
+ * This file also *held* a warning, for the case where a bare `connect` resolved
16
+ * to a local target while the profile declared a deployed one. ADR-037 removed
17
+ * the case rather than the warning: a target is named on the command line or the
18
+ * command does not run, so there is no longer a selection the operator did not
19
+ * make. What replaced it sits one step earlier — `deployments/servable.ts`
20
+ * refuses a *deploy* that would send a profile the revision cannot open, which
21
+ * is the same mistake caught where it is still cheap.
22
+ */
23
+ export function announceConnectTarget(
24
+ runtime: { readonly resolution: Resolution; readonly config: Pick<Config, 'targets'> },
25
+ json?: boolean | undefined,
26
+ ): void {
27
+ // `emit`'s early return only protects lines printed *at* the emit, and this
28
+ // one has to precede the browser — so it carries its own guard, the one
29
+ // `audit.ts` and `owner/shared.ts` already use. `output.ts` gives the reason
30
+ // beside `emit`: a line of prose in front of a JSON document corrupts it.
31
+ if (json === true) return;
32
+
33
+ announce(runtime.resolution);
34
+ }
@@ -0,0 +1,258 @@
1
+ import type { IdentityEntry, ProfileSelection, Resolution } from '#profile';
2
+ import { ConfigDocument } from '../config-edit.ts';
3
+ import { ensureIdentityConnection, repairLines, repaired } from '../config-repair.ts';
4
+ import { announce, announceProfile, emit, ok, print, style, table, warn } from '../output.ts';
5
+ import { resolveProfile, resolveProfileOnly, type GlobalFlags } from '../runtime.ts';
6
+ import { nextAfterEdit, publishProfileEdit } from '../publish.ts';
7
+
8
+ /**
9
+ * `lanes link identity` — who this profile's owner is, for anything written as them.
10
+ *
11
+ * A control-plane command under ADR-007, and the reason is sharper here than for
12
+ * most of them: an agent able to edit this could edit the one fact that stops it
13
+ * signing as the wrong person. So the surface it feeds is read-only and the
14
+ * editing is here.
15
+ *
16
+ * Not to be confused with `src/cli/identity.ts`, which is about a *connection's*
17
+ * identity — which account it is, as the provider reports it at connect time.
18
+ * This is the owner's, as they declare it.
19
+ *
20
+ * Each command is a data function plus a printing wrapper, for the reason given
21
+ * in `commands/profile.ts`: `--json` needs the facts without the rendering.
22
+ */
23
+
24
+ /** What `add` did, including anything it provisioned to make it readable. */
25
+ export interface IdentityAdded {
26
+ readonly profile: string;
27
+ readonly entry: IdentityEntry;
28
+ /** Config edits made so the surface is reachable at all. Usually empty. */
29
+ readonly provisioned: readonly string[];
30
+ readonly total: number;
31
+ }
32
+
33
+ export interface IdentityListing {
34
+ readonly profile: string;
35
+ readonly entries: readonly IdentityEntry[];
36
+ /** Whether an agent can read them, or only the file can. */
37
+ readonly reachable: boolean;
38
+ }
39
+
40
+ /** Whether a rule list puts `identity.*` in force, in either spelling. */
41
+ function covers(rules: ReadonlyArray<{ capability: string }>): boolean {
42
+ return rules.some((rule) => rule.capability === '*' || rule.capability === 'identity.*');
43
+ }
44
+
45
+ /**
46
+ * Whether anything but the file can read this profile's identity.
47
+ *
48
+ * Reported rather than assumed, because a profile hand-edited to hold an
49
+ * `identity` block and nothing else is a real state and a likely one — and
50
+ * listing the entries without saying they are unreachable would be the most
51
+ * misleading thing this command could print.
52
+ */
53
+ function readable(config: {
54
+ connections: ReadonlyArray<{ provider: string }>;
55
+ policy: { allow: ReadonlyArray<{ capability: string }>; deny: ReadonlyArray<{ capability: string }> };
56
+ }): boolean {
57
+ return (
58
+ config.connections.some((connection) => connection.provider === 'identity') &&
59
+ covers(config.policy.allow) &&
60
+ !covers(config.policy.deny)
61
+ );
62
+ }
63
+
64
+ /**
65
+ * Declare one entry, and make sure something can read it.
66
+ *
67
+ * The provisioning is the part worth understanding. An `identity` block on its
68
+ * own is inert: `allowedConnections` finds no connection row for the provider
69
+ * and returns nothing *before* it consults policy, so the surface is absent from
70
+ * `tools/list` with nothing saying why — the same silent failure
71
+ * `ensureIdentityConnection` closes for `setup`. Writing the entry without it
72
+ * would leave an operator looking at a file that says exactly what they meant
73
+ * and an agent that cannot see a word of it.
74
+ *
75
+ * All three edits land in one `save()`, and that is not tidiness:
76
+ * `validateConfig` refuses a `policy.allow` rule naming a provider with no
77
+ * connection, so a run that wrote the rule and failed before the row would
78
+ * leave a profile that no longer loads. One save has no such middle.
79
+ */
80
+ export async function addIdentity(
81
+ kind: string,
82
+ value: string,
83
+ options: { note?: string | undefined } & GlobalFlags,
84
+ ): Promise<{ resolution: Resolution; added: IdentityAdded; published: string }> {
85
+ const { resolution, config, target } = await resolveProfile(options);
86
+
87
+ if (config.identity.some((entry) => entry.kind === kind && entry.value === value)) {
88
+ throw new Error(
89
+ `Profile "${resolution.profile}" already declares ${kind} "${value}".\n` +
90
+ ` To change its note: lanes link identity remove ${kind} ${value}, then add it again.`,
91
+ );
92
+ }
93
+
94
+ const document = await ConfigDocument.open(resolution.workspaceRoot, resolution.profile);
95
+
96
+ const entry: IdentityEntry = { kind, value, ...(options.note ? { note: options.note } : {}) };
97
+ document.addTo(['identity'], entry, { inline: true });
98
+
99
+ const repair = ensureIdentityConnection(document);
100
+ await document.save();
101
+
102
+ return {
103
+ resolution,
104
+ added: {
105
+ profile: resolution.profile,
106
+ entry,
107
+ provisioned: repaired(repair) ? repairLines(repair) : [],
108
+ total: config.identity.length + 1,
109
+ },
110
+ published: nextAfterEdit(await publishProfileEdit({ resolution, config, target })),
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Read the block, without opening a target.
116
+ *
117
+ * `resolveProfileOnly`, deliberately: this reads one field of a YAML file that
118
+ * is declared once and applies to every target the profile has, so demanding
119
+ * `--target` would be the ceremony ADR-037 warns is how a required flag stops
120
+ * being a guard. `SELECTION` files it as `profile`, and this is the call that
121
+ * makes that true rather than merely stated.
122
+ */
123
+ export async function readIdentity(
124
+ flags: GlobalFlags,
125
+ ): Promise<{ selection: ProfileSelection; listing: IdentityListing }> {
126
+ const { selection, config } = await resolveProfileOnly(flags);
127
+
128
+ return {
129
+ selection,
130
+ listing: {
131
+ profile: selection.profile,
132
+ entries: config.identity,
133
+ reachable: readable(config),
134
+ },
135
+ };
136
+ }
137
+
138
+ /**
139
+ * Drop one entry, leaving the connection row and the grant behind.
140
+ *
141
+ * Deliberately: removing the last entry would otherwise silently revoke the
142
+ * surface, and the next `identity add` would have to widen policy again. A
143
+ * command that quietly narrows what an agent may read, and a later one that
144
+ * quietly re-widens it, is worse than a surface that reports nothing declared.
145
+ */
146
+ export async function removeIdentity(
147
+ kind: string,
148
+ value: string,
149
+ flags: GlobalFlags,
150
+ ): Promise<{
151
+ resolution: Resolution;
152
+ removed: IdentityEntry;
153
+ remaining: number;
154
+ published: string;
155
+ }> {
156
+ const { resolution, config, target } = await resolveProfile(flags);
157
+
158
+ const index = config.identity.findIndex((entry) => entry.kind === kind && entry.value === value);
159
+ if (index === -1) {
160
+ throw new Error(
161
+ `Profile "${resolution.profile}" does not declare ${kind} "${value}".\n` +
162
+ ` Run: lanes link identity list`,
163
+ );
164
+ }
165
+
166
+ const document = await ConfigDocument.open(resolution.workspaceRoot, resolution.profile);
167
+ document.removeFrom(['identity'], index);
168
+ await document.save();
169
+
170
+ return {
171
+ resolution,
172
+ removed: config.identity[index] as IdentityEntry,
173
+ remaining: config.identity.length - 1,
174
+ published: nextAfterEdit(await publishProfileEdit({ resolution, config, target })),
175
+ };
176
+ }
177
+
178
+ export async function identityAdd(
179
+ kind: string,
180
+ value: string,
181
+ options: { note?: string | undefined; json?: boolean } & GlobalFlags,
182
+ ): Promise<void> {
183
+ const { resolution, added, published } = await addIdentity(kind, value, options);
184
+
185
+ return emit(options.json, added, () => {
186
+ announce(resolution);
187
+ print(ok(`${added.entry.kind} ${style.bold(added.entry.value)}`));
188
+ if (added.entry.note) print(` note ${added.entry.note}`);
189
+
190
+ // Named rather than folded into the success line: this widened what an
191
+ // agent may read, and a command that broadens a grant says which grant.
192
+ for (const line of added.provisioned) print(` ${style.dim(line)}`);
193
+ if (added.provisioned.length > 0) {
194
+ print(style.dim(' an agent can now read this profile’s identity'));
195
+ }
196
+
197
+ print(style.dim(` ${published}`));
198
+ });
199
+ }
200
+
201
+ export async function identityList(options: { json?: boolean } & GlobalFlags): Promise<void> {
202
+ const { selection, listing } = await readIdentity(options);
203
+
204
+ return emit(options.json, listing, () => {
205
+ announceProfile(selection);
206
+
207
+ if (listing.entries.length === 0) {
208
+ print(style.dim('This profile declares no identity.'));
209
+ print(
210
+ style.dim('Declare one with: lanes link identity add name "Your Name" --note "when to use it"'),
211
+ );
212
+ return;
213
+ }
214
+
215
+ // Grouped by kind for the same reason the provider groups them: the mistake
216
+ // this exists to prevent is a name used where an address was wanted.
217
+ const kinds = [...new Set(listing.entries.map((entry) => entry.kind))];
218
+ const ordered = kinds.flatMap((kind) =>
219
+ listing.entries.filter((entry) => entry.kind === kind),
220
+ );
221
+
222
+ table(
223
+ ordered.map((entry) => [
224
+ ` ${style.dim(entry.kind)}`,
225
+ style.bold(entry.value),
226
+ entry.note ? style.dim(entry.note) : '',
227
+ ]),
228
+ );
229
+
230
+ if (!listing.reachable) {
231
+ print('');
232
+ print(warn('declared, but no agent can read it'));
233
+ print(
234
+ style.dim(
235
+ ' the identity surface needs a connection row and an identity.* allow rule;',
236
+ ),
237
+ );
238
+ print(style.dim(' adding an entry with this command writes both.'));
239
+ }
240
+ });
241
+ }
242
+
243
+ export async function identityRemove(
244
+ kind: string,
245
+ value: string,
246
+ options: { json?: boolean } & GlobalFlags,
247
+ ): Promise<void> {
248
+ const { resolution, removed, remaining, published } = await removeIdentity(kind, value, options);
249
+
250
+ return emit(options.json, { removed, remaining }, () => {
251
+ announce(resolution);
252
+ print(ok(`removed ${removed.kind} ${style.bold(removed.value)}`));
253
+ if (remaining === 0) {
254
+ print(style.dim(' the surface stays granted, and now reports nothing declared'));
255
+ }
256
+ print(style.dim(` ${published}`));
257
+ });
258
+ }