@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,397 @@
1
+ import { rename, writeFile } from 'node:fs/promises';
2
+ import { Document, parseDocument, type Node } from 'yaml';
3
+ import {
4
+ ConfigError,
5
+ isRemoteWorkspace,
6
+ readWorkspaceFile,
7
+ validateConfig,
8
+ workspaceFiles,
9
+ writeWorkspaceFile,
10
+ } from '#profile';
11
+
12
+ /**
13
+ * Editing a profile's config file.
14
+ *
15
+ * The file is the source of truth, and an operator's comments and key ordering
16
+ * are part of what they wrote — a CLI that reformats the file on every edit
17
+ * makes the file hostile to hand-editing, which then makes the CLI mandatory.
18
+ * So edits go through the YAML Document API, which preserves both.
19
+ *
20
+ * Every write validates the resulting document first and writes through a
21
+ * temporary file, so a rejected edit leaves the original exactly as it was.
22
+ * A config file left invalid by a failed command is worse than a command that
23
+ * refuses to run.
24
+ */
25
+
26
+ export class ConfigDocument {
27
+ readonly #document: Document;
28
+ readonly #path: string;
29
+ readonly #location: { workspaceRoot: string; key: string } | undefined;
30
+
31
+ private constructor(
32
+ document: Document,
33
+ path: string,
34
+ location?: { workspaceRoot: string; key: string },
35
+ ) {
36
+ this.#document = document;
37
+ this.#path = path;
38
+ this.#location = location;
39
+ }
40
+
41
+ /**
42
+ * Open a profile's config for editing, wherever the workspace keeps it.
43
+ *
44
+ * Takes the workspace root and a profile rather than a path, because those
45
+ * are no longer the same thing: a workspace may be a bucket, and `join` on a
46
+ * `gs://` URL produces something that addresses nothing.
47
+ */
48
+ static async open(workspaceRoot: string, profile: string): Promise<ConfigDocument> {
49
+ const key = `profiles/${profile}.yaml`;
50
+ const shown = isRemoteWorkspace(workspaceRoot)
51
+ ? `${workspaceRoot}/${key}`
52
+ : `${workspaceRoot}/${key}`;
53
+
54
+ const text = await readWorkspaceFile(workspaceFiles(workspaceRoot), key);
55
+ if (text === null) throw new ConfigError(`${shown}: no such config file`);
56
+
57
+ const document = parseDocument(text);
58
+ if (document.errors.length > 0) {
59
+ throw new ConfigError(`${shown}: ${document.errors[0]?.message ?? 'could not parse YAML'}`);
60
+ }
61
+ return new ConfigDocument(document, shown, { workspaceRoot, key });
62
+ }
63
+
64
+ static fromText(text: string, path = '<config>'): ConfigDocument {
65
+ return new ConfigDocument(parseDocument(text), path);
66
+ }
67
+
68
+ get path(): string {
69
+ return this.#path;
70
+ }
71
+
72
+ toString(): string {
73
+ return this.#document.toString({ lineWidth: 0 });
74
+ }
75
+
76
+ toJSON(): unknown {
77
+ return this.#document.toJSON();
78
+ }
79
+
80
+ /** Read a value by path, e.g. `['providers', 'gmail', 'enabled']`. */
81
+ getIn(path: readonly (string | number)[]): unknown {
82
+ return this.#document.getIn(path);
83
+ }
84
+
85
+ setIn(path: readonly (string | number)[], value: unknown): void {
86
+ // `createNode` first: a plain JS object stored here is not a YAML
87
+ // collection, so a later `setIn` into the same path cannot traverse it and
88
+ // fails with "Expected YAML collection". Setting `providers.gmail` and then
89
+ // `providers.gmail.oauth_app` is exactly that sequence.
90
+ const node =
91
+ value !== null && typeof value === 'object' ? this.#document.createNode(value) : value;
92
+
93
+ this.#document.setIn(path as (string | number)[], node);
94
+ this.#expand(path.slice(0, -1));
95
+ }
96
+
97
+ /**
98
+ * Append to a sequence, creating it if absent.
99
+ *
100
+ * `inline` renders the appended item on one line. Policy rules are far
101
+ * easier to scan one-per-line — which is how init.md's own example writes
102
+ * them — while a connection carries enough fields to want a block.
103
+ */
104
+ addTo(path: readonly (string | number)[], value: unknown, options: { inline?: boolean } = {}): void {
105
+ const node = this.#document.createNode(value) as Node & { flow?: boolean };
106
+ if (options.inline) node.flow = true;
107
+
108
+ // `null` as well as `undefined`: a key written with no value under it —
109
+ // `connections:` on a line of its own, which is what deleting the last entry
110
+ // by hand leaves behind — parses to a null scalar rather than to nothing.
111
+ // Calling `.add` on that is a TypeError with a stack trace, where the same
112
+ // file reached through `open` reports a readable config error, so the
113
+ // difference decided whether a command explained itself or crashed.
114
+ const existing = this.#document.getIn(path as (string | number)[]);
115
+ if (existing === undefined || existing === null) {
116
+ this.#document.setIn(path as (string | number)[], [node]);
117
+ } else {
118
+ (existing as { add(item: unknown): void }).add(node);
119
+ }
120
+
121
+ this.#expand(path);
122
+ }
123
+
124
+ /**
125
+ * Force a collection we just grew onto multiple lines.
126
+ *
127
+ * A template starts with `connections: []`, and the YAML library keeps that
128
+ * flow style as items are appended — so a file meant to be hand-edited
129
+ * silently degrades into one unreadable line. Only the container we touched
130
+ * is expanded; a leaf like `{ adapter: filesystem, path: ./data/x }` reads
131
+ * better on one line and is left alone.
132
+ */
133
+ #expand(path: readonly (string | number)[]): void {
134
+ const node = path.length === 0 ? this.#document.contents : this.#document.getIn(path as (string | number)[]);
135
+ const collection = node as { flow?: boolean; items?: unknown[] } | null;
136
+
137
+ if (collection && Array.isArray(collection.items) && collection.items.length > 0) {
138
+ collection.flow = false;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Validate the resulting document, then write it atomically.
144
+ *
145
+ * Validation happens on the rendered text rather than the in-memory tree so
146
+ * that what is checked is exactly what would land on disk.
147
+ */
148
+ async save(): Promise<void> {
149
+ const rendered = this.toString();
150
+
151
+ // Throws on any validation failure, including a credential value that has
152
+ // crept in — so a CLI edit can never introduce one.
153
+ validateConfig(this.#document.toJSON(), this.#path);
154
+
155
+ if (!this.#location) {
156
+ throw new ConfigError(`${this.#path}: opened from text, so there is nowhere to save it`);
157
+ }
158
+
159
+ const { workspaceRoot, key } = this.#location;
160
+
161
+ if (isRemoteWorkspace(workspaceRoot)) {
162
+ // One PUT, which object storage makes atomic by construction — there is
163
+ // no torn write to guard against and no temporary object to leave behind
164
+ // if the process dies between the two halves of a rename.
165
+ await writeWorkspaceFile(workspaceFiles(workspaceRoot), key, rendered);
166
+ return;
167
+ }
168
+
169
+ // Write-then-rename locally, so a rejected edit or a crash mid-write leaves
170
+ // the original exactly as it was. 0600 because the file names credential
171
+ // refs and the policy that governs them.
172
+ const path = `${workspaceRoot}/${key}`;
173
+ const temporary = `${path}.${process.pid}.tmp`;
174
+ await writeFile(temporary, rendered, { mode: 0o600 });
175
+ await rename(temporary, path);
176
+ }
177
+ }
178
+
179
+ /** The reserved provider id the setup surface registers under. */
180
+ const SETUP_PROVIDER_ID = 'setup';
181
+
182
+ /**
183
+ * What a repair did, split by what a caller does with each half.
184
+ *
185
+ * `connect` reports config edits under `changes` and policy under `granted`,
186
+ * and both are serialised verbatim by `--json` — so an audit asking what a
187
+ * command widened reads only the second, and one blended list of sentences
188
+ * filed the grant as an edit and left prose in a field meant for matching.
189
+ */
190
+ export interface SetupRepair {
191
+ /** Config edits made, spelled for display. Empty when none were needed. */
192
+ readonly changes: readonly string[];
193
+ /** Allow rules added — patterns, not prose, so a caller can act on them. */
194
+ readonly granted: readonly string[];
195
+ }
196
+
197
+ /**
198
+ * Give a profile the `setup` surface if it does not already have it.
199
+ *
200
+ * A profile written before the surface existed has neither the connection row
201
+ * nor the allow rule, and the failure is silent in the worst way:
202
+ * `allowedConnections` returns nothing for a provider with no connection row
203
+ * *before* it consults policy, so `setup_overview` and `setup_provider` are
204
+ * simply absent from `tools/list` with nothing saying why. An agent asked what
205
+ * is connected then has nothing to read and invents a command — which is the
206
+ * bug this exists to close, not a hypothetical.
207
+ *
208
+ * Both halves or neither: a connection row without `setup.*` is as inert as the
209
+ * rule without the row, so adding one alone would look like a fix and change
210
+ * nothing.
211
+ *
212
+ * CLI-side by construction. ADR-007 keeps configuration mutation off the served
213
+ * surface, and a deployed revision holds `objectViewer` on `profiles/`
214
+ * (ADR-023) so it could not write this even if the code let it.
215
+ */
216
+ export function ensureSetupConnection(document: ConfigDocument): SetupRepair {
217
+ // Raw YAML, so nothing here has been through a schema: this runs over sibling
218
+ // profiles that were never validated, and every field is whatever was typed.
219
+ const config = document.toJSON() as {
220
+ connections?: unknown;
221
+ policy?: { allow?: unknown; deny?: unknown };
222
+ } | null;
223
+
224
+ const rule = `${SETUP_PROVIDER_ID}.*`;
225
+ const covers = (pattern: string): boolean => pattern === '*' || pattern === rule;
226
+
227
+ // Denied on purpose, and a deny beats an allow — so writing the rule would
228
+ // widen nothing while announcing that an agent "can now see what is connected
229
+ // here", which would be false. Deleting the two lines no longer removes the
230
+ // surface, because the next `connect` or `deploy` puts them back; a deny is
231
+ // the way it stays off, so it is the one thing this must not undo.
232
+ //
233
+ // Only a rule covering the whole surface counts. `deny: [setup.provider]` is
234
+ // an operator narrowing it, not switching it off, and that narrowing survives
235
+ // the repair untouched — which is the point of denying one capability.
236
+ if (patternsIn(config?.policy?.deny).some(covers)) return { changes: [], granted: [] };
237
+
238
+ const changes: string[] = [];
239
+ const granted: string[] = [];
240
+
241
+ const connections = Array.isArray(config?.connections) ? config.connections : [];
242
+ const isSetup = (row: unknown): boolean =>
243
+ (row as { provider?: unknown } | null)?.provider === SETUP_PROVIDER_ID;
244
+
245
+ if (!connections.some(isSetup)) {
246
+ // Inline, and `main` for the id, so a repaired profile is spelled exactly
247
+ // like `newProfileTemplate` writes a fresh one. Two spellings of one row is
248
+ // how a template and its repair drift apart.
249
+ document.addTo(
250
+ ['connections'],
251
+ { id: 'main', provider: SETUP_PROVIDER_ID, account: 'Setup' },
252
+ { inline: true },
253
+ );
254
+ changes.push(`connections += ${SETUP_PROVIDER_ID}.main`);
255
+ }
256
+
257
+ // `*` already covers it. Re-stating the rule under a blanket allow would be
258
+ // noise in the file and a diff the operator did not ask for.
259
+ if (!patternsIn(config?.policy?.allow).some(covers)) {
260
+ document.addTo(['policy', 'allow'], rule, { inline: true });
261
+ granted.push(rule);
262
+ }
263
+
264
+ return { changes, granted };
265
+ }
266
+
267
+ /** Whether a repair did anything, without a caller adding up two lists. */
268
+ export function repaired(repair: SetupRepair): boolean {
269
+ return repair.changes.length > 0 || repair.granted.length > 0;
270
+ }
271
+
272
+ /** The repair as display lines, in the order the two halves are applied. */
273
+ export function repairLines(repair: SetupRepair): string[] {
274
+ return [...repair.changes, ...repair.granted.map((rule) => `policy.allow += ${rule}`)];
275
+ }
276
+
277
+ /**
278
+ * The patterns a raw policy list puts *in force*, in either spelling.
279
+ *
280
+ * `policyRuleSchema` takes a bare pattern or `{ capability, expires_at }` and
281
+ * both parse to the same thing, so reading only the string form would re-add a
282
+ * rule the operator had already written with an expiry. Anything that is
283
+ * neither is dropped rather than guessed at: this reads unvalidated YAML, and a
284
+ * malformed rule is for `validateConfig` to report, not for this to interpret.
285
+ *
286
+ * **Expiry is part of the reading.** `evaluate` holds a rule to
287
+ * `expiresAt === undefined || expiresAt > now` (`#policy`), so a lapsed rule
288
+ * grants and denies nothing — and reading the capability alone got both
289
+ * directions wrong. A lapsed *allow* read as live, so the repair wrote the row,
290
+ * skipped the rule, and announced success: the inert half-state this exists to
291
+ * prevent. A lapsed *deny* blocked the repair for good and printed nothing,
292
+ * because having nothing to add is how "already had it" looks.
293
+ *
294
+ * An unparseable date reads as lapsed, which is the safe direction — it adds a
295
+ * working rule rather than trusting a broken one, and the `save` that follows
296
+ * hands the malformed value to `validateConfig`, whose job it is to complain.
297
+ */
298
+ function patternsIn(rules: unknown, now = Date.now()): string[] {
299
+ if (!Array.isArray(rules)) return [];
300
+
301
+ return rules
302
+ .filter((rule) => {
303
+ const expiry = (rule as { expires_at?: unknown } | null)?.expires_at;
304
+ return typeof expiry !== 'string' || Date.parse(expiry) > now;
305
+ })
306
+ .map((rule) =>
307
+ typeof rule === 'string' ? rule : (rule as { capability?: unknown } | null)?.capability,
308
+ )
309
+ .filter((pattern): pattern is string => typeof pattern === 'string');
310
+ }
311
+
312
+ /**
313
+ * A fresh profile config.
314
+ *
315
+ * Written with comments, because this is the file an operator will read first
316
+ * and most of what it needs to say is *why*, not *what*.
317
+ */
318
+ export function newProfileTemplate(profile: string, port: number): string {
319
+ return `# Lanes Link profile: ${profile}
320
+ #
321
+ # This file is the source of truth for what exists. It never contains a
322
+ # credential value — only "_ref" pointers into the credential store, which
323
+ # lives beside it and is encrypted at rest.
324
+ #
325
+ # Edit it by hand or through the CLI; both are supported, and CLI edits
326
+ # preserve your comments and ordering.
327
+ contract: 1
328
+
329
+ instance:
330
+ profile: ${profile}
331
+ default_target: local
332
+ port: ${port}
333
+ host: 127.0.0.1
334
+
335
+ # Adapter selection is per target. Everything below "targets" is
336
+ # target-independent and declared exactly once.
337
+ targets:
338
+ local:
339
+ credentials: { adapter: file, path: ./data/${profile}/credentials.enc }
340
+ storage: { adapter: filesystem, path: ./data/${profile} }
341
+
342
+ # The bearer token for the endpoint this profile serves.
343
+ #
344
+ # "lanes link start" serves every profile in the workspace from one URL, and this
345
+ # token is what opens it — so it admits every profile that "lanes link outputs"
346
+ # lists, not only this one. Each call names the profile it means. Run a separate
347
+ # workspace if you need a token that cannot reach them all.
348
+ auth:
349
+ mode: bearer
350
+ token_ref: profile/token
351
+
352
+ limits:
353
+ requests_per_minute: 120 # per profile
354
+ upstream_calls_per_minute: 60 # per connection, protects vendor quota
355
+
356
+ # App registrations, shared by every connection of that vendor.
357
+ oauth_apps: {}
358
+
359
+ # One entry per authorised account. "account" is the identity the provider
360
+ # reports — an address, a workspace — so this list says whose data is reachable
361
+ # without having to look anything up.
362
+ #
363
+ # "setup" holds no account. It lets an agent see what is connected here and what
364
+ # connecting something else would take, so it can tell you the command to run
365
+ # rather than guess at one. It is read-only: nothing it offers writes config,
366
+ # stores a credential, signs in, or changes what is permitted — those stay in
367
+ # this CLI (ADR-007, ADR-019). Delete this entry and the allow line below to
368
+ # remove it entirely.
369
+ connections:
370
+ - { id: main, provider: setup, account: Setup }
371
+
372
+ # Only what is listed here is reachable, and an empty policy grants nothing.
373
+ #
374
+ # Rules name capabilities, never accounts: "gmail.*" covers every Gmail
375
+ # connection in this profile. To grant two accounts differently, run a second
376
+ # profile — profiles share no database and no credential store. They do now
377
+ # share an endpoint and its token, so that separation is enforced per call
378
+ # rather than per URL.
379
+ #
380
+ # allow: ['*'] everything, which is what connect writes
381
+ # allow: [notion.*, gmail.*] two providers
382
+ # deny: [gmail.send_message] a deny always beats an allow
383
+ policy:
384
+ allow: [setup.*]
385
+ deny: []
386
+ `;
387
+ }
388
+
389
+ export function newWorkspaceTemplate(defaultProfile?: string): string {
390
+ return `# Lanes Link workspace
391
+ #
392
+ # A workspace holds one or more profiles, and one endpoint serves all of them:
393
+ # every call names the profile it means. Profiles never share a database or a
394
+ # credential store, so what one holds is invisible to another.
395
+ contract: 1
396
+ ${defaultProfile ? `default_profile: ${defaultProfile}\n` : ''}`;
397
+ }
@@ -0,0 +1,77 @@
1
+ import type { Config, DeployConfig } from '#profile';
2
+
3
+ /**
4
+ * Where this profile's endpoint answers, for the target in play.
5
+ *
6
+ * Deployed, it is not derivable: Cloud Run mixes a project hash into the
7
+ * hostname, so the URL has to be asked for and asking is the driver's job.
8
+ * Locally it is host and port from the config.
9
+ *
10
+ * Shared because getting it wrong is silent in the worst way. `outputs` asked
11
+ * the driver; `mcp add` built the local URL unconditionally, so
12
+ * `lanes link mcp add --target cloud` registered `http://127.0.0.1:7337/mcp`
13
+ * with the agent — a registration that looks successful, names the right
14
+ * server, and points at a port with nothing behind it. One of the two had the
15
+ * answer and the other could not see it, which is what a copied line does
16
+ * eventually.
17
+ */
18
+ export async function endpointUrl(config: Config, target: string): Promise<string> {
19
+ const deployed = await deployedUrl(config.targets[target]?.deploy);
20
+ return deployed ?? `http://${config.instance.host}:${config.instance.port}/mcp`;
21
+ }
22
+
23
+ /**
24
+ * The URL the platform assigned this target's service, if it has one.
25
+ *
26
+ * Degrades to null rather than failing: the platform's CLI may be absent, the
27
+ * service may not be deployed yet, and neither is a reason for a command that
28
+ * only wanted to print an address to stop working. A target with no deployment
29
+ * block never reaches the driver at all.
30
+ */
31
+ export async function deployedUrl(declared: DeployConfig | undefined): Promise<string | null> {
32
+ if (!declared) return null;
33
+
34
+ try {
35
+ const { driverFor } = await import('#deployments/drivers.ts');
36
+ const url = await (await driverFor(declared.platform)).url(declared);
37
+ return url ? `${url}/mcp` : null;
38
+ } catch {
39
+ // A platform this binary has no driver for is a config problem `check`
40
+ // reports; it must not take the caller down with it.
41
+ return null;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Which service a target deploys to, from config alone.
47
+ *
48
+ * Separate from the two functions above because it answers a different question
49
+ * at a different cost. They ask the platform where a service *ended up*, which
50
+ * is a subprocess and a network call; this reads what the profile already
51
+ * declares, which is free and always available.
52
+ *
53
+ * That difference is exactly why `status` needs it. `status` promises to answer
54
+ * without depending on anything being up, so it cannot ask for an address — but
55
+ * printing a loopback URL for a deployed target, which is what it used to do, is
56
+ * worse than printing no URL at all. Naming the service is the honest answer a
57
+ * config-only command can give.
58
+ */
59
+ export function deploymentIdentity(
60
+ declared: DeployConfig | undefined,
61
+ ): DeploymentIdentity | null {
62
+ if (!declared) return null;
63
+
64
+ return {
65
+ platform: declared.platform,
66
+ service: declared.service,
67
+ region: declared.region,
68
+ ...(declared.project !== undefined ? { project: declared.project } : {}),
69
+ };
70
+ }
71
+
72
+ export interface DeploymentIdentity {
73
+ readonly platform: string;
74
+ readonly service: string;
75
+ readonly region: string;
76
+ readonly project?: string;
77
+ }
@@ -0,0 +1,109 @@
1
+ import type { IdentityDeclaration, ProviderManifest } from '#connectivity';
2
+
3
+ /**
4
+ * Working out whose account was just authorised.
5
+ *
6
+ * A connection list that reads `Gmail main`, `Gmail main2`, `Gmail main3`
7
+ * cannot answer the one question it exists to answer. Worse, without an
8
+ * identity there is no way to tell a *reconnect* from a *new account*, so
9
+ * re-running `connect` after a failed attempt appends another row instead of
10
+ * repairing the one already there — which is precisely how `main3` came to
11
+ * exist.
12
+ *
13
+ * Everything here is best-effort. A provider that declares no identity, or
14
+ * whose probe fails, falls back to asking the operator: a label is worth
15
+ * having, never worth failing a connect over.
16
+ */
17
+
18
+ /** Walk a dotted path, tolerating the first array element on the way. */
19
+ export function pluck(value: unknown, path: string): string | null {
20
+ let current: unknown = value;
21
+
22
+ for (const segment of path.split('.')) {
23
+ if (Array.isArray(current)) current = current[0];
24
+ if (current === null || typeof current !== 'object') return null;
25
+ current = (current as Record<string, unknown>)[segment];
26
+ }
27
+
28
+ if (Array.isArray(current)) current = current[0];
29
+ return typeof current === 'string' && current.length > 0 ? current : null;
30
+ }
31
+
32
+ export interface IdentityProbe {
33
+ /** A valid upstream access token, if the provider authenticates. */
34
+ readonly accessToken: () => Promise<string | null>;
35
+ /** Call a capability on the upstream MCP server. */
36
+ readonly callTool?: (name: string, args: Record<string, unknown>) => Promise<unknown>;
37
+ /** Ask the connector, for a protocol whose identity is not a URL away. */
38
+ readonly identify?: () => Promise<string | null>;
39
+ readonly fetch?: typeof globalThis.fetch;
40
+ }
41
+
42
+ export async function resolveAccount(
43
+ manifest: ProviderManifest,
44
+ probe: IdentityProbe,
45
+ ): Promise<string | null> {
46
+ const identity: IdentityDeclaration | undefined = manifest.identity;
47
+ if (!identity) return null;
48
+
49
+ try {
50
+ if (identity.kind === 'connector') {
51
+ return (await probe.identify?.()) ?? null;
52
+ }
53
+
54
+ if (identity.kind === 'http') {
55
+ const token = await probe.accessToken();
56
+ const request = probe.fetch ?? globalThis.fetch;
57
+ const response = await request(identity.url, {
58
+ headers: token ? { authorization: `Bearer ${token}` } : {},
59
+ });
60
+ if (!response.ok) return null;
61
+ return pluck(await response.json(), identity.field);
62
+ }
63
+
64
+ if (!probe.callTool) return null;
65
+ const result = await probe.callTool(identity.tool, identity.arguments);
66
+
67
+ // MCP results arrive as content blocks; the useful part is usually JSON in
68
+ // a text block, so try that before falling back to the raw shape.
69
+ const text = pluck(result, 'content.text');
70
+ if (text) {
71
+ try {
72
+ return pluck(JSON.parse(text), identity.field) ?? text;
73
+ } catch {
74
+ return text;
75
+ }
76
+ }
77
+ return pluck(result, identity.field);
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Turn an account into a connection id.
85
+ *
86
+ * `ada.lovelace@example.com` becomes `ada_lovelace`, which is what appears in
87
+ * `credential_ref` and in the agent's `connection` argument. The local part is
88
+ * enough to tell accounts apart in practice, and the full address is still
89
+ * right there in `account` when it is not.
90
+ */
91
+ export function idFromAccount(account: string, taken: readonly string[] = []): string {
92
+ const local = account.includes('@') ? (account.split('@')[0] ?? account) : account;
93
+
94
+ const base =
95
+ local
96
+ .toLowerCase()
97
+ .replace(/[^a-z0-9]+/g, '_')
98
+ .replace(/^_+|_+$/g, '')
99
+ .slice(0, 40) || 'main';
100
+
101
+ if (!taken.includes(base)) return base;
102
+
103
+ // A genuine collision — two accounts sharing a local part, e.g. the same
104
+ // name at two domains. Suffixing beats overwriting someone else's credential.
105
+ for (let n = 2; ; n++) {
106
+ const candidate = `${base}${n}`;
107
+ if (!taken.includes(candidate)) return candidate;
108
+ }
109
+ }
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env bun
2
+ import { ConfigError } from '#profile';
3
+ import { print, printErr, style } from './output.ts';
4
+ import { version } from './version.ts';
5
+
6
+ /**
7
+ * `lanes` — the command, and the one area it currently has.
8
+ *
9
+ * The grammar is `lanes <area> <command>`, and `link` is the only area today.
10
+ * It is a level of indirection that buys nothing yet, which is the point: the
11
+ * alternative was a second top-level binary the first time Lanes grew another
12
+ * capability, and by then every doc, every shell alias, and every agent
13
+ * registration would already say `lanes-link`.
14
+ *
15
+ * Adding an area is one entry in AREAS and one case below. Areas own their own
16
+ * argv completely — this file peels one token and hands over the rest, so
17
+ * `lanes link status` and a future `lanes <other> status` never collide.
18
+ */
19
+
20
+ const AREAS: Record<string, string> = {
21
+ link: 'a self-hosted MCP gateway for your accounts, memory, skills, and secrets',
22
+ };
23
+
24
+ function areasUsage(): string {
25
+ const rows = Object.entries(AREAS)
26
+ .map(([name, blurb]) => ` ${style.bold(`lanes ${name}`)} ${blurb}`)
27
+ .join('\n');
28
+
29
+ return (
30
+ `${style.bold('lanes')} — your own tools, wherever you work\n\n${rows}\n\n` +
31
+ `Run ${style.bold('lanes <area> help')} for what an area can do, ` +
32
+ `or ${style.bold('lanes --version')} for which release this is.\n`
33
+ );
34
+ }
35
+
36
+ async function main(argv: readonly string[]): Promise<void> {
37
+ const [area, ...rest] = argv;
38
+
39
+ // Bare `lanes`, or asking for help before naming an area: list what exists.
40
+ // Not an error — someone who typed `lanes` wants to know what it offers.
41
+ if (!area || area === 'help' || area === '--help') {
42
+ print(areasUsage());
43
+ return;
44
+ }
45
+
46
+ // Before the area is peeled, because it belongs to the binary rather than to
47
+ // any one area — and because `lanes --version` is the reflex for anything on
48
+ // a PATH. `lanes link version` answers with the same string.
49
+ if (area === '--version' || area === '-v') {
50
+ print(version());
51
+ return;
52
+ }
53
+
54
+ if (area === 'link') {
55
+ // Loaded here rather than at the top of the file so that a failure while
56
+ // the area's module graph evaluates — a malformed environment variable read
57
+ // at import time, say — is caught below and printed as an `error` line. A
58
+ // static import throws before this file's try block is reached, and Bun
59
+ // renders that as a stack trace with the message buried in it.
60
+ const { run: runLink } = await import('./main.ts');
61
+ return await runLink(rest);
62
+ }
63
+
64
+ throw new Error(
65
+ `Unknown area "${area}". Available: ${Object.keys(AREAS).join(', ')}.\n` +
66
+ ` Did you mean: lanes link ${area}?`,
67
+ );
68
+ }
69
+
70
+ try {
71
+ await main(process.argv.slice(2));
72
+ } catch (error) {
73
+ // A ConfigError already names the exact path and what to do about it, so it
74
+ // is printed as-is rather than wrapped in a generic failure message.
75
+ const message = error instanceof ConfigError ? error.message : (error as Error).message;
76
+ printErr(`${style.red('error')} ${message}`);
77
+ process.exit(1);
78
+ }