@lanes-sh/link 0.1.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/LICENSE +202 -0
- package/README.md +144 -0
- package/bin/lanes +42 -0
- package/instructions/agents/lanes-link-scout.md +73 -0
- package/instructions/skills/lanes-link/SKILL.md +187 -0
- package/package.json +95 -0
- package/src/audit/chain.ts +278 -0
- package/src/audit/conformance.ts +374 -0
- package/src/audit/fanout.ts +97 -0
- package/src/audit/index.ts +218 -0
- package/src/audit/stdout.ts +60 -0
- package/src/auth/index.ts +220 -0
- package/src/auth/oauth/metadata.ts +75 -0
- package/src/auth/oauth/server.ts +385 -0
- package/src/auth/oauth/store.ts +215 -0
- package/src/auth/oidc.ts +206 -0
- package/src/auth/remote.ts +72 -0
- package/src/cli/argv.ts +94 -0
- package/src/cli/callback-page.ts +256 -0
- package/src/cli/commands/connect/accounts.ts +94 -0
- package/src/cli/commands/connect/authorise.ts +298 -0
- package/src/cli/commands/connect/client.ts +284 -0
- package/src/cli/commands/connect/index.ts +398 -0
- package/src/cli/commands/connect/outcome.ts +119 -0
- package/src/cli/commands/connect/requirements.ts +103 -0
- package/src/cli/commands/connect/scopes-gate.ts +146 -0
- package/src/cli/commands/connect/settle.ts +136 -0
- package/src/cli/commands/connect/setup.ts +276 -0
- package/src/cli/commands/mcp/assets.ts +189 -0
- package/src/cli/commands/mcp/harnesses.ts +143 -0
- package/src/cli/commands/mcp/list.ts +70 -0
- package/src/cli/commands/mcp/register.ts +199 -0
- package/src/cli/commands/mcp/stdio.ts +57 -0
- package/src/cli/commands/mcp.ts +22 -0
- package/src/cli/commands/operate/attach.ts +121 -0
- package/src/cli/commands/operate/audit.ts +119 -0
- package/src/cli/commands/operate/inspect.ts +355 -0
- package/src/cli/commands/operate/outputs.ts +206 -0
- package/src/cli/commands/operate/policy.ts +80 -0
- package/src/cli/commands/operate/serve.ts +55 -0
- package/src/cli/commands/operate/status.ts +133 -0
- package/src/cli/commands/operate/token.ts +70 -0
- package/src/cli/commands/operate.ts +27 -0
- package/src/cli/commands/owner/memory.ts +110 -0
- package/src/cli/commands/owner/shared.ts +125 -0
- package/src/cli/commands/owner/skills.ts +92 -0
- package/src/cli/commands/owner/vault.ts +140 -0
- package/src/cli/commands/owner.ts +46 -0
- package/src/cli/commands/profile/removal.ts +278 -0
- package/src/cli/commands/profile/remove.ts +315 -0
- package/src/cli/commands/profile.ts +156 -0
- package/src/cli/commands/secrets.ts +176 -0
- package/src/cli/commands/setup.ts +150 -0
- package/src/cli/commands/target.ts +310 -0
- package/src/cli/config-edit.ts +397 -0
- package/src/cli/endpoint-url.ts +77 -0
- package/src/cli/identity.ts +109 -0
- package/src/cli/lanes.ts +78 -0
- package/src/cli/main.ts +333 -0
- package/src/cli/oauth-error.ts +13 -0
- package/src/cli/oauth-exchange.ts +146 -0
- package/src/cli/oauth.ts +354 -0
- package/src/cli/output.ts +184 -0
- package/src/cli/prompt.ts +180 -0
- package/src/cli/publish.ts +185 -0
- package/src/cli/runtime/discovery.ts +123 -0
- package/src/cli/runtime/open.ts +342 -0
- package/src/cli/runtime/registry.ts +185 -0
- package/src/cli/runtime/select.ts +124 -0
- package/src/cli/runtime.ts +34 -0
- package/src/cli/scopes.ts +63 -0
- package/src/cli/usage.ts +111 -0
- package/src/cli/version.ts +25 -0
- package/src/connectivity/auth/README.md +36 -0
- package/src/connectivity/auth/api-key/index.ts +43 -0
- package/src/connectivity/auth/authorize.ts +49 -0
- package/src/connectivity/auth/basic/index.ts +68 -0
- package/src/connectivity/auth/bearer/index.ts +13 -0
- package/src/connectivity/auth/credential.ts +19 -0
- package/src/connectivity/auth/header/index.ts +18 -0
- package/src/connectivity/auth/index.ts +35 -0
- package/src/connectivity/auth/none/index.ts +12 -0
- package/src/connectivity/auth/oauth-authcode/broker.ts +261 -0
- package/src/connectivity/auth/oauth-authcode/index.ts +64 -0
- package/src/connectivity/auth/oauth-authcode/provider.ts +279 -0
- package/src/connectivity/auth/oauth-authcode/refresh.ts +118 -0
- package/src/connectivity/auth/resolve.ts +61 -0
- package/src/connectivity/auth/strategy/index.ts +14 -0
- package/src/connectivity/capability.ts +164 -0
- package/src/connectivity/connector.ts +175 -0
- package/src/connectivity/context.ts +77 -0
- package/src/connectivity/index.ts +94 -0
- package/src/connectivity/mail/attachments.ts +368 -0
- package/src/connectivity/mail/compose.ts +73 -0
- package/src/connectivity/mail/index.ts +40 -0
- package/src/connectivity/mail/message.ts +82 -0
- package/src/connectivity/mail/nodemailer.d.ts +83 -0
- package/src/connectivity/mail/staging.ts +143 -0
- package/src/connectivity/mail/url.ts +301 -0
- package/src/connectivity/manifest/auth.ts +135 -0
- package/src/connectivity/manifest/bundles.ts +23 -0
- package/src/connectivity/manifest/connector.ts +168 -0
- package/src/connectivity/manifest/credential-ref.ts +73 -0
- package/src/connectivity/manifest/identity.ts +43 -0
- package/src/connectivity/manifest/index.ts +46 -0
- package/src/connectivity/manifest/primitives.ts +20 -0
- package/src/connectivity/manifest/provider.ts +208 -0
- package/src/connectivity/manifest/requirements.ts +146 -0
- package/src/connectivity/manifest/setup.ts +55 -0
- package/src/connectivity/provider.ts +163 -0
- package/src/connectivity/transports/README.md +33 -0
- package/src/connectivity/transports/composite/index.ts +68 -0
- package/src/connectivity/transports/dav/calendar.ts +217 -0
- package/src/connectivity/transports/dav/capabilities.ts +151 -0
- package/src/connectivity/transports/dav/client.ts +226 -0
- package/src/connectivity/transports/dav/contacts.ts +75 -0
- package/src/connectivity/transports/dav/ical.ts +412 -0
- package/src/connectivity/transports/dav/index.ts +143 -0
- package/src/connectivity/transports/dav/operations.ts +43 -0
- package/src/connectivity/transports/dav/request.ts +161 -0
- package/src/connectivity/transports/dav/xml.ts +123 -0
- package/src/connectivity/transports/factory.ts +181 -0
- package/src/connectivity/transports/fs/capabilities.ts +88 -0
- package/src/connectivity/transports/fs/commands.ts +258 -0
- package/src/connectivity/transports/fs/index.ts +121 -0
- package/src/connectivity/transports/fs/operations.ts +45 -0
- package/src/connectivity/transports/fs/paths.ts +120 -0
- package/src/connectivity/transports/fs/result.ts +12 -0
- package/src/connectivity/transports/http/index.ts +255 -0
- package/src/connectivity/transports/imap/attachment.ts +166 -0
- package/src/connectivity/transports/imap/capabilities.ts +158 -0
- package/src/connectivity/transports/imap/client.ts +398 -0
- package/src/connectivity/transports/imap/commands.ts +385 -0
- package/src/connectivity/transports/imap/index.ts +152 -0
- package/src/connectivity/transports/imap/operations.ts +64 -0
- package/src/connectivity/transports/imap/parse.ts +130 -0
- package/src/connectivity/transports/imap/parser.ts +272 -0
- package/src/connectivity/transports/imap/result.ts +15 -0
- package/src/connectivity/transports/imap/send.ts +92 -0
- package/src/connectivity/transports/imap/socket.ts +111 -0
- package/src/connectivity/transports/imap/utf7.ts +136 -0
- package/src/connectivity/transports/index.ts +20 -0
- package/src/connectivity/transports/local/index.ts +173 -0
- package/src/connectivity/transports/mcp/index.ts +215 -0
- package/src/deployments/README.md +63 -0
- package/src/deployments/adapters/audit-blob.ts +203 -0
- package/src/deployments/adapters/filesystem.ts +184 -0
- package/src/deployments/adapters/gcp-secret-manager.ts +492 -0
- package/src/deployments/adapters/gcs.ts +191 -0
- package/src/deployments/adapters/otlp.ts +128 -0
- package/src/deployments/adapters/s3.ts +195 -0
- package/src/deployments/azure/README.md +21 -0
- package/src/deployments/bootstrap.ts +177 -0
- package/src/deployments/deploy.ts +290 -0
- package/src/deployments/driver.ts +157 -0
- package/src/deployments/drivers.ts +35 -0
- package/src/deployments/gcp/Dockerfile +70 -0
- package/src/deployments/gcp/cloudbuild.yaml +31 -0
- package/src/deployments/gcp/driver.ts +175 -0
- package/src/deployments/gcp/gcloud.ts +178 -0
- package/src/deployments/gcp/provision.ts +290 -0
- package/src/deployments/gcp/survey.ts +319 -0
- package/src/deployments/local/README.md +12 -0
- package/src/deployments/prepare.ts +257 -0
- package/src/deployments/steps.ts +137 -0
- package/src/deployments/target.ts +295 -0
- package/src/deployments/upload.ts +207 -0
- package/src/dispatch/context.ts +195 -0
- package/src/dispatch/dispatch.ts +350 -0
- package/src/dispatch/index.ts +32 -0
- package/src/dispatch/staging.ts +102 -0
- package/src/policy/index.ts +179 -0
- package/src/policy/limits.ts +77 -0
- package/src/profile/authorization.ts +81 -0
- package/src/profile/files.ts +71 -0
- package/src/profile/index.ts +76 -0
- package/src/profile/layout.ts +123 -0
- package/src/profile/load.ts +199 -0
- package/src/profile/primitives.ts +45 -0
- package/src/profile/schema.ts +347 -0
- package/src/profile/secret-detection.ts +162 -0
- package/src/profile/targets.ts +152 -0
- package/src/profile/workspace.ts +262 -0
- package/src/providers/custom/index.ts +21 -0
- package/src/providers/custom/load.ts +115 -0
- package/src/providers/custom/template.ts +156 -0
- package/src/providers/example/provider.ts +207 -0
- package/src/providers/google/calendar/index.ts +66 -0
- package/src/providers/google/calendar/redact.ts +40 -0
- package/src/providers/google/contacts/index.ts +50 -0
- package/src/providers/google/contacts/redact.ts +21 -0
- package/src/providers/google/docs/index.ts +45 -0
- package/src/providers/google/drive/hints.ts +28 -0
- package/src/providers/google/drive/index.ts +34 -0
- package/src/providers/google/drive/redact.ts +39 -0
- package/src/providers/google/drive-mcp/index.ts +21 -0
- package/src/providers/google/gmail/api.ts +42 -0
- package/src/providers/google/gmail/attachment.ts +142 -0
- package/src/providers/google/gmail/hints.ts +55 -0
- package/src/providers/google/gmail/index.ts +112 -0
- package/src/providers/google/gmail/redact.ts +56 -0
- package/src/providers/google/gmail/send.ts +365 -0
- package/src/providers/google/gmail-mcp/index.ts +35 -0
- package/src/providers/google/index.ts +10 -0
- package/src/providers/google/shared/oauth.ts +122 -0
- package/src/providers/google/shared/scopes.ts +99 -0
- package/src/providers/google/shared/setup.ts +80 -0
- package/src/providers/google/sheets/hints.ts +45 -0
- package/src/providers/google/sheets/index.ts +70 -0
- package/src/providers/google/sheets/redact.ts +45 -0
- package/src/providers/google/specs/calendar.v3.json +1829 -0
- package/src/providers/google/specs/docs.v1.json +381 -0
- package/src/providers/google/specs/drive.v3.json +2208 -0
- package/src/providers/google/specs/gmail.v1.json +2578 -0
- package/src/providers/google/specs/people.v1.json +506 -0
- package/src/providers/google/specs/sheets.v4.json +1269 -0
- package/src/providers/google/specs/tasks.v1.json +840 -0
- package/src/providers/google/specs/vendor.ts +661 -0
- package/src/providers/google/tasks/index.ts +53 -0
- package/src/providers/google/tasks/redact.ts +34 -0
- package/src/providers/harness.ts +95 -0
- package/src/providers/icloud/calendar/index.ts +27 -0
- package/src/providers/icloud/contacts/index.ts +17 -0
- package/src/providers/icloud/drive/index.ts +47 -0
- package/src/providers/icloud/index.ts +8 -0
- package/src/providers/icloud/mail/index.ts +37 -0
- package/src/providers/icloud/shared/setup.ts +66 -0
- package/src/providers/index.ts +93 -0
- package/src/providers/linear/index.ts +11 -0
- package/src/providers/linear/scopes.ts +7 -0
- package/src/providers/memory/provider.ts +429 -0
- package/src/providers/notion/index.ts +19 -0
- package/src/providers/owner.ts +49 -0
- package/src/providers/scopes.ts +26 -0
- package/src/providers/setup/plan.ts +141 -0
- package/src/providers/setup/provider.ts +323 -0
- package/src/providers/shared/frontmatter.ts +119 -0
- package/src/providers/skills/provider.ts +283 -0
- package/src/providers/skills/store.ts +252 -0
- package/src/providers/vault/provider.ts +194 -0
- package/src/registry/index.ts +36 -0
- package/src/registry/policy-bridge.ts +32 -0
- package/src/registry/reconcile.ts +313 -0
- package/src/registry/registry.ts +240 -0
- package/src/secrets/document.ts +293 -0
- package/src/secrets/index.ts +154 -0
- package/src/secrets/system.ts +173 -0
- package/src/secrets/vault.ts +336 -0
- package/src/server/attachments.ts +197 -0
- package/src/server/container.ts +96 -0
- package/src/server/edge.ts +53 -0
- package/src/server/endpoint.ts +352 -0
- package/src/server/generations.ts +362 -0
- package/src/server/harness.ts +400 -0
- package/src/server/index.ts +331 -0
- package/src/server/logging.ts +41 -0
- package/src/server/mcp/build.ts +68 -0
- package/src/server/mcp/icon.ts +145 -0
- package/src/server/mcp/index.ts +32 -0
- package/src/server/mcp/instructions.ts +245 -0
- package/src/server/mcp/naming.ts +39 -0
- package/src/server/mcp/prompts.ts +78 -0
- package/src/server/mcp/resources.ts +106 -0
- package/src/server/mcp/routing.ts +117 -0
- package/src/server/mcp/schema.ts +78 -0
- package/src/server/mcp/tools.ts +186 -0
- package/src/server/mcp/visibility.ts +132 -0
- package/src/server/oauth.ts +222 -0
- package/src/server/rebinding.ts +53 -0
- package/src/server/stdio.ts +192 -0
- package/src/stores/blobs/conformance.ts +223 -0
- package/src/stores/blobs/index.ts +125 -0
- package/src/stores/blobs/testing.ts +49 -0
- package/src/stores/state/index.ts +247 -0
- package/src/stores/state/keys.ts +68 -0
- package/src/stores/state/testing.ts +41 -0
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import { rename, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { Document, parseDocument, type Node } from 'yaml';
|
|
3
|
+
import {
|
|
4
|
+
ConfigError,
|
|
5
|
+
isRemoteWorkspace,
|
|
6
|
+
readWorkspaceFile,
|
|
7
|
+
validateConfig,
|
|
8
|
+
workspaceFiles,
|
|
9
|
+
writeWorkspaceFile,
|
|
10
|
+
} from '#profile';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Editing a profile's config file.
|
|
14
|
+
*
|
|
15
|
+
* The file is the source of truth, and an operator's comments and key ordering
|
|
16
|
+
* are part of what they wrote — a CLI that reformats the file on every edit
|
|
17
|
+
* makes the file hostile to hand-editing, which then makes the CLI mandatory.
|
|
18
|
+
* So edits go through the YAML Document API, which preserves both.
|
|
19
|
+
*
|
|
20
|
+
* Every write validates the resulting document first and writes through a
|
|
21
|
+
* temporary file, so a rejected edit leaves the original exactly as it was.
|
|
22
|
+
* A config file left invalid by a failed command is worse than a command that
|
|
23
|
+
* refuses to run.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export class ConfigDocument {
|
|
27
|
+
readonly #document: Document;
|
|
28
|
+
readonly #path: string;
|
|
29
|
+
readonly #location: { workspaceRoot: string; key: string } | undefined;
|
|
30
|
+
|
|
31
|
+
private constructor(
|
|
32
|
+
document: Document,
|
|
33
|
+
path: string,
|
|
34
|
+
location?: { workspaceRoot: string; key: string },
|
|
35
|
+
) {
|
|
36
|
+
this.#document = document;
|
|
37
|
+
this.#path = path;
|
|
38
|
+
this.#location = location;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Open a profile's config for editing, wherever the workspace keeps it.
|
|
43
|
+
*
|
|
44
|
+
* Takes the workspace root and a profile rather than a path, because those
|
|
45
|
+
* are no longer the same thing: a workspace may be a bucket, and `join` on a
|
|
46
|
+
* `gs://` URL produces something that addresses nothing.
|
|
47
|
+
*/
|
|
48
|
+
static async open(workspaceRoot: string, profile: string): Promise<ConfigDocument> {
|
|
49
|
+
const key = `profiles/${profile}.yaml`;
|
|
50
|
+
const shown = isRemoteWorkspace(workspaceRoot)
|
|
51
|
+
? `${workspaceRoot}/${key}`
|
|
52
|
+
: `${workspaceRoot}/${key}`;
|
|
53
|
+
|
|
54
|
+
const text = await readWorkspaceFile(workspaceFiles(workspaceRoot), key);
|
|
55
|
+
if (text === null) throw new ConfigError(`${shown}: no such config file`);
|
|
56
|
+
|
|
57
|
+
const document = parseDocument(text);
|
|
58
|
+
if (document.errors.length > 0) {
|
|
59
|
+
throw new ConfigError(`${shown}: ${document.errors[0]?.message ?? 'could not parse YAML'}`);
|
|
60
|
+
}
|
|
61
|
+
return new ConfigDocument(document, shown, { workspaceRoot, key });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
static fromText(text: string, path = '<config>'): ConfigDocument {
|
|
65
|
+
return new ConfigDocument(parseDocument(text), path);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
get path(): string {
|
|
69
|
+
return this.#path;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
toString(): string {
|
|
73
|
+
return this.#document.toString({ lineWidth: 0 });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
toJSON(): unknown {
|
|
77
|
+
return this.#document.toJSON();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Read a value by path, e.g. `['providers', 'gmail', 'enabled']`. */
|
|
81
|
+
getIn(path: readonly (string | number)[]): unknown {
|
|
82
|
+
return this.#document.getIn(path);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
setIn(path: readonly (string | number)[], value: unknown): void {
|
|
86
|
+
// `createNode` first: a plain JS object stored here is not a YAML
|
|
87
|
+
// collection, so a later `setIn` into the same path cannot traverse it and
|
|
88
|
+
// fails with "Expected YAML collection". Setting `providers.gmail` and then
|
|
89
|
+
// `providers.gmail.oauth_app` is exactly that sequence.
|
|
90
|
+
const node =
|
|
91
|
+
value !== null && typeof value === 'object' ? this.#document.createNode(value) : value;
|
|
92
|
+
|
|
93
|
+
this.#document.setIn(path as (string | number)[], node);
|
|
94
|
+
this.#expand(path.slice(0, -1));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Append to a sequence, creating it if absent.
|
|
99
|
+
*
|
|
100
|
+
* `inline` renders the appended item on one line. Policy rules are far
|
|
101
|
+
* easier to scan one-per-line — which is how init.md's own example writes
|
|
102
|
+
* them — while a connection carries enough fields to want a block.
|
|
103
|
+
*/
|
|
104
|
+
addTo(path: readonly (string | number)[], value: unknown, options: { inline?: boolean } = {}): void {
|
|
105
|
+
const node = this.#document.createNode(value) as Node & { flow?: boolean };
|
|
106
|
+
if (options.inline) node.flow = true;
|
|
107
|
+
|
|
108
|
+
// `null` as well as `undefined`: a key written with no value under it —
|
|
109
|
+
// `connections:` on a line of its own, which is what deleting the last entry
|
|
110
|
+
// by hand leaves behind — parses to a null scalar rather than to nothing.
|
|
111
|
+
// Calling `.add` on that is a TypeError with a stack trace, where the same
|
|
112
|
+
// file reached through `open` reports a readable config error, so the
|
|
113
|
+
// difference decided whether a command explained itself or crashed.
|
|
114
|
+
const existing = this.#document.getIn(path as (string | number)[]);
|
|
115
|
+
if (existing === undefined || existing === null) {
|
|
116
|
+
this.#document.setIn(path as (string | number)[], [node]);
|
|
117
|
+
} else {
|
|
118
|
+
(existing as { add(item: unknown): void }).add(node);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
this.#expand(path);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Force a collection we just grew onto multiple lines.
|
|
126
|
+
*
|
|
127
|
+
* A template starts with `connections: []`, and the YAML library keeps that
|
|
128
|
+
* flow style as items are appended — so a file meant to be hand-edited
|
|
129
|
+
* silently degrades into one unreadable line. Only the container we touched
|
|
130
|
+
* is expanded; a leaf like `{ adapter: filesystem, path: ./data/x }` reads
|
|
131
|
+
* better on one line and is left alone.
|
|
132
|
+
*/
|
|
133
|
+
#expand(path: readonly (string | number)[]): void {
|
|
134
|
+
const node = path.length === 0 ? this.#document.contents : this.#document.getIn(path as (string | number)[]);
|
|
135
|
+
const collection = node as { flow?: boolean; items?: unknown[] } | null;
|
|
136
|
+
|
|
137
|
+
if (collection && Array.isArray(collection.items) && collection.items.length > 0) {
|
|
138
|
+
collection.flow = false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Validate the resulting document, then write it atomically.
|
|
144
|
+
*
|
|
145
|
+
* Validation happens on the rendered text rather than the in-memory tree so
|
|
146
|
+
* that what is checked is exactly what would land on disk.
|
|
147
|
+
*/
|
|
148
|
+
async save(): Promise<void> {
|
|
149
|
+
const rendered = this.toString();
|
|
150
|
+
|
|
151
|
+
// Throws on any validation failure, including a credential value that has
|
|
152
|
+
// crept in — so a CLI edit can never introduce one.
|
|
153
|
+
validateConfig(this.#document.toJSON(), this.#path);
|
|
154
|
+
|
|
155
|
+
if (!this.#location) {
|
|
156
|
+
throw new ConfigError(`${this.#path}: opened from text, so there is nowhere to save it`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const { workspaceRoot, key } = this.#location;
|
|
160
|
+
|
|
161
|
+
if (isRemoteWorkspace(workspaceRoot)) {
|
|
162
|
+
// One PUT, which object storage makes atomic by construction — there is
|
|
163
|
+
// no torn write to guard against and no temporary object to leave behind
|
|
164
|
+
// if the process dies between the two halves of a rename.
|
|
165
|
+
await writeWorkspaceFile(workspaceFiles(workspaceRoot), key, rendered);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Write-then-rename locally, so a rejected edit or a crash mid-write leaves
|
|
170
|
+
// the original exactly as it was. 0600 because the file names credential
|
|
171
|
+
// refs and the policy that governs them.
|
|
172
|
+
const path = `${workspaceRoot}/${key}`;
|
|
173
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
174
|
+
await writeFile(temporary, rendered, { mode: 0o600 });
|
|
175
|
+
await rename(temporary, path);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The reserved provider id the setup surface registers under. */
|
|
180
|
+
const SETUP_PROVIDER_ID = 'setup';
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* What a repair did, split by what a caller does with each half.
|
|
184
|
+
*
|
|
185
|
+
* `connect` reports config edits under `changes` and policy under `granted`,
|
|
186
|
+
* and both are serialised verbatim by `--json` — so an audit asking what a
|
|
187
|
+
* command widened reads only the second, and one blended list of sentences
|
|
188
|
+
* filed the grant as an edit and left prose in a field meant for matching.
|
|
189
|
+
*/
|
|
190
|
+
export interface SetupRepair {
|
|
191
|
+
/** Config edits made, spelled for display. Empty when none were needed. */
|
|
192
|
+
readonly changes: readonly string[];
|
|
193
|
+
/** Allow rules added — patterns, not prose, so a caller can act on them. */
|
|
194
|
+
readonly granted: readonly string[];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Give a profile the `setup` surface if it does not already have it.
|
|
199
|
+
*
|
|
200
|
+
* A profile written before the surface existed has neither the connection row
|
|
201
|
+
* nor the allow rule, and the failure is silent in the worst way:
|
|
202
|
+
* `allowedConnections` returns nothing for a provider with no connection row
|
|
203
|
+
* *before* it consults policy, so `setup_overview` and `setup_provider` are
|
|
204
|
+
* simply absent from `tools/list` with nothing saying why. An agent asked what
|
|
205
|
+
* is connected then has nothing to read and invents a command — which is the
|
|
206
|
+
* bug this exists to close, not a hypothetical.
|
|
207
|
+
*
|
|
208
|
+
* Both halves or neither: a connection row without `setup.*` is as inert as the
|
|
209
|
+
* rule without the row, so adding one alone would look like a fix and change
|
|
210
|
+
* nothing.
|
|
211
|
+
*
|
|
212
|
+
* CLI-side by construction. ADR-007 keeps configuration mutation off the served
|
|
213
|
+
* surface, and a deployed revision holds `objectViewer` on `profiles/`
|
|
214
|
+
* (ADR-023) so it could not write this even if the code let it.
|
|
215
|
+
*/
|
|
216
|
+
export function ensureSetupConnection(document: ConfigDocument): SetupRepair {
|
|
217
|
+
// Raw YAML, so nothing here has been through a schema: this runs over sibling
|
|
218
|
+
// profiles that were never validated, and every field is whatever was typed.
|
|
219
|
+
const config = document.toJSON() as {
|
|
220
|
+
connections?: unknown;
|
|
221
|
+
policy?: { allow?: unknown; deny?: unknown };
|
|
222
|
+
} | null;
|
|
223
|
+
|
|
224
|
+
const rule = `${SETUP_PROVIDER_ID}.*`;
|
|
225
|
+
const covers = (pattern: string): boolean => pattern === '*' || pattern === rule;
|
|
226
|
+
|
|
227
|
+
// Denied on purpose, and a deny beats an allow — so writing the rule would
|
|
228
|
+
// widen nothing while announcing that an agent "can now see what is connected
|
|
229
|
+
// here", which would be false. Deleting the two lines no longer removes the
|
|
230
|
+
// surface, because the next `connect` or `deploy` puts them back; a deny is
|
|
231
|
+
// the way it stays off, so it is the one thing this must not undo.
|
|
232
|
+
//
|
|
233
|
+
// Only a rule covering the whole surface counts. `deny: [setup.provider]` is
|
|
234
|
+
// an operator narrowing it, not switching it off, and that narrowing survives
|
|
235
|
+
// the repair untouched — which is the point of denying one capability.
|
|
236
|
+
if (patternsIn(config?.policy?.deny).some(covers)) return { changes: [], granted: [] };
|
|
237
|
+
|
|
238
|
+
const changes: string[] = [];
|
|
239
|
+
const granted: string[] = [];
|
|
240
|
+
|
|
241
|
+
const connections = Array.isArray(config?.connections) ? config.connections : [];
|
|
242
|
+
const isSetup = (row: unknown): boolean =>
|
|
243
|
+
(row as { provider?: unknown } | null)?.provider === SETUP_PROVIDER_ID;
|
|
244
|
+
|
|
245
|
+
if (!connections.some(isSetup)) {
|
|
246
|
+
// Inline, and `main` for the id, so a repaired profile is spelled exactly
|
|
247
|
+
// like `newProfileTemplate` writes a fresh one. Two spellings of one row is
|
|
248
|
+
// how a template and its repair drift apart.
|
|
249
|
+
document.addTo(
|
|
250
|
+
['connections'],
|
|
251
|
+
{ id: 'main', provider: SETUP_PROVIDER_ID, account: 'Setup' },
|
|
252
|
+
{ inline: true },
|
|
253
|
+
);
|
|
254
|
+
changes.push(`connections += ${SETUP_PROVIDER_ID}.main`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// `*` already covers it. Re-stating the rule under a blanket allow would be
|
|
258
|
+
// noise in the file and a diff the operator did not ask for.
|
|
259
|
+
if (!patternsIn(config?.policy?.allow).some(covers)) {
|
|
260
|
+
document.addTo(['policy', 'allow'], rule, { inline: true });
|
|
261
|
+
granted.push(rule);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return { changes, granted };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Whether a repair did anything, without a caller adding up two lists. */
|
|
268
|
+
export function repaired(repair: SetupRepair): boolean {
|
|
269
|
+
return repair.changes.length > 0 || repair.granted.length > 0;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** The repair as display lines, in the order the two halves are applied. */
|
|
273
|
+
export function repairLines(repair: SetupRepair): string[] {
|
|
274
|
+
return [...repair.changes, ...repair.granted.map((rule) => `policy.allow += ${rule}`)];
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The patterns a raw policy list puts *in force*, in either spelling.
|
|
279
|
+
*
|
|
280
|
+
* `policyRuleSchema` takes a bare pattern or `{ capability, expires_at }` and
|
|
281
|
+
* both parse to the same thing, so reading only the string form would re-add a
|
|
282
|
+
* rule the operator had already written with an expiry. Anything that is
|
|
283
|
+
* neither is dropped rather than guessed at: this reads unvalidated YAML, and a
|
|
284
|
+
* malformed rule is for `validateConfig` to report, not for this to interpret.
|
|
285
|
+
*
|
|
286
|
+
* **Expiry is part of the reading.** `evaluate` holds a rule to
|
|
287
|
+
* `expiresAt === undefined || expiresAt > now` (`#policy`), so a lapsed rule
|
|
288
|
+
* grants and denies nothing — and reading the capability alone got both
|
|
289
|
+
* directions wrong. A lapsed *allow* read as live, so the repair wrote the row,
|
|
290
|
+
* skipped the rule, and announced success: the inert half-state this exists to
|
|
291
|
+
* prevent. A lapsed *deny* blocked the repair for good and printed nothing,
|
|
292
|
+
* because having nothing to add is how "already had it" looks.
|
|
293
|
+
*
|
|
294
|
+
* An unparseable date reads as lapsed, which is the safe direction — it adds a
|
|
295
|
+
* working rule rather than trusting a broken one, and the `save` that follows
|
|
296
|
+
* hands the malformed value to `validateConfig`, whose job it is to complain.
|
|
297
|
+
*/
|
|
298
|
+
function patternsIn(rules: unknown, now = Date.now()): string[] {
|
|
299
|
+
if (!Array.isArray(rules)) return [];
|
|
300
|
+
|
|
301
|
+
return rules
|
|
302
|
+
.filter((rule) => {
|
|
303
|
+
const expiry = (rule as { expires_at?: unknown } | null)?.expires_at;
|
|
304
|
+
return typeof expiry !== 'string' || Date.parse(expiry) > now;
|
|
305
|
+
})
|
|
306
|
+
.map((rule) =>
|
|
307
|
+
typeof rule === 'string' ? rule : (rule as { capability?: unknown } | null)?.capability,
|
|
308
|
+
)
|
|
309
|
+
.filter((pattern): pattern is string => typeof pattern === 'string');
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* A fresh profile config.
|
|
314
|
+
*
|
|
315
|
+
* Written with comments, because this is the file an operator will read first
|
|
316
|
+
* and most of what it needs to say is *why*, not *what*.
|
|
317
|
+
*/
|
|
318
|
+
export function newProfileTemplate(profile: string, port: number): string {
|
|
319
|
+
return `# Lanes Link profile: ${profile}
|
|
320
|
+
#
|
|
321
|
+
# This file is the source of truth for what exists. It never contains a
|
|
322
|
+
# credential value — only "_ref" pointers into the credential store, which
|
|
323
|
+
# lives beside it and is encrypted at rest.
|
|
324
|
+
#
|
|
325
|
+
# Edit it by hand or through the CLI; both are supported, and CLI edits
|
|
326
|
+
# preserve your comments and ordering.
|
|
327
|
+
contract: 1
|
|
328
|
+
|
|
329
|
+
instance:
|
|
330
|
+
profile: ${profile}
|
|
331
|
+
default_target: local
|
|
332
|
+
port: ${port}
|
|
333
|
+
host: 127.0.0.1
|
|
334
|
+
|
|
335
|
+
# Adapter selection is per target. Everything below "targets" is
|
|
336
|
+
# target-independent and declared exactly once.
|
|
337
|
+
targets:
|
|
338
|
+
local:
|
|
339
|
+
credentials: { adapter: file, path: ./data/${profile}/credentials.enc }
|
|
340
|
+
storage: { adapter: filesystem, path: ./data/${profile} }
|
|
341
|
+
|
|
342
|
+
# The bearer token for the endpoint this profile serves.
|
|
343
|
+
#
|
|
344
|
+
# "lanes link start" serves every profile in the workspace from one URL, and this
|
|
345
|
+
# token is what opens it — so it admits every profile that "lanes link outputs"
|
|
346
|
+
# lists, not only this one. Each call names the profile it means. Run a separate
|
|
347
|
+
# workspace if you need a token that cannot reach them all.
|
|
348
|
+
auth:
|
|
349
|
+
mode: bearer
|
|
350
|
+
token_ref: profile/token
|
|
351
|
+
|
|
352
|
+
limits:
|
|
353
|
+
requests_per_minute: 120 # per profile
|
|
354
|
+
upstream_calls_per_minute: 60 # per connection, protects vendor quota
|
|
355
|
+
|
|
356
|
+
# App registrations, shared by every connection of that vendor.
|
|
357
|
+
oauth_apps: {}
|
|
358
|
+
|
|
359
|
+
# One entry per authorised account. "account" is the identity the provider
|
|
360
|
+
# reports — an address, a workspace — so this list says whose data is reachable
|
|
361
|
+
# without having to look anything up.
|
|
362
|
+
#
|
|
363
|
+
# "setup" holds no account. It lets an agent see what is connected here and what
|
|
364
|
+
# connecting something else would take, so it can tell you the command to run
|
|
365
|
+
# rather than guess at one. It is read-only: nothing it offers writes config,
|
|
366
|
+
# stores a credential, signs in, or changes what is permitted — those stay in
|
|
367
|
+
# this CLI (ADR-007, ADR-019). Delete this entry and the allow line below to
|
|
368
|
+
# remove it entirely.
|
|
369
|
+
connections:
|
|
370
|
+
- { id: main, provider: setup, account: Setup }
|
|
371
|
+
|
|
372
|
+
# Only what is listed here is reachable, and an empty policy grants nothing.
|
|
373
|
+
#
|
|
374
|
+
# Rules name capabilities, never accounts: "gmail.*" covers every Gmail
|
|
375
|
+
# connection in this profile. To grant two accounts differently, run a second
|
|
376
|
+
# profile — profiles share no database and no credential store. They do now
|
|
377
|
+
# share an endpoint and its token, so that separation is enforced per call
|
|
378
|
+
# rather than per URL.
|
|
379
|
+
#
|
|
380
|
+
# allow: ['*'] everything, which is what connect writes
|
|
381
|
+
# allow: [notion.*, gmail.*] two providers
|
|
382
|
+
# deny: [gmail.send_message] a deny always beats an allow
|
|
383
|
+
policy:
|
|
384
|
+
allow: [setup.*]
|
|
385
|
+
deny: []
|
|
386
|
+
`;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export function newWorkspaceTemplate(defaultProfile?: string): string {
|
|
390
|
+
return `# Lanes Link workspace
|
|
391
|
+
#
|
|
392
|
+
# A workspace holds one or more profiles, and one endpoint serves all of them:
|
|
393
|
+
# every call names the profile it means. Profiles never share a database or a
|
|
394
|
+
# credential store, so what one holds is invisible to another.
|
|
395
|
+
contract: 1
|
|
396
|
+
${defaultProfile ? `default_profile: ${defaultProfile}\n` : ''}`;
|
|
397
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { Config, DeployConfig } from '#profile';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Where this profile's endpoint answers, for the target in play.
|
|
5
|
+
*
|
|
6
|
+
* Deployed, it is not derivable: Cloud Run mixes a project hash into the
|
|
7
|
+
* hostname, so the URL has to be asked for and asking is the driver's job.
|
|
8
|
+
* Locally it is host and port from the config.
|
|
9
|
+
*
|
|
10
|
+
* Shared because getting it wrong is silent in the worst way. `outputs` asked
|
|
11
|
+
* the driver; `mcp add` built the local URL unconditionally, so
|
|
12
|
+
* `lanes link mcp add --target cloud` registered `http://127.0.0.1:7337/mcp`
|
|
13
|
+
* with the agent — a registration that looks successful, names the right
|
|
14
|
+
* server, and points at a port with nothing behind it. One of the two had the
|
|
15
|
+
* answer and the other could not see it, which is what a copied line does
|
|
16
|
+
* eventually.
|
|
17
|
+
*/
|
|
18
|
+
export async function endpointUrl(config: Config, target: string): Promise<string> {
|
|
19
|
+
const deployed = await deployedUrl(config.targets[target]?.deploy);
|
|
20
|
+
return deployed ?? `http://${config.instance.host}:${config.instance.port}/mcp`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The URL the platform assigned this target's service, if it has one.
|
|
25
|
+
*
|
|
26
|
+
* Degrades to null rather than failing: the platform's CLI may be absent, the
|
|
27
|
+
* service may not be deployed yet, and neither is a reason for a command that
|
|
28
|
+
* only wanted to print an address to stop working. A target with no deployment
|
|
29
|
+
* block never reaches the driver at all.
|
|
30
|
+
*/
|
|
31
|
+
export async function deployedUrl(declared: DeployConfig | undefined): Promise<string | null> {
|
|
32
|
+
if (!declared) return null;
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const { driverFor } = await import('#deployments/drivers.ts');
|
|
36
|
+
const url = await (await driverFor(declared.platform)).url(declared);
|
|
37
|
+
return url ? `${url}/mcp` : null;
|
|
38
|
+
} catch {
|
|
39
|
+
// A platform this binary has no driver for is a config problem `check`
|
|
40
|
+
// reports; it must not take the caller down with it.
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Which service a target deploys to, from config alone.
|
|
47
|
+
*
|
|
48
|
+
* Separate from the two functions above because it answers a different question
|
|
49
|
+
* at a different cost. They ask the platform where a service *ended up*, which
|
|
50
|
+
* is a subprocess and a network call; this reads what the profile already
|
|
51
|
+
* declares, which is free and always available.
|
|
52
|
+
*
|
|
53
|
+
* That difference is exactly why `status` needs it. `status` promises to answer
|
|
54
|
+
* without depending on anything being up, so it cannot ask for an address — but
|
|
55
|
+
* printing a loopback URL for a deployed target, which is what it used to do, is
|
|
56
|
+
* worse than printing no URL at all. Naming the service is the honest answer a
|
|
57
|
+
* config-only command can give.
|
|
58
|
+
*/
|
|
59
|
+
export function deploymentIdentity(
|
|
60
|
+
declared: DeployConfig | undefined,
|
|
61
|
+
): DeploymentIdentity | null {
|
|
62
|
+
if (!declared) return null;
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
platform: declared.platform,
|
|
66
|
+
service: declared.service,
|
|
67
|
+
region: declared.region,
|
|
68
|
+
...(declared.project !== undefined ? { project: declared.project } : {}),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface DeploymentIdentity {
|
|
73
|
+
readonly platform: string;
|
|
74
|
+
readonly service: string;
|
|
75
|
+
readonly region: string;
|
|
76
|
+
readonly project?: string;
|
|
77
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { IdentityDeclaration, ProviderManifest } from '#connectivity';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Working out whose account was just authorised.
|
|
5
|
+
*
|
|
6
|
+
* A connection list that reads `Gmail main`, `Gmail main2`, `Gmail main3`
|
|
7
|
+
* cannot answer the one question it exists to answer. Worse, without an
|
|
8
|
+
* identity there is no way to tell a *reconnect* from a *new account*, so
|
|
9
|
+
* re-running `connect` after a failed attempt appends another row instead of
|
|
10
|
+
* repairing the one already there — which is precisely how `main3` came to
|
|
11
|
+
* exist.
|
|
12
|
+
*
|
|
13
|
+
* Everything here is best-effort. A provider that declares no identity, or
|
|
14
|
+
* whose probe fails, falls back to asking the operator: a label is worth
|
|
15
|
+
* having, never worth failing a connect over.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Walk a dotted path, tolerating the first array element on the way. */
|
|
19
|
+
export function pluck(value: unknown, path: string): string | null {
|
|
20
|
+
let current: unknown = value;
|
|
21
|
+
|
|
22
|
+
for (const segment of path.split('.')) {
|
|
23
|
+
if (Array.isArray(current)) current = current[0];
|
|
24
|
+
if (current === null || typeof current !== 'object') return null;
|
|
25
|
+
current = (current as Record<string, unknown>)[segment];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (Array.isArray(current)) current = current[0];
|
|
29
|
+
return typeof current === 'string' && current.length > 0 ? current : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface IdentityProbe {
|
|
33
|
+
/** A valid upstream access token, if the provider authenticates. */
|
|
34
|
+
readonly accessToken: () => Promise<string | null>;
|
|
35
|
+
/** Call a capability on the upstream MCP server. */
|
|
36
|
+
readonly callTool?: (name: string, args: Record<string, unknown>) => Promise<unknown>;
|
|
37
|
+
/** Ask the connector, for a protocol whose identity is not a URL away. */
|
|
38
|
+
readonly identify?: () => Promise<string | null>;
|
|
39
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function resolveAccount(
|
|
43
|
+
manifest: ProviderManifest,
|
|
44
|
+
probe: IdentityProbe,
|
|
45
|
+
): Promise<string | null> {
|
|
46
|
+
const identity: IdentityDeclaration | undefined = manifest.identity;
|
|
47
|
+
if (!identity) return null;
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
if (identity.kind === 'connector') {
|
|
51
|
+
return (await probe.identify?.()) ?? null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (identity.kind === 'http') {
|
|
55
|
+
const token = await probe.accessToken();
|
|
56
|
+
const request = probe.fetch ?? globalThis.fetch;
|
|
57
|
+
const response = await request(identity.url, {
|
|
58
|
+
headers: token ? { authorization: `Bearer ${token}` } : {},
|
|
59
|
+
});
|
|
60
|
+
if (!response.ok) return null;
|
|
61
|
+
return pluck(await response.json(), identity.field);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!probe.callTool) return null;
|
|
65
|
+
const result = await probe.callTool(identity.tool, identity.arguments);
|
|
66
|
+
|
|
67
|
+
// MCP results arrive as content blocks; the useful part is usually JSON in
|
|
68
|
+
// a text block, so try that before falling back to the raw shape.
|
|
69
|
+
const text = pluck(result, 'content.text');
|
|
70
|
+
if (text) {
|
|
71
|
+
try {
|
|
72
|
+
return pluck(JSON.parse(text), identity.field) ?? text;
|
|
73
|
+
} catch {
|
|
74
|
+
return text;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return pluck(result, identity.field);
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Turn an account into a connection id.
|
|
85
|
+
*
|
|
86
|
+
* `ada.lovelace@example.com` becomes `ada_lovelace`, which is what appears in
|
|
87
|
+
* `credential_ref` and in the agent's `connection` argument. The local part is
|
|
88
|
+
* enough to tell accounts apart in practice, and the full address is still
|
|
89
|
+
* right there in `account` when it is not.
|
|
90
|
+
*/
|
|
91
|
+
export function idFromAccount(account: string, taken: readonly string[] = []): string {
|
|
92
|
+
const local = account.includes('@') ? (account.split('@')[0] ?? account) : account;
|
|
93
|
+
|
|
94
|
+
const base =
|
|
95
|
+
local
|
|
96
|
+
.toLowerCase()
|
|
97
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
98
|
+
.replace(/^_+|_+$/g, '')
|
|
99
|
+
.slice(0, 40) || 'main';
|
|
100
|
+
|
|
101
|
+
if (!taken.includes(base)) return base;
|
|
102
|
+
|
|
103
|
+
// A genuine collision — two accounts sharing a local part, e.g. the same
|
|
104
|
+
// name at two domains. Suffixing beats overwriting someone else's credential.
|
|
105
|
+
for (let n = 2; ; n++) {
|
|
106
|
+
const candidate = `${base}${n}`;
|
|
107
|
+
if (!taken.includes(candidate)) return candidate;
|
|
108
|
+
}
|
|
109
|
+
}
|
package/src/cli/lanes.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { ConfigError } from '#profile';
|
|
3
|
+
import { print, printErr, style } from './output.ts';
|
|
4
|
+
import { version } from './version.ts';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `lanes` — the command, and the one area it currently has.
|
|
8
|
+
*
|
|
9
|
+
* The grammar is `lanes <area> <command>`, and `link` is the only area today.
|
|
10
|
+
* It is a level of indirection that buys nothing yet, which is the point: the
|
|
11
|
+
* alternative was a second top-level binary the first time Lanes grew another
|
|
12
|
+
* capability, and by then every doc, every shell alias, and every agent
|
|
13
|
+
* registration would already say `lanes-link`.
|
|
14
|
+
*
|
|
15
|
+
* Adding an area is one entry in AREAS and one case below. Areas own their own
|
|
16
|
+
* argv completely — this file peels one token and hands over the rest, so
|
|
17
|
+
* `lanes link status` and a future `lanes <other> status` never collide.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const AREAS: Record<string, string> = {
|
|
21
|
+
link: 'a self-hosted MCP gateway for your accounts, memory, skills, and secrets',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function areasUsage(): string {
|
|
25
|
+
const rows = Object.entries(AREAS)
|
|
26
|
+
.map(([name, blurb]) => ` ${style.bold(`lanes ${name}`)} ${blurb}`)
|
|
27
|
+
.join('\n');
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
`${style.bold('lanes')} — your own tools, wherever you work\n\n${rows}\n\n` +
|
|
31
|
+
`Run ${style.bold('lanes <area> help')} for what an area can do, ` +
|
|
32
|
+
`or ${style.bold('lanes --version')} for which release this is.\n`
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function main(argv: readonly string[]): Promise<void> {
|
|
37
|
+
const [area, ...rest] = argv;
|
|
38
|
+
|
|
39
|
+
// Bare `lanes`, or asking for help before naming an area: list what exists.
|
|
40
|
+
// Not an error — someone who typed `lanes` wants to know what it offers.
|
|
41
|
+
if (!area || area === 'help' || area === '--help') {
|
|
42
|
+
print(areasUsage());
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Before the area is peeled, because it belongs to the binary rather than to
|
|
47
|
+
// any one area — and because `lanes --version` is the reflex for anything on
|
|
48
|
+
// a PATH. `lanes link version` answers with the same string.
|
|
49
|
+
if (area === '--version' || area === '-v') {
|
|
50
|
+
print(version());
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (area === 'link') {
|
|
55
|
+
// Loaded here rather than at the top of the file so that a failure while
|
|
56
|
+
// the area's module graph evaluates — a malformed environment variable read
|
|
57
|
+
// at import time, say — is caught below and printed as an `error` line. A
|
|
58
|
+
// static import throws before this file's try block is reached, and Bun
|
|
59
|
+
// renders that as a stack trace with the message buried in it.
|
|
60
|
+
const { run: runLink } = await import('./main.ts');
|
|
61
|
+
return await runLink(rest);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
throw new Error(
|
|
65
|
+
`Unknown area "${area}". Available: ${Object.keys(AREAS).join(', ')}.\n` +
|
|
66
|
+
` Did you mean: lanes link ${area}?`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
await main(process.argv.slice(2));
|
|
72
|
+
} catch (error) {
|
|
73
|
+
// A ConfigError already names the exact path and what to do about it, so it
|
|
74
|
+
// is printed as-is rather than wrapped in a generic failure message.
|
|
75
|
+
const message = error instanceof ConfigError ? error.message : (error as Error).message;
|
|
76
|
+
printErr(`${style.red('error')} ${message}`);
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|