@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,162 @@
1
+ /**
2
+ * Reject credential values that have been written into a config file.
3
+ *
4
+ * Config carries `_ref` pointers into the credential store; it never carries a
5
+ * value. This module is what turns that from an intention into a check, and it
6
+ * runs on every load and before every CLI write, so a config file cannot become
7
+ * a place secrets accumulate.
8
+ *
9
+ * Findings name the exact path (`oauth_apps.google.client_secret`) because "your
10
+ * config contains a secret" is not actionable on a file with fifty keys.
11
+ *
12
+ * Deliberately biased toward false negatives over false positives. A rule that
13
+ * rejects a legitimate display name makes the tool unusable, and this is a
14
+ * second line of defence — `.gitignore` is the first, and the credential store
15
+ * is what makes the whole class of mistake unnecessary.
16
+ */
17
+
18
+ export interface SecretFinding {
19
+ /** Dotted path, with array indices: `connections[0].credential_ref`. */
20
+ readonly path: string;
21
+ readonly rule: string;
22
+ readonly detail: string;
23
+ }
24
+
25
+ /** Vendor prefixes that are never anything but a live credential. */
26
+ const CREDENTIAL_PREFIXES: ReadonlyArray<{ prefix: string; label: string }> = [
27
+ { prefix: 'sk-', label: 'an OpenAI-style secret key' },
28
+ { prefix: 'xoxb-', label: 'a Slack bot token' },
29
+ { prefix: 'xoxp-', label: 'a Slack user token' },
30
+ { prefix: 'xapp-', label: 'a Slack app token' },
31
+ { prefix: 'ya29.', label: 'a Google OAuth access token' },
32
+ { prefix: 'ghp_', label: 'a GitHub personal access token' },
33
+ { prefix: 'gho_', label: 'a GitHub OAuth token' },
34
+ { prefix: 'github_pat_', label: 'a GitHub fine-grained token' },
35
+ { prefix: 'AKIA', label: 'an AWS access key id' },
36
+ { prefix: 'AIza', label: 'a Google API key' },
37
+ { prefix: 'llk_', label: 'a Lanes Link profile token' },
38
+ { prefix: '1//', label: 'a Google refresh token' },
39
+ ];
40
+
41
+ const PRIVATE_KEY_BLOCK = /-----BEGIN[ A-Z]*PRIVATE KEY-----/;
42
+
43
+ /**
44
+ * Key names that must hold a reference rather than a value.
45
+ *
46
+ * A key ending in `_ref` is exempt: `client_secret_ref` is exactly what config
47
+ * is supposed to contain, while `client_secret` is exactly what it must not.
48
+ */
49
+ const VALUE_BEARING_KEY = /(?:^|_)(secret|token|password|passwd|apikey|credential)$|_key$/;
50
+
51
+ /** A single opaque token — no spaces, the shape credentials actually take. */
52
+ const OPAQUE_TOKEN = /^[A-Za-z0-9_\-+/=.]{24,}$/;
53
+
54
+ export function shannonEntropy(value: string): number {
55
+ if (value.length === 0) return 0;
56
+ const counts = new Map<string, number>();
57
+ for (const char of value) counts.set(char, (counts.get(char) ?? 0) + 1);
58
+
59
+ let entropy = 0;
60
+ for (const count of counts.values()) {
61
+ const p = count / value.length;
62
+ entropy -= p * Math.log2(p);
63
+ }
64
+ return entropy;
65
+ }
66
+
67
+ function characterClasses(value: string): number {
68
+ let classes = 0;
69
+ if (/[a-z]/.test(value)) classes++;
70
+ if (/[A-Z]/.test(value)) classes++;
71
+ if (/[0-9]/.test(value)) classes++;
72
+ return classes;
73
+ }
74
+
75
+ /**
76
+ * Does this look like a random blob rather than a path, name, or identifier?
77
+ *
78
+ * Requiring all three of length, mixed character classes, and high entropy is
79
+ * what keeps `./data/personal.credentials.enc` (no uppercase, no digits) and
80
+ * `google/client_secret` (lowercase only) from being flagged, while catching a
81
+ * base64 or hex credential.
82
+ */
83
+ function looksHighEntropy(value: string): boolean {
84
+ return (
85
+ OPAQUE_TOKEN.test(value) &&
86
+ characterClasses(value) >= 3 &&
87
+ shannonEntropy(value) >= 3.5 &&
88
+ !/^https?:/i.test(value)
89
+ );
90
+ }
91
+
92
+ function inspectValue(path: string, key: string, value: string): SecretFinding | null {
93
+ if (value.length === 0) return null;
94
+
95
+ if (PRIVATE_KEY_BLOCK.test(value)) {
96
+ return { path, rule: 'private-key-block', detail: 'contains an inline private key block' };
97
+ }
98
+
99
+ for (const { prefix, label } of CREDENTIAL_PREFIXES) {
100
+ if (value.startsWith(prefix)) {
101
+ return {
102
+ path,
103
+ rule: 'credential-prefix',
104
+ detail: `starts with "${prefix}", which identifies ${label}`,
105
+ };
106
+ }
107
+ }
108
+
109
+ // A key that names a credential must carry a reference, not a value —
110
+ // regardless of how innocuous the value looks.
111
+ if (!key.endsWith('_ref') && VALUE_BEARING_KEY.test(key)) {
112
+ return {
113
+ path,
114
+ rule: 'value-bearing-key',
115
+ detail:
116
+ `"${key}" names a credential, so it must hold a reference rather than a value. ` +
117
+ `Use "${key}_ref" pointing at the credential store, e.g. "google/${key}".`,
118
+ };
119
+ }
120
+
121
+ if (looksHighEntropy(value)) {
122
+ return {
123
+ path,
124
+ rule: 'high-entropy',
125
+ detail: `is a ${value.length}-character high-entropy string, which looks like a credential`,
126
+ };
127
+ }
128
+
129
+ return null;
130
+ }
131
+
132
+ /** Walk a parsed config and report every credential-shaped value in it. */
133
+ export function findSecrets(value: unknown, path = '', key = ''): SecretFinding[] {
134
+ if (typeof value === 'string') {
135
+ const finding = inspectValue(path, key, value);
136
+ return finding ? [finding] : [];
137
+ }
138
+
139
+ if (Array.isArray(value)) {
140
+ return value.flatMap((item, index) => findSecrets(item, `${path}[${index}]`, key));
141
+ }
142
+
143
+ if (value !== null && typeof value === 'object') {
144
+ return Object.entries(value).flatMap(([childKey, childValue]) =>
145
+ findSecrets(childValue, path ? `${path}.${childKey}` : childKey, childKey),
146
+ );
147
+ }
148
+
149
+ return [];
150
+ }
151
+
152
+ export function formatSecretFindings(findings: readonly SecretFinding[]): string {
153
+ const lines = findings.map((finding) => ` ${finding.path} ${finding.detail}`);
154
+ return (
155
+ `Configuration must not contain credential values, only "_ref" pointers into the credential store.\n` +
156
+ `${findings.length === 1 ? 'This value' : 'These values'} looked like a credential:\n` +
157
+ `${lines.join('\n')}\n\n` +
158
+ `Move the value into the credential store and reference it instead. ` +
159
+ `If this is a false positive, rename the key or shorten the value — ` +
160
+ `there is deliberately no way to suppress this check.`
161
+ );
162
+ }
@@ -0,0 +1,152 @@
1
+ import { ConfigError } from './load.ts';
2
+ import type { Config } from './schema.ts';
3
+
4
+ /**
5
+ * Which target a command acts on.
6
+ *
7
+ * A target names an adapter set — where credentials are kept and where bytes
8
+ * go — and choosing one is a different subject from finding the workspace and
9
+ * the profile, which is why it is a different file. `workspace.ts` reached the
10
+ * size budget holding both, and the budget's job is to say which of the two
11
+ * things a file is doing should leave.
12
+ *
13
+ * The order is `--target`, then `LANES_LINK_TARGET`, then
14
+ * `instance.default_target`. `deploy` resolves it differently and deliberately;
15
+ * see `resolveDeployTarget`.
16
+ */
17
+
18
+ /** The variable that names a target for every command in a shell. */
19
+ export const TARGET_ENV = 'LANES_LINK_TARGET';
20
+
21
+ /**
22
+ * The target somebody asked for, before the config gets a say.
23
+ *
24
+ * Split out because two callers need this precedence and must not disagree
25
+ * about it: `resolveSelection` records a provisional answer for `announce`
26
+ * before any config is loaded, and `resolveTarget` settles it afterwards. While
27
+ * the flag was the only source, both could spell it `targetFlag ?? …` and stay
28
+ * accidentally correct. With a second source, one of them learning about it and
29
+ * the other not is a line reading `config-default` beside a target the
30
+ * environment chose — which is the one line that exists to prevent exactly that.
31
+ *
32
+ * Returns no target rather than a default: what an unanswered question falls
33
+ * back to is `instance.default_target`, and that is the config's to supply.
34
+ */
35
+ export function askedTarget(
36
+ targetFlag: string | undefined,
37
+ env: Record<string, string | undefined>,
38
+ ): { target: string | undefined; source: 'flag' | 'environment' | undefined } {
39
+ if (targetFlag) return { target: targetFlag, source: 'flag' };
40
+
41
+ const fromEnv = env[TARGET_ENV];
42
+ if (fromEnv) return { target: fromEnv, source: 'environment' };
43
+
44
+ return { target: undefined, source: undefined };
45
+ }
46
+
47
+ /**
48
+ * The refusal for a target that is not in the file, in one spelling.
49
+ *
50
+ * It was two — here and in the CLI's `openSecretStoreFor` — which is one more
51
+ * than a sentence naming the available targets survives: the copies drift the
52
+ * moment either learns something the other does not. This one knows about
53
+ * `LANES_LINK_TARGET`, and that is precisely the knowledge a copy would lack —
54
+ * an exported typo otherwise fails every command in the shell with a message
55
+ * that reads as a problem with the config file.
56
+ */
57
+ export function undeclaredTarget(
58
+ target: string,
59
+ config: Config,
60
+ source?: 'flag' | 'environment' | 'config-default',
61
+ ): ConfigError {
62
+ const have = Object.keys(config.targets).join(', ') || 'none';
63
+ return new ConfigError(
64
+ `Target "${target}" is not declared in this profile (have: ${have})` +
65
+ (source === 'environment'
66
+ ? `\n${TARGET_ENV}=${target} is set in this shell — unset it, or pass --target.`
67
+ : ''),
68
+ );
69
+ }
70
+
71
+ /**
72
+ * Fill in the target once the profile's config has been loaded.
73
+ *
74
+ * Order: `--target`, then `LANES_LINK_TARGET`, then `instance.default_target`.
75
+ * The middle one is the same bargain `LANES_LINK_PROFILE` already offers — a
76
+ * shell's worth of commands without retyping a flag, somewhere `env` shows it.
77
+ *
78
+ * `allowUndeclared` is for the one command whose job is to create the target it
79
+ * was given — `deploy`, on a first run. Every other command naming a target that
80
+ * does not exist has made a typo, and the list of what does exist is the useful
81
+ * answer; refusing there is what stops `--target clod` opening the default one.
82
+ */
83
+ export function resolveTarget(
84
+ config: Config,
85
+ targetFlag?: string,
86
+ options: {
87
+ allowUndeclared?: boolean;
88
+ env?: Record<string, string | undefined>;
89
+ } = {},
90
+ ): { target: string; source: 'flag' | 'environment' | 'config-default' } {
91
+ const env = options.env ?? (process.env as Record<string, string | undefined>);
92
+ const asked = askedTarget(targetFlag, env);
93
+
94
+ const target = asked.target ?? config.instance.default_target;
95
+ const source = asked.source ?? 'config-default';
96
+
97
+ if (options.allowUndeclared !== true && !(target in config.targets)) {
98
+ throw undeclaredTarget(target, config, source);
99
+ }
100
+ return { target, source };
101
+ }
102
+
103
+ /**
104
+ * The target a deploy means, when nobody said.
105
+ *
106
+ * `--target cloud` was required on every deploy, and the reason was an accident:
107
+ * an absent flag falls back to `instance.default_target`, which is `local` —
108
+ * a target that by definition is not deployed anywhere. So the one command whose
109
+ * subject is never ambiguous was the one command that made you say it, and the
110
+ * default it would otherwise have taken was not merely unhelpful but wrong.
111
+ *
112
+ * The rule is what someone would say out loud: deploy the target that has a
113
+ * deployment. One is the answer; none means the first run, which conventionally
114
+ * creates `cloud` and is what every example in the docs names; several is a
115
+ * genuine question, and asking beats rolling a revision to whichever came first
116
+ * in a YAML mapping.
117
+ *
118
+ * `--target` still wins, which is how you deploy the second one.
119
+ *
120
+ * **`LANES_LINK_TARGET` is deliberately not read here.** It is the same kind of
121
+ * answer as `instance.default_target` — a shell-wide "where do my commands
122
+ * run" — and the paragraph above is why that kind of answer is the wrong one
123
+ * for this question. It is also the only place where being wrong creates cloud
124
+ * resources rather than an error: `deploy` is the one caller that passes
125
+ * `allowUndeclared`, so an exported typo would not be refused, it would be
126
+ * surveyed, written into the profile, and rolled out as a new service. An
127
+ * environment variable must not be able to name a Cloud Run service into
128
+ * existence. Say `--target`; `deploy` is rare enough to afford it.
129
+ */
130
+ export const CONVENTIONAL_DEPLOY_TARGET = 'cloud';
131
+
132
+ export function resolveDeployTarget(
133
+ config: Config,
134
+ targetFlag?: string,
135
+ ): { target: string; source: 'flag' | 'deployable' } {
136
+ if (targetFlag) return { target: targetFlag, source: 'flag' };
137
+
138
+ // `deploy` alone: the legacy `cloudrun:` spelling is normalised into it by the
139
+ // loader, so exactly one shape reaches here.
140
+ const deployable = Object.entries(config.targets)
141
+ .filter(([, declared]) => declared.deploy !== undefined)
142
+ .map(([name]) => name);
143
+
144
+ if (deployable.length > 1) {
145
+ throw new ConfigError(
146
+ `This profile declares ${deployable.length} deployable targets (${deployable.join(', ')}). ` +
147
+ 'Name the one you mean with --target.',
148
+ );
149
+ }
150
+
151
+ return { target: deployable[0] ?? CONVENTIONAL_DEPLOY_TARGET, source: 'deployable' };
152
+ }
@@ -0,0 +1,262 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { isRemoteWorkspace, readWorkspaceFile, workspaceFiles } from './files.ts';
3
+ import { parseConfig, type LoadedConfig } from './load.ts';
4
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
5
+ import { homedir } from 'node:os';
6
+ import { parse as parseYaml } from 'yaml';
7
+ import { ConfigError } from './load.ts';
8
+ import { workspaceSchema, type Config, type WorkspaceConfig } from './schema.ts';
9
+ import { askedTarget } from './targets.ts';
10
+
11
+ /**
12
+ * Workspace and profile resolution.
13
+ *
14
+ * A workspace is a directory holding one or more profiles:
15
+ *
16
+ * lanes-link.yaml workspace settings: contract, default_profile
17
+ * profiles/
18
+ * personal.yaml
19
+ * work.yaml
20
+ * data/ local state per profile, gitignored
21
+ *
22
+ * There is deliberately no sticky `lanes link use` that persists a *hidden*
23
+ * current selection. Persisted context state is the standard way operators run
24
+ * destructive commands against the wrong target, and the version of it that
25
+ * bites is the dotfile nothing prints.
26
+ *
27
+ * So both ways of not retyping a flag are visible ones.
28
+ * `export LANES_LINK_PROFILE=work` and `export LANES_LINK_TARGET=cloud` live in
29
+ * the shell, where `env` shows them; `default_profile` and
30
+ * `instance.default_target` live in files the operator reads and `check`
31
+ * validates, written by `lanes link profile default` and `lanes link target
32
+ * use`. Every command prints which of the four it landed on and where that came
33
+ * from — see `announce`.
34
+ */
35
+
36
+ export const WORKSPACE_FILE = 'lanes-link.yaml';
37
+
38
+ export interface Resolution {
39
+ readonly workspaceRoot: string;
40
+ readonly profile: string;
41
+ readonly profilePath: string;
42
+ readonly target: string;
43
+ /** Where the value came from, so every command can print how it got here. */
44
+ readonly profileSource: 'flag' | 'environment' | 'workspace-default';
45
+ /**
46
+ * `deployable` is `deploy` choosing the only target it could have meant.
47
+ *
48
+ * It is its own source rather than reusing `config-default` because it is a
49
+ * different claim: the config default is what *commands* run against, and for
50
+ * every other command that is the local target. Printing "config-default"
51
+ * beside a target the config does not default to would be a lie on the one
52
+ * line that exists to say how the command got here.
53
+ *
54
+ * `environment` is `LANES_LINK_TARGET`, and it earns its own name for the
55
+ * same reason: a target chosen by a variable exported in another terminal an
56
+ * hour ago is the one an operator is most likely to be surprised by, and the
57
+ * fix — `unset` — is not the fix for a config default. `deploy` never
58
+ * produces it; `resolveDeployTarget` says why.
59
+ */
60
+ readonly targetSource: 'flag' | 'environment' | 'config-default' | 'deployable';
61
+ }
62
+
63
+ export interface ResolveOptions {
64
+ readonly profileFlag?: string | undefined;
65
+ readonly targetFlag?: string | undefined;
66
+ readonly cwd?: string;
67
+ readonly env?: Record<string, string | undefined>;
68
+ }
69
+
70
+ /**
71
+ * `LANES_LINK_HOME`, else the nearest ancestor containing `lanes-link.yaml`,
72
+ * else `~/.lanes-link`.
73
+ */
74
+ export function resolveWorkspaceRoot(options: ResolveOptions = {}): string {
75
+ const env = options.env ?? (process.env as Record<string, string | undefined>);
76
+ const explicit = env['LANES_LINK_HOME'];
77
+ // A bucket URL is already absolute; `resolve` would turn `gs://b/p` into a
78
+ // path under the current directory and the failure would arrive much later,
79
+ // as a missing file rather than as a mangled root.
80
+ if (explicit) return isRemoteWorkspace(explicit) ? explicit.replace(/\/$/, '') : resolve(explicit);
81
+
82
+ let directory = resolve(options.cwd ?? process.cwd());
83
+ for (;;) {
84
+ // `existsSync`, not `Bun.file(path).size`: a missing file reports size 0,
85
+ // so a `>= 0` check would call every candidate a workspace and stop at the
86
+ // first directory it looked at.
87
+ if (existsSync(join(directory, WORKSPACE_FILE))) return directory;
88
+ const parent = dirname(directory);
89
+ if (parent === directory) break;
90
+ directory = parent;
91
+ }
92
+
93
+ return join(homedir(), '.lanes-link');
94
+ }
95
+
96
+ /**
97
+ * Where Lanes Link itself is installed — the directory holding `package.json`,
98
+ * and with it `skills/` and `docs/`.
99
+ *
100
+ * Not the workspace: this is the code, not the operator's data. Found by
101
+ * walking up rather than by counting `..` segments, because the count is a
102
+ * function of where the calling file sits and a file that moves one level takes
103
+ * a silently wrong path with it. Both callers had already been through that
104
+ * once.
105
+ */
106
+ export function installRoot(from: string): string {
107
+ let directory = resolve(from);
108
+ for (;;) {
109
+ if (existsSync(join(directory, 'package.json'))) return directory;
110
+ const parent = dirname(directory);
111
+ if (parent === directory) {
112
+ throw new Error(`No package.json in any directory above ${from}`);
113
+ }
114
+ directory = parent;
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Where a profile's config lives, for display and for filesystem callers.
120
+ *
121
+ * `join` would collapse the `//` in a bucket URL, so a remote root composes by
122
+ * hand. Loading goes through `loadProfileConfig` rather than this — what a
123
+ * command prints and what it reads stopped being the same string when the
124
+ * workspace stopped being a directory.
125
+ */
126
+ export function profilePath(workspaceRoot: string, profile: string): string {
127
+ const key = `profiles/${profile}.yaml`;
128
+ return isRemoteWorkspace(workspaceRoot) ? `${workspaceRoot}/${key}` : join(workspaceRoot, key);
129
+ }
130
+
131
+ /** A profile's config, however this workspace is stored. */
132
+ export async function loadProfileConfig(
133
+ workspaceRoot: string,
134
+ profile: string,
135
+ ): Promise<LoadedConfig> {
136
+ const key = `profiles/${profile}.yaml`;
137
+ const text = await readWorkspaceFile(workspaceFiles(workspaceRoot), key);
138
+ const shown = profilePath(workspaceRoot, profile);
139
+
140
+ if (text === null) throw new ConfigError(`${shown}: no such config file`);
141
+ return parseConfig(text, shown);
142
+ }
143
+
144
+ export async function readWorkspace(workspaceRoot: string): Promise<WorkspaceConfig | null> {
145
+ const path = join(workspaceRoot, WORKSPACE_FILE);
146
+ const text = await readWorkspaceFile(workspaceFiles(workspaceRoot), WORKSPACE_FILE);
147
+ if (text === null) return null;
148
+
149
+ const parsed = workspaceSchema.safeParse(parseYaml(text));
150
+ if (!parsed.success) {
151
+ throw new ConfigError(
152
+ `${path}:\n${parsed.error.issues.map((i) => ` ${i.path.join('.')}: ${i.message}`).join('\n')}`,
153
+ );
154
+ }
155
+ return parsed.data;
156
+ }
157
+
158
+ export async function listProfiles(workspaceRoot: string): Promise<string[]> {
159
+ try {
160
+ const entries = await workspaceFiles(workspaceRoot).list('profiles/');
161
+ return entries
162
+ .map((entry) => entry.key.slice('profiles/'.length))
163
+ .filter((name) => name.endsWith('.yaml') && !name.endsWith('.example.yaml'))
164
+ // Direct children only: a nested directory under `profiles/` is not a
165
+ // profile, and a bucket listing is flat so it would otherwise look like one.
166
+ .filter((name) => !name.includes('/'))
167
+ .map((name) => name.slice(0, -'.yaml'.length))
168
+ .sort();
169
+ } catch {
170
+ return [];
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Resolve which profile and target a command acts on.
176
+ *
177
+ * Order: `--profile`, then `LANES_LINK_PROFILE`, then the workspace's
178
+ * `default_profile`, then an error that lists what is available — never a
179
+ * silent pick, because the wrong guess here operates on the wrong accounts.
180
+ */
181
+ export async function resolveSelection(options: ResolveOptions = {}): Promise<Resolution> {
182
+ const env = options.env ?? (process.env as Record<string, string | undefined>);
183
+ const workspaceRoot = resolveWorkspaceRoot(options);
184
+
185
+ let profile: string | undefined;
186
+ let profileSource: Resolution['profileSource'] = 'workspace-default';
187
+
188
+ if (options.profileFlag) {
189
+ profile = options.profileFlag;
190
+ profileSource = 'flag';
191
+ } else if (env['LANES_LINK_PROFILE']) {
192
+ profile = env['LANES_LINK_PROFILE'];
193
+ profileSource = 'environment';
194
+ } else {
195
+ profile = (await readWorkspace(workspaceRoot))?.default_profile;
196
+ profileSource = 'workspace-default';
197
+ }
198
+
199
+ if (!profile) {
200
+ const available = await listProfiles(workspaceRoot);
201
+ throw new ConfigError(
202
+ `No profile selected in workspace ${workspaceRoot}.\n` +
203
+ (available.length > 0
204
+ ? `Available: ${available.join(', ')}\n` +
205
+ `Pass --profile <name>, set LANES_LINK_PROFILE, or set default_profile in ${WORKSPACE_FILE}.`
206
+ : `No profiles exist yet. Create one with: lanes link profile add <name> --default`),
207
+ );
208
+ }
209
+
210
+ // Provisional: the config has not been read yet, so `instance.default_target`
211
+ // is not available to fall back to. `resolveTarget` settles it, from the same
212
+ // helper, so the two cannot disagree about what beats what.
213
+ const asked = askedTarget(options.targetFlag, env);
214
+
215
+ const path = profilePath(workspaceRoot, profile);
216
+ if (!(await workspaceFiles(workspaceRoot).has(`profiles/${profile}.yaml`))) {
217
+ const available = await listProfiles(workspaceRoot);
218
+ throw new ConfigError(
219
+ `Profile "${profile}" does not exist (looked for ${path}).\n` +
220
+ (available.length > 0 ? `Available: ${available.join(', ')}` : 'No profiles exist yet.'),
221
+ );
222
+ }
223
+
224
+ return {
225
+ workspaceRoot,
226
+ profile,
227
+ profilePath: path,
228
+ target: asked.target ?? '',
229
+ profileSource,
230
+ targetSource: asked.source ?? 'config-default',
231
+ };
232
+ }
233
+
234
+ /**
235
+ * Resolve a target-relative path against the workspace root.
236
+ *
237
+ * Only meaningful for the adapters that take a filesystem path — `file`
238
+ * credentials, the `filesystem` blob store. A workspace in a bucket selects
239
+ * none of those, so reaching here with a remote root means a target declared a
240
+ * local adapter against a remote workspace, and saying so beats handing a
241
+ * `gs://…/data/x` string to `Bun.file`.
242
+ */
243
+ export function workspacePath(workspaceRoot: string, path: string): string {
244
+ if (isRemoteWorkspace(workspaceRoot)) {
245
+ throw new ConfigError(
246
+ `This target wants a filesystem path ("${path}"), but the workspace is ${workspaceRoot}. ` +
247
+ 'A workspace in a bucket can only use adapters that address it as one: ' +
248
+ 'credentials `gcp-secret-manager`, storage `gcs` or `s3`, vault `secret` or `blob`.',
249
+ );
250
+ }
251
+ return isAbsolute(path) ? path : resolve(workspaceRoot, path);
252
+ }
253
+
254
+ /**
255
+ * The line every command prints before acting, read-only commands included.
256
+ *
257
+ * This is the primary guard against operating on the wrong instance, and it
258
+ * costs one line.
259
+ */
260
+ export function describeSelection(resolution: Resolution): string {
261
+ return `profile: ${resolution.profile} (${resolution.profileSource}) target: ${resolution.target} (${resolution.targetSource})`;
262
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Custom providers — the ones an operator writes.
3
+ *
4
+ * A YAML manifest in `~/.lanes-link/data/<profile>/providers.d/`, validated by
5
+ * exactly the schema the built-ins are validated by, registered into exactly
6
+ * the same registry. A service nobody has integrated is a file, not a pull
7
+ * request someone waits on.
8
+ *
9
+ * That equivalence is the scalability claim of the manifest design, and it is
10
+ * only worth anything if it is literally true — so nothing here is a reduced
11
+ * form of a built-in. `template.ts` scaffolds one starting point per
12
+ * connectivity type; `load.ts` reads and validates them.
13
+ */
14
+
15
+ export {
16
+ loadProfileProviders,
17
+ parseManifest,
18
+ parseManifestFile,
19
+ type LoadedManifest,
20
+ } from './load.ts';
21
+ export { manifestTemplate } from './template.ts';