@lanes-sh/link 0.1.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 (276) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +144 -0
  3. package/bin/lanes +42 -0
  4. package/instructions/agents/lanes-link-scout.md +73 -0
  5. package/instructions/skills/lanes-link/SKILL.md +187 -0
  6. package/package.json +95 -0
  7. package/src/audit/chain.ts +278 -0
  8. package/src/audit/conformance.ts +374 -0
  9. package/src/audit/fanout.ts +97 -0
  10. package/src/audit/index.ts +218 -0
  11. package/src/audit/stdout.ts +60 -0
  12. package/src/auth/index.ts +220 -0
  13. package/src/auth/oauth/metadata.ts +75 -0
  14. package/src/auth/oauth/server.ts +385 -0
  15. package/src/auth/oauth/store.ts +215 -0
  16. package/src/auth/oidc.ts +206 -0
  17. package/src/auth/remote.ts +72 -0
  18. package/src/cli/argv.ts +94 -0
  19. package/src/cli/callback-page.ts +256 -0
  20. package/src/cli/commands/connect/accounts.ts +94 -0
  21. package/src/cli/commands/connect/authorise.ts +298 -0
  22. package/src/cli/commands/connect/client.ts +284 -0
  23. package/src/cli/commands/connect/index.ts +398 -0
  24. package/src/cli/commands/connect/outcome.ts +119 -0
  25. package/src/cli/commands/connect/requirements.ts +103 -0
  26. package/src/cli/commands/connect/scopes-gate.ts +146 -0
  27. package/src/cli/commands/connect/settle.ts +136 -0
  28. package/src/cli/commands/connect/setup.ts +276 -0
  29. package/src/cli/commands/mcp/assets.ts +189 -0
  30. package/src/cli/commands/mcp/harnesses.ts +143 -0
  31. package/src/cli/commands/mcp/list.ts +70 -0
  32. package/src/cli/commands/mcp/register.ts +199 -0
  33. package/src/cli/commands/mcp/stdio.ts +57 -0
  34. package/src/cli/commands/mcp.ts +22 -0
  35. package/src/cli/commands/operate/attach.ts +121 -0
  36. package/src/cli/commands/operate/audit.ts +119 -0
  37. package/src/cli/commands/operate/inspect.ts +355 -0
  38. package/src/cli/commands/operate/outputs.ts +206 -0
  39. package/src/cli/commands/operate/policy.ts +80 -0
  40. package/src/cli/commands/operate/serve.ts +55 -0
  41. package/src/cli/commands/operate/status.ts +133 -0
  42. package/src/cli/commands/operate/token.ts +70 -0
  43. package/src/cli/commands/operate.ts +27 -0
  44. package/src/cli/commands/owner/memory.ts +110 -0
  45. package/src/cli/commands/owner/shared.ts +125 -0
  46. package/src/cli/commands/owner/skills.ts +92 -0
  47. package/src/cli/commands/owner/vault.ts +140 -0
  48. package/src/cli/commands/owner.ts +46 -0
  49. package/src/cli/commands/profile/removal.ts +278 -0
  50. package/src/cli/commands/profile/remove.ts +315 -0
  51. package/src/cli/commands/profile.ts +156 -0
  52. package/src/cli/commands/secrets.ts +176 -0
  53. package/src/cli/commands/setup.ts +150 -0
  54. package/src/cli/commands/target.ts +310 -0
  55. package/src/cli/config-edit.ts +397 -0
  56. package/src/cli/endpoint-url.ts +77 -0
  57. package/src/cli/identity.ts +109 -0
  58. package/src/cli/lanes.ts +78 -0
  59. package/src/cli/main.ts +333 -0
  60. package/src/cli/oauth-error.ts +13 -0
  61. package/src/cli/oauth-exchange.ts +146 -0
  62. package/src/cli/oauth.ts +354 -0
  63. package/src/cli/output.ts +184 -0
  64. package/src/cli/prompt.ts +180 -0
  65. package/src/cli/publish.ts +185 -0
  66. package/src/cli/runtime/discovery.ts +123 -0
  67. package/src/cli/runtime/open.ts +342 -0
  68. package/src/cli/runtime/registry.ts +185 -0
  69. package/src/cli/runtime/select.ts +124 -0
  70. package/src/cli/runtime.ts +34 -0
  71. package/src/cli/scopes.ts +63 -0
  72. package/src/cli/usage.ts +111 -0
  73. package/src/cli/version.ts +25 -0
  74. package/src/connectivity/auth/README.md +36 -0
  75. package/src/connectivity/auth/api-key/index.ts +43 -0
  76. package/src/connectivity/auth/authorize.ts +49 -0
  77. package/src/connectivity/auth/basic/index.ts +68 -0
  78. package/src/connectivity/auth/bearer/index.ts +13 -0
  79. package/src/connectivity/auth/credential.ts +19 -0
  80. package/src/connectivity/auth/header/index.ts +18 -0
  81. package/src/connectivity/auth/index.ts +35 -0
  82. package/src/connectivity/auth/none/index.ts +12 -0
  83. package/src/connectivity/auth/oauth-authcode/broker.ts +261 -0
  84. package/src/connectivity/auth/oauth-authcode/index.ts +64 -0
  85. package/src/connectivity/auth/oauth-authcode/provider.ts +279 -0
  86. package/src/connectivity/auth/oauth-authcode/refresh.ts +118 -0
  87. package/src/connectivity/auth/resolve.ts +61 -0
  88. package/src/connectivity/auth/strategy/index.ts +14 -0
  89. package/src/connectivity/capability.ts +164 -0
  90. package/src/connectivity/connector.ts +175 -0
  91. package/src/connectivity/context.ts +77 -0
  92. package/src/connectivity/index.ts +94 -0
  93. package/src/connectivity/mail/attachments.ts +368 -0
  94. package/src/connectivity/mail/compose.ts +73 -0
  95. package/src/connectivity/mail/index.ts +40 -0
  96. package/src/connectivity/mail/message.ts +82 -0
  97. package/src/connectivity/mail/nodemailer.d.ts +83 -0
  98. package/src/connectivity/mail/staging.ts +143 -0
  99. package/src/connectivity/mail/url.ts +301 -0
  100. package/src/connectivity/manifest/auth.ts +135 -0
  101. package/src/connectivity/manifest/bundles.ts +23 -0
  102. package/src/connectivity/manifest/connector.ts +168 -0
  103. package/src/connectivity/manifest/credential-ref.ts +73 -0
  104. package/src/connectivity/manifest/identity.ts +43 -0
  105. package/src/connectivity/manifest/index.ts +46 -0
  106. package/src/connectivity/manifest/primitives.ts +20 -0
  107. package/src/connectivity/manifest/provider.ts +208 -0
  108. package/src/connectivity/manifest/requirements.ts +146 -0
  109. package/src/connectivity/manifest/setup.ts +55 -0
  110. package/src/connectivity/provider.ts +163 -0
  111. package/src/connectivity/transports/README.md +33 -0
  112. package/src/connectivity/transports/composite/index.ts +68 -0
  113. package/src/connectivity/transports/dav/calendar.ts +217 -0
  114. package/src/connectivity/transports/dav/capabilities.ts +151 -0
  115. package/src/connectivity/transports/dav/client.ts +226 -0
  116. package/src/connectivity/transports/dav/contacts.ts +75 -0
  117. package/src/connectivity/transports/dav/ical.ts +412 -0
  118. package/src/connectivity/transports/dav/index.ts +143 -0
  119. package/src/connectivity/transports/dav/operations.ts +43 -0
  120. package/src/connectivity/transports/dav/request.ts +161 -0
  121. package/src/connectivity/transports/dav/xml.ts +123 -0
  122. package/src/connectivity/transports/factory.ts +181 -0
  123. package/src/connectivity/transports/fs/capabilities.ts +88 -0
  124. package/src/connectivity/transports/fs/commands.ts +258 -0
  125. package/src/connectivity/transports/fs/index.ts +121 -0
  126. package/src/connectivity/transports/fs/operations.ts +45 -0
  127. package/src/connectivity/transports/fs/paths.ts +120 -0
  128. package/src/connectivity/transports/fs/result.ts +12 -0
  129. package/src/connectivity/transports/http/index.ts +255 -0
  130. package/src/connectivity/transports/imap/attachment.ts +166 -0
  131. package/src/connectivity/transports/imap/capabilities.ts +158 -0
  132. package/src/connectivity/transports/imap/client.ts +398 -0
  133. package/src/connectivity/transports/imap/commands.ts +385 -0
  134. package/src/connectivity/transports/imap/index.ts +152 -0
  135. package/src/connectivity/transports/imap/operations.ts +64 -0
  136. package/src/connectivity/transports/imap/parse.ts +130 -0
  137. package/src/connectivity/transports/imap/parser.ts +272 -0
  138. package/src/connectivity/transports/imap/result.ts +15 -0
  139. package/src/connectivity/transports/imap/send.ts +92 -0
  140. package/src/connectivity/transports/imap/socket.ts +111 -0
  141. package/src/connectivity/transports/imap/utf7.ts +136 -0
  142. package/src/connectivity/transports/index.ts +20 -0
  143. package/src/connectivity/transports/local/index.ts +173 -0
  144. package/src/connectivity/transports/mcp/index.ts +215 -0
  145. package/src/deployments/README.md +63 -0
  146. package/src/deployments/adapters/audit-blob.ts +203 -0
  147. package/src/deployments/adapters/filesystem.ts +184 -0
  148. package/src/deployments/adapters/gcp-secret-manager.ts +492 -0
  149. package/src/deployments/adapters/gcs.ts +191 -0
  150. package/src/deployments/adapters/otlp.ts +128 -0
  151. package/src/deployments/adapters/s3.ts +195 -0
  152. package/src/deployments/azure/README.md +21 -0
  153. package/src/deployments/bootstrap.ts +177 -0
  154. package/src/deployments/deploy.ts +290 -0
  155. package/src/deployments/driver.ts +157 -0
  156. package/src/deployments/drivers.ts +35 -0
  157. package/src/deployments/gcp/Dockerfile +70 -0
  158. package/src/deployments/gcp/cloudbuild.yaml +31 -0
  159. package/src/deployments/gcp/driver.ts +175 -0
  160. package/src/deployments/gcp/gcloud.ts +178 -0
  161. package/src/deployments/gcp/provision.ts +290 -0
  162. package/src/deployments/gcp/survey.ts +319 -0
  163. package/src/deployments/local/README.md +12 -0
  164. package/src/deployments/prepare.ts +257 -0
  165. package/src/deployments/steps.ts +137 -0
  166. package/src/deployments/target.ts +295 -0
  167. package/src/deployments/upload.ts +207 -0
  168. package/src/dispatch/context.ts +195 -0
  169. package/src/dispatch/dispatch.ts +350 -0
  170. package/src/dispatch/index.ts +32 -0
  171. package/src/dispatch/staging.ts +102 -0
  172. package/src/policy/index.ts +179 -0
  173. package/src/policy/limits.ts +77 -0
  174. package/src/profile/authorization.ts +81 -0
  175. package/src/profile/files.ts +71 -0
  176. package/src/profile/index.ts +76 -0
  177. package/src/profile/layout.ts +123 -0
  178. package/src/profile/load.ts +199 -0
  179. package/src/profile/primitives.ts +45 -0
  180. package/src/profile/schema.ts +347 -0
  181. package/src/profile/secret-detection.ts +162 -0
  182. package/src/profile/targets.ts +152 -0
  183. package/src/profile/workspace.ts +262 -0
  184. package/src/providers/custom/index.ts +21 -0
  185. package/src/providers/custom/load.ts +115 -0
  186. package/src/providers/custom/template.ts +156 -0
  187. package/src/providers/example/provider.ts +207 -0
  188. package/src/providers/google/calendar/index.ts +66 -0
  189. package/src/providers/google/calendar/redact.ts +40 -0
  190. package/src/providers/google/contacts/index.ts +50 -0
  191. package/src/providers/google/contacts/redact.ts +21 -0
  192. package/src/providers/google/docs/index.ts +45 -0
  193. package/src/providers/google/drive/hints.ts +28 -0
  194. package/src/providers/google/drive/index.ts +34 -0
  195. package/src/providers/google/drive/redact.ts +39 -0
  196. package/src/providers/google/drive-mcp/index.ts +21 -0
  197. package/src/providers/google/gmail/api.ts +42 -0
  198. package/src/providers/google/gmail/attachment.ts +142 -0
  199. package/src/providers/google/gmail/hints.ts +55 -0
  200. package/src/providers/google/gmail/index.ts +112 -0
  201. package/src/providers/google/gmail/redact.ts +56 -0
  202. package/src/providers/google/gmail/send.ts +365 -0
  203. package/src/providers/google/gmail-mcp/index.ts +35 -0
  204. package/src/providers/google/index.ts +10 -0
  205. package/src/providers/google/shared/oauth.ts +122 -0
  206. package/src/providers/google/shared/scopes.ts +99 -0
  207. package/src/providers/google/shared/setup.ts +80 -0
  208. package/src/providers/google/sheets/hints.ts +45 -0
  209. package/src/providers/google/sheets/index.ts +70 -0
  210. package/src/providers/google/sheets/redact.ts +45 -0
  211. package/src/providers/google/specs/calendar.v3.json +1829 -0
  212. package/src/providers/google/specs/docs.v1.json +381 -0
  213. package/src/providers/google/specs/drive.v3.json +2208 -0
  214. package/src/providers/google/specs/gmail.v1.json +2578 -0
  215. package/src/providers/google/specs/people.v1.json +506 -0
  216. package/src/providers/google/specs/sheets.v4.json +1269 -0
  217. package/src/providers/google/specs/tasks.v1.json +840 -0
  218. package/src/providers/google/specs/vendor.ts +661 -0
  219. package/src/providers/google/tasks/index.ts +53 -0
  220. package/src/providers/google/tasks/redact.ts +34 -0
  221. package/src/providers/harness.ts +95 -0
  222. package/src/providers/icloud/calendar/index.ts +27 -0
  223. package/src/providers/icloud/contacts/index.ts +17 -0
  224. package/src/providers/icloud/drive/index.ts +47 -0
  225. package/src/providers/icloud/index.ts +8 -0
  226. package/src/providers/icloud/mail/index.ts +37 -0
  227. package/src/providers/icloud/shared/setup.ts +66 -0
  228. package/src/providers/index.ts +93 -0
  229. package/src/providers/linear/index.ts +11 -0
  230. package/src/providers/linear/scopes.ts +7 -0
  231. package/src/providers/memory/provider.ts +429 -0
  232. package/src/providers/notion/index.ts +19 -0
  233. package/src/providers/owner.ts +49 -0
  234. package/src/providers/scopes.ts +26 -0
  235. package/src/providers/setup/plan.ts +141 -0
  236. package/src/providers/setup/provider.ts +323 -0
  237. package/src/providers/shared/frontmatter.ts +119 -0
  238. package/src/providers/skills/provider.ts +283 -0
  239. package/src/providers/skills/store.ts +252 -0
  240. package/src/providers/vault/provider.ts +194 -0
  241. package/src/registry/index.ts +36 -0
  242. package/src/registry/policy-bridge.ts +32 -0
  243. package/src/registry/reconcile.ts +313 -0
  244. package/src/registry/registry.ts +240 -0
  245. package/src/secrets/document.ts +293 -0
  246. package/src/secrets/index.ts +154 -0
  247. package/src/secrets/system.ts +173 -0
  248. package/src/secrets/vault.ts +336 -0
  249. package/src/server/attachments.ts +197 -0
  250. package/src/server/container.ts +96 -0
  251. package/src/server/edge.ts +53 -0
  252. package/src/server/endpoint.ts +352 -0
  253. package/src/server/generations.ts +362 -0
  254. package/src/server/harness.ts +400 -0
  255. package/src/server/index.ts +331 -0
  256. package/src/server/logging.ts +41 -0
  257. package/src/server/mcp/build.ts +68 -0
  258. package/src/server/mcp/icon.ts +145 -0
  259. package/src/server/mcp/index.ts +32 -0
  260. package/src/server/mcp/instructions.ts +245 -0
  261. package/src/server/mcp/naming.ts +39 -0
  262. package/src/server/mcp/prompts.ts +78 -0
  263. package/src/server/mcp/resources.ts +106 -0
  264. package/src/server/mcp/routing.ts +117 -0
  265. package/src/server/mcp/schema.ts +78 -0
  266. package/src/server/mcp/tools.ts +186 -0
  267. package/src/server/mcp/visibility.ts +132 -0
  268. package/src/server/oauth.ts +222 -0
  269. package/src/server/rebinding.ts +53 -0
  270. package/src/server/stdio.ts +192 -0
  271. package/src/stores/blobs/conformance.ts +223 -0
  272. package/src/stores/blobs/index.ts +125 -0
  273. package/src/stores/blobs/testing.ts +49 -0
  274. package/src/stores/state/index.ts +247 -0
  275. package/src/stores/state/keys.ts +68 -0
  276. package/src/stores/state/testing.ts +41 -0
