@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
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { readWorkspaceFile, workspaceFiles, WORKSPACE_FILE } from '#profile';
|
|
2
|
+
import { captureGcloud } from './gcp/gcloud.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Finding a deployment nothing in the workspace mentions any more.
|
|
6
|
+
*
|
|
7
|
+
* The last resort, and the one that works from nothing. A profile that lost its
|
|
8
|
+
* target block and a workspace with no index between them leave no local record
|
|
9
|
+
* that a deployment ever existed — but the deployment is still there, still
|
|
10
|
+
* answering, and still holding the config it was given. Asking the platform is
|
|
11
|
+
* the only way back from that state.
|
|
12
|
+
*
|
|
13
|
+
* Deliberately opt-in. It is a `gcloud` call per project and there may be
|
|
14
|
+
* dozens, so a command that did this on every run would be one nobody waits
|
|
15
|
+
* for. Everything cheaper is tried first.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface Candidate {
|
|
19
|
+
readonly project: string;
|
|
20
|
+
readonly region: string;
|
|
21
|
+
readonly service: string;
|
|
22
|
+
/** The bucket holding a workspace, when one was found beside the service. */
|
|
23
|
+
readonly workspace: string | undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Cloud Run services across every project this login can see.
|
|
28
|
+
*
|
|
29
|
+
* Not filtered by name. A service is a candidate because a bucket beside it
|
|
30
|
+
* holds a workspace, not because of what it is called — the operator may have
|
|
31
|
+
* named it anything, and a name filter would hide exactly the deployment whose
|
|
32
|
+
* naming convention nobody remembers.
|
|
33
|
+
*/
|
|
34
|
+
export async function discoverDeployments(
|
|
35
|
+
onProgress?: (project: string) => void,
|
|
36
|
+
): Promise<Candidate[]> {
|
|
37
|
+
const projects = await captureGcloud(['projects', 'list', '--format', 'value(projectId)']);
|
|
38
|
+
if (!projects.ok) return [];
|
|
39
|
+
|
|
40
|
+
const found: Candidate[] = [];
|
|
41
|
+
|
|
42
|
+
for (const project of projects.stdout.split('\n').map((line) => line.trim()).filter(Boolean)) {
|
|
43
|
+
onProgress?.(project);
|
|
44
|
+
|
|
45
|
+
const services = await captureGcloud([
|
|
46
|
+
'run',
|
|
47
|
+
'services',
|
|
48
|
+
'list',
|
|
49
|
+
'--project',
|
|
50
|
+
project,
|
|
51
|
+
'--format',
|
|
52
|
+
'value(metadata.name,metadata.labels."cloud.googleapis.com/location")',
|
|
53
|
+
]);
|
|
54
|
+
if (!services.ok || !services.stdout) continue;
|
|
55
|
+
|
|
56
|
+
for (const line of services.stdout.split('\n').map((entry) => entry.trim()).filter(Boolean)) {
|
|
57
|
+
const [service = '', region = ''] = line.split(/\s+/);
|
|
58
|
+
if (!service || !region) continue;
|
|
59
|
+
|
|
60
|
+
found.push({ project, region, service, workspace: await workspaceBeside(project) });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return found;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Whether a project holds a bucket with a workspace in it.
|
|
69
|
+
*
|
|
70
|
+
* The survey names the bucket after the project, so that is checked first and
|
|
71
|
+
* is almost always the answer. Falling back to listing every bucket costs a
|
|
72
|
+
* second call and covers a workspace whose bucket was named by hand.
|
|
73
|
+
*/
|
|
74
|
+
async function workspaceBeside(project: string): Promise<string | undefined> {
|
|
75
|
+
if (await holdsWorkspace(`gs://${project}`)) return `gs://${project}`;
|
|
76
|
+
|
|
77
|
+
const buckets = await captureGcloud([
|
|
78
|
+
'storage',
|
|
79
|
+
'buckets',
|
|
80
|
+
'list',
|
|
81
|
+
'--project',
|
|
82
|
+
project,
|
|
83
|
+
'--format',
|
|
84
|
+
'value(name)',
|
|
85
|
+
]);
|
|
86
|
+
if (!buckets.ok) return undefined;
|
|
87
|
+
|
|
88
|
+
for (const name of buckets.stdout.split('\n').map((line) => line.trim()).filter(Boolean)) {
|
|
89
|
+
if (name === project) continue;
|
|
90
|
+
if (await holdsWorkspace(`gs://${name}`)) return `gs://${name}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A bucket is a workspace when it has the file that says so. Never throws. */
|
|
97
|
+
export async function holdsWorkspace(url: string): Promise<boolean> {
|
|
98
|
+
try {
|
|
99
|
+
return (await readWorkspaceFile(workspaceFiles(url), WORKSPACE_FILE)) !== null;
|
|
100
|
+
} catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -61,11 +61,15 @@ export interface PrepareResult {
|
|
|
61
61
|
* Called before `provision`, which is before any store is opened — so it reads
|
|
62
62
|
* config and manifests only, and touches no credential.
|
|
63
63
|
*/
|
|
64
|
-
export async function rotatableRefs(
|
|
64
|
+
export async function rotatableRefs(
|
|
65
|
+
root: string,
|
|
66
|
+
profiles: readonly string[] | undefined,
|
|
67
|
+
): Promise<string[]> {
|
|
65
68
|
const refs = new Set<string>();
|
|
69
|
+
const wanted = profiles === undefined ? undefined : new Set(profiles);
|
|
66
70
|
|
|
67
71
|
for (const name of await listProfiles(root)) {
|
|
68
|
-
if (
|
|
72
|
+
if (wanted !== undefined && !wanted.has(name)) continue;
|
|
69
73
|
|
|
70
74
|
let config: Config;
|
|
71
75
|
try {
|
|
@@ -121,7 +125,7 @@ export async function rotatableRefs(root: string, profile: string | undefined):
|
|
|
121
125
|
*/
|
|
122
126
|
export async function readableRefs(
|
|
123
127
|
root: string,
|
|
124
|
-
|
|
128
|
+
profiles: readonly string[] | undefined,
|
|
125
129
|
declared: TargetConfig | undefined,
|
|
126
130
|
): Promise<string[]> {
|
|
127
131
|
const refs = new Set<string>();
|
|
@@ -134,8 +138,10 @@ export async function readableRefs(
|
|
|
134
138
|
refs.add(VAULT_KEY_REF);
|
|
135
139
|
}
|
|
136
140
|
|
|
141
|
+
const wanted = profiles === undefined ? undefined : new Set(profiles);
|
|
142
|
+
|
|
137
143
|
for (const name of await listProfiles(root)) {
|
|
138
|
-
if (
|
|
144
|
+
if (wanted !== undefined && !wanted.has(name)) continue;
|
|
139
145
|
|
|
140
146
|
let config: Config;
|
|
141
147
|
try {
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { DeployConfig } from '#profile';
|
|
2
|
+
import { heading, ok, print, style, warn } from '#cli/output.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What a deploy tells the operator, as against what it does.
|
|
6
|
+
*
|
|
7
|
+
* Split out of `deploy.ts` because that file is the ordered list of things that
|
|
8
|
+
* have to happen to roll a revision, and this is none of them: which door the
|
|
9
|
+
* platform left open, whether the endpoint came up, how to register it, and
|
|
10
|
+
* which credentials the revision will find missing. Every one of these runs
|
|
11
|
+
* before or after the rollout and changes nothing about it.
|
|
12
|
+
*
|
|
13
|
+
* They are worth keeping together. Each is a sentence someone acts on — two of
|
|
14
|
+
* them are commands to paste — and the failure they exist against is a deploy
|
|
15
|
+
* that succeeds and leaves the operator with a URL and no idea what to do next.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* How to register the endpoint, and when.
|
|
20
|
+
*
|
|
21
|
+
* The ordering is the whole point of the second half. A client captures
|
|
22
|
+
* `tools/list` when it connects and keeps it: this endpoint is stateless, so
|
|
23
|
+
* there is no stream on which to send `notifications/tools/list_changed`, and
|
|
24
|
+
* `buildMcpServer` no longer pretends otherwise. A first deploy necessarily
|
|
25
|
+
* publishes a profile whose only connection is `setup.main` — the accounts come
|
|
26
|
+
* after — so a connector registered in that window captures a two-tool surface
|
|
27
|
+
* and holds it. The endpoint is right, every reload lands, and the client shows
|
|
28
|
+
* two tools until someone removes and re-adds it.
|
|
29
|
+
*
|
|
30
|
+
* Unconditional, and that is the correction that matters. This was gated on
|
|
31
|
+
* `prepared.warnings.length`, which is zero in precisely the case it describes:
|
|
32
|
+
* a fresh profile declares only `setup.main`, `setup` is a local provider with
|
|
33
|
+
* no credential, so `prepareSecrets` has nothing to warn about. The advice
|
|
34
|
+
* appeared only on a later re-deploy, by which point the connector is usually
|
|
35
|
+
* registered and the ordering is no longer available to get right.
|
|
36
|
+
*/
|
|
37
|
+
export function registerLine(profile: string, target: string): string {
|
|
38
|
+
return style.dim(
|
|
39
|
+
` Connect your accounts first, then register with:\n` +
|
|
40
|
+
` lanes link outputs --profile ${profile} --target ${target}\n` +
|
|
41
|
+
' A client keeps the tool list it fetched when it connected, so one registered\n' +
|
|
42
|
+
' before the accounts holds a surface without them until it is re-added.',
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The accounts a browser still has to authorise, and the step after them.
|
|
48
|
+
*
|
|
49
|
+
* Printed last rather than before the build, because this is the only thing
|
|
50
|
+
* left to do and a list eight steps up the scrollback is a list nobody reads.
|
|
51
|
+
*
|
|
52
|
+
* There is no second deploy at the end of it any more, and the reason the old
|
|
53
|
+
* one existed is worth keeping. Connection *credentials* are read live on every
|
|
54
|
+
* call, so a fresh `connect` looked like it should be picked up — but whether a
|
|
55
|
+
* connection was usable at all was decided by a reconcile that ran once per
|
|
56
|
+
* process, so a revision that came up with an account unauthorised went on
|
|
57
|
+
* refusing it, naming the connection rather than the staleness. Reconcile now
|
|
58
|
+
* runs again on every reload, and `connect` asks for one (ADR-029).
|
|
59
|
+
*/
|
|
60
|
+
export function reportUnauthorised(warnings: readonly string[], profile: string, target: string): void {
|
|
61
|
+
if (warnings.length === 0) return;
|
|
62
|
+
|
|
63
|
+
heading('Not authorised yet');
|
|
64
|
+
for (const problem of warnings) print(warn(problem));
|
|
65
|
+
print('');
|
|
66
|
+
print(
|
|
67
|
+
style.dim(
|
|
68
|
+
' A browser consent per account is the one step this cannot take for you:\n' +
|
|
69
|
+
` lanes link connect <provider> --profile ${profile} --target ${target}\n` +
|
|
70
|
+
' Each is served as soon as it is authorised. There is no second deploy —\n' +
|
|
71
|
+
' deploying is how code gets here, and authorising an account changes none.',
|
|
72
|
+
),
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Who can reach the service once this lands.
|
|
78
|
+
*
|
|
79
|
+
* Printed on every deploy rather than only when it changes, because it is the
|
|
80
|
+
* one property of a deployment that is invisible from the outside until someone
|
|
81
|
+
* either cannot get in or should not have been able to.
|
|
82
|
+
*/
|
|
83
|
+
export function reachability(access: DeployConfig['access']): string {
|
|
84
|
+
return access === 'iam'
|
|
85
|
+
? style.dim(
|
|
86
|
+
' access: iam — the platform admits only callers holding its own identity\n' +
|
|
87
|
+
' token. No agent harness can mint one; use --access public with an\n' +
|
|
88
|
+
' authorization block if a remote MCP client has to reach this.',
|
|
89
|
+
)
|
|
90
|
+
: style.dim(
|
|
91
|
+
' access: public — the platform lets requests through and this endpoint\n' +
|
|
92
|
+
' authenticates them. The bearer token is what protects it.',
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Ask the deployed endpoint whether it came up. */
|
|
97
|
+
export async function healthLine(url: string): Promise<string> {
|
|
98
|
+
try {
|
|
99
|
+
const response = await fetch(`${url}/health`, { signal: AbortSignal.timeout(10_000) });
|
|
100
|
+
if (!response.ok) return warn(`the endpoint answered /health with ${response.status}`);
|
|
101
|
+
|
|
102
|
+
// Asked anonymously, so it reports that the revision is up and nothing
|
|
103
|
+
// about what it serves — the profile list is behind the token now.
|
|
104
|
+
// `lanes link outputs` holds one and prints the rest.
|
|
105
|
+
const body = (await response.json()) as { profiles?: string[] };
|
|
106
|
+
return ok(
|
|
107
|
+
body.profiles
|
|
108
|
+
? `healthy — serving ${body.profiles.join(', ')}`
|
|
109
|
+
: 'healthy — run `lanes link outputs` with this profile and target for what it serves',
|
|
110
|
+
);
|
|
111
|
+
} catch {
|
|
112
|
+
// A cold start plus a database connect can outrun a short probe, and
|
|
113
|
+
// `access: iam` makes /health unreachable from here by design. Neither is a
|
|
114
|
+
// failed deploy.
|
|
115
|
+
return warn('could not reach /health from here — with access: iam that is expected');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -42,13 +42,14 @@ export interface Unservable {
|
|
|
42
42
|
*/
|
|
43
43
|
export async function unservableProfiles(input: {
|
|
44
44
|
readonly workspaceRoot: string;
|
|
45
|
-
readonly
|
|
45
|
+
readonly profiles: readonly string[] | undefined;
|
|
46
46
|
readonly target: string;
|
|
47
47
|
}): Promise<Unservable[]> {
|
|
48
48
|
const found: Unservable[] = [];
|
|
49
|
+
const wanted = input.profiles === undefined ? undefined : new Set(input.profiles);
|
|
49
50
|
|
|
50
51
|
for (const name of await listProfiles(input.workspaceRoot)) {
|
|
51
|
-
if (
|
|
52
|
+
if (wanted !== undefined && !wanted.has(name)) continue;
|
|
52
53
|
|
|
53
54
|
let declared: string[];
|
|
54
55
|
try {
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ConfigError,
|
|
3
|
+
findDeployment,
|
|
4
|
+
loadWorkspaceProfiles,
|
|
5
|
+
type WorkspaceProfiles,
|
|
6
|
+
} from '#profile';
|
|
7
|
+
import { rotatableCredentialRefsFor } from '#registry';
|
|
8
|
+
import { buildRegistryWithWorkspace } from '#cli/runtime.ts';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Which profiles a deploy sends, and whether they can share one place.
|
|
12
|
+
*
|
|
13
|
+
* `deploy` used to send the profile it was told and no other. That reads as the
|
|
14
|
+
* safe default and is not what the thing being deployed does: one endpoint
|
|
15
|
+
* serves *every* profile in the bucket (ADR-009), so a workspace with two
|
|
16
|
+
* profiles needed two deploys, and the second one had to be told a target the
|
|
17
|
+
* profile did not declare yet — which the survey then offered to create
|
|
18
|
+
* somewhere new. The way to deploy both was to know that already.
|
|
19
|
+
*
|
|
20
|
+
* The set is derived rather than guessed: it is every profile declaring the
|
|
21
|
+
* target, which is exactly the set the endpoint will try to open. `--profile`
|
|
22
|
+
* narrows it, and naming one is still how a first deploy works, because a
|
|
23
|
+
* target nothing declares has no set to derive (ADR-043).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export interface Serving {
|
|
27
|
+
/** Every profile this deploy will upload. Never empty. */
|
|
28
|
+
readonly profiles: string[];
|
|
29
|
+
/** Whose token opens the endpoint, and what the revision is told it is. */
|
|
30
|
+
readonly primary: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function servingProfiles(input: {
|
|
34
|
+
readonly workspaceRoot: string;
|
|
35
|
+
readonly target: string;
|
|
36
|
+
/** What `--profile` named, in order. Empty when it named nothing. */
|
|
37
|
+
readonly named: readonly string[];
|
|
38
|
+
}): Promise<Serving> {
|
|
39
|
+
const { workspaceRoot, target, named } = input;
|
|
40
|
+
|
|
41
|
+
if (named.length > 0) {
|
|
42
|
+
return { profiles: [...named], primary: named[0]! };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const workspace = await loadWorkspaceProfiles(workspaceRoot);
|
|
46
|
+
const declaring = workspace.loaded
|
|
47
|
+
.filter((entry) => target in entry.config.targets)
|
|
48
|
+
.map((entry) => entry.profile);
|
|
49
|
+
|
|
50
|
+
if (declaring.length === 0) {
|
|
51
|
+
throw new ConfigError(
|
|
52
|
+
`No profile declares "${target}", so there is no set to deploy.\n` +
|
|
53
|
+
' A first deploy creates the target, and has to be told which profile\n' +
|
|
54
|
+
' it belongs to:\n' +
|
|
55
|
+
` lanes link deploy --target ${target} --profile <name>\n\n` +
|
|
56
|
+
` If "${target}" was deployed before and the declaration was lost:\n` +
|
|
57
|
+
` lanes link sync targets --target ${target} --discover`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { profiles: declaring, primary: await choosePrimary(workspaceRoot, target, declaring) };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Whose bearer token opens the endpoint.
|
|
66
|
+
*
|
|
67
|
+
* One endpoint, one token, every profile behind it (ADR-009) — so this decides
|
|
68
|
+
* who gets in, and it is the one thing about a deployment that must not be
|
|
69
|
+
* inferred from whatever happens to sort first. Recorded by the last deploy
|
|
70
|
+
* when there was one; otherwise there has to be exactly one candidate, or the
|
|
71
|
+
* operator is asked.
|
|
72
|
+
*/
|
|
73
|
+
async function choosePrimary(
|
|
74
|
+
workspaceRoot: string,
|
|
75
|
+
target: string,
|
|
76
|
+
declaring: readonly string[],
|
|
77
|
+
): Promise<string> {
|
|
78
|
+
const recorded = (await findDeployment(workspaceRoot, target))?.primary;
|
|
79
|
+
if (recorded !== undefined && declaring.includes(recorded)) return recorded;
|
|
80
|
+
|
|
81
|
+
if (declaring.length === 1) return declaring[0]!;
|
|
82
|
+
|
|
83
|
+
throw new ConfigError(
|
|
84
|
+
`${declaring.length} profiles declare "${target}", and nothing records which\n` +
|
|
85
|
+
"of them owns the endpoint's token. One token opens the endpoint and\n" +
|
|
86
|
+
'reaches every profile behind it, so this cannot be picked for you.\n\n' +
|
|
87
|
+
` Name it once and it is remembered:\n` +
|
|
88
|
+
` lanes link deploy --target ${target} --profile ${declaring[0]!}` +
|
|
89
|
+
declaring
|
|
90
|
+
.slice(1)
|
|
91
|
+
.map((name) => ` --profile ${name}`)
|
|
92
|
+
.join(''),
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A credential reference two profiles would both write, in one store.
|
|
98
|
+
*
|
|
99
|
+
* References are flat — `gmail/main`, not `personal/gmail/main` — and a target
|
|
100
|
+
* has one credential store, so two profiles deployed to the same project share
|
|
101
|
+
* a namespace. `docs/detailed/configuration.md` admits this in an aside about
|
|
102
|
+
* removing a profile; deploying both at once is where it stops being an aside.
|
|
103
|
+
*
|
|
104
|
+
* The failure is silent and it is the bad kind: `personal`'s Gmail refresh
|
|
105
|
+
* token is overwritten by `work`'s, both profiles go on listing their own
|
|
106
|
+
* account in config, and the first symptom is one of them reading the other's
|
|
107
|
+
* mailbox. Nothing downstream can catch it, because by then there is one
|
|
108
|
+
* credential and it is valid.
|
|
109
|
+
*
|
|
110
|
+
* `profile/token` is deliberately not a collision. Every profile defaults to
|
|
111
|
+
* that ref and the endpoint has exactly one token by design — sharing it is
|
|
112
|
+
* what ADR-009 says happens, rather than an accident.
|
|
113
|
+
*/
|
|
114
|
+
export interface Collision {
|
|
115
|
+
readonly ref: string;
|
|
116
|
+
readonly profiles: string[];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function collidingRefs(
|
|
120
|
+
workspaceRoot: string,
|
|
121
|
+
profiles: readonly string[],
|
|
122
|
+
workspace?: WorkspaceProfiles,
|
|
123
|
+
): Promise<Collision[]> {
|
|
124
|
+
const loaded = (workspace ?? (await loadWorkspaceProfiles(workspaceRoot))).loaded.filter(
|
|
125
|
+
(entry) => profiles.includes(entry.profile),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const owners = new Map<string, string[]>();
|
|
129
|
+
|
|
130
|
+
for (const entry of loaded) {
|
|
131
|
+
const registry = await buildRegistryWithWorkspace(workspaceRoot, entry.profile);
|
|
132
|
+
const refs = new Set<string>();
|
|
133
|
+
|
|
134
|
+
for (const connection of entry.config.connections) {
|
|
135
|
+
const manifest = registry.manifest(connection.provider);
|
|
136
|
+
for (const ref of rotatableCredentialRefsFor(connection, manifest)) refs.add(ref);
|
|
137
|
+
if (connection.credential_ref) refs.add(connection.credential_ref);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for (const ref of refs) {
|
|
141
|
+
if (ref === entry.config.auth.token_ref) continue;
|
|
142
|
+
owners.set(ref, [...(owners.get(ref) ?? []), entry.profile]);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return [...owners.entries()]
|
|
147
|
+
.filter(([, names]) => names.length > 1)
|
|
148
|
+
.map(([ref, names]) => ({ ref, profiles: names.sort() }))
|
|
149
|
+
.sort((a, b) => a.ref.localeCompare(b.ref));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The refusal, as a block, so the wording is testable without a deploy. */
|
|
153
|
+
export function collisionRefusal(found: readonly Collision[], target: string): string {
|
|
154
|
+
const rows = found.map((one) => ` ${one.ref} ${one.profiles.join(', ')}`).join('\n');
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
`${found.length} credential reference(s) would be written by more than one\n` +
|
|
158
|
+
`profile into the one credential store "${target}" has:\n\n${rows}\n\n` +
|
|
159
|
+
' References are flat, so these are the same secret and the last deploy\n' +
|
|
160
|
+
' wins — after which one profile is reading the other\'s account, and\n' +
|
|
161
|
+
' both still name their own in config.\n\n' +
|
|
162
|
+
' Give the connections different ids, or deploy the profiles to targets\n' +
|
|
163
|
+
' in separate projects.'
|
|
164
|
+
);
|
|
165
|
+
}
|