@lanes-sh/link 0.2.2 → 0.3.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.
Files changed (143) hide show
  1. package/README.md +22 -8
  2. package/instructions/skills/lanes-link/SKILL.md +42 -14
  3. package/package.json +1 -1
  4. package/src/cli/argv.ts +50 -0
  5. package/src/cli/brand.ts +178 -0
  6. package/src/cli/callback-page.ts +108 -128
  7. package/src/cli/commands/connect/accounts.ts +5 -0
  8. package/src/cli/commands/connect/assertion.ts +187 -0
  9. package/src/cli/commands/connect/authorise.ts +61 -17
  10. package/src/cli/commands/connect/client.ts +37 -9
  11. package/src/cli/commands/connect/discover.ts +94 -0
  12. package/src/cli/commands/connect/family.ts +72 -0
  13. package/src/cli/commands/connect/index.ts +113 -115
  14. package/src/cli/commands/connect/method.ts +237 -0
  15. package/src/cli/commands/connect/outcome.ts +42 -1
  16. package/src/cli/commands/connect/pasted-token.ts +66 -0
  17. package/src/cli/commands/connect/requirements.ts +60 -8
  18. package/src/cli/commands/connect/setup.ts +16 -5
  19. package/src/cli/commands/connect/target-note.ts +34 -0
  20. package/src/cli/commands/identity.ts +258 -0
  21. package/src/cli/commands/knowledge/index.ts +390 -0
  22. package/src/cli/commands/knowledge/migrate.ts +180 -0
  23. package/src/cli/commands/knowledge/setup.ts +144 -0
  24. package/src/cli/commands/knowledge.ts +10 -0
  25. package/src/cli/commands/mcp/harnesses.ts +16 -2
  26. package/src/cli/commands/mcp/register.ts +9 -1
  27. package/src/cli/commands/mcp/stdio.ts +21 -0
  28. package/src/cli/commands/operate/dashboard.ts +107 -0
  29. package/src/cli/commands/operate/findings.ts +151 -0
  30. package/src/cli/commands/operate/inspect.ts +63 -164
  31. package/src/cli/commands/operate/outputs.ts +38 -11
  32. package/src/cli/commands/operate/policy.ts +7 -7
  33. package/src/cli/commands/operate/serve.ts +3 -0
  34. package/src/cli/commands/operate/status.ts +108 -1
  35. package/src/cli/commands/operate/token.ts +1 -1
  36. package/src/cli/commands/operate.ts +2 -0
  37. package/src/cli/commands/profile/declare.ts +154 -0
  38. package/src/cli/commands/profile/removal.ts +17 -0
  39. package/src/cli/commands/profile/remove.ts +5 -5
  40. package/src/cli/commands/profile.ts +83 -35
  41. package/src/cli/commands/secrets.ts +6 -6
  42. package/src/cli/commands/setup.ts +22 -6
  43. package/src/cli/commands/sync.ts +262 -0
  44. package/src/cli/commands/target.ts +65 -83
  45. package/src/cli/config-edit.ts +53 -144
  46. package/src/cli/config-repair.ts +186 -0
  47. package/src/cli/dashboard-page.ts +284 -0
  48. package/src/cli/dashboard-shell.ts +125 -0
  49. package/src/cli/dispatch-owner.ts +93 -0
  50. package/src/cli/identity.ts +12 -1
  51. package/src/cli/main.ts +90 -61
  52. package/src/cli/nearest.ts +45 -0
  53. package/src/cli/oauth-callback.ts +187 -0
  54. package/src/cli/oauth-exchange.ts +57 -15
  55. package/src/cli/oauth.ts +67 -177
  56. package/src/cli/output.ts +21 -5
  57. package/src/cli/provider-marks.ts +45 -0
  58. package/src/cli/runtime/open.ts +74 -51
  59. package/src/cli/runtime/registry.ts +60 -2
  60. package/src/cli/runtime/select.ts +26 -13
  61. package/src/cli/runtime/vault.ts +61 -0
  62. package/src/cli/runtime.ts +2 -1
  63. package/src/cli/selection.ts +365 -0
  64. package/src/cli/usage.ts +42 -11
  65. package/src/connectivity/auth/README.md +7 -1
  66. package/src/connectivity/auth/basic/index.ts +1 -1
  67. package/src/connectivity/auth/index.ts +14 -0
  68. package/src/connectivity/auth/oauth-authcode/broker.ts +26 -0
  69. package/src/connectivity/auth/oauth-authcode/index.ts +16 -2
  70. package/src/connectivity/auth/oauth-authcode/provider.ts +1 -1
  71. package/src/connectivity/auth/oauth-authcode/refresh.ts +3 -3
  72. package/src/connectivity/auth/oauth-jwt/README.md +33 -0
  73. package/src/connectivity/auth/oauth-jwt/index.ts +237 -0
  74. package/src/connectivity/auth/oauth-jwt/key.ts +148 -0
  75. package/src/connectivity/auth/resolve.ts +1 -1
  76. package/src/connectivity/auth/token.ts +11 -0
  77. package/src/connectivity/index.ts +2 -0
  78. package/src/connectivity/manifest/auth.ts +99 -2
  79. package/src/connectivity/manifest/identity.ts +12 -0
  80. package/src/connectivity/manifest/index.ts +3 -1
  81. package/src/connectivity/manifest/provider.ts +37 -8
  82. package/src/connectivity/manifest/requirements.ts +109 -6
  83. package/src/deployments/adapters/filesystem.ts +10 -1
  84. package/src/deployments/adapters/github-api.ts +106 -0
  85. package/src/deployments/adapters/github-commit.ts +103 -0
  86. package/src/deployments/adapters/github-repo.ts +356 -0
  87. package/src/deployments/adapters/github-testing.ts +258 -0
  88. package/src/deployments/adapters/github.ts +125 -0
  89. package/src/deployments/deploy.ts +94 -114
  90. package/src/deployments/discover.ts +103 -0
  91. package/src/deployments/driver.ts +8 -1
  92. package/src/deployments/gcp/driver.ts +3 -1
  93. package/src/deployments/knowledge.ts +119 -0
  94. package/src/deployments/prepare.ts +12 -6
  95. package/src/deployments/report.ts +117 -0
  96. package/src/deployments/servable.ts +82 -0
  97. package/src/deployments/serving.ts +165 -0
  98. package/src/deployments/sync-apply.ts +276 -0
  99. package/src/deployments/sync.ts +136 -0
  100. package/src/deployments/target.ts +3 -2
  101. package/src/deployments/upload.ts +19 -12
  102. package/src/dispatch/dispatch.ts +1 -1
  103. package/src/profile/deployments.ts +80 -0
  104. package/src/profile/identity.ts +60 -0
  105. package/src/profile/index.ts +23 -5
  106. package/src/profile/knowledge.ts +124 -0
  107. package/src/profile/load.ts +17 -5
  108. package/src/profile/primitives.ts +24 -1
  109. package/src/profile/schema.ts +81 -3
  110. package/src/profile/targets.ts +122 -109
  111. package/src/profile/workspace.ts +139 -79
  112. package/src/providers/google/calendar/index.ts +2 -0
  113. package/src/providers/google/contacts/index.ts +2 -0
  114. package/src/providers/google/docs/index.ts +2 -0
  115. package/src/providers/google/drive/index.ts +2 -0
  116. package/src/providers/google/gmail/index.ts +2 -0
  117. package/src/providers/google/gmail-imap/index.ts +125 -0
  118. package/src/providers/google/index.ts +2 -1
  119. package/src/providers/google/shared/oauth.ts +18 -6
  120. package/src/providers/google/shared/service-account.ts +110 -0
  121. package/src/providers/google/shared/setup.ts +21 -3
  122. package/src/providers/google/sheets/index.ts +2 -0
  123. package/src/providers/google/tasks/index.ts +2 -0
  124. package/src/providers/identity/provider.ts +166 -0
  125. package/src/providers/index.ts +3 -0
  126. package/src/providers/owner.ts +10 -2
  127. package/src/providers/scopes.ts +2 -0
  128. package/src/providers/setup/plan.ts +31 -9
  129. package/src/providers/setup/provider.ts +23 -0
  130. package/src/providers/slack/index.ts +81 -33
  131. package/src/providers/slack/oauth.ts +103 -0
  132. package/src/providers/slack/scopes.ts +37 -0
  133. package/src/server/container.ts +18 -1
  134. package/src/server/cors.ts +252 -0
  135. package/src/server/dashboard.ts +208 -0
  136. package/src/server/endpoint.ts +45 -1
  137. package/src/server/generations.ts +11 -2
  138. package/src/server/harness.ts +7 -0
  139. package/src/server/index.ts +45 -6
  140. package/src/server/mcp/index.ts +1 -0
  141. package/src/server/mcp/instructions.ts +28 -1
  142. package/src/server/mcp/visibility.ts +33 -0
  143. package/src/stores/blobs/route.ts +123 -0
