@lanes-sh/link 0.5.3 → 0.6.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/instructions/agents/lanes-link-scout.md +1 -1
- package/instructions/skills/lanes-link/SKILL.md +33 -29
- package/package.json +2 -1
- package/src/cli/commands/connect/custom/index.ts +2 -0
- package/src/cli/commands/connect/custom/spec.ts +1 -0
- package/src/cli/commands/connect/declare.ts +76 -0
- package/src/cli/commands/connect/family.ts +19 -3
- package/src/cli/commands/connect/index.ts +23 -24
- package/src/cli/commands/connect/outcome.ts +8 -0
- package/src/cli/commands/connect/settle.ts +94 -22
- package/src/cli/commands/connect/target-note.ts +2 -2
- package/src/cli/commands/connection.ts +18 -7
- package/src/cli/commands/knowledge/index.ts +14 -34
- package/src/cli/commands/mcp/register.ts +1 -1
- package/src/cli/commands/operate/dashboard.ts +2 -2
- package/src/cli/commands/operate/inspect.ts +5 -1
- package/src/cli/commands/operate/migrate.ts +85 -1
- package/src/cli/commands/operate/outputs.ts +7 -22
- package/src/cli/commands/operate/status.ts +95 -70
- package/src/cli/commands/operate/tools.ts +1 -1
- package/src/cli/commands/profile/removal.ts +36 -25
- package/src/cli/commands/profile/remove.ts +5 -2
- package/src/cli/commands/profile.ts +56 -40
- package/src/cli/commands/sync.ts +94 -162
- package/src/cli/commands/target.ts +115 -74
- package/src/cli/commands/update.ts +56 -1
- package/src/cli/config-edit.ts +47 -16
- package/src/cli/dashboard-page.ts +10 -1
- package/src/cli/endpoint-url.ts +3 -3
- package/src/cli/main.ts +11 -7
- package/src/cli/publish.ts +4 -2
- package/src/cli/runtime/open.ts +20 -10
- package/src/cli/runtime/select.ts +69 -22
- package/src/cli/selection-require.ts +79 -0
- package/src/cli/selection.ts +45 -92
- package/src/cli/usage.ts +3 -0
- package/src/cli/workspace-migrate.ts +385 -0
- package/src/deployments/bootstrap.ts +31 -11
- package/src/deployments/deploy.ts +54 -27
- package/src/deployments/knowledge.ts +5 -2
- package/src/deployments/prepare.ts +3 -1
- package/src/deployments/serving.ts +19 -15
- package/src/deployments/upload.ts +10 -1
- package/src/profile/deployments.ts +64 -53
- package/src/profile/index.ts +22 -6
- package/src/profile/legacy.ts +92 -0
- package/src/profile/load.ts +12 -28
- package/src/profile/registry.ts +182 -0
- package/src/profile/schema.ts +171 -110
- package/src/profile/targets.ts +62 -91
- package/src/profile/testing.ts +78 -0
- package/src/profile/workspace.ts +11 -25
- package/src/providers/setup/provider.ts +10 -2
- package/src/server/dashboard.ts +5 -1
- package/src/server/harness.ts +1 -6
- package/src/cli/commands/profile/declare.ts +0 -154
- package/src/deployments/servable.ts +0 -82
- package/src/deployments/sync-apply.ts +0 -330
- package/src/deployments/sync.ts +0 -164
|
@@ -68,7 +68,7 @@ stops a write is policy on the endpoint:
|
|
|
68
68
|
|
|
69
69
|
```console
|
|
70
70
|
$ lanes link policy deny memory.write --profile <name> --target <name>
|
|
71
|
-
$ lanes link policy list --profile <name>
|
|
71
|
+
$ lanes link policy list --profile <name> --target <name>
|
|
72
72
|
```
|
|
73
73
|
|
|
74
74
|
If you are running against a profile that grants writes, that is the owner's
|
|
@@ -22,7 +22,9 @@ and personal apart; they share no database and no credential store.
|
|
|
22
22
|
**Ask which profile is meant when it is ambiguous. Do not default to whichever
|
|
23
23
|
is listed first.** Quietly picking one crosses the line the profile exists to
|
|
24
24
|
draw. There is no "current profile" to switch — the choice is made per call, and
|
|
25
|
-
`lanes link profile list
|
|
25
|
+
`lanes link profile list --target <name>` shows what exists *in that target*. A
|
|
26
|
+
profile lives in exactly one, so `personal` on `local` and `personal` on `cloud`
|
|
27
|
+
are two profiles that share a name rather than one profile in two places.
|
|
26
28
|
|
|
27
29
|
**What a command must be told is never inferred — but it is not always both.**
|
|
28
30
|
Nothing resolves from an environment variable or a config default, so a command
|
|
@@ -30,12 +32,11 @@ missing what it needs refuses rather than acting somewhere else. Passing a flag
|
|
|
30
32
|
command does not read is refused too, which makes "add both to be safe" its own
|
|
31
33
|
failure. Four levels:
|
|
32
34
|
|
|
33
|
-
- **Neither.** `lanes link
|
|
35
|
+
- **Neither.** `lanes link target list`, `lanes link mcp list`,
|
|
34
36
|
`lanes link version`.
|
|
35
|
-
- **`--
|
|
36
|
-
`lanes link
|
|
37
|
-
|
|
38
|
-
YAML that applies wherever the profile runs.
|
|
37
|
+
- **`--target` alone.** `lanes link profile list`, `lanes link profile add`,
|
|
38
|
+
`lanes link profile remove`, `lanes link target show`. A profile lives inside
|
|
39
|
+
one target's workspace, so listing or creating one names which workspace.
|
|
39
40
|
- **`--target`, with the profiles derived from it.** `lanes link status`,
|
|
40
41
|
`lanes link deploy` and `lanes link sync targets` act on one endpoint serving
|
|
41
42
|
every profile that declares that target. `--profile` is accepted and *narrows*
|
|
@@ -222,7 +223,7 @@ granted, and retrying will not reveal it. A call that *is* refused was refused b
|
|
|
222
223
|
policy on purpose.
|
|
223
224
|
|
|
224
225
|
Report it plainly and let the owner decide whether to widen the grant —
|
|
225
|
-
`lanes link policy list --profile <name>` shows the rules, `lanes link policy allow <capability> --profile <name> --target <name>`
|
|
226
|
+
`lanes link policy list --profile <name> --target <name>` shows the rules, `lanes link policy allow <capability> --profile <name> --target <name>`
|
|
226
227
|
changes them, and that is their call, not yours. **Do not look for another route
|
|
227
228
|
to the same data.** Every call is audited either way; `lanes link audit tail --profile <name> --target <name>`
|
|
228
229
|
shows what was attempted, refusals included.
|
|
@@ -299,10 +300,12 @@ key on — do not treat the absence of JSON as a failure. `status`, `doctor`,
|
|
|
299
300
|
`config show`, `audit tail` and every `mcp` subcommand do not.
|
|
300
301
|
|
|
301
302
|
**A profile is created and removed, never switched.** `lanes link profile add
|
|
302
|
-
<name>` writes a new one
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
the profile
|
|
303
|
+
<name> --target <name>` writes a new one *into that target's workspace* — one
|
|
304
|
+
target, not a list, because a profile lives in exactly one.
|
|
305
|
+
`lanes link profile remove <name> --target <name>` takes `--dry-run` and then
|
|
306
|
+
`--yes`, and removes the profile itself along with its stores: the file is in
|
|
307
|
+
that workspace, so there is nowhere left for it to survive. Neither reads
|
|
308
|
+
`--profile`; both name the profile positionally.
|
|
306
309
|
|
|
307
310
|
There is no current profile and no default target. `lanes link profile default`
|
|
308
311
|
and `lanes link target use` are gone and now refuse with an explanation — if you
|
|
@@ -312,9 +315,9 @@ replacement to find. The choice is made per command, on purpose.
|
|
|
312
315
|
## Deploying, and what it decides
|
|
313
316
|
|
|
314
317
|
`lanes link deploy` builds an image and rolls a revision. Its subject is a
|
|
315
|
-
**target**, and the profiles behind it are every profile
|
|
316
|
-
so one deploy serves all of them — there is no per-profile deploy to
|
|
317
|
-
reason to loop over them.
|
|
318
|
+
**target**, and the profiles behind it are every profile *in* that target's
|
|
319
|
+
workspace, so one deploy serves all of them — there is no per-profile deploy to
|
|
320
|
+
run and no reason to loop over them.
|
|
318
321
|
|
|
319
322
|
**Always `--dry-run` first, and show what it printed.** It creates cloud
|
|
320
323
|
resources that cost money, it implements no `--json` to inspect instead, and that
|
|
@@ -326,9 +329,9 @@ Two things it refuses to guess, and both are the owner's to answer:
|
|
|
326
329
|
- **Whose bearer token opens the endpoint.** One token reaches every profile
|
|
327
330
|
behind that target, so this decides who gets in. With several candidates and
|
|
328
331
|
nothing recorded, it refuses and prints the command that names one.
|
|
329
|
-
- **A first deploy.** A target
|
|
330
|
-
so `--profile` is required there. It may be repeated, and the first
|
|
331
|
-
is the primary.
|
|
332
|
+
- **A first deploy.** A target that does not exist yet has no workspace to derive
|
|
333
|
+
a set from, so `--profile` is required there. It may be repeated, and the first
|
|
334
|
+
one named is the primary.
|
|
332
335
|
|
|
333
336
|
**Never pass `--yes`, `--non-interactive`, `--access public` or
|
|
334
337
|
`--service-account` yourself.** Each settles a question about who can reach their
|
|
@@ -338,22 +341,23 @@ this file already applies to `--accept-broad-scopes`.
|
|
|
338
341
|
Deploying is how new code reaches the endpoint. It is not how an account gets
|
|
339
342
|
connected and not how a config change lands; both of those publish themselves.
|
|
340
343
|
|
|
341
|
-
## When
|
|
344
|
+
## When a machine has lost track of a deployment
|
|
342
345
|
|
|
343
|
-
A
|
|
344
|
-
machine, a reinstall, a
|
|
345
|
-
is still serving; what went missing is the
|
|
346
|
-
is a `lanes link status` that reports nothing
|
|
347
|
-
up.
|
|
346
|
+
A workspace holds a *pointer* to each target it does not itself hold, and a
|
|
347
|
+
machine can lose one — a new laptop, a reinstall, a workspace file restored from
|
|
348
|
+
something older. The endpoint is still serving; what went missing is the line
|
|
349
|
+
saying where it lives. The symptom is a `lanes link status` that reports nothing
|
|
350
|
+
for a target you know is up.
|
|
348
351
|
|
|
349
|
-
`lanes link sync targets`
|
|
350
|
-
|
|
351
|
-
`--dry-run` reports what it would
|
|
352
|
+
`lanes link sync targets` adopts it. `--discover` looks for a deployment the
|
|
353
|
+
workspace has no pointer to, `--from <location>` names one directly, and
|
|
354
|
+
`--dry-run` reports what it would write without writing it. Run the dry run and
|
|
352
355
|
show it.
|
|
353
356
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
+
**It cannot lose anything.** Adopting writes one line — the pointer — and the
|
|
358
|
+
workspace at the other end stays authoritative for everything else. There is no
|
|
359
|
+
`--prefer` any more and passing one is refused: it chose a winner when a profile
|
|
360
|
+
existed in two copies that could disagree, and there is one copy now.
|
|
357
361
|
|
|
358
362
|
## Registering it, and re-registering it
|
|
359
363
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lanes-sh/link",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "A self-hostable MCP gateway for all your connections, memory, tasks, files, and secrets",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://lanes.sh/link",
|
|
@@ -66,6 +66,7 @@
|
|
|
66
66
|
"#dispatch": "./src/dispatch/index.ts",
|
|
67
67
|
"#policy": "./src/policy/index.ts",
|
|
68
68
|
"#profile": "./src/profile/index.ts",
|
|
69
|
+
"#profile/*": "./src/profile/*",
|
|
69
70
|
"#providers/*": "./src/providers/*",
|
|
70
71
|
"#registry": "./src/registry/index.ts",
|
|
71
72
|
"#secrets": "./src/secrets/index.ts",
|
|
@@ -49,6 +49,7 @@ export interface ConnectCustomOptions extends GlobalFlags, CustomFlags {
|
|
|
49
49
|
/** Forwarded to `connect` untouched. */
|
|
50
50
|
readonly id?: string | undefined;
|
|
51
51
|
readonly displayName?: string | undefined;
|
|
52
|
+
readonly label?: string | undefined;
|
|
52
53
|
readonly replace?: boolean | undefined;
|
|
53
54
|
readonly nonInteractive?: boolean | undefined;
|
|
54
55
|
readonly acceptBroadScopes?: boolean | undefined;
|
|
@@ -170,6 +171,7 @@ export async function connectCustom(
|
|
|
170
171
|
quiet: options.quiet ?? false,
|
|
171
172
|
...(options.id ? { id: options.id } : {}),
|
|
172
173
|
...(options.displayName ? { displayName: options.displayName } : {}),
|
|
174
|
+
...(options.label ? { label: options.label } : {}),
|
|
173
175
|
...(options.replace ? { replace: options.replace } : {}),
|
|
174
176
|
...(options.nonInteractive ? { nonInteractive: options.nonInteractive } : {}),
|
|
175
177
|
...(options.acceptBroadScopes ? { acceptBroadScopes: options.acceptBroadScopes } : {}),
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { ConnectionConfig } from '#profile';
|
|
2
|
+
import type { ConfigDocument } from '../../config-edit.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Writing the connection row — the one edit `connect` makes to the profile.
|
|
6
|
+
*
|
|
7
|
+
* Its own file because it answers its own question: given an account, an id and
|
|
8
|
+
* a name, which lines of YAML change? The five numbered steps around it are
|
|
9
|
+
* about vendors, browsers and credential stores, and none of that reaches here.
|
|
10
|
+
*
|
|
11
|
+
* Two fields, and the difference between them is the whole subject. `account` is
|
|
12
|
+
* an identity the provider reported, and three things read it as one — the
|
|
13
|
+
* reconnect match in `settleIdentity`, the id derived from it, and the `From`
|
|
14
|
+
* header `gmail.send_message` writes. `label` is what the operator calls the
|
|
15
|
+
* same row, addressed by nothing and displayed everywhere.
|
|
16
|
+
*/
|
|
17
|
+
export function declareConnection(input: {
|
|
18
|
+
readonly document: ConfigDocument;
|
|
19
|
+
/** The profile as it stands, which says whether this is an add or a repair. */
|
|
20
|
+
readonly connections: readonly ConnectionConfig[];
|
|
21
|
+
readonly providerId: string;
|
|
22
|
+
readonly connectionId: string;
|
|
23
|
+
readonly account: string;
|
|
24
|
+
readonly label: string;
|
|
25
|
+
/** Which route in, where the provider offered a choice. */
|
|
26
|
+
readonly method: string | undefined;
|
|
27
|
+
}): readonly string[] {
|
|
28
|
+
const { document, connections, providerId, connectionId, account, label, method } = input;
|
|
29
|
+
|
|
30
|
+
const key = `${providerId}.${connectionId}`;
|
|
31
|
+
const index = connections.findIndex((c) => `${c.provider}.${c.id}` === key);
|
|
32
|
+
const changes: string[] = [];
|
|
33
|
+
|
|
34
|
+
if (index === -1) {
|
|
35
|
+
// No `credential_ref`: it derives to `<provider>/<id>`, which is exactly
|
|
36
|
+
// where the OAuth provider already looks. Writing it would add a line per
|
|
37
|
+
// connection that can only ever agree or be a bug.
|
|
38
|
+
//
|
|
39
|
+
// No `label` either, when it is the account. Pressing Enter at the prompt is
|
|
40
|
+
// the common answer, and a line repeating the address above it is a line to
|
|
41
|
+
// read past forever.
|
|
42
|
+
document.addTo(['connections'], {
|
|
43
|
+
id: connectionId,
|
|
44
|
+
provider: providerId,
|
|
45
|
+
account,
|
|
46
|
+
...(label === account ? {} : { label }),
|
|
47
|
+
});
|
|
48
|
+
changes.push(`connections += ${key} (${account})`);
|
|
49
|
+
return changes;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// A reconnect. The credential was just replaced; the declaration stays as it
|
|
53
|
+
// is, so re-running connect after an expiry is a no-op on the file rather than
|
|
54
|
+
// a second row.
|
|
55
|
+
const declared = connections[index];
|
|
56
|
+
|
|
57
|
+
if (declared?.account !== account) {
|
|
58
|
+
document.setIn(['connections', index, 'account'], account);
|
|
59
|
+
changes.push(`connections.${key}.account = ${account}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Compared against what the row is *called*, which is the account until
|
|
63
|
+
// somebody names it otherwise. Without the fallback, every reconnect of an
|
|
64
|
+
// unlabelled connection writes a label that says what the line above it says.
|
|
65
|
+
if ((declared?.label ?? declared?.account) !== label) {
|
|
66
|
+
document.setIn(['connections', index, 'label'], label);
|
|
67
|
+
changes.push(`connections.${key}.label = ${label}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Named where the provider offered a choice, because this is the line an
|
|
71
|
+
// operator reads to see that a re-connect swapped the route rather than
|
|
72
|
+
// refreshed it — and `--auth` reaches here having asked nothing. Unnamed for a
|
|
73
|
+
// provider with one way in, whose output is unchanged.
|
|
74
|
+
changes.push(`re-authorised ${key}${method ? ` with ${method}` : ''}`);
|
|
75
|
+
return changes;
|
|
76
|
+
}
|
|
@@ -48,8 +48,13 @@ export function familyMembers(registry: ProviderRegistry, name: string): readonl
|
|
|
48
48
|
* the target belongs to the account, not to each service under it, so it is
|
|
49
49
|
* printed once here and suppressed in every member. Three copies of it is three
|
|
50
50
|
* times nothing new.
|
|
51
|
+
*
|
|
52
|
+
* The label travels on the same argument: it too belongs to the account, so the
|
|
53
|
+
* first member asks what to call it and the rest are told.
|
|
51
54
|
*/
|
|
52
|
-
export async function connectFamily<
|
|
55
|
+
export async function connectFamily<
|
|
56
|
+
Options extends { readonly id?: string | undefined; readonly label?: string | undefined },
|
|
57
|
+
>(input: {
|
|
53
58
|
readonly name: string;
|
|
54
59
|
readonly members: readonly string[];
|
|
55
60
|
readonly options: Options;
|
|
@@ -64,9 +69,20 @@ export async function connectFamily<Options extends { readonly id?: string | und
|
|
|
64
69
|
|
|
65
70
|
progress(style.dim(familyNote(name, members)));
|
|
66
71
|
|
|
67
|
-
|
|
72
|
+
let inherited = { ...options, id: options.id ?? namedId };
|
|
68
73
|
const outcomes: ConnectOutcome[] = [];
|
|
69
|
-
|
|
74
|
+
|
|
75
|
+
for (const member of members) {
|
|
76
|
+
const outcome = await connect(member, inherited, true);
|
|
77
|
+
outcomes.push(outcome);
|
|
78
|
+
|
|
79
|
+
// One account, one name. Without this, connecting iCloud asks what to call
|
|
80
|
+
// it three times — and three different answers is three rows that read as
|
|
81
|
+
// three accounts.
|
|
82
|
+
if (inherited.label === undefined && outcome.label !== undefined) {
|
|
83
|
+
inherited = { ...inherited, label: outcome.label };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
70
86
|
|
|
71
87
|
return familyOutcome(outcomes);
|
|
72
88
|
}
|
|
@@ -6,6 +6,7 @@ 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 { declareConnection } from './declare.ts';
|
|
9
10
|
import { discoverCapabilities } from './discover.ts';
|
|
10
11
|
import { connectFamily, familyMembers } from './family.ts';
|
|
11
12
|
import { authoriseWithKey } from './assertion.ts';
|
|
@@ -38,6 +39,14 @@ import { unknownProvider } from './unknown.ts';
|
|
|
38
39
|
export interface ConnectOptions extends GlobalFlags {
|
|
39
40
|
readonly id?: string | undefined;
|
|
40
41
|
readonly displayName?: string | undefined;
|
|
42
|
+
/**
|
|
43
|
+
* `--label`: what to call this connection, instead of being asked.
|
|
44
|
+
*
|
|
45
|
+
* Distinct from `--display-name`, which answers *whose account this is* for a
|
|
46
|
+
* provider that cannot report it. This one never touches the identity, so it
|
|
47
|
+
* is safe to pass anything a person would say out loud.
|
|
48
|
+
*/
|
|
49
|
+
readonly label?: string | undefined;
|
|
41
50
|
/** Ask for the stored credential again — a key rotated, or a password revoked. */
|
|
42
51
|
readonly replace?: boolean | undefined;
|
|
43
52
|
/**
|
|
@@ -272,11 +281,12 @@ export async function runConnect(
|
|
|
272
281
|
// adding a new one. Without it, a retried connect appends a second row
|
|
273
282
|
// rather than repairing the first — which is how `main2` and `main3`
|
|
274
283
|
// ended up in a config describing two mailboxes.
|
|
275
|
-
const { connectionId, account } = await settleIdentity({
|
|
284
|
+
const { connectionId, account, label } = await settleIdentity({
|
|
276
285
|
manifest,
|
|
277
286
|
provisionalId,
|
|
278
287
|
explicitId: named,
|
|
279
288
|
account: options.displayName,
|
|
289
|
+
label: options.label,
|
|
280
290
|
runtime,
|
|
281
291
|
prompter,
|
|
282
292
|
});
|
|
@@ -306,31 +316,18 @@ export async function runConnect(
|
|
|
306
316
|
});
|
|
307
317
|
|
|
308
318
|
// 4. Declare the connection, or update the one this account already has.
|
|
309
|
-
|
|
310
|
-
(
|
|
319
|
+
changes.push(
|
|
320
|
+
...declareConnection({
|
|
321
|
+
document,
|
|
322
|
+
connections: runtime.config.connections,
|
|
323
|
+
providerId,
|
|
324
|
+
connectionId,
|
|
325
|
+
account,
|
|
326
|
+
label,
|
|
327
|
+
method: method.id,
|
|
328
|
+
}),
|
|
311
329
|
);
|
|
312
330
|
|
|
313
|
-
if (existingIndex === -1) {
|
|
314
|
-
// No `credential_ref`: it derives to `<provider>/<id>`, which is exactly
|
|
315
|
-
// where the OAuth provider already looks. Writing it would add a line per
|
|
316
|
-
// connection that can only ever agree or be a bug.
|
|
317
|
-
document.addTo(['connections'], { id: connectionId, provider: providerId, account });
|
|
318
|
-
changes.push(`connections += ${connectionKey} (${account})`);
|
|
319
|
-
} else {
|
|
320
|
-
// A reconnect. The credential was just replaced above; the declaration
|
|
321
|
-
// stays as it is, so re-running connect after an expiry is a no-op on the
|
|
322
|
-
// file rather than a second row.
|
|
323
|
-
if (runtime.config.connections[existingIndex]?.account !== account) {
|
|
324
|
-
document.setIn(['connections', existingIndex, 'account'], account);
|
|
325
|
-
changes.push(`connections.${connectionKey}.account = ${account}`);
|
|
326
|
-
}
|
|
327
|
-
// Named where the provider offered a choice, because this is the line an
|
|
328
|
-
// operator reads to see that a re-connect swapped the route rather than
|
|
329
|
-
// refreshed it — and `--auth` reaches here having asked nothing. Unnamed
|
|
330
|
-
// for a provider with one way in, whose output is unchanged.
|
|
331
|
-
changes.push(`re-authorised ${connectionKey}${method.id ? ` with ${method.id}` : ''}`);
|
|
332
|
-
}
|
|
333
|
-
|
|
334
331
|
// 5. Grant it — one rule per provider; `grant.ts` says why not per capability.
|
|
335
332
|
const granted = grantProvider(document, runtime.config.policy.allow, providerId);
|
|
336
333
|
|
|
@@ -365,6 +362,7 @@ export async function runConnect(
|
|
|
365
362
|
ok: true,
|
|
366
363
|
key: connectionKey,
|
|
367
364
|
account,
|
|
365
|
+
label,
|
|
368
366
|
...where(runtime),
|
|
369
367
|
discovered: discovered.length,
|
|
370
368
|
next: ALREADY,
|
|
@@ -381,6 +379,7 @@ export async function runConnect(
|
|
|
381
379
|
ok: true,
|
|
382
380
|
key: connectionKey,
|
|
383
381
|
account,
|
|
382
|
+
label,
|
|
384
383
|
...where(runtime),
|
|
385
384
|
changes,
|
|
386
385
|
granted,
|
|
@@ -17,6 +17,14 @@ export interface ConnectOutcome {
|
|
|
17
17
|
readonly ok: boolean;
|
|
18
18
|
readonly key?: string;
|
|
19
19
|
readonly account?: string;
|
|
20
|
+
/**
|
|
21
|
+
* What the connection is called, where that is not just the account.
|
|
22
|
+
*
|
|
23
|
+
* Reported so the family path can carry it: `connect icloud` is three runs on
|
|
24
|
+
* one account, and the name belongs to the account rather than to each
|
|
25
|
+
* service under it.
|
|
26
|
+
*/
|
|
27
|
+
readonly label?: string;
|
|
20
28
|
/** The profile written to, for a caller that cannot see the announce line. */
|
|
21
29
|
readonly profile?: string;
|
|
22
30
|
/** The target written to — which credential store now holds this account. */
|
|
@@ -5,7 +5,7 @@ import type { Config } from '#profile';
|
|
|
5
5
|
import type { AnyConnector, ProviderManifest } from '#connectivity';
|
|
6
6
|
import { idFromAccount, resolveAccount } from '../../identity.ts';
|
|
7
7
|
import { style } from '../../output.ts';
|
|
8
|
-
import { terminalPrompter, type Prompter } from '../../prompt.ts';
|
|
8
|
+
import { PromptCancelled, terminalPrompter, type Prompter } from '../../prompt.ts';
|
|
9
9
|
import { accountSiblings } from './accounts.ts';
|
|
10
10
|
|
|
11
11
|
/**
|
|
@@ -16,12 +16,26 @@ import { accountSiblings } from './accounts.ts';
|
|
|
16
16
|
* connection already holds that account, we reuse *its* id, which is what turns
|
|
17
17
|
* a re-run of `connect` into a repair rather than a duplicate. Only when
|
|
18
18
|
* identity cannot be resolved at all do we ask.
|
|
19
|
+
*
|
|
20
|
+
* The *label* is settled last and separately, because it is the one thing here
|
|
21
|
+
* the provider cannot answer. `account` is load-bearing three times over — the
|
|
22
|
+
* reconnect match above, the id derived from it, and the `From` header
|
|
23
|
+
* `gmail.send_message` writes — so the operator's own words for a connection
|
|
24
|
+
* cannot be put there, and `label` is where they go instead.
|
|
19
25
|
*/
|
|
20
26
|
export async function settleIdentity(input: {
|
|
21
27
|
manifest: ProviderManifest;
|
|
22
28
|
provisionalId: string;
|
|
23
29
|
explicitId: string | undefined;
|
|
24
30
|
account: string | undefined;
|
|
31
|
+
/**
|
|
32
|
+
* The label, where the caller already has one.
|
|
33
|
+
*
|
|
34
|
+
* `--label`, and the family path: `connect icloud` is three `connect` runs on
|
|
35
|
+
* one account, and asking what to call it once per service is asking the same
|
|
36
|
+
* question three times.
|
|
37
|
+
*/
|
|
38
|
+
label?: string | undefined;
|
|
25
39
|
runtime: {
|
|
26
40
|
config: Config;
|
|
27
41
|
credentials: SecretStore;
|
|
@@ -30,7 +44,7 @@ export async function settleIdentity(input: {
|
|
|
30
44
|
authorizeRequest(providerId: string, connectionId: string, request: Request): Promise<Request>;
|
|
31
45
|
};
|
|
32
46
|
prompter?: Prompter;
|
|
33
|
-
}): Promise<{ connectionId: string; account: string }> {
|
|
47
|
+
}): Promise<{ connectionId: string; account: string; label: string }> {
|
|
34
48
|
const { manifest, provisionalId, explicitId, runtime } = input;
|
|
35
49
|
const prompter = input.prompter ?? terminalPrompter;
|
|
36
50
|
|
|
@@ -112,6 +126,11 @@ export async function settleIdentity(input: {
|
|
|
112
126
|
const unaccounted = !account && manifest.auth.kind === 'none';
|
|
113
127
|
if (unaccounted) account = manifest.name;
|
|
114
128
|
|
|
129
|
+
// Whether the name we hold is one the operator has just typed, rather than one
|
|
130
|
+
// a provider reported. It settles the label below: a name someone chose a
|
|
131
|
+
// second ago does not need confirming against itself.
|
|
132
|
+
let typed = false;
|
|
133
|
+
|
|
115
134
|
if (!account) {
|
|
116
135
|
// Nothing to go on. Asking beats inventing `main2`, and the answer is the
|
|
117
136
|
// one piece of information the file cannot reconstruct later — which is
|
|
@@ -125,32 +144,85 @@ export async function settleIdentity(input: {
|
|
|
125
144
|
}
|
|
126
145
|
|
|
127
146
|
account =
|
|
128
|
-
(await prompter.ask(`Which account is this? ${style.dim('(
|
|
147
|
+
(await prompter.ask(`Which account is this? ${style.dim('(the address or handle)')}`)) ||
|
|
129
148
|
`${manifest.name} ${provisionalId}`;
|
|
149
|
+
typed = true;
|
|
130
150
|
}
|
|
131
151
|
|
|
132
|
-
|
|
152
|
+
const taken = siblings.map((candidate) => candidate.id);
|
|
133
153
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
};
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
const already = siblings.find(
|
|
145
|
-
(candidate) => candidate.account.toLowerCase() === account.toLowerCase(),
|
|
146
|
-
);
|
|
147
|
-
if (already) return { connectionId: already.id, account };
|
|
154
|
+
const connectionId =
|
|
155
|
+
explicitId ??
|
|
156
|
+
(unaccounted
|
|
157
|
+
? idFromAccount('main', taken)
|
|
158
|
+
: (siblings.find(
|
|
159
|
+
(candidate) => candidate.account.toLowerCase() === account!.toLowerCase(),
|
|
160
|
+
)?.id ?? idFromAccount(account, taken)));
|
|
148
161
|
|
|
149
162
|
return {
|
|
150
|
-
connectionId
|
|
151
|
-
account,
|
|
152
|
-
siblings.map((candidate) => candidate.id),
|
|
153
|
-
),
|
|
163
|
+
connectionId,
|
|
154
164
|
account,
|
|
165
|
+
label: await settleLabel({
|
|
166
|
+
given: input.label,
|
|
167
|
+
// What the row this is about to land on is already called. Looked up
|
|
168
|
+
// across the whole vendor account rather than this provider alone, for the
|
|
169
|
+
// reason `accountSiblings` exists: `connect icloud_calendar` adopts iCloud
|
|
170
|
+
// Mail's id, and should adopt the name that goes with it too.
|
|
171
|
+
declared: siblings.find((candidate) => candidate.id === connectionId)?.label,
|
|
172
|
+
account,
|
|
173
|
+
typed,
|
|
174
|
+
prompter,
|
|
175
|
+
}),
|
|
155
176
|
};
|
|
156
177
|
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* What to call this connection, offering what it is already called.
|
|
181
|
+
*
|
|
182
|
+
* Asked on every interactive connect, not only where identity resolution failed.
|
|
183
|
+
* That was the old behaviour and it had the case exactly backwards: the run that
|
|
184
|
+
* could not name the account is the run where the operator has least to add,
|
|
185
|
+
* and the run that resolved `ada@example.com` — where they may well want "Work
|
|
186
|
+
* mail" — never asked at all.
|
|
187
|
+
*
|
|
188
|
+
* The suggestion is in the question and an empty answer takes it, so the cost of
|
|
189
|
+
* always asking is one keystroke. Nothing addresses a connection by its label,
|
|
190
|
+
* so there is no answer here that can break anything.
|
|
191
|
+
*/
|
|
192
|
+
async function settleLabel(input: {
|
|
193
|
+
given: string | undefined;
|
|
194
|
+
declared: string | undefined;
|
|
195
|
+
account: string;
|
|
196
|
+
typed: boolean;
|
|
197
|
+
prompter: Prompter;
|
|
198
|
+
}): Promise<string> {
|
|
199
|
+
const { given, declared, account, typed, prompter } = input;
|
|
200
|
+
|
|
201
|
+
if (given) return given;
|
|
202
|
+
|
|
203
|
+
// A label already chosen wins over the account, so re-authorising an expired
|
|
204
|
+
// credential does not quietly undo the operator's own word for the row.
|
|
205
|
+
const suggestion = declared ?? account;
|
|
206
|
+
|
|
207
|
+
if (typed || !prompter.interactive) return suggestion;
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
return (
|
|
211
|
+
(await prompter.ask(`What should this be called? ${style.dim(`[${suggestion}]`)}`)) ||
|
|
212
|
+
suggestion
|
|
213
|
+
);
|
|
214
|
+
} catch (refusal) {
|
|
215
|
+
// Ctrl-C is an answer: the operator stopped the command, and swallowing it
|
|
216
|
+
// here would finish a connect they interrupted.
|
|
217
|
+
if (refusal instanceof PromptCancelled) throw refusal;
|
|
218
|
+
|
|
219
|
+
// Anything else is `terminalPrompter` discovering there is no terminal —
|
|
220
|
+
// it reports itself interactive and finds out only when asked, so a piped
|
|
221
|
+
// `lanes link connect gmail` reaches this line having already opened a
|
|
222
|
+
// browser and stored a credential. Failing there for want of a display name
|
|
223
|
+
// would undo none of that. A label is worth having and never worth failing
|
|
224
|
+
// a connect over; the account above it is the identity, and that one still
|
|
225
|
+
// refuses rather than inventing a name.
|
|
226
|
+
return suggestion;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Resolution } from '#profile';
|
|
2
2
|
import { announce } from '../../output.ts';
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -21,7 +21,7 @@ import { announce } from '../../output.ts';
|
|
|
21
21
|
* is the same mistake caught where it is still cheap.
|
|
22
22
|
*/
|
|
23
23
|
export function announceConnectTarget(
|
|
24
|
-
runtime: { readonly resolution: Resolution
|
|
24
|
+
runtime: { readonly resolution: Resolution },
|
|
25
25
|
json?: boolean | undefined,
|
|
26
26
|
): void {
|
|
27
27
|
// `emit`'s early return only protects lines printed *at* the emit, and this
|
|
@@ -297,9 +297,18 @@ export async function disconnect(key: string | undefined, flags: DisconnectFlags
|
|
|
297
297
|
})
|
|
298
298
|
}
|
|
299
299
|
|
|
300
|
+
/**
|
|
301
|
+
* Rename a connection, writing `label` and never `account`.
|
|
302
|
+
*
|
|
303
|
+
* It wrote `account` until it was noticed that `account` is not a display name:
|
|
304
|
+
* `settleIdentity` matches on it to tell a repair from a new account,
|
|
305
|
+
* `idFromAccount` derives the id from it, and `gmail.send_message` writes it
|
|
306
|
+
* into a `From` header. Renaming through it therefore un-recognised the account
|
|
307
|
+
* it renamed — the next `connect` added a second row beside it.
|
|
308
|
+
*/
|
|
300
309
|
export async function renameConnection(
|
|
301
310
|
key: string,
|
|
302
|
-
|
|
311
|
+
label: string,
|
|
303
312
|
flags: RelabelFlags,
|
|
304
313
|
): Promise<{ resolution: Resolution; relabelled: Relabelled }> {
|
|
305
314
|
const runtime = await openRuntime(flags);
|
|
@@ -309,7 +318,7 @@ export async function renameConnection(
|
|
|
309
318
|
const located = locate(config, key, resolution.profile);
|
|
310
319
|
const document = await ConfigDocument.open(resolution.workspaceRoot, resolution.profile);
|
|
311
320
|
|
|
312
|
-
document.setIn(['connections', located.index, '
|
|
321
|
+
document.setIn(['connections', located.index, 'label'], label);
|
|
313
322
|
await document.save();
|
|
314
323
|
|
|
315
324
|
return {
|
|
@@ -318,8 +327,10 @@ export async function renameConnection(
|
|
|
318
327
|
profile: resolution.profile,
|
|
319
328
|
target,
|
|
320
329
|
key,
|
|
321
|
-
|
|
322
|
-
|
|
330
|
+
// What it was called a moment ago, which is the account only until the
|
|
331
|
+
// first rename.
|
|
332
|
+
from: located.connection.label ?? located.connection.account,
|
|
333
|
+
to: label,
|
|
323
334
|
published: nextAfterEdit(await publishProfileEdit({ resolution, config, target })),
|
|
324
335
|
},
|
|
325
336
|
};
|
|
@@ -330,13 +341,13 @@ export async function renameConnection(
|
|
|
330
341
|
|
|
331
342
|
export async function relabel(
|
|
332
343
|
key: string | undefined,
|
|
333
|
-
|
|
344
|
+
label: string | undefined,
|
|
334
345
|
flags: RelabelFlags,
|
|
335
346
|
): Promise<void> {
|
|
336
347
|
if (!key) throw new Error('Which connection? Run: lanes link status');
|
|
337
|
-
if (!
|
|
348
|
+
if (!label) throw new Error(`What should ${key} be called? Run: lanes link relabel ${key} "New name"`);
|
|
338
349
|
|
|
339
|
-
const { resolution, relabelled: result } = await renameConnection(key,
|
|
350
|
+
const { resolution, relabelled: result } = await renameConnection(key, label, flags);
|
|
340
351
|
|
|
341
352
|
return emit(flags.json, result, () => {
|
|
342
353
|
announce(resolution);
|