@lanes-sh/link 0.2.2 → 0.3.0
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 +21 -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 +56 -158
- package/src/cli/commands/operate/outputs.ts +38 -11
- package/src/cli/commands/operate/serve.ts +3 -0
- 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.ts +83 -35
- package/src/cli/commands/setup.ts +22 -6
- package/src/cli/commands/target.ts +65 -83
- package/src/cli/config-edit.ts +48 -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/identity.ts +12 -1
- package/src/cli/main.ts +68 -4
- 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 +67 -49
- 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 +357 -0
- package/src/cli/usage.ts +32 -9
- 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 +48 -19
- 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 +2 -2
- package/src/deployments/servable.ts +81 -0
- package/src/deployments/target.ts +3 -2
- package/src/deployments/upload.ts +2 -1
- package/src/dispatch/dispatch.ts +1 -1
- package/src/profile/identity.ts +60 -0
- package/src/profile/index.ts +15 -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 +46 -3
- package/src/profile/targets.ts +74 -114
- package/src/profile/workspace.ts +71 -84
- 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 +5 -2
- 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,125 @@
|
|
|
1
|
+
import { containedKey, type BlobKey, type BlobMetadata, type BlobStore } from '#stores/blobs';
|
|
2
|
+
import { inferContentType } from './filesystem.ts';
|
|
3
|
+
import type { GithubRepository } from './github-repo.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A GitHub repository as a `BlobStore`.
|
|
7
|
+
*
|
|
8
|
+
* The one thing a profile can put here is what the owner wrote — memory entries
|
|
9
|
+
* and skills. Not state, not the audit log, not the credential store, and never
|
|
10
|
+
* the vault; `src/profile/knowledge.ts` says why, and says it as a schema with
|
|
11
|
+
* no field to set rather than as a default.
|
|
12
|
+
*
|
|
13
|
+
* **The API, not a clone.** A clone would be faster and would work offline, and
|
|
14
|
+
* it fails on the property this interface exists to provide: a container
|
|
15
|
+
* filesystem is discarded on every revision, so a deployed endpoint's clone
|
|
16
|
+
* would be re-fetched at best and silently empty at worst. That is precisely
|
|
17
|
+
* the bug ADR-014 §2 fixed for the vault, and re-introducing it for memory is
|
|
18
|
+
* not worth the latency. It also needs a `git` binary, which the image does not
|
|
19
|
+
* carry. ADR-041.
|
|
20
|
+
*
|
|
21
|
+
* Vendor-named, like `gcs.ts` and `gcp-secret-manager.ts` beside it: ADR-013's
|
|
22
|
+
* rule is that an adapter for a *protocol* takes the protocol's name, and a
|
|
23
|
+
* client for one vendor's API cannot honestly claim one.
|
|
24
|
+
*
|
|
25
|
+
* **Several of these share one `GithubRepository`.** Memory and skills are two
|
|
26
|
+
* roots in one repository, and pointing both at one client means one head, one
|
|
27
|
+
* tree, and one blob cache between them.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export interface GithubBlobStoreOptions {
|
|
31
|
+
readonly repository: GithubRepository;
|
|
32
|
+
/**
|
|
33
|
+
* A directory inside the repository that this store is rooted at — `memory`,
|
|
34
|
+
* `skills`, or those under a profile's own `path` prefix. Keys never see it.
|
|
35
|
+
*/
|
|
36
|
+
readonly root?: string | undefined;
|
|
37
|
+
/** What a write commit says it did. `%s` is the key. */
|
|
38
|
+
readonly message?: ((operation: 'store' | 'remove', key: string) => string) | undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const DEFAULT_MESSAGE = (operation: 'store' | 'remove', key: string): string =>
|
|
42
|
+
`${operation === 'store' ? 'Store' : 'Remove'} ${key}`;
|
|
43
|
+
|
|
44
|
+
export function createGithubBlobStore(options: GithubBlobStoreOptions): BlobStore {
|
|
45
|
+
const repository = options.repository;
|
|
46
|
+
const message = options.message ?? DEFAULT_MESSAGE;
|
|
47
|
+
const root = (options.root ?? '').replace(/^\/+/, '').replace(/\/+$/, '');
|
|
48
|
+
const prefix = root === '' ? '' : `${root}/`;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A key becomes a path in the repository.
|
|
52
|
+
*
|
|
53
|
+
* `containedKey` is `#stores/blobs`', not a second rule written here.
|
|
54
|
+
* `conformance.ts` asserts that a key one target refuses is not one another
|
|
55
|
+
* accepts, and that can only hold while there is exactly one answer to what
|
|
56
|
+
* a key resolves to.
|
|
57
|
+
*/
|
|
58
|
+
const pathFor = (key: BlobKey): string => `${prefix}${containedKey(key)}`;
|
|
59
|
+
|
|
60
|
+
/** The reverse, for a listing. Null when the path is outside this root. */
|
|
61
|
+
const keyFor = (path: string): BlobKey | null =>
|
|
62
|
+
path.startsWith(prefix) ? path.slice(prefix.length) : null;
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
async put(key, data, putOptions) {
|
|
66
|
+
// `putOptions.contentType` is accepted and not stored, which is the one
|
|
67
|
+
// place this adapter cannot match the others. There is nowhere to put it:
|
|
68
|
+
// a sidecar would be a file in the owner's own repository, listed beside
|
|
69
|
+
// their entries and loaded as one. `list` infers from the extension
|
|
70
|
+
// instead, using the same map the filesystem adapter infers from — which
|
|
71
|
+
// covers every type anything here writes.
|
|
72
|
+
void putOptions;
|
|
73
|
+
await repository.writeFile(pathFor(key), data, message('store', key));
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
async get(key) {
|
|
77
|
+
const path = pathFor(key);
|
|
78
|
+
const { entries } = await repository.entries();
|
|
79
|
+
const entry = entries.get(path);
|
|
80
|
+
// Absence is a value, not an error — every caller is written against null.
|
|
81
|
+
if (!entry) return null;
|
|
82
|
+
|
|
83
|
+
// An empty file has a real blob and zero bytes; `blob` returns the empty
|
|
84
|
+
// array rather than null, which is the case `conformance.ts` pins.
|
|
85
|
+
return repository.blob(entry.sha);
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
async has(key) {
|
|
89
|
+
const path = pathFor(key);
|
|
90
|
+
return (await repository.entries()).entries.has(path);
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
async delete(key) {
|
|
94
|
+
// Deleting what is not there is not a failure: callers use this to make
|
|
95
|
+
// absence true, and a sweep racing another sweep must not throw.
|
|
96
|
+
await repository.deleteFile(pathFor(key), message('remove', key));
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
async list(innerPrefix) {
|
|
100
|
+
const { entries, committedAt } = await repository.entries();
|
|
101
|
+
const wanted = innerPrefix ?? '';
|
|
102
|
+
const found: BlobMetadata[] = [];
|
|
103
|
+
|
|
104
|
+
for (const entry of entries.values()) {
|
|
105
|
+
const key = keyFor(entry.path);
|
|
106
|
+
if (key === null || !key.startsWith(wanted)) continue;
|
|
107
|
+
|
|
108
|
+
const contentType = inferContentType(key);
|
|
109
|
+
found.push({
|
|
110
|
+
key,
|
|
111
|
+
size: entry.size,
|
|
112
|
+
...(contentType ? { contentType } : {}),
|
|
113
|
+
// The branch tip's date, the same for every entry in one listing.
|
|
114
|
+
// Per-file timestamps would be one `GET /commits?path=…` each, and
|
|
115
|
+
// nothing needs them: `skillFingerprint` only needs this to change
|
|
116
|
+
// when the tree does, and memory uses it as a fallback for a
|
|
117
|
+
// hand-written file with no `updated_at` in its frontmatter.
|
|
118
|
+
modifiedAt: committedAt,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return found.sort((a, b) => a.key.localeCompare(b.key));
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ConfigError,
|
|
1
|
+
import { ConfigError, type DeployConfig } from '#profile';
|
|
2
2
|
import { announce, fail, heading, ok, print, style, warn } from '#cli/output.ts';
|
|
3
3
|
import { staleNudge } from '#cli/release.ts';
|
|
4
4
|
import { confirm, isInteractive } from '#cli/prompt.ts';
|
|
@@ -8,6 +8,7 @@ import { printSteps, runSteps } from './steps.ts';
|
|
|
8
8
|
import { driverFor } from './drivers.ts';
|
|
9
9
|
import { prepareSecrets, readableRefs, rotatableRefs } from './prepare.ts';
|
|
10
10
|
import { deployedWorkspace, repairSetupSurface, uploadWorkspace } from './upload.ts';
|
|
11
|
+
import { unservableProfiles, unservableRefusal } from './servable.ts';
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* `lanes link deploy` — set up what is missing, build the image, roll a revision,
|
|
@@ -46,13 +47,20 @@ export interface DeployFlags extends GlobalFlags {
|
|
|
46
47
|
export async function deploy(flags: DeployFlags): Promise<void> {
|
|
47
48
|
// The one command allowed to name a target that does not exist yet: creating
|
|
48
49
|
// it is what a first deploy is for.
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
50
|
+
//
|
|
51
|
+
// It used to work out its own target too — the one declaring a `deploy` block,
|
|
52
|
+
// guessing when there was one, refusing when there were two, and inventing
|
|
53
|
+
// `cloud` when there were none. That inference was a defence against
|
|
54
|
+
// `instance.default_target`, which is `local` on a scaffolded profile and
|
|
55
|
+
// never the answer to "deploy what". With the fallback gone (ADR-037) the
|
|
56
|
+
// defence has nothing to defend against, and what is left is three behaviours
|
|
57
|
+
// from one command line on the command that creates cloud resources and rolls
|
|
58
|
+
// a public URL — where `allowUndeclaredTarget` means a mistake is not refused
|
|
59
|
+
// but surveyed, written into the profile, and deployed as a new service.
|
|
60
|
+
const { resolution, config, target } = await resolveProfile(flags, {
|
|
61
|
+
allowUndeclaredTarget: true,
|
|
62
|
+
});
|
|
63
|
+
announce(resolution);
|
|
56
64
|
|
|
57
65
|
// `check` before anything external, per the gate order: a config that will be
|
|
58
66
|
// rejected on boot should be rejected here, not after a five-minute build.
|
|
@@ -103,7 +111,7 @@ export async function deploy(flags: DeployFlags): Promise<void> {
|
|
|
103
111
|
deploy: deployConfig,
|
|
104
112
|
tag,
|
|
105
113
|
target,
|
|
106
|
-
|
|
114
|
+
profile: resolution.profile,
|
|
107
115
|
...(workspace !== undefined ? { workspace } : {}),
|
|
108
116
|
...(secretEnv ? { secretEnv } : {}),
|
|
109
117
|
});
|
|
@@ -168,8 +176,9 @@ export async function deploy(flags: DeployFlags): Promise<void> {
|
|
|
168
176
|
for (const problem of prepared.blocking) print(fail(problem));
|
|
169
177
|
throw new ConfigError(
|
|
170
178
|
'The deployed instance cannot start without these. Store them with ' +
|
|
171
|
-
`lanes link secrets set <ref> --target ${target}, or
|
|
172
|
-
`lanes link secrets push --
|
|
179
|
+
`lanes link secrets set <ref> --profile ${resolution.profile} --target ${target}, or ` +
|
|
180
|
+
`copy a local setup with lanes link secrets push --profile ${resolution.profile} ` +
|
|
181
|
+
`--from local --to ${target}.`,
|
|
173
182
|
);
|
|
174
183
|
}
|
|
175
184
|
|
|
@@ -193,6 +202,25 @@ export async function deploy(flags: DeployFlags): Promise<void> {
|
|
|
193
202
|
// the upload sends would leave a served profile without the surface — this
|
|
194
203
|
// bug again, one profile over.
|
|
195
204
|
if (workspace) {
|
|
205
|
+
// Before anything is copied, and before the rollout: the endpoint opens
|
|
206
|
+
// every profile in the bucket against this one target, so a profile that
|
|
207
|
+
// does not declare it is not a profile that gets skipped — it is a revision
|
|
208
|
+
// that never goes healthy. `servable.ts` has the whole failure.
|
|
209
|
+
//
|
|
210
|
+
// Here rather than at the top of the command because it reads the same
|
|
211
|
+
// scope the upload does, and that scope is not settled until `workspace`
|
|
212
|
+
// says there is a bucket to send to at all.
|
|
213
|
+
const unservable = await unservableProfiles({
|
|
214
|
+
workspaceRoot: resolution.workspaceRoot,
|
|
215
|
+
profile: flags.profile,
|
|
216
|
+
target,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
if (unservable.length > 0) {
|
|
220
|
+
heading('Cannot be served');
|
|
221
|
+
throw new ConfigError(unservableRefusal(unservable, target));
|
|
222
|
+
}
|
|
223
|
+
|
|
196
224
|
await repairSetupSurface(resolution.workspaceRoot, flags.profile);
|
|
197
225
|
|
|
198
226
|
// Before the rollout, so the revision that comes up finds a config to read.
|
|
@@ -208,7 +236,7 @@ export async function deploy(flags: DeployFlags): Promise<void> {
|
|
|
208
236
|
if (!url) {
|
|
209
237
|
print(
|
|
210
238
|
warn(
|
|
211
|
-
`deployed, but the platform reported no URL yet — run: lanes link outputs --target ${target}`,
|
|
239
|
+
`deployed, but the platform reported no URL yet — run: lanes link outputs --profile ${resolution.profile} --target ${target}`,
|
|
212
240
|
),
|
|
213
241
|
);
|
|
214
242
|
return;
|
|
@@ -218,9 +246,9 @@ export async function deploy(flags: DeployFlags): Promise<void> {
|
|
|
218
246
|
print(` ${url}/mcp`);
|
|
219
247
|
print(await healthLine(url));
|
|
220
248
|
print('');
|
|
221
|
-
print(registerLine(target));
|
|
249
|
+
print(registerLine(resolution.profile, target));
|
|
222
250
|
|
|
223
|
-
reportUnauthorised(prepared.warnings, target);
|
|
251
|
+
reportUnauthorised(prepared.warnings, resolution.profile, target);
|
|
224
252
|
}
|
|
225
253
|
|
|
226
254
|
/**
|
|
@@ -242,9 +270,10 @@ export async function deploy(flags: DeployFlags): Promise<void> {
|
|
|
242
270
|
* appeared only on a later re-deploy, by which point the connector is usually
|
|
243
271
|
* registered and the ordering is no longer available to get right.
|
|
244
272
|
*/
|
|
245
|
-
function registerLine(target: string): string {
|
|
273
|
+
function registerLine(profile: string, target: string): string {
|
|
246
274
|
return style.dim(
|
|
247
|
-
` Connect your accounts first, then register with
|
|
275
|
+
` Connect your accounts first, then register with:\n` +
|
|
276
|
+
` lanes link outputs --profile ${profile} --target ${target}\n` +
|
|
248
277
|
' A client keeps the tool list it fetched when it connected, so one registered\n' +
|
|
249
278
|
' before the accounts holds a surface without them until it is re-added.',
|
|
250
279
|
);
|
|
@@ -264,7 +293,7 @@ function registerLine(target: string): string {
|
|
|
264
293
|
* refusing it, naming the connection rather than the staleness. Reconcile now
|
|
265
294
|
* runs again on every reload, and `connect` asks for one (ADR-029).
|
|
266
295
|
*/
|
|
267
|
-
function reportUnauthorised(warnings: readonly string[], target: string): void {
|
|
296
|
+
function reportUnauthorised(warnings: readonly string[], profile: string, target: string): void {
|
|
268
297
|
if (warnings.length === 0) return;
|
|
269
298
|
|
|
270
299
|
heading('Not authorised yet');
|
|
@@ -273,7 +302,7 @@ function reportUnauthorised(warnings: readonly string[], target: string): void {
|
|
|
273
302
|
print(
|
|
274
303
|
style.dim(
|
|
275
304
|
' A browser consent per account is the one step this cannot take for you:\n' +
|
|
276
|
-
` lanes link connect <provider> --target ${target}\n` +
|
|
305
|
+
` lanes link connect <provider> --profile ${profile} --target ${target}\n` +
|
|
277
306
|
' Each is served as soon as it is authorised. There is no second deploy —\n' +
|
|
278
307
|
' deploying is how code gets here, and authorising an account changes none.',
|
|
279
308
|
),
|
|
@@ -313,7 +342,7 @@ async function healthLine(url: string): Promise<string> {
|
|
|
313
342
|
return ok(
|
|
314
343
|
body.profiles
|
|
315
344
|
? `healthy — serving ${body.profiles.join(', ')}`
|
|
316
|
-
: 'healthy — run `lanes link outputs` for what it serves',
|
|
345
|
+
: 'healthy — run `lanes link outputs` with this profile and target for what it serves',
|
|
317
346
|
);
|
|
318
347
|
} catch {
|
|
319
348
|
// A cold start plus a database connect can outrun a short probe, and
|
|
@@ -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
|
+
}
|
|
@@ -188,7 +188,7 @@ export async function prepareSecrets(input: PrepareInput): Promise<PrepareResult
|
|
|
188
188
|
if (!ref || (await credentials.has(ref))) continue;
|
|
189
189
|
warnings.push(
|
|
190
190
|
`${connection.provider}.${connection.id} is not authorised yet — no credential at "${ref}"\n` +
|
|
191
|
-
` lanes link connect ${connection.provider} --target ${target} --id ${connection.id}`,
|
|
191
|
+
` lanes link connect ${connection.provider} --profile ${input.config.instance.profile} --target ${target} --id ${connection.id}`,
|
|
192
192
|
);
|
|
193
193
|
}
|
|
194
194
|
|
|
@@ -252,6 +252,6 @@ async function seedProfileToken(input: {
|
|
|
252
252
|
}
|
|
253
253
|
|
|
254
254
|
const { created } = await ensureProfileToken(input.credentials, ref);
|
|
255
|
-
if (created) print(ok(`minted an endpoint token at "${ref}" — read it with
|
|
255
|
+
if (created) print(ok(`minted an endpoint token at "${ref}" — read it with lanes link token show`));
|
|
256
256
|
}
|
|
257
257
|
|
|
@@ -0,0 +1,81 @@
|
|
|
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 profile: string | undefined;
|
|
46
|
+
readonly target: string;
|
|
47
|
+
}): Promise<Unservable[]> {
|
|
48
|
+
const found: Unservable[] = [];
|
|
49
|
+
|
|
50
|
+
for (const name of await listProfiles(input.workspaceRoot)) {
|
|
51
|
+
if (input.profile !== undefined && name !== input.profile) continue;
|
|
52
|
+
|
|
53
|
+
let declared: string[];
|
|
54
|
+
try {
|
|
55
|
+
const { config } = await loadProfileConfig(input.workspaceRoot, name);
|
|
56
|
+
declared = Object.keys(config.targets);
|
|
57
|
+
} catch {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!declared.includes(input.target)) found.push({ profile: name, declares: declared });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return found;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The refusal, as a block, so the wording is testable without a deploy. */
|
|
68
|
+
export function unservableRefusal(found: readonly Unservable[], target: string): string {
|
|
69
|
+
const rows = found
|
|
70
|
+
.map((one) => ` ${one.profile} declares: ${one.declares.join(', ') || 'nothing'}`)
|
|
71
|
+
.join('\n');
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
`${found.length} profile${found.length === 1 ? '' : 's'} would be uploaded that cannot run on "${target}":\n` +
|
|
75
|
+
`${rows}\n\n` +
|
|
76
|
+
' The endpoint opens every profile in the bucket with this target, so the\n' +
|
|
77
|
+
' revision would come up and refuse to start.\n' +
|
|
78
|
+
` lanes link profile add <name> --target ${target} declares it on a new one\n` +
|
|
79
|
+
' --profile <name> deploys just the one'
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -271,15 +271,16 @@ export async function openStorage(
|
|
|
271
271
|
* Two refs make the same two mistakes available twice over, and "undefined is
|
|
272
272
|
* not a string" from inside an S3 client is not a diagnosis.
|
|
273
273
|
*/
|
|
274
|
-
async function requireSecret(
|
|
274
|
+
export async function requireSecret(
|
|
275
275
|
secrets: SecretStore,
|
|
276
276
|
ref: string | undefined,
|
|
277
277
|
field: string,
|
|
278
278
|
target: string,
|
|
279
|
+
requiredBy = 'the s3 adapter',
|
|
279
280
|
): Promise<string> {
|
|
280
281
|
if (!ref) {
|
|
281
282
|
throw new ConfigError(
|
|
282
|
-
`${field} is required for
|
|
283
|
+
`${field} is required for ${requiredBy} — the value is a credential, so config names it ` +
|
|
283
284
|
'rather than carrying it.',
|
|
284
285
|
);
|
|
285
286
|
}
|
|
@@ -7,7 +7,8 @@ import {
|
|
|
7
7
|
type Config,
|
|
8
8
|
type TargetConfig,
|
|
9
9
|
} from '#profile';
|
|
10
|
-
import { ConfigDocument
|
|
10
|
+
import { ConfigDocument } from '#cli/config-edit.ts';
|
|
11
|
+
import { ensureSetupConnection, repairLines, repaired } from '#cli/config-repair.ts';
|
|
11
12
|
import { ok, print, style, warn } from '#cli/output.ts';
|
|
12
13
|
|
|
13
14
|
/**
|
package/src/dispatch/dispatch.ts
CHANGED
|
@@ -241,7 +241,7 @@ export class Dispatcher {
|
|
|
241
241
|
return (outcome = deny(
|
|
242
242
|
'denied_connection_unauthorized',
|
|
243
243
|
record.status === 'unauthorized'
|
|
244
|
-
? `Connection ${request.connectionKey} has no valid credential.
|
|
244
|
+
? `Connection ${request.connectionKey} has no valid credential. Connecting it again for this profile and target would store one.`
|
|
245
245
|
: `Connection ${request.connectionKey} is disabled.`,
|
|
246
246
|
));
|
|
247
247
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { identifier } from './primitives.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Who the owner is, in this profile — the names, addresses, and handles they
|
|
6
|
+
* want used when something is written as them.
|
|
7
|
+
*
|
|
8
|
+
* A profile already says what is *reachable*: connections, credentials, what
|
|
9
|
+
* policy permits. It said nothing about *whose* they are. So an agent composing
|
|
10
|
+
* a message, opening a pull request, or signing off had to infer a name from
|
|
11
|
+
* whatever happened to be in the conversation, and it mixed them across
|
|
12
|
+
* profiles — the work signature on a personal mailbox, the wrong handle in a
|
|
13
|
+
* commit trailer. The information was knowable and simply not written down.
|
|
14
|
+
*
|
|
15
|
+
* Three shapes were possible and only this one is right:
|
|
16
|
+
*
|
|
17
|
+
* - **A list, not a map.** `names: [John, Semin]` cannot say *when* to use
|
|
18
|
+
* which, and the note is the whole point — an owner with two names has them
|
|
19
|
+
* for a reason. A flat list of `{kind, value, note}` mirrors `connections`,
|
|
20
|
+
* which is the other list in this file whose order a reader relies on.
|
|
21
|
+
* - **`kind` is free-form**, any `identifier`. Shipping an enum would mean a
|
|
22
|
+
* release every time someone wants `linkedin`, `pronouns`, or `signature`,
|
|
23
|
+
* and there is nothing this file could do with the knowledge that a value is
|
|
24
|
+
* an email that would be worth that. Rendering is generic and stays generic.
|
|
25
|
+
* - **`note` is prose, not a reference.** Binding an entry to a connection was
|
|
26
|
+
* the obvious alternative and is a trap: it puts a cross-reference into
|
|
27
|
+
* `assertReferentialIntegrity`, and then renaming a connection breaks config
|
|
28
|
+
* *load* — the profile stops opening because a name in a signature moved.
|
|
29
|
+
* The agent reading "use with the personal mailbox" gets it right without
|
|
30
|
+
* costing anything the day that mailbox is renamed.
|
|
31
|
+
*
|
|
32
|
+
* Declaration order is meaningful and preserved: the first entry of a kind is
|
|
33
|
+
* the one to reach for absent a reason, and the reader of the YAML sees the same
|
|
34
|
+
* order the agent is told.
|
|
35
|
+
*
|
|
36
|
+
* Not a secret, and worth being explicit about why, because this is the first
|
|
37
|
+
* block in a profile that holds the owner's own data rather than a pointer to
|
|
38
|
+
* it. A name and an address are disclosed by the first message of any mailbox
|
|
39
|
+
* this endpoint serves; withholding them here while serving the mailbox would
|
|
40
|
+
* be theatre. What is refused is a *credential* pasted into a value — a token
|
|
41
|
+
* beginning `ghp_` in a `github` entry trips `secret-detection.ts` like anything
|
|
42
|
+
* else in this file, which is that check working rather than getting in the way.
|
|
43
|
+
*/
|
|
44
|
+
export const identityEntrySchema = z.object({
|
|
45
|
+
/** `name`, `email`, `github` — or anything else the owner finds useful. */
|
|
46
|
+
kind: identifier,
|
|
47
|
+
value: z.string().min(1),
|
|
48
|
+
/**
|
|
49
|
+
* When this one applies, in the owner's words.
|
|
50
|
+
*
|
|
51
|
+
* Optional, and usually absent on the only entry of its kind: a profile with
|
|
52
|
+
* one address needs no note explaining which address to use. It earns its
|
|
53
|
+
* place the moment there are two.
|
|
54
|
+
*/
|
|
55
|
+
note: z.string().min(1).optional(),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
export const identitySchema = z.array(identityEntrySchema);
|
|
59
|
+
|
|
60
|
+
export type IdentityEntry = z.infer<typeof identityEntrySchema>;
|