@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.
@@ -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(root: string, profile: string | undefined): Promise<string[]> {
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 (profile !== undefined && name !== profile) continue;
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
- profile: string | undefined,
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 (profile !== undefined && name !== profile) continue;
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 profile: string | undefined;
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 (input.profile !== undefined && name !== input.profile) continue;
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
+ }