@lanes-sh/link 0.5.4 → 0.6.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/instructions/agents/lanes-link-scout.md +1 -1
- package/instructions/skills/lanes-link/SKILL.md +33 -29
- package/package.json +2 -1
- package/src/cli/commands/connect/target-note.ts +2 -2
- package/src/cli/commands/knowledge/index.ts +14 -34
- package/src/cli/commands/mcp/register.ts +1 -1
- package/src/cli/commands/operate/dashboard.ts +2 -2
- package/src/cli/commands/operate/inspect.ts +5 -1
- package/src/cli/commands/operate/migrate.ts +85 -1
- package/src/cli/commands/operate/outputs.ts +7 -22
- package/src/cli/commands/operate/status.ts +84 -69
- package/src/cli/commands/operate/tools.ts +1 -1
- package/src/cli/commands/profile/removal.ts +36 -25
- package/src/cli/commands/profile/remove.ts +5 -2
- package/src/cli/commands/profile.ts +56 -40
- package/src/cli/commands/sync.ts +94 -162
- package/src/cli/commands/target.ts +115 -74
- package/src/cli/commands/update.ts +56 -1
- package/src/cli/config-edit.ts +47 -16
- package/src/cli/endpoint-url.ts +3 -3
- package/src/cli/main.ts +9 -7
- package/src/cli/migrate-plan.ts +160 -0
- package/src/cli/publish.ts +4 -2
- package/src/cli/runtime/open.ts +19 -10
- package/src/cli/runtime/select.ts +69 -22
- package/src/cli/selection-require.ts +79 -0
- package/src/cli/selection.ts +44 -92
- package/src/cli/workspace-migrate.ts +263 -0
- package/src/deployments/bootstrap.ts +31 -11
- package/src/deployments/deploy.ts +103 -27
- package/src/deployments/knowledge.ts +5 -2
- package/src/deployments/prepare.ts +3 -1
- package/src/deployments/serving.ts +19 -15
- package/src/deployments/upload.ts +10 -1
- package/src/profile/deployments.ts +64 -53
- package/src/profile/index.ts +22 -6
- package/src/profile/legacy.ts +92 -0
- package/src/profile/load.ts +12 -28
- package/src/profile/registry.ts +182 -0
- package/src/profile/schema.ts +162 -110
- package/src/profile/targets.ts +62 -91
- package/src/profile/testing.ts +78 -0
- package/src/profile/workspace.ts +11 -25
- package/src/server/dashboard.ts +4 -1
- package/src/server/harness.ts +1 -6
- package/src/cli/commands/profile/declare.ts +0 -154
- package/src/deployments/servable.ts +0 -82
- package/src/deployments/sync-apply.ts +0 -330
- package/src/deployments/sync.ts +0 -164
package/src/profile/workspace.ts
CHANGED
|
@@ -66,6 +66,16 @@ export interface ResolveOptions {
|
|
|
66
66
|
readonly profileFlag?: string | undefined;
|
|
67
67
|
readonly cwd?: string;
|
|
68
68
|
readonly env?: Record<string, string | undefined>;
|
|
69
|
+
/**
|
|
70
|
+
* The workspace to look in, when the caller has already resolved a target.
|
|
71
|
+
*
|
|
72
|
+
* A profile lives in exactly one target's workspace (ADR-052), so a command
|
|
73
|
+
* naming a target has to follow it before it can say whether the profile
|
|
74
|
+
* exists — the answer for `cloud` lives in a bucket and is nowhere on this
|
|
75
|
+
* disk. Absent means the local root, which is what the commands taking no
|
|
76
|
+
* target want.
|
|
77
|
+
*/
|
|
78
|
+
readonly root?: string;
|
|
69
79
|
}
|
|
70
80
|
|
|
71
81
|
/**
|
|
@@ -183,7 +193,7 @@ export async function listProfiles(workspaceRoot: string): Promise<string[]> {
|
|
|
183
193
|
*/
|
|
184
194
|
export async function resolveSelection(options: ResolveOptions = {}): Promise<ProfileSelection> {
|
|
185
195
|
const env = options.env ?? (process.env as Record<string, string | undefined>);
|
|
186
|
-
const workspaceRoot = resolveWorkspaceRoot(options);
|
|
196
|
+
const workspaceRoot = options.root ?? resolveWorkspaceRoot(options);
|
|
187
197
|
const profile = options.profileFlag;
|
|
188
198
|
|
|
189
199
|
if (!profile) throw noProfileNamed(workspaceRoot, await listProfiles(workspaceRoot), env);
|
|
@@ -296,27 +306,3 @@ export async function loadWorkspaceProfiles(workspaceRoot: string): Promise<Work
|
|
|
296
306
|
|
|
297
307
|
return { workspaceRoot, loaded, unreadable };
|
|
298
308
|
}
|
|
299
|
-
|
|
300
|
-
/**
|
|
301
|
-
* Which profiles declare each target name, across the whole workspace.
|
|
302
|
-
*
|
|
303
|
-
* A target is declared per profile and the endpoint serves every profile in the
|
|
304
|
-
* workspace (ADR-009), so "who has `cloud`" is a question with a list for an
|
|
305
|
-
* answer rather than a yes or no. The disagreement — one profile declaring it
|
|
306
|
-
* and its sibling not — is the state that reads as a target having vanished.
|
|
307
|
-
*/
|
|
308
|
-
export function targetsByName(
|
|
309
|
-
workspace: WorkspaceProfiles,
|
|
310
|
-
): ReadonlyMap<string, readonly string[]> {
|
|
311
|
-
const byName = new Map<string, string[]>();
|
|
312
|
-
|
|
313
|
-
for (const { profile, config } of workspace.loaded) {
|
|
314
|
-
for (const target of Object.keys(config.targets)) {
|
|
315
|
-
const profiles = byName.get(target);
|
|
316
|
-
if (profiles) profiles.push(profile);
|
|
317
|
-
else byName.set(target, [profile]);
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
return byName;
|
|
322
|
-
}
|
package/src/server/dashboard.ts
CHANGED
|
@@ -199,7 +199,10 @@ export async function handleDashboard(
|
|
|
199
199
|
profile: name,
|
|
200
200
|
profiles: generation.names(),
|
|
201
201
|
target: runtime.target,
|
|
202
|
-
|
|
202
|
+
// One, and it is the one being served. A revision serves exactly the
|
|
203
|
+
// target it was deployed as, and the config no longer carries a list of
|
|
204
|
+
// alternatives for it to offer (ADR-052).
|
|
205
|
+
targets: [runtime.target],
|
|
203
206
|
connections,
|
|
204
207
|
ownClients: Object.keys(runtime.config.oauth_apps),
|
|
205
208
|
});
|
package/src/server/harness.ts
CHANGED
|
@@ -37,15 +37,10 @@ export const TEST_TOKEN = 'llk_test_token_value';
|
|
|
37
37
|
|
|
38
38
|
export function configFor(profile: string, port: number, policy: string): Config {
|
|
39
39
|
return parseConfig(`
|
|
40
|
-
contract:
|
|
40
|
+
contract: 2
|
|
41
41
|
instance:
|
|
42
42
|
profile: ${profile}
|
|
43
|
-
default_target: local
|
|
44
43
|
port: ${port}
|
|
45
|
-
targets:
|
|
46
|
-
local:
|
|
47
|
-
credentials: { adapter: file, path: ./data/${profile}/credentials.enc }
|
|
48
|
-
storage: { adapter: filesystem, path: ./data/${profile} }
|
|
49
44
|
limits:
|
|
50
45
|
requests_per_minute: 1000
|
|
51
46
|
upstream_calls_per_minute: 1000
|
|
@@ -1,154 +0,0 @@
|
|
|
1
|
-
import { listProfiles, loadProfileConfig, ConfigError, type TargetConfig } from '#profile';
|
|
2
|
-
import { ask, isInteractive } from '../../prompt.ts';
|
|
3
|
-
import { print, style } from '../../output.ts';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* The targets a new profile declares, and where their values come from.
|
|
7
|
-
*
|
|
8
|
-
* `profile add` used to emit exactly one target — `local`, hard-coded in the
|
|
9
|
-
* template — while accepting a `--target` it dropped on the floor. So the
|
|
10
|
-
* command that creates a profile could not create the thing a profile is mostly
|
|
11
|
-
* *for*: somewhere to run.
|
|
12
|
-
*
|
|
13
|
-
* A `local` target is derivable and never asked about: a path under the
|
|
14
|
-
* workspace, an encrypted file beside it. Anything else names a cloud account,
|
|
15
|
-
* and this is the one place in the CLI where guessing those is actively harmful.
|
|
16
|
-
* `deploy`'s survey proposes a *fresh* project id (`lanes-link-<random>`),
|
|
17
|
-
* which is right for a first deploy and exactly wrong here — pressing return
|
|
18
|
-
* would build a second, separate deployment rather than adding this profile to
|
|
19
|
-
* the one the operator already has. So the defaults come from a sibling profile
|
|
20
|
-
* that already declares the same target name, and the service name is the only
|
|
21
|
-
* one derived, because that is the one field that has to differ per profile.
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
export interface DeclaredTarget {
|
|
25
|
-
readonly name: string;
|
|
26
|
-
/** Rendered YAML, indented two spaces, ready to sit under `targets:`. */
|
|
27
|
-
readonly block: string;
|
|
28
|
-
/** Which profile the values were taken from, when they were taken from one. */
|
|
29
|
-
readonly from?: string;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** The `local` target, which is the same shape in every profile. */
|
|
33
|
-
export function localBlock(profile: string, name = 'local'): string {
|
|
34
|
-
return (
|
|
35
|
-
` ${name}:\n` +
|
|
36
|
-
` credentials: { adapter: file, path: ./data/${profile}/credentials.enc }\n` +
|
|
37
|
-
` storage: { adapter: filesystem, path: ./data/${profile} }\n`
|
|
38
|
-
);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* A sibling profile's declaration of the same target name, if there is one.
|
|
43
|
-
*
|
|
44
|
-
* The first match wins rather than the operator being asked which — two
|
|
45
|
-
* profiles declaring `cloud` against different projects is possible and rare,
|
|
46
|
-
* and the values are shown and editable at the prompt either way.
|
|
47
|
-
*/
|
|
48
|
-
export async function siblingTarget(
|
|
49
|
-
workspaceRoot: string,
|
|
50
|
-
target: string,
|
|
51
|
-
exclude: string,
|
|
52
|
-
): Promise<{ profile: string; declared: TargetConfig } | null> {
|
|
53
|
-
for (const name of await listProfiles(workspaceRoot)) {
|
|
54
|
-
if (name === exclude) continue;
|
|
55
|
-
|
|
56
|
-
try {
|
|
57
|
-
const { config } = await loadProfileConfig(workspaceRoot, name);
|
|
58
|
-
const declared = config.targets[target];
|
|
59
|
-
if (declared) return { profile: name, declared };
|
|
60
|
-
} catch {
|
|
61
|
-
// A profile that will not parse is not a source of defaults, and saying
|
|
62
|
-
// so here would report a broken sibling as a problem with this command.
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
return null;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Ask for what a non-local target needs, pre-filled from a sibling.
|
|
71
|
-
*
|
|
72
|
-
* Refuses rather than inventing when there is nobody to ask and nothing to copy
|
|
73
|
-
* — the same bargain `connect --non-interactive` makes. A profile written with
|
|
74
|
-
* a guessed project id parses, deploys, and points at the wrong account.
|
|
75
|
-
*/
|
|
76
|
-
export async function askTarget(input: {
|
|
77
|
-
readonly target: string;
|
|
78
|
-
readonly profile: string;
|
|
79
|
-
readonly sibling: { profile: string; declared: TargetConfig } | null;
|
|
80
|
-
readonly nonInteractive?: boolean;
|
|
81
|
-
}): Promise<DeclaredTarget> {
|
|
82
|
-
const { target, profile, sibling } = input;
|
|
83
|
-
|
|
84
|
-
if (!sibling) {
|
|
85
|
-
throw new ConfigError(
|
|
86
|
-
`No profile in this workspace declares a target called "${target}", so there is\n` +
|
|
87
|
-
'nothing to copy its adapters from.\n' +
|
|
88
|
-
` lanes link profile add ${profile} --target local\n` +
|
|
89
|
-
` lanes link deploy --profile ${profile} --target ${target}\n` +
|
|
90
|
-
' The deploy surveys for what it does not know and writes the block.',
|
|
91
|
-
);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const declared = sibling.declared;
|
|
95
|
-
const project = declared.credentials.project ?? '';
|
|
96
|
-
const bucket = declared.storage.bucket ?? '';
|
|
97
|
-
|
|
98
|
-
// The one field that must differ: two profiles served by one project need two
|
|
99
|
-
// services, which is what makes them separately deployable.
|
|
100
|
-
const service = `lanes-link-${profile}-mcp`;
|
|
101
|
-
|
|
102
|
-
if (input.nonInteractive === true || !isInteractive()) {
|
|
103
|
-
return {
|
|
104
|
-
name: target,
|
|
105
|
-
block: cloudBlock({ target, project, bucket, service, declared }),
|
|
106
|
-
from: sibling.profile,
|
|
107
|
-
};
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
print(style.dim(` ${target} — defaults from profile "${sibling.profile}"`));
|
|
111
|
-
|
|
112
|
-
const answeredProject = (await ask(` Cloud project [${project}]: `)) || project;
|
|
113
|
-
const answeredBucket = (await ask(` Bucket [${bucket}]: `)) || bucket;
|
|
114
|
-
const answeredService = (await ask(` Service [${service}]: `)) || service;
|
|
115
|
-
|
|
116
|
-
return {
|
|
117
|
-
name: target,
|
|
118
|
-
block: cloudBlock({
|
|
119
|
-
target,
|
|
120
|
-
project: answeredProject,
|
|
121
|
-
bucket: answeredBucket,
|
|
122
|
-
service: answeredService,
|
|
123
|
-
declared,
|
|
124
|
-
}),
|
|
125
|
-
from: sibling.profile,
|
|
126
|
-
};
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/** One target's YAML, carrying over the adapters the sibling chose. */
|
|
130
|
-
function cloudBlock(input: {
|
|
131
|
-
target: string;
|
|
132
|
-
project: string;
|
|
133
|
-
bucket: string;
|
|
134
|
-
service: string;
|
|
135
|
-
declared: TargetConfig;
|
|
136
|
-
}): string {
|
|
137
|
-
const { declared } = input;
|
|
138
|
-
const deploy = declared.deploy;
|
|
139
|
-
|
|
140
|
-
return (
|
|
141
|
-
` ${input.target}:\n` +
|
|
142
|
-
` credentials: { adapter: ${declared.credentials.adapter}, project: ${input.project} }\n` +
|
|
143
|
-
` storage: { adapter: ${declared.storage.adapter}, bucket: ${input.bucket} }\n` +
|
|
144
|
-
` vault: { adapter: ${declared.vault?.adapter ?? 'secret'} }\n` +
|
|
145
|
-
(deploy
|
|
146
|
-
? ` deploy:\n` +
|
|
147
|
-
` platform: ${deploy.platform}\n` +
|
|
148
|
-
` project: ${input.project}\n` +
|
|
149
|
-
` region: ${deploy.region}\n` +
|
|
150
|
-
` service: ${input.service}\n` +
|
|
151
|
-
` access: ${deploy.access}\n`
|
|
152
|
-
: '')
|
|
153
|
-
);
|
|
154
|
-
}
|
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
import { listProfiles, loadProfileConfig } from '#profile';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Whether every profile a deploy sends can actually run on the target it sends them to.
|
|
5
|
-
*
|
|
6
|
-
* A deploy uploads config for a set of profiles and rolls a revision baked with
|
|
7
|
-
* one `LANES_LINK_TARGET`. The endpoint then opens *every* profile it finds in
|
|
8
|
-
* the bucket against that one target — see `openReconciled` — and a profile that
|
|
9
|
-
* does not declare it throws on the way up. The catch there closes the runtimes
|
|
10
|
-
* and rethrows, so the container exits and the revision never goes healthy.
|
|
11
|
-
*
|
|
12
|
-
* That is the whole failure, and its shape is what makes it worth a pre-flight:
|
|
13
|
-
* the profile at fault is usually the one just scaffolded, the operator was not
|
|
14
|
-
* thinking about the deployment when they made it, and the symptom is a revision
|
|
15
|
-
* that will not start — which reads as a problem with the deploy, the image, or
|
|
16
|
-
* the platform. Nothing points at the new profile. Refusing here costs one file
|
|
17
|
-
* read per profile and names it.
|
|
18
|
-
*
|
|
19
|
-
* Its own file, not a private function in `deploy.ts`, for the reason
|
|
20
|
-
* `handoff.ts` gives: `deploy.test.ts` does not import `deploy.ts`, and making
|
|
21
|
-
* it do so would pull the whole CLI runtime into a unit test.
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
export interface Unservable {
|
|
25
|
-
readonly profile: string;
|
|
26
|
-
/** What it does declare, so the refusal says how far off it is. */
|
|
27
|
-
readonly declares: readonly string[];
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* The profiles this deploy would send that the revision could not open.
|
|
32
|
-
*
|
|
33
|
-
* Scoped exactly as `uploadWorkspace` and `repairOwnerLayer` are — by the
|
|
34
|
-
* `--profile` flag, absent meaning the whole workspace — because the set that
|
|
35
|
-
* gets uploaded is the set that gets served, and checking a different one would
|
|
36
|
-
* be checking the wrong question.
|
|
37
|
-
*
|
|
38
|
-
* A profile that cannot be parsed is not reported here. It is already fatal
|
|
39
|
-
* further along, with a better message than this could give, and a YAML error
|
|
40
|
-
* dressed up as "cannot run on cloud" would send someone looking at their
|
|
41
|
-
* targets instead of their syntax.
|
|
42
|
-
*/
|
|
43
|
-
export async function unservableProfiles(input: {
|
|
44
|
-
readonly workspaceRoot: string;
|
|
45
|
-
readonly profiles: readonly string[] | undefined;
|
|
46
|
-
readonly target: string;
|
|
47
|
-
}): Promise<Unservable[]> {
|
|
48
|
-
const found: Unservable[] = [];
|
|
49
|
-
const wanted = input.profiles === undefined ? undefined : new Set(input.profiles);
|
|
50
|
-
|
|
51
|
-
for (const name of await listProfiles(input.workspaceRoot)) {
|
|
52
|
-
if (wanted !== undefined && !wanted.has(name)) continue;
|
|
53
|
-
|
|
54
|
-
let declared: string[];
|
|
55
|
-
try {
|
|
56
|
-
const { config } = await loadProfileConfig(input.workspaceRoot, name);
|
|
57
|
-
declared = Object.keys(config.targets);
|
|
58
|
-
} catch {
|
|
59
|
-
continue;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
if (!declared.includes(input.target)) found.push({ profile: name, declares: declared });
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
return found;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** The refusal, as a block, so the wording is testable without a deploy. */
|
|
69
|
-
export function unservableRefusal(found: readonly Unservable[], target: string): string {
|
|
70
|
-
const rows = found
|
|
71
|
-
.map((one) => ` ${one.profile} declares: ${one.declares.join(', ') || 'nothing'}`)
|
|
72
|
-
.join('\n');
|
|
73
|
-
|
|
74
|
-
return (
|
|
75
|
-
`${found.length} profile${found.length === 1 ? '' : 's'} would be uploaded that cannot run on "${target}":\n` +
|
|
76
|
-
`${rows}\n\n` +
|
|
77
|
-
' The endpoint opens every profile in the bucket with this target, so the\n' +
|
|
78
|
-
' revision would come up and refuse to start.\n' +
|
|
79
|
-
` lanes link profile add <name> --target ${target} declares it on a new one\n` +
|
|
80
|
-
' --profile <name> deploys just the one'
|
|
81
|
-
);
|
|
82
|
-
}
|
|
@@ -1,330 +0,0 @@
|
|
|
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, keyOfElement, 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 local = document.toJSON();
|
|
159
|
-
const written = new Set<string>();
|
|
160
|
-
|
|
161
|
-
for (const change of pulls) {
|
|
162
|
-
const array = keyedArrayFor(change.path);
|
|
163
|
-
|
|
164
|
-
if (array) {
|
|
165
|
-
// One write per array, however many of its elements were missing — and
|
|
166
|
-
// the value written is the *merge*, never the remote array.
|
|
167
|
-
//
|
|
168
|
-
// It used to be `setIn(array, remoteArray)`, which reads as "pull the
|
|
169
|
-
// connections" and means "replace the connections". A profile that had
|
|
170
|
-
// gained six accounts locally since the last deploy lost all six to a
|
|
171
|
-
// command whose entire purpose is not losing things. The tests missed it
|
|
172
|
-
// because every one of them had a local array that was a subset of the
|
|
173
|
-
// remote — the case where replacing and merging agree.
|
|
174
|
-
const key = array.join('.');
|
|
175
|
-
if (written.has(key)) continue;
|
|
176
|
-
written.add(key);
|
|
177
|
-
|
|
178
|
-
const merged = mergeKeyed(
|
|
179
|
-
array,
|
|
180
|
-
valueAt(local, array),
|
|
181
|
-
valueAt(remote, array),
|
|
182
|
-
pulls
|
|
183
|
-
.filter((one) => keyedArrayFor(one.path)?.join('.') === key)
|
|
184
|
-
.map((one) => one.path[array.length])
|
|
185
|
-
.filter((name): name is string => name !== undefined),
|
|
186
|
-
);
|
|
187
|
-
if (merged !== undefined) document.setIn([...array], merged);
|
|
188
|
-
continue;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
const value = valueAt(remote, change.path);
|
|
192
|
-
// Absent from the raw document means the remote side only has it as a
|
|
193
|
-
// default, which the local side fills in identically. Nothing to write.
|
|
194
|
-
if (value === undefined) continue;
|
|
195
|
-
|
|
196
|
-
document.setIn([...change.path], value);
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
await document.save();
|
|
200
|
-
return written.size;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
* The local array, with the named elements taken from the remote one.
|
|
205
|
-
*
|
|
206
|
-
* Element order is local's, with anything new appended, so a pull reads as a
|
|
207
|
-
* diff in the file rather than as a reshuffle. An element named in `wanted` and
|
|
208
|
-
* absent from the remote array is skipped rather than removed: the diff said it
|
|
209
|
-
* was missing locally, so it cannot also be missing remotely.
|
|
210
|
-
*/
|
|
211
|
-
function mergeKeyed(
|
|
212
|
-
arrayPath: readonly string[],
|
|
213
|
-
local: unknown,
|
|
214
|
-
remote: unknown,
|
|
215
|
-
wanted: readonly string[],
|
|
216
|
-
): unknown[] | undefined {
|
|
217
|
-
if (!Array.isArray(remote)) return undefined;
|
|
218
|
-
|
|
219
|
-
const merged = Array.isArray(local) ? [...(local as unknown[])] : [];
|
|
220
|
-
const keyOf = (item: unknown): string | undefined => keyOfElement(arrayPath, item);
|
|
221
|
-
|
|
222
|
-
for (const name of new Set(wanted)) {
|
|
223
|
-
const incoming = remote.find((item) => keyOf(item) === name);
|
|
224
|
-
if (incoming === undefined) continue;
|
|
225
|
-
|
|
226
|
-
const at = merged.findIndex((item) => keyOf(item) === name);
|
|
227
|
-
if (at >= 0) merged[at] = incoming;
|
|
228
|
-
else merged.push(incoming);
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
return merged;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/** Read a path out of the raw document, for handing to `setIn`. */
|
|
235
|
-
function valueAt(config: unknown, path: readonly string[]): unknown {
|
|
236
|
-
let value: unknown = config;
|
|
237
|
-
for (const step of path) {
|
|
238
|
-
if (!(typeof value === 'object' && value !== null)) return undefined;
|
|
239
|
-
value = (value as Record<string, unknown>)[step];
|
|
240
|
-
}
|
|
241
|
-
return value;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/**
|
|
245
|
-
* Send the local profile up, once it holds everything both sides had.
|
|
246
|
-
*
|
|
247
|
-
* Wholesale rather than key by key, and only *after* the pulls have been
|
|
248
|
-
* applied: at that point the local file is the union, so copying it up makes
|
|
249
|
-
* the remote one the union too. The remote copy has no comments of its own to
|
|
250
|
-
* lose — `uploadWorkspace` wrote it — so there is nothing finer-grained to
|
|
251
|
-
* preserve, and one PUT cannot half-apply a merge.
|
|
252
|
-
*/
|
|
253
|
-
export async function applyPushes(
|
|
254
|
-
localRoot: string,
|
|
255
|
-
remoteRoot: string,
|
|
256
|
-
profile: string,
|
|
257
|
-
changes: readonly Change[],
|
|
258
|
-
): Promise<boolean> {
|
|
259
|
-
if (!changes.some((change) => change.direction === 'push')) return false;
|
|
260
|
-
|
|
261
|
-
const text = await readWorkspaceFile(workspaceFiles(localRoot), profileKey(profile));
|
|
262
|
-
if (text === null) return false;
|
|
263
|
-
|
|
264
|
-
await writeWorkspaceFile(workspaceFiles(remoteRoot), profileKey(profile), text);
|
|
265
|
-
return true;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
/**
|
|
269
|
-
* The authored areas inside `data/` — skills and provider manifests.
|
|
270
|
-
*
|
|
271
|
-
* They ride the same allowlist a deploy uploads by, and for the reason that
|
|
272
|
-
* comment gives at length: the list is what keeps a credential store out of a
|
|
273
|
-
* bucket, and a second answer to "which files" is how the two drift. Compared
|
|
274
|
-
* by content, since they are documents rather than config, and differing
|
|
275
|
-
* content is a conflict like any other.
|
|
276
|
-
*/
|
|
277
|
-
export interface BlobSync {
|
|
278
|
-
readonly key: string;
|
|
279
|
-
readonly direction: 'pull' | 'push' | 'conflict';
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
export async function planBlobs(localRoot: string, remoteRoot: string): Promise<BlobSync[]> {
|
|
283
|
-
const read = async (root: string): Promise<Map<string, Uint8Array>> => {
|
|
284
|
-
const files = workspaceFiles(root);
|
|
285
|
-
const found = new Map<string, Uint8Array>();
|
|
286
|
-
|
|
287
|
-
for (const entry of await files.list('')) {
|
|
288
|
-
// `undefined` profile: every profile's authored areas, since sync is
|
|
289
|
-
// scoped to the target and not to one of them.
|
|
290
|
-
if (!isWorkspaceConfig(entry.key) || entry.key.endsWith('.yaml')) continue;
|
|
291
|
-
const bytes = await files.get(entry.key);
|
|
292
|
-
if (bytes !== null) found.set(entry.key, bytes);
|
|
293
|
-
}
|
|
294
|
-
return found;
|
|
295
|
-
};
|
|
296
|
-
|
|
297
|
-
const here = await read(localRoot);
|
|
298
|
-
const there = await read(remoteRoot);
|
|
299
|
-
const equal = (a: Uint8Array, b: Uint8Array): boolean =>
|
|
300
|
-
a.length === b.length && a.every((byte, index) => byte === b[index]);
|
|
301
|
-
|
|
302
|
-
return [...new Set([...here.keys(), ...there.keys()])].sort().flatMap((key): BlobSync[] => {
|
|
303
|
-
const local = here.get(key);
|
|
304
|
-
const remote = there.get(key);
|
|
305
|
-
if (local === undefined) return [{ key, direction: 'pull' }];
|
|
306
|
-
if (remote === undefined) return [{ key, direction: 'push' }];
|
|
307
|
-
return equal(local, remote) ? [] : [{ key, direction: 'conflict' }];
|
|
308
|
-
});
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
export async function applyBlobs(
|
|
312
|
-
localRoot: string,
|
|
313
|
-
remoteRoot: string,
|
|
314
|
-
blobs: readonly BlobSync[],
|
|
315
|
-
): Promise<number> {
|
|
316
|
-
let copied = 0;
|
|
317
|
-
|
|
318
|
-
for (const blob of blobs) {
|
|
319
|
-
const from = blob.direction === 'pull' ? remoteRoot : localRoot;
|
|
320
|
-
const to = blob.direction === 'pull' ? localRoot : remoteRoot;
|
|
321
|
-
|
|
322
|
-
const bytes = await workspaceFiles(from).get(blob.key);
|
|
323
|
-
if (bytes === null) continue;
|
|
324
|
-
|
|
325
|
-
await workspaceFiles(to).put(blob.key, bytes, { contentType: 'application/octet-stream' });
|
|
326
|
-
copied += 1;
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
return copied;
|
|
330
|
-
}
|