@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.
- package/.gcloudignore +25 -15
- package/README.md +62 -45
- package/instructions/skills/lanes-link/SKILL.md +25 -0
- package/package.json +3 -2
- package/src/audit/conformance.ts +1 -1
- package/src/cli/argv.ts +14 -1
- package/src/cli/commands/connect/acquire.ts +100 -0
- package/src/cli/commands/connect/custom/index.ts +1 -1
- package/src/cli/commands/connect/declare.ts +19 -0
- package/src/cli/commands/connect/index.ts +54 -56
- package/src/cli/commands/connect/requirements.ts +23 -1
- package/src/cli/commands/connect/setup.ts +1 -1
- package/src/cli/commands/connect/variables.ts +220 -0
- package/src/cli/commands/knowledge/index.ts +22 -4
- package/src/cli/commands/knowledge/migrate.ts +61 -43
- package/src/cli/commands/operate/tools.ts +1 -1
- package/src/cli/commands/owner/entities.ts +244 -0
- package/src/cli/commands/owner/shared.ts +14 -0
- package/src/cli/commands/owner/vault.ts +2 -2
- package/src/cli/commands/owner.ts +13 -3
- package/src/cli/commands/setup.ts +12 -0
- package/src/cli/config-edit.ts +16 -6
- package/src/cli/config-repair.ts +10 -0
- package/src/cli/dispatch-owner.ts +30 -1
- package/src/cli/main.ts +2 -1
- package/src/cli/runtime/open.ts +14 -7
- package/src/cli/runtime/registry.ts +6 -1
- package/src/cli/selection.ts +11 -0
- package/src/cli/usage.ts +13 -2
- package/src/connectivity/auth/strategy/index.ts +1 -1
- package/src/connectivity/capability.ts +1 -1
- package/src/connectivity/context.ts +1 -1
- package/src/connectivity/index.ts +1 -1
- package/src/connectivity/mail/attachments.ts +42 -73
- package/src/connectivity/mail/content-type.ts +71 -0
- package/src/connectivity/mail/index.ts +1 -1
- package/src/connectivity/manifest/provider.ts +50 -0
- package/src/connectivity/manifest/variables.ts +173 -0
- package/src/connectivity/transports/factory.ts +74 -1
- package/src/connectivity/transports/http/index.ts +8 -2
- package/src/connectivity/transports/http/keys.ts +83 -0
- package/src/connectivity/transports/imap/attachment.ts +53 -10
- package/src/connectivity/transports/imap/capabilities.ts +28 -0
- package/src/connectivity/transports/imap/download.ts +111 -0
- package/src/connectivity/transports/imap/index.ts +13 -0
- package/src/connectivity/transports/imap/operations.ts +1 -0
- package/src/deployments/adapters/s3.ts +1 -1
- package/src/deployments/gcp/provision.ts +1 -1
- package/src/deployments/knowledge.ts +44 -18
- package/src/deployments/prepare.ts +1 -1
- package/src/deployments/serving.ts +1 -1
- package/src/dispatch/dispatch.ts +17 -10
- package/src/dispatch/staging.ts +76 -1
- package/src/profile/knowledge.ts +19 -11
- package/src/profile/schema.ts +2 -2
- package/src/providers/README.md +189 -0
- package/src/providers/airtable/index.ts +10 -0
- package/src/providers/algolia/index.ts +10 -0
- package/src/providers/amplitude/index.ts +10 -0
- package/src/providers/apify/index.ts +10 -0
- package/src/providers/asana/index.ts +10 -0
- package/src/providers/assets/provider.ts +11 -4
- package/src/providers/atlassian/index.ts +19 -0
- package/src/providers/attio/index.ts +10 -0
- package/src/providers/betterstack/index.ts +10 -0
- package/src/providers/box/index.ts +64 -0
- package/src/providers/brightdata/index.ts +10 -0
- package/src/providers/buildkite/index.ts +10 -0
- package/src/providers/bunq/index.ts +2 -2
- package/src/providers/calendly/index.ts +10 -0
- package/src/providers/canva/index.ts +10 -0
- package/src/providers/circleci/index.ts +10 -0
- package/src/providers/clickup/index.ts +10 -0
- package/src/providers/close/index.ts +10 -0
- package/src/providers/cloudflare_bindings/index.ts +14 -0
- package/src/providers/cloudflare_observability/index.ts +10 -0
- package/src/providers/contentful/index.ts +10 -0
- package/src/providers/custom/load.ts +1 -1
- package/src/providers/datadog/index.ts +10 -0
- package/src/providers/discord/index.ts +1 -1
- package/src/providers/discord/specs/discord.v10.json +0 -88
- package/src/providers/discord/specs/vendor.ts +1 -1
- package/src/providers/dropbox/index.ts +10 -0
- package/src/providers/entities/catalogue.ts +366 -0
- package/src/providers/entities/find.ts +279 -0
- package/src/providers/entities/provider.ts +334 -0
- package/src/providers/entities/render.ts +142 -0
- package/src/providers/entities/store.ts +276 -0
- package/src/providers/entities/writes.ts +279 -0
- package/src/providers/example/provider.ts +1 -1
- package/src/providers/expensify/index.ts +10 -0
- package/src/providers/fastmail/calendar/index.ts +21 -0
- package/src/providers/fastmail/contacts/index.ts +17 -0
- package/src/providers/fastmail/index.ts +4 -0
- package/src/providers/fastmail/mail/index.ts +38 -0
- package/src/providers/fastmail/shared/setup.ts +52 -0
- package/src/providers/figma/index.ts +10 -0
- package/src/providers/fireflies/index.ts +10 -0
- package/src/providers/flagsmith/index.ts +10 -0
- package/src/providers/gamma/index.ts +10 -0
- package/src/providers/github/index.ts +2 -2
- package/src/providers/google/gmail/attachment.ts +16 -1
- package/src/providers/google/gmail-imap/index.ts +7 -2
- package/src/providers/google/shared/oauth.ts +1 -1
- package/src/providers/google/shared/service-account.ts +1 -1
- package/src/providers/google/shared/setup.ts +1 -1
- package/src/providers/google/specs/calendar.v3.json +0 -32
- package/src/providers/google/specs/docs.v1.json +0 -76
- package/src/providers/google/specs/drive.v3.json +0 -32
- package/src/providers/google/specs/gmail.v1.json +0 -76
- package/src/providers/google/specs/people.v1.json +0 -77
- package/src/providers/google/specs/sheets.v4.json +0 -76
- package/src/providers/google/specs/tasks.v1.json +0 -76
- package/src/providers/grafana/index.ts +10 -0
- package/src/providers/heroku/index.ts +10 -0
- package/src/providers/hubspot/index.ts +75 -0
- package/src/providers/hygraph/index.ts +10 -0
- package/src/providers/icloud/mail/index.ts +5 -0
- package/src/providers/icloud/shared/setup.ts +1 -1
- package/src/providers/index.ts +255 -0
- package/src/providers/insightly/index.ts +10 -0
- package/src/providers/jam/index.ts +10 -0
- package/src/providers/klaviyo/index.ts +10 -0
- package/src/providers/mailbox/index.ts +87 -0
- package/src/providers/memory/provider.ts +3 -8
- package/src/providers/mercury/index.ts +10 -0
- package/src/providers/microsoft/calendar/index.ts +49 -0
- package/src/providers/microsoft/contacts/index.ts +50 -0
- package/src/providers/microsoft/drive/index.ts +62 -0
- package/src/providers/microsoft/index.ts +12 -0
- package/src/providers/microsoft/mail/index.ts +76 -0
- package/src/providers/microsoft/shared/oauth.ts +58 -0
- package/src/providers/microsoft/shared/scopes.ts +56 -0
- package/src/providers/microsoft/shared/setup.ts +45 -0
- package/src/providers/microsoft/specs/microsoft-todo.v1.json +755 -0
- package/src/providers/microsoft/specs/onedrive.v1.json +993 -0
- package/src/providers/microsoft/specs/outlook-calendar.v1.json +1743 -0
- package/src/providers/microsoft/specs/outlook-contacts.v1.json +430 -0
- package/src/providers/microsoft/specs/outlook-mail.v1.json +901 -0
- package/src/providers/microsoft/specs/vendor.ts +232 -0
- package/src/providers/microsoft/todo/index.ts +54 -0
- package/src/providers/miro/index.ts +10 -0
- package/src/providers/mixpanel/index.ts +10 -0
- package/src/providers/monday/index.ts +10 -0
- package/src/providers/mux/index.ts +10 -0
- package/src/providers/navan/index.ts +10 -0
- package/src/providers/neon/index.ts +10 -0
- package/src/providers/netlify/index.ts +10 -0
- package/src/providers/nextcloud/calendar/index.ts +28 -0
- package/src/providers/nextcloud/contacts/index.ts +18 -0
- package/src/providers/nextcloud/index.ts +3 -0
- package/src/providers/nextcloud/shared/setup.ts +59 -0
- package/src/providers/owner.ts +15 -3
- package/src/providers/paddle/index.ts +10 -0
- package/src/providers/paypal/index.ts +10 -0
- package/src/providers/posthog/index.ts +10 -0
- package/src/providers/prisma/index.ts +10 -0
- package/src/providers/ramp/index.ts +10 -0
- package/src/providers/recurly/index.ts +10 -0
- package/src/providers/reddit/index.ts +1 -1
- package/src/providers/remote/index.ts +10 -0
- package/src/providers/render/index.ts +35 -0
- package/src/providers/replicate/index.ts +10 -0
- package/src/providers/resend/index.ts +10 -0
- package/src/providers/riverside/index.ts +10 -0
- package/src/providers/rootly/index.ts +10 -0
- package/src/providers/rudderstack/index.ts +10 -0
- package/src/providers/salesloft/index.ts +10 -0
- package/src/providers/sanity/index.ts +10 -0
- package/src/providers/scopes.ts +2 -0
- package/src/providers/sentry/index.ts +10 -0
- package/src/providers/setup/plan.ts +11 -0
- package/src/providers/setup/provider.ts +12 -0
- package/src/providers/shared/openapi.ts +70 -0
- package/src/providers/shared/slug.ts +28 -0
- package/src/providers/shared/vendor-operations.ts +43 -1
- package/src/providers/shared/vendor-spec.ts +98 -17
- package/src/providers/shortcut/index.ts +10 -0
- package/src/providers/slack/index.ts +2 -2
- package/src/providers/square/index.ts +10 -0
- package/src/providers/storyblok/index.ts +10 -0
- package/src/providers/stripe/index.ts +10 -0
- package/src/providers/supabase/index.ts +10 -0
- package/src/providers/tasks/store.ts +2 -7
- package/src/providers/tavily/index.ts +10 -0
- package/src/providers/todoist/index.ts +10 -0
- package/src/providers/untested.ts +105 -0
- package/src/providers/vault/provider.ts +1 -1
- package/src/providers/vercel/index.ts +10 -0
- package/src/providers/vimeo/index.ts +10 -0
- package/src/providers/webflow/index.ts +10 -0
- package/src/providers/whimsical/index.ts +10 -0
- package/src/providers/wix/index.ts +10 -0
- package/src/providers/workable/index.ts +10 -0
- package/src/providers/yahoo_mail/index.ts +73 -0
- package/src/providers/zapier/index.ts +10 -0
- package/src/providers/zoho_mail/index.ts +79 -0
- package/src/secrets/document.ts +1 -1
- package/src/server/attachments.ts +129 -5
- package/src/server/index.ts +3 -3
- package/src/server/mcp/instructions.ts +88 -18
- 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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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.
|
|
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:
|
|
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/
|
|
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(
|
|
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'}
|
|
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
|
|
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 {
|
|
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:
|
|
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
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
120
|
-
|
|
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<
|
|
156
|
+
): Promise<Record<KnowledgeArea, number>> {
|
|
139
157
|
const { entries } = await repository.entries();
|
|
140
|
-
const
|
|
141
|
-
|
|
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
|
|
149
|
-
|
|
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
|
-
|
|
155
|
-
|
|
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
|
|
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
|
|
167
|
-
const
|
|
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
|
|
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
|
*/
|