@lanes-sh/link 0.5.3 → 0.5.4
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/package.json +1 -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/connection.ts +18 -7
- package/src/cli/commands/operate/status.ts +11 -1
- package/src/cli/dashboard-page.ts +10 -1
- package/src/cli/main.ts +2 -0
- package/src/cli/runtime/open.ts +1 -0
- package/src/cli/selection.ts +1 -0
- package/src/cli/usage.ts +3 -0
- package/src/profile/schema.ts +9 -0
- package/src/providers/setup/provider.ts +10 -2
- package/src/server/dashboard.ts +1 -0
package/package.json
CHANGED
|
@@ -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
|
+
}
|
|
@@ -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);
|
|
@@ -46,6 +46,9 @@ export async function status(flags: StatusFlags): Promise<void> {
|
|
|
46
46
|
provider: connection.provider,
|
|
47
47
|
id: connection.id,
|
|
48
48
|
account: connection.account,
|
|
49
|
+
// Null rather than absent: a caller reading this to prefill a rename box
|
|
50
|
+
// needs to tell "called nothing in particular" from a field it forgot.
|
|
51
|
+
label: connection.label ?? null,
|
|
49
52
|
state: byKey.get(key)?.status ?? 'not reconciled',
|
|
50
53
|
};
|
|
51
54
|
});
|
|
@@ -102,7 +105,14 @@ export async function status(flags: StatusFlags): Promise<void> {
|
|
|
102
105
|
connection.state === 'active'
|
|
103
106
|
? style.green(connection.state)
|
|
104
107
|
: style.yellow(connection.state),
|
|
105
|
-
|
|
108
|
+
// Both, where they differ. The label is what the operator called
|
|
109
|
+
// it and the account is which mailbox it is; a row answering only
|
|
110
|
+
// one of those is the row this column already was.
|
|
111
|
+
style.dim(
|
|
112
|
+
connection.label && connection.label !== connection.account
|
|
113
|
+
? `${connection.label} — ${connection.account}`
|
|
114
|
+
: connection.account,
|
|
115
|
+
),
|
|
106
116
|
]),
|
|
107
117
|
);
|
|
108
118
|
}
|
|
@@ -30,6 +30,8 @@ export interface DashboardConnection {
|
|
|
30
30
|
readonly key: string;
|
|
31
31
|
readonly provider: string;
|
|
32
32
|
readonly account: string;
|
|
33
|
+
/** What the operator calls it, where that is not just the account. */
|
|
34
|
+
readonly label?: string | undefined;
|
|
33
35
|
/** `active`, `unauthorized`, `disabled`, or `not reconciled`. */
|
|
34
36
|
readonly state: string;
|
|
35
37
|
}
|
|
@@ -155,7 +157,14 @@ function connectionsSection(view: DashboardView): string {
|
|
|
155
157
|
mark(connection.provider) +
|
|
156
158
|
`<code class="key">${escapeHtml(connection.key)}</code>` +
|
|
157
159
|
statusPill(connection.state) +
|
|
158
|
-
|
|
160
|
+
// The label reads, the account hovers: the row is one line wide and the
|
|
161
|
+
// address is the longer of the two, so putting both in it wraps every
|
|
162
|
+
// row to make one of them legible.
|
|
163
|
+
`<span class="account"${
|
|
164
|
+
connection.label && connection.label !== connection.account
|
|
165
|
+
? ` title="${escapeHtml(connection.account)}"`
|
|
166
|
+
: ''
|
|
167
|
+
}>${escapeHtml(connection.label ?? connection.account)}</span>` +
|
|
159
168
|
repair +
|
|
160
169
|
'</div>'
|
|
161
170
|
);
|
package/src/cli/main.ts
CHANGED
|
@@ -88,6 +88,7 @@ export async function run(argv: readonly string[]): Promise<void> {
|
|
|
88
88
|
...customFlags(flags, argv),
|
|
89
89
|
id: text(flags, 'id'),
|
|
90
90
|
displayName: text(flags, 'display-name'),
|
|
91
|
+
label: text(flags, 'label'),
|
|
91
92
|
replace: flags['replace'] === true,
|
|
92
93
|
nonInteractive: flags['non-interactive'] === true,
|
|
93
94
|
acceptBroadScopes: flags['accept-broad-scopes'] === true,
|
|
@@ -104,6 +105,7 @@ export async function run(argv: readonly string[]): Promise<void> {
|
|
|
104
105
|
...global,
|
|
105
106
|
id: text(flags, 'id'),
|
|
106
107
|
displayName: text(flags, 'display-name'),
|
|
108
|
+
label: text(flags, 'label'),
|
|
107
109
|
replace: flags['replace'] === true,
|
|
108
110
|
nonInteractive: flags['non-interactive'] === true,
|
|
109
111
|
acceptBroadScopes: flags['accept-broad-scopes'] === true,
|
package/src/cli/runtime/open.ts
CHANGED
|
@@ -240,6 +240,7 @@ export async function openRuntime(
|
|
|
240
240
|
key: `${connection.provider}.${connection.id}`,
|
|
241
241
|
provider: connection.provider,
|
|
242
242
|
account: connection.account,
|
|
243
|
+
...(connection.label ? { label: connection.label } : {}),
|
|
243
244
|
}));
|
|
244
245
|
|
|
245
246
|
// Read before the registry is built, so a store that cannot be reached is a
|
package/src/cli/selection.ts
CHANGED
package/src/cli/usage.ts
CHANGED
|
@@ -154,6 +154,9 @@ ${style.bold('Other flags')}
|
|
|
154
154
|
--json machine-readable output, where a command offers it
|
|
155
155
|
--non-interactive never prompt: connect refuses with what to store,
|
|
156
156
|
deploy takes the answers its config already holds
|
|
157
|
+
--label <text> what to call a connection, instead of being asked at the
|
|
158
|
+
end of connect. A display name only: nothing addresses
|
|
159
|
+
a connection by it. Use relabel to change one later
|
|
157
160
|
--accept-broad-scopes agree in advance to scopes broader than a provider needs
|
|
158
161
|
--own-client register your own OAuth client instead of using the
|
|
159
162
|
one this project operates (connect only)
|
package/src/profile/schema.ts
CHANGED
|
@@ -303,11 +303,20 @@ export const policySchema = z.object({
|
|
|
303
303
|
* is *whose mailbox is this*. `id` is the stable key that `credential_ref` and
|
|
304
304
|
* the agent's `connection` argument point at, and it is derived from `account`
|
|
305
305
|
* so it means something too.
|
|
306
|
+
*
|
|
307
|
+
* `label` is the operator's own word for the same row, and it exists because
|
|
308
|
+
* `account` cannot be. Three things read `account` as an identity — the
|
|
309
|
+
* reconnect match in `settleIdentity`, the id derived from it, and the `From`
|
|
310
|
+
* header `gmail.send_message` writes — so a `relabel` that wrote "Work mail"
|
|
311
|
+
* there stopped the next `connect` recognising the account it had renamed.
|
|
312
|
+
* Nothing addresses a connection by its label; it is only ever displayed.
|
|
306
313
|
*/
|
|
307
314
|
export const connectionSchema = z.object({
|
|
308
315
|
id: z.string().regex(/^[a-z0-9][a-z0-9_]*$/, 'must be lowercase alphanumeric with underscores'),
|
|
309
316
|
provider: identifier,
|
|
310
317
|
account: z.string().min(1),
|
|
318
|
+
/** Absent means the row is shown as its account, which is the usual case. */
|
|
319
|
+
label: z.string().min(1).optional(),
|
|
311
320
|
credential_ref: credentialRef.optional(),
|
|
312
321
|
/** Provider-specific, validated later against that provider's own schema. */
|
|
313
322
|
config: z.record(z.string(), z.unknown()).optional(),
|
|
@@ -185,7 +185,7 @@ export function createSetupProvider(options: SetupProviderOptions): ProviderDefi
|
|
|
185
185
|
|
|
186
186
|
function renderOverview(
|
|
187
187
|
options: SetupProviderOptions,
|
|
188
|
-
connections: ReadonlyArray<{ key: string; account: string }>,
|
|
188
|
+
connections: ReadonlyArray<{ key: string; account: string; label?: string | undefined }>,
|
|
189
189
|
plans: readonly ProviderPlan[],
|
|
190
190
|
self: string,
|
|
191
191
|
): string {
|
|
@@ -196,7 +196,15 @@ function renderOverview(
|
|
|
196
196
|
} else {
|
|
197
197
|
lines.push('Connected and reachable:');
|
|
198
198
|
for (const connection of connections) {
|
|
199
|
-
|
|
199
|
+
// Account first, label in brackets. The other way round for a person
|
|
200
|
+
// reading `status`, and deliberately not here: an agent choosing which
|
|
201
|
+
// connection to call needs the identity, and "Work mail" is not one.
|
|
202
|
+
lines.push(
|
|
203
|
+
` ${connection.key} — ${connection.account}` +
|
|
204
|
+
(connection.label && connection.label !== connection.account
|
|
205
|
+
? ` (${connection.label})`
|
|
206
|
+
: ''),
|
|
207
|
+
);
|
|
200
208
|
}
|
|
201
209
|
}
|
|
202
210
|
|
package/src/server/dashboard.ts
CHANGED
|
@@ -179,6 +179,7 @@ export async function handleDashboard(
|
|
|
179
179
|
key: `${connection.provider}.${connection.id}`,
|
|
180
180
|
provider: connection.provider,
|
|
181
181
|
account: connection.account,
|
|
182
|
+
...(connection.label ? { label: connection.label } : {}),
|
|
182
183
|
state: byKey.get(`${connection.provider}.${connection.id}`)?.status ?? 'not reconciled',
|
|
183
184
|
}));
|
|
184
185
|
|