@lanes-sh/link 0.3.1 → 0.4.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 (70) hide show
  1. package/README.md +11 -3
  2. package/instructions/agents/lanes-link-scout.md +2 -2
  3. package/instructions/skills/lanes-link/SKILL.md +135 -11
  4. package/package.json +3 -1
  5. package/src/cli/argv.ts +52 -0
  6. package/src/cli/commands/connect/authorise.ts +5 -0
  7. package/src/cli/commands/connect/custom/ask.ts +167 -0
  8. package/src/cli/commands/connect/custom/credential.ts +143 -0
  9. package/src/cli/commands/connect/custom/derive.ts +229 -0
  10. package/src/cli/commands/connect/custom/index.ts +285 -0
  11. package/src/cli/commands/connect/custom/prompts.ts +160 -0
  12. package/src/cli/commands/connect/custom/spec.ts +293 -0
  13. package/src/cli/commands/connect/custom/values.ts +53 -0
  14. package/src/cli/commands/connect/custom/write.ts +166 -0
  15. package/src/cli/commands/connect/grant.ts +27 -0
  16. package/src/cli/commands/connect/index.ts +24 -27
  17. package/src/cli/commands/connect/outcome.ts +3 -1
  18. package/src/cli/commands/connect/requirements.ts +11 -1
  19. package/src/cli/commands/connect/settle.ts +17 -0
  20. package/src/cli/commands/connect/setup.ts +9 -1
  21. package/src/cli/commands/connect/strategy.ts +87 -0
  22. package/src/cli/commands/connect/unknown.ts +41 -0
  23. package/src/cli/identity.ts +29 -5
  24. package/src/cli/main.ts +37 -13
  25. package/src/cli/oauth.ts +89 -36
  26. package/src/cli/runtime/open.ts +13 -1
  27. package/src/cli/runtime/registry.ts +12 -0
  28. package/src/cli/selection.ts +12 -0
  29. package/src/cli/usage.ts +9 -1
  30. package/src/connectivity/auth/README.md +8 -1
  31. package/src/connectivity/auth/strategy/index.ts +128 -4
  32. package/src/connectivity/connector.ts +11 -0
  33. package/src/connectivity/index.ts +11 -1
  34. package/src/connectivity/manifest/auth.ts +19 -0
  35. package/src/connectivity/manifest/connector.ts +21 -0
  36. package/src/connectivity/manifest/primitives.ts +5 -1
  37. package/src/connectivity/manifest/provider.ts +30 -12
  38. package/src/connectivity/provider.ts +55 -0
  39. package/src/connectivity/transports/factory.ts +1 -0
  40. package/src/connectivity/transports/http/index.ts +73 -2
  41. package/src/deployments/sync-apply.ts +62 -8
  42. package/src/deployments/sync.ts +34 -6
  43. package/src/dispatch/dispatch.ts +44 -5
  44. package/src/providers/bunq/hints.ts +43 -0
  45. package/src/providers/bunq/index.ts +87 -0
  46. package/src/providers/bunq/redact.ts +64 -0
  47. package/src/providers/bunq/specs/bunq.v1.json +864 -0
  48. package/src/providers/bunq/specs/vendor.ts +338 -0
  49. package/src/providers/bunq/strategy/handshake.ts +211 -0
  50. package/src/providers/bunq/strategy/index.ts +298 -0
  51. package/src/providers/bunq/strategy/keys.ts +72 -0
  52. package/src/providers/custom/index.ts +1 -6
  53. package/src/providers/custom/load.ts +56 -14
  54. package/src/providers/custom/template.ts +1 -1
  55. package/src/providers/discord/hints.ts +195 -0
  56. package/src/providers/discord/index.ts +121 -0
  57. package/src/providers/discord/redact.ts +99 -0
  58. package/src/providers/discord/specs/discord.v10.json +2333 -0
  59. package/src/providers/discord/specs/vendor.ts +164 -0
  60. package/src/providers/google/specs/vendor.ts +32 -317
  61. package/src/providers/index.ts +9 -0
  62. package/src/providers/reddit/index.ts +113 -0
  63. package/src/providers/reddit/oauth.ts +77 -0
  64. package/src/providers/reddit/redact.ts +33 -0
  65. package/src/providers/reddit/scopes.ts +27 -0
  66. package/src/providers/reddit/specs/reddit.v1.json +700 -0
  67. package/src/providers/scopes.ts +2 -0
  68. package/src/providers/shared/openapi.ts +155 -0
  69. package/src/providers/shared/vendor-operations.ts +98 -0
  70. package/src/providers/shared/vendor-spec.ts +309 -0
