@lanes-sh/link 0.6.10 → 0.7.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 (202) hide show
  1. package/.gcloudignore +25 -15
  2. package/README.md +62 -45
  3. package/instructions/skills/lanes-link/SKILL.md +25 -0
  4. package/package.json +3 -2
  5. package/src/audit/conformance.ts +1 -1
  6. package/src/cli/argv.ts +14 -1
  7. package/src/cli/commands/connect/acquire.ts +100 -0
  8. package/src/cli/commands/connect/custom/index.ts +1 -1
  9. package/src/cli/commands/connect/declare.ts +19 -0
  10. package/src/cli/commands/connect/index.ts +54 -56
  11. package/src/cli/commands/connect/requirements.ts +23 -1
  12. package/src/cli/commands/connect/setup.ts +1 -1
  13. package/src/cli/commands/connect/variables.ts +220 -0
  14. package/src/cli/commands/knowledge/index.ts +22 -4
  15. package/src/cli/commands/knowledge/migrate.ts +61 -43
  16. package/src/cli/commands/operate/tools.ts +1 -1
  17. package/src/cli/commands/owner/entities.ts +244 -0
  18. package/src/cli/commands/owner/shared.ts +14 -0
  19. package/src/cli/commands/owner/vault.ts +2 -2
  20. package/src/cli/commands/owner.ts +13 -3
  21. package/src/cli/commands/setup.ts +12 -0
  22. package/src/cli/config-edit.ts +16 -6
  23. package/src/cli/config-repair.ts +10 -0
  24. package/src/cli/dispatch-owner.ts +30 -1
  25. package/src/cli/main.ts +2 -1
  26. package/src/cli/runtime/open.ts +14 -7
  27. package/src/cli/runtime/registry.ts +6 -1
  28. package/src/cli/selection.ts +11 -0
  29. package/src/cli/usage.ts +13 -2
  30. package/src/connectivity/auth/strategy/index.ts +1 -1
  31. package/src/connectivity/capability.ts +1 -1
  32. package/src/connectivity/context.ts +1 -1
  33. package/src/connectivity/index.ts +1 -1
  34. package/src/connectivity/mail/attachments.ts +42 -73
  35. package/src/connectivity/mail/content-type.ts +71 -0
  36. package/src/connectivity/mail/index.ts +1 -1
  37. package/src/connectivity/manifest/provider.ts +50 -0
  38. package/src/connectivity/manifest/variables.ts +173 -0
  39. package/src/connectivity/transports/factory.ts +74 -1
  40. package/src/connectivity/transports/http/index.ts +8 -2
  41. package/src/connectivity/transports/http/keys.ts +83 -0
  42. package/src/connectivity/transports/imap/attachment.ts +53 -10
  43. package/src/connectivity/transports/imap/capabilities.ts +28 -0
  44. package/src/connectivity/transports/imap/download.ts +111 -0
  45. package/src/connectivity/transports/imap/index.ts +13 -0
  46. package/src/connectivity/transports/imap/operations.ts +1 -0
  47. package/src/deployments/adapters/s3.ts +1 -1
  48. package/src/deployments/gcp/provision.ts +1 -1
  49. package/src/deployments/knowledge.ts +44 -18
  50. package/src/deployments/prepare.ts +1 -1
  51. package/src/deployments/serving.ts +1 -1
  52. package/src/dispatch/dispatch.ts +17 -10
  53. package/src/dispatch/staging.ts +76 -1
  54. package/src/profile/knowledge.ts +19 -11
  55. package/src/profile/schema.ts +2 -2
  56. package/src/providers/README.md +189 -0
  57. package/src/providers/airtable/index.ts +10 -0
  58. package/src/providers/algolia/index.ts +10 -0
  59. package/src/providers/amplitude/index.ts +10 -0
  60. package/src/providers/apify/index.ts +10 -0
  61. package/src/providers/asana/index.ts +10 -0
  62. package/src/providers/assets/provider.ts +11 -4
  63. package/src/providers/atlassian/index.ts +19 -0
  64. package/src/providers/attio/index.ts +10 -0
  65. package/src/providers/betterstack/index.ts +10 -0
  66. package/src/providers/box/index.ts +64 -0
  67. package/src/providers/brightdata/index.ts +10 -0
  68. package/src/providers/buildkite/index.ts +10 -0
  69. package/src/providers/bunq/index.ts +2 -2
  70. package/src/providers/calendly/index.ts +10 -0
  71. package/src/providers/canva/index.ts +10 -0
  72. package/src/providers/circleci/index.ts +10 -0
  73. package/src/providers/clickup/index.ts +10 -0
  74. package/src/providers/close/index.ts +10 -0
  75. package/src/providers/cloudflare_bindings/index.ts +14 -0
  76. package/src/providers/cloudflare_observability/index.ts +10 -0
  77. package/src/providers/contentful/index.ts +10 -0
  78. package/src/providers/custom/load.ts +1 -1
  79. package/src/providers/datadog/index.ts +10 -0
  80. package/src/providers/discord/index.ts +1 -1
  81. package/src/providers/discord/specs/discord.v10.json +0 -88
  82. package/src/providers/discord/specs/vendor.ts +1 -1
  83. package/src/providers/dropbox/index.ts +10 -0
  84. package/src/providers/entities/catalogue.ts +366 -0
  85. package/src/providers/entities/find.ts +279 -0
  86. package/src/providers/entities/provider.ts +334 -0
  87. package/src/providers/entities/render.ts +142 -0
  88. package/src/providers/entities/store.ts +276 -0
  89. package/src/providers/entities/writes.ts +279 -0
  90. package/src/providers/example/provider.ts +1 -1
  91. package/src/providers/expensify/index.ts +10 -0
  92. package/src/providers/fastmail/calendar/index.ts +21 -0
  93. package/src/providers/fastmail/contacts/index.ts +17 -0
  94. package/src/providers/fastmail/index.ts +4 -0
  95. package/src/providers/fastmail/mail/index.ts +38 -0
  96. package/src/providers/fastmail/shared/setup.ts +52 -0
  97. package/src/providers/figma/index.ts +10 -0
  98. package/src/providers/fireflies/index.ts +10 -0
  99. package/src/providers/flagsmith/index.ts +10 -0
  100. package/src/providers/gamma/index.ts +10 -0
  101. package/src/providers/github/index.ts +2 -2
  102. package/src/providers/google/gmail/attachment.ts +16 -1
  103. package/src/providers/google/gmail-imap/index.ts +7 -2
  104. package/src/providers/google/shared/oauth.ts +1 -1
  105. package/src/providers/google/shared/service-account.ts +1 -1
  106. package/src/providers/google/shared/setup.ts +1 -1
  107. package/src/providers/google/specs/calendar.v3.json +0 -32
  108. package/src/providers/google/specs/docs.v1.json +0 -76
  109. package/src/providers/google/specs/drive.v3.json +0 -32
  110. package/src/providers/google/specs/gmail.v1.json +0 -76
  111. package/src/providers/google/specs/people.v1.json +0 -77
  112. package/src/providers/google/specs/sheets.v4.json +0 -76
  113. package/src/providers/google/specs/tasks.v1.json +0 -76
  114. package/src/providers/grafana/index.ts +10 -0
  115. package/src/providers/heroku/index.ts +10 -0
  116. package/src/providers/hubspot/index.ts +75 -0
  117. package/src/providers/hygraph/index.ts +10 -0
  118. package/src/providers/icloud/mail/index.ts +5 -0
  119. package/src/providers/icloud/shared/setup.ts +1 -1
  120. package/src/providers/index.ts +255 -0
  121. package/src/providers/insightly/index.ts +10 -0
  122. package/src/providers/jam/index.ts +10 -0
  123. package/src/providers/klaviyo/index.ts +10 -0
  124. package/src/providers/mailbox/index.ts +87 -0
  125. package/src/providers/memory/provider.ts +3 -8
  126. package/src/providers/mercury/index.ts +10 -0
  127. package/src/providers/microsoft/calendar/index.ts +49 -0
  128. package/src/providers/microsoft/contacts/index.ts +50 -0
  129. package/src/providers/microsoft/drive/index.ts +62 -0
  130. package/src/providers/microsoft/index.ts +12 -0
  131. package/src/providers/microsoft/mail/index.ts +76 -0
  132. package/src/providers/microsoft/shared/oauth.ts +58 -0
  133. package/src/providers/microsoft/shared/scopes.ts +56 -0
  134. package/src/providers/microsoft/shared/setup.ts +45 -0
  135. package/src/providers/microsoft/specs/microsoft-todo.v1.json +755 -0
  136. package/src/providers/microsoft/specs/onedrive.v1.json +993 -0
  137. package/src/providers/microsoft/specs/outlook-calendar.v1.json +1743 -0
  138. package/src/providers/microsoft/specs/outlook-contacts.v1.json +430 -0
  139. package/src/providers/microsoft/specs/outlook-mail.v1.json +901 -0
  140. package/src/providers/microsoft/specs/vendor.ts +232 -0
  141. package/src/providers/microsoft/todo/index.ts +54 -0
  142. package/src/providers/miro/index.ts +10 -0
  143. package/src/providers/mixpanel/index.ts +10 -0
  144. package/src/providers/monday/index.ts +10 -0
  145. package/src/providers/mux/index.ts +10 -0
  146. package/src/providers/navan/index.ts +10 -0
  147. package/src/providers/neon/index.ts +10 -0
  148. package/src/providers/netlify/index.ts +10 -0
  149. package/src/providers/nextcloud/calendar/index.ts +28 -0
  150. package/src/providers/nextcloud/contacts/index.ts +18 -0
  151. package/src/providers/nextcloud/index.ts +3 -0
  152. package/src/providers/nextcloud/shared/setup.ts +59 -0
  153. package/src/providers/owner.ts +15 -3
  154. package/src/providers/paddle/index.ts +10 -0
  155. package/src/providers/paypal/index.ts +10 -0
  156. package/src/providers/posthog/index.ts +10 -0
  157. package/src/providers/prisma/index.ts +10 -0
  158. package/src/providers/ramp/index.ts +10 -0
  159. package/src/providers/recurly/index.ts +10 -0
  160. package/src/providers/reddit/index.ts +1 -1
  161. package/src/providers/remote/index.ts +10 -0
  162. package/src/providers/render/index.ts +35 -0
  163. package/src/providers/replicate/index.ts +10 -0
  164. package/src/providers/resend/index.ts +10 -0
  165. package/src/providers/riverside/index.ts +10 -0
  166. package/src/providers/rootly/index.ts +10 -0
  167. package/src/providers/rudderstack/index.ts +10 -0
  168. package/src/providers/salesloft/index.ts +10 -0
  169. package/src/providers/sanity/index.ts +10 -0
  170. package/src/providers/scopes.ts +2 -0
  171. package/src/providers/sentry/index.ts +10 -0
  172. package/src/providers/setup/plan.ts +11 -0
  173. package/src/providers/setup/provider.ts +12 -0
  174. package/src/providers/shared/openapi.ts +70 -0
  175. package/src/providers/shared/slug.ts +28 -0
  176. package/src/providers/shared/vendor-operations.ts +43 -1
  177. package/src/providers/shared/vendor-spec.ts +98 -17
  178. package/src/providers/shortcut/index.ts +10 -0
  179. package/src/providers/slack/index.ts +2 -2
  180. package/src/providers/square/index.ts +10 -0
  181. package/src/providers/storyblok/index.ts +10 -0
  182. package/src/providers/stripe/index.ts +10 -0
  183. package/src/providers/supabase/index.ts +10 -0
  184. package/src/providers/tasks/store.ts +2 -7
  185. package/src/providers/tavily/index.ts +10 -0
  186. package/src/providers/todoist/index.ts +10 -0
  187. package/src/providers/untested.ts +105 -0
  188. package/src/providers/vault/provider.ts +1 -1
  189. package/src/providers/vercel/index.ts +10 -0
  190. package/src/providers/vimeo/index.ts +10 -0
  191. package/src/providers/webflow/index.ts +10 -0
  192. package/src/providers/whimsical/index.ts +10 -0
  193. package/src/providers/wix/index.ts +10 -0
  194. package/src/providers/workable/index.ts +10 -0
  195. package/src/providers/yahoo_mail/index.ts +73 -0
  196. package/src/providers/zapier/index.ts +10 -0
  197. package/src/providers/zoho_mail/index.ts +79 -0
  198. package/src/secrets/document.ts +1 -1
  199. package/src/server/attachments.ts +129 -5
  200. package/src/server/index.ts +3 -3
  201. package/src/server/mcp/instructions.ts +88 -18
  202. package/src/server/stdio.ts +1 -1
