@lanes-sh/link 0.2.1 → 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 +59 -14
- package/package.json +1 -1
- package/src/auth/index.ts +3 -1
- package/src/auth/oauth/metadata.ts +83 -9
- package/src/auth/oauth/redirects.ts +70 -0
- package/src/auth/oauth/server.ts +49 -69
- package/src/auth/oauth/store.ts +19 -5
- 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 +9 -1
- package/src/deployments/gcp/survey.ts +3 -0
- 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/authorization.ts +13 -4
- 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 +72 -4
- 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 +57 -4
- package/src/server/generation.ts +1 -0
- package/src/server/generations.ts +13 -2
- package/src/server/harness.ts +20 -3
- package/src/server/index.ts +76 -12
- package/src/server/mcp/build.ts +1 -1
- package/src/server/mcp/index.ts +1 -0
- package/src/server/mcp/instructions.ts +62 -5
- package/src/server/mcp/visibility.ts +42 -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),
|
|
@@ -113,6 +115,12 @@ export function deployPlan(input: PlanInput): DeployStep[] {
|
|
|
113
115
|
// harness can mint one — so a target reached by a remote MCP client
|
|
114
116
|
// declares `public` and gates the request in the application instead.
|
|
115
117
|
cloudrun.access === 'iam' ? '--no-allow-unauthenticated' : '--allow-unauthenticated',
|
|
118
|
+
// Always passed, including the zero. Config is the source of truth here
|
|
119
|
+
// (ADR-004), and a flag sent only when non-zero would let a value be
|
|
120
|
+
// raised and never lowered — the revision would keep whatever the last
|
|
121
|
+
// deploy that bothered to mention it had set.
|
|
122
|
+
'--min-instances',
|
|
123
|
+
String(cloudrun.min_instances),
|
|
116
124
|
],
|
|
117
125
|
},
|
|
118
126
|
];
|
|
@@ -127,6 +127,9 @@ export async function surveyCloudRun(input: SurveyInput): Promise<SurveyResult>
|
|
|
127
127
|
|
|
128
128
|
const deploy: DeployConfig = {
|
|
129
129
|
platform: 'cloudrun',
|
|
130
|
+
// Not asked about. Zero is right for almost every target and the question
|
|
131
|
+
// would cost every operator a decision to buy one of them a knob.
|
|
132
|
+
min_instances: current.min_instances ?? 0,
|
|
130
133
|
project,
|
|
131
134
|
region,
|
|
132
135
|
service,
|
|
@@ -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
|
}
|
|
@@ -34,11 +34,20 @@ const selfAuthorizationSchema = z.object({
|
|
|
34
34
|
/**
|
|
35
35
|
* How long an issued access token lives.
|
|
36
36
|
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
37
|
+
* Twelve hours, where this used to be one. The old reading — short by design,
|
|
38
|
+
* refreshed rather than lengthened — assumed the refresh happens. Expiry is
|
|
39
|
+
* in practice where a remote client loses its session: one observed against
|
|
40
|
+
* this endpoint let its access token lapse and reported needing authorization
|
|
41
|
+
* while the matching refresh token sat in the store unused, weeks from its
|
|
42
|
+
* own expiry. That is the client's bug and nothing here can fix it. What the
|
|
43
|
+
* endpoint can do is stop offering the chance twenty-four times a day.
|
|
44
|
+
*
|
|
45
|
+
* What is given up is real and bounded: a stolen access token is useful for
|
|
46
|
+
* longer. The revocable half is unchanged — the refresh path is still what a
|
|
47
|
+
* dropped row closes — and the refresh token was already the longer-lived of
|
|
48
|
+
* the pair at thirty days. `1440` is the ceiling, for one window a day.
|
|
40
49
|
*/
|
|
41
|
-
access_token_ttl_minutes: z.number().int().positive().max(1440).default(
|
|
50
|
+
access_token_ttl_minutes: z.number().int().positive().max(1440).default(720),
|
|
42
51
|
});
|
|
43
52
|
|
|
44
53
|
const oidcAuthorizationSchema = z.object({
|