@@ -1,10 +1,134 @@
1
+ import type { AuthStrategy, AuthStrategyContext } from '../../connector.ts';
2
+ import type { ProviderContext } from '../../context.ts';
3
+ import type { ProviderDefinition } from '../../provider.ts';
4
+ import type { ProviderManifest } from '../../manifest/index.ts';
5
+
1
6
  /**
2
7
  * The escape hatch: auth no declarative form should try to express.
3
8
  *
4
- * bunq generates an RSA keypair, runs a three-step handshake, signs every
5
- * request, and verifies the response signature. That earns code. Nothing is
6
- * registered yet, so this fails loudly rather than sending an unauthenticated
7
- * request that would fail confusingly upstream.
9
+ * A strategy is the one place per-vendor code is permitted outside `local`
10
+ * providers, and it stays *auth* a request in, an authorised request out.
11
+ * The moment one starts translating endpoints, the problem ADR-008 removed has
12
+ * come back.
13
+ *
14
+ * There is no global registry here, and that is deliberate. A strategy is not a
15
+ * plugin the runtime discovers; it is a property of the provider that needs it,
16
+ * so it travels on that provider's `ProviderDefinition` the same way an authored
17
+ * capability does. Two things follow, both of them the point:
18
+ *
19
+ * - this component stays free of vendor names, which is the rule
20
+ * `architecture.test.ts` holds over everything a request passes through
21
+ * - adding one is still a folder under `providers/` and a line in its index,
22
+ * rather than a folder here plus a registration somewhere else
23
+ */
24
+
25
+ /**
26
+ * What `strategyFor` needs from the registry, and no more.
27
+ *
28
+ * Structural rather than the real `ProviderRegistry`, so this file states its
29
+ * dependency as two methods instead of importing a component to name a type.
30
+ */
31
+ export interface StrategySource {
32
+ get(id: string): { readonly definition?: ProviderDefinition | undefined } | undefined;
33
+ list(): readonly { readonly definition?: ProviderDefinition | undefined }[];
34
+ }
35
+
36
+ /**
37
+ * The strategy a manifest declares.
38
+ *
39
+ * Its own definition first, which is the built-in case: `providers/bunq`
40
+ * carries the code for the manifest beside it, and the two must agree about
41
+ * which strategy that is. A manifest naming `strategy: acme` while its
42
+ * definition carries something else is a wiring mistake that would otherwise
43
+ * surface as a signature the vendor rejects — a long way from its cause.
44
+ *
45
+ * Then any other registered provider that supplies one by that name, which is
46
+ * the case a declaration-only manifest needs. A workspace YAML in
47
+ * `providers.d/` is parsed into a `ProviderManifest` and has no definition at
48
+ * all, so without this it could name a strategy and never reach it — and naming
49
+ * one is exactly what a manifest is for. It is also the only way to point a
50
+ * connection at a vendor's sandbox, since a built-in manifest's `options` are
51
+ * not the operator's to edit.
52
+ *
53
+ * Borrowing the *code* is not borrowing the *credential*: the ref still derives
54
+ * from the manifest's own id, so `bunq_sandbox` reads `bunq_sandbox/<id>` and
55
+ * cannot reach what `bunq` holds.
56
+ */
57
+ export function strategyFor(
58
+ manifest: ProviderManifest,
59
+ source: StrategySource,
60
+ ): AuthStrategy {
61
+ if (manifest.auth.kind !== 'strategy') {
62
+ throw new Error(`Provider "${manifest.id}" does not declare a strategy.`);
63
+ }
64
+
65
+ const named = manifest.auth.strategy;
66
+ const own = source.get(manifest.id)?.definition?.authStrategy;
67
+
68
+ if (own) {
69
+ if (own.id !== named) {
70
+ throw new Error(
71
+ `Provider "${manifest.id}" declares auth strategy "${named}" but carries "${own.id}".`,
72
+ );
73
+ }
74
+ return own;
75
+ }
76
+
77
+ const borrowed = source
78
+ .list()
79
+ .map((entry) => entry.definition?.authStrategy)
80
+ .find((strategy) => strategy?.id === named);
81
+
82
+ if (!borrowed) refuseStrategy(named);
83
+ return borrowed;
84
+ }
85
+
86
+ /**
87
+ * What a strategy is given, derived from what the provider is given.
88
+ *
89
+ * Narrower than a `ProviderContext` on purpose: the connection's own
90
+ * credentials read-only, its own scoped state, and its logger. Nothing else,
91
+ * and in particular no storage, no audit logger, and no signal — a strategy
92
+ * authenticates a request and has no business doing anything a provider does.
93
+ *
94
+ * `write` is the one part that varies, and the variation *is* the rule.
95
+ * Persisting a credential is a setup-time act, so `connect` passes a writer and
96
+ * the dispatch path does not. Per-request code then cannot store a credential
97
+ * because there is nothing on the object to store one with, rather than because
98
+ * somebody remembered not to.
99
+ *
100
+ * Takes the three fields it reads rather than a whole `ProviderContext`, so the
101
+ * two callers can be what they are: dispatch hands over the context it already
102
+ * built, and `connect` — which has no provider context, because the connection
103
+ * does not exist yet — assembles the same three from the runtime.
104
+ */
105
+ export function strategyContextFrom(input: {
106
+ /** Dispatch hands over the `ProviderContext` it built; `connect` assembles the three itself. */
107
+ readonly source: Pick<ProviderContext, 'credentials' | 'state' | 'log'>;
108
+ readonly manifest: ProviderManifest;
109
+ readonly connectionId: string;
110
+ readonly profile: string;
111
+ readonly write?: ((ref: string, value: string) => Promise<void>) | undefined;
112
+ }): AuthStrategyContext {
113
+ const { source, manifest, connectionId, profile, write } = input;
114
+
115
+ return {
116
+ manifest,
117
+ connectionId,
118
+ profile,
119
+ credentials: source.credentials,
120
+ state: source.state,
121
+ log: source.log,
122
+ options: manifest.auth.kind === 'strategy' ? (manifest.auth.options ?? {}) : {},
123
+ ...(write ? { write } : {}),
124
+ };
125
+ }
126
+
127
+ /**
128
+ * A manifest asked for a strategy nothing supplies.
129
+ *
130
+ * Fails loudly rather than sending an unauthenticated request, which would fail
131
+ * upstream with an error about the vendor rather than about the wiring.
8
132
  */