@@ -6,7 +6,9 @@ import { nonInteractivePrompter, terminalPrompter, type Prompter } from '../../p
6
6
  import { openRuntime, type GlobalFlags } from '../../runtime.ts';
7
7
  import { moveCredential, siblingAccountId } from './accounts.ts';
8
8
  import { grantProvider } from './grant.ts';
9
+ import { acquireCredential } from './acquire.ts';
9
10
  import { declareConnection } from './declare.ts';
11
+ import { resolveConnectionAddress, type ResolvedAddress } from './variables.ts';
10
12
  import { discoverCapabilities } from './discover.ts';
11
13
  import { connectFamily, familyMembers } from './family.ts';
12
14
  import { authoriseWithKey } from './assertion.ts';
@@ -14,6 +16,7 @@ import { authorise } from './authorise.ts';
14
16
  import { authorisePastedToken } from './pasted-token.ts';
15
17
  import { chooseAuthMethod } from './method.ts';
16
18
  import { preflight } from './requirements.ts';
19
+ import { parseSet } from './variables.ts';
17
20
  import { ALREADY, NOTHING, renderOutcome, where, type ConnectOutcome } from './outcome.ts';
18
21
  import { nextAfterEdit, publishRuntimeEdit } from '#cli/publish.ts';
19
22
  import { bindNewCredential } from './bind-credential.ts';
