@lanes-sh/link 0.5.0 → 0.5.1

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.0",
3
+ "version": "0.5.1",
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",
@@ -2,9 +2,10 @@ import { credentialRefFor, formatPlan, planIsNoop, planReconcile } from '#regist
2
2
  import { DEFAULT_SURFACES } from '../../config-repair.ts';
3
3
  import { announce, announceProfile, emit, fail, ok, print, warn } from '../../output.ts';
4
4
  import { staleNudge } from '../../release.ts';
5
- import { openRuntime, resolveProfileOnly, type GlobalFlags } from '../../runtime.ts';
5
+ import { openRuntime, resolveProfileOnly, type GlobalFlags, type Runtime } from '../../runtime.ts';
6
6
  import type { FetchLike } from '#deployments/knowledge.ts';
7
7
  import { credentialAge, reportCapabilityDrift } from './findings.ts';
8
+ import { migratedRenamedProviders } from './migrate.ts';
8
9
 
9
10
  /**
10
11
  * The gate order — check, doctor, plan, start — exists so failures surface in
@@ -41,6 +42,8 @@ export async function plan(flags: GlobalFlags): Promise<void> {
41
42
 
42
43
  export interface DoctorFlags extends GlobalFlags {
43
44
  readonly json?: boolean | undefined;
45
+ /** Apply a repair `doctor` would otherwise only report. */
46
+ readonly fix?: boolean | undefined;
44
47
  /** Injected for tests. A knowledge repository is the only thing doctor fetches. */
45
48
  readonly fetch?: FetchLike | undefined;
46
49
  }
