@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 +1 -1
- package/src/cli/commands/operate/inspect.ts +16 -2
- package/src/cli/commands/operate/migrate.ts +100 -0
- package/src/cli/config-migrate.ts +251 -0
- package/src/cli/main.ts +1 -1
- package/src/cli/selection.ts +4 -0
- package/src/cli/usage.ts +2 -0
- package/src/profile/index.ts +4 -0
- package/src/profile/load.ts +96 -8
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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':
|
package/src/cli/selection.ts
CHANGED
|
@@ -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]
|
package/src/profile/index.ts
CHANGED
|
@@ -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 {
|
package/src/profile/load.ts
CHANGED
|
@@ -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
|
-
|
|
143
|
-
|
|
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
|
-
`"
|
|
147
|
-
`"${connection.account}" rather than "
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|