@lanes-sh/link 0.3.0 → 0.3.2
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 +1 -0
- package/package.json +1 -1
- package/src/cli/commands/operate/inspect.ts +7 -6
- package/src/cli/commands/operate/outputs.ts +1 -1
- package/src/cli/commands/operate/policy.ts +7 -7
- package/src/cli/commands/operate/status.ts +108 -1
- package/src/cli/commands/profile/remove.ts +5 -5
- package/src/cli/commands/secrets.ts +6 -6
- package/src/cli/commands/sync.ts +262 -0
- package/src/cli/config-edit.ts +5 -0
- package/src/cli/dispatch-owner.ts +93 -0
- package/src/cli/main.ts +28 -63
- package/src/cli/nearest.ts +45 -0
- package/src/cli/runtime/open.ts +7 -2
- package/src/cli/selection.ts +55 -47
- package/src/cli/usage.ts +10 -2
- package/src/deployments/deploy.ts +57 -106
- package/src/deployments/discover.ts +103 -0
- package/src/deployments/prepare.ts +10 -4
- package/src/deployments/report.ts +117 -0
- package/src/deployments/servable.ts +3 -2
- package/src/deployments/serving.ts +165 -0
- package/src/deployments/sync-apply.ts +330 -0
- package/src/deployments/sync.ts +164 -0
- package/src/deployments/upload.ts +17 -11
- package/src/profile/deployments.ts +80 -0
- package/src/profile/index.ts +8 -0
- package/src/profile/schema.ts +36 -1
- package/src/profile/targets.ts +53 -0
- package/src/profile/workspace.ts +73 -0
- package/src/providers/google/shared/setup.ts +20 -5
- package/src/server/index.ts +1 -1
package/README.md
CHANGED
|
@@ -150,6 +150,7 @@ than being rebuilt.
|
|
|
150
150
|
- **[Connect your accounts](docs/connect.md)** — every provider, and what each one needs
|
|
151
151
|
- **[Add it to your agent](docs/clients.md)** — Claude Code, Codex, Claude Desktop, claude.ai, ChatGPT
|
|
152
152
|
- **[Deploy to your own cloud](docs/deploy.md)** — five commands to a URL
|
|
153
|
+
- **[Every command](docs/detailed/commands.md)** — arguments and flags, one entry each
|
|
153
154
|
- **[Full reference](docs/detailed/)** — architecture, configuration, the CLI, the security model,
|
|
154
155
|
writing a provider, and the decision records
|
|
155
156
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { credentialRefFor, formatPlan, planIsNoop, planReconcile } from '#registry';
|
|
2
|
-
import { announce, emit, fail, ok, print, warn } from '../../output.ts';
|
|
2
|
+
import { announce, announceProfile, emit, fail, ok, print, warn } from '../../output.ts';
|
|
3
3
|
import { staleNudge } from '../../release.ts';
|
|
4
|
-
import { openRuntime,
|
|
4
|
+
import { openRuntime, resolveProfileOnly, type GlobalFlags } from '../../runtime.ts';
|
|
5
5
|
import type { FetchLike } from '#deployments/knowledge.ts';
|
|
6
6
|
import { credentialAge, reportCapabilityDrift } from './findings.ts';
|
|
7
7
|
|
|
@@ -13,12 +13,13 @@ import { credentialAge, reportCapabilityDrift } from './findings.ts';
|
|
|
13
13
|
|
|
14
14
|
/** Static validation only. No external calls, no database, no credentials. */
|
|
15
15
|
export async function check(flags: GlobalFlags): Promise<void> {
|
|
16
|
-
const {
|
|
17
|
-
|
|
16
|
+
const { selection, config } = await resolveProfileOnly(flags);
|
|
17
|
+
announceProfile(selection);
|
|
18
18
|
|
|
19
19
|
// Reaching here means the loader accepted it: contract major, no credential
|
|
20
|
-
// values, referential integrity
|
|
21
|
-
|
|
20
|
+
// values, and referential integrity all passed. Not target resolution — this
|
|
21
|
+
// validates a file, and the file is the same whichever target reads it.
|
|
22
|
+
print(ok(`${selection.profilePath} is valid`));
|
|
22
23
|
print(
|
|
23
24
|
` ${config.connections.length} connection(s), ` +
|
|
24
25
|
`${config.policy.allow.length} allow rule(s), ${config.policy.deny.length} deny rule(s)`,
|
|
@@ -142,7 +142,7 @@ export async function outputs(flags: OutputsFlags): Promise<void> {
|
|
|
142
142
|
async function servable(workspaceRoot: string, target: string): Promise<string[]> {
|
|
143
143
|
const all = await listProfiles(workspaceRoot);
|
|
144
144
|
const cannot = new Set(
|
|
145
|
-
(await unservableProfiles({ workspaceRoot,
|
|
145
|
+
(await unservableProfiles({ workspaceRoot, profiles: undefined, target })).map(
|
|
146
146
|
(one) => one.profile,
|
|
147
147
|
),
|
|
148
148
|
);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { loadConfigFile } from '#profile';
|
|
2
2
|
import { ConfigDocument } from '../../config-edit.ts';
|
|
3
|
-
import { announce, heading, ok, print, style, table } from '../../output.ts';
|
|
4
|
-
import { resolveProfile, type GlobalFlags } from '../../runtime.ts';
|
|
3
|
+
import { announce, announceProfile, heading, ok, print, style, table } from '../../output.ts';
|
|
4
|
+
import { resolveProfile, resolveProfileOnly, type GlobalFlags } from '../../runtime.ts';
|
|
5
5
|
import { nextAfterEdit, publishProfileEdit } from '../../publish.ts';
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -12,8 +12,8 @@ import { nextAfterEdit, publishProfileEdit } from '../../publish.ts';
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
export async function policyList(flags: GlobalFlags): Promise<void> {
|
|
15
|
-
const {
|
|
16
|
-
|
|
15
|
+
const { selection, config } = await resolveProfileOnly(flags);
|
|
16
|
+
announceProfile(selection);
|
|
17
17
|
|
|
18
18
|
if (config.policy.allow.length === 0 && config.policy.deny.length === 0) {
|
|
19
19
|
print(style.dim('No rules. Default deny is in effect: nothing is reachable.'));
|
|
@@ -73,8 +73,8 @@ export async function policyRule(
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
export async function configShow(flags: GlobalFlags): Promise<void> {
|
|
76
|
-
const {
|
|
77
|
-
|
|
78
|
-
const { config } = await loadConfigFile(
|
|
76
|
+
const { selection } = await resolveProfileOnly(flags);
|
|
77
|
+
announceProfile(selection);
|
|
78
|
+
const { config } = await loadConfigFile(selection.profilePath);
|
|
79
79
|
print(JSON.stringify(config, null, 2));
|
|
80
80
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { listProfiles } from '#profile';
|
|
1
|
+
import { listProfiles, loadWorkspaceProfiles, resolveWorkspaceRoot } from '#profile';
|
|
2
2
|
import { oneProfile, visibleCapabilities } from '#server/mcp';
|
|
3
3
|
import { toPolicyDocument } from '#registry';
|
|
4
4
|
import { announce, emit, heading, print, style, table } from '../../output.ts';
|
|
@@ -32,6 +32,8 @@ export interface StatusFlags extends GlobalFlags {
|
|
|
32
32
|
* config-only command can say that much honestly.
|
|
33
33
|
*/
|
|
34
34
|
export async function status(flags: StatusFlags): Promise<void> {
|
|
35
|
+
if (flags.profile === undefined) return workspaceStatus(flags);
|
|
36
|
+
|
|
35
37
|
const runtime = await openRuntime(flags);
|
|
36
38
|
try {
|
|
37
39
|
const records = await runtime.state.connections.list();
|
|
@@ -131,3 +133,108 @@ export async function status(flags: StatusFlags): Promise<void> {
|
|
|
131
133
|
await runtime.close();
|
|
132
134
|
}
|
|
133
135
|
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* `lanes link status --target <t>` — the whole workspace at one target.
|
|
139
|
+
*
|
|
140
|
+
* The default, because the subject of this command is the endpoint and one
|
|
141
|
+
* endpoint serves every profile in the workspace (ADR-009, ADR-043). Naming a
|
|
142
|
+
* profile narrows it to the detailed view above; naming none is not a missing
|
|
143
|
+
* answer, it is the wider one.
|
|
144
|
+
*
|
|
145
|
+
* **Opens no store and makes no network call.** The view above already declines
|
|
146
|
+
* to probe, for the reasons in this file's header; this one additionally
|
|
147
|
+
* declines to open a target's adapters, so it stays instant and — more to the
|
|
148
|
+
* point — still answers for a target whose stores are unreachable, misdeclared,
|
|
149
|
+
* or gone. A summary that needs the deployment to be healthy cannot report that
|
|
150
|
+
* it is not.
|
|
151
|
+
*
|
|
152
|
+
* That is what makes the row it exists for legible: a target declared by one
|
|
153
|
+
* profile and not its sibling. From inside either profile that state is
|
|
154
|
+
* invisible, and it is what a vanished deployment looks like from the outside.
|
|
155
|
+
*/
|
|
156
|
+
async function workspaceStatus(flags: StatusFlags): Promise<void> {
|
|
157
|
+
const target = flags.target!;
|
|
158
|
+
const root = resolveWorkspaceRoot();
|
|
159
|
+
const workspace = await loadWorkspaceProfiles(root);
|
|
160
|
+
|
|
161
|
+
const profiles = workspace.loaded.map((entry) => ({
|
|
162
|
+
name: entry.profile,
|
|
163
|
+
declares: target in entry.config.targets,
|
|
164
|
+
connections: entry.config.connections.length,
|
|
165
|
+
deployment: deploymentIdentity(entry.config.targets[target]?.deploy),
|
|
166
|
+
}));
|
|
167
|
+
|
|
168
|
+
const declaring = profiles.filter((entry) => entry.declares);
|
|
169
|
+
|
|
170
|
+
// Distinct deployments, not the first one found. Two profiles declaring the
|
|
171
|
+
// same target name against different services is drift worth printing rather
|
|
172
|
+
// than a tie to break silently — and picking one would make the wrong half of
|
|
173
|
+
// the workspace look correctly configured.
|
|
174
|
+
const deployments = [
|
|
175
|
+
...new Map(
|
|
176
|
+
declaring
|
|
177
|
+
.filter((entry) => entry.deployment !== null)
|
|
178
|
+
.map((entry) => [JSON.stringify(entry.deployment), entry.deployment!] as const),
|
|
179
|
+
).values(),
|
|
180
|
+
];
|
|
181
|
+
|
|
182
|
+
return emit(
|
|
183
|
+
flags.json,
|
|
184
|
+
{ workspace: root, target, profiles, deployments, unreadable: workspace.unreadable },
|
|
185
|
+
() => {
|
|
186
|
+
print(style.dim(`workspace ${style.bold(root)} target ${style.bold(target)}`));
|
|
187
|
+
|
|
188
|
+
heading('Profiles');
|
|
189
|
+
table(
|
|
190
|
+
profiles.map((entry) => [
|
|
191
|
+
` ${style.bold(entry.name)}`,
|
|
192
|
+
entry.declares ? style.green(`${target} declared`) : style.yellow('not declared'),
|
|
193
|
+
style.dim(entry.declares ? `${entry.connections} connection(s)` : '—'),
|
|
194
|
+
]),
|
|
195
|
+
);
|
|
196
|
+
for (const bad of workspace.unreadable) {
|
|
197
|
+
print(` ${style.bold(bad.profile)} ${style.yellow(bad.reason)}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
heading('Endpoint');
|
|
201
|
+
if (declaring.length === 0) {
|
|
202
|
+
print(style.yellow(` No profile declares "${target}".`));
|
|
203
|
+
print(
|
|
204
|
+
style.dim(
|
|
205
|
+
' If it was deployed once, the deployment still exists and only the\n' +
|
|
206
|
+
` declaration is gone — recover it with: lanes link sync targets --target ${target}`,
|
|
207
|
+
),
|
|
208
|
+
);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (deployments.length === 0) {
|
|
213
|
+
print(style.dim(' No deployment — this target runs wherever the CLI does.'));
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
for (const deployment of deployments) {
|
|
218
|
+
const { platform, service, region } = deployment;
|
|
219
|
+
print(` ${platform} service ${style.bold(service)} in ${region}`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (deployments.length > 1) {
|
|
223
|
+
print(
|
|
224
|
+
style.yellow(
|
|
225
|
+
` ${deployments.length} profiles declare "${target}" against different services.`,
|
|
226
|
+
),
|
|
227
|
+
);
|
|
228
|
+
print(style.dim(' They are separate endpoints sharing a name. Check each profile.'));
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
print(
|
|
233
|
+
style.dim(
|
|
234
|
+
" the address is the platform's to assign — run: " +
|
|
235
|
+
`lanes link outputs --target ${target} --profile ${declaring[0]!.name}`,
|
|
236
|
+
),
|
|
237
|
+
);
|
|
238
|
+
},
|
|
239
|
+
);
|
|
240
|
+
}
|
|
@@ -12,12 +12,12 @@ import {
|
|
|
12
12
|
import { parseDocument } from 'yaml';
|
|
13
13
|
import { rm } from 'node:fs/promises';
|
|
14
14
|
import { terminalPrompter, type Prompter } from '../../prompt.ts';
|
|
15
|
-
import {
|
|
15
|
+
import { announceProfile, emit, fail, ok, print, style } from '../../output.ts';
|
|
16
16
|
import {
|
|
17
17
|
buildRegistryWithWorkspace,
|
|
18
18
|
openBlobStoreFor,
|
|
19
19
|
openSecretStoreFor,
|
|
20
|
-
|
|
20
|
+
resolveProfileOnly,
|
|
21
21
|
type GlobalFlags,
|
|
22
22
|
} from '../../runtime.ts';
|
|
23
23
|
import { removalPlan, renderPlan, type RemovalItem, type RemovalPlan } from './removal.ts';
|
|
@@ -250,10 +250,10 @@ export interface RemoveFlags extends GlobalFlags {
|
|
|
250
250
|
* in the tool.
|
|
251
251
|
*/
|
|
252
252
|
export async function removeProfile(name: string, flags: RemoveFlags): Promise<void> {
|
|
253
|
-
const {
|
|
254
|
-
|
|
253
|
+
const { selection, config } = await resolveProfileOnly({ ...flags, profile: name });
|
|
254
|
+
announceProfile(selection);
|
|
255
255
|
|
|
256
|
-
const root =
|
|
256
|
+
const root = selection.workspaceRoot;
|
|
257
257
|
const registry = await buildRegistryWithWorkspace(root, name);
|
|
258
258
|
const files = workspaceFiles(root);
|
|
259
259
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ConfigError } from '#profile';
|
|
2
2
|
import { assertValidSecretRef } from '#secrets';
|
|
3
|
-
import { announce, heading, ok, print, style } from '../output.ts';
|
|
4
|
-
import { openSecretStoreFor, resolveProfile, type GlobalFlags } from '../runtime.ts';
|
|
3
|
+
import { announce, announceProfile, heading, ok, print, style } from '../output.ts';
|
|
4
|
+
import { openSecretStoreFor, resolveProfile, resolveProfileOnly, type GlobalFlags } from '../runtime.ts';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* `lanes link secrets` — moving credential values between a profile's targets.
|
|
@@ -35,11 +35,11 @@ export async function secretsPush(flags: SecretsFlags): Promise<void> {
|
|
|
35
35
|
throw new ConfigError(`--from and --to are both "${flags.from}"; there is nothing to copy.`);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
const {
|
|
39
|
-
|
|
38
|
+
const { selection, config } = await resolveProfileOnly(flags);
|
|
39
|
+
announceProfile(selection);
|
|
40
40
|
|
|
41
|
-
const source = await openSecretStoreFor(config,
|
|
42
|
-
const destination = await openSecretStoreFor(config,
|
|
41
|
+
const source = await openSecretStoreFor(config, selection.workspaceRoot, flags.from);
|
|
42
|
+
const destination = await openSecretStoreFor(config, selection.workspaceRoot, flags.to);
|
|
43
43
|
|
|
44
44
|
const refs = await source.list();
|
|
45
45
|
if (refs.length === 0) {
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ConfigError,
|
|
3
|
+
findDeployment,
|
|
4
|
+
loadWorkspaceProfiles,
|
|
5
|
+
recordDeployment,
|
|
6
|
+
resolveWorkspaceRoot,
|
|
7
|
+
} from '#profile';
|
|
8
|
+
import {
|
|
9
|
+
applyBlobs,
|
|
10
|
+
applyPulls,
|
|
11
|
+
applyPushes,
|
|
12
|
+
planBlobs,
|
|
13
|
+
planProfile,
|
|
14
|
+
profilesInEither,
|
|
15
|
+
resolved,
|
|
16
|
+
type Prefer,
|
|
17
|
+
type ProfileSync,
|
|
18
|
+
} from '#deployments/sync-apply.ts';
|
|
19
|
+
import { conflictsIn, type Change } from '#deployments/sync.ts';
|
|
20
|
+
import { deployedWorkspace } from '#deployments/upload.ts';
|
|
21
|
+
import { discoverDeployments, holdsWorkspace } from '#deployments/discover.ts';
|
|
22
|
+
import { emit, heading, ok, print, style, waiting, warn } from '../output.ts';
|
|
23
|
+
import { confirm, isInteractive } from '../prompt.ts';
|
|
24
|
+
import type { GlobalFlags } from '../runtime.ts';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `lanes link sync targets` — the workspace and a target's copy of it, reconciled.
|
|
28
|
+
*
|
|
29
|
+
* A deploy leaves two copies of every profile: the one in the workspace and the
|
|
30
|
+
* one in the bucket the endpoint reads. They are meant to agree, and when they
|
|
31
|
+
* stop there was no way to find out, let alone to say which side had lost
|
|
32
|
+
* something. The reported case: a local profile was rewritten and lost its
|
|
33
|
+
* cloud target, `auth.authorization`, and six connections, while the bucket
|
|
34
|
+
* still held every one of them and the service went on answering.
|
|
35
|
+
*
|
|
36
|
+
* Union, and refuse where a union is impossible (ADR-044). Nothing here
|
|
37
|
+
* overwrites a value that exists on both sides — that is `--prefer`, asked for
|
|
38
|
+
* explicitly, because silently choosing one copy over the other is the failure
|
|
39
|
+
* this command was written in response to.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
export interface SyncFlags extends GlobalFlags {
|
|
43
|
+
readonly json?: boolean | undefined;
|
|
44
|
+
readonly dryRun?: boolean | undefined;
|
|
45
|
+
readonly from?: string | undefined;
|
|
46
|
+
readonly discover?: boolean | undefined;
|
|
47
|
+
readonly prefer?: string | undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parsePrefer(value: string | undefined): Prefer | undefined {
|
|
51
|
+
if (value === undefined) return undefined;
|
|
52
|
+
if (value !== 'local' && value !== 'remote') {
|
|
53
|
+
throw new ConfigError(`--prefer must be "local" or "remote", not "${value}"`);
|
|
54
|
+
}
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Where the target's copy lives, tried cheapest first.
|
|
60
|
+
*
|
|
61
|
+
* The order is the point. A declared target answers instantly and is right
|
|
62
|
+
* whenever anything is; the index answers instantly and is right when the
|
|
63
|
+
* declaration is what went missing; `--from` is for when the operator knows and
|
|
64
|
+
* the workspace does not; and discovery is the one that works from nothing, at
|
|
65
|
+
* the cost of a `gcloud` call per project.
|
|
66
|
+
*/
|
|
67
|
+
async function locateRemote(
|
|
68
|
+
root: string,
|
|
69
|
+
target: string,
|
|
70
|
+
flags: SyncFlags,
|
|
71
|
+
): Promise<{ workspace: string; how: string }> {
|
|
72
|
+
if (flags.from) {
|
|
73
|
+
if (!(await holdsWorkspace(flags.from))) {
|
|
74
|
+
throw new ConfigError(
|
|
75
|
+
`${flags.from} does not hold a workspace — no ${'lanes-link.yaml'} in it.\n` +
|
|
76
|
+
' Check the bucket name, or run with --discover to search for one.',
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
return { workspace: flags.from.replace(/\/$/, ''), how: '--from' };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
for (const entry of (await loadWorkspaceProfiles(root)).loaded) {
|
|
83
|
+
const declared = entry.config.targets[target];
|
|
84
|
+
const workspace = declared ? deployedWorkspace(declared) : undefined;
|
|
85
|
+
if (workspace) return { workspace, how: `declared by ${entry.profile}` };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const recorded = await findDeployment(root, target);
|
|
89
|
+
if (recorded) return { workspace: recorded.workspace, how: 'recorded by a previous deploy' };
|
|
90
|
+
|
|
91
|
+
if (flags.discover !== true) {
|
|
92
|
+
throw new ConfigError(
|
|
93
|
+
`Nothing here says where "${target}" lives.\n` +
|
|
94
|
+
' No profile declares it, and no deploy recorded it in lanes-link.yaml.\n\n' +
|
|
95
|
+
' If you know the bucket: lanes link sync targets --target ' +
|
|
96
|
+
`${target} --from gs://<bucket>\n` +
|
|
97
|
+
` If you do not: lanes link sync targets --target ${target} --discover`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const candidates = (
|
|
102
|
+
await waiting('searching your projects for a deployment', () =>
|
|
103
|
+
discoverDeployments(),
|
|
104
|
+
)
|
|
105
|
+
).filter((candidate) => candidate.workspace !== undefined);
|
|
106
|
+
|
|
107
|
+
if (candidates.length === 0) {
|
|
108
|
+
throw new ConfigError(
|
|
109
|
+
'Found no deployment holding a workspace in any project this login can see.\n' +
|
|
110
|
+
' Check you are logged in as the right account: gcloud auth list',
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
print('');
|
|
115
|
+
for (const candidate of candidates) {
|
|
116
|
+
print(` ${style.bold(candidate.service)} ${candidate.region} ${candidate.project}`);
|
|
117
|
+
print(style.dim(` workspace ${candidate.workspace}`));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// One is offered rather than chosen: adopting the wrong deployment would
|
|
121
|
+
// merge a stranger's config into this workspace.
|
|
122
|
+
const first = candidates[0]!;
|
|
123
|
+
if (candidates.length > 1 || !isInteractive()) {
|
|
124
|
+
throw new ConfigError(
|
|
125
|
+
`Found ${candidates.length} deployment(s). Name the one you mean:\n` +
|
|
126
|
+
` lanes link sync targets --target ${target} --from ${first.workspace}`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (!(await confirm(` Sync "${target}" against ${first.workspace}?`))) {
|
|
131
|
+
throw new ConfigError('Nothing was read or written.');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return { workspace: first.workspace!, how: 'discovered' };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const arrow = (direction: Change['direction']): string =>
|
|
138
|
+
direction === 'pull' ? style.green('←') : direction === 'push' ? style.cyan('→') : style.yellow('!');
|
|
139
|
+
|
|
140
|
+
const describe = (change: Change): string => {
|
|
141
|
+
const path = change.path.length === 0 ? 'the whole profile' : change.path.join('.');
|
|
142
|
+
if (change.direction === 'pull') return `${path} ${style.dim('missing locally')}`;
|
|
143
|
+
if (change.direction === 'push') return `${path} ${style.dim('missing remotely')}`;
|
|
144
|
+
return `${path} ${style.dim(`local ${short(change.local)} ≠ remote ${short(change.remote)}`)}`;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const short = (value: unknown): string => {
|
|
148
|
+
const text = typeof value === 'string' ? value : JSON.stringify(value);
|
|
149
|
+
return (text ?? 'nothing').length > 40 ? `${(text ?? '').slice(0, 37)}…` : (text ?? 'nothing');
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export async function syncTargets(flags: SyncFlags): Promise<void> {
|
|
153
|
+
const target = flags.target!;
|
|
154
|
+
const prefer = parsePrefer(flags.prefer);
|
|
155
|
+
const root = resolveWorkspaceRoot();
|
|
156
|
+
|
|
157
|
+
const { workspace: remote, how } = await locateRemote(root, target, flags);
|
|
158
|
+
|
|
159
|
+
const names = flags.profile ? [flags.profile] : await profilesInEither(root, remote);
|
|
160
|
+
const plans: ProfileSync[] = [];
|
|
161
|
+
for (const profile of names) plans.push(await planProfile(root, remote, profile, prefer));
|
|
162
|
+
|
|
163
|
+
const blobs = await planBlobs(root, remote);
|
|
164
|
+
const conflicts = [
|
|
165
|
+
...plans.flatMap((plan) => conflictsIn(plan.changes)),
|
|
166
|
+
...blobs.filter((blob) => blob.direction === 'conflict'),
|
|
167
|
+
];
|
|
168
|
+
|
|
169
|
+
const payload = {
|
|
170
|
+
workspace: root,
|
|
171
|
+
remote,
|
|
172
|
+
target,
|
|
173
|
+
profiles: plans.map((plan) => ({ profile: plan.profile, changes: plan.changes })),
|
|
174
|
+
blobs,
|
|
175
|
+
conflicts: conflicts.length,
|
|
176
|
+
applied: false,
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const render = (): void => {
|
|
180
|
+
print(style.dim(`workspace ${style.bold(root)} target ${style.bold(target)}`));
|
|
181
|
+
print(style.dim(`remote ${remote} (${how})`));
|
|
182
|
+
|
|
183
|
+
let anything = false;
|
|
184
|
+
for (const plan of plans) {
|
|
185
|
+
if (plan.changes.length === 0) continue;
|
|
186
|
+
anything = true;
|
|
187
|
+
|
|
188
|
+
heading(plan.profile);
|
|
189
|
+
for (const change of plan.changes) print(` ${arrow(change.direction)} ${describe(change)}`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (blobs.length > 0) {
|
|
193
|
+
anything = true;
|
|
194
|
+
heading('Skills and manifests');
|
|
195
|
+
for (const blob of blobs) print(` ${arrow(blob.direction)} ${blob.key}`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (!anything) print(ok('already in step — nothing to copy in either direction'));
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
if (conflicts.length > 0 && prefer === undefined) {
|
|
202
|
+
render();
|
|
203
|
+
print('');
|
|
204
|
+
throw new ConfigError(
|
|
205
|
+
`${conflicts.length} conflict(s): both copies hold these, and they disagree.\n` +
|
|
206
|
+
' Nothing was written. Re-run with --prefer local or --prefer remote,\n' +
|
|
207
|
+
' or edit one side so they agree.',
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (flags.dryRun === true) {
|
|
212
|
+
return emit(flags.json, payload, () => {
|
|
213
|
+
render();
|
|
214
|
+
print('');
|
|
215
|
+
print(style.dim(' --dry-run: nothing was written on either side.'));
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (flags.json !== true) render();
|
|
220
|
+
|
|
221
|
+
let pulled = 0;
|
|
222
|
+
let pushed = 0;
|
|
223
|
+
for (const plan of plans) {
|
|
224
|
+
const changes = resolved(plan.changes, prefer);
|
|
225
|
+
pulled += await applyPulls(root, remote, plan.profile, changes);
|
|
226
|
+
if (await applyPushes(root, remote, plan.profile, changes)) pushed += 1;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const copied = await applyBlobs(
|
|
230
|
+
root,
|
|
231
|
+
remote,
|
|
232
|
+
resolvedBlobs(blobs, prefer),
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
// Recorded now that a bucket has been confirmed to hold this target's
|
|
236
|
+
// workspace: the next recovery does not have to discover it again.
|
|
237
|
+
await recordDeployment(root, { target, workspace: remote });
|
|
238
|
+
|
|
239
|
+
return emit(flags.json, { ...payload, applied: true, pulled, pushed, copied }, () => {
|
|
240
|
+
print('');
|
|
241
|
+
if (pulled === 0 && pushed === 0 && copied === 0) return;
|
|
242
|
+
|
|
243
|
+
print(ok(`${pulled} key(s) pulled, ${pushed} profile(s) pushed, ${copied} file(s) copied`));
|
|
244
|
+
print(style.dim(` recorded ${remote} in lanes-link.yaml, so this is findable next time`));
|
|
245
|
+
if (pushed > 0) {
|
|
246
|
+
print(style.dim(` the endpoint re-reads its config on the next call — or run:`));
|
|
247
|
+
print(style.dim(` lanes link status --target ${target}`));
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** A conflicting file follows `--prefer` like a conflicting key does. */
|
|
253
|
+
function resolvedBlobs(
|
|
254
|
+
blobs: readonly { key: string; direction: 'pull' | 'push' | 'conflict' }[],
|
|
255
|
+
prefer: Prefer | undefined,
|
|
256
|
+
): { key: string; direction: 'pull' | 'push' }[] {
|
|
257
|
+
return blobs.flatMap((blob) => {
|
|
258
|
+
if (blob.direction !== 'conflict') return [{ key: blob.key, direction: blob.direction }];
|
|
259
|
+
if (prefer === undefined) return [];
|
|
260
|
+
return [{ key: blob.key, direction: prefer === 'remote' ? ('pull' as const) : ('push' as const) }];
|
|
261
|
+
});
|
|
262
|
+
}
|
package/src/cli/config-edit.ts
CHANGED
|
@@ -296,6 +296,11 @@ export function newWorkspaceTemplate(): string {
|
|
|
296
296
|
# A workspace holds one or more profiles, and one endpoint serves all of them:
|
|
297
297
|
# every call names the profile it means, with --profile. Profiles never share a
|
|
298
298
|
# database or a credential store, so what one holds is invisible to another.
|
|
299
|
+
#
|
|
300
|
+
# "deploy" adds a "deployments:" list here. It is an index, not configuration —
|
|
301
|
+
# nothing resolves from it. It records where a deployment lives so that losing
|
|
302
|
+
# the target block out of a profile does not lose the service, the bucket, and
|
|
303
|
+
# the credential store along with it. "lanes link sync targets" reads it.
|
|
299
304
|
contract: 1
|
|
300
305
|
`;
|
|
301
306
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import {
|
|
2
|
+
memoryForget,
|
|
3
|
+
memoryGet,
|
|
4
|
+
memoryList,
|
|
5
|
+
memoryWrite,
|
|
6
|
+
skillsAdd,
|
|
7
|
+
skillsList,
|
|
8
|
+
skillsRemove,
|
|
9
|
+
skillsShow,
|
|
10
|
+
vaultGet,
|
|
11
|
+
vaultKeyGenerate,
|
|
12
|
+
vaultList,
|
|
13
|
+
vaultRemove,
|
|
14
|
+
vaultSet,
|
|
15
|
+
type OwnerFlags,
|
|
16
|
+
} from './commands/owner.ts';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The three commands over the owner's own data: memory, skills, and the vault.
|
|
20
|
+
*
|
|
21
|
+
* Split out of `main.ts` for the reason the budget in `src/architecture.test.ts`
|
|
22
|
+
* exists to find, rather than to satisfy a line count. These three are one
|
|
23
|
+
* subject — what the owner put here themselves, as against what a provider
|
|
24
|
+
* holds on their behalf — they already live together in `commands/owner/`, and
|
|
25
|
+
* they are the only commands in the grammar sharing a flag shape of their own.
|
|
26
|
+
*
|
|
27
|
+
* `main.ts` keeps the grammar. This keeps one branch of it.
|
|
28
|
+
*/
|
|
29
|
+
export function dispatchOwner(
|
|
30
|
+
first: string,
|
|
31
|
+
second: string | undefined,
|
|
32
|
+
rest: readonly string[],
|
|
33
|
+
owner: OwnerFlags,
|
|
34
|
+
program: string,
|
|
35
|
+
): Promise<void> | void {
|
|
36
|
+
switch (first) {
|
|
37
|
+
case 'memory':
|
|
38
|
+
switch (second) {
|
|
39
|
+
case 'list':
|
|
40
|
+
case undefined:
|
|
41
|
+
return memoryList(owner);
|
|
42
|
+
case 'get':
|
|
43
|
+
return memoryGet(rest[0], owner);
|
|
44
|
+
case 'write':
|
|
45
|
+
return memoryWrite(rest[0], owner);
|
|
46
|
+
case 'forget':
|
|
47
|
+
return memoryForget(rest[0], owner);
|
|
48
|
+
default:
|
|
49
|
+
throw new Error(`Unknown: ${program} memory ${second}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
case 'skills':
|
|
53
|
+
switch (second) {
|
|
54
|
+
case 'list':
|
|
55
|
+
case undefined:
|
|
56
|
+
return skillsList(owner);
|
|
57
|
+
case 'show':
|
|
58
|
+
return skillsShow(rest[0], owner);
|
|
59
|
+
case 'add':
|
|
60
|
+
return skillsAdd(rest[0], owner);
|
|
61
|
+
case 'remove':
|
|
62
|
+
return skillsRemove(rest[0], owner);
|
|
63
|
+
default:
|
|
64
|
+
throw new Error(`Unknown: ${program} skills ${second}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
case 'vault':
|
|
68
|
+
switch (second) {
|
|
69
|
+
case 'list':
|
|
70
|
+
case undefined:
|
|
71
|
+
return vaultList(owner);
|
|
72
|
+
case 'get':
|
|
73
|
+
return vaultGet(rest[0], owner);
|
|
74
|
+
case 'set':
|
|
75
|
+
return vaultSet(rest[0], owner);
|
|
76
|
+
case 'remove':
|
|
77
|
+
return vaultRemove(rest[0], owner);
|
|
78
|
+
case 'key':
|
|
79
|
+
if (rest[0] !== 'generate') {
|
|
80
|
+
throw new Error(`Usage: ${program} vault key generate`);
|
|
81
|
+
}
|
|
82
|
+
return vaultKeyGenerate(owner);
|
|
83
|
+
default:
|
|
84
|
+
throw new Error(`Unknown: ${program} vault ${second}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
default:
|
|
88
|
+
// Unreachable: `main.ts` narrows to the three above before calling. Kept
|
|
89
|
+
// so that adding a fourth case there and forgetting it here is a thrown
|
|
90
|
+
// error rather than a command that silently does nothing.
|
|
91
|
+
throw new Error(`Unknown: ${program} ${first}`);
|
|
92
|
+
}
|
|
93
|
+
}
|