@lanes-sh/link 0.2.2 → 0.3.1

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 (143) hide show
  1. package/README.md +22 -8
  2. package/instructions/skills/lanes-link/SKILL.md +42 -14
  3. package/package.json +1 -1
  4. package/src/cli/argv.ts +50 -0
  5. package/src/cli/brand.ts +178 -0
  6. package/src/cli/callback-page.ts +108 -128
  7. package/src/cli/commands/connect/accounts.ts +5 -0
  8. package/src/cli/commands/connect/assertion.ts +187 -0
  9. package/src/cli/commands/connect/authorise.ts +61 -17
  10. package/src/cli/commands/connect/client.ts +37 -9
  11. package/src/cli/commands/connect/discover.ts +94 -0
  12. package/src/cli/commands/connect/family.ts +72 -0
  13. package/src/cli/commands/connect/index.ts +113 -115
  14. package/src/cli/commands/connect/method.ts +237 -0
  15. package/src/cli/commands/connect/outcome.ts +42 -1
  16. package/src/cli/commands/connect/pasted-token.ts +66 -0
  17. package/src/cli/commands/connect/requirements.ts +60 -8
  18. package/src/cli/commands/connect/setup.ts +16 -5
  19. package/src/cli/commands/connect/target-note.ts +34 -0
  20. package/src/cli/commands/identity.ts +258 -0
  21. package/src/cli/commands/knowledge/index.ts +390 -0
  22. package/src/cli/commands/knowledge/migrate.ts +180 -0
  23. package/src/cli/commands/knowledge/setup.ts +144 -0
  24. package/src/cli/commands/knowledge.ts +10 -0
  25. package/src/cli/commands/mcp/harnesses.ts +16 -2
  26. package/src/cli/commands/mcp/register.ts +9 -1
  27. package/src/cli/commands/mcp/stdio.ts +21 -0
  28. package/src/cli/commands/operate/dashboard.ts +107 -0
  29. package/src/cli/commands/operate/findings.ts +151 -0
  30. package/src/cli/commands/operate/inspect.ts +63 -164
  31. package/src/cli/commands/operate/outputs.ts +38 -11
  32. package/src/cli/commands/operate/policy.ts +7 -7
  33. package/src/cli/commands/operate/serve.ts +3 -0
  34. package/src/cli/commands/operate/status.ts +108 -1
  35. package/src/cli/commands/operate/token.ts +1 -1
  36. package/src/cli/commands/operate.ts +2 -0
  37. package/src/cli/commands/profile/declare.ts +154 -0
  38. package/src/cli/commands/profile/removal.ts +17 -0
  39. package/src/cli/commands/profile/remove.ts +5 -5
  40. package/src/cli/commands/profile.ts +83 -35
  41. package/src/cli/commands/secrets.ts +6 -6
  42. package/src/cli/commands/setup.ts +22 -6
  43. package/src/cli/commands/sync.ts +262 -0
  44. package/src/cli/commands/target.ts +65 -83
  45. package/src/cli/config-edit.ts +53 -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/dispatch-owner.ts +93 -0
  50. package/src/cli/identity.ts +12 -1
  51. package/src/cli/main.ts +90 -61
  52. package/src/cli/nearest.ts +45 -0
  53. package/src/cli/oauth-callback.ts +187 -0
  54. package/src/cli/oauth-exchange.ts +57 -15
  55. package/src/cli/oauth.ts +67 -177
  56. package/src/cli/output.ts +21 -5
  57. package/src/cli/provider-marks.ts +45 -0
  58. package/src/cli/runtime/open.ts +74 -51
  59. package/src/cli/runtime/registry.ts +60 -2
  60. package/src/cli/runtime/select.ts +26 -13
  61. package/src/cli/runtime/vault.ts +61 -0
  62. package/src/cli/runtime.ts +2 -1
  63. package/src/cli/selection.ts +365 -0
  64. package/src/cli/usage.ts +42 -11
  65. package/src/connectivity/auth/README.md +7 -1
  66. package/src/connectivity/auth/basic/index.ts +1 -1
  67. package/src/connectivity/auth/index.ts +14 -0
  68. package/src/connectivity/auth/oauth-authcode/broker.ts +26 -0
  69. package/src/connectivity/auth/oauth-authcode/index.ts +16 -2
  70. package/src/connectivity/auth/oauth-authcode/provider.ts +1 -1
  71. package/src/connectivity/auth/oauth-authcode/refresh.ts +3 -3
  72. package/src/connectivity/auth/oauth-jwt/README.md +33 -0
  73. package/src/connectivity/auth/oauth-jwt/index.ts +237 -0
  74. package/src/connectivity/auth/oauth-jwt/key.ts +148 -0
  75. package/src/connectivity/auth/resolve.ts +1 -1
  76. package/src/connectivity/auth/token.ts +11 -0
  77. package/src/connectivity/index.ts +2 -0
  78. package/src/connectivity/manifest/auth.ts +99 -2
  79. package/src/connectivity/manifest/identity.ts +12 -0
  80. package/src/connectivity/manifest/index.ts +3 -1
  81. package/src/connectivity/manifest/provider.ts +37 -8
  82. package/src/connectivity/manifest/requirements.ts +109 -6
  83. package/src/deployments/adapters/filesystem.ts +10 -1
  84. package/src/deployments/adapters/github-api.ts +106 -0
  85. package/src/deployments/adapters/github-commit.ts +103 -0
  86. package/src/deployments/adapters/github-repo.ts +356 -0
  87. package/src/deployments/adapters/github-testing.ts +258 -0
  88. package/src/deployments/adapters/github.ts +125 -0
  89. package/src/deployments/deploy.ts +94 -114
  90. package/src/deployments/discover.ts +103 -0
  91. package/src/deployments/driver.ts +8 -1
  92. package/src/deployments/gcp/driver.ts +3 -1
  93. package/src/deployments/knowledge.ts +119 -0
  94. package/src/deployments/prepare.ts +12 -6
  95. package/src/deployments/report.ts +117 -0
  96. package/src/deployments/servable.ts +82 -0
  97. package/src/deployments/serving.ts +165 -0
  98. package/src/deployments/sync-apply.ts +276 -0
  99. package/src/deployments/sync.ts +136 -0
  100. package/src/deployments/target.ts +3 -2
  101. package/src/deployments/upload.ts +19 -12
  102. package/src/dispatch/dispatch.ts +1 -1
  103. package/src/profile/deployments.ts +80 -0
  104. package/src/profile/identity.ts +60 -0
  105. package/src/profile/index.ts +23 -5
  106. package/src/profile/knowledge.ts +124 -0
  107. package/src/profile/load.ts +17 -5
  108. package/src/profile/primitives.ts +24 -1
  109. package/src/profile/schema.ts +81 -3
  110. package/src/profile/targets.ts +122 -109
  111. package/src/profile/workspace.ts +139 -79
  112. package/src/providers/google/calendar/index.ts +2 -0
  113. package/src/providers/google/contacts/index.ts +2 -0
  114. package/src/providers/google/docs/index.ts +2 -0
  115. package/src/providers/google/drive/index.ts +2 -0
  116. package/src/providers/google/gmail/index.ts +2 -0
  117. package/src/providers/google/gmail-imap/index.ts +125 -0
  118. package/src/providers/google/index.ts +2 -1
  119. package/src/providers/google/shared/oauth.ts +18 -6
  120. package/src/providers/google/shared/service-account.ts +110 -0
  121. package/src/providers/google/shared/setup.ts +21 -3
  122. package/src/providers/google/sheets/index.ts +2 -0
  123. package/src/providers/google/tasks/index.ts +2 -0
  124. package/src/providers/identity/provider.ts +166 -0
  125. package/src/providers/index.ts +3 -0
  126. package/src/providers/owner.ts +10 -2
  127. package/src/providers/scopes.ts +2 -0
  128. package/src/providers/setup/plan.ts +31 -9
  129. package/src/providers/setup/provider.ts +23 -0
  130. package/src/providers/slack/index.ts +81 -33
  131. package/src/providers/slack/oauth.ts +103 -0
  132. package/src/providers/slack/scopes.ts +37 -0
  133. package/src/server/container.ts +18 -1
  134. package/src/server/cors.ts +252 -0
  135. package/src/server/dashboard.ts +208 -0
  136. package/src/server/endpoint.ts +45 -1
  137. package/src/server/generations.ts +11 -2
  138. package/src/server/harness.ts +7 -0
  139. package/src/server/index.ts +45 -6
  140. package/src/server/mcp/index.ts +1 -0
  141. package/src/server/mcp/instructions.ts +28 -1
  142. package/src/server/mcp/visibility.ts +33 -0
  143. package/src/stores/blobs/route.ts +123 -0
