@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.
- package/README.md +22 -8
- package/instructions/skills/lanes-link/SKILL.md +42 -14
- package/package.json +1 -1
- package/src/cli/argv.ts +50 -0
- package/src/cli/brand.ts +178 -0
- package/src/cli/callback-page.ts +108 -128
- package/src/cli/commands/connect/accounts.ts +5 -0
- package/src/cli/commands/connect/assertion.ts +187 -0
- package/src/cli/commands/connect/authorise.ts +61 -17
- package/src/cli/commands/connect/client.ts +37 -9
- package/src/cli/commands/connect/discover.ts +94 -0
- package/src/cli/commands/connect/family.ts +72 -0
- package/src/cli/commands/connect/index.ts +113 -115
- package/src/cli/commands/connect/method.ts +237 -0
- package/src/cli/commands/connect/outcome.ts +42 -1
- package/src/cli/commands/connect/pasted-token.ts +66 -0
- package/src/cli/commands/connect/requirements.ts +60 -8
- package/src/cli/commands/connect/setup.ts +16 -5
- package/src/cli/commands/connect/target-note.ts +34 -0
- package/src/cli/commands/identity.ts +258 -0
- package/src/cli/commands/knowledge/index.ts +390 -0
- package/src/cli/commands/knowledge/migrate.ts +180 -0
- package/src/cli/commands/knowledge/setup.ts +144 -0
- package/src/cli/commands/knowledge.ts +10 -0
- package/src/cli/commands/mcp/harnesses.ts +16 -2
- package/src/cli/commands/mcp/register.ts +9 -1
- package/src/cli/commands/mcp/stdio.ts +21 -0
- package/src/cli/commands/operate/dashboard.ts +107 -0
- package/src/cli/commands/operate/findings.ts +151 -0
- package/src/cli/commands/operate/inspect.ts +63 -164
- package/src/cli/commands/operate/outputs.ts +38 -11
- package/src/cli/commands/operate/policy.ts +7 -7
- package/src/cli/commands/operate/serve.ts +3 -0
- package/src/cli/commands/operate/status.ts +108 -1
- package/src/cli/commands/operate/token.ts +1 -1
- package/src/cli/commands/operate.ts +2 -0
- package/src/cli/commands/profile/declare.ts +154 -0
- package/src/cli/commands/profile/removal.ts +17 -0
- package/src/cli/commands/profile/remove.ts +5 -5
- package/src/cli/commands/profile.ts +83 -35
- package/src/cli/commands/secrets.ts +6 -6
- package/src/cli/commands/setup.ts +22 -6
- package/src/cli/commands/sync.ts +262 -0
- package/src/cli/commands/target.ts +65 -83
- package/src/cli/config-edit.ts +53 -144
- package/src/cli/config-repair.ts +186 -0
- package/src/cli/dashboard-page.ts +284 -0
- package/src/cli/dashboard-shell.ts +125 -0
- package/src/cli/dispatch-owner.ts +93 -0
- package/src/cli/identity.ts +12 -1
- package/src/cli/main.ts +90 -61
- package/src/cli/nearest.ts +45 -0
- package/src/cli/oauth-callback.ts +187 -0
- package/src/cli/oauth-exchange.ts +57 -15
- package/src/cli/oauth.ts +67 -177
- package/src/cli/output.ts +21 -5
- package/src/cli/provider-marks.ts +45 -0
- package/src/cli/runtime/open.ts +74 -51
- package/src/cli/runtime/registry.ts +60 -2
- package/src/cli/runtime/select.ts +26 -13
- package/src/cli/runtime/vault.ts +61 -0
- package/src/cli/runtime.ts +2 -1
- package/src/cli/selection.ts +365 -0
- package/src/cli/usage.ts +42 -11
- package/src/connectivity/auth/README.md +7 -1
- package/src/connectivity/auth/basic/index.ts +1 -1
- package/src/connectivity/auth/index.ts +14 -0
- package/src/connectivity/auth/oauth-authcode/broker.ts +26 -0
- package/src/connectivity/auth/oauth-authcode/index.ts +16 -2
- package/src/connectivity/auth/oauth-authcode/provider.ts +1 -1
- package/src/connectivity/auth/oauth-authcode/refresh.ts +3 -3
- package/src/connectivity/auth/oauth-jwt/README.md +33 -0
- package/src/connectivity/auth/oauth-jwt/index.ts +237 -0
- package/src/connectivity/auth/oauth-jwt/key.ts +148 -0
- package/src/connectivity/auth/resolve.ts +1 -1
- package/src/connectivity/auth/token.ts +11 -0
- package/src/connectivity/index.ts +2 -0
- package/src/connectivity/manifest/auth.ts +99 -2
- package/src/connectivity/manifest/identity.ts +12 -0
- package/src/connectivity/manifest/index.ts +3 -1
- package/src/connectivity/manifest/provider.ts +37 -8
- package/src/connectivity/manifest/requirements.ts +109 -6
- package/src/deployments/adapters/filesystem.ts +10 -1
- package/src/deployments/adapters/github-api.ts +106 -0
- package/src/deployments/adapters/github-commit.ts +103 -0
- package/src/deployments/adapters/github-repo.ts +356 -0
- package/src/deployments/adapters/github-testing.ts +258 -0
- package/src/deployments/adapters/github.ts +125 -0
- package/src/deployments/deploy.ts +94 -114
- package/src/deployments/discover.ts +103 -0
- package/src/deployments/driver.ts +8 -1
- package/src/deployments/gcp/driver.ts +3 -1
- package/src/deployments/knowledge.ts +119 -0
- package/src/deployments/prepare.ts +12 -6
- package/src/deployments/report.ts +117 -0
- package/src/deployments/servable.ts +82 -0
- package/src/deployments/serving.ts +165 -0
- package/src/deployments/sync-apply.ts +276 -0
- package/src/deployments/sync.ts +136 -0
- package/src/deployments/target.ts +3 -2
- package/src/deployments/upload.ts +19 -12
- package/src/dispatch/dispatch.ts +1 -1
- package/src/profile/deployments.ts +80 -0
- package/src/profile/identity.ts +60 -0
- package/src/profile/index.ts +23 -5
- package/src/profile/knowledge.ts +124 -0
- package/src/profile/load.ts +17 -5
- package/src/profile/primitives.ts +24 -1
- package/src/profile/schema.ts +81 -3
- package/src/profile/targets.ts +122 -109
- package/src/profile/workspace.ts +139 -79
- package/src/providers/google/calendar/index.ts +2 -0
- package/src/providers/google/contacts/index.ts +2 -0
- package/src/providers/google/docs/index.ts +2 -0
- package/src/providers/google/drive/index.ts +2 -0
- package/src/providers/google/gmail/index.ts +2 -0
- package/src/providers/google/gmail-imap/index.ts +125 -0
- package/src/providers/google/index.ts +2 -1
- package/src/providers/google/shared/oauth.ts +18 -6
- package/src/providers/google/shared/service-account.ts +110 -0
- package/src/providers/google/shared/setup.ts +21 -3
- package/src/providers/google/sheets/index.ts +2 -0
- package/src/providers/google/tasks/index.ts +2 -0
- package/src/providers/identity/provider.ts +166 -0
- package/src/providers/index.ts +3 -0
- package/src/providers/owner.ts +10 -2
- package/src/providers/scopes.ts +2 -0
- package/src/providers/setup/plan.ts +31 -9
- package/src/providers/setup/provider.ts +23 -0
- package/src/providers/slack/index.ts +81 -33
- package/src/providers/slack/oauth.ts +103 -0
- package/src/providers/slack/scopes.ts +37 -0
- package/src/server/container.ts +18 -1
- package/src/server/cors.ts +252 -0
- package/src/server/dashboard.ts +208 -0
- package/src/server/endpoint.ts +45 -1
- package/src/server/generations.ts +11 -2
- package/src/server/harness.ts +7 -0
- package/src/server/index.ts +45 -6
- package/src/server/mcp/index.ts +1 -0
- package/src/server/mcp/instructions.ts +28 -1
- package/src/server/mcp/visibility.ts +33 -0
- 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
|
-
|
|
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
|
-
|
|
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(
|
|
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 {
|
|
@@ -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
|
|
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
|
+
}
|