@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,180 @@
|
|
|
1
|
+
import { ConfigError, KNOWLEDGE_LAYOUT, knowledgeRoot, type KnowledgeConfig } from '#profile';
|
|
2
|
+
import type { BlobStore } from '#stores/blobs';
|
|
3
|
+
import { commitFiles, type CommitFile } from '#deployments/adapters/github-commit.ts';
|
|
4
|
+
import type { GithubRepository } from '#deployments/adapters/github-repo.ts';
|
|
5
|
+
import { print, style } from '../../output.ts';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Moving what is already stored, in whichever direction.
|
|
9
|
+
*
|
|
10
|
+
* Four steps, and the order is the whole of the safety: **read, commit, verify,
|
|
11
|
+
* then delete.** A migration that deleted first, or that trusted a write it had
|
|
12
|
+
* not read back, would leave an owner with no memory and a plausible-looking
|
|
13
|
+
* success line. Nothing here deletes anything until the destination has been
|
|
14
|
+
* re-read and found to hold every byte that was sent.
|
|
15
|
+
*
|
|
16
|
+
* The write is **one commit**, through the Git Data API rather than one
|
|
17
|
+
* Contents call per file. Two hundred commits each reading "Store memory
|
|
18
|
+
* a-note.md" is not a history anybody opens, and it is two hundred round trips
|
|
19
|
+
* against a limit this endpoint now shares.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export interface Movable {
|
|
23
|
+
/** The key in the local store — `main/note.md`, or `triage/SKILL.md`. */
|
|
24
|
+
readonly key: string;
|
|
25
|
+
/** Where it goes in the repository. */
|
|
26
|
+
readonly path: string;
|
|
27
|
+
readonly area: 'memory' | 'skills';
|
|
28
|
+
readonly data: Uint8Array;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Everything this profile has stored locally, ready to move.
|
|
33
|
+
*
|
|
34
|
+
* Memory is read from the profile's blob root under the provider's own
|
|
35
|
+
* namespace rather than from the connections the config declares. A connection
|
|
36
|
+
* removed from the file leaves its entries behind, and those entries are still
|
|
37
|
+
* the owner's — leaving them on a disk that is about to stop being consulted is
|
|
38
|
+
* how they would be lost without anything saying so.
|
|
39
|
+
*/
|
|
40
|
+
export async function localContents(
|
|
41
|
+
storage: BlobStore,
|
|
42
|
+
skills: BlobStore,
|
|
43
|
+
knowledge: KnowledgeConfig,
|
|
44
|
+
): Promise<Movable[]> {
|
|
45
|
+
const found: Movable[] = [];
|
|
46
|
+
const prefix = `${KNOWLEDGE_LAYOUT.memory}/`;
|
|
47
|
+
|
|
48
|
+
for (const entry of await storage.list(prefix)) {
|
|
49
|
+
const data = await storage.get(entry.key);
|
|
50
|
+
if (data === null) continue; // Listed then deleted; not worth failing over.
|
|
51
|
+
const key = entry.key.slice(prefix.length);
|
|
52
|
+
found.push({
|
|
53
|
+
key,
|
|
54
|
+
area: 'memory',
|
|
55
|
+
path: `${knowledgeRoot(knowledge, 'memory')}/${key}`,
|
|
56
|
+
data,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (const entry of await skills.list()) {
|
|
61
|
+
const data = await skills.get(entry.key);
|
|
62
|
+
if (data === null) continue;
|
|
63
|
+
found.push({
|
|
64
|
+
key: entry.key,
|
|
65
|
+
area: 'skills',
|
|
66
|
+
path: `${knowledgeRoot(knowledge, 'skills')}/${entry.key}`,
|
|
67
|
+
data,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return found;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Paths the repository already holds that a migration would overwrite. */
|
|
75
|
+
export async function collisions(
|
|
76
|
+
repository: GithubRepository,
|
|
77
|
+
movable: readonly Movable[],
|
|
78
|
+
): Promise<string[]> {
|
|
79
|
+
const { entries } = await repository.entries();
|
|
80
|
+
return movable.filter((item) => entries.has(item.path)).map((item) => item.path);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Commit everything, then read it back before saying so.
|
|
85
|
+
*
|
|
86
|
+
* The verification is not ceremony. `commitFiles` reports what GitHub answered,
|
|
87
|
+
* and what this needs to know is what GitHub *stored* — which is a different
|
|
88
|
+
* question the moment a tree is built against a base that moved, or a blob
|
|
89
|
+
* upload succeeded and its tree entry did not. One extra request buys the right
|
|
90
|
+
* to delete the only other copy.
|
|
91
|
+
*/
|
|
92
|
+
export async function moveIn(
|
|
93
|
+
repository: GithubRepository,
|
|
94
|
+
movable: readonly Movable[],
|
|
95
|
+
message: string,
|
|
96
|
+
): Promise<void> {
|
|
97
|
+
const files: CommitFile[] = movable.map((item) => ({ path: item.path, data: item.data }));
|
|
98
|
+
await commitFiles(repository, files, [], message);
|
|
99
|
+
|
|
100
|
+
const { entries } = await repository.entries();
|
|
101
|
+
const missing = movable.filter((item) => entries.get(item.path)?.size !== item.data.byteLength);
|
|
102
|
+
|
|
103
|
+
if (missing.length > 0) {
|
|
104
|
+
throw new ConfigError(
|
|
105
|
+
`The commit landed but ${missing.length} of ${movable.length} files did not read back ` +
|
|
106
|
+
`correctly (first: ${missing[0]?.path}). Nothing local has been deleted and the config ` +
|
|
107
|
+
'has not been changed, so this profile is exactly as it was. Try again.',
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Remove what has been verified elsewhere. Never called before `moveIn`. */
|
|
113
|
+
export async function removeLocal(
|
|
114
|
+
storage: BlobStore,
|
|
115
|
+
skills: BlobStore,
|
|
116
|
+
movable: readonly Movable[],
|
|
117
|
+
): Promise<void> {
|
|
118
|
+
for (const item of movable) {
|
|
119
|
+
if (item.area === 'memory') await storage.delete(`${KNOWLEDGE_LAYOUT.memory}/${item.key}`);
|
|
120
|
+
else await skills.delete(item.key);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The other direction: everything in the repository, written back to the
|
|
126
|
+
* profile's own storage.
|
|
127
|
+
*
|
|
128
|
+
* Nothing is deleted from the repository. It is version control — the history
|
|
129
|
+
* holds every entry regardless of what the tip says — so removing the files
|
|
130
|
+
* would buy no privacy and would throw away the copy somebody may still want.
|
|
131
|
+
* The command says so rather than deciding it silently.
|
|
132
|
+
*/
|
|
133
|
+
export async function moveOut(
|
|
134
|
+
repository: GithubRepository,
|
|
135
|
+
knowledge: KnowledgeConfig,
|
|
136
|
+
storage: BlobStore,
|
|
137
|
+
skills: BlobStore,
|
|
138
|
+
): Promise<{ memory: number; skills: number }> {
|
|
139
|
+
const { entries } = await repository.entries();
|
|
140
|
+
const roots = {
|
|
141
|
+
memory: `${knowledgeRoot(knowledge, 'memory')}/`,
|
|
142
|
+
skills: `${knowledgeRoot(knowledge, 'skills')}/`,
|
|
143
|
+
} as const;
|
|
144
|
+
|
|
145
|
+
const counts = { memory: 0, skills: 0 };
|
|
146
|
+
|
|
147
|
+
for (const entry of entries.values()) {
|
|
148
|
+
for (const area of ['memory', 'skills'] as const) {
|
|
149
|
+
if (!entry.path.startsWith(roots[area])) continue;
|
|
150
|
+
|
|
151
|
+
const key = entry.path.slice(roots[area].length);
|
|
152
|
+
const data = await repository.blob(entry.sha);
|
|
153
|
+
|
|
154
|
+
if (area === 'memory') await storage.put(`${KNOWLEDGE_LAYOUT.memory}/${key}`, data);
|
|
155
|
+
else await skills.put(key, data);
|
|
156
|
+
|
|
157
|
+
counts[area] += 1;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return counts;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** `12 skills and 47 memory entries`, or whichever half of that is non-zero. */
|
|
165
|
+
export function summarise(movable: readonly Movable[]): string {
|
|
166
|
+
const skills = movable.filter((item) => item.area === 'skills').length;
|
|
167
|
+
const memory = movable.length - skills;
|
|
168
|
+
|
|
169
|
+
const parts = [
|
|
170
|
+
...(skills > 0 ? [`${skills} skill${skills === 1 ? '' : 's'}`] : []),
|
|
171
|
+
...(memory > 0 ? [`${memory} memory entr${memory === 1 ? 'y' : 'ies'}`] : []),
|
|
172
|
+
];
|
|
173
|
+
return parts.length === 0 ? 'nothing' : parts.join(' and ');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function printMovable(movable: readonly Movable[]): void {
|
|
177
|
+
for (const item of movable) {
|
|
178
|
+
print(` ${style.green('→')} ${style.dim(`${item.path}`)}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { ConfigError, type KnowledgeConfig } from '#profile';
|
|
2
|
+
import type { SecretStore } from '#secrets';
|
|
3
|
+
import { GithubRepository, type RepositoryFacts } from '#deployments/adapters/github-repo.ts';
|
|
4
|
+
import type { FetchLike } from '#deployments/knowledge.ts';
|
|
5
|
+
import { heading, print, style } from '../../output.ts';
|
|
6
|
+
import { askSecret, isInteractive } from '../../prompt.ts';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Getting a token, and finding out whether the repository is a sane place to
|
|
10
|
+
* put somebody's memory.
|
|
11
|
+
*
|
|
12
|
+
* Both halves happen **before** anything is written — no config edit, no
|
|
13
|
+
* commit, no local deletion. A half-switched profile is the worst outcome
|
|
14
|
+
* available here: the config says one thing, the bytes are somewhere else, and
|
|
15
|
+
* the failure looks like an empty memory rather than like an error.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The steps, shown when there is no token yet.
|
|
20
|
+
*
|
|
21
|
+
* The same shape as `src/providers/github/index.ts`'s `setup.steps`, and
|
|
22
|
+
* deliberately not the same token. That one talks to GitHub's MCP server and
|
|
23
|
+
* needs Contents **read**; this one writes and needs Contents **write**. Two
|
|
24
|
+
* permissions, two lifetimes, and two things to revoke separately — revoking
|
|
25
|
+
* an MCP connection must not quietly empty somebody's memory.
|
|
26
|
+
*/
|
|
27
|
+
const STEPS = [
|
|
28
|
+
'Create a repository for this, or pick one you already have. **Make it private** — memory entries are yours.',
|
|
29
|
+
'Open https://github.com/settings/personal-access-tokens and choose "Generate new token".',
|
|
30
|
+
'Name it "Lanes Link knowledge" — the name is how you revoke this one later without touching your other tokens — and set an expiry you are willing to renew.',
|
|
31
|
+
'Resource owner: yourself, or the organisation that owns the repository. An organisation may require an owner to approve the token before it works.',
|
|
32
|
+
'Repository access: **only** the one repository this will store in.',
|
|
33
|
+
'Permissions: Contents (read and write), Metadata (read, added for you). Nothing else is needed.',
|
|
34
|
+
'Generate, then copy the token. GitHub shows it once, and it starts with github_pat_.',
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
export function printSetupSteps(repo: string): void {
|
|
38
|
+
heading('A token for this repository');
|
|
39
|
+
print(style.dim(` Storing memory and skills in ${repo} needs a token that may write to it.`));
|
|
40
|
+
print('');
|
|
41
|
+
for (const [index, step] of STEPS.entries()) {
|
|
42
|
+
print(` ${style.dim(`${index + 1}.`)} ${step.replace(/\*\*(.+?)\*\*/g, (_, inner: string) => style.bold(inner))}`);
|
|
43
|
+
}
|
|
44
|
+
print('');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The token this profile will use, asked for only when it is not already
|
|
49
|
+
* stored.
|
|
50
|
+
*
|
|
51
|
+
* Read from a terminal without echo, or from stdin when there is no terminal —
|
|
52
|
+
* never from argv, which lands in shell history and in `ps` output while the
|
|
53
|
+
* command runs. The same rule `lanes link secrets set` states and for the same
|
|
54
|
+
* reasons.
|
|
55
|
+
*/
|
|
56
|
+
export async function resolveToken(
|
|
57
|
+
secrets: SecretStore,
|
|
58
|
+
ref: string,
|
|
59
|
+
repo: string,
|
|
60
|
+
/** `--profile x --target y`, so the command in the refusal is one that runs. */
|
|
61
|
+
selection: string,
|
|
62
|
+
options: { replace?: boolean | undefined } = {},
|
|
63
|
+
): Promise<string> {
|
|
64
|
+
if (!options.replace) {
|
|
65
|
+
const stored = await secrets.get(ref);
|
|
66
|
+
if (stored) return stored;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!process.stdin.isTTY) {
|
|
70
|
+
const piped = (await Bun.stdin.text()).trim();
|
|
71
|
+
if (piped) return piped;
|
|
72
|
+
|
|
73
|
+
throw new ConfigError(
|
|
74
|
+
`No token stored at "${ref}", and stdin is a terminal-less pipe with nothing in it.\n` +
|
|
75
|
+
` printf %s "<github_pat_…>" | lanes link knowledge use github --repo ${repo}${selection}\n` +
|
|
76
|
+
' Pass it this way rather than as an argument: an argument is in your shell history.',
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!isInteractive()) {
|
|
81
|
+
throw new ConfigError(`No token stored at "${ref}", and there is nobody to ask for one.`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
printSetupSteps(repo);
|
|
85
|
+
const token = await askSecret(' GitHub personal access token');
|
|
86
|
+
if (!token) throw new ConfigError('No token given, so nothing was changed.');
|
|
87
|
+
return token;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* What the repository is, and whether this is somewhere memory may go.
|
|
92
|
+
*
|
|
93
|
+
* Three refusals, and the first is the one worth the flag. A public repository
|
|
94
|
+
* is a working store and a catastrophic one: every entry the owner has written,
|
|
95
|
+
* and every procedure naming their accounts and their colleagues, world-readable
|
|
96
|
+
* and indexed. It is refused rather than warned about, because a warning during
|
|
97
|
+
* a migration is read after the migration.
|
|
98
|
+
*/
|
|
99
|
+
export async function probe(
|
|
100
|
+
repository: GithubRepository,
|
|
101
|
+
options: { allowPublic?: boolean | undefined },
|
|
102
|
+
): Promise<{ facts: RepositoryFacts; viewer: string }> {
|
|
103
|
+
const viewer = await repository.viewer();
|
|
104
|
+
const facts = await repository.facts();
|
|
105
|
+
|
|
106
|
+
if (!facts.canPush) {
|
|
107
|
+
throw new ConfigError(
|
|
108
|
+
`The token can read ${facts.fullName} but not write to it. Memory and skills are written, ` +
|
|
109
|
+
'not just read, so this needs Contents: read and write.\n' +
|
|
110
|
+
' Regenerate at https://github.com/settings/personal-access-tokens, then run this again.',
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!facts.private && options.allowPublic !== true) {
|
|
115
|
+
throw new ConfigError(
|
|
116
|
+
`${facts.fullName} is public. Memory entries are the owner's own notes and a skill names ` +
|
|
117
|
+
'the accounts and people it operates on — a public repository publishes both, permanently ' +
|
|
118
|
+
'and searchably.\n' +
|
|
119
|
+
' Make it private, or pass --allow-public if you genuinely mean to publish them.',
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return { facts, viewer };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* A client for a repository, built from what the command was told.
|
|
128
|
+
*
|
|
129
|
+
* The one construction point in this command, so `use github` and `use local`
|
|
130
|
+
* cannot end up talking to different clients — and so a test has one thing to
|
|
131
|
+
* hand a `fetch` to.
|
|
132
|
+
*/
|
|
133
|
+
export function repositoryFor(
|
|
134
|
+
knowledge: KnowledgeConfig,
|
|
135
|
+
token: string,
|
|
136
|
+
call?: FetchLike,
|
|
137
|
+
): GithubRepository {
|
|
138
|
+
return new GithubRepository({
|
|
139
|
+
repo: knowledge.repo,
|
|
140
|
+
token,
|
|
141
|
+
...(knowledge.branch !== undefined ? { branch: knowledge.branch } : {}),
|
|
142
|
+
...(call ? { fetch: call } : {}),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `lanes link knowledge` — which store holds this profile's memory and skills.
|
|
3
|
+
*
|
|
4
|
+
* A barrel, matching `owner.ts` and `operate.ts` beside it, so `main.ts` binds
|
|
5
|
+
* one path per noun rather than one per file. The command itself is three files
|
|
6
|
+
* under `knowledge/`: the token and the repository probe, the migration, and
|
|
7
|
+
* the grammar that puts them in order.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export { knowledgeShow, knowledgeUse, type KnowledgeFlags } from './knowledge/index.ts';
|
|
@@ -25,6 +25,16 @@ export interface AddInput {
|
|
|
25
25
|
readonly token: string;
|
|
26
26
|
readonly tokenEnv: string;
|
|
27
27
|
readonly scope: string;
|
|
28
|
+
/**
|
|
29
|
+
* Which selection this registration is for.
|
|
30
|
+
*
|
|
31
|
+
* Not part of the URL — a deployed endpoint serves every profile in its bucket
|
|
32
|
+
* and each call names one. It is here because the shell commands a harness is
|
|
33
|
+
* told to run afterwards do need it, and a `lanes link token show` without it
|
|
34
|
+
* substitutes to nothing.
|
|
35
|
+
*/
|
|
36
|
+
readonly profile: string;
|
|
37
|
+
readonly target: string;
|
|
28
38
|
}
|
|
29
39
|
|
|
30
40
|
export interface Harness {
|
|
@@ -125,10 +135,14 @@ export const HARNESSES: readonly Harness[] = [
|
|
|
125
135
|
// installs to both unchanged. Codex has no subagent directory, so it gets
|
|
126
136
|
// the skill and not the scout — and `mcp add` says which it did.
|
|
127
137
|
skills: () => join(CODEX_HOME(), 'skills'),
|
|
128
|
-
afterAdd: ({ tokenEnv }) => [
|
|
138
|
+
afterAdd: ({ tokenEnv, profile, target }) => [
|
|
129
139
|
`Codex reads the token from $${tokenEnv} when it starts, so set it where Codex will see it:`,
|
|
130
140
|
'',
|
|
131
|
-
|
|
141
|
+
// Both flags, and this line is the reason they matter more here than
|
|
142
|
+
// anywhere else: an unresolvable substitution yields the empty string, the
|
|
143
|
+
// header becomes "Bearer ", and the only symptom is a 401 that reads as a
|
|
144
|
+
// bad token rather than a command that refused.
|
|
145
|
+
` export ${tokenEnv}="$(lanes link token show --raw --profile ${profile} --target ${target})"`,
|
|
132
146
|
'',
|
|
133
147
|
'Add that to your shell profile. This is the better half of the bargain: the token never',
|
|
134
148
|
'reaches ~/.codex/config.toml, and a "lanes link token rotate" is picked up on next launch',
|
|
@@ -78,7 +78,15 @@ export async function mcpAdd(target: string | undefined, options: McpAddOptions)
|
|
|
78
78
|
// registered loopback with the agent: a registration that reports success,
|
|
79
79
|
// names the right server, and points at a port with nothing behind it.
|
|
80
80
|
const url = await endpointUrl(runtime.config, runtime.target);
|
|
81
|
-
const input: AddInput = {
|
|
81
|
+
const input: AddInput = {
|
|
82
|
+
name,
|
|
83
|
+
url,
|
|
84
|
+
token,
|
|
85
|
+
tokenEnv,
|
|
86
|
+
scope,
|
|
87
|
+
profile: runtime.resolution.profile,
|
|
88
|
+
target: runtime.resolution.target,
|
|
89
|
+
};
|
|
82
90
|
|
|
83
91
|
// Registering an endpoint that is down is legitimate — the harness stores
|
|
84
92
|
// the address and connects on demand — but it is usually a mistake worth
|
|
@@ -20,6 +20,27 @@ import { startStdioEndpoint } from '#server/endpoint.ts';
|
|
|
20
20
|
export async function mcpStdio(
|
|
21
21
|
flags: GlobalFlags & { only?: boolean | undefined },
|
|
22
22
|
): Promise<void> {
|
|
23
|
+
// Its own refusal, written for the only person who will read it: someone
|
|
24
|
+
// looking at a client's MCP log after the server "disconnected". There is no
|
|
25
|
+
// command line here to add a flag to — the client's config file is the only
|
|
26
|
+
// place that can carry one — so the message has to be a paste rather than an
|
|
27
|
+
// instruction, and the generic "pass --profile" would be advice with nowhere
|
|
28
|
+
// to follow it.
|
|
29
|
+
if (!flags.profile || !flags.target) {
|
|
30
|
+
printErr(
|
|
31
|
+
'lanes link mcp stdio needs --profile and --target in its "args".\n' +
|
|
32
|
+
'\n' +
|
|
33
|
+
' This client spawns the endpoint, so its config file is the only place\n' +
|
|
34
|
+
' that can say which profile and target it serves:\n' +
|
|
35
|
+
'\n' +
|
|
36
|
+
' "lanes-link": {\n' +
|
|
37
|
+
' "command": "/path/to/lanes",\n' +
|
|
38
|
+
' "args": ["link","mcp","stdio","--profile","<name>","--target","<name>"]\n' +
|
|
39
|
+
' }\n',
|
|
40
|
+
);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
|
|
23
44
|
const endpoint = await startStdioEndpoint({
|
|
24
45
|
flags,
|
|
25
46
|
...(flags.only ? { only: true } : {}),
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { deploymentIdentity, endpointHealth, localUrl } from '../../endpoint-url.ts';
|
|
2
|
+
import { defaultOpenBrowser } from '../../oauth.ts';
|
|
3
|
+
import { announce, ok, print, style, warn } from '../../output.ts';
|
|
4
|
+
import {
|
|
5
|
+
ensureProfileToken,
|
|
6
|
+
openRuntime,
|
|
7
|
+
resolveProfile,
|
|
8
|
+
type GlobalFlags,
|
|
9
|
+
} from '../../runtime.ts';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `lanes link dashboard` — open the page this endpoint serves.
|
|
13
|
+
*
|
|
14
|
+
* The command exists because the page cannot be reached without it. A browser
|
|
15
|
+
* navigating to `/dashboard` carries no `Authorization` header, so the token has
|
|
16
|
+
* to arrive some other way, and the only place it already exists is the
|
|
17
|
+
* credential store this command can read. It puts it in the URL once; the
|
|
18
|
+
* endpoint exchanges it for a session cookie and redirects to a URL without it.
|
|
19
|
+
*
|
|
20
|
+
* Local only, deliberately. See `#server/dashboard.ts` — ADR-018 leaves a
|
|
21
|
+
* deployed instance with no door a person at a browser can come through, and a
|
|
22
|
+
* command that opened a loopback URL for a target that is not there would be
|
|
23
|
+
* reporting success for a page nobody can load.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export interface DashboardFlags extends GlobalFlags {
|
|
27
|
+
/** Print the URL instead of opening it. */
|
|
28
|
+
readonly print?: boolean | undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function dashboard(flags: DashboardFlags): Promise<void> {
|
|
32
|
+
// `resolveProfile` rather than `openRuntime`, and the order is the point.
|
|
33
|
+
// Opening a runtime opens that target's adapters, so a deployed target sends
|
|
34
|
+
// this command to Secret Manager and a bucket before it can say the one thing
|
|
35
|
+
// it needs to — that the dashboard is not there. The refusal below would
|
|
36
|
+
// still be correct and nobody would ever read it, because a storage 403
|
|
37
|
+
// arrives first. This is the seam `runtime.ts` documents `deploy` and
|
|
38
|
+
// `secrets push` stopping at, for the same reason.
|
|
39
|
+
const { resolution, config, target } = await resolveProfile(flags);
|
|
40
|
+
announce(resolution);
|
|
41
|
+
|
|
42
|
+
const deployed = deploymentIdentity(config.targets[target]?.deploy);
|
|
43
|
+
if (deployed) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
`Target "${target}" is deployed to ${deployed.platform}, and the dashboard is served only ` +
|
|
46
|
+
'by a local endpoint — a browser carries no bearer token, and Cloud Run\'s own gate ' +
|
|
47
|
+
'admits only a Google-signed identity token it cannot mint either (ADR-018).\n' +
|
|
48
|
+
`What a deployed target can answer: ` +
|
|
49
|
+
`lanes link status --profile ${resolution.profile} --target ${target}`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const runtime = await openRuntime(flags);
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const { token } = await ensureProfileToken(runtime.credentials, runtime.config.auth.token_ref);
|
|
57
|
+
|
|
58
|
+
const url = new URL(localUrl(runtime.config));
|
|
59
|
+
url.pathname = '/dashboard';
|
|
60
|
+
url.searchParams.set('k', token);
|
|
61
|
+
// Named rather than left to the endpoint's primary, so the page opens on
|
|
62
|
+
// the profile this command resolved and printed above.
|
|
63
|
+
url.searchParams.set('profile', runtime.resolution.profile);
|
|
64
|
+
|
|
65
|
+
// The page is served by a running endpoint or by nothing. Saying so here
|
|
66
|
+
// beats opening a browser at a connection error.
|
|
67
|
+
const live = await endpointHealth(localUrl(runtime.config), token);
|
|
68
|
+
if (!live) {
|
|
69
|
+
// Both flags on both lines. They read as correct without them only
|
|
70
|
+
// because they were written while a missing one still resolved to the
|
|
71
|
+
// workspace default; with nothing to fall back on (ADR-037) each is a
|
|
72
|
+
// paste that refuses, and this one is printed at the moment somebody is
|
|
73
|
+
// least able to guess what it wanted.
|
|
74
|
+
const where = `--profile ${runtime.resolution.profile} --target ${target}`;
|
|
75
|
+
|
|
76
|
+
print(warn(`nothing is serving this port — run: lanes link start ${where}`));
|
|
77
|
+
print(style.dim(` then: lanes link dashboard ${where}`));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!live.profiles.includes(runtime.resolution.profile)) {
|
|
82
|
+
// `start --only` serves one profile. The URL would 404 on the rest, which
|
|
83
|
+
// is honest but unhelpful without being told why.
|
|
84
|
+
print(
|
|
85
|
+
warn(
|
|
86
|
+
`the endpoint on this port serves ${live.profiles.join(', ')} — ` +
|
|
87
|
+
`not "${runtime.resolution.profile}"`,
|
|
88
|
+
),
|
|
89
|
+
);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (flags.print) {
|
|
94
|
+
// Printed, not opened: for a terminal on a machine with no browser, and
|
|
95
|
+
// for anyone who would rather see what they are about to open. It carries
|
|
96
|
+
// the token, which is why it is behind a flag rather than always shown.
|
|
97
|
+
print(url.href);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
defaultOpenBrowser(url.href);
|
|
102
|
+
print(ok(`opened ${style.bold(`${url.origin}/dashboard`)}`));
|
|
103
|
+
print(style.dim(' the link carries a one-time key; the page swaps it for a session cookie.'));
|
|
104
|
+
} finally {
|
|
105
|
+
await runtime.close();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { ownerPrincipal } from '#auth';
|
|
2
|
+
import type { DiscoveredCapability } from '#connectivity';
|
|
3
|
+
import { BROKERED } from '#connectivity/auth/index.ts';
|
|
4
|
+
import { allowedConnections } from '#policy';
|
|
5
|
+
import { toPolicyDocument } from '#registry';
|
|
6
|
+
import { capabilityDiff, discoveryProbe } from '../../runtime/discovery.ts';
|
|
7
|
+
import type { openRuntime } from '../../runtime.ts';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The two things `doctor` has to work out rather than simply read.
|
|
11
|
+
*
|
|
12
|
+
* Everything else in `inspect.ts` is a lookup — is the token there, does the
|
|
13
|
+
* credential resolve, does the connection name a provider that exists — and
|
|
14
|
+
* these two are analyses: one dates a credential from what the OAuth provider
|
|
15
|
+
* stamped on it, and the other diffs what an upstream now offers against what
|
|
16
|
+
* the endpoint is serving. They are the length in that file, and they are the
|
|
17
|
+
* part that changes for reasons the gate order has nothing to do with.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* How old a stored OAuth credential is.
|
|
22
|
+
*
|
|
23
|
+
* Derived from the `expires_at` the OAuth provider stamps when saving tokens.
|
|
24
|
+
* Returns null for anything that is not a token blob — an app password has no
|
|
25
|
+
* meaningful age, and guessing one would produce a confusing warning.
|
|
26
|
+
*/
|
|
27
|
+
export async function credentialAge(
|
|
28
|
+
credentials: { get(ref: string): Promise<string | null> },
|
|
29
|
+
ref: string,
|
|
30
|
+
): Promise<{ days: number; brokered: boolean } | null> {
|
|
31
|
+
const raw = await credentials.get(ref);
|
|
32
|
+
if (!raw) return null;
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const parsed = JSON.parse(raw) as {
|
|
36
|
+
expires_at?: number;
|
|
37
|
+
expires_in?: number;
|
|
38
|
+
authorized_via?: string;
|
|
39
|
+
};
|
|
40
|
+
if (typeof parsed.expires_at !== 'number') return null;
|
|
41
|
+
|
|
42
|
+
const issued = parsed.expires_at - (parsed.expires_in ?? 3600) * 1000;
|
|
43
|
+
return {
|
|
44
|
+
days: Math.floor((Date.now() - issued) / 86_400_000),
|
|
45
|
+
brokered: parsed.authorized_via === BROKERED,
|
|
46
|
+
};
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Capabilities the upstream has grown since you connected.
|
|
54
|
+
*
|
|
55
|
+
* This is what replaced pinning an allow line per tool. `connect` writes
|
|
56
|
+
* `provider.*`, which is readable and which a vendor can quietly widen by
|
|
57
|
+
* shipping a new tool — so the widening has to be *visible* somewhere, and
|
|
58
|
+
* doctor is where you look when you want to know what changed.
|
|
59
|
+
*
|
|
60
|
+
* Compared against the discovery cache rather than against config, because the
|
|
61
|
+
* cache is what the running server actually serves. Failures are reported and
|
|
62
|
+
* skipped: an upstream being down is not a reason for doctor to fail, and it is
|
|
63
|
+
* already obvious from every other check.
|
|
64
|
+
*
|
|
65
|
+
* This used to open `if (kind !== 'mcp') continue`, which meant it watched the
|
|
66
|
+
* one connector kind whose capabilities this repository does not ship and
|
|
67
|
+
* ignored the two it does. Drive gained three operations in a commit and the
|
|
68
|
+
* endpoint served six for as long as the operator did not re-authorise; the
|
|
69
|
+
* check written to make exactly that visible never ran. `http` is no longer
|
|
70
|
+
* listed here because it no longer can drift — `runtime/discovery.ts` re-derives
|
|
71
|
+
* it at startup — so what is left is the kinds that genuinely still cache.
|
|
72
|
+
*/
|
|
73
|
+
export async function reportCapabilityDrift(
|
|
74
|
+
runtime: Awaited<ReturnType<typeof openRuntime>>,
|
|
75
|
+
say: (message: string) => void,
|
|
76
|
+
): Promise<void> {
|
|
77
|
+
const policy = toPolicyDocument(runtime.config);
|
|
78
|
+
const principal = ownerPrincipal(runtime.config.instance.profile).id;
|
|
79
|
+
|
|
80
|
+
for (const entry of runtime.registry.list()) {
|
|
81
|
+
const connection = runtime.config.connections.find((c) => c.provider === entry.manifest.id);
|
|
82
|
+
if (!connection) continue;
|
|
83
|
+
|
|
84
|
+
const connector = runtime.connectorFor(entry.manifest.id, connection.id);
|
|
85
|
+
const probe = discoveryProbe(entry.manifest, connector);
|
|
86
|
+
// Re-derived every startup, so what is served is what is shipped. Probing
|
|
87
|
+
// it here would only ever compare a value against itself.
|
|
88
|
+
if (!probe || probe.cost === 'offline') continue;
|
|
89
|
+
|
|
90
|
+
const cached = runtime.registry.discovered(entry.manifest.id) ?? [];
|
|
91
|
+
|
|
92
|
+
let live: readonly DiscoveredCapability[];
|
|
93
|
+
try {
|
|
94
|
+
live = await probe.run();
|
|
95
|
+
} catch {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// A provider that was never discovered serves nothing at all, which reads
|
|
100
|
+
// from the outside like the provider being broken. Skipping it silently is
|
|
101
|
+
// how that stays a mystery.
|
|
102
|
+
if (cached.length === 0) {
|
|
103
|
+
say(
|
|
104
|
+
`${entry.manifest.name} has never been discovered, so none of its ${live.length} ` +
|
|
105
|
+
`capability(ies) are served — run: lanes link connect ${entry.manifest.id}`,
|
|
106
|
+
);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const diff = capabilityDiff(cached, live);
|
|
111
|
+
|
|
112
|
+
if (diff.added.length > 0) {
|
|
113
|
+
// Through the same `allowedConnections` the dispatcher enforces with,
|
|
114
|
+
// rather than reading `policy.allow` directly. The predicate this replaced
|
|
115
|
+
// ignored its own `name` argument, so the answer was all-or-nothing, and
|
|
116
|
+
// it never consulted `deny` — telling an operator their policy covered a
|
|
117
|
+
// capability they had explicitly withheld.
|
|
118
|
+
const reachable = diff.added.filter(
|
|
119
|
+
(name) =>
|
|
120
|
+
allowedConnections(
|
|
121
|
+
`${entry.manifest.id}.${name}`,
|
|
122
|
+
[`${entry.manifest.id}.${connection.id}`],
|
|
123
|
+
principal,
|
|
124
|
+
policy,
|
|
125
|
+
).length > 0,
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
say(
|
|
129
|
+
`${entry.manifest.name} has ${diff.added.length} new capability(ies) since you connected: ` +
|
|
130
|
+
`${diff.added.slice(0, 5).join(', ')}${diff.added.length > 5 ? ', …' : ''}` +
|
|
131
|
+
(reachable.length > 0
|
|
132
|
+
? `\n Your policy covers ${reachable.length} of them — lanes link policy deny ${entry.manifest.id}.<name> to withhold one.`
|
|
133
|
+
: ''),
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (diff.removed.length > 0) {
|
|
138
|
+
say(`${entry.manifest.name} no longer offers: ${diff.removed.slice(0, 5).join(', ')}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// The case ADR-017 said `plan` structurally could not report: a schema or a
|
|
142
|
+
// description moved, the name did not, and the endpoint is serving the old
|
|
143
|
+
// shape until it restarts.
|
|
144
|
+
if (diff.changed.length > 0) {
|
|
145
|
+
say(
|
|
146
|
+
`${entry.manifest.name} changed the shape of: ${diff.changed.slice(0, 5).join(', ')}` +
|
|
147
|
+
`\n Run: lanes link connect ${entry.manifest.id}, then restart the endpoint.`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|