@lanes-sh/link 0.5.2 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lanes-sh/link",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
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",
@@ -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 } : {}),
@@ -227,6 +227,7 @@ export const CONNECT_CUSTOM_FLAGS: readonly string[] = [
227
227
  // and a broker's, and a synthesized manifest never declares a broker.
228
228
  'id',
229
229
  'display-name',
230
+ 'label',
230
231
  'replace',
231
232
  'non-interactive',
232
233
  'accept-broad-scopes',
@@ -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<Options extends { readonly id?: string | undefined }>(input: {
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
- const inherited = { ...options, id: options.id ?? namedId };
72
+ let inherited = { ...options, id: options.id ?? namedId };
68
73
  const outcomes: ConnectOutcome[] = [];
69
- for (const member of members) outcomes.push(await connect(member, inherited, true));
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
- const existingIndex = runtime.config.connections.findIndex(
310
- (c) => `${c.provider}.${c.id}` === connectionKey,
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('(label for this connection)')}`)) ||
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
- if (explicitId) return { connectionId: explicitId, account };
152
+ const taken = siblings.map((candidate) => candidate.id);
133
153
 
134
- if (unaccounted) {
135
- return {
136
- connectionId: idFromAccount(
137
- 'main',
138
- siblings.map((candidate) => candidate.id),
139
- ),
140
- account: account!,
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: idFromAccount(
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
- account: string,
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, 'account'], account);
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
- from: located.connection.account,
322
- to: account,
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
- account: string | undefined,
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 (!account) throw new Error(`What should ${key} be called? Run: lanes link relabel ${key} "New name"`);
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, account, flags);
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
- style.dim(connection.account),
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
  }
@@ -1,6 +1,7 @@
1
1
  import { homedir } from 'node:os';
2
2
  import { join, sep } from 'node:path';
3
- import { installRoot } from '#profile';
3
+ import { installRoot, resolveWorkspaceRoot } from '#profile';
4
+ import { repairOwnerLayer } from '../config-repair.ts';
4
5
  import { emit, fail, ok, print, printErr, progress, style, warn } from '../output.ts';
5
6
  import { PACKAGE, release, type ReleaseState } from '../release.ts';
6
7
  import { version } from '../version.ts';
@@ -152,6 +153,27 @@ export async function update(flags: UpdateFlags): Promise<void> {
152
153
  // was down would make this the flakiest check in it.
153
154
  if (flags.check === true && decision.action === 'install') process.exitCode = 1;
154
155
 
156
+ // Whatever the registry said, and before the branch that returns early.
157
+ //
158
+ // `start`, `connect` and `deploy` already repair a profile that is missing
159
+ // part of the owner layer, and for months that was enough. It is not: a
160
+ // release that adds a surface — `tasks` and `assets` in 0.5.0 — leaves every
161
+ // existing profile without it until one of those three next runs, and someone
162
+ // who serves their endpoint from elsewhere may run none of them for weeks. The
163
+ // page they look at meanwhile offers to *add* what they already have,
164
+ // which is where this was reported from.
165
+ //
166
+ // `update` is the command that means "bring me current", so it is the honest
167
+ // place for the other half of current. Not on `--check`, which is a question
168
+ // and must not write, and not conditional on an install having happened: the
169
+ // profile of someone already on the latest version is exactly the one this was
170
+ // reported against.
171
+ if (flags.check !== true) {
172
+ await repairOwnerLayer(resolveWorkspaceRoot(), undefined, {
173
+ ...(flags.json === true ? { report: progress } : {}),
174
+ });
175
+ }
176
+
155
177
  const report = {
156
178
  installed: current.installed,
157
179
  latest: current.latest,
@@ -261,7 +261,14 @@ export function ensureIdentityConnection(document: ConfigDocument): SurfaceRepai
261
261
  export async function repairOwnerLayer(
262
262
  workspaceRoot: string,
263
263
  profiles: readonly string[] | undefined,
264
+ options: { report?: (line: string) => void } = {},
264
265
  ): Promise<void> {
266
+ // stdout by default, because every caller but one is printing a report a
267
+ // person reads. `update --json` passes `progress` instead: what it produces is
268
+ // a document, and a line of prose in front of it corrupts whatever is parsing.
269
+ // Routed rather than silenced — nothing else here widens a policy without
270
+ // saying so, and this must not be the exception.
271
+ const say = options.report ?? print;
265
272
  const wanted = profiles === undefined ? undefined : new Set(profiles);
266
273
 
267
274
  for (const name of await listProfiles(workspaceRoot)) {
@@ -274,13 +281,13 @@ export async function repairOwnerLayer(
274
281
 
275
282
  await document.save();
276
283
 
277
- print(ok(`gave ${style.bold(name)} its own owner layer`));
278
- for (const change of repairLines(repair)) print(` ${style.dim(change)}`);
279
- print(
284
+ say(ok(`gave ${style.bold(name)} its own owner layer`));
285
+ for (const change of repairLines(repair)) say(` ${style.dim(change)}`);
286
+ say(
280
287
  ` ${style.dim('memory, tasks, assets, skills, vault and setup — your own material, no account behind any of them')}`,
281
288
  );
282
289
  } catch (error) {
283
- print(
290
+ say(
284
291
  warn(
285
292
  `could not give ${name} its owner layer: ${error instanceof Error ? error.message.split('\n')[0] : String(error)}`,
286
293
  ),
@@ -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
- `<span class="account">${escapeHtml(connection.account)}</span>` +
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,
@@ -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
@@ -306,6 +306,7 @@ const ACCEPTS: Record<string, readonly string[]> = {
306
306
  connect: [
307
307
  'id',
308
308
  'display-name',
309
+ 'label',
309
310
  'replace',
310
311
  'non-interactive',
311
312
  'accept-broad-scopes',
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)
@@ -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
- lines.push(` ${connection.key} — ${connection.account}`);
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
 
@@ -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