@lanes-sh/link 0.2.2 → 0.3.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/README.md +22 -8
- package/instructions/skills/lanes-link/SKILL.md +42 -14
- package/package.json +1 -1
- package/src/cli/argv.ts +50 -0
- package/src/cli/brand.ts +178 -0
- package/src/cli/callback-page.ts +108 -128
- package/src/cli/commands/connect/accounts.ts +5 -0
- package/src/cli/commands/connect/assertion.ts +187 -0
- package/src/cli/commands/connect/authorise.ts +61 -17
- package/src/cli/commands/connect/client.ts +37 -9
- package/src/cli/commands/connect/discover.ts +94 -0
- package/src/cli/commands/connect/family.ts +72 -0
- package/src/cli/commands/connect/index.ts +113 -115
- package/src/cli/commands/connect/method.ts +237 -0
- package/src/cli/commands/connect/outcome.ts +42 -1
- package/src/cli/commands/connect/pasted-token.ts +66 -0
- package/src/cli/commands/connect/requirements.ts +60 -8
- package/src/cli/commands/connect/setup.ts +16 -5
- package/src/cli/commands/connect/target-note.ts +34 -0
- package/src/cli/commands/identity.ts +258 -0
- package/src/cli/commands/knowledge/index.ts +390 -0
- package/src/cli/commands/knowledge/migrate.ts +180 -0
- package/src/cli/commands/knowledge/setup.ts +144 -0
- package/src/cli/commands/knowledge.ts +10 -0
- package/src/cli/commands/mcp/harnesses.ts +16 -2
- package/src/cli/commands/mcp/register.ts +9 -1
- package/src/cli/commands/mcp/stdio.ts +21 -0
- package/src/cli/commands/operate/dashboard.ts +107 -0
- package/src/cli/commands/operate/findings.ts +151 -0
- package/src/cli/commands/operate/inspect.ts +63 -164
- package/src/cli/commands/operate/outputs.ts +38 -11
- package/src/cli/commands/operate/policy.ts +7 -7
- package/src/cli/commands/operate/serve.ts +3 -0
- package/src/cli/commands/operate/status.ts +108 -1
- package/src/cli/commands/operate/token.ts +1 -1
- package/src/cli/commands/operate.ts +2 -0
- package/src/cli/commands/profile/declare.ts +154 -0
- package/src/cli/commands/profile/removal.ts +17 -0
- package/src/cli/commands/profile/remove.ts +5 -5
- package/src/cli/commands/profile.ts +83 -35
- package/src/cli/commands/secrets.ts +6 -6
- package/src/cli/commands/setup.ts +22 -6
- package/src/cli/commands/sync.ts +262 -0
- package/src/cli/commands/target.ts +65 -83
- package/src/cli/config-edit.ts +53 -144
- package/src/cli/config-repair.ts +186 -0
- package/src/cli/dashboard-page.ts +284 -0
- package/src/cli/dashboard-shell.ts +125 -0
- package/src/cli/dispatch-owner.ts +93 -0
- package/src/cli/identity.ts +12 -1
- package/src/cli/main.ts +90 -61
- package/src/cli/nearest.ts +45 -0
- package/src/cli/oauth-callback.ts +187 -0
- package/src/cli/oauth-exchange.ts +57 -15
- package/src/cli/oauth.ts +67 -177
- package/src/cli/output.ts +21 -5
- package/src/cli/provider-marks.ts +45 -0
- package/src/cli/runtime/open.ts +74 -51
- package/src/cli/runtime/registry.ts +60 -2
- package/src/cli/runtime/select.ts +26 -13
- package/src/cli/runtime/vault.ts +61 -0
- package/src/cli/runtime.ts +2 -1
- package/src/cli/selection.ts +365 -0
- package/src/cli/usage.ts +42 -11
- package/src/connectivity/auth/README.md +7 -1
- package/src/connectivity/auth/basic/index.ts +1 -1
- package/src/connectivity/auth/index.ts +14 -0
- package/src/connectivity/auth/oauth-authcode/broker.ts +26 -0
- package/src/connectivity/auth/oauth-authcode/index.ts +16 -2
- package/src/connectivity/auth/oauth-authcode/provider.ts +1 -1
- package/src/connectivity/auth/oauth-authcode/refresh.ts +3 -3
- package/src/connectivity/auth/oauth-jwt/README.md +33 -0
- package/src/connectivity/auth/oauth-jwt/index.ts +237 -0
- package/src/connectivity/auth/oauth-jwt/key.ts +148 -0
- package/src/connectivity/auth/resolve.ts +1 -1
- package/src/connectivity/auth/token.ts +11 -0
- package/src/connectivity/index.ts +2 -0
- package/src/connectivity/manifest/auth.ts +99 -2
- package/src/connectivity/manifest/identity.ts +12 -0
- package/src/connectivity/manifest/index.ts +3 -1
- package/src/connectivity/manifest/provider.ts +37 -8
- package/src/connectivity/manifest/requirements.ts +109 -6
- package/src/deployments/adapters/filesystem.ts +10 -1
- package/src/deployments/adapters/github-api.ts +106 -0
- package/src/deployments/adapters/github-commit.ts +103 -0
- package/src/deployments/adapters/github-repo.ts +356 -0
- package/src/deployments/adapters/github-testing.ts +258 -0
- package/src/deployments/adapters/github.ts +125 -0
- package/src/deployments/deploy.ts +94 -114
- package/src/deployments/discover.ts +103 -0
- package/src/deployments/driver.ts +8 -1
- package/src/deployments/gcp/driver.ts +3 -1
- package/src/deployments/knowledge.ts +119 -0
- package/src/deployments/prepare.ts +12 -6
- package/src/deployments/report.ts +117 -0
- package/src/deployments/servable.ts +82 -0
- package/src/deployments/serving.ts +165 -0
- package/src/deployments/sync-apply.ts +276 -0
- package/src/deployments/sync.ts +136 -0
- package/src/deployments/target.ts +3 -2
- package/src/deployments/upload.ts +19 -12
- package/src/dispatch/dispatch.ts +1 -1
- package/src/profile/deployments.ts +80 -0
- package/src/profile/identity.ts +60 -0
- package/src/profile/index.ts +23 -5
- package/src/profile/knowledge.ts +124 -0
- package/src/profile/load.ts +17 -5
- package/src/profile/primitives.ts +24 -1
- package/src/profile/schema.ts +81 -3
- package/src/profile/targets.ts +122 -109
- package/src/profile/workspace.ts +139 -79
- package/src/providers/google/calendar/index.ts +2 -0
- package/src/providers/google/contacts/index.ts +2 -0
- package/src/providers/google/docs/index.ts +2 -0
- package/src/providers/google/drive/index.ts +2 -0
- package/src/providers/google/gmail/index.ts +2 -0
- package/src/providers/google/gmail-imap/index.ts +125 -0
- package/src/providers/google/index.ts +2 -1
- package/src/providers/google/shared/oauth.ts +18 -6
- package/src/providers/google/shared/service-account.ts +110 -0
- package/src/providers/google/shared/setup.ts +21 -3
- package/src/providers/google/sheets/index.ts +2 -0
- package/src/providers/google/tasks/index.ts +2 -0
- package/src/providers/identity/provider.ts +166 -0
- package/src/providers/index.ts +3 -0
- package/src/providers/owner.ts +10 -2
- package/src/providers/scopes.ts +2 -0
- package/src/providers/setup/plan.ts +31 -9
- package/src/providers/setup/provider.ts +23 -0
- package/src/providers/slack/index.ts +81 -33
- package/src/providers/slack/oauth.ts +103 -0
- package/src/providers/slack/scopes.ts +37 -0
- package/src/server/container.ts +18 -1
- package/src/server/cors.ts +252 -0
- package/src/server/dashboard.ts +208 -0
- package/src/server/endpoint.ts +45 -1
- package/src/server/generations.ts +11 -2
- package/src/server/harness.ts +7 -0
- package/src/server/index.ts +45 -6
- package/src/server/mcp/index.ts +1 -0
- package/src/server/mcp/instructions.ts +28 -1
- package/src/server/mcp/visibility.ts +33 -0
- package/src/stores/blobs/route.ts +123 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { parseDocument } from 'yaml';
|
|
2
|
+
import {
|
|
3
|
+
ConfigError,
|
|
4
|
+
parseConfig,
|
|
5
|
+
readWorkspaceFile,
|
|
6
|
+
workspaceFiles,
|
|
7
|
+
writeWorkspaceFile,
|
|
8
|
+
type Config,
|
|
9
|
+
} from '#profile';
|
|
10
|
+
import { ConfigDocument } from '#cli/config-edit.ts';
|
|
11
|
+
import { diffConfigs, keyedArrayFor, type Change } from './sync.ts';
|
|
12
|
+
import { isWorkspaceConfig } from './upload.ts';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Deciding a difference and writing it down.
|
|
16
|
+
*
|
|
17
|
+
* The rule is union, and refusal where a union is not possible. Anything one
|
|
18
|
+
* side is missing is copied to it; anything both sides hold differently stops
|
|
19
|
+
* the sync and prints the diff. Last-writer-wins was the alternative and it is
|
|
20
|
+
* the failure this whole command exists because of — a copy of a profile
|
|
21
|
+
* quietly replaced another that held six connections it did not.
|
|
22
|
+
*
|
|
23
|
+
* `--prefer` is how a conflict is resolved, and it is deliberately one flag for
|
|
24
|
+
* the whole run rather than a prompt per key: a sync that asks twelve questions
|
|
25
|
+
* is one answered by pressing return, and the twelfth answer is the one that
|
|
26
|
+
* matters.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export type Prefer = 'local' | 'remote';
|
|
30
|
+
|
|
31
|
+
export interface ProfileSync {
|
|
32
|
+
readonly profile: string;
|
|
33
|
+
readonly changes: readonly Change[];
|
|
34
|
+
/** Only on the local side: the remote file does not exist at all. */
|
|
35
|
+
readonly onlyRemote: boolean;
|
|
36
|
+
readonly onlyLocal: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const profileKey = (profile: string): string => `profiles/${profile}.yaml`;
|
|
40
|
+
|
|
41
|
+
/** Parse a profile out of a workspace, or `undefined` when it is not there. */
|
|
42
|
+
async function readProfile(root: string, profile: string): Promise<Config | undefined> {
|
|
43
|
+
const text = await readWorkspaceFile(workspaceFiles(root), profileKey(profile));
|
|
44
|
+
if (text === null) return undefined;
|
|
45
|
+
|
|
46
|
+
// A remote copy that will not parse is a conflict the operator has to look
|
|
47
|
+
// at, not something to overwrite with the local one — it may be the only copy
|
|
48
|
+
// of something.
|
|
49
|
+
try {
|
|
50
|
+
return parseConfig(text, `${root}/${profileKey(profile)}`).config;
|
|
51
|
+
} catch (error) {
|
|
52
|
+
throw new ConfigError(
|
|
53
|
+
`${root}/${profileKey(profile)} could not be read, so it cannot be merged:\n` +
|
|
54
|
+
` ${error instanceof Error ? (error.message.split('\n')[0] ?? '') : String(error)}\n` +
|
|
55
|
+
' Fix it there, or pass --prefer local to overwrite it.',
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The same file, unvalidated, as the source of what actually gets written.
|
|
62
|
+
*
|
|
63
|
+
* The diff compares *validated* configs, because that is the only comparison
|
|
64
|
+
* that gets equality right: `policy.allow: [gmail.*]` and
|
|
65
|
+
* `[{capability: gmail.*}]` are the same grant written two ways, and a raw
|
|
66
|
+
* comparison would call that a conflict.
|
|
67
|
+
*
|
|
68
|
+
* What is written has to come from here all the same. Writing the validated
|
|
69
|
+
* value back means writing every default zod filled in on the way through —
|
|
70
|
+
* `min_instances: 0`, an OAuth token lifetime nobody set — into a file the
|
|
71
|
+
* operator reads. Recovering six connections should not also silently expand
|
|
72
|
+
* three policy rules into a shape they were not written in.
|
|
73
|
+
*
|
|
74
|
+
* The two cannot disagree about whether something is missing: a default is
|
|
75
|
+
* filled in identically on both sides, so it is equal, so it is never a change.
|
|
76
|
+
*/
|
|
77
|
+
async function readRawProfile(root: string, profile: string): Promise<unknown> {
|
|
78
|
+
const text = await readWorkspaceFile(workspaceFiles(root), profileKey(profile));
|
|
79
|
+
return text === null ? undefined : parseDocument(text).toJSON();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Every profile either side has, so one that exists only remotely is still seen. */
|
|
83
|
+
export async function profilesInEither(local: string, remote: string): Promise<string[]> {
|
|
84
|
+
const names = async (root: string): Promise<string[]> =>
|
|
85
|
+
(await workspaceFiles(root).list('profiles/'))
|
|
86
|
+
.map((entry) => entry.key.slice('profiles/'.length))
|
|
87
|
+
.filter((name) => name.endsWith('.yaml') && !name.endsWith('.example.yaml'))
|
|
88
|
+
.filter((name) => !name.includes('/'))
|
|
89
|
+
.map((name) => name.slice(0, -'.yaml'.length));
|
|
90
|
+
|
|
91
|
+
return [...new Set([...(await names(local)), ...(await names(remote))])].sort();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** What differs, for one profile, between the workspace and a target's copy. */
|
|
95
|
+
export async function planProfile(
|
|
96
|
+
localRoot: string,
|
|
97
|
+
remoteRoot: string,
|
|
98
|
+
profile: string,
|
|
99
|
+
prefer: Prefer | undefined,
|
|
100
|
+
): Promise<ProfileSync> {
|
|
101
|
+
const local = await readProfile(localRoot, profile);
|
|
102
|
+
const remote = prefer === 'local' && local !== undefined
|
|
103
|
+
? // Told local wins outright: there is nothing to ask the remote copy, and
|
|
104
|
+
// reading it could only produce conflicts already decided.
|
|
105
|
+
undefined
|
|
106
|
+
: await readProfile(remoteRoot, profile);
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
profile,
|
|
110
|
+
changes: diffConfigs(local, remote),
|
|
111
|
+
onlyRemote: local === undefined && remote !== undefined,
|
|
112
|
+
onlyLocal: remote === undefined && local !== undefined,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The changes that will be written, once `--prefer` has decided the rest. */
|
|
117
|
+
export function resolved(changes: readonly Change[], prefer: Prefer | undefined): Change[] {
|
|
118
|
+
return changes.map((change) => {
|
|
119
|
+
if (change.direction !== 'conflict') return change;
|
|
120
|
+
if (prefer === undefined) return change;
|
|
121
|
+
return { ...change, direction: prefer === 'remote' ? 'pull' : 'push' } as Change;
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Write everything the local copy is missing into the local profile.
|
|
127
|
+
*
|
|
128
|
+
* Through `ConfigDocument`, so the comments an operator wrote survive a
|
|
129
|
+
* recovery, and so the result is validated before it lands — a merged config
|
|
130
|
+
* that would not load is a worse outcome than the one being fixed.
|
|
131
|
+
*
|
|
132
|
+
* A keyed-array element is written by rewriting its array. A YAML sequence has
|
|
133
|
+
* no addressable slot for "the entry whose provider is gmail", and merging by
|
|
134
|
+
* index is exactly the comparison this avoided making in the first place.
|
|
135
|
+
*/
|
|
136
|
+
export async function applyPulls(
|
|
137
|
+
localRoot: string,
|
|
138
|
+
remoteRoot: string,
|
|
139
|
+
profile: string,
|
|
140
|
+
changes: readonly Change[],
|
|
141
|
+
): Promise<number> {
|
|
142
|
+
const pulls = changes.filter((change) => change.direction === 'pull');
|
|
143
|
+
if (pulls.length === 0) return 0;
|
|
144
|
+
|
|
145
|
+
// A profile local does not have at all is a file copy: there is no document
|
|
146
|
+
// to edit, and every key in it is a pull.
|
|
147
|
+
if (pulls.some((change) => change.path.length === 0)) {
|
|
148
|
+
const text = await readWorkspaceFile(workspaceFiles(remoteRoot), profileKey(profile));
|
|
149
|
+
if (text === null) return 0;
|
|
150
|
+
await writeWorkspaceFile(workspaceFiles(localRoot), profileKey(profile), text);
|
|
151
|
+
return 1;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const document = await ConfigDocument.open(localRoot, profile);
|
|
155
|
+
const remote = await readRawProfile(remoteRoot, profile);
|
|
156
|
+
if (remote === undefined) return 0;
|
|
157
|
+
|
|
158
|
+
const written = new Set<string>();
|
|
159
|
+
for (const change of pulls) {
|
|
160
|
+
const array = keyedArrayFor(change.path);
|
|
161
|
+
const path = array ?? change.path;
|
|
162
|
+
|
|
163
|
+
// One write per array, however many of its elements were missing.
|
|
164
|
+
const key = path.join('.');
|
|
165
|
+
if (written.has(key)) continue;
|
|
166
|
+
written.add(key);
|
|
167
|
+
|
|
168
|
+
const value = valueAt(remote, path);
|
|
169
|
+
// Absent from the raw document means the remote side only has it as a
|
|
170
|
+
// default, which the local side fills in identically. Nothing to write.
|
|
171
|
+
if (value === undefined) continue;
|
|
172
|
+
|
|
173
|
+
document.setIn([...path], value);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
await document.save();
|
|
177
|
+
return written.size;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Read a path out of the raw document, for handing to `setIn`. */
|
|
181
|
+
function valueAt(config: unknown, path: readonly string[]): unknown {
|
|
182
|
+
let value: unknown = config;
|
|
183
|
+
for (const step of path) {
|
|
184
|
+
if (!(typeof value === 'object' && value !== null)) return undefined;
|
|
185
|
+
value = (value as Record<string, unknown>)[step];
|
|
186
|
+
}
|
|
187
|
+
return value;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Send the local profile up, once it holds everything both sides had.
|
|
192
|
+
*
|
|
193
|
+
* Wholesale rather than key by key, and only *after* the pulls have been
|
|
194
|
+
* applied: at that point the local file is the union, so copying it up makes
|
|
195
|
+
* the remote one the union too. The remote copy has no comments of its own to
|
|
196
|
+
* lose — `uploadWorkspace` wrote it — so there is nothing finer-grained to
|
|
197
|
+
* preserve, and one PUT cannot half-apply a merge.
|
|
198
|
+
*/
|
|
199
|
+
export async function applyPushes(
|
|
200
|
+
localRoot: string,
|
|
201
|
+
remoteRoot: string,
|
|
202
|
+
profile: string,
|
|
203
|
+
changes: readonly Change[],
|
|
204
|
+
): Promise<boolean> {
|
|
205
|
+
if (!changes.some((change) => change.direction === 'push')) return false;
|
|
206
|
+
|
|
207
|
+
const text = await readWorkspaceFile(workspaceFiles(localRoot), profileKey(profile));
|
|
208
|
+
if (text === null) return false;
|
|
209
|
+
|
|
210
|
+
await writeWorkspaceFile(workspaceFiles(remoteRoot), profileKey(profile), text);
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The authored areas inside `data/` — skills and provider manifests.
|
|
216
|
+
*
|
|
217
|
+
* They ride the same allowlist a deploy uploads by, and for the reason that
|
|
218
|
+
* comment gives at length: the list is what keeps a credential store out of a
|
|
219
|
+
* bucket, and a second answer to "which files" is how the two drift. Compared
|
|
220
|
+
* by content, since they are documents rather than config, and differing
|
|
221
|
+
* content is a conflict like any other.
|
|
222
|
+
*/
|
|
223
|
+
export interface BlobSync {
|
|
224
|
+
readonly key: string;
|
|
225
|
+
readonly direction: 'pull' | 'push' | 'conflict';
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export async function planBlobs(localRoot: string, remoteRoot: string): Promise<BlobSync[]> {
|
|
229
|
+
const read = async (root: string): Promise<Map<string, Uint8Array>> => {
|
|
230
|
+
const files = workspaceFiles(root);
|
|
231
|
+
const found = new Map<string, Uint8Array>();
|
|
232
|
+
|
|
233
|
+
for (const entry of await files.list('')) {
|
|
234
|
+
// `undefined` profile: every profile's authored areas, since sync is
|
|
235
|
+
// scoped to the target and not to one of them.
|
|
236
|
+
if (!isWorkspaceConfig(entry.key) || entry.key.endsWith('.yaml')) continue;
|
|
237
|
+
const bytes = await files.get(entry.key);
|
|
238
|
+
if (bytes !== null) found.set(entry.key, bytes);
|
|
239
|
+
}
|
|
240
|
+
return found;
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const here = await read(localRoot);
|
|
244
|
+
const there = await read(remoteRoot);
|
|
245
|
+
const equal = (a: Uint8Array, b: Uint8Array): boolean =>
|
|
246
|
+
a.length === b.length && a.every((byte, index) => byte === b[index]);
|
|
247
|
+
|
|
248
|
+
return [...new Set([...here.keys(), ...there.keys()])].sort().flatMap((key): BlobSync[] => {
|
|
249
|
+
const local = here.get(key);
|
|
250
|
+
const remote = there.get(key);
|
|
251
|
+
if (local === undefined) return [{ key, direction: 'pull' }];
|
|
252
|
+
if (remote === undefined) return [{ key, direction: 'push' }];
|
|
253
|
+
return equal(local, remote) ? [] : [{ key, direction: 'conflict' }];
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export async function applyBlobs(
|
|
258
|
+
localRoot: string,
|
|
259
|
+
remoteRoot: string,
|
|
260
|
+
blobs: readonly BlobSync[],
|
|
261
|
+
): Promise<number> {
|
|
262
|
+
let copied = 0;
|
|
263
|
+
|
|
264
|
+
for (const blob of blobs) {
|
|
265
|
+
const from = blob.direction === 'pull' ? remoteRoot : localRoot;
|
|
266
|
+
const to = blob.direction === 'pull' ? localRoot : remoteRoot;
|
|
267
|
+
|
|
268
|
+
const bytes = await workspaceFiles(from).get(blob.key);
|
|
269
|
+
if (bytes === null) continue;
|
|
270
|
+
|
|
271
|
+
await workspaceFiles(to).put(blob.key, bytes, { contentType: 'application/octet-stream' });
|
|
272
|
+
copied += 1;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return copied;
|
|
276
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { Config } from '#profile';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What differs between a workspace and a target's copy of it.
|
|
5
|
+
*
|
|
6
|
+
* A deployed endpoint reads its config out of a bucket, and `deploy` puts it
|
|
7
|
+
* there — so from the moment of the first deploy there are two copies of every
|
|
8
|
+
* profile. They are supposed to agree. When they do not, one of them holds
|
|
9
|
+
* something the other has lost, and until now nothing could say which or bring
|
|
10
|
+
* it back.
|
|
11
|
+
*
|
|
12
|
+
* This file only *finds* the difference. Deciding it and writing it are
|
|
13
|
+
* `sync-apply.ts`, and choosing which bucket to compare against is the command.
|
|
14
|
+
* Split that way because the diff is the part worth testing exhaustively and
|
|
15
|
+
* the only part with no I/O in it.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Which side is missing what, or that both have it and disagree. */
|
|
19
|
+
export type Direction = 'pull' | 'push' | 'conflict';
|
|
20
|
+
|
|
21
|
+
export interface Change {
|
|
22
|
+
/** Where in the config, e.g. `['targets', 'cloud']` or `['connections']`. */
|
|
23
|
+
readonly path: readonly string[];
|
|
24
|
+
readonly direction: Direction;
|
|
25
|
+
/** What remote holds. Absent when remote is the side that is missing it. */
|
|
26
|
+
readonly remote?: unknown;
|
|
27
|
+
/** What local holds. Absent when local is the side that is missing it. */
|
|
28
|
+
readonly local?: unknown;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Arrays whose elements are records rather than an ordered list.
|
|
33
|
+
*
|
|
34
|
+
* `connections` is a set of accounts keyed by `provider.id`; comparing it
|
|
35
|
+
* positionally would call a reordered file a conflict and, worse, would call an
|
|
36
|
+
* *added* connection a change to whichever one now sits at that index. The key
|
|
37
|
+
* function is what makes "personal gained a mailbox" a different fact from
|
|
38
|
+
* "personal's third connection changed".
|
|
39
|
+
*
|
|
40
|
+
* `identity` is deliberately absent. Its declaration order is meaningful — the
|
|
41
|
+
* first entry of a kind is the one to reach for — so it is an ordered list and
|
|
42
|
+
* compares as one.
|
|
43
|
+
*/
|
|
44
|
+
const KEYED: Record<string, (item: Record<string, unknown>) => string> = {
|
|
45
|
+
connections: (item) => `${String(item['provider'])}.${String(item['id'])}`,
|
|
46
|
+
'policy.allow': (item) => String(item['capability']),
|
|
47
|
+
'policy.deny': (item) => String(item['capability']),
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
51
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
52
|
+
|
|
53
|
+
const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Every difference between one profile's two copies.
|
|
57
|
+
*
|
|
58
|
+
* Recursive over objects so a change reports at the narrowest path that
|
|
59
|
+
* describes it: two profiles differing only in `auth.authorization` produce
|
|
60
|
+
* that path, not `auth`, and applying it cannot take `token_ref` with it.
|
|
61
|
+
*/
|
|
62
|
+
export function diffConfigs(local: Config | undefined, remote: Config | undefined): Change[] {
|
|
63
|
+
if (local === undefined && remote === undefined) return [];
|
|
64
|
+
|
|
65
|
+
// A whole profile that only one side has. Reported at the root, because the
|
|
66
|
+
// unit that has to be copied is the file.
|
|
67
|
+
if (local === undefined) return [{ path: [], direction: 'pull', remote }];
|
|
68
|
+
if (remote === undefined) return [{ path: [], direction: 'push', local }];
|
|
69
|
+
|
|
70
|
+
return walk([], local as unknown, remote as unknown);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function walk(path: readonly string[], local: unknown, remote: unknown): Change[] {
|
|
74
|
+
if (same(local, remote)) return [];
|
|
75
|
+
|
|
76
|
+
if (local === undefined) return [{ path, direction: 'pull', remote }];
|
|
77
|
+
if (remote === undefined) return [{ path, direction: 'push', local }];
|
|
78
|
+
|
|
79
|
+
const key = path.join('.');
|
|
80
|
+
const keyOf = KEYED[key];
|
|
81
|
+
if (keyOf && Array.isArray(local) && Array.isArray(remote)) {
|
|
82
|
+
return walkKeyed(path, keyOf, local, remote);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (isRecord(local) && isRecord(remote)) {
|
|
86
|
+
const keys = [...new Set([...Object.keys(local), ...Object.keys(remote)])].sort();
|
|
87
|
+
return keys.flatMap((name) => walk([...path, name], local[name], remote[name]));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Two scalars, or two ordered arrays, that are not equal. There is no
|
|
91
|
+
// narrower path to report and no way to have both.
|
|
92
|
+
return [{ path, direction: 'conflict', local, remote }];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A keyed array, compared as the set of records it is.
|
|
97
|
+
*
|
|
98
|
+
* Each element reports under `path.key` — `connections.gmail.work` — so a
|
|
99
|
+
* missing account names itself in the output instead of appearing as an index.
|
|
100
|
+
* Applying still rewrites the whole array, because a YAML sequence has no
|
|
101
|
+
* addressable slot for "the element whose provider is gmail".
|
|
102
|
+
*/
|
|
103
|
+
function walkKeyed(
|
|
104
|
+
path: readonly string[],
|
|
105
|
+
keyOf: (item: Record<string, unknown>) => string,
|
|
106
|
+
local: readonly unknown[],
|
|
107
|
+
remote: readonly unknown[],
|
|
108
|
+
): Change[] {
|
|
109
|
+
const index = (items: readonly unknown[]): Map<string, unknown> =>
|
|
110
|
+
new Map(items.filter(isRecord).map((item) => [keyOf(item), item]));
|
|
111
|
+
|
|
112
|
+
const here = index(local);
|
|
113
|
+
const there = index(remote);
|
|
114
|
+
const names = [...new Set([...here.keys(), ...there.keys()])].sort();
|
|
115
|
+
|
|
116
|
+
return names.flatMap((name) => walk([...path, name], here.get(name), there.get(name)));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The array a change belongs to, when it belongs to one.
|
|
121
|
+
*
|
|
122
|
+
* `['connections', 'gmail.work']` is applied by rewriting `connections`, so
|
|
123
|
+
* both the writer and the renderer need to know where the element stops and the
|
|
124
|
+
* key begins. Longest prefix first, so `policy.allow` wins over `policy`.
|
|
125
|
+
*/
|
|
126
|
+
export function keyedArrayFor(path: readonly string[]): readonly string[] | undefined {
|
|
127
|
+
for (let depth = path.length - 1; depth > 0; depth--) {
|
|
128
|
+
if (path.slice(0, depth).join('.') in KEYED) return path.slice(0, depth);
|
|
129
|
+
}
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Whether a set of changes can be applied without being told which side wins. */
|
|
134
|
+
export function conflictsIn(changes: readonly Change[]): Change[] {
|
|
135
|
+
return changes.filter((change) => change.direction === 'conflict');
|
|
136
|
+
}
|
|
@@ -271,15 +271,16 @@ export async function openStorage(
|
|
|
271
271
|
* Two refs make the same two mistakes available twice over, and "undefined is
|
|
272
272
|
* not a string" from inside an S3 client is not a diagnosis.
|
|
273
273
|
*/
|
|
274
|
-
async function requireSecret(
|
|
274
|
+
export async function requireSecret(
|
|
275
275
|
secrets: SecretStore,
|
|
276
276
|
ref: string | undefined,
|
|
277
277
|
field: string,
|
|
278
278
|
target: string,
|
|
279
|
+
requiredBy = 'the s3 adapter',
|
|
279
280
|
): Promise<string> {
|
|
280
281
|
if (!ref) {
|
|
281
282
|
throw new ConfigError(
|
|
282
|
-
`${field} is required for
|
|
283
|
+
`${field} is required for ${requiredBy} — the value is a credential, so config names it ` +
|
|
283
284
|
'rather than carrying it.',
|
|
284
285
|
);
|
|
285
286
|
}
|
|
@@ -7,7 +7,8 @@ import {
|
|
|
7
7
|
type Config,
|
|
8
8
|
type TargetConfig,
|
|
9
9
|
} from '#profile';
|
|
10
|
-
import { ConfigDocument
|
|
10
|
+
import { ConfigDocument } from '#cli/config-edit.ts';
|
|
11
|
+
import { ensureSetupConnection, repairLines, repaired } from '#cli/config-repair.ts';
|
|
11
12
|
import { ok, print, style, warn } from '#cli/output.ts';
|
|
12
13
|
|
|
13
14
|
/**
|
|
@@ -63,17 +64,21 @@ export function deployedWorkspace(declared: TargetConfig): string | undefined {
|
|
|
63
64
|
* prefix: `data/personal/skills.detour/` is not `skills.d`, and the difference
|
|
64
65
|
* between matching it and not is a credential in a bucket.
|
|
65
66
|
*/
|
|
66
|
-
export function isWorkspaceConfig(key: string,
|
|
67
|
+
export function isWorkspaceConfig(key: string, profiles?: readonly string[]): boolean {
|
|
67
68
|
if (key === WORKSPACE_FILE) return true;
|
|
68
69
|
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
70
|
+
// A set rather than one name, because a deploy now sends every profile that
|
|
71
|
+
// declares the target rather than the single one it was told. `undefined`
|
|
72
|
+
// still means the whole workspace, and an *empty* set means nothing — which
|
|
73
|
+
// is a distinction a bare string could not make.
|
|
74
|
+
const wanted = profiles === undefined ? undefined : new Set(profiles);
|
|
75
|
+
|
|
72
76
|
const owner = authoredAreaOwner(key);
|
|
73
|
-
if (owner !== null) return
|
|
77
|
+
if (owner !== null) return wanted === undefined || wanted.has(owner);
|
|
74
78
|
|
|
75
79
|
if (!key.startsWith('profiles/') || !key.endsWith('.yaml')) return false;
|
|
76
|
-
|
|
80
|
+
const name = key.slice('profiles/'.length, -'.yaml'.length);
|
|
81
|
+
return wanted === undefined || wanted.has(name);
|
|
77
82
|
}
|
|
78
83
|
|
|
79
84
|
/**
|
|
@@ -119,10 +124,12 @@ function authoredAreaOwner(key: string): string | null {
|
|
|
119
124
|
*/
|
|
120
125
|
export async function repairSetupSurface(
|
|
121
126
|
workspaceRoot: string,
|
|
122
|
-
|
|
127
|
+
profiles: readonly string[] | undefined,
|
|
123
128
|
): Promise<void> {
|
|
129
|
+
const wanted = profiles === undefined ? undefined : new Set(profiles);
|
|
130
|
+
|
|
124
131
|
for (const name of await listProfiles(workspaceRoot)) {
|
|
125
|
-
if (
|
|
132
|
+
if (wanted !== undefined && !wanted.has(name)) continue;
|
|
126
133
|
|
|
127
134
|
try {
|
|
128
135
|
const document = await ConfigDocument.open(workspaceRoot, name);
|
|
@@ -150,14 +157,14 @@ export async function repairSetupSurface(
|
|
|
150
157
|
export async function uploadWorkspace(
|
|
151
158
|
root: string,
|
|
152
159
|
destination: string,
|
|
153
|
-
|
|
160
|
+
profiles: readonly string[] | undefined,
|
|
154
161
|
): Promise<void> {
|
|
155
162
|
const local = workspaceFiles(root);
|
|
156
163
|
const remote = workspaceFiles(destination);
|
|
157
164
|
|
|
158
165
|
let copied = 0;
|
|
159
166
|
for (const entry of await local.list('')) {
|
|
160
|
-
if (!isWorkspaceConfig(entry.key,
|
|
167
|
+
if (!isWorkspaceConfig(entry.key, profiles)) continue;
|
|
161
168
|
|
|
162
169
|
const bytes = await local.get(entry.key);
|
|
163
170
|
if (bytes === null) continue;
|
|
@@ -202,6 +209,6 @@ export async function publishWorkspace(input: {
|
|
|
202
209
|
const destination = deployedWorkspace(declared);
|
|
203
210
|
if (!destination) return null;
|
|
204
211
|
|
|
205
|
-
await uploadWorkspace(input.workspaceRoot, destination, input.profile);
|
|
212
|
+
await uploadWorkspace(input.workspaceRoot, destination, [input.profile]);
|
|
206
213
|
return destination;
|
|
207
214
|
}
|
package/src/dispatch/dispatch.ts
CHANGED
|
@@ -241,7 +241,7 @@ export class Dispatcher {
|
|
|
241
241
|
return (outcome = deny(
|
|
242
242
|
'denied_connection_unauthorized',
|
|
243
243
|
record.status === 'unauthorized'
|
|
244
|
-
? `Connection ${request.connectionKey} has no valid credential.
|
|
244
|
+
? `Connection ${request.connectionKey} has no valid credential. Connecting it again for this profile and target would store one.`
|
|
245
245
|
: `Connection ${request.connectionKey} is disabled.`,
|
|
246
246
|
));
|
|
247
247
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { parseDocument } from 'yaml';
|
|
2
|
+
import { readWorkspaceFile, workspaceFiles, writeWorkspaceFile } from './files.ts';
|
|
3
|
+
import { workspaceSchema, type DeploymentRecord } from './schema.ts';
|
|
4
|
+
import { WORKSPACE_FILE, readWorkspace } from './workspace.ts';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The workspace's record of where its deployments live.
|
|
8
|
+
*
|
|
9
|
+
* A target is declared by a profile, and that declaration is what every command
|
|
10
|
+
* resolves from. This is the index beside it, and the distinction is the whole
|
|
11
|
+
* design: a profile file rewritten by hand or by a tool took a live Cloud Run
|
|
12
|
+
* service, its bucket, and its credential store out of reach in one edit,
|
|
13
|
+
* because the four lines naming them were the only copy. The service was still
|
|
14
|
+
* running. Nothing could find it.
|
|
15
|
+
*
|
|
16
|
+
* So the record is kept where the thing it describes is not: one level up, in
|
|
17
|
+
* `lanes-link.yaml`. `sync targets` reads it to know which bucket to open, and
|
|
18
|
+
* nothing else reads it at all — an index that starts being resolved from is a
|
|
19
|
+
* second source of truth, which is the failure ADR-037 spent a release
|
|
20
|
+
* removing (ADR-044).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Every deployment the workspace has recorded. Empty for a workspace with none. */
|
|
24
|
+
export async function readDeployments(workspaceRoot: string): Promise<DeploymentRecord[]> {
|
|
25
|
+
// A workspace file that will not parse is not a reason to fail a recovery:
|
|
26
|
+
// the caller has other ways to find a target, and this is the cheapest.
|
|
27
|
+
try {
|
|
28
|
+
return (await readWorkspace(workspaceRoot))?.deployments ?? [];
|
|
29
|
+
} catch {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** What the workspace knows about one target, if anything. */
|
|
35
|
+
export async function findDeployment(
|
|
36
|
+
workspaceRoot: string,
|
|
37
|
+
target: string,
|
|
38
|
+
): Promise<DeploymentRecord | undefined> {
|
|
39
|
+
return (await readDeployments(workspaceRoot)).find((entry) => entry.target === target);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Record a deployment, replacing any earlier entry for the same target.
|
|
44
|
+
*
|
|
45
|
+
* Keyed by target rather than appended, because a target has one deployment by
|
|
46
|
+
* definition — a second entry would be a history, and a history is a thing to
|
|
47
|
+
* read wrong. Redeploying the same target to a new bucket should leave one
|
|
48
|
+
* record naming the new one.
|
|
49
|
+
*
|
|
50
|
+
* Merged into the existing entry so a field this caller does not know about —
|
|
51
|
+
* `primary`, on a redeploy that did not ask — is carried forward rather than
|
|
52
|
+
* dropped.
|
|
53
|
+
*
|
|
54
|
+
* Written through the YAML document API, so the comments in a workspace file an
|
|
55
|
+
* operator has annotated survive being indexed.
|
|
56
|
+
*/
|
|
57
|
+
export async function recordDeployment(
|
|
58
|
+
workspaceRoot: string,
|
|
59
|
+
entry: DeploymentRecord,
|
|
60
|
+
): Promise<void> {
|
|
61
|
+
const files = workspaceFiles(workspaceRoot);
|
|
62
|
+
const text = (await readWorkspaceFile(files, WORKSPACE_FILE)) ?? 'contract: 1\n';
|
|
63
|
+
|
|
64
|
+
const document = parseDocument(text);
|
|
65
|
+
const existing = await readDeployments(workspaceRoot);
|
|
66
|
+
const previous = existing.find((record) => record.target === entry.target);
|
|
67
|
+
|
|
68
|
+
const merged = [
|
|
69
|
+
...existing.filter((record) => record.target !== entry.target),
|
|
70
|
+
{ ...previous, ...entry },
|
|
71
|
+
].sort((a, b) => a.target.localeCompare(b.target));
|
|
72
|
+
|
|
73
|
+
document.setIn(['deployments'], merged);
|
|
74
|
+
|
|
75
|
+
// Validated before it lands, on the rendered tree rather than the input, so
|
|
76
|
+
// what is checked is what would be read back.
|
|
77
|
+
workspaceSchema.parse(document.toJSON());
|
|
78
|
+
|
|
79
|
+
await writeWorkspaceFile(files, WORKSPACE_FILE, String(document));
|
|
80
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { identifier } from './primitives.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Who the owner is, in this profile — the names, addresses, and handles they
|
|
6
|
+
* want used when something is written as them.
|
|
7
|
+
*
|
|
8
|
+
* A profile already says what is *reachable*: connections, credentials, what
|
|
9
|
+
* policy permits. It said nothing about *whose* they are. So an agent composing
|
|
10
|
+
* a message, opening a pull request, or signing off had to infer a name from
|
|
11
|
+
* whatever happened to be in the conversation, and it mixed them across
|
|
12
|
+
* profiles — the work signature on a personal mailbox, the wrong handle in a
|
|
13
|
+
* commit trailer. The information was knowable and simply not written down.
|
|
14
|
+
*
|
|
15
|
+
* Three shapes were possible and only this one is right:
|
|
16
|
+
*
|
|
17
|
+
* - **A list, not a map.** `names: [John, Semin]` cannot say *when* to use
|
|
18
|
+
* which, and the note is the whole point — an owner with two names has them
|
|
19
|
+
* for a reason. A flat list of `{kind, value, note}` mirrors `connections`,
|
|
20
|
+
* which is the other list in this file whose order a reader relies on.
|
|
21
|
+
* - **`kind` is free-form**, any `identifier`. Shipping an enum would mean a
|
|
22
|
+
* release every time someone wants `linkedin`, `pronouns`, or `signature`,
|
|
23
|
+
* and there is nothing this file could do with the knowledge that a value is
|
|
24
|
+
* an email that would be worth that. Rendering is generic and stays generic.
|
|
25
|
+
* - **`note` is prose, not a reference.** Binding an entry to a connection was
|
|
26
|
+
* the obvious alternative and is a trap: it puts a cross-reference into
|
|
27
|
+
* `assertReferentialIntegrity`, and then renaming a connection breaks config
|
|
28
|
+
* *load* — the profile stops opening because a name in a signature moved.
|
|
29
|
+
* The agent reading "use with the personal mailbox" gets it right without
|
|
30
|
+
* costing anything the day that mailbox is renamed.
|
|
31
|
+
*
|
|
32
|
+
* Declaration order is meaningful and preserved: the first entry of a kind is
|
|
33
|
+
* the one to reach for absent a reason, and the reader of the YAML sees the same
|
|
34
|
+
* order the agent is told.
|
|
35
|
+
*
|
|
36
|
+
* Not a secret, and worth being explicit about why, because this is the first
|
|
37
|
+
* block in a profile that holds the owner's own data rather than a pointer to
|
|
38
|
+
* it. A name and an address are disclosed by the first message of any mailbox
|
|
39
|
+
* this endpoint serves; withholding them here while serving the mailbox would
|
|
40
|
+
* be theatre. What is refused is a *credential* pasted into a value — a token
|
|
41
|
+
* beginning `ghp_` in a `github` entry trips `secret-detection.ts` like anything
|
|
42
|
+
* else in this file, which is that check working rather than getting in the way.
|
|
43
|
+
*/
|
|
44
|
+
export const identityEntrySchema = z.object({
|
|
45
|
+
/** `name`, `email`, `github` — or anything else the owner finds useful. */
|
|
46
|
+
kind: identifier,
|
|
47
|
+
value: z.string().min(1),
|
|
48
|
+
/**
|
|
49
|
+
* When this one applies, in the owner's words.
|
|
50
|
+
*
|
|
51
|
+
* Optional, and usually absent on the only entry of its kind: a profile with
|
|
52
|
+
* one address needs no note explaining which address to use. It earns its
|
|
53
|
+
* place the moment there are two.
|
|
54
|
+
*/
|
|
55
|
+
note: z.string().min(1).optional(),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
export const identitySchema = z.array(identityEntrySchema);
|
|
59
|
+
|
|
60
|
+
export type IdentityEntry = z.infer<typeof identityEntrySchema>;
|