9
133
  export function refuseStrategy(strategy: string): never {
10
134
  throw new Error(
@@ -159,6 +159,17 @@ export interface AuthStrategy {
159
159
  export interface AuthStrategyContext {
160
160
  readonly manifest: ProviderManifest;
161
161
  readonly connectionId: string;
162
+ /**
163
+ * Which profile this is acting for.
164
+ *
165
+ * Nothing about authenticating one request needs it. What needs it is any
166
+ * strategy that keeps something in process memory: one endpoint serves every
167
+ * profile in the workspace from one process, so `<provider>.<connection>` is
168
+ * not a unique key — two profiles each holding a bunq connection called
169
+ * `main` would share whatever it named. `state` and `credentials` are already
170
+ * scoped per profile and a cache in front of them must be too.
171
+ */
172
+ readonly profile: string;
162
173
  /** Read-only, scoped to this connection — the same boundary providers get. */
163
174
  readonly credentials: ProviderContext['credentials'];
164
175
  /**
@@ -48,7 +48,17 @@ export {
48
48
  * Everything else is a manifest.
49
49
  */
50
50
  export type { AuthRequirement, ProviderDefinition } from './provider.ts';
51
- export { defineLocalProvider, defineProviderWithCapabilities } from './provider.ts';
51
+ export {
52
+ defineLocalProvider,
53
+ defineProviderWithCapabilities,
54
+ defineProviderWithStrategy,
55
+ } from './provider.ts';
56
+
57
+ /**
58
+ * The strategy seam. Vendor-free by construction: these three know that a
59
+ * strategy exists and never which one.
60
+ */
61
+ export { refuseStrategy, strategyContextFrom, strategyFor } from './auth/strategy/index.ts';
52
62
 
53
63
  export type {
54
64
  ProviderManifest,
@@ -164,6 +164,25 @@ export const authOAuthSchema = z.object({
164
164
  */
165
165
  authorize_url: z.url().optional(),
166
166
  token_url: z.url().optional(),
167
+ /**
168
+ * A loopback redirect to name to the vendor verbatim, for one that matches it
169
+ * exactly rather than by prefix.
170
+ *
171
+ * `connect` normally listens on a port the kernel picks, which works because
172
+ * most authorization servers accept any loopback port. One that pins the
173
+ * whole URL cannot: the port it was told in a console months ago is not the
174
+ * port this run happens to get, and the grant is refused with
175
+ * `redirect_uri_mismatch`.
176
+ *
177
+ * The full URL rather than a port, because the vendor's console decides the
178
+ * spelling — `localhost` and `127.0.0.1` are different strings to a matcher
179
+ * even when they are the same host — and the two must be identical.
180
+ *
181
+ * Mutually exclusive with `broker`, which answers the same question the other
182
+ * way: there the redirect is the broker's HTTPS origin and the loopback port
183
+ * travels in `state`.
184
+ */
185
+ redirect_uri: z.url().optional(),
167
186
  /**
168
187
  * Extra parameters on the authorization request.
169
188
  *
@@ -56,6 +56,27 @@ export const httpConnectorSchema = z.object({
56
56
  exclude: z.array(z.string()).default([]),
57
57
  })
58
58
  .optional(),
59
+ /**
60
+ * Sent on every request this connector makes.
61
+ *
62
+ * The same field as `mcp`'s above and refused the same way for
63
+ * `Authorization` — but for the opposite reason. An mcp server chooses its
64
+ * own tool list and a header is the only way to ask it for less; a REST API
65
+ * asks for nothing, and this is for what the *vendor* requires of a client
66
+ * rather than what a caller wants from it.
67
+ *
68
+ * The case in hand is a `User-Agent`. Nothing else here sets one, so every
69
+ * install of this program looks identical to a host that rate-limits by
70
+ * client — and a service that asks callers to identify themselves throttles
71
+ * the default agent hardest, which reads as an outage rather than a refusal.
72
+ *
73
+ * Precedence is narrowest-wins: `accept` is a default this may override, a
74
+ * header parameter the operation itself declares overrides this, and
75
+ * `content-type` is derived from the document and overrides everything —
76
+ * a blanket header cannot silently change how one operation's body is
77
+ * encoded.
78
+ */
79
+ headers: z.record(z.string(), z.string()).optional(),
59
80
  });
60
81
 
61
82
  /**
@@ -16,5 +16,9 @@ export const credentialRef = z
16
16
  .string()
17
17
  .regex(
18
18
  /^[a-z0-9][a-z0-9_-]*(?:\/[a-z0-9][a-z0-9_-]*)+$/,
19
- 'must be a credential reference like "bunq/api_key", not a literal value',
19
+ // A placeholder rather than a real provider id. The example teaches the
20
+ // shape either way, and this file is inside the scope
21
+ // `architecture.test.ts` keeps free of vendor names — an error message that
22
+ // names one is exactly the "message that assumes a vendor" the rule is for.
23
+ 'must be a credential reference like "acme/api_key", not a literal value',
20
24
  );
@@ -225,6 +225,36 @@ export function defineProvider(input: unknown): ProviderManifest {
225
225
  );
226
226
  }
227
227
 
228
+ // Two answers to "where does the browser come back to", and a manifest
229
+ // naming both leaves it to whichever the flow reads first. A broker's
230
+ // redirect is its own HTTPS origin, with the loopback port carried in
231
+ // `state`; a fixed redirect is this machine, named exactly. Neither is wrong,
232
+ // but they cannot both be in force.
233
+ if (manifest.auth.kind === 'oauth' && manifest.auth.broker && manifest.auth.redirect_uri) {
234
+ throw new Error(
235
+ `Provider "${manifest.id}": auth may declare "broker" or "redirect_uri", not both — a brokered flow redirects to the broker and carries the loopback port in state, so a fixed redirect would never be used.`,
236
+ );
237
+ }
238
+
239
+ // Connector headers are for what the *server* offers as configuration; the
240
+ // credential is the auth block's, and a manifest setting both would have one
241
+ // quietly overwrite the other depending on which the transport merged last.
242
+ //
243
+ // Checked for every connector that has the field rather than for `mcp` alone.
244
+ // It was written when `mcp` was the only one, and the reasoning never had
245
+ // anything to do with which transport carried the header — an `http`
246
+ // connector naming `Authorization` collides with `auth` in exactly the same
247
+ // way, and would have validated cleanly.
248
+ const connectorHeaders =
249
+ 'headers' in manifest.connector ? (manifest.connector.headers ?? {}) : {};
250
+ for (const name of Object.keys(connectorHeaders)) {
251
+ if (name.toLowerCase() === 'authorization') {
252
+ throw new Error(
253
+ `Provider "${manifest.id}": connector.headers may not set "${name}" — the credential comes from auth, and setting both would leave which one is sent up to merge order.`,
254
+ );
255
+ }
256
+ }
257
+
228
258
  if (manifest.connector.kind === 'mcp') {
229
259
  const auth = manifest.auth;
230
260
 
@@ -250,18 +280,6 @@ export function defineProvider(input: unknown): ProviderManifest {
250
280
  `Provider "${manifest.id}": an mcp connector always sends its token as "Authorization: Bearer", so auth.header ("${auth.header}") cannot be honoured. Remove it, or reach this service with an http connector.`,
251
281
  );
252
282
  }
253
-
254
- // The third spelling of the same collision. Connector headers are for what
255
- // the *server* offers as configuration; the credential is the auth block's,
256
- // and a manifest setting both would have one quietly overwrite the other
257
- // depending on which the transport merged last.
258
- for (const name of Object.keys(manifest.connector.headers ?? {})) {
259
- if (name.toLowerCase() === 'authorization') {
260
- throw new Error(
261
- `Provider "${manifest.id}": connector.headers may not set "${name}" — the credential comes from auth, and setting both would leave which one is sent up to merge order.`,
262
- );
263
- }
264
- }
265
283
  }
266
284
 
267
285
  const names = new Set<string>();
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import type { SecretRef } from '#secrets';
3
3
  import type { Capability } from './capability.ts';
4
+ import type { AuthStrategy } from './connector.ts';
4
5
  import type { ProviderContext } from './context.ts';
5
6
  import { bundleSchema, defineProvider, type ProviderManifest } from './manifest/index.ts';
6
7
 
@@ -32,6 +33,16 @@ export interface ProviderDefinition<
32
33
 
33
34
  readonly capabilities: readonly Capability[];
34
35
 
36
+ /**
37
+ * Pluggable auth, for a vendor whose handshake no manifest field can describe.
38
+ *
39
+ * Carried here rather than in a registry the runtime searches, because a
40
+ * strategy belongs to exactly one provider and nothing else may use it. The
41
+ * manifest declares *that* there is one and names it; this is the code, and
42
+ * `strategyFor` checks the two agree.
43
+ */
44
+ readonly authStrategy?: AuthStrategy;
45
+
35
46
  /**
36
47
  * Which credential refs a connection may read. Core turns this into the
37
48
  * allowlist behind `ProviderContext.credentials`, so a provider cannot reach
@@ -161,3 +172,47 @@ export function defineProviderWithCapabilities(input: {
161
172
  capabilities: input.capabilities,
162
173
  };
163
174
  }
175
+
176
+ /**
177
+ * A manifest-based provider whose authentication is code rather than a field.
178
+ *
179
+ * The sibling of `defineProviderWithCapabilities`, and rarer. That one exists
180
+ * where a vendor's API can do something its *document* cannot express; this one
181
+ * where a vendor's **handshake** can. Everything else about the provider stays
182
+ * declared — the connector kind, the operations, the redaction — and the
183
+ * strategy only ever sees a request on its way out and a response on its way
184
+ * back.
185
+ *
186
+ * ADR-008 puts a number on how much this is allowed to be: roughly 150 lines,
187
+ * for auth, once. A strategy that grows a second job is the 612-line problem
188
+ * returning by a different door.
189
+ */
190
+ export function defineProviderWithStrategy(input: {
191
+ readonly manifest: ProviderManifest;
192
+ readonly strategy: AuthStrategy;
193
+ readonly capabilities?: readonly Capability[];
194
+ }): ProviderDefinition {
195
+ const { manifest, strategy } = input;
196
+
197
+ if (manifest.auth.kind !== 'strategy') {
198
+ throw new Error(
199
+ `Provider "${manifest.id}" carries an auth strategy but declares auth.kind "${manifest.auth.kind}". Declare { kind: 'strategy', strategy: '${strategy.id}' }.`,
200
+ );
201
+ }
202
+
203
+ if (manifest.auth.strategy !== strategy.id) {
204
+ throw new Error(
205
+ `Provider "${manifest.id}" declares auth strategy "${manifest.auth.strategy}" but carries "${strategy.id}".`,
206
+ );
207
+ }
208
+
209
+ return {
210
+ manifest,
211
+ // Permissive for the same reason `defineProviderWithCapabilities` is: a
212
+ // manifest provider's connection is already described by its manifest.
213
+ configSchema: z.unknown(),
214
+ connectionSchema: z.unknown(),
215
+ capabilities: input.capabilities ?? [],
216
+ authStrategy: strategy,
217
+ };
218
+ }
@@ -180,6 +180,7 @@ function build(
180
180
  ...(manifest.connector.operations?.exclude?.length
181
181
  ? { exclude: manifest.connector.operations.exclude }
182
182
  : {}),
183
+ ...(manifest.connector.headers ? { headers: manifest.connector.headers } : {}),
183
184
  });
184
185
  }
185
186
  }
@@ -26,9 +26,70 @@ export interface HttpConnectorOptions {
26
26
  readonly openapi: string;
27
27
  readonly include?: readonly string[];
28
28
  readonly exclude?: readonly string[];
29
+ /** Sent on every request. Never `Authorization` — the manifest refuses it. */
30
+ readonly headers?: Readonly<Record<string, string>>;
29
31
  readonly fetch?: typeof globalThis.fetch;
30
32
  }
31
33
 
34
+ /**
35
+ * The one body encoding that is not JSON often enough to be worth knowing about.
36
+ *
37
+ * A form-encoded write is not a legacy curiosity: it is what a large share of
38
+ * APIs that predate JSON request bodies still require, and one of them refusing
39
+ * `application/json` is not a negotiation — the request fails outright, with an
40
+ * error about the parameters rather than about the encoding.
41
+ */
42
+ const FORM_ENCODED = 'application/x-www-form-urlencoded';
43
+
44
+ /**
45
+ * What the *document* says the body is, rather than what we would prefer.
46
+ *
47
+ * `mcp-from-openapi` records the declared media type on each body entry of the
48
+ * mapper, so this needs nothing threaded through `target` — the mapper is
49
+ * already cached there, which is what lets a cold instance encode correctly
50
+ * without re-reading the spec.
51
+ *
52
+ * JSON is the default because an operation with no declared request body has no
53
+ * media type to read, and because it is what this connector always sent.
54
+ */
55
+ function bodyContentType(mapper: readonly ParameterMapper[]): string {
56
+ for (const entry of mapper) {
57
+ if (entry.type !== 'body') continue;
58
+ const declared = entry.serialization?.contentType;
59
+ if (declared) return declared;
60
+ }
61
+
62
+ return 'application/json';
63
+ }
64
+
65
+ /**
66
+ * Arrays repeat rather than join, for the reason the query string does above.
67
+ *
68
+ * A nested object is JSON inside the field, which is the only thing a
69
+ * form-encoded body can do with one — there is no standard spelling of nesting
70
+ * here, and every API that accepts one accepts it as a string.
71
+ */
72
+ function encodeBody(body: Readonly<Record<string, unknown>>, contentType: string): string {
73
+ if (!contentType.startsWith(FORM_ENCODED)) return JSON.stringify(body);
74
+
75
+ const form = new URLSearchParams();
76
+
77
+ for (const [key, value] of Object.entries(body)) {
78
+ if (value === undefined || value === null) continue;
79
+
80
+ if (Array.isArray(value)) {
81
+ for (const element of value) {
82
+ if (element !== undefined && element !== null) form.append(key, String(element));
83
+ }
84
+ continue;
85
+ }
86
+
87
+ form.set(key, typeof value === 'object' ? JSON.stringify(value) : String(value));
88
+ }
89
+
90
+ return form.toString();
91
+ }
92
+
32
93
  /**
33
94
  * `*` matches any run of characters; anything else is literal.
34
95
  *
@@ -148,6 +209,12 @@ export function createHttpConnector(options: HttpConnectorOptions): Connector {
148
209
 
149
210
  const url = new URL(options.baseUrl.replace(/\/$/, '') + buildPath(template, args, mapper));
150
211
  const headers = new Headers({ accept: 'application/json' });
212
+
213
+ // After `accept`, which is a default worth overriding, and before the
214
+ // operation's own header parameters, which are narrower than a header
215
+ // declared once for the whole connector.
216
+ for (const [key, value] of Object.entries(options.headers ?? {})) headers.set(key, value);
217
+
151
218
  const body = collect(args, mapper, 'body');
152
219
 
153
220
  for (const [key, value] of Object.entries(collect(args, mapper, 'query'))) {
@@ -174,7 +241,11 @@ export function createHttpConnector(options: HttpConnectorOptions): Connector {
174
241
  }
175
242
 
176
243
  const hasBody = Object.keys(body).length > 0;
177
- if (hasBody) headers.set('content-type', 'application/json');
244
+ const contentType = bodyContentType(mapper);
245
+ // Last, so it beats a connector-wide header. Which encoding an operation
246
+ // uses is the document's to state, and a blanket `content-type` that
247
+ // silently changed it would be a bug nobody could see from the manifest.
248
+ if (hasBody) headers.set('content-type', contentType);
178
249
 
179
250
  // Auth is attached by core, from the manifest's auth kind or its strategy.
180
251
  // A connector never sees a raw credential.
@@ -182,7 +253,7 @@ export function createHttpConnector(options: HttpConnectorOptions): Connector {
182
253
  new Request(url.href, {
183
254
  method,
184
255
  headers,
185
- ...(hasBody ? { body: JSON.stringify(body) } : {}),
256
+ ...(hasBody ? { body: encodeBody(body, contentType) } : {}),
186
257
  signal: context.provider.signal,
187
258
  }),
188
259
  );
@@ -8,7 +8,7 @@ import {
8
8
  type Config,
9
9
  } from '#profile';
10
10
  import { ConfigDocument } from '#cli/config-edit.ts';
11
- import { diffConfigs, keyedArrayFor, type Change } from './sync.ts';
11
+ import { diffConfigs, keyOfElement, keyedArrayFor, type Change } from './sync.ts';
12
12
  import { isWorkspaceConfig } from './upload.ts';
13
13
 
14
14
  /**
@@ -155,28 +155,82 @@ export async function applyPulls(
155
155
  const remote = await readRawProfile(remoteRoot, profile);
156
156
  if (remote === undefined) return 0;
157
157
 
158
+ const local = document.toJSON();
158
159
  const written = new Set<string>();
160
+
159
161
  for (const change of pulls) {
160
162
  const array = keyedArrayFor(change.path);
161
- const path = array ?? change.path;
162
163
 
163
- // One write per array, however many of its elements were missing.
164
- const key = path.join('.');
165
- if (written.has(key)) continue;
166
- written.add(key);
164
+ if (array) {
165
+ // One write per array, however many of its elements were missing — and
166
+ // the value written is the *merge*, never the remote array.
167
+ //
168
+ // It used to be `setIn(array, remoteArray)`, which reads as "pull the
169
+ // connections" and means "replace the connections". A profile that had
170
+ // gained six accounts locally since the last deploy lost all six to a
171
+ // command whose entire purpose is not losing things. The tests missed it
172
+ // because every one of them had a local array that was a subset of the
173
+ // remote — the case where replacing and merging agree.
174
+ const key = array.join('.');
175
+ if (written.has(key)) continue;
176
+ written.add(key);
177
+
178
+ const merged = mergeKeyed(
179
+ array,
180
+ valueAt(local, array),
181
+ valueAt(remote, array),
182
+ pulls
183
+ .filter((one) => keyedArrayFor(one.path)?.join('.') === key)
184
+ .map((one) => one.path[array.length])
185
+ .filter((name): name is string => name !== undefined),
186
+ );
187
+ if (merged !== undefined) document.setIn([...array], merged);
188
+ continue;
189
+ }
167
190
 
168
- const value = valueAt(remote, path);
191
+ const value = valueAt(remote, change.path);
169
192
  // Absent from the raw document means the remote side only has it as a
170
193
  // default, which the local side fills in identically. Nothing to write.
171
194
  if (value === undefined) continue;
172
195
 
173
- document.setIn([...path], value);
196
+ document.setIn([...change.path], value);
174
197
  }
175
198
 
176
199
  await document.save();
177
200
  return written.size;
178
201
  }
179
202
 
203
+ /**
204
+ * The local array, with the named elements taken from the remote one.
205
+ *
206
+ * Element order is local's, with anything new appended, so a pull reads as a
207
+ * diff in the file rather than as a reshuffle. An element named in `wanted` and
208
+ * absent from the remote array is skipped rather than removed: the diff said it
209
+ * was missing locally, so it cannot also be missing remotely.
210
+ */
211
+ function mergeKeyed(
212
+ arrayPath: readonly string[],
213
+ local: unknown,
214
+ remote: unknown,
215
+ wanted: readonly string[],
216
+ ): unknown[] | undefined {
217
+ if (!Array.isArray(remote)) return undefined;
218
+
219
+ const merged = Array.isArray(local) ? [...(local as unknown[])] : [];
220
+ const keyOf = (item: unknown): string | undefined => keyOfElement(arrayPath, item);
221
+
222
+ for (const name of new Set(wanted)) {
223
+ const incoming = remote.find((item) => keyOf(item) === name);
224
+ if (incoming === undefined) continue;
225
+
226
+ const at = merged.findIndex((item) => keyOf(item) === name);
227
+ if (at >= 0) merged[at] = incoming;
228
+ else merged.push(incoming);
229
+ }
230
+
231
+ return merged;
232
+ }
233
+
180
234
  /** Read a path out of the raw document, for handing to `setIn`. */
181
235
  function valueAt(config: unknown, path: readonly string[]): unknown {
182
236
  let value: unknown = config;
@@ -41,12 +41,23 @@ export interface Change {
41
41
  * first entry of a kind is the one to reach for — so it is an ordered list and
42
42
  * compares as one.
43
43
  */
44
- const KEYED: Record<string, (item: Record<string, unknown>) => string> = {
45
- connections: (item) => `${String(item['provider'])}.${String(item['id'])}`,
46
- 'policy.allow': (item) => String(item['capability']),
47
- 'policy.deny': (item) => String(item['capability']),
44
+ const KEYED: Record<string, (item: unknown) => string | undefined> = {
45
+ connections: (item) =>
46
+ isRecord(item) ? `${String(item['provider'])}.${String(item['id'])}` : undefined,
47
+ // Two shapes, and both are reached. The diff runs over validated configs,
48
+ // where `allow: [gmail.*]` has become `[{capability: gmail.*}]`; the writer
49
+ // runs over the raw document, where it is still a string. A key function that
50
+ // knew only the validated shape found nothing to merge and silently wrote the
51
+ // array without it.
52
+ 'policy.allow': capabilityOf,
53
+ 'policy.deny': capabilityOf,
48
54
  };
49
55
 
56
+ function capabilityOf(item: unknown): string | undefined {
57
+ if (typeof item === 'string') return item;
58
+ return isRecord(item) ? String(item['capability']) : undefined;
59
+ }
60
+
50
61
  const isRecord = (value: unknown): value is Record<string, unknown> =>
51
62
  typeof value === 'object' && value !== null && !Array.isArray(value);
52
63
 
@@ -102,12 +113,16 @@ function walk(path: readonly string[], local: unknown, remote: unknown): Change[
102
113
  */
103
114
  function walkKeyed(
104
115
  path: readonly string[],
105
- keyOf: (item: Record<string, unknown>) => string,
116
+ keyOf: (item: unknown) => string | undefined,
106
117
  local: readonly unknown[],
107
118
  remote: readonly unknown[],
108
119
  ): Change[] {
109
120
  const index = (items: readonly unknown[]): Map<string, unknown> =>
110
- new Map(items.filter(isRecord).map((item) => [keyOf(item), item]));
121
+ new Map(
122
+ items
123
+ .map((item) => [keyOf(item), item] as const)
124
+ .filter((pair): pair is readonly [string, unknown] => pair[0] !== undefined),
125
+ );
111
126
 
112
127
  const here = index(local);
113
128
  const there = index(remote);
@@ -130,6 +145,19 @@ export function keyedArrayFor(path: readonly string[]): readonly string[] | unde
130
145
  return undefined;
131
146
  }
132
147
 
148
+ /**
149
+ * How an element of a keyed array identifies itself, for the writer.
150
+ *
151
+ * The diff indexes these to compare them; applying one has to find the same
152
+ * element again in *both* raw documents, and it cannot re-derive the key from
153
+ * the path — `connections.gmail.work` is one key containing a dot, not two
154
+ * steps. Exported so the two halves cannot disagree about what identifies a
155
+ * connection.
156
+ */
157
+ export function keyOfElement(arrayPath: readonly string[], item: unknown): string | undefined {
158
+ return KEYED[arrayPath.join('.')]?.(item);
159
+ }
160
+
133
161
  /** Whether a set of changes can be applied without being told which side wins. */
134
162
  export function conflictsIn(changes: readonly Change[]): Change[] {
135
163
  return changes.filter((change) => change.direction === 'conflict');