@@ -0,0 +1,180 @@
1
+ import { ConfigError, KNOWLEDGE_LAYOUT, knowledgeRoot, type KnowledgeConfig } from '#profile';
2
+ import type { BlobStore } from '#stores/blobs';
3
+ import { commitFiles, type CommitFile } from '#deployments/adapters/github-commit.ts';
4
+ import type { GithubRepository } from '#deployments/adapters/github-repo.ts';
5
+ import { print, style } from '../../output.ts';
6
+
7
+ /**
8
+ * Moving what is already stored, in whichever direction.
9
+ *
10
+ * Four steps, and the order is the whole of the safety: **read, commit, verify,
11
+ * then delete.** A migration that deleted first, or that trusted a write it had
12
+ * not read back, would leave an owner with no memory and a plausible-looking
13
+ * success line. Nothing here deletes anything until the destination has been
14
+ * re-read and found to hold every byte that was sent.
15
+ *
16
+ * The write is **one commit**, through the Git Data API rather than one
17
+ * Contents call per file. Two hundred commits each reading "Store memory
18
+ * a-note.md" is not a history anybody opens, and it is two hundred round trips
19
+ * against a limit this endpoint now shares.
20
+ */
21
+
22
+ export interface Movable {
23
+ /** The key in the local store — `main/note.md`, or `triage/SKILL.md`. */
24
+ readonly key: string;
25
+ /** Where it goes in the repository. */
26
+ readonly path: string;
27
+ readonly area: 'memory' | 'skills';
28
+ readonly data: Uint8Array;
29
+ }
30
+
31
+ /**
32
+ * Everything this profile has stored locally, ready to move.
33
+ *
34
+ * Memory is read from the profile's blob root under the provider's own
35
+ * namespace rather than from the connections the config declares. A connection
36
+ * removed from the file leaves its entries behind, and those entries are still
37
+ * the owner's — leaving them on a disk that is about to stop being consulted is
38
+ * how they would be lost without anything saying so.
39
+ */
40
+ export async function localContents(
41
+ storage: BlobStore,
42
+ skills: BlobStore,
43
+ knowledge: KnowledgeConfig,
44
+ ): Promise<Movable[]> {
45
+ const found: Movable[] = [];
46
+ const prefix = `${KNOWLEDGE_LAYOUT.memory}/`;
47
+
48
+ for (const entry of await storage.list(prefix)) {
49
+ const data = await storage.get(entry.key);
50
+ if (data === null) continue; // Listed then deleted; not worth failing over.
51
+ const key = entry.key.slice(prefix.length);
52
+ found.push({
53
+ key,
54
+ area: 'memory',
55
+ path: `${knowledgeRoot(knowledge, 'memory')}/${key}`,
56
+ data,
57
+ });
58
+ }
59
+
60
+ for (const entry of await skills.list()) {
61
+ const data = await skills.get(entry.key);
62
+ if (data === null) continue;
63
+ found.push({
64
+ key: entry.key,
65
+ area: 'skills',
66
+ path: `${knowledgeRoot(knowledge, 'skills')}/${entry.key}`,
67
+ data,
68
+ });
69
+ }
70
+
71
+ return found;
72
+ }
73
+
74
+ /** Paths the repository already holds that a migration would overwrite. */
75
+ export async function collisions(
76
+ repository: GithubRepository,
77
+ movable: readonly Movable[],
78
+ ): Promise<string[]> {
79
+ const { entries } = await repository.entries();
80
+ return movable.filter((item) => entries.has(item.path)).map((item) => item.path);
81
+ }
82
+
83
+ /**
84
+ * Commit everything, then read it back before saying so.
85
+ *
86
+ * The verification is not ceremony. `commitFiles` reports what GitHub answered,
87
+ * and what this needs to know is what GitHub *stored* — which is a different
88
+ * question the moment a tree is built against a base that moved, or a blob
89
+ * upload succeeded and its tree entry did not. One extra request buys the right
90
+ * to delete the only other copy.
91
+ */
92
+ export async function moveIn(
93
+ repository: GithubRepository,
94
+ movable: readonly Movable[],
95
+ message: string,
96
+ ): Promise<void> {
97
+ const files: CommitFile[] = movable.map((item) => ({ path: item.path, data: item.data }));
98
+ await commitFiles(repository, files, [], message);
99
+
100
+ const { entries } = await repository.entries();
101
+ const missing = movable.filter((item) => entries.get(item.path)?.size !== item.data.byteLength);
102
+
103
+ if (missing.length > 0) {
104
+ throw new ConfigError(
105
+ `The commit landed but ${missing.length} of ${movable.length} files did not read back ` +
106
+ `correctly (first: ${missing[0]?.path}). Nothing local has been deleted and the config ` +
107
+ 'has not been changed, so this profile is exactly as it was. Try again.',
108
+ );
109
+ }
110
+ }
111
+
112
+ /** Remove what has been verified elsewhere. Never called before `moveIn`. */
113
+ export async function removeLocal(
114
+ storage: BlobStore,
115
+ skills: BlobStore,
116
+ movable: readonly Movable[],
117
+ ): Promise<void> {
118
+ for (const item of movable) {
119
+ if (item.area === 'memory') await storage.delete(`${KNOWLEDGE_LAYOUT.memory}/${item.key}`);
120
+ else await skills.delete(item.key);
121
+ }
122
+ }
123
+
124
+ /**
125
+ * The other direction: everything in the repository, written back to the
126
+ * profile's own storage.
127
+ *
128
+ * Nothing is deleted from the repository. It is version control — the history
129
+ * holds every entry regardless of what the tip says — so removing the files
130
+ * would buy no privacy and would throw away the copy somebody may still want.
131
+ * The command says so rather than deciding it silently.
132
+ */
133
+ export async function moveOut(
134
+ repository: GithubRepository,
135
+ knowledge: KnowledgeConfig,
136
+ storage: BlobStore,
137
+ skills: BlobStore,
138
+ ): Promise<{ memory: number; skills: number }> {
139
+ const { entries } = await repository.entries();
140
+ const roots = {
141
+ memory: `${knowledgeRoot(knowledge, 'memory')}/`,
142
+ skills: `${knowledgeRoot(knowledge, 'skills')}/`,
143
+ } as const;
144
+
145
+ const counts = { memory: 0, skills: 0 };
146
+
147
+ for (const entry of entries.values()) {
148
+ for (const area of ['memory', 'skills'] as const) {
149
+ if (!entry.path.startsWith(roots[area])) continue;
150
+
151
+ const key = entry.path.slice(roots[area].length);
152
+ const data = await repository.blob(entry.sha);
153
+
154
+ if (area === 'memory') await storage.put(`${KNOWLEDGE_LAYOUT.memory}/${key}`, data);
155
+ else await skills.put(key, data);
156
+
157
+ counts[area] += 1;
158
+ }
159
+ }
160
+
161
+ return counts;
162
+ }
163
+
164
+ /** `12 skills and 47 memory entries`, or whichever half of that is non-zero. */
165
+ export function summarise(movable: readonly Movable[]): string {
166
+ const skills = movable.filter((item) => item.area === 'skills').length;
167
+ const memory = movable.length - skills;
168
+
169
+ const parts = [
170
+ ...(skills > 0 ? [`${skills} skill${skills === 1 ? '' : 's'}`] : []),
171
+ ...(memory > 0 ? [`${memory} memory entr${memory === 1 ? 'y' : 'ies'}`] : []),
172
+ ];
173
+ return parts.length === 0 ? 'nothing' : parts.join(' and ');
174
+ }
175
+
176
+ export function printMovable(movable: readonly Movable[]): void {
177
+ for (const item of movable) {
178
+ print(` ${style.green('→')} ${style.dim(`${item.path}`)}`);
179
+ }
180
+ }
@@ -0,0 +1,144 @@
1
+ import { ConfigError, type KnowledgeConfig } from '#profile';
2
+ import type { SecretStore } from '#secrets';
3
+ import { GithubRepository, type RepositoryFacts } from '#deployments/adapters/github-repo.ts';
4
+ import type { FetchLike } from '#deployments/knowledge.ts';
5
+ import { heading, print, style } from '../../output.ts';
6
+ import { askSecret, isInteractive } from '../../prompt.ts';
7
+
8
+ /**
9
+ * Getting a token, and finding out whether the repository is a sane place to
10
+ * put somebody's memory.
11
+ *
12
+ * Both halves happen **before** anything is written — no config edit, no
13
+ * commit, no local deletion. A half-switched profile is the worst outcome
14
+ * available here: the config says one thing, the bytes are somewhere else, and
15
+ * the failure looks like an empty memory rather than like an error.
16
+ */
17
+
18
+ /**
19
+ * The steps, shown when there is no token yet.
20
+ *
21
+ * The same shape as `src/providers/github/index.ts`'s `setup.steps`, and
22
+ * deliberately not the same token. That one talks to GitHub's MCP server and
23
+ * needs Contents **read**; this one writes and needs Contents **write**. Two
24
+ * permissions, two lifetimes, and two things to revoke separately — revoking
25
+ * an MCP connection must not quietly empty somebody's memory.
26
+ */
27
+ const STEPS = [
28
+ 'Create a repository for this, or pick one you already have. **Make it private** — memory entries are yours.',
29
+ 'Open https://github.com/settings/personal-access-tokens and choose "Generate new token".',
30
+ 'Name it "Lanes Link knowledge" — the name is how you revoke this one later without touching your other tokens — and set an expiry you are willing to renew.',
31
+ 'Resource owner: yourself, or the organisation that owns the repository. An organisation may require an owner to approve the token before it works.',
32
+ 'Repository access: **only** the one repository this will store in.',
33
+ 'Permissions: Contents (read and write), Metadata (read, added for you). Nothing else is needed.',
34
+ 'Generate, then copy the token. GitHub shows it once, and it starts with github_pat_.',
35
+ ];
36
+
37
+ export function printSetupSteps(repo: string): void {
38
+ heading('A token for this repository');
39
+ print(style.dim(` Storing memory and skills in ${repo} needs a token that may write to it.`));
40
+ print('');
41
+ for (const [index, step] of STEPS.entries()) {
42
+ print(` ${style.dim(`${index + 1}.`)} ${step.replace(/\*\*(.+?)\*\*/g, (_, inner: string) => style.bold(inner))}`);
43
+ }
44
+ print('');
45
+ }
46
+
47
+ /**
48
+ * The token this profile will use, asked for only when it is not already
49
+ * stored.
50
+ *
51
+ * Read from a terminal without echo, or from stdin when there is no terminal —
52
+ * never from argv, which lands in shell history and in `ps` output while the
53
+ * command runs. The same rule `lanes link secrets set` states and for the same
54
+ * reasons.
55
+ */
56
+ export async function resolveToken(
57
+ secrets: SecretStore,
58
+ ref: string,
59
+ repo: string,
60
+ /** `--profile x --target y`, so the command in the refusal is one that runs. */
61
+ selection: string,
62
+ options: { replace?: boolean | undefined } = {},
63
+ ): Promise<string> {
64
+ if (!options.replace) {
65
+ const stored = await secrets.get(ref);
66
+ if (stored) return stored;
67
+ }
68
+
69
+ if (!process.stdin.isTTY) {
70
+ const piped = (await Bun.stdin.text()).trim();
71
+ if (piped) return piped;
72
+
73
+ throw new ConfigError(
74
+ `No token stored at "${ref}", and stdin is a terminal-less pipe with nothing in it.\n` +
75
+ ` printf %s "<github_pat_…>" | lanes link knowledge use github --repo ${repo}${selection}\n` +
76
+ ' Pass it this way rather than as an argument: an argument is in your shell history.',
77
+ );
78
+ }
79
+
80
+ if (!isInteractive()) {
81
+ throw new ConfigError(`No token stored at "${ref}", and there is nobody to ask for one.`);
82
+ }
83
+
84
+ printSetupSteps(repo);
85
+ const token = await askSecret(' GitHub personal access token');
86
+ if (!token) throw new ConfigError('No token given, so nothing was changed.');
87
+ return token;
88
+ }
89
+
90
+ /**
91
+ * What the repository is, and whether this is somewhere memory may go.
92
+ *
93
+ * Three refusals, and the first is the one worth the flag. A public repository
94
+ * is a working store and a catastrophic one: every entry the owner has written,
95
+ * and every procedure naming their accounts and their colleagues, world-readable
96
+ * and indexed. It is refused rather than warned about, because a warning during
97
+ * a migration is read after the migration.
98
+ */
99
+ export async function probe(
100
+ repository: GithubRepository,
101
+ options: { allowPublic?: boolean | undefined },
102
+ ): Promise<{ facts: RepositoryFacts; viewer: string }> {
103
+ const viewer = await repository.viewer();
104
+ const facts = await repository.facts();
105
+
106
+ if (!facts.canPush) {
107
+ throw new ConfigError(
108
+ `The token can read ${facts.fullName} but not write to it. Memory and skills are written, ` +
109
+ 'not just read, so this needs Contents: read and write.\n' +
110
+ ' Regenerate at https://github.com/settings/personal-access-tokens, then run this again.',
111
+ );
112
+ }
113
+
114
+ if (!facts.private && options.allowPublic !== true) {
115
+ throw new ConfigError(
116
+ `${facts.fullName} is public. Memory entries are the owner's own notes and a skill names ` +
117
+ 'the accounts and people it operates on — a public repository publishes both, permanently ' +
118
+ 'and searchably.\n' +
119
+ ' Make it private, or pass --allow-public if you genuinely mean to publish them.',
120
+ );
121
+ }
122
+
123
+ return { facts, viewer };
124
+ }
125
+
126
+ /**
127
+ * A client for a repository, built from what the command was told.
128
+ *
129
+ * The one construction point in this command, so `use github` and `use local`
130
+ * cannot end up talking to different clients — and so a test has one thing to
131
+ * hand a `fetch` to.
132
+ */
133
+ export function repositoryFor(
134
+ knowledge: KnowledgeConfig,
135
+ token: string,
136
+ call?: FetchLike,
137
+ ): GithubRepository {
138
+ return new GithubRepository({
139
+ repo: knowledge.repo,
140
+ token,
141
+ ...(knowledge.branch !== undefined ? { branch: knowledge.branch } : {}),
142
+ ...(call ? { fetch: call } : {}),
143
+ });
144
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * `lanes link knowledge` — which store holds this profile's memory and skills.
3
+ *
4
+ * A barrel, matching `owner.ts` and `operate.ts` beside it, so `main.ts` binds
5
+ * one path per noun rather than one per file. The command itself is three files
6
+ * under `knowledge/`: the token and the repository probe, the migration, and
7
+ * the grammar that puts them in order.
8
+ */
9
+
10
+ export { knowledgeShow, knowledgeUse, type KnowledgeFlags } from './knowledge/index.ts';
@@ -25,6 +25,16 @@ export interface AddInput {
25
25
  readonly token: string;
26
26
  readonly tokenEnv: string;
27
27
  readonly scope: string;
28
+ /**
29
+ * Which selection this registration is for.
30
+ *
31
+ * Not part of the URL — a deployed endpoint serves every profile in its bucket
32
+ * and each call names one. It is here because the shell commands a harness is
33
+ * told to run afterwards do need it, and a `lanes link token show` without it
34
+ * substitutes to nothing.
35
+ */
36
+ readonly profile: string;
37
+ readonly target: string;
28
38
  }
29
39
 
30
40
  export interface Harness {
@@ -125,10 +135,14 @@ export const HARNESSES: readonly Harness[] = [
125
135
  // installs to both unchanged. Codex has no subagent directory, so it gets
126
136
  // the skill and not the scout — and `mcp add` says which it did.
127
137
  skills: () => join(CODEX_HOME(), 'skills'),
128
- afterAdd: ({ tokenEnv }) => [
138
+ afterAdd: ({ tokenEnv, profile, target }) => [
129
139
  `Codex reads the token from $${tokenEnv} when it starts, so set it where Codex will see it:`,
130
140
  '',
131
- ` export ${tokenEnv}="$(lanes link token show --raw)"`,
141
+ // Both flags, and this line is the reason they matter more here than
142
+ // anywhere else: an unresolvable substitution yields the empty string, the
143
+ // header becomes "Bearer ", and the only symptom is a 401 that reads as a
144
+ // bad token rather than a command that refused.
145
+ ` export ${tokenEnv}="$(lanes link token show --raw --profile ${profile} --target ${target})"`,
132
146
  '',
133
147
  'Add that to your shell profile. This is the better half of the bargain: the token never',
134
148
  'reaches ~/.codex/config.toml, and a "lanes link token rotate" is picked up on next launch',
@@ -78,7 +78,15 @@ export async function mcpAdd(target: string | undefined, options: McpAddOptions)
78
78
  // registered loopback with the agent: a registration that reports success,
79
79
  // names the right server, and points at a port with nothing behind it.
80
80
  const url = await endpointUrl(runtime.config, runtime.target);
81
- const input: AddInput = { name, url, token, tokenEnv, scope };
81
+ const input: AddInput = {
82
+ name,
83
+ url,
84
+ token,
85
+ tokenEnv,
86
+ scope,
87
+ profile: runtime.resolution.profile,
88
+ target: runtime.resolution.target,
89
+ };
82
90
 
83
91
  // Registering an endpoint that is down is legitimate — the harness stores
84
92
  // the address and connects on demand — but it is usually a mistake worth
@@ -20,6 +20,27 @@ import { startStdioEndpoint } from '#server/endpoint.ts';
20
20
  export async function mcpStdio(
21
21
  flags: GlobalFlags & { only?: boolean | undefined },
22
22
  ): Promise<void> {
23
+ // Its own refusal, written for the only person who will read it: someone
24
+ // looking at a client's MCP log after the server "disconnected". There is no
25
+ // command line here to add a flag to — the client's config file is the only
26
+ // place that can carry one — so the message has to be a paste rather than an
27
+ // instruction, and the generic "pass --profile" would be advice with nowhere
28
+ // to follow it.
29
+ if (!flags.profile || !flags.target) {
30
+ printErr(
31
+ 'lanes link mcp stdio needs --profile and --target in its "args".\n' +
32
+ '\n' +
33
+ ' This client spawns the endpoint, so its config file is the only place\n' +
34
+ ' that can say which profile and target it serves:\n' +
35
+ '\n' +
36
+ ' "lanes-link": {\n' +
37
+ ' "command": "/path/to/lanes",\n' +
38
+ ' "args": ["link","mcp","stdio","--profile","<name>","--target","<name>"]\n' +
39
+ ' }\n',
40
+ );
41
+ process.exit(1);
42
+ }
43
+
23
44
  const endpoint = await startStdioEndpoint({
24
45
  flags,
25
46
  ...(flags.only ? { only: true } : {}),
@@ -0,0 +1,107 @@
1
+ import { deploymentIdentity, endpointHealth, localUrl } from '../../endpoint-url.ts';
2
+ import { defaultOpenBrowser } from '../../oauth.ts';
3
+ import { announce, ok, print, style, warn } from '../../output.ts';
4
+ import {
5
+ ensureProfileToken,
6
+ openRuntime,
7
+ resolveProfile,
8
+ type GlobalFlags,
9
+ } from '../../runtime.ts';
10
+
11
+ /**
12
+ * `lanes link dashboard` — open the page this endpoint serves.
13
+ *
14
+ * The command exists because the page cannot be reached without it. A browser
15
+ * navigating to `/dashboard` carries no `Authorization` header, so the token has
16
+ * to arrive some other way, and the only place it already exists is the
17
+ * credential store this command can read. It puts it in the URL once; the
18
+ * endpoint exchanges it for a session cookie and redirects to a URL without it.
19
+ *
20
+ * Local only, deliberately. See `#server/dashboard.ts` — ADR-018 leaves a
21
+ * deployed instance with no door a person at a browser can come through, and a
22
+ * command that opened a loopback URL for a target that is not there would be
23
+ * reporting success for a page nobody can load.
24
+ */
25
+
26
+ export interface DashboardFlags extends GlobalFlags {
27
+ /** Print the URL instead of opening it. */
28
+ readonly print?: boolean | undefined;
29
+ }
30
+
31
+ export async function dashboard(flags: DashboardFlags): Promise<void> {
32
+ // `resolveProfile` rather than `openRuntime`, and the order is the point.
33
+ // Opening a runtime opens that target's adapters, so a deployed target sends
34
+ // this command to Secret Manager and a bucket before it can say the one thing
35
+ // it needs to — that the dashboard is not there. The refusal below would
36
+ // still be correct and nobody would ever read it, because a storage 403
37
+ // arrives first. This is the seam `runtime.ts` documents `deploy` and
38
+ // `secrets push` stopping at, for the same reason.
39
+ const { resolution, config, target } = await resolveProfile(flags);
40
+ announce(resolution);
41
+
42
+ const deployed = deploymentIdentity(config.targets[target]?.deploy);
43
+ if (deployed) {
44
+ throw new Error(
45
+ `Target "${target}" is deployed to ${deployed.platform}, and the dashboard is served only ` +
46
+ 'by a local endpoint — a browser carries no bearer token, and Cloud Run\'s own gate ' +
47
+ 'admits only a Google-signed identity token it cannot mint either (ADR-018).\n' +
48
+ `What a deployed target can answer: ` +
49
+ `lanes link status --profile ${resolution.profile} --target ${target}`,
50
+ );
51
+ }
52
+
53
+ const runtime = await openRuntime(flags);
54
+
55
+ try {
56
+ const { token } = await ensureProfileToken(runtime.credentials, runtime.config.auth.token_ref);
57
+
58
+ const url = new URL(localUrl(runtime.config));
59
+ url.pathname = '/dashboard';
60
+ url.searchParams.set('k', token);
61
+ // Named rather than left to the endpoint's primary, so the page opens on
62
+ // the profile this command resolved and printed above.
63
+ url.searchParams.set('profile', runtime.resolution.profile);
64
+
65
+ // The page is served by a running endpoint or by nothing. Saying so here
66
+ // beats opening a browser at a connection error.
67
+ const live = await endpointHealth(localUrl(runtime.config), token);
68
+ if (!live) {
69
+ // Both flags on both lines. They read as correct without them only
70
+ // because they were written while a missing one still resolved to the
71
+ // workspace default; with nothing to fall back on (ADR-037) each is a
72
+ // paste that refuses, and this one is printed at the moment somebody is
73
+ // least able to guess what it wanted.
74
+ const where = `--profile ${runtime.resolution.profile} --target ${target}`;
75
+
76
+ print(warn(`nothing is serving this port — run: lanes link start ${where}`));
77
+ print(style.dim(` then: lanes link dashboard ${where}`));
78
+ return;
79
+ }
80
+
81
+ if (!live.profiles.includes(runtime.resolution.profile)) {
82
+ // `start --only` serves one profile. The URL would 404 on the rest, which
83
+ // is honest but unhelpful without being told why.
84
+ print(
85
+ warn(
86
+ `the endpoint on this port serves ${live.profiles.join(', ')} — ` +
87
+ `not "${runtime.resolution.profile}"`,
88
+ ),
89
+ );
90
+ return;
91
+ }
92
+
93
+ if (flags.print) {
94
+ // Printed, not opened: for a terminal on a machine with no browser, and
95
+ // for anyone who would rather see what they are about to open. It carries
96
+ // the token, which is why it is behind a flag rather than always shown.
97
+ print(url.href);
98
+ return;
99
+ }
100
+
101
+ defaultOpenBrowser(url.href);
102
+ print(ok(`opened ${style.bold(`${url.origin}/dashboard`)}`));
103
+ print(style.dim(' the link carries a one-time key; the page swaps it for a session cookie.'));
104
+ } finally {
105
+ await runtime.close();
106
+ }
107
+ }
@@ -0,0 +1,151 @@
1
+ import { ownerPrincipal } from '#auth';
2
+ import type { DiscoveredCapability } from '#connectivity';
3
+ import { BROKERED } from '#connectivity/auth/index.ts';
4
+ import { allowedConnections } from '#policy';
5
+ import { toPolicyDocument } from '#registry';
6
+ import { capabilityDiff, discoveryProbe } from '../../runtime/discovery.ts';
7
+ import type { openRuntime } from '../../runtime.ts';
8
+
9
+ /**
10
+ * The two things `doctor` has to work out rather than simply read.
11
+ *
12
+ * Everything else in `inspect.ts` is a lookup — is the token there, does the
13
+ * credential resolve, does the connection name a provider that exists — and
14
+ * these two are analyses: one dates a credential from what the OAuth provider
15
+ * stamped on it, and the other diffs what an upstream now offers against what
16
+ * the endpoint is serving. They are the length in that file, and they are the
17
+ * part that changes for reasons the gate order has nothing to do with.
18
+ */
19
+
20
+ /**
21
+ * How old a stored OAuth credential is.
22
+ *
23
+ * Derived from the `expires_at` the OAuth provider stamps when saving tokens.
24
+ * Returns null for anything that is not a token blob — an app password has no
25
+ * meaningful age, and guessing one would produce a confusing warning.
26
+ */
27
+ export async function credentialAge(
28
+ credentials: { get(ref: string): Promise<string | null> },
29
+ ref: string,
30
+ ): Promise<{ days: number; brokered: boolean } | null> {
31
+ const raw = await credentials.get(ref);
32
+ if (!raw) return null;
33
+
34
+ try {
35
+ const parsed = JSON.parse(raw) as {
36
+ expires_at?: number;
37
+ expires_in?: number;
38
+ authorized_via?: string;
39
+ };
40
+ if (typeof parsed.expires_at !== 'number') return null;
41
+
42
+ const issued = parsed.expires_at - (parsed.expires_in ?? 3600) * 1000;
43
+ return {
44
+ days: Math.floor((Date.now() - issued) / 86_400_000),
45
+ brokered: parsed.authorized_via === BROKERED,
46
+ };
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Capabilities the upstream has grown since you connected.
54
+ *
55
+ * This is what replaced pinning an allow line per tool. `connect` writes
56
+ * `provider.*`, which is readable and which a vendor can quietly widen by
57
+ * shipping a new tool — so the widening has to be *visible* somewhere, and
58
+ * doctor is where you look when you want to know what changed.
59
+ *
60
+ * Compared against the discovery cache rather than against config, because the
61
+ * cache is what the running server actually serves. Failures are reported and
62
+ * skipped: an upstream being down is not a reason for doctor to fail, and it is
63
+ * already obvious from every other check.
64
+ *
65
+ * This used to open `if (kind !== 'mcp') continue`, which meant it watched the
66
+ * one connector kind whose capabilities this repository does not ship and
67
+ * ignored the two it does. Drive gained three operations in a commit and the
68
+ * endpoint served six for as long as the operator did not re-authorise; the
69
+ * check written to make exactly that visible never ran. `http` is no longer
70
+ * listed here because it no longer can drift — `runtime/discovery.ts` re-derives
71
+ * it at startup — so what is left is the kinds that genuinely still cache.
72
+ */
73
+ export async function reportCapabilityDrift(
74
+ runtime: Awaited<ReturnType<typeof openRuntime>>,
75
+ say: (message: string) => void,
76
+ ): Promise<void> {
77
+ const policy = toPolicyDocument(runtime.config);
78
+ const principal = ownerPrincipal(runtime.config.instance.profile).id;
79
+
80
+ for (const entry of runtime.registry.list()) {
81
+ const connection = runtime.config.connections.find((c) => c.provider === entry.manifest.id);
82
+ if (!connection) continue;
83
+
84
+ const connector = runtime.connectorFor(entry.manifest.id, connection.id);
85
+ const probe = discoveryProbe(entry.manifest, connector);
86
+ // Re-derived every startup, so what is served is what is shipped. Probing
87
+ // it here would only ever compare a value against itself.
88
+ if (!probe || probe.cost === 'offline') continue;
89
+
90
+ const cached = runtime.registry.discovered(entry.manifest.id) ?? [];
91
+
92
+ let live: readonly DiscoveredCapability[];
93
+ try {
94
+ live = await probe.run();
95
+ } catch {
96
+ continue;
97
+ }
98
+
99
+ // A provider that was never discovered serves nothing at all, which reads
100
+ // from the outside like the provider being broken. Skipping it silently is
101
+ // how that stays a mystery.
102
+ if (cached.length === 0) {
103
+ say(
104
+ `${entry.manifest.name} has never been discovered, so none of its ${live.length} ` +
105
+ `capability(ies) are served — run: lanes link connect ${entry.manifest.id}`,
106
+ );
107
+ continue;
108
+ }
109
+
110
+ const diff = capabilityDiff(cached, live);
111
+
112
+ if (diff.added.length > 0) {
113
+ // Through the same `allowedConnections` the dispatcher enforces with,
114
+ // rather than reading `policy.allow` directly. The predicate this replaced
115
+ // ignored its own `name` argument, so the answer was all-or-nothing, and
116
+ // it never consulted `deny` — telling an operator their policy covered a
117
+ // capability they had explicitly withheld.
118
+ const reachable = diff.added.filter(
119
+ (name) =>
120
+ allowedConnections(
121
+ `${entry.manifest.id}.${name}`,
122
+ [`${entry.manifest.id}.${connection.id}`],
123
+ principal,
124
+ policy,
125
+ ).length > 0,
126
+ );
127
+
128
+ say(
129
+ `${entry.manifest.name} has ${diff.added.length} new capability(ies) since you connected: ` +
130
+ `${diff.added.slice(0, 5).join(', ')}${diff.added.length > 5 ? ', …' : ''}` +
131
+ (reachable.length > 0
132
+ ? `\n Your policy covers ${reachable.length} of them — lanes link policy deny ${entry.manifest.id}.<name> to withhold one.`
133
+ : ''),
134
+ );
135
+ }
136
+
137
+ if (diff.removed.length > 0) {
138
+ say(`${entry.manifest.name} no longer offers: ${diff.removed.slice(0, 5).join(', ')}`);
139
+ }
140
+
141
+ // The case ADR-017 said `plan` structurally could not report: a schema or a
142
+ // description moved, the name did not, and the endpoint is serving the old
143
+ // shape until it restarts.
144
+ if (diff.changed.length > 0) {
145
+ say(
146
+ `${entry.manifest.name} changed the shape of: ${diff.changed.slice(0, 5).join(', ')}` +
147
+ `\n Run: lanes link connect ${entry.manifest.id}, then restart the endpoint.`,
148
+ );
149
+ }
150
+ }
151
+ }