@@ -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
+ }
@@ -39,7 +39,14 @@ export interface PlanInput {
39
39
  readonly deploy: DeployConfig;
40
40
  readonly tag: string;
41
41
  readonly target: string;
42
- readonly profile?: string | undefined;
42
+ /**
43
+ * The primary profile this revision serves.
44
+ *
45
+ * Required rather than optional: `deploy` names it (ADR-037), and a revision
46
+ * that boots without `LANES_LINK_PROFILE` refuses. Leaving it optional here is
47
+ * what let deployments ship without the variable at all.
48
+ */
49
+ readonly profile: string;
43
50
  /**
44
51
  * Where the running instance reads its config, as a store URL.
45
52
  *
@@ -103,7 +103,9 @@ export function deployPlan(input: PlanInput): DeployStep[] {
103
103
  '--set-env-vars',
104
104
  [
105
105
  `LANES_LINK_TARGET=${input.target}`,
106
- ...(input.profile ? [`LANES_LINK_PROFILE=${input.profile}`] : []),
106
+ // Unconditional: `deploy` requires --profile (ADR-037), so this is
107
+ // always known, and a revision without it refuses at boot.
108
+ `LANES_LINK_PROFILE=${input.profile}`,
107
109
  ...(input.workspace ? [`LANES_LINK_HOME=${input.workspace}`] : []),
108
110
  ].join(','),
109
111
  ...secretMounts(input.secretEnv),
@@ -0,0 +1,119 @@
1
+ import type { SecretStore } from '#secrets';
2
+ import type { BlobStore } from '#stores/blobs';
3
+ import { knowledgeRoot, type KnowledgeConfig } from '#profile';
4
+ import { requireSecret, type TargetInput } from './target.ts';
5
+ import type { FetchLike } from './adapters/github-api.ts';
6
+ // Type-only, so a target with no `knowledge` block never loads the adapter.
7
+ // The same rule `target.ts` follows for the cloud adapters, kept here by the
8
+ // fact that a type import is erased entirely.
9
+ import type { GithubRepository } from './adapters/github-repo.ts';
10
+ // Re-exported so a caller that wants to replace the transport does not have to
11
+ // reach past this module into an adapter. It is narrower than
12
+ // `typeof globalThis.fetch`, which under Bun's types carries a `preconnect` a
13
+ // test double would otherwise have to stub.
14
+ export type { FetchLike } from './adapters/github-api.ts';
15
+
16
+ /**
17
+ * Opening the place a profile keeps its memory and its skills, when that is not
18
+ * the place it keeps everything else.
19
+ *
20
+ * Its own file rather than a fifth case in `target.ts`, because it is not the
21
+ * same kind of decision. `target.ts` answers "where does this target run" —
22
+ * credentials here, bytes there — and every consumer of a `BlobStore` rides
23
+ * that one answer. This answers a narrower one: two directories of documents
24
+ * the owner wrote have somewhere else to be, and nothing else moves with them.
25
+ *
26
+ * ADR-041 has the argument. `src/profile/knowledge.ts` has the contract, and
27
+ * the reason there is no field here that could name the credential store or the
28
+ * vault.
29
+ */
30
+
31
+ /**
32
+ * One repository client, two stores.
33
+ *
34
+ * Memory and skills are two directories in one repository, and pointing both at
35
+ * one client means one branch head, one tree, and one blob cache between them —
36
+ * so the endpoint's two-second skill poll keeps memory's view current for free.
37
+ * Two clients would each poll, each cache, and could disagree about which
38
+ * commit is current.
39
+ */
40
+ export interface KnowledgeStores {
41
+ readonly repository: GithubRepository;
42
+ readonly skills: BlobStore;
43
+ /** Rooted at the repository's memory directory; keys are `<connection>/<id>.md`. */
44
+ readonly memory: BlobStore;
45
+ /** One line for `target show` and `doctor`, so the config file is not the only witness. */
46
+ readonly describe: string;
47
+ }
48
+
49
+ /**
50
+ * `undefined` when the target declares no `knowledge` block — which is every
51
+ * profile written before it existed, so the caller's fallback is the path that
52
+ * has always run rather than a special case.
53
+ */
54
+ export async function openKnowledge(
55
+ input: TargetInput,
56
+ secrets: SecretStore,
57
+ /** Injected for tests. The repository is the only thing these stores reach. */
58
+ call?: FetchLike,
59
+ ): Promise<KnowledgeStores | undefined> {
60
+ const { declared, target } = input;
61
+ const knowledge = declared.knowledge;
62
+ if (!knowledge) return undefined;
63
+
64
+ const token = await requireSecret(
65
+ secrets,
66
+ knowledge.token_ref,
67
+ `targets.${target}.knowledge.token_ref`,
68
+ target,
69
+ 'the github adapter',
70
+ );
71
+
72
+ const { GithubRepository } = await import('./adapters/github-repo.ts');
73
+ const repository = new GithubRepository({
74
+ repo: knowledge.repo,
75
+ token,
76
+ ...(knowledge.branch !== undefined ? { branch: knowledge.branch } : {}),
77
+ ...(call ? { fetch: call } : {}),
78
+ });
79
+
80
+ return {
81
+ repository,
82
+ ...(await knowledgeStores(repository, knowledge)),
83
+ describe: describeKnowledge(knowledge),
84
+ };
85
+ }
86
+
87
+ /**
88
+ * The two stores a knowledge repository holds.
89
+ *
90
+ * Separate from `openKnowledge` because `lanes link knowledge use` builds these
91
+ * against a repository it is still probing — before any of it has been written
92
+ * into the config `openKnowledge` reads. Two spellings of one layout is how a
93
+ * migration comes to write where nothing later looks.
94
+ */
95
+ export async function knowledgeStores(
96
+ repository: GithubRepository,
97
+ knowledge: KnowledgeConfig,
98
+ ): Promise<{ skills: BlobStore; memory: BlobStore }> {
99
+ const { createGithubBlobStore } = await import('./adapters/github.ts');
100
+
101
+ const build = (area: 'memory' | 'skills'): BlobStore =>
102
+ createGithubBlobStore({
103
+ repository,
104
+ root: knowledgeRoot(knowledge, area),
105
+ // What the commit says it did. Named per area rather than left to the
106
+ // adapter's default, because "Store main/note.md" in a repository holding
107
+ // both is a line that does not say which of them changed.
108
+ message: (operation, key) =>
109
+ `${operation === 'store' ? 'Store' : 'Remove'} ${area === 'skills' ? 'skill' : 'memory'} ${key}`,
110
+ });
111
+
112
+ return { skills: build('skills'), memory: build('memory') };
113
+ }
114
+
115
+ /** `github:owner/name#branch/path`, in one place so every reader agrees. */
116
+ export function describeKnowledge(knowledge: KnowledgeConfig): string {
117
+ const branch = knowledge.branch ? `#${knowledge.branch}` : '';
118
+ return `github:${knowledge.repo}${branch}${knowledge.path ? `/${knowledge.path}` : ''}`;
119
+ }
@@ -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 {
@@ -188,7 +194,7 @@ export async function prepareSecrets(input: PrepareInput): Promise<PrepareResult
188
194
  if (!ref || (await credentials.has(ref))) continue;
189
195
  warnings.push(
190
196
  `${connection.provider}.${connection.id} is not authorised yet — no credential at "${ref}"\n` +
191
- ` lanes link connect ${connection.provider} --target ${target} --id ${connection.id}`,
197
+ ` lanes link connect ${connection.provider} --profile ${input.config.instance.profile} --target ${target} --id ${connection.id}`,
192
198
  );
193
199
  }
194
200
 
@@ -252,6 +258,6 @@ async function seedProfileToken(input: {
252
258
  }
253
259
 
254
260
  const { created } = await ensureProfileToken(input.credentials, ref);
255
- if (created) print(ok(`minted an endpoint token at "${ref}" — read it with: lanes link token show`));
261
+ if (created) print(ok(`minted an endpoint token at "${ref}" — read it with lanes link token show`));
256
262
  }
257
263
 
@@ -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
+ }
@@ -0,0 +1,82 @@
1
+ import { listProfiles, loadProfileConfig } from '#profile';
2
+
3
+ /**
4
+ * Whether every profile a deploy sends can actually run on the target it sends them to.
5
+ *
6
+ * A deploy uploads config for a set of profiles and rolls a revision baked with
7
+ * one `LANES_LINK_TARGET`. The endpoint then opens *every* profile it finds in
8
+ * the bucket against that one target — see `openReconciled` — and a profile that
9
+ * does not declare it throws on the way up. The catch there closes the runtimes
10
+ * and rethrows, so the container exits and the revision never goes healthy.
11
+ *
12
+ * That is the whole failure, and its shape is what makes it worth a pre-flight:
13
+ * the profile at fault is usually the one just scaffolded, the operator was not
14
+ * thinking about the deployment when they made it, and the symptom is a revision
15
+ * that will not start — which reads as a problem with the deploy, the image, or
16
+ * the platform. Nothing points at the new profile. Refusing here costs one file
17
+ * read per profile and names it.
18
+ *
19
+ * Its own file, not a private function in `deploy.ts`, for the reason
20
+ * `handoff.ts` gives: `deploy.test.ts` does not import `deploy.ts`, and making
21
+ * it do so would pull the whole CLI runtime into a unit test.
22
+ */
23
+
24
+ export interface Unservable {
25
+ readonly profile: string;
26
+ /** What it does declare, so the refusal says how far off it is. */
27
+ readonly declares: readonly string[];
28
+ }
29
+
30
+ /**
31
+ * The profiles this deploy would send that the revision could not open.
32
+ *
33
+ * Scoped exactly as `uploadWorkspace` and `repairSetupSurface` are — by the
34
+ * `--profile` flag, absent meaning the whole workspace — because the set that
35
+ * gets uploaded is the set that gets served, and checking a different one would
36
+ * be checking the wrong question.
37
+ *
38
+ * A profile that cannot be parsed is not reported here. It is already fatal
39
+ * further along, with a better message than this could give, and a YAML error
40
+ * dressed up as "cannot run on cloud" would send someone looking at their
41
+ * targets instead of their syntax.
42
+ */
43
+ export async function unservableProfiles(input: {
44
+ readonly workspaceRoot: string;
45
+ readonly profiles: readonly string[] | undefined;
46
+ readonly target: string;
47
+ }): Promise<Unservable[]> {
48
+ const found: Unservable[] = [];
49
+ const wanted = input.profiles === undefined ? undefined : new Set(input.profiles);
50
+
51
+ for (const name of await listProfiles(input.workspaceRoot)) {
52
+ if (wanted !== undefined && !wanted.has(name)) continue;
53
+
54
+ let declared: string[];
55
+ try {
56
+ const { config } = await loadProfileConfig(input.workspaceRoot, name);
57
+ declared = Object.keys(config.targets);
58
+ } catch {
59
+ continue;
60
+ }
61
+
62
+ if (!declared.includes(input.target)) found.push({ profile: name, declares: declared });
63
+ }
64
+
65
+ return found;
66
+ }
67
+
68
+ /** The refusal, as a block, so the wording is testable without a deploy. */
69
+ export function unservableRefusal(found: readonly Unservable[], target: string): string {
70
+ const rows = found
71
+ .map((one) => ` ${one.profile} declares: ${one.declares.join(', ') || 'nothing'}`)
72
+ .join('\n');
73
+
74
+ return (
75
+ `${found.length} profile${found.length === 1 ? '' : 's'} would be uploaded that cannot run on "${target}":\n` +
76
+ `${rows}\n\n` +
77
+ ' The endpoint opens every profile in the bucket with this target, so the\n' +
78
+ ' revision would come up and refuse to start.\n' +
79
+ ` lanes link profile add <name> --target ${target} declares it on a new one\n` +
80
+ ' --profile <name> deploys just the one'
81
+ );
82
+ }
@@ -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
+ }