@@ -40,6 +43,8 @@ import { unknownProvider } from './unknown.ts';
40
43
  export interface ConnectOptions extends GlobalFlags {
41
44
  readonly id?: string | undefined;
42
45
  readonly displayName?: string | undefined;
46
+ /** `--set key=value`, repeatable: where this connection's service is. */
47
+ readonly set?: readonly string[] | string | undefined;
43
48
  /**
44
49
  * `--label`: what to call this connection, instead of being asked.
45
50
  *
@@ -125,6 +130,9 @@ export async function runConnect(
125
130
  // manifests — a custom provider must be as connectable as a built-in.
126
131
  const runtime = await openRuntime(options);
127
132
 
133
+ // Declared out here so the `finally` can close whatever it built.
134
+ let address: ResolvedAddress | undefined;
135
+
128
136
  try {
129
137
  // After the runtime rather than before, like every other command that
130
138
  // announces: the alternative is resolving the profile twice, which for a
@@ -214,66 +222,52 @@ export async function runConnect(
214
222
  credentials: runtime.credentials,
215
223
  spec: target,
216
224
  method: method.kind,
225
+ supplied: parseSet(options.set),
217
226
  });
218
227
 
219
228
  if (blocked) return { ...NOTHING, ok: false, ...blocked };
220
229
  }
221
230
 
222
- if (method.kind === 'assertion') {
223
- await authoriseWithKey({
224
- manifest,
225
- assertion: method.assertion,
226
- connectionId: provisionalId,
227
- credentials: runtime.credentials,
228
- changes,
229
- // Same reading as the static-credential arm below: naming a connection,
230
- // or asking outright, is how someone says "that one again" — which is
231
- // what a rotated key calls for.
232
- replace: options.nonInteractive !== true && (options.replace === true || named !== undefined),
233
- prompter,
234
- });
235
- } else if (method.kind === 'pasted') {
236
- await authorisePastedToken({
237
- manifest,
238
- connectionId: provisionalId,
239
- credentials: runtime.credentials,
240
- prompter,
241
- });
242
- } else if (manifest.auth.kind === 'oauth') {
243
- await authorise({
244
- manifest,
245
- connectionId: provisionalId,
246
- credentials: runtime.credentials,
247
- document,
248
- changes,
249
- firstForProvider: !runtime.config.connections.some((c) => c.provider === providerId),
250
- target,
251
- profile,
252
- client: method.client,
253
- prompter,
254
- acceptBroadScopes: options.acceptBroadScopes === true,
255
- ...(options.fetch ? { fetch: options.fetch } : {}),
256
- });
257
- } else if (manifest.auth.kind !== 'none') {
258
- await ensureStaticCredential({
259
- manifest,
260
- connectionId: provisionalId,
261
- credentials: runtime.credentials,
262
- // Naming a connection is how someone says "this one, again" — which is
263
- // what a rotated key or a revoked app-specific password calls for.
264
- // `--replace` says the same thing about a family, where there is no
265
- // single provider to name and the ids are not the operator's to know.
266
- //
267
- // Never non-interactively: there, "again" already happened. The new
268
- // value was written with `secrets set` before this ran, so asking would
269
- // be asking for something the store already holds.
270
- replace: options.nonInteractive !== true && (options.replace === true || named !== undefined),
271
- provisional: provisionalId === PROVISIONAL_ID,
272
- prompter,
273
- });
274
- }
231
+ // 1a. Where this connection's service is, for a provider whose address is
232
+ // not the same for everybody.
233
+ //
234
+ // First, and before anything is written: step 0b's rule is that a
235
+ // refusal leaves the profile as it was, and asked last a non-interactive
236
+ // run with no `--set` acquired a credential before refusing.
237
+ address = await resolveConnectionAddress({
238
+ manifest,
239
+ prompter,
240
+ runtime,
241
+ providerId,
242
+ provisionalId,
243
+ interactive: options.nonInteractive !== true,
244
+ set: options.set,
245
+ });
275
246
 
276
- // 1b. The vendor's own handshake see `./strategy.ts`, including why here.
247
+ // 1b. Get the credential, whichever of the four ways this provider offers.
248
+ // See `./acquire.ts` — the arms each live in their own module already,
249
+ // and that file is the choosing.
250
+ await acquireCredential({
251
+ method,
252
+ manifest,
253
+ provisionalId,
254
+ credentials: runtime.credentials,
255
+ document,
256
+ changes,
257
+ providerId,
258
+ connections: runtime.config.connections,
259
+ target,
260
+ profile,
261
+ prompter,
262
+ named: named !== undefined,
263
+ provisional: provisionalId === PROVISIONAL_ID,
264
+ replace: options.replace === true,
265
+ nonInteractive: options.nonInteractive === true,
266
+ acceptBroadScopes: options.acceptBroadScopes === true,
267
+ ...(options.fetch ? { fetch: options.fetch } : {}),
268
+ });
269
+
270
+ // 1c. The vendor's own handshake — see `./strategy.ts`, including why here.
277
271
  await runStrategySetup(manifest, provisionalId, runtime);
278
272
 
279
273
  // 2. Ask the provider whose account that was.
@@ -288,7 +282,7 @@ export async function runConnect(
288
282
  explicitId: named,
289
283
  account: options.displayName,
290
284
  label: options.label,
291
- runtime,
285
+ runtime: { ...runtime, connectorFor: address.connectorFor },
292
286
  prompter,
293
287
  });
294
288
 
@@ -309,7 +303,7 @@ export async function runConnect(
309
303
  manifest,
310
304
  connectionId,
311
305
  credentials: runtime.credentials,
312
- connectorFor: runtime.connectorFor.bind(runtime),
306
+ connectorFor: address.connectorFor,
313
307
  remember: async (found) => {
314
308
  await runtime.state.kv.set('discovery', providerId, JSON.stringify(found));
315
309
  registry.setDiscovered(providerId, found);
@@ -326,6 +320,7 @@ export async function runConnect(
326
320
  account,
327
321
  label,
328
322
  method: method.id,
323
+ config: address.values,
329
324
  }),
330
325
  );
331
326
 
@@ -394,6 +389,9 @@ export async function runConnect(
394
389
  next: nextAfterEdit(publish),
395
390
  };
396
391
  } finally {
392
+ // Before the runtime, and separately: this one holds its own cache, so a
393
+ // provider whose connector is a socket would otherwise keep it until exit.
394
+ await address?.close();
397
395
  await runtime.close();
398
396
  }
399
397
  }
@@ -97,6 +97,8 @@ export async function preflight(input: {
97
97
  * a step it does not perform.
98
98
  */
99
99
  readonly method?: 'oauth' | 'assertion' | 'pasted';
100
+ /** What `--set` supplied, so an address given on the command line is not reported missing. */
101
+ readonly supplied?: Readonly<Record<string, string>> | undefined;
100
102
  }): Promise<Blocked | null> {
101
103
  const { manifest, connectionId, profile, target, spec } = input;
102
104
  const method = input.method ?? 'oauth';
@@ -150,8 +152,28 @@ export async function preflight(input: {
150
152
  };
151
153
  }
152
154
 
155
+ // Before the credential check, so the two come back together rather than one
156
+ // per run. An address is not a credential and no `secrets set` places it —
157
+ // `--set` does — but to somebody scripting this it is the same thing: another
158
+ // value the command will refuse without. Reporting it only after the
159
+ // credentials were stored made a two-step setup a three-step one.
160
+ const address = manifest.variables
161
+ .filter((variable) => (input.supplied ?? {})[variable.key] === undefined)
162
+ .map((variable) => `--set ${variable.key}=${variable.example}`);
163
+
153
164
  const missing = await missingRequirements(requirements, input.credentials);
154
- if (missing.length === 0) return null;
165
+ if (missing.length === 0 && address.length === 0) return null;
166
+
167
+ if (address.length > 0) {
168
+ return {
169
+ reason: 'missing_credentials',
170
+ message:
171
+ `${manifest.name} needs ${missing.length + address.length} value(s) before it can connect. ` +
172
+ `Its address is not stored — it is given on the command line.`,
173
+ needs: missing,
174
+ then: `${rerun}${connectionId ? ` --id ${connectionId}` : ''} ${address.join(' ')} --non-interactive`,
175
+ };
176
+ }
155
177
 
156
178
  return {
157
179
  reason: 'missing_credentials',
@@ -115,7 +115,7 @@ export function reuseStoredCredential(input: {
115
115
  /**
116
116
  * Ask for a static credential — an API key, or an app-specific password.
117
117
  *
118
- * This did not exist, which meant the custom-provider path `docs/detailed/creating-a-provider.md`
118
+ * This did not exist, which meant the custom-provider path `https://lanes.sh/docs/link/creating-a-provider`
119
119
  * documents did not work: a manifest declaring `auth: {kind: header}` connected
120
120
  * with no complaint, never asked for the key, and then reported itself
121
121
  * unauthorized forever — with `doctor` advising the very command that had just
@@ -0,0 +1,220 @@
1
+ import type { AnyConnector, ProviderManifest } from '#connectivity';
2
+ import { connectorFactory, type ConnectorFactory } from '#connectivity/transports';
3
+ import type { ProviderRegistry } from '#registry';
4
+
5
+ /** Only the fields this reads; the real row carries more. */
6
+ interface ConnectionRow {
7
+ readonly provider: string;
8
+ readonly id: string;
9
+ readonly config?: Readonly<Record<string, unknown>> | undefined;
10
+ }
11
+ import type { SecretStore } from '#secrets';
12
+ import type { Prompter } from '../../prompt.ts';
13
+
14
+ /**
15
+ * Asking a connection where its service is.
16
+ *
17
+ * Only a provider that declares `variables` reaches this — a Zendesk-shaped host
18
+ * that carries the tenant, or anything self-hosted. Everything else has one
19
+ * address for everybody and is never asked anything here.
20
+ *
21
+ * Asked *before* the account is settled, and that ordering is forced rather than
22
+ * chosen: settling the identity means calling the service, and there is nothing
23
+ * to call until the address is known. It is also why the values cannot simply be
24
+ * written to the connection row first — the row's id is not decided until the
25
+ * identity comes back.
26
+ */
27
+ export interface VariableValues {
28
+ readonly [key: string]: string;
29
+ }
30
+
31
+ export async function askForVariables(input: {
32
+ readonly manifest: ProviderManifest;
33
+ readonly prompter: Prompter;
34
+ /** Values already on the row, when reconnecting. Offered as the default. */
35
+ readonly existing?: Readonly<Record<string, unknown>> | undefined;
36
+ /** With nobody to ask, an absent value is a refusal rather than a prompt. */
37
+ readonly interactive: boolean;
38
+ /** From `--set key=value`, which is how a non-interactive run supplies one. */
39
+ readonly supplied?: Readonly<Record<string, string>> | undefined;
40
+ }): Promise<VariableValues> {
41
+ const { manifest, prompter, existing, interactive } = input;
42
+ const supplied = input.supplied ?? {};
43
+ if (manifest.variables.length === 0) return {};
44
+
45
+ const values: Record<string, string> = {};
46
+
47
+ for (const variable of manifest.variables) {
48
+ // `--set` outranks what the row already carries: naming a value is how
49
+ // somebody says the server moved.
50
+ const previous = supplied[variable.key] ?? existing?.[variable.key];
51
+ const known = typeof previous === 'string' && previous !== '' ? previous : undefined;
52
+
53
+ if (!interactive) {
54
+ if (!known) {
55
+ throw new Error(
56
+ `${manifest.name} needs to be told its ${variable.label} and there is nobody to ask. ` +
57
+ `Give it on the command line: --set ${variable.key}=${variable.example}`,
58
+ );
59
+ }
60
+ if (!new RegExp(variable.pattern).test(known)) {
61
+ throw new Error(
62
+ `"${known}" is not a usable ${variable.label} — it must look like ${variable.example}.`,
63
+ );
64
+ }
65
+ values[variable.key] = known;
66
+ continue;
67
+ }
68
+
69
+ // The description goes in the question rather than beside it: `Prompter.ask`
70
+ // takes one string, and a hostname nobody can guess needs the sentence that
71
+ // says where to find it.
72
+ const answer = (
73
+ await prompter.ask(
74
+ `${variable.description}\n${variable.label}${known ? ` [${known}]` : ` (e.g. ${variable.example})`}`,
75
+ )
76
+ ).trim();
77
+
78
+ const chosen = answer === '' ? known : answer;
79
+ if (!chosen) {
80
+ throw new Error(`${manifest.name} cannot be reached without its ${variable.label}.`);
81
+ }
82
+
83
+ // Checked here as well as at substitution, so a mistyped subdomain is a
84
+ // question asked again rather than a connection that is written and then
85
+ // fails on its first call. `applyVariables` is still the one that matters —
86
+ // it guards the value a *file* supplies, which never passes through here.
87
+ if (!new RegExp(variable.pattern).test(chosen)) {
88
+ throw new Error(
89
+ `"${chosen}" is not a usable ${variable.label} — it must look like ${variable.example}. ` +
90
+ `The value goes into the address this connection calls, so anything that could change ` +
91
+ `the host is refused.`,
92
+ );
93
+ }
94
+
95
+ values[variable.key] = chosen;
96
+ }
97
+
98
+ return values;
99
+ }
100
+
101
+ /**
102
+ * The address a provider was last given, from whichever of its connections has one.
103
+ *
104
+ * Also reached across a family: `nextcloud_calendar` and `nextcloud_contacts`
105
+ * are two providers on one server, and the setup steps tell people to connect
106
+ * them together — so being asked the same hostname twice is the thing to avoid.
107
+ * They are matched on sharing a credential app, which is already how "one
108
+ * account, several providers" is expressed.
109
+ */
110
+ function previousAddress(
111
+ connections: readonly ConnectionRow[],
112
+ providerId: string,
113
+ manifest: ProviderManifest,
114
+ ): Readonly<Record<string, unknown>> | undefined {
115
+ const family = manifest.auth.kind === 'basic' ? manifest.auth.app : undefined;
116
+
117
+ const mine = connections.find(
118
+ (connection) => connection.provider === providerId && connection.config,
119
+ );
120
+ if (mine) return mine.config;
121
+
122
+ if (!family) return undefined;
123
+
124
+ return connections.find(
125
+ (connection) => connection.provider.startsWith(`${family}_`) && connection.config,
126
+ )?.config;
127
+ }
128
+
129
+ export interface ResolvedAddress {
130
+ /** What the connection will store under `config`. Empty for a fixed-address provider. */
131
+ readonly values: VariableValues;
132
+ /** The factory the identity probe and discovery should use. */
133
+ readonly connectorFor: (providerId: string, connectionId: string) => AnyConnector | undefined;
134
+ /** Only ever a scoped factory; the runtime's own is never closed by this. */
135
+ close(): Promise<void>;
136
+ }
137
+
138
+ /**
139
+ * Everything a connect run needs in order to reach a service whose address it
140
+ * has just been told.
141
+ *
142
+ * The scoped factory is the part worth explaining. The values belong to a
143
+ * connection row that does not exist yet — the row is written at step 4, and
144
+ * the id it will carry is not settled until step 2 — so there is nowhere for
145
+ * the runtime's own lookup to read them from. Rather than write a provisional
146
+ * row and rename it, this run gets a factory of its own holding the values
147
+ * directly. The runtime's factory is untouched, which matters because its cache
148
+ * is what keeps a stateful connector a single instance.
149
+ *
150
+ * A provider with no variables gets the runtime's factory back and no second
151
+ * cache, so the ordinary path is exactly as it was.
152
+ */
153
+ export async function resolveConnectionAddress(input: {
154
+ readonly manifest: ProviderManifest;
155
+ readonly prompter: Prompter;
156
+ readonly providerId: string;
157
+ readonly provisionalId: string;
158
+ readonly interactive: boolean;
159
+ /** `--set key=value`, as the parser hands it over: absent, one, or many. */
160
+ readonly set?: readonly string[] | string | undefined;
161
+ readonly runtime: {
162
+ readonly registry: ProviderRegistry;
163
+ readonly credentials: SecretStore;
164
+ readonly config: { readonly connections: readonly ConnectionRow[] };
165
+ connectorFor(providerId: string, connectionId: string): AnyConnector | undefined;
166
+ };
167
+ }): Promise<ResolvedAddress> {
168
+ const { manifest, runtime } = input;
169
+
170
+ const values = await askForVariables({
171
+ manifest,
172
+ prompter: input.prompter,
173
+ // What was said last time, offered as the default.
174
+ //
175
+ // Not keyed on the provisional id, which is `pending` unless `--id` was
176
+ // given — so the lookup almost never hit and every reconnect asked for the
177
+ // server address again. Any declared connection of this provider will do:
178
+ // the id is not settled yet, and a second Nextcloud on a *different* host is
179
+ // rarer than reconnecting the one there is. It is offered, not imposed —
180
+ // pressing Enter accepts it and typing replaces it.
181
+ existing: previousAddress(runtime.config.connections, input.providerId, manifest),
182
+ interactive: input.interactive,
183
+ supplied: parseSet(input.set),
184
+ });
185
+
186
+ if (manifest.variables.length === 0) {
187
+ return {
188
+ values,
189
+ connectorFor: runtime.connectorFor.bind(runtime),
190
+ close: async () => {},
191
+ };
192
+ }
193
+
194
+ const scoped: ConnectorFactory = connectorFactory({
195
+ registry: runtime.registry,
196
+ credentials: runtime.credentials,
197
+ connectionConfig: () => values,
198
+ });
199
+
200
+ return { values, connectorFor: scoped, close: () => scoped.closeAll() };
201
+ }
202
+
203
+ /**
204
+ * `--set key=value` into a map, refusing anything that is not a pair.
205
+ *
206
+ * A flag given once arrives as a string and twice as an array, which is the
207
+ * parser's shape rather than a choice here.
208
+ */
209
+ export function parseSet(input: readonly string[] | string | undefined): Record<string, string> {
210
+ const given = input === undefined ? [] : typeof input === 'string' ? [input] : input;
211
+ const values: Record<string, string> = {};
212
+
213
+ for (const entry of given) {
214
+ const at = entry.indexOf('=');
215
+ if (at <= 0) throw new Error(`--set wants key=value, and got "${entry}".`);
216
+ values[entry.slice(0, at)] = entry.slice(at + 1);
217
+ }
218
+
219
+ return values;
220
+ }
@@ -74,10 +74,17 @@ export async function knowledgeShow(flags: KnowledgeFlags): Promise<void> {
74
74
  const selection = ` --profile ${runtime.resolution.profile} --target ${runtime.target}`;
75
75
  const skills = (await runtime.skills.list()).length;
76
76
  const memory = (await runtime.storage.list(`${KNOWLEDGE_LAYOUT.memory}/`)).length;
77
+ const entities = (await runtime.storage.list(`${KNOWLEDGE_LAYOUT.entities}/`)).length;
77
78
  const where = runtime.knowledge?.describe;
78
79
 
79
80
  if (flags.json) {
80
- print(JSON.stringify({ target: runtime.target, where: where ?? 'local', memory, skills }, null, 2));
81
+ print(
82
+ JSON.stringify(
83
+ { target: runtime.target, where: where ?? 'local', memory, skills, entities },
84
+ null,
85
+ 2,
86
+ ),
87
+ );
81
88
  return;
82
89
  }
83
90
 
@@ -96,6 +103,16 @@ export async function knowledgeShow(flags: KnowledgeFlags): Promise<void> {
96
103
  where ? `${where}/${KNOWLEDGE_LAYOUT.skills}` : layout.skills(profile),
97
104
  style.dim(`${skills} file${skills === 1 ? '' : 's'}`),
98
105
  ],
106
+ // The count includes the derived `_index.json`, deliberately: it is a
107
+ // file in that directory and it is committed with the rest, so a number
108
+ // that quietly excluded it would not match what a person sees there.
109
+ [
110
+ ' entities',
111
+ where
112
+ ? `${where}/${KNOWLEDGE_LAYOUT.entities}`
113
+ : `${layout.blobs(profile)}/${KNOWLEDGE_LAYOUT.entities}`,
114
+ style.dim(`${entities} file${entities === 1 ? '' : 's'}`),
115
+ ],
99
116
  ]);
100
117
 
101
118
  print('');
@@ -266,8 +283,9 @@ async function useLocal(flags: KnowledgeFlags): Promise<void> {
266
283
  const moved = await moveOut(repository, knowledge, local.storage, local.skills);
267
284
  print(
268
285
  ok(
269
- `wrote back ${moved.memory} memory entr${moved.memory === 1 ? 'y' : 'ies'} and ` +
270
- `${moved.skills} skill file${moved.skills === 1 ? '' : 's'}`,
286
+ `wrote back ${moved.memory} memory entr${moved.memory === 1 ? 'y' : 'ies'}, ` +
287
+ `${moved.skills} skill file${moved.skills === 1 ? '' : 's'} and ` +
288
+ `${moved.entities} entity file${moved.entities === 1 ? '' : 's'}`,
271
289
  ),
272
290
  );
273
291
  }
@@ -275,7 +293,7 @@ async function useLocal(flags: KnowledgeFlags): Promise<void> {
275
293
  await writeBlock(runtime.config, runtime.resolution.workspaceRoot, undefined);
276
294
 
277
295
  print('');
278
- print(ok('memory and skills are back on this target\'s own storage'));
296
+ print(ok('memory, skills and entities are back on this target\'s own storage'));
279
297
  print(
280
298
  style.dim(
281
299
  ` ${knowledge.repo} still holds everything — it is version control, so nothing was removed from it.`,
@@ -1,4 +1,10 @@
1
- import { ConfigError, KNOWLEDGE_LAYOUT, knowledgeRoot, type KnowledgeConfig } from '#profile';
1
+ import {
2
+ ConfigError,
3
+ KNOWLEDGE_LAYOUT,
4
+ knowledgeRoot,
5
+ type KnowledgeArea,
6
+ type KnowledgeConfig,
7
+ } from '#profile';
2
8
  import type { BlobStore } from '#stores/blobs';
3
9
  import { commitFiles, type CommitFile } from '#deployments/adapters/github-commit.ts';
4
10
  import type { GithubRepository } from '#deployments/adapters/github-repo.ts';
@@ -24,10 +30,31 @@ export interface Movable {
24
30
  readonly key: string;
25
31
  /** Where it goes in the repository. */
26
32
  readonly path: string;
27
- readonly area: 'memory' | 'skills';
33
+ readonly area: KnowledgeArea;
28
34
  readonly data: Uint8Array;
29
35
  }
30
36
 
37
+ /**
38
+ * Where each area lives locally, as one table.
39
+ *
40
+ * Two of the three are prefixes inside the profile's blob root and the third is
41
+ * a store of its own, and that asymmetry was written out three times before —
42
+ * once per direction — which is three places to forget a fourth area. Stating
43
+ * it once means `localContents`, `removeLocal` and `moveOut` each became a loop
44
+ * over this rather than a branch on a literal.
45
+ */
46
+ type LocalArea = { readonly store: BlobStore; readonly prefix: string };
47
+
48
+ function localAreas(storage: BlobStore, skills: BlobStore): Record<KnowledgeArea, LocalArea> {
49
+ return {
50
+ memory: { store: storage, prefix: `${KNOWLEDGE_LAYOUT.memory}/` },
51
+ skills: { store: skills, prefix: '' },
52
+ entities: { store: storage, prefix: `${KNOWLEDGE_LAYOUT.entities}/` },
53
+ };
54
+ }
55
+
56
+ const AREAS = ['memory', 'skills', 'entities'] as const;
57
+
31
58
  /**
32
59
  * Everything this profile has stored locally, ready to move.
33
60
  *
@@ -42,30 +69,19 @@ export async function localContents(
42
69
  skills: BlobStore,
43
70
  knowledge: KnowledgeConfig,
44
71
  ): Promise<Movable[]> {
72
+ const areas = localAreas(storage, skills);
45
73
  const found: Movable[] = [];
46
- const prefix = `${KNOWLEDGE_LAYOUT.memory}/`;
47
-
48
- for (const entry of await storage.list(prefix)) {
49
- const data = await storage.get(entry.key);
50
- if (data === null) continue; // Listed then deleted; not worth failing over.
51
- const key = entry.key.slice(prefix.length);
52
- found.push({
53
- key,
54
- area: 'memory',
55
- path: `${knowledgeRoot(knowledge, 'memory')}/${key}`,
56
- data,
57
- });
58
- }
59
74
 
60
- for (const entry of await skills.list()) {
61
- const data = await skills.get(entry.key);
62
- if (data === null) continue;
63
- found.push({
64
- key: entry.key,
65
- area: 'skills',
66
- path: `${knowledgeRoot(knowledge, 'skills')}/${entry.key}`,
67
- data,
68
- });
75
+ for (const area of AREAS) {
76
+ const { store, prefix } = areas[area];
77
+
78
+ for (const entry of await store.list(prefix)) {
79
+ const data = await store.get(entry.key);
80
+ if (data === null) continue; // Listed then deleted; not worth failing over.
81
+
82
+ const key = entry.key.slice(prefix.length);
83
+ found.push({ key, area, path: `${knowledgeRoot(knowledge, area)}/${key}`, data });
84
+ }
69
85
  }
70
86
 
71
87
  return found;
@@ -115,9 +131,11 @@ export async function removeLocal(
115
131
  skills: BlobStore,
116
132
  movable: readonly Movable[],
117
133
  ): Promise<void> {
134
+ const areas = localAreas(storage, skills);
135
+
118
136
  for (const item of movable) {
119
- if (item.area === 'memory') await storage.delete(`${KNOWLEDGE_LAYOUT.memory}/${item.key}`);
120
- else await skills.delete(item.key);
137
+ const { store, prefix } = areas[item.area];
138
+ await store.delete(`${prefix}${item.key}`);
121
139
  }
122
140
  }
123
141
 
@@ -135,24 +153,19 @@ export async function moveOut(
135
153
  knowledge: KnowledgeConfig,
136
154
  storage: BlobStore,
137
155
  skills: BlobStore,
138
- ): Promise<{ memory: number; skills: number }> {
156
+ ): Promise<Record<KnowledgeArea, number>> {
139
157
  const { entries } = await repository.entries();
140
- const roots = {
141
- memory: `${knowledgeRoot(knowledge, 'memory')}/`,
142
- skills: `${knowledgeRoot(knowledge, 'skills')}/`,
143
- } as const;
144
-
145
- const counts = { memory: 0, skills: 0 };
158
+ const areas = localAreas(storage, skills);
159
+ const counts: Record<KnowledgeArea, number> = { memory: 0, skills: 0, entities: 0 };
146
160
 
147
161
  for (const entry of entries.values()) {
148
- for (const area of ['memory', 'skills'] as const) {
149
- if (!entry.path.startsWith(roots[area])) continue;
150
-
151
- const key = entry.path.slice(roots[area].length);
152
- const data = await repository.blob(entry.sha);
162
+ for (const area of AREAS) {
163
+ const root = `${knowledgeRoot(knowledge, area)}/`;
164
+ if (!entry.path.startsWith(root)) continue;
153
165
 
154
- if (area === 'memory') await storage.put(`${KNOWLEDGE_LAYOUT.memory}/${key}`, data);
155
- else await skills.put(key, data);
166
+ const key = entry.path.slice(root.length);
167
+ const { store, prefix } = areas[area];
168
+ await store.put(`${prefix}${key}`, await repository.blob(entry.sha));
156
169
 
157
170
  counts[area] += 1;
158
171
  }
@@ -161,14 +174,19 @@ export async function moveOut(
161
174
  return counts;
162
175
  }
163
176
 
164
- /** `12 skills and 47 memory entries`, or whichever half of that is non-zero. */
177
+ /** `12 skills and 47 memory entries`, or whichever parts of that are non-zero. */
165
178
  export function summarise(movable: readonly Movable[]): string {
166
- const skills = movable.filter((item) => item.area === 'skills').length;
167
- const memory = movable.length - skills;
179
+ const count = (area: KnowledgeArea) => movable.filter((item) => item.area === area).length;
180
+ const skills = count('skills');
181
+ const memory = count('memory');
182
+ const entities = count('entities');
168
183
 
184
+ // Skills and memory keep their existing wording and their existing order: a
185
+ // third clause is an addition to this sentence, not a rewrite of it.
169
186
  const parts = [
170
187
  ...(skills > 0 ? [`${skills} skill${skills === 1 ? '' : 's'}`] : []),
171
188
  ...(memory > 0 ? [`${memory} memory entr${memory === 1 ? 'y' : 'ies'}`] : []),
189
+ ...(entities > 0 ? [`${entities} entit${entities === 1 ? 'y' : 'ies'}`] : []),
172
190
  ];
173
191
  return parts.length === 0 ? 'nothing' : parts.join(' and ');
174
192
  }
@@ -154,7 +154,7 @@ interface Surface {
154
154
  * `tools` as a count, matching `/reload` and `connect`; the names beside it.
155
155
  *
156
156
  * One key, one meaning. `ReloadResult.tools` and `PublishOutcome.tools` are both
157
- * numbers, and the count is what `docs/connect.md` tells an operator to compare
157
+ * numbers, and the count is what `https://lanes.sh/docs/link/connect` tells an operator to compare
158
158
  * against their client — shipping the same key here as an array would make
159
159
  * `.tools > 5` true for a single tool.
160
160
  */