@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,319 @@
1
+ import { ConfigError, type DeployConfig, type TargetConfig } from '#profile';
2
+ import { heading, print, style, waiting } from '#cli/output.ts';
3
+ import { ask, confirm } from '#cli/prompt.ts';
4
+ import type { SurveyInput, SurveyResult } from '../driver.ts';
5
+ import { activeProject, openBillingAccounts, projectExists } from './gcloud.ts';
6
+
7
+ /**
8
+ * Asking for what the config does not say yet.
9
+ *
10
+ * `deploy` used to refuse a target with no deployment block and tell the
11
+ * operator which four keys to go and write. That is a correct error message and
12
+ * a bad first run: the command knows what it needs, it can offer a sensible
13
+ * value for every one of them, and the answers belong in the file rather than in
14
+ * a flag the operator has to remember next time.
15
+ *
16
+ * It then refused a target that did not exist at all, which was the same mistake
17
+ * one level up. `credentials`, `storage` and `vault` have exactly one workable
18
+ * answer each on Cloud Run — Secret Manager, a bucket, and the sealed document
19
+ * beside the credentials — so a hand-written block could only ever be a
20
+ * transcription, and a wrong transcription fails somewhere unhelpful. The
21
+ * `filesystem` adapter in particular *appears* to work up here and loses
22
+ * everything on the next instance recycle.
23
+ *
24
+ * Every default is something already true rather than something invented — the
25
+ * project `gcloud` is pointed at, the profile's own name — so the common case is
26
+ * a handful of presses of return.
27
+ */
28
+
29
+ const DEFAULT_REGION = 'europe-west1';
30
+
31
+ /**
32
+ * Five random letters, for the two namespaces that are global to all of Google.
33
+ *
34
+ * A project id and a bucket name are both unique across every Google Cloud
35
+ * customer, so a memorable name is a name somebody already has. Google's own
36
+ * console does the same thing for the same reason.
37
+ *
38
+ * Letters rather than digits so the result is still pronounceable, and drawn
39
+ * from `crypto` rather than `Math.random` — not because a guessable bucket name
40
+ * is an vulnerability on its own, but because these names end up published in a
41
+ * URL and an unseeded PRNG that repeats across two machines produces a collision
42
+ * that looks like a permissions bug.
43
+ */
44
+ function randomSuffix(length = 5): string {
45
+ const alphabet = 'abcdefghijklmnopqrstuvwxyz';
46
+ const bytes = crypto.getRandomValues(new Uint8Array(length));
47
+
48
+ return [...bytes].map((byte) => alphabet[byte % alphabet.length]).join('');
49
+ }
50
+
51
+ /**
52
+ * The globally-unique name this deploy proposes for itself.
53
+ *
54
+ * One suffix, one name, used for **both** the project and the bucket — they are
55
+ * separate namespaces, so the same string is free in one when it is free in the
56
+ * other, and a deployment whose project and bucket are called the same thing is
57
+ * one you can find either way round from either console.
58
+ *
59
+ * Generated exactly once. Every later deploy reads the name back out of the
60
+ * profile, because the survey only runs for what the config does not say — a
61
+ * second suffix would name a second empty project beside the one holding
62
+ * everything.
63
+ *
64
+ * Sixteen characters, inside the project id limit of 30 and the bucket limit of
65
+ * 63, with no profile name in it: the profile is in the *service* name, which is
66
+ * the thing that has to differ when one project holds two of these.
67
+ */
68
+ export function proposedName(suffix = randomSuffix()): string {
69
+ return `lanes-link-${suffix}`;
70
+ }
71
+
72
+ /**
73
+ * What the service is called when nobody says.
74
+ *
75
+ * It used to be the bare profile name — `personal`, in a project that may hold
76
+ * a dozen unrelated services, saying nothing about what it is or who put it
77
+ * there. `mcp` is the useful half: what this serves is an MCP endpoint, and that
78
+ * is what someone finds it looking for months later.
79
+ *
80
+ * The profile stays in the middle because one workspace can deploy several, and
81
+ * two profiles deploying to one project must not collide on a name.
82
+ */
83
+ export function defaultServiceName(profile: string): string {
84
+ return `lanes-link-${profile}-mcp`;
85
+ }
86
+
87
+ /**
88
+ * The local part of the runtime service account, which has its own length rule.
89
+ *
90
+ * Service account ids are capped at 30 characters and a Cloud Run service name
91
+ * at 49, so a legal service name can derive an illegal account. That failure is
92
+ * one `gcloud iam service-accounts create` away from being silent — the step
93
+ * tolerates failure, so the deploy would carry on and roll a revision with no
94
+ * identity, and the first symptom would be it failing to read a secret.
95
+ */
96
+ export function accountId(service: string): string {
97
+ const proposed = `${service}-run`;
98
+ if (proposed.length <= 30) return proposed;
99
+
100
+ // Trim the service, not the suffix: `-run` is what says which account this is.
101
+ return `${service.slice(0, 30 - '-run'.length).replace(/-+$/, '')}-run`;
102
+ }
103
+
104
+ export async function surveyCloudRun(input: SurveyInput): Promise<SurveyResult> {
105
+ const { current, profile, gated, adapters } = input;
106
+
107
+ heading('Where should this deploy?');
108
+ print(style.dim(' Answers are written into your profile, so this is asked once.'));
109
+ print('');
110
+
111
+ const project = await askProject(current.project);
112
+ const billing = await askBilling(project, current.billing_account);
113
+ const region = await askWithDefault(' Region', current.region ?? DEFAULT_REGION);
114
+ const service = await askWithDefault(
115
+ ' Cloud Run service name',
116
+ current.service ?? defaultServiceName(profile),
117
+ );
118
+
119
+ // Not asked. A revision needs an identity that can read the credential store,
120
+ // and the only interesting choice is *whether* it is dedicated — which it
121
+ // should always be, because the default compute account is shared with
122
+ // everything else in the project.
123
+ const serviceAccount =
124
+ current.service_account ?? `${accountId(service)}@${project}.iam.gserviceaccount.com`;
125
+
126
+ const access = await askAccess(current.access, gated);
127
+
128
+ const deploy: DeployConfig = {
129
+ platform: 'cloudrun',
130
+ project,
131
+ region,
132
+ service,
133
+ access,
134
+ service_account: serviceAccount,
135
+ ...(billing ? { billing_account: billing } : {}),
136
+ };
137
+
138
+ // Only the `deploy` block was missing: the adapters are already chosen, and
139
+ // re-asking would be offering to overwrite a working target.
140
+ if (!adapters) return { target: { deploy } as TargetConfig };
141
+
142
+ // The project's own name. One deployment, one string: the bucket namespace is
143
+ // separate from the project namespace, so a name free in one is free in the
144
+ // other, and finding either from the other needs nothing written down.
145
+ const bucket = await askBucket(project);
146
+
147
+ return {
148
+ target: {
149
+ credentials: { adapter: 'gcp-secret-manager', project },
150
+ storage: { adapter: 'gcs', bucket },
151
+ // Not optional, and not a preference. The default is `file`, which wants a
152
+ // filesystem path, and this workspace lives in a bucket — so a target
153
+ // without this line parses, deploys, and refuses to boot.
154
+ vault: { adapter: 'secret' },
155
+ deploy,
156
+ } as TargetConfig,
157
+ ...(access === 'public' && !gated && (await askRemoteClients()) ? { authorization: { mode: 'self' } } : {}),
158
+ };
159
+ }
160
+
161
+ /**
162
+ * Which project this deploys into, or the one it is about to create.
163
+ *
164
+ * The default used to be whatever `gcloud config` was pointed at, which is
165
+ * whichever project you last worked on — a value that is *always* set, rarely
166
+ * the right one, and wrong in the most expensive direction: accepting it deploys
167
+ * a personal gateway holding live refresh tokens into a project shared with
168
+ * something else. A fresh id proposes the opposite default, and typing an
169
+ * existing name over it is one line.
170
+ */
171
+ async function askProject(current: string | undefined): Promise<string> {
172
+ const active = await waiting('reading your gcloud configuration', activeProject);
173
+
174
+ print(' A project of its own is the cleanest home for this — it holds the bucket,');
175
+ print(
176
+ style.dim(
177
+ ' the credential store, and nothing else. A new id is proposed; type the name\n' +
178
+ ` of an existing project to deploy into that instead${active ? ` (yours is ${active})` : ''}.`,
179
+ ),
180
+ );
181
+
182
+ return askWithDefault(' Google Cloud project', current ?? proposedName());
183
+ }
184
+
185
+ /**
186
+ * The billing account, asked only when there is a project to attach it to.
187
+ *
188
+ * Skipped entirely for a project that already exists — it is already billed, or
189
+ * it is not and that is not this command's business. Asking anyway would be a
190
+ * question with no consequence, which is the kind that teaches people to press
191
+ * return without reading.
192
+ *
193
+ * Refused rather than defaulted when there is no open account: a project created
194
+ * without billing enables no APIs, and every step after it fails with a message
195
+ * about the API rather than about billing.
196
+ */
197
+ async function askBilling(project: string, current: string | undefined): Promise<string | undefined> {
198
+ if (current) return current;
199
+ if (await waiting(`checking whether ${project} exists`, () => projectExists(project))) {
200
+ return undefined;
201
+ }
202
+
203
+ const accounts = await waiting('listing your billing accounts', openBillingAccounts);
204
+ print('');
205
+ print(` ${style.bold(project)} does not exist yet, so this deploy will create it.`);
206
+ print(style.dim(' A new project needs a billing account before any API can be enabled.'));
207
+
208
+ if (accounts.length === 0) {
209
+ throw new ConfigError(
210
+ `No open billing account found for this login, and "${project}" would have to be created.\n` +
211
+ ' Either name an existing project at the prompt, or set one up at\n' +
212
+ ' https://console.cloud.google.com/billing and re-run.',
213
+ );
214
+ }
215
+
216
+ for (const account of accounts) print(style.dim(` ${account.id} ${account.name}`));
217
+ return askWithDefault(' Billing account', accounts[0]!.id);
218
+ }
219
+
220
+ /**
221
+ * The one derived name that can collide with a stranger's.
222
+ *
223
+ * Bucket names are global across every Google Cloud customer, so this is asked
224
+ * rather than assumed even though the default is almost always free. `taken` is
225
+ * not a failure worth a stack trace three steps later.
226
+ */
227
+ async function askBucket(project: string): Promise<string> {
228
+ print('');
229
+ print(' A bucket holds everything this endpoint remembers:');
230
+ print(
231
+ style.dim(
232
+ ' its config, its connection state, the audit log, memory, skills and\n' +
233
+ ' attachments. Named after the project by default; bucket names are\n' +
234
+ ' globally unique, so this one may be taken even when the project was not.',
235
+ ),
236
+ );
237
+ return askWithDefault(' Bucket', project);
238
+ }
239
+
240
+ /**
241
+ * Whether a remote client has to reach this, which is what `mode: self` decides.
242
+ *
243
+ * Asked, and asked here, because nothing else in a first run does. `access:
244
+ * public` opens the platform's door; it does not give a Claude or ChatGPT
245
+ * connector any way to *obtain* a token, and those clients have nowhere to paste
246
+ * a fixed one. Without `auth.authorization` the endpoint answers a connector's
247
+ * handshake with a 401 carrying nothing to act on, which reads as a broken
248
+ * server rather than a missing setting — so the previous default outcome of a
249
+ * first deploy was an endpoint that could not be added to a phone, and no
250
+ * message anywhere said why.
251
+ */
252
+ async function askRemoteClients(): Promise<boolean> {
253
+ print('');
254
+ print(' Will you add this to Claude or ChatGPT, including on a phone?');
255
+ print(
256
+ style.dim(
257
+ ' yes this endpoint issues its own tokens — the client registers itself,\n' +
258
+ ' a browser opens on its approval page, you paste the token once.\n' +
259
+ ' Nothing to set up: no OAuth client, no console, no redirect URI.\n' +
260
+ ' no the bearer token is the only way in, which is all a local\n' +
261
+ ' registration needs. Add it later under auth.authorization.',
262
+ ),
263
+ );
264
+ return confirm(' Issue tokens for remote clients?');
265
+ }
266
+
267
+ /**
268
+ * Which door the platform leaves open.
269
+ *
270
+ * Asked rather than assumed, and asked with the consequence attached, because
271
+ * the two answers fail in opposite directions and neither failure is legible
272
+ * from the outside: `iam` looks like a broken endpoint to every MCP client, and
273
+ * `public` on a profile with no gate of its own is an open endpoint that answers
274
+ * normally.
275
+ */
276
+ async function askAccess(
277
+ current: DeployConfig['access'] | undefined,
278
+ gated: boolean,
279
+ ): Promise<DeployConfig['access']> {
280
+ const proposed = current ?? (gated ? 'public' : 'iam');
281
+
282
+ print('');
283
+ print(' Who may reach the service?');
284
+ print(
285
+ style.dim(
286
+ ' iam the platform checks a Google identity token first. Nothing that\n' +
287
+ ' cannot mint one gets through — which includes every agent harness.\n' +
288
+ ' public the platform lets the request in and this endpoint authenticates it.',
289
+ ),
290
+ );
291
+ if (gated) {
292
+ print(
293
+ style.dim(
294
+ ' This profile authenticates requests itself, so "public" is the working\n' +
295
+ ' choice — "iam" in front of it would lock out the clients it exists for.',
296
+ ),
297
+ );
298
+ }
299
+
300
+ const answer = (await askWithDefault(' Access', proposed)).toLowerCase();
301
+ return answer === 'public' ? 'public' : 'iam';
302
+ }
303
+
304
+ /**
305
+ * Ask, showing the default, and take it on an empty answer.
306
+ *
307
+ * Refuses an empty value with no default rather than writing one: an empty
308
+ * service name parses, reaches `gcloud`, and fails several minutes into a build.
309
+ */
310
+ async function askWithDefault(question: string, fallback: string | null | undefined): Promise<string> {
311
+ const shown = fallback ? `${question} ${style.dim(`[${fallback}]`)}` : question;
312
+
313
+ for (;;) {
314
+ const answer = (await ask(shown)).trim();
315
+ if (answer) return answer;
316
+ if (fallback) return fallback;
317
+ print(style.dim(' Required.'));
318
+ }
319
+ }
@@ -0,0 +1,12 @@
1
+ # Local — the default target
2
+
3
+ SQLite, an encrypted file, and a directory. No network, no account, no
4
+ credential beyond the two encryption keys, and everything under one workspace
5
+ root that a `cp -R` backs up.
6
+
7
+ There is no `target.ts` here: the local branches of `../target.ts` are three
8
+ lines each and inlining them keeps the whole mapping readable in one place. A
9
+ deployment earns a folder when it has machinery of its own — `../gcp/` has a
10
+ Dockerfile, a build config, and a rollout driver.
11
+
12
+ What lives on disk, and where, is `#profile`'s `workspace.ts`.
@@ -0,0 +1,257 @@
1
+ import { credentialRefFor, ownClientRefsFor, rotatableCredentialRefsFor } from '#registry';
2
+ import { listProfiles, loadProfileConfig, type Config, type TargetConfig } from '#profile';
3
+ import { VAULT_DOCUMENT_REF, VAULT_KEY_REF, generateVaultKey, type SecretStore } from '#secrets';
4
+ import { ok, print, style, warn } from '#cli/output.ts';
5
+ import { buildRegistryWithWorkspace, ensureProfileToken } from '#cli/runtime.ts';
6
+
7
+ /**
8
+ * Getting the target's credential store to the state a revision can boot from.
9
+ *
10
+ * This began as a refusal — list what is missing, name the command that stores
11
+ * each one, exit. That is a correct refusal and three round trips: the operator
12
+ * runs `deploy`, reads the list, runs `token rotate`, runs `secrets set`, runs
13
+ * `deploy` again. Two of those values the command can produce or ask for right
14
+ * here, and the third is genuinely somebody else's to create.
15
+ *
16
+ * What blocks versus what warns follows what the runtime does with each: without
17
+ * the profile token the endpoint refuses to start, and without the database URL
18
+ * it cannot open a database at all. A connection credential that is missing
19
+ * leaves that connection `unauthorized`, which is a documented state rather than
20
+ * an outage — so it is said out loud and not treated as a failure.
21
+ */
22
+
23
+ export interface PrepareInput {
24
+ readonly config: Config;
25
+ readonly credentials: SecretStore;
26
+ readonly root: string;
27
+ readonly target: string;
28
+ /** Never prompt; report only. `--dry-run` must not write to a credential store. */
29
+ readonly readOnly: boolean;
30
+ }
31
+
32
+ export interface PrepareResult {
33
+ /** Refs still missing after any seeding, which a revision cannot start without. */
34
+ readonly blocking: readonly string[];
35
+ readonly warnings: readonly string[];
36
+ }
37
+
38
+ /**
39
+ * Every credential reference a *running* revision rewrites — what `provision`
40
+ * needs in order to grant `secretVersionAdder` on each one and nothing else.
41
+ *
42
+ * Kept apart from the connection loop below rather than derived from it, because
43
+ * the two answer different questions and diverge on purpose: that loop asks
44
+ * where a connection's credential lives, which a hand-placed `credential_ref`
45
+ * may decide, and this asks what gets written while serving, which the refresh
46
+ * path derives from the manifest and has never read config for. Sharing one list
47
+ * would silently pick one answer for both.
48
+ *
49
+ * **Scoped exactly as the upload is**, for the reason `repairSetupSurface`
50
+ * states and one more: a profile this deploy sends is a profile the endpoint may
51
+ * serve, and a connection whose secret nobody bound fails an hour after the
52
+ * revision reports healthy. The asymmetry decides it — an extra binding is a
53
+ * resource-level grant on a secret the operator already owns, and a missing one
54
+ * is a 403 in somebody's chat window.
55
+ *
56
+ * A profile that cannot be read is skipped rather than fatal, matching the
57
+ * repair: `deploy` validates the profile it resolved, and a broken sibling
58
+ * should not cost the rollout. It goes up either way, and if it is served at
59
+ * all it is served broken for reasons that have nothing to do with IAM.
60
+ *
61
+ * Called before `provision`, which is before any store is opened — so it reads
62
+ * config and manifests only, and touches no credential.
63
+ */
64
+ export async function rotatableRefs(root: string, profile: string | undefined): Promise<string[]> {
65
+ const refs = new Set<string>();
66
+
67
+ for (const name of await listProfiles(root)) {
68
+ if (profile !== undefined && name !== profile) continue;
69
+
70
+ let config: Config;
71
+ try {
72
+ ({ config } = await loadProfileConfig(root, name));
73
+ } catch {
74
+ continue;
75
+ }
76
+
77
+ // Inside the loop because manifests are the profile's own (ADR-030): a
78
+ // connection in `work` naming a provider only `work` declares resolves to
79
+ // nothing against `personal`'s registry, and a missed ref here is a 403 an
80
+ // hour after the revision reports healthy.
81
+ const registry = await buildRegistryWithWorkspace(root, name);
82
+
83
+ for (const connection of config.connections) {
84
+ const manifest = registry.manifest(connection.provider);
85
+ for (const ref of rotatableCredentialRefsFor(connection, manifest)) refs.add(ref);
86
+ }
87
+ }
88
+
89
+ // Sorted, and a set: two Gmail connections share one dynamically registered
90
+ // client ref, and two profiles may name the same connection. A step list that
91
+ // repeated one would read as two different grants.
92
+ return [...refs].sort();
93
+ }
94
+
95
+ /**
96
+ * Every credential reference a running revision *reads*, so provision can bind
97
+ * `secretAccessor` on each one instead of on the whole project.
98
+ *
99
+ * The project-level grant this replaces was not an oversight — the adapter
100
+ * argued for it, on the grounds that the line worth defending is
101
+ * `secrets.create` — but it is broader than the revision ever needs, and the
102
+ * survey happily points a deploy at a project the operator already uses for
103
+ * other things. An SSRF or an RCE in the endpoint should reach this profile's
104
+ * own credentials, not every secret sharing a project with it.
105
+ *
106
+ * Feasible because the serving path only ever reads by explicit ref: `list()`
107
+ * is a CLI call, and `secretAccessor` never granted `secrets.list` anyway. So
108
+ * the set is knowable at deploy time, and it is the same walk `rotatableRefs`
109
+ * does — profiles, their connections, and the manifests behind them.
110
+ *
111
+ * Deliberately a superset, matching the asymmetry `rotatableRefs` states: an
112
+ * extra resource-level binding on a secret the operator already owns costs
113
+ * nothing, and a missing one is a 403 an hour after the revision reports
114
+ * healthy. Both `credentialRefFor` and `rotatableCredentialRefsFor` go in
115
+ * because the first honours a hand-placed `credential_ref` and the second is
116
+ * blind to it by design — the revision may read either.
117
+ *
118
+ * The same deploy-time bound the write side already lives with applies here: a
119
+ * connection authorised after a rollout is unreadable until the next one. That
120
+ * is not new, and `provision` is re-run by every deploy.
121
+ */
122
+ export async function readableRefs(
123
+ root: string,
124
+ profile: string | undefined,
125
+ declared: TargetConfig | undefined,
126
+ ): Promise<string[]> {
127
+ const refs = new Set<string>();
128
+
129
+ if (declared?.vault?.adapter === 'secret') {
130
+ refs.add(VAULT_KEY_REF);
131
+ refs.add(declared.vault.ref ?? VAULT_DOCUMENT_REF);
132
+ } else if (declared?.vault?.adapter === 'blob') {
133
+ // Ciphertext lives in the bucket, but the key that opens it is still here.
134
+ refs.add(VAULT_KEY_REF);
135
+ }
136
+
137
+ for (const name of await listProfiles(root)) {
138
+ if (profile !== undefined && name !== profile) continue;
139
+
140
+ let config: Config;
141
+ try {
142
+ ({ config } = await loadProfileConfig(root, name));
143
+ } catch {
144
+ continue;
145
+ }
146
+
147
+ refs.add(config.auth.token_ref);
148
+ // The OIDC audience check reads this on every verify (`server/endpoint.ts`).
149
+ if (config.auth.authorization?.mode === 'oidc') {
150
+ refs.add(config.auth.authorization.client_id_ref);
151
+ }
152
+
153
+ // Per profile, for the reason `rotatableRefs` gives: a manifest is this
154
+ // profile's, so the registry that resolves its connections must be too.
155
+ const registry = await buildRegistryWithWorkspace(root, name);
156
+
157
+ for (const connection of config.connections) {
158
+ const manifest = registry.manifest(connection.provider);
159
+ const ref = credentialRefFor(connection, manifest);
160
+ if (ref) refs.add(ref);
161
+ for (const rotatable of rotatableCredentialRefsFor(connection, manifest)) refs.add(rotatable);
162
+ for (const client of ownClientRefsFor(manifest, config.oauth_apps)) refs.add(client);
163
+ }
164
+ }
165
+
166
+ return [...refs].sort();
167
+ }
168
+
169
+ export async function prepareSecrets(input: PrepareInput): Promise<PrepareResult> {
170
+ const { config, credentials, root, target, readOnly } = input;
171
+ const blocking: string[] = [];
172
+ const warnings: string[] = [];
173
+
174
+ await seedProfileToken({ config, credentials, readOnly, blocking });
175
+ await seedVaultKey({ declared: config.targets[target], credentials, readOnly });
176
+
177
+ // Connection credentials are written by `connect`, against a real account, in
178
+ // a browser. Nothing here can produce one, and a deploy that stopped for one
179
+ // would be refusing to roll a revision over a mailbox that has not been
180
+ // authorised yet — which is a thing you might reasonably want to do.
181
+ //
182
+ // The command is spelled out rather than described. This is the one manual
183
+ // step a deploy genuinely cannot take for you, so it should cost a paste
184
+ // rather than a trip to the docs to work out how the id is spelled.
185
+ const registry = await buildRegistryWithWorkspace(root, config.instance.profile);
186
+ for (const connection of config.connections) {
187
+ const ref = credentialRefFor(connection, registry.manifest(connection.provider));
188
+ if (!ref || (await credentials.has(ref))) continue;
189
+ warnings.push(
190
+ `${connection.provider}.${connection.id} is not authorised yet — no credential at "${ref}"\n` +
191
+ ` lanes link connect ${connection.provider} --target ${target} --id ${connection.id}`,
192
+ );
193
+ }
194
+
195
+ return { blocking, warnings };
196
+ }
197
+
198
+ /**
199
+ * The key that seals the vault document.
200
+ *
201
+ * Minted here for the same reason the profile token is: it is a random string
202
+ * this process generates correctly and nobody can usefully choose. It used to be
203
+ * three manual commands in `docs/detailed/deployment-cloudrun.md` — generate, store,
204
+ * mount — whose only failure mode was forgetting them and finding out when the
205
+ * first `vault.*` call failed against a revision that had booted healthy.
206
+ *
207
+ * Only for a target whose credential store can hold it. A `file` vault is a
208
+ * local run, where the environment variable is the operator's own business.
209
+ *
210
+ * Never regenerated. A second key does not fail — it decrypts nothing, and the
211
+ * document it cannot open is the one holding every password the owner put
212
+ * there.
213
+ */
214
+ async function seedVaultKey(input: {
215
+ declared: TargetConfig | undefined;
216
+ credentials: SecretStore;
217
+ readOnly: boolean;
218
+ }): Promise<void> {
219
+ const adapter = input.declared?.vault?.adapter;
220
+ if (adapter !== 'secret' && adapter !== 'blob') return;
221
+ if (input.readOnly || (await input.credentials.has(VAULT_KEY_REF))) return;
222
+
223
+ await input.credentials.set(VAULT_KEY_REF, generateVaultKey());
224
+ print(ok(`minted the vault key at "${VAULT_KEY_REF}" — the revision reads it from there`));
225
+ }
226
+
227
+ /**
228
+ * The endpoint's own bearer token.
229
+ *
230
+ * Minted rather than demanded, and not even asked about: it is a random string
231
+ * this process generates correctly and the operator cannot usefully choose, so a
232
+ * prompt would be a question with one answer. `ensureProfileToken` is the same
233
+ * call `outputs` makes for a local target, which is what keeps "the token" one
234
+ * thing rather than a per-command notion.
235
+ *
236
+ * The deployed container deliberately will *not* do this — a token invented
237
+ * inside something that scales to zero is a token nobody can read back, and the
238
+ * endpoint would come up healthy while rejecting every agent.
239
+ */
240
+ async function seedProfileToken(input: {
241
+ config: Config;
242
+ credentials: SecretStore;
243
+ readOnly: boolean;
244
+ blocking: string[];
245
+ }): Promise<void> {
246
+ const ref = input.config.auth.token_ref;
247
+ if (await input.credentials.has(ref)) return;
248
+
249
+ if (input.readOnly) {
250
+ input.blocking.push(`the endpoint bearer token — nothing at "${ref}"`);
251
+ return;
252
+ }
253
+
254
+ const { created } = await ensureProfileToken(input.credentials, ref);
255
+ if (created) print(ok(`minted an endpoint token at "${ref}" — read it with: lanes link token show`));
256
+ }
257
+