@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
|
@@ -82,8 +82,17 @@ export interface RemovalPlan {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
export interface PlanOptions {
|
|
85
|
-
/**
|
|
86
|
-
|
|
85
|
+
/**
|
|
86
|
+
* The target whose stores this plans against.
|
|
87
|
+
*
|
|
88
|
+
* Required now, and not a restriction: a profile lives in exactly one target
|
|
89
|
+
* (ADR-052), so there is no "all of them" left for this to mean. It used to be
|
|
90
|
+
* optional because a profile could declare several and removing it meant
|
|
91
|
+
* emptying each.
|
|
92
|
+
*/
|
|
93
|
+
readonly target: string;
|
|
94
|
+
/** That target's adapter set, from the workspace declaring it (ADR-052). */
|
|
95
|
+
readonly declared: TargetConfig;
|
|
87
96
|
readonly openSecrets: (target: string) => Promise<SecretStore>;
|
|
88
97
|
readonly openBlobs: (target: string, area?: string) => Promise<BlobStore>;
|
|
89
98
|
readonly readDefaultProfile?: (() => Promise<string | undefined>) | undefined;
|
|
@@ -110,14 +119,13 @@ export async function removalPlan(
|
|
|
110
119
|
const untouched: { target: string; refs: string[] }[] = [];
|
|
111
120
|
const warnings: string[] = [];
|
|
112
121
|
|
|
113
|
-
|
|
122
|
+
// One target, always. A profile lived in as many as it declared and this
|
|
123
|
+
// planned across all of them; it lives in exactly one now (ADR-052), and that
|
|
124
|
+
// one is whichever workspace the caller resolved to reach this file.
|
|
125
|
+
const names: string[] = [options.target];
|
|
126
|
+
const declared = options.declared;
|
|
114
127
|
|
|
115
128
|
for (const name of names) {
|
|
116
|
-
const declared = config.targets[name];
|
|
117
|
-
if (!declared) {
|
|
118
|
-
warnings.push(`Target "${name}" is not declared by this profile, so nothing was planned.`);
|
|
119
|
-
continue;
|
|
120
|
-
}
|
|
121
129
|
|
|
122
130
|
// A repository this profile keeps memory and skills in is not this
|
|
123
131
|
// command's to empty, and it is not reachable from here either: the routing
|
|
@@ -128,9 +136,9 @@ export async function removalPlan(
|
|
|
128
136
|
// `rm -r data/<profile>` used to be the whole of "what could this profile
|
|
129
137
|
// reach". So it is said before the operator confirms rather than discovered
|
|
130
138
|
// afterwards.
|
|
131
|
-
if (
|
|
139
|
+
if (config.knowledge) {
|
|
132
140
|
warnings.push(
|
|
133
|
-
`
|
|
141
|
+
`This profile keeps its memory and skills in ${config.knowledge.repo}. ` +
|
|
134
142
|
'Nothing here touches a repository, so they survive this removal — delete them there ' +
|
|
135
143
|
'if you want them gone.',
|
|
136
144
|
);
|
|
@@ -226,23 +234,26 @@ export async function removalPlan(
|
|
|
226
234
|
}
|
|
227
235
|
}
|
|
228
236
|
|
|
229
|
-
//
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
items.push({ target: null, kind: 'config', id: profilePath(root, profile) });
|
|
237
|
+
// Always, now. `--target` used to mean "decommission this one target and leave
|
|
238
|
+
// the profile behind", which was a coherent thing to want while a profile
|
|
239
|
+
// could be declared against several. It lives in exactly one (ADR-052), and
|
|
240
|
+
// the file itself is *in* that target's workspace — so emptying the target and
|
|
241
|
+
// keeping the profile would leave a config nothing can open, in a workspace it
|
|
242
|
+
// no longer belongs to.
|
|
243
|
+
const defaultProfile = await options.readDefaultProfile?.();
|
|
244
|
+
if (defaultProfile === profile) {
|
|
245
|
+
items.push({
|
|
246
|
+
target: null,
|
|
247
|
+
kind: 'workspace-key',
|
|
248
|
+
id: 'default_profile',
|
|
249
|
+
note: 'cleared, not repointed at whatever remains',
|
|
250
|
+
});
|
|
244
251
|
}
|
|
245
252
|
|
|
253
|
+
// Last. It is the only record of where everything else lives, so a failure
|
|
254
|
+
// before this point leaves data a later run can still find.
|
|
255
|
+
items.push({ target: null, kind: 'config', id: profilePath(root, profile) });
|
|
256
|
+
|
|
246
257
|
return { profile, items, untouched, warnings };
|
|
247
258
|
}
|
|
248
259
|
|
|
@@ -3,6 +3,7 @@ import type { BlobStore } from '#stores/blobs';
|
|
|
3
3
|
import {
|
|
4
4
|
ConfigError,
|
|
5
5
|
WORKSPACE_FILE,
|
|
6
|
+
openTarget,
|
|
6
7
|
readWorkspace,
|
|
7
8
|
readWorkspaceFile,
|
|
8
9
|
workspaceFiles,
|
|
@@ -250,15 +251,17 @@ export interface RemoveFlags extends GlobalFlags {
|
|
|
250
251
|
* in the tool.
|
|
251
252
|
*/
|
|
252
253
|
export async function removeProfile(name: string, flags: RemoveFlags): Promise<void> {
|
|
253
|
-
const { selection, config } = await resolveProfileOnly({ ...flags, profile: name });
|
|
254
|
+
const { selection, config, target } = await resolveProfileOnly({ ...flags, profile: name });
|
|
254
255
|
announceProfile(selection);
|
|
255
256
|
|
|
256
257
|
const root = selection.workspaceRoot;
|
|
257
258
|
const registry = await buildRegistryWithWorkspace(root, name);
|
|
258
259
|
const files = workspaceFiles(root);
|
|
260
|
+
const { declared } = await openTarget(root, target);
|
|
259
261
|
|
|
260
262
|
const plan = await removalPlan(config, root, name, registry, {
|
|
261
|
-
target
|
|
263
|
+
target,
|
|
264
|
+
declared,
|
|
262
265
|
openSecrets: (target) => openSecretStoreFor(config, root, target),
|
|
263
266
|
openBlobs: (target, area) => openBlobStoreFor(config, root, target, area),
|
|
264
267
|
readDefaultProfile: async () => (await readWorkspace(root))?.default_profile,
|
|
@@ -7,10 +7,13 @@ import {
|
|
|
7
7
|
listProfiles,
|
|
8
8
|
profilePath,
|
|
9
9
|
readWorkspace,
|
|
10
|
+
isRemoteWorkspace,
|
|
11
|
+
workspaceFiles,
|
|
12
|
+
writeWorkspaceFile,
|
|
13
|
+
resolveTargetWorkspace,
|
|
10
14
|
resolveWorkspaceRoot,
|
|
11
15
|
} from '#profile';
|
|
12
16
|
import { newProfileTemplate, newWorkspaceTemplate } from '../config-edit.ts';
|
|
13
|
-
import { askTarget, localBlock, siblingTarget } from './profile/declare.ts';
|
|
14
17
|
import { emit, ok, print, style, table } from '../output.ts';
|
|
15
18
|
|
|
16
19
|
/**
|
|
@@ -48,70 +51,80 @@ export interface ProfileListing {
|
|
|
48
51
|
/**
|
|
49
52
|
* Write a new profile, and the workspace file if this is the first one.
|
|
50
53
|
*
|
|
51
|
-
* The
|
|
54
|
+
* The target is the argument that used to be missing. `--target` was accepted
|
|
52
55
|
* and dropped here, and the template could only ever emit `local` — so the
|
|
53
56
|
* command reported success and produced a profile that could not reach the
|
|
54
57
|
* deployment the operator had just told it about.
|
|
58
|
+
*
|
|
59
|
+
* It now decides *where the file goes* rather than what is written in it: a
|
|
60
|
+
* profile lives in one target's workspace and declares nothing about it
|
|
61
|
+
* (ADR-052), so `--target cloud` writes into the bucket and the endpoint there
|
|
62
|
+
* serves it on its next reconcile.
|
|
55
63
|
*/
|
|
56
64
|
export async function createProfile(
|
|
57
65
|
name: string,
|
|
58
66
|
options: { targets: readonly string[]; nonInteractive?: boolean },
|
|
59
67
|
): Promise<ProfileCreated> {
|
|
60
|
-
const
|
|
68
|
+
const local = resolveWorkspaceRoot();
|
|
69
|
+
const target = options.targets[0]!;
|
|
70
|
+
|
|
71
|
+
// The workspace file before the target is resolved, not after. `profile add
|
|
72
|
+
// <name> --target local` on an empty directory is how a workspace comes into
|
|
73
|
+
// existence, and the target it names is declared *by* that file — so writing
|
|
74
|
+
// it second means resolving a target nothing has declared yet.
|
|
75
|
+
if (!isRemoteWorkspace(local) && !existsSync(join(local, WORKSPACE_FILE))) {
|
|
76
|
+
await mkdir(local, { recursive: true });
|
|
77
|
+
await writeFile(join(local, WORKSPACE_FILE), newWorkspaceTemplate(), { mode: 0o600 });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const root = await resolveTargetWorkspace(local, target);
|
|
61
81
|
const path = profilePath(root, name);
|
|
62
82
|
|
|
63
|
-
if (
|
|
83
|
+
if (await workspaceFiles(root).has(`profiles/${name}.yaml`)) {
|
|
84
|
+
throw new Error(`Profile "${name}" already exists at ${path}`);
|
|
85
|
+
}
|
|
64
86
|
|
|
65
|
-
|
|
87
|
+
// Only a directory needs making. A bucket has no directories, and the write
|
|
88
|
+
// that follows creates the key outright.
|
|
89
|
+
if (!isRemoteWorkspace(root)) await mkdir(join(root, 'profiles'), { recursive: true });
|
|
66
90
|
|
|
67
91
|
// Each profile gets its own port so two can serve at once without an
|
|
68
92
|
// operator having to think about it.
|
|
69
93
|
const existing = await listProfiles(root);
|
|
70
94
|
const port = FIRST_PORT + existing.length;
|
|
71
95
|
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const declared = await askTarget({
|
|
85
|
-
target,
|
|
86
|
-
profile: name,
|
|
87
|
-
sibling: await siblingTarget(root, target, name),
|
|
88
|
-
...(options.nonInteractive === true ? { nonInteractive: true } : {}),
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
blocks.push(declared.block);
|
|
92
|
-
if (declared.from) copiedFrom[target] = declared.from;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const workspaceFile = join(root, WORKSPACE_FILE);
|
|
96
|
-
if (!existsSync(workspaceFile)) {
|
|
97
|
-
await writeFile(workspaceFile, newWorkspaceTemplate(), { mode: 0o600 });
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
await writeFile(path, newProfileTemplate(name, port, blocks.join('')), { mode: 0o600 });
|
|
96
|
+
// The prompting that used to happen here is gone. A new profile had to be
|
|
97
|
+
// given an adapter block per target it declared, and for anything but `local`
|
|
98
|
+
// there was nothing safe to derive one from — so the command copied a
|
|
99
|
+
// sibling's, or asked. It declares no target now (ADR-052): it is written into
|
|
100
|
+
// the workspace of the target it was named with, and that workspace already
|
|
101
|
+
// says where its bytes go.
|
|
102
|
+
await writeWorkspaceFile(
|
|
103
|
+
workspaceFiles(root),
|
|
104
|
+
`profiles/${name}.yaml`,
|
|
105
|
+
newProfileTemplate(name, port),
|
|
106
|
+
);
|
|
101
107
|
|
|
102
|
-
return { name, path, port, targets: options.targets, copiedFrom };
|
|
108
|
+
return { name, path, port, targets: options.targets, copiedFrom: {} };
|
|
103
109
|
}
|
|
104
110
|
|
|
105
111
|
/**
|
|
106
|
-
* Every profile in
|
|
112
|
+
* Every profile in one target's workspace, and which one is the default.
|
|
113
|
+
*
|
|
114
|
+
* **It takes a target, and that is the whole point of ADR-052.** A profile lives
|
|
115
|
+
* in exactly one target's workspace, so "which profiles exist" is a question
|
|
116
|
+
* about a target rather than about this machine — `personal` on `local` and
|
|
117
|
+
* `personal` on `cloud` are two files in two places, and listing the local
|
|
118
|
+
* directory for both is precisely the confusion this change removes.
|
|
107
119
|
*
|
|
108
120
|
* Names and paths only — deliberately not each profile's port, which would mean
|
|
109
121
|
* parsing every config. One unparseable profile would then fail the command
|
|
110
122
|
* that tells you which profiles exist, and that is exactly when you need it.
|
|
111
123
|
* `status --json` reports the endpoint for a profile you have named.
|
|
112
124
|
*/
|
|
113
|
-
export async function readProfiles(): Promise<ProfileListing> {
|
|
114
|
-
const
|
|
125
|
+
export async function readProfiles(target: string): Promise<ProfileListing> {
|
|
126
|
+
const local = resolveWorkspaceRoot();
|
|
127
|
+
const root = await resolveTargetWorkspace(local, target);
|
|
115
128
|
const profiles = await listProfiles(root);
|
|
116
129
|
const workspace = await readWorkspace(root);
|
|
117
130
|
|
|
@@ -158,8 +171,11 @@ export async function profileAdd(
|
|
|
158
171
|
});
|
|
159
172
|
}
|
|
160
173
|
|
|
161
|
-
export async function profileList(
|
|
162
|
-
|
|
174
|
+
export async function profileList(
|
|
175
|
+
target: string,
|
|
176
|
+
options: { json?: boolean } = {},
|
|
177
|
+
): Promise<void> {
|
|
178
|
+
const listing = await readProfiles(target);
|
|
163
179
|
|
|
164
180
|
return emit(options.json, listing, () => {
|
|
165
181
|
if (listing.profiles.length === 0) {
|
package/src/cli/commands/sync.ts
CHANGED
|
@@ -1,42 +1,35 @@
|
|
|
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';
|
|
1
|
+
import { ConfigError, readRegistry, recordTarget, resolveWorkspaceRoot } from '#profile';
|
|
21
2
|
import { discoverDeployments, holdsWorkspace } from '#deployments/discover.ts';
|
|
22
|
-
import { emit, heading, ok, print, style, waiting
|
|
3
|
+
import { emit, heading, ok, print, style, waiting } from '../output.ts';
|
|
23
4
|
import { confirm, isInteractive } from '../prompt.ts';
|
|
24
5
|
import type { GlobalFlags } from '../runtime.ts';
|
|
25
6
|
|
|
26
7
|
/**
|
|
27
|
-
* `lanes link sync targets` —
|
|
8
|
+
* `lanes link sync targets` — adopt a deployment this workspace has lost track of.
|
|
28
9
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* something. The reported case: a local profile was
|
|
33
|
-
* cloud target, `auth.authorization`, and six
|
|
34
|
-
* still held every one of them and the service
|
|
10
|
+
* **This used to reconcile two copies of every profile, and there is only one
|
|
11
|
+
* now.** A deploy left the workspace holding one copy and the bucket another,
|
|
12
|
+
* they were meant to agree, and when they stopped there was no way to say which
|
|
13
|
+
* side had lost something. The reported case, twice: a local profile was
|
|
14
|
+
* rewritten and lost its cloud target, `auth.authorization`, and six
|
|
15
|
+
* connections, while the bucket still held every one of them and the service
|
|
16
|
+
* went on answering.
|
|
35
17
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
18
|
+
* ADR-052 removed the second copy rather than the disagreement. A profile lives
|
|
19
|
+
* in exactly one target's workspace, so the diff engine this command was built
|
|
20
|
+
* around — `sync-apply.ts`, `sync.ts`, `--prefer local|remote` — had nothing
|
|
21
|
+
* left to compare and is gone with contract 1.
|
|
22
|
+
*
|
|
23
|
+
* What survives is the half that was never about merging: **finding a
|
|
24
|
+
* deployment the local registry has no pointer to**, and writing that pointer.
|
|
25
|
+
* A new machine, a reinstall, a workspace file restored from something older —
|
|
26
|
+
* the endpoint is still serving, and what went missing is the line saying where
|
|
27
|
+
* it lives. That is one write, and it cannot lose anything, because the bucket
|
|
28
|
+
* is authoritative for everything except its own address.
|
|
29
|
+
*
|
|
30
|
+
* `--prefer` is gone and is refused by name rather than ignored: it decided
|
|
31
|
+
* which side won a merge, and someone typing it is asking for behaviour this
|
|
32
|
+
* command no longer has.
|
|
40
33
|
*/
|
|
41
34
|
|
|
42
35
|
export interface SyncFlags extends GlobalFlags {
|
|
@@ -47,22 +40,13 @@ export interface SyncFlags extends GlobalFlags {
|
|
|
47
40
|
readonly prefer?: string | undefined;
|
|
48
41
|
}
|
|
49
42
|
|
|
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
43
|
/**
|
|
59
|
-
* Where the target's
|
|
44
|
+
* Where the target's workspace is, tried cheapest first.
|
|
60
45
|
*
|
|
61
|
-
* The order is the point.
|
|
62
|
-
* whenever anything is;
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* the cost of a `gcloud` call per project.
|
|
46
|
+
* The order is the point. An existing pointer answers instantly and is right
|
|
47
|
+
* whenever anything is; `--from` is for when the operator knows and the
|
|
48
|
+
* workspace does not; and discovery is the one that works from nothing, at the
|
|
49
|
+
* cost of a `gcloud` call per project.
|
|
66
50
|
*/
|
|
67
51
|
async function locateRemote(
|
|
68
52
|
root: string,
|
|
@@ -72,26 +56,20 @@ async function locateRemote(
|
|
|
72
56
|
if (flags.from) {
|
|
73
57
|
if (!(await holdsWorkspace(flags.from))) {
|
|
74
58
|
throw new ConfigError(
|
|
75
|
-
`${flags.from} does not hold a workspace — no
|
|
59
|
+
`${flags.from} does not hold a workspace — no lanes-link.yaml in it.\n` +
|
|
76
60
|
' Check the bucket name, or run with --discover to search for one.',
|
|
77
61
|
);
|
|
78
62
|
}
|
|
79
63
|
return { workspace: flags.from.replace(/\/$/, ''), how: '--from' };
|
|
80
64
|
}
|
|
81
65
|
|
|
82
|
-
|
|
83
|
-
|
|
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' };
|
|
66
|
+
const entry = (await readRegistry(root))[target];
|
|
67
|
+
if (entry?.workspace) return { workspace: entry.workspace, how: 'already recorded' };
|
|
90
68
|
|
|
91
69
|
if (flags.discover !== true) {
|
|
92
70
|
throw new ConfigError(
|
|
93
71
|
`Nothing here says where "${target}" lives.\n` +
|
|
94
|
-
' No
|
|
72
|
+
' No pointer to it in lanes-link.yaml, and nothing else records one.\n\n' +
|
|
95
73
|
' If you know the bucket: lanes link sync targets --target ' +
|
|
96
74
|
`${target} --from gs://<bucket>\n` +
|
|
97
75
|
` If you do not: lanes link sync targets --target ${target} --discover`,
|
|
@@ -99,9 +77,7 @@ async function locateRemote(
|
|
|
99
77
|
}
|
|
100
78
|
|
|
101
79
|
const candidates = (
|
|
102
|
-
await waiting('searching your projects for a deployment', () =>
|
|
103
|
-
discoverDeployments(),
|
|
104
|
-
)
|
|
80
|
+
await waiting('searching your projects for a deployment', () => discoverDeployments())
|
|
105
81
|
).filter((candidate) => candidate.workspace !== undefined);
|
|
106
82
|
|
|
107
83
|
if (candidates.length === 0) {
|
|
@@ -117,8 +93,8 @@ async function locateRemote(
|
|
|
117
93
|
print(style.dim(` workspace ${candidate.workspace}`));
|
|
118
94
|
}
|
|
119
95
|
|
|
120
|
-
// One is offered rather than chosen: adopting the wrong deployment would
|
|
121
|
-
//
|
|
96
|
+
// One is offered rather than chosen: adopting the wrong deployment would point
|
|
97
|
+
// this workspace at a stranger's accounts.
|
|
122
98
|
const first = candidates[0]!;
|
|
123
99
|
if (candidates.length > 1 || !isInteractive()) {
|
|
124
100
|
throw new ConfigError(
|
|
@@ -127,136 +103,92 @@ async function locateRemote(
|
|
|
127
103
|
);
|
|
128
104
|
}
|
|
129
105
|
|
|
130
|
-
if (!(await confirm(`
|
|
106
|
+
if (!(await confirm(` Point "${target}" at ${first.workspace}?`))) {
|
|
131
107
|
throw new ConfigError('Nothing was read or written.');
|
|
132
108
|
}
|
|
133
109
|
|
|
134
110
|
return { workspace: first.workspace!, how: 'discovered' };
|
|
135
111
|
}
|
|
136
112
|
|
|
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
113
|
export async function syncTargets(flags: SyncFlags): Promise<void> {
|
|
153
114
|
const target = flags.target!;
|
|
154
|
-
const prefer = parsePrefer(flags.prefer);
|
|
155
115
|
const root = resolveWorkspaceRoot();
|
|
156
116
|
|
|
157
|
-
|
|
117
|
+
if (flags.prefer !== undefined) {
|
|
118
|
+
throw new ConfigError(
|
|
119
|
+
'--prefer decided which of two copies of a profile won a merge, and there is\n' +
|
|
120
|
+
'only one copy now: the one in the target\'s own workspace (ADR-052).\n' +
|
|
121
|
+
' This command adopts a deployment, and adopting cannot overwrite anything.\n' +
|
|
122
|
+
' Drop the flag and run it again.',
|
|
123
|
+
);
|
|
124
|
+
}
|
|
158
125
|
|
|
159
|
-
const
|
|
160
|
-
const plans: ProfileSync[] = [];
|
|
161
|
-
for (const profile of names) plans.push(await planProfile(root, remote, profile, prefer));
|
|
126
|
+
const { workspace, how } = await locateRemote(root, target, flags);
|
|
162
127
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
128
|
+
// What is actually there, so an adoption cannot point at an empty bucket and
|
|
129
|
+
// report success. This is the one read the command makes, and it is also the
|
|
130
|
+
// check: a workspace that declares this target is a workspace that can serve
|
|
131
|
+
// it.
|
|
132
|
+
const remoteRegistry = await readRegistry(workspace);
|
|
133
|
+
const declaresIt = remoteRegistry[target] !== undefined;
|
|
134
|
+
const existing = (await readRegistry(root))[target];
|
|
135
|
+
const already = existing?.workspace === workspace;
|
|
168
136
|
|
|
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
|
-
};
|
|
137
|
+
const payload = { workspace: root, remote: workspace, target, how, declaresIt, applied: false };
|
|
178
138
|
|
|
179
139
|
const render = (): void => {
|
|
180
140
|
print(style.dim(`workspace ${style.bold(root)} target ${style.bold(target)}`));
|
|
181
|
-
print(style.dim(`remote ${
|
|
182
|
-
|
|
183
|
-
let anything = false;
|
|
184
|
-
for (const plan of plans) {
|
|
185
|
-
if (plan.changes.length === 0) continue;
|
|
186
|
-
anything = true;
|
|
141
|
+
print(style.dim(`remote ${workspace} (${how})`));
|
|
142
|
+
print('');
|
|
187
143
|
|
|
188
|
-
|
|
189
|
-
|
|
144
|
+
if (!declaresIt) {
|
|
145
|
+
const there = Object.keys(remoteRegistry).sort().join(', ') || 'none';
|
|
146
|
+
print(
|
|
147
|
+
style.yellow(
|
|
148
|
+
`${workspace} does not declare a target called "${target}" (it declares: ${there}).`,
|
|
149
|
+
),
|
|
150
|
+
);
|
|
151
|
+
print(
|
|
152
|
+
style.dim(
|
|
153
|
+
' Adopting it would write a pointer to a workspace that cannot answer for\n' +
|
|
154
|
+
' this target. Check the name, or deploy it there first.',
|
|
155
|
+
),
|
|
156
|
+
);
|
|
157
|
+
return;
|
|
190
158
|
}
|
|
191
159
|
|
|
192
|
-
if (
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
for (const blob of blobs) print(` ${arrow(blob.direction)} ${blob.key}`);
|
|
160
|
+
if (already) {
|
|
161
|
+
print(ok('already pointed there — nothing to change'));
|
|
162
|
+
return;
|
|
196
163
|
}
|
|
197
164
|
|
|
198
|
-
|
|
165
|
+
heading('Would write');
|
|
166
|
+
print(` targets.${target}.workspace: ${workspace}`);
|
|
167
|
+
print(style.dim(' The bucket keeps everything else; this records where it is.'));
|
|
199
168
|
};
|
|
200
169
|
|
|
201
|
-
if (
|
|
170
|
+
if (!declaresIt) {
|
|
202
171
|
render();
|
|
203
|
-
|
|
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
|
-
});
|
|
172
|
+
throw new ConfigError(`"${target}" is not declared at ${workspace}.`);
|
|
217
173
|
}
|
|
218
174
|
|
|
219
|
-
if (flags.
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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;
|
|
175
|
+
if (flags.dryRun || already) {
|
|
176
|
+
render();
|
|
177
|
+
if (flags.dryRun) print(style.dim(' --dry-run: nothing was written.'));
|
|
178
|
+
return emit(flags.json, payload, () => {});
|
|
227
179
|
}
|
|
228
180
|
|
|
229
|
-
|
|
230
|
-
root,
|
|
231
|
-
remote,
|
|
232
|
-
resolvedBlobs(blobs, prefer),
|
|
233
|
-
);
|
|
181
|
+
render();
|
|
234
182
|
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
print('');
|
|
241
|
-
if (pulled === 0 && pushed === 0 && copied === 0) return;
|
|
183
|
+
// Only the pointer. Everything that describes the target — its adapters, its
|
|
184
|
+
// deploy block, whose token opens it — is declared where it lives, and reading
|
|
185
|
+
// it from there is what makes this safe to run on a workspace that has lost
|
|
186
|
+
// its own copy of anything.
|
|
187
|
+
await recordTarget(root, target, { workspace });
|
|
242
188
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
}
|
|
189
|
+
print('');
|
|
190
|
+
print(ok(`"${target}" now points at ${workspace}`));
|
|
191
|
+
print(style.dim(` lanes link status --target ${target} reads it from there`));
|
|
251
192
|
|
|
252
|
-
|
|
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
|
-
});
|
|
193
|
+
return emit(flags.json, { ...payload, applied: true }, () => {});
|
|
262
194
|
}
|