@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,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>;
|
package/src/profile/index.ts
CHANGED
|
@@ -19,11 +19,21 @@ export {
|
|
|
19
19
|
type Config,
|
|
20
20
|
type ConnectionConfig,
|
|
21
21
|
type DeployConfig,
|
|
22
|
+
type IdentityEntry,
|
|
22
23
|
type PolicyRuleConfig,
|
|
23
24
|
type TargetConfig,
|
|
24
25
|
type WorkspaceConfig,
|
|
25
26
|
} from './schema.ts';
|
|
26
27
|
|
|
28
|
+
export {
|
|
29
|
+
KNOWLEDGE_LAYOUT,
|
|
30
|
+
knowledgeRoot,
|
|
31
|
+
knowledgeTargetSchema,
|
|
32
|
+
parseRepository,
|
|
33
|
+
type KnowledgeArea,
|
|
34
|
+
type KnowledgeConfig,
|
|
35
|
+
} from './knowledge.ts';
|
|
36
|
+
|
|
27
37
|
export {
|
|
28
38
|
ConfigError,
|
|
29
39
|
loadConfigFile,
|
|
@@ -41,10 +51,10 @@ export {
|
|
|
41
51
|
|
|
42
52
|
export {
|
|
43
53
|
WORKSPACE_FILE,
|
|
44
|
-
describeSelection,
|
|
45
54
|
installRoot,
|
|
46
55
|
listProfiles,
|
|
47
56
|
loadProfileConfig,
|
|
57
|
+
noProfileNamed,
|
|
48
58
|
profilePath,
|
|
49
59
|
readWorkspace,
|
|
50
60
|
resolveSelection,
|
|
@@ -52,10 +62,9 @@ export {
|
|
|
52
62
|
workspacePath,
|
|
53
63
|
} from './workspace.ts';
|
|
54
64
|
export {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
resolveTarget,
|
|
65
|
+
LEGACY_TARGET_ENV,
|
|
66
|
+
noTargetNamed,
|
|
67
|
+
requireTarget,
|
|
59
68
|
undeclaredTarget,
|
|
60
69
|
} from './targets.ts';
|
|
61
70
|
export {
|
|
@@ -65,6 +74,7 @@ export {
|
|
|
65
74
|
writeWorkspaceFile,
|
|
66
75
|
} from './files.ts';
|
|
67
76
|
export {
|
|
77
|
+
type ProfileSelection,
|
|
68
78
|
type Resolution,
|
|
69
79
|
type ResolveOptions,
|
|
70
80
|
} from './workspace.ts';
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { credentialRef } from './primitives.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Where a profile's memory and skills live, when that is not where everything
|
|
6
|
+
* else lives.
|
|
7
|
+
*
|
|
8
|
+
* A target's `storage:` block names one backend for everything a profile holds
|
|
9
|
+
* — runtime state, the audit log, memory entries, skills, cached attachments.
|
|
10
|
+
* That is right for most of it and wrong for two: state and the log are
|
|
11
|
+
* artefacts of one installation, and memory entries and skills are documents
|
|
12
|
+
* the owner wrote. Documents want history, review, and to be readable from more
|
|
13
|
+
* than one machine, and the backend that suits an object nobody reads is not
|
|
14
|
+
* the backend that suits those.
|
|
15
|
+
*
|
|
16
|
+
* So this block moves exactly those two, and it is the whole of what it can
|
|
17
|
+
* move. There is no `credentials` or `vault` value here and there will not be:
|
|
18
|
+
* a repository is a place to publish, and those two hold the material whose
|
|
19
|
+
* entire value is that it is not published (`docs/detailed/security.md`). The
|
|
20
|
+
* exclusion is structural — a field that does not exist cannot be set by
|
|
21
|
+
* accident, by a flag, or by an operator following an example.
|
|
22
|
+
*
|
|
23
|
+
* `adapter` is an enum of one, the same shape `vaultTargetSchema` uses, so a
|
|
24
|
+
* second host is a case rather than a schema rewrite.
|
|
25
|
+
*
|
|
26
|
+
* Absent means what has always happened, which is why every field that could
|
|
27
|
+
* carry a default does. A profile written before this block existed loads
|
|
28
|
+
* unchanged and stores its memory and skills exactly where it did.
|
|
29
|
+
*
|
|
30
|
+
* ADR-041.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* `owner/name`, as GitHub spells it everywhere a person types one.
|
|
35
|
+
*
|
|
36
|
+
* A URL is deliberately not accepted. The browser URL, the SSH remote, and this
|
|
37
|
+
* are three spellings of one value, and a schema that takes all three has to
|
|
38
|
+
* normalise them somewhere — at which point the file no longer says what the
|
|
39
|
+
* operator wrote. The CLI accepts a pasted URL and reduces it to this before
|
|
40
|
+
* writing, which puts the leniency where a person is typing rather than in the
|
|
41
|
+
* contract.
|
|
42
|
+
*/
|
|
43
|
+
const repository = z
|
|
44
|
+
.string()
|
|
45
|
+
.regex(
|
|
46
|
+
/^[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9._-]+$/,
|
|
47
|
+
'must be "owner/name" — not a URL, and not a local path',
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A directory inside the repository, for one that holds other things too.
|
|
52
|
+
*
|
|
53
|
+
* Normalised to no leading or trailing slash so `knowledge`, `/knowledge` and
|
|
54
|
+
* `knowledge/` are one prefix rather than three key spaces that look identical
|
|
55
|
+
* in a diff and address different objects.
|
|
56
|
+
*/
|
|
57
|
+
const repositoryPath = z
|
|
58
|
+
.string()
|
|
59
|
+
.refine((value) => !value.split('/').includes('..'), {
|
|
60
|
+
message: 'must not contain ".." — it is a prefix inside the repository, not a path to walk',
|
|
61
|
+
})
|
|
62
|
+
.transform((value) => value.replace(/^\/+/, '').replace(/\/+$/, ''));
|
|
63
|
+
|
|
64
|
+
export const knowledgeTargetSchema = z.object({
|
|
65
|
+
adapter: z.literal('github'),
|
|
66
|
+
repo: repository,
|
|
67
|
+
/**
|
|
68
|
+
* Defaults at open time rather than here, to the repository's own default
|
|
69
|
+
* branch. Writing `main` into every generated config would be a guess, and a
|
|
70
|
+
* wrong one for anybody whose default is still `master` — a guess that then
|
|
71
|
+
* fails as "branch not found" rather than as "you did not say".
|
|
72
|
+
*/
|
|
73
|
+
branch: z.string().min(1).optional(),
|
|
74
|
+
path: repositoryPath.optional(),
|
|
75
|
+
/**
|
|
76
|
+
* A reference, never the token.
|
|
77
|
+
*
|
|
78
|
+
* Its own ref rather than a reuse of the `github` provider connection's,
|
|
79
|
+
* which holds a token for GitHub's MCP server. That one needs Contents:
|
|
80
|
+
* *read*; this one needs Contents: *write*, has a different lifetime, and
|
|
81
|
+
* should be revocable on its own — revoking an MCP connection must not
|
|
82
|
+
* silently empty somebody's memory.
|
|
83
|
+
*/
|
|
84
|
+
token_ref: credentialRef.default('knowledge/token'),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
export type KnowledgeConfig = z.infer<typeof knowledgeTargetSchema>;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Where each area sits inside the repository.
|
|
91
|
+
*
|
|
92
|
+
* Two directories, named after the two things that move. `memory` is also the
|
|
93
|
+
* memory provider's own blob namespace — the prefix core scopes it into under
|
|
94
|
+
* the profile's blob root — which is why the same word does both jobs: the
|
|
95
|
+
* route that redirects it and the directory it lands in are the same fact, and
|
|
96
|
+
* spelling them separately is how they would come to disagree.
|
|
97
|
+
*
|
|
98
|
+
* Declared here, beside the schema, so the runtime that opens these stores and
|
|
99
|
+
* the command that migrates into them read one definition.
|
|
100
|
+
*/
|
|
101
|
+
export const KNOWLEDGE_LAYOUT = {
|
|
102
|
+
memory: 'memory',
|
|
103
|
+
skills: 'skills',
|
|
104
|
+
} as const;
|
|
105
|
+
|
|
106
|
+
export type KnowledgeArea = keyof typeof KNOWLEDGE_LAYOUT;
|
|
107
|
+
|
|
108
|
+
/** The directory one area occupies, under the profile's optional path prefix. */
|
|
109
|
+
export function knowledgeRoot(knowledge: KnowledgeConfig, area: KnowledgeArea): string {
|
|
110
|
+
const directory = KNOWLEDGE_LAYOUT[area];
|
|
111
|
+
return knowledge.path ? `${knowledge.path}/${directory}` : directory;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** `owner/name`, out of whatever a person pasted. Null when it is not one. */
|
|
115
|
+
export function parseRepository(given: string): string | null {
|
|
116
|
+
const trimmed = given
|
|
117
|
+
.trim()
|
|
118
|
+
.replace(/^https?:\/\/(?:www\.)?github\.com\//i, '')
|
|
119
|
+
.replace(/^(?:ssh:\/\/)?git@[^:]+[:/]/i, '')
|
|
120
|
+
.replace(/\.git$/i, '')
|
|
121
|
+
.replace(/\/+$/, '');
|
|
122
|
+
|
|
123
|
+
return repository.safeParse(trimmed).success ? trimmed : null;
|
|
124
|
+
}
|
package/src/profile/load.ts
CHANGED
|
@@ -114,11 +114,9 @@ function assertReferentialIntegrity(config: Config, source: string): void {
|
|
|
114
114
|
if (targetNames.size === 0) {
|
|
115
115
|
problems.push('targets: at least one target must be declared');
|
|
116
116
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
);
|
|
121
|
-
}
|
|
117
|
+
// `instance.default_target` is deliberately not checked. Nothing reads it
|
|
118
|
+
// (ADR-037), so validating it would be validating a comment — and failing
|
|
119
|
+
// `check` on a stale value would teach that the key still matters.
|
|
122
120
|
|
|
123
121
|
// Only what holds for every platform. What one platform needs and the next
|
|
124
122
|
// has no concept of — a GCP project, an AWS role ARN — is refused by the
|
|
@@ -146,6 +144,20 @@ function assertReferentialIntegrity(config: Config, source: string): void {
|
|
|
146
144
|
connectionKeys.add(key);
|
|
147
145
|
});
|
|
148
146
|
|
|
147
|
+
// Same reason as a duplicate connection: two entries with the same kind and
|
|
148
|
+
// value cannot both be meant, and the one that loses is invisible. It matters
|
|
149
|
+
// more here than it looks, because the two would usually differ only in their
|
|
150
|
+
// `note` — so the discarded one is precisely the guidance someone wrote down
|
|
151
|
+
// to stop an agent picking wrong.
|
|
152
|
+
const identityKeys = new Set<string>();
|
|
153
|
+
config.identity.forEach((entry, index) => {
|
|
154
|
+
const key = `${entry.kind}=${entry.value}`;
|
|
155
|
+
if (identityKeys.has(key)) {
|
|
156
|
+
problems.push(`identity[${index}]: duplicate entry "${entry.kind}: ${entry.value}"`);
|
|
157
|
+
}
|
|
158
|
+
identityKeys.add(key);
|
|
159
|
+
});
|
|
160
|
+
|
|
149
161
|
const checkRules = (rules: readonly PolicyRuleConfig[], field: 'allow' | 'deny'): void => {
|
|
150
162
|
rules.forEach((rule, index) => {
|
|
151
163
|
const where = `policy.${field}[${index}]`;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* The
|
|
4
|
+
* The four string shapes the config contract is built out of.
|
|
5
5
|
*
|
|
6
6
|
* Their own file because more than one schema module needs them, and the
|
|
7
7
|
* alternative was either a circular import or a second copy of a regex that
|
|
@@ -43,3 +43,26 @@ export const capabilityPattern = z
|
|
|
43
43
|
/^(?:\*|[a-z][a-z0-9_]*\.(?:\*|[A-Za-z0-9][A-Za-z0-9_-]*(?:\.[A-Za-z0-9][A-Za-z0-9_-]*)*(?:\.\*)?))$/,
|
|
44
44
|
'must be "*", "gmail.*", "gmail.users.*", or "gmail.users.drafts.send"',
|
|
45
45
|
);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A browser origin, exactly — scheme, host, and port, with nothing after it. Or
|
|
49
|
+
* `*`, which is what an absent list means anyway and is accepted so that saying
|
|
50
|
+
* it explicitly is not an error.
|
|
51
|
+
*
|
|
52
|
+
* Checked by round trip rather than by a regex, because `URL` already knows what
|
|
53
|
+
* an origin is and the failure this catches is a trailing slash or a path that
|
|
54
|
+
* looks harmless and matches nothing: a browser sends `Origin: https://x.example`
|
|
55
|
+
* and a configured `https://x.example/` would compare unequal, silently refusing
|
|
56
|
+
* the origin its owner believed they had allowed.
|
|
57
|
+
*/
|
|
58
|
+
export const browserOrigin = z.string().refine(
|
|
59
|
+
(value) => {
|
|
60
|
+
if (value === '*') return true;
|
|
61
|
+
try {
|
|
62
|
+
return new URL(value).origin === value;
|
|
63
|
+
} catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
'must be "*" or an origin with no trailing slash or path, e.g. "https://chat.example"',
|
|
68
|
+
);
|
package/src/profile/schema.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { capabilityPattern, credentialRef, identifier } from './primitives.ts';
|
|
2
|
+
import { browserOrigin, capabilityPattern, credentialRef, identifier } from './primitives.ts';
|
|
3
3
|
import { authorizationSchema } from './authorization.ts';
|
|
4
|
+
import { identitySchema } from './identity.ts';
|
|
5
|
+
import { knowledgeTargetSchema } from './knowledge.ts';
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* Configuration is declarative desired state. This file is the source of truth
|
|
@@ -178,6 +180,20 @@ export const deployTargetSchema = z.object({
|
|
|
178
180
|
billing_account: z.string().optional(),
|
|
179
181
|
/** The identity the running revision assumes. Needs read access to the credential store. */
|
|
180
182
|
service_account: z.string().optional(),
|
|
183
|
+
/**
|
|
184
|
+
* Instances kept running when nothing is calling.
|
|
185
|
+
*
|
|
186
|
+
* Zero is the default and the right answer for almost everything here: a cold
|
|
187
|
+
* start on the MCP path measures under three seconds, and the platform queues
|
|
188
|
+
* the request behind it, so scaling to zero is invisible to a caller.
|
|
189
|
+
*
|
|
190
|
+
* It is a knob because one path is not a caller. A client refreshes its token
|
|
191
|
+
* exactly when it wakes after an idle gap — which is exactly when the instance
|
|
192
|
+
* is cold — and a refresh that fails at the network level sends a remote
|
|
193
|
+
* client through a fresh browser authorization rather than surfacing an error.
|
|
194
|
+
* Raise it if a re-authorization ever lines up with a cold `/token`.
|
|
195
|
+
*/
|
|
196
|
+
min_instances: z.number().int().min(0).max(10).default(0),
|
|
181
197
|
});
|
|
182
198
|
|
|
183
199
|
/**
|
|
@@ -200,6 +216,15 @@ export const targetSchema = z
|
|
|
200
216
|
audit: auditTargetSchema.optional(),
|
|
201
217
|
storage: storageTargetSchema,
|
|
202
218
|
vault: vaultTargetSchema.optional(),
|
|
219
|
+
/**
|
|
220
|
+
* Memory and skills, somewhere other than `storage` above.
|
|
221
|
+
*
|
|
222
|
+
* Optional and absent by default, so every profile written before it keeps
|
|
223
|
+
* storing both exactly where it did. See `knowledge.ts` for why these two
|
|
224
|
+
* are separable from the rest and why the credential store and the vault
|
|
225
|
+
* are not.
|
|
226
|
+
*/
|
|
227
|
+
knowledge: knowledgeTargetSchema.optional(),
|
|
203
228
|
deploy: deployTargetSchema.optional(),
|
|
204
229
|
/** @deprecated Write `deploy` with `platform: cloudrun`. */
|
|
205
230
|
cloudrun: legacyCloudRunSchema.optional(),
|
|
@@ -221,7 +246,18 @@ export const targetSchema = z
|
|
|
221
246
|
.transform(({ cloudrun, ...target }) =>
|
|
222
247
|
target.deploy || !cloudrun
|
|
223
248
|
? target
|
|
224
|
-
: {
|
|
249
|
+
: {
|
|
250
|
+
...target,
|
|
251
|
+
// The pre-`deploy` spelling predates both of these, so it gets the
|
|
252
|
+
// same defaults the current one would: the closed door, and no
|
|
253
|
+
// instance kept warm.
|
|
254
|
+
deploy: {
|
|
255
|
+
...cloudrun,
|
|
256
|
+
platform: 'cloudrun' as const,
|
|
257
|
+
access: 'iam' as const,
|
|
258
|
+
min_instances: 0,
|
|
259
|
+
},
|
|
260
|
+
},
|
|
225
261
|
);
|
|
226
262
|
|
|
227
263
|
/**
|
|
@@ -288,7 +324,22 @@ export const configSchema = z.object({
|
|
|
288
324
|
|
|
289
325
|
instance: z.object({
|
|
290
326
|
profile: identifier,
|
|
291
|
-
|
|
327
|
+
/**
|
|
328
|
+
* @deprecated Parsed, never read. See ADR-037.
|
|
329
|
+
*
|
|
330
|
+
* Every command names its target on the command line now, so nothing
|
|
331
|
+
* consults this. It stays *declared* rather than being dropped, and that is
|
|
332
|
+
* the whole point: an undeclared key is stripped silently by the schema,
|
|
333
|
+
* which would leave `check` and `doctor` with nothing to report and an
|
|
334
|
+
* operator staring at a line they reasonably believe still selects
|
|
335
|
+
* something. Declaring it is what lets them be told it is inert.
|
|
336
|
+
*
|
|
337
|
+
* Optional, so a profile written today needs no such line, and unvalidated,
|
|
338
|
+
* so a stale value naming a target that no longer exists is harmless rather
|
|
339
|
+
* than a failure on a key nothing reads. The `database:` note above records
|
|
340
|
+
* the same decision for the same reason.
|
|
341
|
+
*/
|
|
342
|
+
default_target: z.string().min(1).optional(),
|
|
292
343
|
port: z.number().int().min(1).max(65535).default(7337),
|
|
293
344
|
/**
|
|
294
345
|
* Loopback by default. Binding elsewhere is possible but the server
|
|
@@ -309,6 +360,15 @@ export const configSchema = z.object({
|
|
|
309
360
|
* get one. Omitting it leaves every existing profile behaving identically.
|
|
310
361
|
*/
|
|
311
362
|
authorization: authorizationSchema.optional(),
|
|
363
|
+
/**
|
|
364
|
+
* Browser origins that may call a *deployment's* MCP endpoint. Absent
|
|
365
|
+
* means `*`, so naming any is a narrowing and nothing needs setting.
|
|
366
|
+
*
|
|
367
|
+
* A deployment only, and this cannot widen that: a loopback endpoint
|
|
368
|
+
* refuses every cross-origin request and must keep doing so. Why the
|
|
369
|
+
* default is a wildcard is `src/server/cors.ts` and ADR-040.
|
|
370
|
+
*/
|
|
371
|
+
allowed_origins: z.array(browserOrigin).optional(),
|
|
312
372
|
})
|
|
313
373
|
.default({ mode: 'bearer', token_ref: 'profile/token' }),
|
|
314
374
|
|
|
@@ -328,6 +388,13 @@ export const configSchema = z.object({
|
|
|
328
388
|
*/
|
|
329
389
|
connections: z.array(connectionSchema).default([]),
|
|
330
390
|
policy: policySchema.default({ allow: [], deny: [] }),
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Who the owner is, for anything written as them. Optional and additive, so
|
|
394
|
+
* every profile written before it keeps loading unchanged — the same reasoning
|
|
395
|
+
* as `auth.authorization` above, and the reason `contract` does not move.
|
|
396
|
+
*/
|
|
397
|
+
identity: identitySchema.default([]),
|
|
331
398
|
});
|
|
332
399
|
|
|
333
400
|
export type Config = z.infer<typeof configSchema>;
|
|
@@ -336,7 +403,8 @@ export type PolicyRuleConfig = z.infer<typeof policyRuleSchema>;
|
|
|
336
403
|
export type TargetConfig = z.infer<typeof targetSchema>;
|
|
337
404
|
export type DeployConfig = z.infer<typeof deployTargetSchema>;
|
|
338
405
|
export type AuthorizationConfig = z.infer<typeof authorizationSchema>;
|
|
339
|
-
export { authorizationSchema };
|
|
406
|
+
export { authorizationSchema, identitySchema };
|
|
407
|
+
export type { IdentityEntry } from './identity.ts';
|
|
340
408
|
|
|
341
409
|
/** The workspace file: `lanes-link.yaml`, alongside a `profiles/` directory. */
|
|
342
410
|
export const workspaceSchema = z.object({
|
package/src/profile/targets.ts
CHANGED
|
@@ -6,147 +6,107 @@ import type { Config } from './schema.ts';
|
|
|
6
6
|
*
|
|
7
7
|
* A target names an adapter set — where credentials are kept and where bytes
|
|
8
8
|
* go — and choosing one is a different subject from finding the workspace and
|
|
9
|
-
* the profile, which is why it is a different file.
|
|
10
|
-
* size budget holding both, and the budget's job is to say which of the two
|
|
11
|
-
* things a file is doing should leave.
|
|
9
|
+
* the profile, which is why it is a different file.
|
|
12
10
|
*
|
|
13
|
-
*
|
|
14
|
-
* `instance.default_target`.
|
|
15
|
-
*
|
|
11
|
+
* **`--target`, or the command does not run** (ADR-037). There is no fallback:
|
|
12
|
+
* not `LANES_LINK_TARGET`, not `instance.default_target`. Both are still
|
|
13
|
+
* parsed, so no existing config file has to change, and neither is read.
|
|
14
|
+
*
|
|
15
|
+
* The chain this replaces resolved `--target`, then the variable, then the key,
|
|
16
|
+
* and printed which of the three it landed on. What that bought was one flag
|
|
17
|
+
* saved per command. What it cost was that an *ignored* flag still produced a
|
|
18
|
+
* working command — `profile add --target cloud` dropped the flag on the floor
|
|
19
|
+
* and the next command carried on from a different source, so the mistake
|
|
20
|
+
* surfaced one command later with nothing connecting it to its cause. A
|
|
21
|
+
* resolver with nowhere to fall back to cannot fail that way.
|
|
16
22
|
*/
|
|
17
23
|
|
|
18
|
-
/** The variable that names a target for every command in a shell. */
|
|
19
|
-
export const TARGET_ENV = 'LANES_LINK_TARGET';
|
|
20
|
-
|
|
21
24
|
/**
|
|
22
|
-
* The target
|
|
23
|
-
*
|
|
24
|
-
* Split out because two callers need this precedence and must not disagree
|
|
25
|
-
* about it: `resolveSelection` records a provisional answer for `announce`
|
|
26
|
-
* before any config is loaded, and `resolveTarget` settles it afterwards. While
|
|
27
|
-
* the flag was the only source, both could spell it `targetFlag ?? …` and stay
|
|
28
|
-
* accidentally correct. With a second source, one of them learning about it and
|
|
29
|
-
* the other not is a line reading `config-default` beside a target the
|
|
30
|
-
* environment chose — which is the one line that exists to prevent exactly that.
|
|
25
|
+
* The target this command named, checked against what the profile declares.
|
|
31
26
|
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
27
|
+
* `allowUndeclared` is for the one command whose job is to create the target it
|
|
28
|
+
* was given — `deploy`, on a first run. Every other command naming a target that
|
|
29
|
+
* does not exist has made a typo, and the list of what does exist is the useful
|
|
30
|
+
* answer.
|
|
34
31
|
*/
|
|
35
|
-
export function
|
|
32
|
+
export function requireTarget(
|
|
33
|
+
config: Config,
|
|
36
34
|
targetFlag: string | undefined,
|
|
37
|
-
|
|
38
|
-
):
|
|
39
|
-
if (targetFlag)
|
|
35
|
+
options: { allowUndeclared?: boolean; profile?: string } = {},
|
|
36
|
+
): string {
|
|
37
|
+
if (!targetFlag) throw noTargetNamed(config, options.profile);
|
|
40
38
|
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
if (options.allowUndeclared !== true && !(targetFlag in config.targets)) {
|
|
40
|
+
throw undeclaredTarget(targetFlag, config, options.profile);
|
|
41
|
+
}
|
|
43
42
|
|
|
44
|
-
return
|
|
43
|
+
return targetFlag;
|
|
45
44
|
}
|
|
46
45
|
|
|
47
46
|
/**
|
|
48
|
-
* The refusal for a
|
|
47
|
+
* The refusal for a command that named no target.
|
|
49
48
|
*
|
|
50
|
-
* It
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* that reads as a problem with the config file.
|
|
49
|
+
* It lists the targets with their adapters, because "which one" is the question
|
|
50
|
+
* being asked and the adapter set is what distinguishes them. It also reports
|
|
51
|
+
* the two things that used to answer this and no longer do — an exported
|
|
52
|
+
* variable and the key still sitting in the file — since an operator looking at
|
|
53
|
+
* either has every reason to believe it is still working.
|
|
56
54
|
*/
|
|
57
|
-
export function
|
|
58
|
-
target: string,
|
|
55
|
+
export function noTargetNamed(
|
|
59
56
|
config: Config,
|
|
60
|
-
|
|
57
|
+
profile?: string,
|
|
58
|
+
env: Record<string, string | undefined> = process.env as Record<string, string | undefined>,
|
|
61
59
|
): ConfigError {
|
|
62
|
-
const
|
|
60
|
+
const names = Object.keys(config.targets);
|
|
61
|
+
const whose = profile ? ` by profile "${profile}"` : '';
|
|
62
|
+
|
|
63
|
+
const rows = names
|
|
64
|
+
.map((name) => {
|
|
65
|
+
const declared = config.targets[name]!;
|
|
66
|
+
const deployed = declared.deploy ? ' deployed' : '';
|
|
67
|
+
return ` ${name} ${declared.credentials.adapter} ${declared.storage.adapter}${deployed}`;
|
|
68
|
+
})
|
|
69
|
+
.join('\n');
|
|
70
|
+
|
|
71
|
+
const stale = env[LEGACY_TARGET_ENV];
|
|
72
|
+
const inert = config.instance.default_target;
|
|
73
|
+
|
|
63
74
|
return new ConfigError(
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
75
|
+
'--target is required. This command opens a target\'s stores, and nothing\n' +
|
|
76
|
+
'else selects one.\n\n' +
|
|
77
|
+
` Targets declared${whose}\n${rows}\n` +
|
|
78
|
+
`\n e.g. lanes link status --profile ${profile ?? '<name>'} --target ${names[0] ?? '<target>'}` +
|
|
79
|
+
(stale
|
|
80
|
+
? `\n\n ${LEGACY_TARGET_ENV}=${stale} is set in this shell and is no longer read.\n` +
|
|
81
|
+
' Unset it, or pass --target.'
|
|
82
|
+
: '') +
|
|
83
|
+
(inert
|
|
84
|
+
? `\n\n instance.default_target: ${inert} is still in this profile. It is no longer\n` +
|
|
85
|
+
' read either, and is safe to delete.'
|
|
67
86
|
: ''),
|
|
68
87
|
);
|
|
69
88
|
}
|
|
70
89
|
|
|
71
90
|
/**
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
* Order: `--target`, then `LANES_LINK_TARGET`, then `instance.default_target`.
|
|
75
|
-
* The middle one is the same bargain `LANES_LINK_PROFILE` already offers — a
|
|
76
|
-
* shell's worth of commands without retyping a flag, somewhere `env` shows it.
|
|
91
|
+
* The variable that used to name a target, kept only to say it is ignored.
|
|
77
92
|
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
93
|
+
* Not read by anything that resolves. It exists so a refusal can name the thing
|
|
94
|
+
* an operator is looking at and reasonably believes is still working — which is
|
|
95
|
+
* the whole difference between "this stopped working" and "this stopped working
|
|
96
|
+
* and here is why".
|
|
82
97
|
*/
|
|
83
|
-
export
|
|
84
|
-
config: Config,
|
|
85
|
-
targetFlag?: string,
|
|
86
|
-
options: {
|
|
87
|
-
allowUndeclared?: boolean;
|
|
88
|
-
env?: Record<string, string | undefined>;
|
|
89
|
-
} = {},
|
|
90
|
-
): { target: string; source: 'flag' | 'environment' | 'config-default' } {
|
|
91
|
-
const env = options.env ?? (process.env as Record<string, string | undefined>);
|
|
92
|
-
const asked = askedTarget(targetFlag, env);
|
|
93
|
-
|
|
94
|
-
const target = asked.target ?? config.instance.default_target;
|
|
95
|
-
const source = asked.source ?? 'config-default';
|
|
96
|
-
|
|
97
|
-
if (options.allowUndeclared !== true && !(target in config.targets)) {
|
|
98
|
-
throw undeclaredTarget(target, config, source);
|
|
99
|
-
}
|
|
100
|
-
return { target, source };
|
|
101
|
-
}
|
|
98
|
+
export const LEGACY_TARGET_ENV = 'LANES_LINK_TARGET';
|
|
102
99
|
|
|
103
100
|
/**
|
|
104
|
-
* The
|
|
105
|
-
*
|
|
106
|
-
* `--target cloud` was required on every deploy, and the reason was an accident:
|
|
107
|
-
* an absent flag falls back to `instance.default_target`, which is `local` —
|
|
108
|
-
* a target that by definition is not deployed anywhere. So the one command whose
|
|
109
|
-
* subject is never ambiguous was the one command that made you say it, and the
|
|
110
|
-
* default it would otherwise have taken was not merely unhelpful but wrong.
|
|
111
|
-
*
|
|
112
|
-
* The rule is what someone would say out loud: deploy the target that has a
|
|
113
|
-
* deployment. One is the answer; none means the first run, which conventionally
|
|
114
|
-
* creates `cloud` and is what every example in the docs names; several is a
|
|
115
|
-
* genuine question, and asking beats rolling a revision to whichever came first
|
|
116
|
-
* in a YAML mapping.
|
|
117
|
-
*
|
|
118
|
-
* `--target` still wins, which is how you deploy the second one.
|
|
101
|
+
* The refusal for a target that is not in the file, in one spelling.
|
|
119
102
|
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
* for this question. It is also the only place where being wrong creates cloud
|
|
124
|
-
* resources rather than an error: `deploy` is the one caller that passes
|
|
125
|
-
* `allowUndeclared`, so an exported typo would not be refused, it would be
|
|
126
|
-
* surveyed, written into the profile, and rolled out as a new service. An
|
|
127
|
-
* environment variable must not be able to name a Cloud Run service into
|
|
128
|
-
* existence. Say `--target`; `deploy` is rare enough to afford it.
|
|
103
|
+
* It was two — here and in the CLI's `openSecretStoreFor` — which is one more
|
|
104
|
+
* than a sentence naming the available targets survives: the copies drift the
|
|
105
|
+
* moment either learns something the other does not.
|
|
129
106
|
*/
|
|
130
|
-
export
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
config: Config,
|
|
134
|
-
targetFlag?: string,
|
|
135
|
-
): { target: string; source: 'flag' | 'deployable' } {
|
|
136
|
-
if (targetFlag) return { target: targetFlag, source: 'flag' };
|
|
137
|
-
|
|
138
|
-
// `deploy` alone: the legacy `cloudrun:` spelling is normalised into it by the
|
|
139
|
-
// loader, so exactly one shape reaches here.
|
|
140
|
-
const deployable = Object.entries(config.targets)
|
|
141
|
-
.filter(([, declared]) => declared.deploy !== undefined)
|
|
142
|
-
.map(([name]) => name);
|
|
143
|
-
|
|
144
|
-
if (deployable.length > 1) {
|
|
145
|
-
throw new ConfigError(
|
|
146
|
-
`This profile declares ${deployable.length} deployable targets (${deployable.join(', ')}). ` +
|
|
147
|
-
'Name the one you mean with --target.',
|
|
148
|
-
);
|
|
149
|
-
}
|
|
107
|
+
export function undeclaredTarget(target: string, config: Config, profile?: string): ConfigError {
|
|
108
|
+
const have = Object.keys(config.targets).join(', ') || 'none';
|
|
109
|
+
const whose = profile ? `profile "${profile}"` : 'this profile';
|
|
150
110
|
|
|
151
|
-
return {
|
|
111
|
+
return new ConfigError(`Target "${target}" is not declared by ${whose} (have: ${have})`);
|
|
152
112
|
}
|