@@ -63,7 +66,18 @@ export interface DoctorFinding {
63
66
 
64
67
  /** Read-only external checks: credentials resolve, stores reachable. */
65
68
  export async function doctor(flags: DoctorFlags): Promise<void> {
66
- const runtime = await openRuntime(flags, { fetch: flags.fetch });
69
+ // The one check that cannot use a runtime, because it answers for the profiles
70
+ // that cannot open one. A provider rename left in the config refuses at load,
71
+ // which takes every command down together — including the rest of this one —
72
+ // so it is asked first and, with `--fix`, undone. Anything else that refused
73
+ // is rethrown untouched.
74
+ let runtime: Runtime;
75
+ try {
76
+ runtime = await openRuntime(flags, { fetch: flags.fetch });
77
+ } catch (refusal) {
78
+ if (await migratedRenamedProviders(flags, refusal)) return;
79
+ throw refusal;
80
+ }
67
81
 
68
82
  const checks: string[] = [];
69
83
  const warnings: DoctorFinding[] = [];
@@ -0,0 +1,100 @@
1
+ import { ConfigError, resolveSelection } from '#profile';
2
+ import { ConfigDocument } from '../../config-edit.ts';
3
+ import { migrateRenamedProviders, pendingRenames, shapeOf } from '../../config-migrate.ts';
4
+ import { emit, fail, ok, print, style, warn } from '../../output.ts';
5
+ import { openSecretStoreFor, type GlobalFlags } from '../../runtime.ts';
6
+
7
+ /**
8
+ * `doctor` answering for a profile whose config will not load.
9
+ *
10
+ * Here rather than in `inspect.ts` because it is the opposite of everything
11
+ * there: every other check reads a runtime, and this one runs precisely when no
12
+ * runtime can be opened. Keeping it beside them would have put a second
13
+ * `try`/`catch` shape around a file that is otherwise one long list of findings.
14
+ *
15
+ * Why `doctor` at all, and not a command of its own: it is already the command
16
+ * whose job is to say what is wrong and name the fix, and a `lanes link migrate`
17
+ * would be a command an operator has to know exists before their config breaks.
18
+ * `doctor` is what someone runs when something is broken, so it has to be the
19
+ * one that works when everything else refuses.
20
+ */
21
+
22
+ export interface RenameFlags extends GlobalFlags {
23
+ readonly json?: boolean | undefined;
24
+ /** Apply the migration rather than reporting it. */
25
+ readonly fix?: boolean | undefined;
26
+ }
27
+
28
+ /**
29
+ * Whether a refusal to load was a provider rename, and — with `--fix` — undo it.
30
+ *
31
+ * Returns false for anything else, so the caller rethrows the original error
32
+ * rather than replacing a real config problem with "nothing to migrate".
33
+ *
34
+ * The selection is resolved again here, and cheaply: `resolveSelection` reads
35
+ * the workspace and the flag, never a profile's config, which is what makes it
36
+ * usable on the path where the config is the thing that is broken.
37
+ */
38
+ export async function migratedRenamedProviders(
39
+ flags: RenameFlags,
40
+ refusal: unknown,
41
+ ): Promise<boolean> {
42
+ if (!(refusal instanceof ConfigError)) return false;
43
+
44
+ const selection = await resolveSelection({ profileFlag: flags.profile });
45
+ const document = await ConfigDocument.open(selection.workspaceRoot, selection.profile);
46
+ if (pendingRenames(document).length === 0) return false;
47
+
48
+ // Shape-only, because the check this document fails runs after the schema.
49
+ // Throws when it is malformed beyond a rename, which is a better sentence
50
+ // than the referential one it would otherwise be reported under.
51
+ const config = shapeOf(document);
52
+ const target = flags.target ?? '';
53
+ const credentials = await openSecretStoreFor(config, selection.workspaceRoot, target);
54
+
55
+ const migration = await migrateRenamedProviders(document, credentials, {
56
+ apply: flags.fix === true,
57
+ });
58
+
59
+ const applied = flags.fix === true && migration.changes.length > 0;
60
+
61
+ // A report is a problem, because the profile is still unusable. A repair that
62
+ // left nothing behind is not, and one that could not decide every row is —
63
+ // those rows are exactly as broken as before.
64
+ if (!applied || migration.blocked.length > 0) process.exitCode = 1;
65
+
66
+ await emit(
67
+ flags.json,
68
+ {
69
+ ok: applied && migration.blocked.length === 0,
70
+ profile: selection.profile,
71
+ target,
72
+ applied,
73
+ rows: migration.rows,
74
+ changes: migration.changes,
75
+ blocked: migration.blocked,
76
+ },
77
+ () => {
78
+ print(`profile ${style.bold(selection.profile)} target ${style.bold(target)}`);
79
+ print();
80
+
81
+ if (applied) {
82
+ print(ok(`${document.path} no longer names a provider that has moved`));
83
+ } else {
84
+ print(fail(`${document.path} names a provider that has moved, so nothing can load it`));
85
+ }
86
+
87
+ for (const change of migration.changes) {
88
+ print(` ${style.dim(applied ? change : `would ${change}`)}`);
89
+ }
90
+ for (const problem of migration.blocked) print(warn(problem));
91
+
92
+ if (!applied && migration.changes.length > 0) {
93
+ print();
94
+ print(`Run the same command with ${style.bold('--fix')} to apply it.`);
95
+ }
96
+ },
97
+ );
98
+
99
+ return true;
100
+ }
@@ -0,0 +1,251 @@
1
+ import { ConfigError, renamedProviderFor, validateConfigShape, type Config } from '#profile';
2
+ import type { SecretStore } from '#secrets';
3
+ import { ConfigDocument } from './config-edit.ts';
4
+
5
+ /**
6
+ * Applying a provider rename to a profile that still names the old id.
7
+ *
8
+ * Apart from `config-repair.ts` because the subject differs, not because either
9
+ * file grew: that one gives a profile a surface it never had, reading a config
10
+ * that loads. This one runs when the config does *not* load, which is the whole
11
+ * difficulty. `renamedProviderFor` refuses a stale row at load (`#profile`), and
12
+ * every command opens the config — so one row left saying `provider: tasks`
13
+ * takes `status`, `start`, `plan` and `doctor` down together, for a state an
14
+ * upgrade put the operator in without asking. The only way back was to
15
+ * hand-edit YAML, which is not a thing a CLI should require to undo its own
16
+ * release.
17
+ *
18
+ * So this reads raw YAML through `ConfigDocument` and edits it comment-first,
19
+ * exactly as the other repairs do.
20
+ *
21
+ * **It never guesses.** A `tasks` row labelled anything but `Tasks` is either a
22
+ * pre-rename Google Tasks connection or a hand-edited built-in one, and the
23
+ * refusal names both because the two fixes are opposite. What decides here is
24
+ * evidence rather than a heuristic: a stored credential at `tasks/<id>` can only
25
+ * belong to the OAuth connection, because the built-in holds none and never
26
+ * has. With no credential this reports both readings and changes nothing.
27
+ */
28
+
29
+ /** One row that has to move. */
30
+ export interface PendingRename {
31
+ readonly index: number;
32
+ readonly from: string;
33
+ readonly to: string;
34
+ readonly id: string;
35
+ readonly account: string;
36
+ /** `provider.id`, as the rest of the CLI addresses a connection. */
37
+ readonly key: string;
38
+ }
39
+
40
+ export interface RenameMigration {
41
+ /** Every stale row found, whether or not it could be moved. */
42
+ readonly rows: readonly PendingRename[];
43
+ /** What was done, or would be — spelled for display. */
44
+ readonly changes: readonly string[];
45
+ /** What was left alone, each with why. */
46
+ readonly blocked: readonly string[];
47
+ }
48
+
49
+ /**
50
+ * The rows a document still spells the old way.
51
+ *
52
+ * Raw YAML, so nothing here has been through a schema — this runs on a file the
53
+ * loader has already refused, and every field is whatever was typed. A row
54
+ * missing `provider` or `account` is not a rename, it is a shape error, and
55
+ * reporting that is `validateConfig`'s job rather than this one's.
56
+ */
57
+ export function pendingRenames(document: ConfigDocument): PendingRename[] {
58
+ const pending: PendingRename[] = [];
59
+
60
+ connectionsOf(document).forEach((row, index) => {
61
+ const { provider, id, account } = (row ?? {}) as {
62
+ provider?: unknown;
63
+ id?: unknown;
64
+ account?: unknown;
65
+ };
66
+ if (typeof provider !== 'string' || typeof id !== 'string' || typeof account !== 'string') {
67
+ return;
68
+ }
69
+
70
+ const moved = renamedProviderFor({ provider, account });
71
+ if (moved) {
72
+ pending.push({ index, from: provider, to: moved.to, id, account, key: `${provider}.${id}` });
73
+ }
74
+ });
75
+
76
+ return pending;
77
+ }
78
+
79
+ /**
80
+ * Report what a profile is owed, and optionally apply it.
81
+ *
82
+ * Takes the credential store rather than opening one, so the caller decides
83
+ * whether the target is reachable and a test needs no adapter. `apply: false` is
84
+ * what `doctor` prints without `--fix`: the same reading against the same
85
+ * evidence, with nothing written.
86
+ *
87
+ * The credential is copied *before* the config is saved, and the old reference
88
+ * deleted only after. A crash between the two leaves a second copy of a secret
89
+ * the operator already holds — recoverable, and invisible. The other order
90
+ * leaves a config naming a credential that is gone, which presents as a
91
+ * connection that lost its authorisation for no reason anyone can see.
92
+ */
93
+ export async function migrateRenamedProviders(
94
+ document: ConfigDocument,
95
+ credentials: SecretStore,
96
+ options: { apply: boolean },
97
+ ): Promise<RenameMigration> {
98
+ const rows = pendingRenames(document);
99
+ if (rows.length === 0) return { rows, changes: [], blocked: [] };
100
+
101
+ const changes: string[] = [];
102
+ const blocked: string[] = [];
103
+ const accepted: PendingRename[] = [];
104
+
105
+ for (const row of rows) {
106
+ if (await credentials.has(`${row.from}/${row.id}`)) {
107
+ accepted.push(row);
108
+ changes.push(`connections[${row.index}]: ${row.from} → ${row.to} (${row.key})`);
109
+ changes.push(`credential: ${row.from}/${row.id} → ${row.to}/${row.id}`);
110
+ } else {
111
+ blocked.push(
112
+ `${row.key} has no stored credential, so nothing proves it was ${row.to} rather than a ` +
113
+ `built-in row labelled "${row.account}" by hand — set its provider or its account in ` +
114
+ `the file itself`,
115
+ );
116
+ }
117
+ }
118
+
119
+ // The policy rules second, because whether one can move depends on what is
120
+ // left declaring the old id once the rows above have.
121
+ for (const [from, to] of new Map(accepted.map((row) => [row.from, row.to]))) {
122
+ const policy = renamePolicyRules(document, { from, to }, {
123
+ stillDeclared: keepsDeclaring(document, from, accepted),
124
+ apply: options.apply,
125
+ });
126
+ changes.push(...policy.changes);
127
+ blocked.push(...policy.blocked);
128
+ }
129
+
130
+ if (!options.apply || accepted.length === 0) return { rows, changes, blocked };
131
+
132
+ for (const row of accepted) {
133
+ document.setIn(['connections', row.index, 'provider'], row.to);
134
+
135
+ const value = await credentials.get(`${row.from}/${row.id}`);
136
+ if (value !== null) await credentials.set(`${row.to}/${row.id}`, value);
137
+ }
138
+
139
+ // Throws unless the result is a config that loads, which is the assertion
140
+ // worth having here — the whole premise was that it did not.
141
+ await document.save();
142
+
143
+ for (const row of accepted) await credentials.delete(`${row.from}/${row.id}`);
144
+
145
+ return { rows, changes, blocked };
146
+ }
147
+
148
+ /**
149
+ * Rewrite the policy rules that named the old id, where that is unambiguous.
150
+ *
151
+ * A rule names a provider and never an account, so `tasks.*` written for Google
152
+ * Tasks has to follow the rename or the migrated connection is granted nothing
153
+ * — `allowedConnections` drops a provider no rule covers before policy is even
154
+ * consulted, so the repair would land a row that serves exactly as little as
155
+ * the broken one did.
156
+ *
157
+ * But a profile declaring *both* — a Google Tasks row and the built-in — has one
158
+ * rule serving two providers, and moving it would silently revoke the one that
159
+ * kept its name. That profile keeps its rule and is told to add the second,
160
+ * which is a sentence rather than a guess at which was meant.
161
+ *
162
+ * Both lists. A `deny` written to switch Google Tasks off means it as firmly as
163
+ * an allow means it on, and leaving it behind would re-enable something the
164
+ * operator turned off.
165
+ */
166
+ function renamePolicyRules(
167
+ document: ConfigDocument,
168
+ provider: { from: string; to: string },
169
+ options: { stillDeclared: boolean; apply: boolean },
170
+ ): { changes: string[]; blocked: string[] } {
171
+ const changes: string[] = [];
172
+ const blocked: string[] = [];
173
+
174
+ for (const field of ['allow', 'deny'] as const) {
175
+ const rules = document.getIn(['policy', field]) as { items?: unknown[] } | null;
176
+
177
+ (rules?.items ?? []).forEach((_item, index) => {
178
+ // Either spelling: a bare pattern, or `{ capability, expires_at }`. The
179
+ // path to it differs; the decision does not.
180
+ const bare = document.getIn(['policy', field, index]);
181
+ const path =
182
+ typeof bare === 'string'
183
+ ? (['policy', field, index] as const)
184
+ : (['policy', field, index, 'capability'] as const);
185
+
186
+ const capability = typeof bare === 'string' ? bare : document.getIn(path);
187
+ if (typeof capability !== 'string') return;
188
+
189
+ const [named, ...rest] = capability.split('.');
190
+ if (named !== provider.from) return;
191
+
192
+ const moved = [provider.to, ...rest].join('.');
193
+
194
+ if (options.stillDeclared) {
195
+ blocked.push(
196
+ `policy.${field} keeps "${capability}" — this profile still declares a ` +
197
+ `"${provider.from}" connection, so add "${moved}" rather than moving it`,
198
+ );
199
+ return;
200
+ }
201
+
202
+ if (options.apply) document.setIn(path, moved);
203
+ changes.push(`policy.${field}: ${capability} → ${moved}`);
204
+ });
205
+ }
206
+
207
+ return { changes, blocked };
208
+ }
209
+
210
+ /**
211
+ * Whether a row naming the old provider survives the migration.
212
+ *
213
+ * Computed against the accepted set rather than by re-reading the document,
214
+ * because the same answer has to hold on a report-only run, where nothing has
215
+ * been rewritten yet.
216
+ */
217
+ function keepsDeclaring(
218
+ document: ConfigDocument,
219
+ provider: string,
220
+ accepted: readonly PendingRename[],
221
+ ): boolean {
222
+ return connectionsOf(document).some(
223
+ (row, index) =>
224
+ (row as { provider?: unknown } | null)?.provider === provider &&
225
+ !accepted.some((moved) => moved.index === index),
226
+ );
227
+ }
228
+
229
+ function connectionsOf(document: ConfigDocument): unknown[] {
230
+ const config = document.toJSON() as { connections?: unknown } | null;
231
+ return Array.isArray(config?.connections) ? config.connections : [];
232
+ }
233
+
234
+ /**
235
+ * The config of a document the loader has refused, shape-checked only.
236
+ *
237
+ * `openSecretStoreFor` needs a `Config` to name the target's adapter, and the
238
+ * document in hand fails the check that runs *after* the schema — so this is
239
+ * that parse without it. A document failing the schema itself is beyond this
240
+ * repair, and says so rather than reporting a rename it cannot see.
241
+ */
242
+ export function shapeOf(document: ConfigDocument): Config {
243
+ try {
244
+ return validateConfigShape(document.toJSON(), document.path);
245
+ } catch (error) {
246
+ throw new ConfigError(
247
+ `${document.path} is malformed beyond a provider rename, so there is nothing to migrate.\n` +
248
+ ` ${error instanceof Error ? error.message : String(error)}`,
249
+ );
250
+ }
251
+ }
package/src/cli/main.ts CHANGED
@@ -277,7 +277,7 @@ export async function run(argv: readonly string[]): Promise<void> {
277
277
  case 'plan':
278
278
  return plan(global);
279
279
  case 'doctor':
280
- return doctor({ ...global, json });
280
+ return doctor({ ...global, json, fix: flags['fix'] === true });
281
281
  case 'status':
282
282
  return status({ ...global, json });
283
283
  case 'outputs':
@@ -320,6 +320,10 @@ const ACCEPTS: Record<string, readonly string[]> = {
320
320
  // the list that decides whether it may be typed.
321
321
  'profile remove': ['dry-run', 'yes', 'target'],
322
322
  disconnect: ['yes', 'keep-credential'],
323
+ // The one repair `doctor` can apply rather than only name. Narrow on purpose:
324
+ // it undoes a provider rename this project shipped, and every other finding
325
+ // there is something only the operator can decide.
326
+ doctor: ['fix'],
323
327
  relabel: [],
324
328
  'target list': ['urls', 'target'],
325
329
  'target show': ['target'],
package/src/cli/usage.ts CHANGED
@@ -127,6 +127,8 @@ ${style.bold('Deploying')}
127
127
  ${style.bold('Inspection')}
128
128
  ${PROGRAM} check static validation, no external calls
129
129
  ${PROGRAM} doctor [--json] credentials resolve, stores reachable
130
+ ${PROGRAM} doctor --fix apply a repair it can make itself, such as
131
+ a provider this project renamed under you
130
132
  ${PROGRAM} tools [--json] what the endpoint advertises to a client
131
133
  ${PROGRAM} plan what reconcile would change
132
134
  ${PROGRAM} audit tail [--limit N] [--denied-only] [--format md]
@@ -38,10 +38,14 @@ export {
38
38
 
39
39
  export {
40
40
  ConfigError,
41
+ RENAMED_PROVIDERS,
41
42
  loadConfigFile,
42
43
  parseConfig,
44
+ renamedProviderFor,
43
45
  validateConfig,
46
+ validateConfigShape,
44
47
  type LoadedConfig,
48
+ type ProviderRename,
45
49
  } from './load.ts';
46
50
 
47
51
  export {
@@ -40,6 +40,25 @@ export interface LoadedConfig {
40
40
  * 4. Referential integrity, which needs a well-formed document to check.
41
41
  */
42
42
  export function validateConfig(raw: unknown, source = '<config>'): Config {
43
+ const config = validateConfigShape(raw, source);
44
+ assertReferentialIntegrity(config, source);
45
+ return config;
46
+ }
47
+
48
+ /**
49
+ * The first three steps, without the fourth.
50
+ *
51
+ * Only one caller, and it is the repair: `migrateRenamedProviders` has to open
52
+ * the credential store of a config that referential integrity has just refused,
53
+ * because whether a credential is stored is the evidence deciding which of two
54
+ * readings a row gets. A shape-valid document is enough to name a target's
55
+ * adapter, and nothing here trusts the part that failed.
56
+ *
57
+ * Not exported as a way to *load* a config. Everything that acts on one goes
58
+ * through `validateConfig`, and the split exists so that the one command whose
59
+ * job is to fix a refusal is not blocked by it.
60
+ */
61
+ export function validateConfigShape(raw: unknown, source = '<config>'): Config {
43
62
  if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
44
63
  throw new ConfigError(`${source}: expected a YAML mapping at the top level`);
45
64
  }
@@ -59,7 +78,6 @@ export function validateConfig(raw: unknown, source = '<config>'): Config {
59
78
  throw new ConfigError(`${source}:\n${formatZodIssues(parsed.error)}`);
60
79
  }
61
80
 
62
- assertReferentialIntegrity(parsed.data, source);
63
81
  return parsed.data;
64
82
  }
65
83
 
@@ -138,15 +156,85 @@ function formatZodIssues(error: z.ZodError): string {
138
156
  * are, and keying on `id !== 'main'` would refuse a valid profile forever to
139
157
  * catch a one-release migration. Labelling both `Tasks` is consistent with what
140
158
  * the accountless providers already do — every memory connection is `Memory`.
159
+ *
160
+ * **The refusal names a command, because it is a refusal at load.** Every
161
+ * command opens the config, so this one takes `status`, `start` and `doctor`
162
+ * down together and leaves hand-editing YAML as the only way back — for a state
163
+ * an upgrade put the operator in, without asking. `doctor --fix` is that way
164
+ * back, and it is named here because this is the only place anyone sees.
141
165
  */
142
- function renamedProvider(connection: { provider: string; account: string }): string | null {
143
- if (connection.provider !== 'tasks' || connection.account === 'Tasks') return null;
166
+ export interface ProviderRename {
167
+ /** What a row naming the old id should say instead. */
168
+ readonly to: string;
169
+ /** The account label that means this row is the built-in, not a vendor one. */
170
+ readonly keeps: string;
171
+ /** What the plain noun now names, for the sentence below. */
172
+ readonly becomes: string;
173
+ /** What it used to name. */
174
+ readonly was: string;
175
+ /** The built-in, in the operator's words. */
176
+ readonly noun: string;
177
+ }
178
+
179
+ /**
180
+ * Provider ids that have moved, and everything two places need to agree on.
181
+ *
182
+ * `renamedProviderFor` refuses a row still naming the old id; `#cli`'s
183
+ * `migrateRenamedProviders` rewrites one. A rename landing in one and not the
184
+ * other is a refusal with no fix, or a fix nothing asks for — which is why the
185
+ * pair reads from one table rather than each knowing the rename itself.
186
+ *
187
+ * `keeps` is the single spelling `newProfileTemplate` and `ensureReservedConnection`
188
+ * write for the built-in's row. Keep it in step with `RESERVED_SURFACES` there.
189
+ */
190
+ export const RENAMED_PROVIDERS: Readonly<Record<string, ProviderRename>> = {
191
+ tasks: {
192
+ to: 'google_tasks',
193
+ keeps: 'Tasks',
194
+ becomes: 'the built-in task list',
195
+ was: 'Google Tasks',
196
+ noun: 'task list',
197
+ },
198
+ };
199
+
200
+ /**
201
+ * The repair, spelled with the selection it will refuse without.
202
+ *
203
+ * Both flags come off the document being validated rather than off the command
204
+ * that is running: nothing has resolved anything yet, and the profile is written
205
+ * in the file. A profile declaring one target names it; one declaring several
206
+ * cannot be guessed at, and a placeholder is more honest than picking.
207
+ */
208
+ function repairCommand(config: Config): string {
209
+ const targets = Object.keys(config.targets);
210
+ const target = targets.length === 1 ? targets[0] : `<${targets.join('|') || 'target'}>`;
211
+ return `lanes link doctor --fix --profile ${config.instance.profile} --target ${target}`;
212
+ }
213
+
214
+ /** The rename a row is owed, or `null` when it is owed none. */
215
+ export function renamedProviderFor(connection: {
216
+ provider: string;
217
+ account: string;
218
+ }): ProviderRename | null {
219
+ const moved = RENAMED_PROVIDERS[connection.provider];
220
+ if (!moved || connection.account === moved.keeps) return null;
221
+ return moved;
222
+ }
223
+
224
+ function renamedProvider(
225
+ connection: { provider: string; account: string },
226
+ repair: string,
227
+ ): string | null {
228
+ const moved = renamedProviderFor(connection);
229
+ if (!moved) return null;
144
230
 
145
231
  return (
146
- `"tasks" is now the built-in task list, and this row is labelled ` +
147
- `"${connection.account}" rather than "Tasks".\n` +
148
- ' If it was Google Tasks: set provider to google_tasks here, and rename any "tasks.*" policy rule.\n' +
149
- ' If it is your own task list: set account to Tasks.'
232
+ `"${connection.provider}" is now ${moved.becomes}, and this row is labelled ` +
233
+ `"${connection.account}" rather than "${moved.keeps}".\n` +
234
+ ` If it was ${moved.was}: set provider to ${moved.to} here, and rename any ` +
235
+ `"${connection.provider}.*" policy rule.\n` +
236
+ ` If it is your own ${moved.noun}: set account to ${moved.keeps}.\n` +
237
+ ` ${repair} applies the first, where a stored credential proves it.`
150
238
  );
151
239
  }
152
240
 
@@ -186,7 +274,7 @@ function assertReferentialIntegrity(config: Config, source: string): void {
186
274
  }
187
275
  connectionKeys.add(key);
188
276
 
189
- const renamed = renamedProvider(connection);
277
+ const renamed = renamedProvider(connection, repairCommand(config));
190
278
  if (renamed) problems.push(`connections[${index}]: ${renamed}`);
191
279
  });
192
280