@@ -0,0 +1,180 @@
1
+ import { createInterface } from 'node:readline';
2
+ import { style } from './output.ts';
3
+
4
+ /**
5
+ * Interactive input.
6
+ *
7
+ * Secrets are read without echoing. That is not theatre: a client secret typed
8
+ * into a terminal ends up in scrollback, in a screen recording, and in whatever
9
+ * the terminal emulator persists between sessions.
10
+ */
11
+
12
+ export class PromptCancelled extends Error {
13
+ constructor() {
14
+ super('Cancelled.');
15
+ this.name = 'PromptCancelled';
16
+ }
17
+ }
18
+
19
+ /**
20
+ * Whether there is anyone to answer.
21
+ *
22
+ * Exported so a command can *decide* rather than discover: `deploy` asks its
23
+ * setup questions every run, which is only the right default when a person is
24
+ * there. Piped into a script, the same run has to fall back to what the config
25
+ * already says instead of throwing at the first prompt.
26
+ */
27
+ export function isInteractive(): boolean {
28
+ return process.stdin.isTTY === true;
29
+ }
30
+
31
+ function assertInteractive(): void {
32
+ if (!process.stdin.isTTY) {
33
+ throw new Error(
34
+ 'This command needs an interactive terminal to collect credentials. ' +
35
+ 'For scripted setup, write the credentials to the store first and re-run.',
36
+ );
37
+ }
38
+ }
39
+
40
+ export async function ask(question: string): Promise<string> {
41
+ assertInteractive();
42
+
43
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
44
+ try {
45
+ const answer = await new Promise<string>((resolve, reject) => {
46
+ rl.question(`${question}: `, resolve);
47
+ rl.once('SIGINT', () => reject(new PromptCancelled()));
48
+ });
49
+ return answer.trim();
50
+ } finally {
51
+ rl.close();
52
+ // `close()` releases the interface but leaves stdin resumed and referenced,
53
+ // which keeps the event loop alive: the command finishes, prints, and then
54
+ // sits there until Ctrl-C. `askSecret` already pauses; this is the same
55
+ // cleanup for the same reason.
56
+ process.stdin.pause();
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Read a line without echoing it.
62
+ *
63
+ * Raw mode rather than a muted writable stream: muting the output still lets
64
+ * the terminal's own line editing echo, which defeats the point on some
65
+ * emulators. Reading keystrokes directly is the only way to be sure nothing
66
+ * reaches the screen.
67
+ */
68
+ export async function askSecret(question: string): Promise<string> {
69
+ assertInteractive();
70
+
71
+ process.stdout.write(`${question}: `);
72
+
73
+ const stdin = process.stdin;
74
+ const wasRaw = stdin.isRaw === true;
75
+ stdin.setRawMode(true);
76
+ stdin.resume();
77
+ stdin.setEncoding('utf8');
78
+
79
+ return new Promise<string>((resolve, reject) => {
80
+ let value = '';
81
+
82
+ const cleanup = (): void => {
83
+ stdin.removeListener('data', onData);
84
+ stdin.setRawMode(wasRaw);
85
+ stdin.pause();
86
+ process.stdout.write('\n');
87
+ };
88
+
89
+ const onData = (chunk: string): void => {
90
+ for (const char of chunk) {
91
+ switch (char) {
92
+ case '\r':
93
+ case '\n':
94
+ cleanup();
95
+ resolve(value.trim());
96
+ return;
97
+ case '': // Ctrl-C
98
+ cleanup();
99
+ reject(new PromptCancelled());
100
+ return;
101
+ case '': // backspace
102
+ case '\b':
103
+ value = value.slice(0, -1);
104
+ break;
105
+ default:
106
+ // Ignore other control characters rather than storing them.
107
+ if (char >= ' ') value += char;
108
+ }
109
+ }
110
+ };
111
+
112
+ stdin.on('data', onData);
113
+ });
114
+ }
115
+
116
+ export async function confirm(question: string, defaultYes = true): Promise<boolean> {
117
+ const answer = await ask(`${question} ${style.dim(defaultYes ? '[Y/n]' : '[y/N]')}`);
118
+ if (answer.length === 0) return defaultYes;
119
+ return /^y(es)?$/i.test(answer);
120
+ }
121
+
122
+ /**
123
+ * Where a command collects an answer from.
124
+ *
125
+ * The same seam as `EndpointReporter`, for the same reason: `connect` had one
126
+ * caller, a terminal, and the terminal was reached directly. An agent driving
127
+ * the CLI is a second caller, and it cannot answer a prompt — so the choice of
128
+ * where answers come from has to be the caller's rather than a module-level
129
+ * import of `process.stdin`.
130
+ *
131
+ * `interactive` is on the interface rather than inferred by callers, because
132
+ * the useful refusal message differs per question. A missing credential points
133
+ * at `secrets set`; a broad scope points at `--accept-broad-scopes`. Only the
134
+ * caller knows which, and a prompter that threw a generic error would bury it.
135
+ */
136
+ export interface Prompter {
137
+ readonly interactive: boolean;
138
+ ask(question: string): Promise<string>;
139
+ askSecret(question: string): Promise<string>;
140
+ confirm(question: string, defaultYes?: boolean): Promise<boolean>;
141
+ }
142
+
143
+ /** Today's behaviour: a real terminal, refusing when there is not one. */
144
+ export const terminalPrompter: Prompter = {
145
+ interactive: true,
146
+ ask,
147
+ askSecret,
148
+ confirm,
149
+ };
150
+
151
+ /**
152
+ * A prompter that answers nothing, and says what to do instead.
153
+ *
154
+ * It deliberately does not read flags, environment variables, or a file. A
155
+ * credential passed as an argument is in the shell history, in `ps` output
156
+ * while the process runs, and in any transcript of the session — which is why
157
+ * `secrets set` already refuses argv and reads stdin. Adding a second, weaker
158
+ * path here would contradict that rule one directory away, and an agent-typed
159
+ * flag lands in a transcript too.
160
+ *
161
+ * Reaching this at all means the preflight missed something: a non-interactive
162
+ * `connect` resolves every declared value before it starts. So the message
163
+ * names the command that lists them rather than guessing at the one value.
164
+ */
165
+ export function nonInteractivePrompter(planCommand: string): Prompter {
166
+ const refuse = (question: string): never => {
167
+ throw new Error(
168
+ `Cannot ask for "${question.trim()}" — this run is non-interactive.\n` +
169
+ ` Store what this provider needs first, then re-run. To list it:\n` +
170
+ ` ${planCommand}`,
171
+ );
172
+ };
173
+
174
+ return {
175
+ interactive: false,
176
+ ask: async (question) => refuse(question),
177
+ askSecret: async (question) => refuse(question),
178
+ confirm: async (question) => refuse(question),
179
+ };
180
+ }
@@ -0,0 +1,185 @@
1
+ import type { SecretStore } from '#secrets';
2
+ import type { Config } from '#profile';
3
+ import { publishWorkspace } from '#deployments/upload.ts';
4
+ import { openSecretStoreFor, type Runtime } from './runtime.ts';
5
+ import { endpointUrl } from './endpoint-url.ts';
6
+
7
+ /**
8
+ * Getting an edit to the endpoint that has to serve it.
9
+ *
10
+ * A config edit used to reach a deployed endpoint by exactly one route: roll a
11
+ * new revision. That put "which accounts can this reach" on the same command as
12
+ * "what code does this run", and made connecting an account cost a Docker
13
+ * build. ADR-029 separates them — an edit publishes itself and says so, and
14
+ * `deploy` goes back to being about code.
15
+ *
16
+ * Two steps, and the second is allowed to fail. Publishing is what makes the
17
+ * change *durable* for the next instance to boot; notifying is what makes it
18
+ * visible to the one already running. An endpoint that is not up yet, is
19
+ * scaled to zero, or sits behind a network this machine cannot cross is not a
20
+ * failed edit — it will read the published config when it next starts. So a
21
+ * notify that cannot land is reported, never thrown.
22
+ */
23
+
24
+ /** How long to wait for a running endpoint to answer. */
25
+ const NOTIFY_TIMEOUT_MS = 10_000;
26
+
27
+ export interface PublishOutcome {
28
+ /** Where the config was copied, when the target reads from a store. */
29
+ readonly published?: string;
30
+ /** Whether a running endpoint confirmed it is now serving the edit. */
31
+ readonly served: boolean;
32
+ /** The endpoint that was told, or would have been. */
33
+ readonly url?: string;
34
+ /** Why it is not being served yet, in a form fit to print. */
35
+ readonly reason?: string;
36
+ }
37
+
38
+ /**
39
+ * Copy the config where this target reads it, then tell it to re-read.
40
+ *
41
+ * The order is load-bearing: an endpoint told to reload before the config it
42
+ * should read has landed would reload the previous config and report success.
43
+ */
44
+ export async function publishAndNotify(input: {
45
+ readonly config: Config;
46
+ readonly workspaceRoot: string;
47
+ readonly target: string;
48
+ readonly profile: string;
49
+ readonly credentials: SecretStore;
50
+ }): Promise<PublishOutcome> {
51
+ let published: string | null = null;
52
+
53
+ try {
54
+ published = await publishWorkspace(input);
55
+ } catch (error) {
56
+ // The local edit already succeeded and is already on disk. What failed is
57
+ // getting it to the bucket, which means the *next* revision would not see
58
+ // it either — worth saying loudly, and not worth undoing the edit for.
59
+ return {
60
+ served: false,
61
+ reason: `could not publish the config to this target: ${message(error)}`,
62
+ };
63
+ }
64
+
65
+ const notified = await notifyReload(input);
66
+ return { ...(published ? { published } : {}), ...notified };
67
+ }
68
+
69
+ /** The same thing, for a command that already holds an open runtime. */
70
+ export function publishRuntimeEdit(runtime: Runtime): Promise<PublishOutcome> {
71
+ return publishAndNotify({
72
+ config: runtime.config,
73
+ workspaceRoot: runtime.resolution.workspaceRoot,
74
+ target: runtime.target,
75
+ profile: runtime.resolution.profile,
76
+ credentials: runtime.credentials,
77
+ });
78
+ }
79
+
80
+ /**
81
+ * The same thing for a command that resolved a profile but opened no runtime.
82
+ *
83
+ * `policy allow` and `policy deny` edit the config without needing a registry,
84
+ * a dispatcher or a state handle — and a deny that a deployed endpoint has not
85
+ * heard about is the one kind of staleness worth being strict about, so they
86
+ * still have to publish. `openSecretStoreFor` is the cheap half of a runtime:
87
+ * the credential store alone, which is all the notify needs to authenticate.
88
+ */
89
+ export async function publishProfileEdit(input: {
90
+ readonly resolution: { readonly workspaceRoot: string; readonly profile: string };
91
+ readonly config: Config;
92
+ readonly target: string;
93
+ }): Promise<PublishOutcome> {
94
+ const credentials = await openSecretStoreFor(
95
+ input.config,
96
+ input.resolution.workspaceRoot,
97
+ input.target,
98
+ );
99
+
100
+ return publishAndNotify({
101
+ config: input.config,
102
+ workspaceRoot: input.resolution.workspaceRoot,
103
+ target: input.target,
104
+ profile: input.resolution.profile,
105
+ credentials,
106
+ });
107
+ }
108
+
109
+ /** Ask a running endpoint to re-read its config. Never throws. */
110
+ async function notifyReload(input: {
111
+ readonly config: Config;
112
+ readonly target: string;
113
+ readonly credentials: SecretStore;
114
+ }): Promise<PublishOutcome> {
115
+ let url: string;
116
+ try {
117
+ // Answers for a deployed target as well as a local one — a loopback URL
118
+ // sent to a deployment reaches a port with nothing behind it, which is the
119
+ // bug this function's own doc comment records.
120
+ url = (await endpointUrl(input.config, input.target)).replace(/\/mcp$/, '/reload');
121
+ } catch (error) {
122
+ return { served: false, reason: `could not work out where the endpoint is: ${message(error)}` };
123
+ }
124
+
125
+ const token = await input.credentials.get(input.config.auth.token_ref);
126
+ if (!token) {
127
+ return {
128
+ served: false,
129
+ url,
130
+ reason: `no profile token at "${input.config.auth.token_ref}" to authenticate with`,
131
+ };
132
+ }
133
+
134
+ try {
135
+ const response = await fetch(url, {
136
+ method: 'POST',
137
+ headers: { authorization: `Bearer ${token}` },
138
+ signal: AbortSignal.timeout(NOTIFY_TIMEOUT_MS),
139
+ });
140
+
141
+ if (!response.ok) {
142
+ return { served: false, url, reason: `the endpoint answered ${response.status}` };
143
+ }
144
+
145
+ const body = (await response.json()) as { reloaded?: unknown; reason?: unknown };
146
+ if (body.reloaded !== true) {
147
+ return {
148
+ served: false,
149
+ url,
150
+ reason:
151
+ typeof body.reason === 'string'
152
+ ? `the endpoint could not reload: ${body.reason}`
153
+ : 'the endpoint did not reload',
154
+ };
155
+ }
156
+
157
+ return { served: true, url };
158
+ } catch {
159
+ // Nothing listening, scaled to zero, or unreachable from here — all of
160
+ // which resolve themselves the next time the endpoint starts, because the
161
+ // config it reads on the way up is the one just published.
162
+ return { served: false, url, reason: 'no endpoint answered' };
163
+ }
164
+ }
165
+
166
+ function message(error: unknown): string {
167
+ return error instanceof Error ? (error.message.split('\n')[0] ?? error.message) : String(error);
168
+ }
169
+
170
+ /**
171
+ * The line `connect` and friends print last.
172
+ *
173
+ * It used to be a guess derived from whether the target was deployable —
174
+ * "restart it" or "roll a revision" — because there was no way to know. There
175
+ * is now: the endpoint either answered or it did not.
176
+ */
177
+ export function nextAfterEdit(outcome: PublishOutcome): string {
178
+ if (outcome.served) return 'Serving it now — the endpoint has re-read its config.';
179
+
180
+ // Naming the URL, because the likeliest reason nothing answered is that the
181
+ // endpoint is somewhere else: `lanes link start --port` moves the socket
182
+ // without moving `instance.port`, which is where this address comes from.
183
+ const where = outcome.url ? ` at ${outcome.url}` : '';
184
+ return `${outcome.reason ?? 'no endpoint answered'}${where} — saved, and the endpoint will serve this when it next starts.`;
185
+ }
@@ -0,0 +1,123 @@
1
+ import type { AnyConnector, DiscoveredCapability, ProviderManifest } from '#connectivity';
2
+ import { createHttpConnector } from '#connectivity/transports';
3
+
4
+ /**
5
+ * What it costs to ask a provider what it can do.
6
+ *
7
+ * Discovery is not one thing. For an `http` connector it is a pure function of
8
+ * an OpenAPI document committed to this repository — no token, no socket, and
9
+ * measured at 15ms for all seven Google providers together. For `mcp` it is a
10
+ * request. For `imap` and `dav` it is a TLS handshake and a login against a
11
+ * server that throttles exactly that.
12
+ *
13
+ * Startup re-derives the free ones and `doctor` reports on the rest, and both
14
+ * ask this module which is which. Two callers guessing separately at what a
15
+ * probe costs is how they drift — which is the bug this file exists to fix, one
16
+ * level up: `connect` was the only writer of the discovery cache, so a spec
17
+ * change landed in the repository and never reached the endpoint.
18
+ */
19
+
20
+ export type ProbeCost =
21
+ /** Reads a committed document. Safe on the boot path. */
22
+ | 'offline'
23
+ /** One HTTP request to the upstream. */
24
+ | 'network'
25
+ /** Opens an authenticated session. Never on the boot path. */
26
+ | 'session';
27
+
28
+ export interface DiscoveryProbe {
29
+ readonly cost: ProbeCost;
30
+ run(): Promise<readonly DiscoveredCapability[]>;
31
+ }
32
+
33
+ /**
34
+ * An `openapi` that names a document we ship, rather than one we fetch.
35
+ *
36
+ * Load-bearing, and the reason this is a check rather than an assumption about
37
+ * `kind === 'http'`: `providers/custom/template.ts` documents that a custom
38
+ * provider may point `openapi` at a URL. Re-deriving that at startup would put
39
+ * a network fetch on the boot path of every command, so those keep the cache.
40
+ */
41
+ function isLocalDocument(openapi: string): boolean {
42
+ return !/^https?:\/\//i.test(openapi);
43
+ }
44
+
45
+ /**
46
+ * How to re-discover this provider, or `undefined` if it cannot be discovered.
47
+ *
48
+ * `connector` is only consulted for the kinds that need one — an `http`
49
+ * provider is probed straight from its manifest, so it does not need a
50
+ * connection to exist. That is what lets startup re-derive before any
51
+ * connection is read, and what makes the probe work when a token has expired.
52
+ */
53
+ export function discoveryProbe(
54
+ manifest: ProviderManifest,
55
+ connector?: AnyConnector | undefined,
56
+ ): DiscoveryProbe | undefined {
57
+ const declared = manifest.connector;
58
+
59
+ // Authored capabilities are not discovered — they are the definition.
60
+ if (declared.kind === 'local') return undefined;
61
+
62
+ if (declared.kind === 'http' && isLocalDocument(declared.openapi)) {
63
+ return {
64
+ cost: 'offline',
65
+ run: () =>
66
+ createHttpConnector({
67
+ baseUrl: declared.base_url,
68
+ openapi: declared.openapi,
69
+ ...(declared.operations?.include?.length
70
+ ? { include: declared.operations.include }
71
+ : {}),
72
+ ...(declared.operations?.exclude?.length
73
+ ? { exclude: declared.operations.exclude }
74
+ : {}),
75
+ }).discover({ manifest }),
76
+ };
77
+ }
78
+
79
+ if (!connector) return undefined;
80
+
81
+ const cost: ProbeCost = declared.kind === 'imap' || declared.kind === 'dav' ? 'session' : 'network';
82
+ return { cost, run: () => connector.discover({ manifest }) };
83
+ }
84
+
85
+ /**
86
+ * One capability set against another, by name.
87
+ *
88
+ * `changed` is the case ADR-017 said `plan` structurally could not report — "it
89
+ * diffs capability names, and what changed here is a schema". A description or
90
+ * an input schema moving is exactly what a hint or a re-vendor does, and an
91
+ * operator who cannot see it has no way to know a restart is owed.
92
+ */
93
+ export interface CapabilityDiff {
94
+ readonly added: readonly string[];
95
+ readonly removed: readonly string[];
96
+ readonly changed: readonly string[];
97
+ }
98
+
99
+ export function capabilityDiff(
100
+ before: readonly DiscoveredCapability[],
101
+ after: readonly DiscoveredCapability[],
102
+ ): CapabilityDiff {
103
+ const previous = new Map(before.map((capability) => [capability.name, capability]));
104
+ const next = new Map(after.map((capability) => [capability.name, capability]));
105
+
106
+ const added = [...next.keys()].filter((name) => !previous.has(name));
107
+ const removed = [...previous.keys()].filter((name) => !next.has(name));
108
+ const changed = [...next.keys()].filter((name) => {
109
+ const was = previous.get(name);
110
+ if (!was) return false;
111
+ const is = next.get(name)!;
112
+ return (
113
+ was.description !== is.description ||
114
+ JSON.stringify(was.inputSchema) !== JSON.stringify(is.inputSchema)
115
+ );
116
+ });
117
+
118
+ return { added, removed, changed };
119
+ }
120
+
121
+ export function isEmptyDiff(diff: CapabilityDiff): boolean {
122
+ return diff.added.length === 0 && diff.removed.length === 0 && diff.changed.length === 0;
123
+ }