@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,137 @@
|
|
|
1
|
+
import { ConfigError } from '#profile';
|
|
2
|
+
import { print, style, waiting, warn } from '#cli/output.ts';
|
|
3
|
+
import type { DeployDriver, DeployStep } from './driver.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Running a driver's steps, and saying what happened to each.
|
|
7
|
+
*
|
|
8
|
+
* Split from `deploy.ts` because it is a different concern from the order those
|
|
9
|
+
* steps go in: this is what "a step succeeded", "a step is already done" and "a
|
|
10
|
+
* step is not ready yet" mean, and all three are decisions about a message
|
|
11
|
+
* rather than about a deployment. Keeping them together pushed that file past
|
|
12
|
+
* the size budget `src/architecture.test.ts` holds.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export function printSteps(driver: DeployDriver, steps: readonly DeployStep[]): void {
|
|
16
|
+
print('');
|
|
17
|
+
for (const step of steps) {
|
|
18
|
+
print(` ${style.dim(step.title)}`);
|
|
19
|
+
print(` ${driver.tool} ${step.argv.join(' ')}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A failure that means "not yet" rather than "no".
|
|
25
|
+
*
|
|
26
|
+
* Enabling an API returns before the API is usable. The gap is seconds to a
|
|
27
|
+
* couple of minutes, and inside it Google answers calls to that service with a
|
|
28
|
+
* permission error rather than a "still starting" one — so the message an
|
|
29
|
+
* operator gets is indistinguishable from having the wrong role, on a project
|
|
30
|
+
* they own. This deploy enables seven APIs and then immediately uses all of
|
|
31
|
+
* them, which makes it the most likely thing in the repository to land in that
|
|
32
|
+
* window; a real deploy failed there, at the build, after uploading its source.
|
|
33
|
+
*
|
|
34
|
+
* `PERMISSION_DENIED` is genuinely ambiguous and is retried anyway. Waiting out
|
|
35
|
+
* a bounded budget before reporting a real permission problem costs a minute;
|
|
36
|
+
* failing a five-minute build because an API was enabled ninety seconds ago
|
|
37
|
+
* costs the whole deploy and reads as a misconfiguration nobody has.
|
|
38
|
+
*/
|
|
39
|
+
const UNREADY =
|
|
40
|
+
/SERVICE_DISABLED|has not been used in project|is not enabled|caller does not have permission|PERMISSION_DENIED/i;
|
|
41
|
+
|
|
42
|
+
/** Exported for its tests: which failures are worth waiting out. */
|
|
43
|
+
export function isUnready(stderr: string): boolean {
|
|
44
|
+
return UNREADY.test(stderr);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A failure that means the thing is already there.
|
|
49
|
+
*
|
|
50
|
+
* Every provisioning step is written to be run again, and on a second deploy all
|
|
51
|
+
* of them are. Google reports that as an error — `ALREADY_EXISTS`, `409`, "is
|
|
52
|
+
* the subject of a conflict", "you already own it" — and letting those through
|
|
53
|
+
* meant a successful deploy printed a screen of red for the expected case, which
|
|
54
|
+
* teaches an operator to skim past exactly the output that matters when
|
|
55
|
+
* something is actually wrong.
|
|
56
|
+
*/
|
|
57
|
+
const EXISTS = /ALREADY_EXISTS|already exists|subject of a conflict|already own it|HTTPError 409/i;
|
|
58
|
+
|
|
59
|
+
export function isAlreadyThere(stderr: string): boolean {
|
|
60
|
+
return EXISTS.test(stderr);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Spent across the whole run rather than per step.
|
|
65
|
+
*
|
|
66
|
+
* The wait is for one event — the APIs this deploy just enabled becoming usable
|
|
67
|
+
* — so once any step has waited it out, the rest find them ready. A per-step
|
|
68
|
+
* budget would multiply one propagation delay by the number of steps behind it,
|
|
69
|
+
* and a deploy that can hang for a quarter of an hour is not more reliable than
|
|
70
|
+
* one that fails.
|
|
71
|
+
*/
|
|
72
|
+
const PROPAGATION_BUDGET_MS = 150_000;
|
|
73
|
+
const BACKOFF_MS = [5_000, 10_000, 20_000, 30_000, 45_000];
|
|
74
|
+
|
|
75
|
+
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
|
76
|
+
|
|
77
|
+
export async function runSteps(
|
|
78
|
+
driver: DeployDriver,
|
|
79
|
+
steps: readonly DeployStep[],
|
|
80
|
+
/** Injectable so a test can assert the retry without sleeping through it. */
|
|
81
|
+
backoff: readonly number[] = BACKOFF_MS,
|
|
82
|
+
): Promise<void> {
|
|
83
|
+
let budget = PROPAGATION_BUDGET_MS;
|
|
84
|
+
|
|
85
|
+
for (const step of steps) {
|
|
86
|
+
print('');
|
|
87
|
+
print(style.dim(` ${step.title}`));
|
|
88
|
+
|
|
89
|
+
// A step that is allowed to have already happened is also one whose output
|
|
90
|
+
// is a wall of IAM policy. Captured rather than streamed, so this decides
|
|
91
|
+
// what to say about it — the long steps that need streaming are exactly the
|
|
92
|
+
// ones that are not allowed to fail.
|
|
93
|
+
const quiet = step.tolerateFailure === true;
|
|
94
|
+
let result = await driver.run(step.argv, { quiet });
|
|
95
|
+
|
|
96
|
+
for (const pause of backoff) {
|
|
97
|
+
if (result.ok || budget < pause || !isUnready(result.stderr)) break;
|
|
98
|
+
budget -= pause;
|
|
99
|
+
|
|
100
|
+
await waiting(
|
|
101
|
+
`not ready yet — an API this needs may have been enabled moments ago; retrying in ${Math.round(pause / 1000)}s`,
|
|
102
|
+
() => sleep(pause),
|
|
103
|
+
);
|
|
104
|
+
result = await driver.run(step.argv, { quiet });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (result.ok) {
|
|
108
|
+
if (quiet) print(style.dim(` ${style.green('+')} done`));
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (step.tolerateFailure) {
|
|
113
|
+
// The expected case on every deploy after the first, said as such.
|
|
114
|
+
// Anything else it tolerated is still worth seeing, because a tolerated
|
|
115
|
+
// failure that is not "already there" is how a deploy rolls a revision
|
|
116
|
+
// with no service account and finds out several minutes later.
|
|
117
|
+
print(
|
|
118
|
+
isAlreadyThere(result.stderr)
|
|
119
|
+
? style.dim(` ${style.green('=')} already there`)
|
|
120
|
+
: warn(` ${firstLine(result.stderr)}`),
|
|
121
|
+
);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
throw new ConfigError(`Deploy stopped: ${step.title} failed. ${result.stderr}`.trim());
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The line of a `gcloud` failure that says what went wrong, without the trace. */
|
|
130
|
+
function firstLine(stderr: string): string {
|
|
131
|
+
return (
|
|
132
|
+
stderr
|
|
133
|
+
.split('\n')
|
|
134
|
+
.map((line) => line.trim())
|
|
135
|
+
.find((line) => line.startsWith('ERROR:') || line.length > 0) ?? 'failed with no message'
|
|
136
|
+
);
|
|
137
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import type { AuditReader, AuditSink, AuditStore } from '#audit';
|
|
2
|
+
import { ConfigError, layout, workspacePath, type Config, type TargetConfig } from '#profile';
|
|
3
|
+
import type { SecretStore } from '#secrets';
|
|
4
|
+
import { createFileSecretStore } from '#secrets';
|
|
5
|
+
import type { BlobStore } from '#stores/blobs';
|
|
6
|
+
import { createRuntimeState, type RuntimeState } from '#stores/state';
|
|
7
|
+
import { createBlobAuditStore } from './adapters/audit-blob.ts';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Turning a declared target into a set of open adapters.
|
|
11
|
+
*
|
|
12
|
+
* A target names where a profile runs — `local`, `cloud` — and the whole of
|
|
13
|
+
* that difference is the two backends below: where credentials are kept, and
|
|
14
|
+
* where bytes go. State and the audit log ride the second of those rather than
|
|
15
|
+
* declaring backends of their own. Connections, providers, policy
|
|
16
|
+
* and limits are declared once and apply to every target, which is what lets a
|
|
17
|
+
* deployment change nothing at the application layer.
|
|
18
|
+
*
|
|
19
|
+
* **This is the only place the mapping exists.** It used to be three `switch`
|
|
20
|
+
* statements two hundred lines apart inside the CLI's runtime assembly, which
|
|
21
|
+
* meant adding a target meant finding all three and meant the CLI knew what
|
|
22
|
+
* Cloud Run was. Adding one is now a folder beside this file and a case here.
|
|
23
|
+
*
|
|
24
|
+
* Adapters are named for the *protocol* and deployments for the *vendor*
|
|
25
|
+
* (ADR-013). `s3` needs an endpoint and a key pair; R2, MinIO, Supabase Storage
|
|
26
|
+
* and AWS differ only in those values, so a vendor name on the adapter would
|
|
27
|
+
* claim a coupling that does not exist. The host is a deploy-time choice; the
|
|
28
|
+
* protocol is the interface.
|
|
29
|
+
*
|
|
30
|
+
* Cloud adapters are imported *inside* their branch, deliberately: a local run
|
|
31
|
+
* never loads the S3 or Secret Manager client, so a missing GCP credential
|
|
32
|
+
* cannot fail a `lanes link start` that was never going to talk to Google.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
export interface TargetInput {
|
|
36
|
+
readonly declared: TargetConfig;
|
|
37
|
+
readonly config: Config;
|
|
38
|
+
readonly root: string;
|
|
39
|
+
readonly target: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Blob storage, rooted per area.
|
|
44
|
+
*
|
|
45
|
+
* Three consumers want it with different roots — the provider blobs a
|
|
46
|
+
* `ProviderContext` is scoped from, the skills directory, and the vault document
|
|
47
|
+
* — and resolving the S3 key pair once rather than three times is why this is a
|
|
48
|
+
* factory rather than a store. On a Secret Manager credential store each of
|
|
49
|
+
* those resolutions is a network call.
|
|
50
|
+
*
|
|
51
|
+
* `area` is a location *within* the target: a workspace-relative directory for
|
|
52
|
+
* the filesystem adapter, a key prefix for S3. Omitting it gives the profile's
|
|
53
|
+
* own blob root, which is what providers get.
|
|
54
|
+
*
|
|
55
|
+
* That last sentence was true of `filesystem` and false of the two bucket
|
|
56
|
+
* adapters, which read an omitted area as the empty prefix — so a deployed
|
|
57
|
+
* instance scattered memory and attachments across the root of the bucket while
|
|
58
|
+
* its state and log sat under `data/<profile>/`. One profile per bucket made it
|
|
59
|
+
* look deliberate. It was not: it put provider blobs outside the prefix the
|
|
60
|
+
* write grant is conditioned on, so the first write 403'd.
|
|
61
|
+
*/
|
|
62
|
+
export type StorageFactory = (area?: string) => BlobStore;
|
|
63
|
+
|
|
64
|
+
export async function openSecrets(input: TargetInput): Promise<SecretStore> {
|
|
65
|
+
const { declared, config, root, target } = input;
|
|
66
|
+
|
|
67
|
+
switch (declared.credentials.adapter) {
|
|
68
|
+
case 'file':
|
|
69
|
+
return createFileSecretStore({
|
|
70
|
+
path: workspacePath(
|
|
71
|
+
root,
|
|
72
|
+
declared.credentials.path ?? layout.credentials(config.instance.profile),
|
|
73
|
+
),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
case 'gcp-secret-manager': {
|
|
77
|
+
if (!declared.credentials.project) {
|
|
78
|
+
throw new ConfigError(
|
|
79
|
+
`targets.${target}.credentials.project is required for the gcp-secret-manager adapter.`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
const { GcpSecretManagerStore } = await import('./adapters/gcp-secret-manager.ts');
|
|
83
|
+
return new GcpSecretManagerStore({ project: declared.credentials.project });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Runtime state — connections, provider state, cursors.
|
|
90
|
+
*
|
|
91
|
+
* Not a switch, for the same reason `openAudit` is not one: state is one
|
|
92
|
+
* object per key in whatever `BlobStore` the target already opened. It used to
|
|
93
|
+
* be `sqlite` or `postgres` behind a `database:` block, which was a query
|
|
94
|
+
* engine and a second service bought for a workload that is entirely point
|
|
95
|
+
* reads. See `#stores/state`.
|
|
96
|
+
*
|
|
97
|
+
* Its own root, `layout.state`, so it is not addressable from a provider's
|
|
98
|
+
* blob namespace — the same containment `openAudit` relies on.
|
|
99
|
+
*/
|
|
100
|
+
export function openState(storage: StorageFactory, profile: string): RuntimeState {
|
|
101
|
+
return createRuntimeState(storage(layout.state(profile)));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The audit log.
|
|
106
|
+
*
|
|
107
|
+
* Not a switch, because there is only one answer: objects in whatever blob
|
|
108
|
+
* store the target already opened. That is the point of the layout — a
|
|
109
|
+
* directory under the profile locally and a prefix in the bucket deployed are
|
|
110
|
+
* the same tree, so there is one sink rather than one per target and no
|
|
111
|
+
* conformance question about whether the two agree.
|
|
112
|
+
*
|
|
113
|
+
* Named as its own factory rather than folded into `openStorage` because the
|
|
114
|
+
* log is not provider blobs: it gets a root a provider cannot be namespaced
|
|
115
|
+
* into (`layout.audit`), and that separation is what keeps ADR-007's wall
|
|
116
|
+
* intact.
|
|
117
|
+
*/
|
|
118
|
+
export function openAudit(storage: StorageFactory, profile: string): AuditStore {
|
|
119
|
+
return createBlobAuditStore({ storage: storage(layout.audit(profile)) });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The log, plus whatever copies the target asks for.
|
|
124
|
+
*
|
|
125
|
+
* The blob sink is always first and always awaited — `fanOutAudit` treats the
|
|
126
|
+
* first as the record of truth and everything after it as best-effort. That
|
|
127
|
+
* ordering is fixed here rather than declared in config, because "which sink
|
|
128
|
+
* is the log" is not a preference: a sink that can fail cannot carry a
|
|
129
|
+
* guarantee that says every invocation is recorded.
|
|
130
|
+
*
|
|
131
|
+
* Returned as `{ sink, reader }` because the two halves diverge once copies
|
|
132
|
+
* exist: `tail` and `verify` mean the durable log, never the fan-out.
|
|
133
|
+
*/
|
|
134
|
+
export async function openAuditSinks(
|
|
135
|
+
input: TargetInput,
|
|
136
|
+
storage: StorageFactory,
|
|
137
|
+
secrets: SecretStore,
|
|
138
|
+
log?: (message: string) => void,
|
|
139
|
+
): Promise<{ sink: AuditSink; reader: AuditReader }> {
|
|
140
|
+
const durable = openAudit(storage, input.config.instance.profile);
|
|
141
|
+
const declared = input.declared.audit?.sinks ?? [];
|
|
142
|
+
if (declared.length === 0) return { sink: durable, reader: durable };
|
|
143
|
+
|
|
144
|
+
const secondaries: AuditSink[] = [];
|
|
145
|
+
for (const sink of declared) {
|
|
146
|
+
if (sink.kind === 'stdout') {
|
|
147
|
+
const { createStdoutAuditSink } = await import('#audit/stdout.ts');
|
|
148
|
+
secondaries.push(createStdoutAuditSink());
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const headers = sink.headers_ref ? await secrets.get(sink.headers_ref) : null;
|
|
153
|
+
const { createOtlpAuditSink } = await import('./adapters/otlp.ts');
|
|
154
|
+
secondaries.push(
|
|
155
|
+
createOtlpAuditSink({
|
|
156
|
+
endpoint: sink.endpoint,
|
|
157
|
+
...(headers ? { headers: parseHeaders(headers, sink.headers_ref!) } : {}),
|
|
158
|
+
...(sink.service_name ? { serviceName: sink.service_name } : {}),
|
|
159
|
+
}),
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const { fanOutAudit } = await import('#audit/fanout.ts');
|
|
164
|
+
return {
|
|
165
|
+
sink: fanOutAudit({
|
|
166
|
+
primary: durable,
|
|
167
|
+
secondaries,
|
|
168
|
+
...(log ? { onError: log } : {}),
|
|
169
|
+
}),
|
|
170
|
+
reader: durable,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* `key=value,key=value`, the form the OTel SDKs use for `OTEL_EXPORTER_*_HEADERS`.
|
|
176
|
+
*
|
|
177
|
+
* Read from the credential store rather than the config file: these carry an
|
|
178
|
+
* API key, and `profile/secret-detection.ts` would refuse one written inline.
|
|
179
|
+
*/
|
|
180
|
+
function parseHeaders(raw: string, ref: string): Record<string, string> {
|
|
181
|
+
const headers: Record<string, string> = {};
|
|
182
|
+
|
|
183
|
+
for (const pair of raw.split(',')) {
|
|
184
|
+
const at = pair.indexOf('=');
|
|
185
|
+
if (at === -1) {
|
|
186
|
+
throw new ConfigError(
|
|
187
|
+
`The value at "${ref}" is not OTLP headers. Expected key=value pairs separated by commas.`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
headers[pair.slice(0, at).trim()] = pair.slice(at + 1).trim();
|
|
191
|
+
}
|
|
192
|
+
return headers;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function openStorage(
|
|
196
|
+
input: TargetInput,
|
|
197
|
+
secrets: SecretStore,
|
|
198
|
+
): Promise<StorageFactory> {
|
|
199
|
+
const { declared, config, root, target } = input;
|
|
200
|
+
|
|
201
|
+
switch (declared.storage.adapter) {
|
|
202
|
+
case 'filesystem': {
|
|
203
|
+
const { createFilesystemBlobStore } = await import('./adapters/filesystem.ts');
|
|
204
|
+
const base = declared.storage.path ?? layout.blobs(config.instance.profile);
|
|
205
|
+
return (area) =>
|
|
206
|
+
createFilesystemBlobStore({ root: workspacePath(root, area === undefined ? base : area) });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
case 'gcs': {
|
|
210
|
+
// No key pair: this authenticates as whatever identity is already
|
|
211
|
+
// present — the Cloud Run service account the deploy granted
|
|
212
|
+
// objectAdmin, or the operator's own gcloud credentials locally. That is
|
|
213
|
+
// the whole reason it exists beside `s3`, which needs HMAC keys minted
|
|
214
|
+
// by hand in a console.
|
|
215
|
+
const { bucket, prefix } = declared.storage;
|
|
216
|
+
if (!bucket) {
|
|
217
|
+
throw new ConfigError(`targets.${target}.storage.bucket is required for the gcs adapter.`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const { createGcsBlobStore } = await import('./adapters/gcs.ts');
|
|
221
|
+
const base = prefix ?? '';
|
|
222
|
+
const root = layout.blobs(config.instance.profile);
|
|
223
|
+
|
|
224
|
+
return (area) =>
|
|
225
|
+
createGcsBlobStore({
|
|
226
|
+
bucket,
|
|
227
|
+
prefix: `${base}${area ?? root}/`,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
case 's3': {
|
|
232
|
+
const { bucket, endpoint, region, prefix } = declared.storage;
|
|
233
|
+
if (!bucket) {
|
|
234
|
+
throw new ConfigError(`targets.${target}.storage.bucket is required for the s3 adapter.`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const accessKeyId = await requireSecret(
|
|
238
|
+
secrets,
|
|
239
|
+
declared.storage.access_key_id_ref,
|
|
240
|
+
`targets.${target}.storage.access_key_id_ref`,
|
|
241
|
+
target,
|
|
242
|
+
);
|
|
243
|
+
const secretAccessKey = await requireSecret(
|
|
244
|
+
secrets,
|
|
245
|
+
declared.storage.secret_access_key_ref,
|
|
246
|
+
`targets.${target}.storage.secret_access_key_ref`,
|
|
247
|
+
target,
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
const { createS3BlobStore } = await import('./adapters/s3.ts');
|
|
251
|
+
const base = prefix ?? '';
|
|
252
|
+
const root = layout.blobs(config.instance.profile);
|
|
253
|
+
|
|
254
|
+
return (area) =>
|
|
255
|
+
createS3BlobStore({
|
|
256
|
+
bucket,
|
|
257
|
+
accessKeyId,
|
|
258
|
+
secretAccessKey,
|
|
259
|
+
...(endpoint !== undefined ? { endpoint } : {}),
|
|
260
|
+
...(region !== undefined ? { region } : {}),
|
|
261
|
+
prefix: `${base}${area ?? root}/`,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Resolve a declared secret ref, or say which one is missing and how to store
|
|
269
|
+
* it.
|
|
270
|
+
*
|
|
271
|
+
* Two refs make the same two mistakes available twice over, and "undefined is
|
|
272
|
+
* not a string" from inside an S3 client is not a diagnosis.
|
|
273
|
+
*/
|
|
274
|
+
async function requireSecret(
|
|
275
|
+
secrets: SecretStore,
|
|
276
|
+
ref: string | undefined,
|
|
277
|
+
field: string,
|
|
278
|
+
target: string,
|
|
279
|
+
): Promise<string> {
|
|
280
|
+
if (!ref) {
|
|
281
|
+
throw new ConfigError(
|
|
282
|
+
`${field} is required for the s3 adapter — the key is a credential, so config names it ` +
|
|
283
|
+
'rather than carrying it.',
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const value = await secrets.get(ref);
|
|
288
|
+
if (!value) {
|
|
289
|
+
throw new ConfigError(
|
|
290
|
+
`${field} names "${ref}", which is not in this target's secret store. ` +
|
|
291
|
+
`Store it with: lanes link secrets set ${ref} --target ${target}`,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
return value;
|
|
295
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DATA_DIR,
|
|
3
|
+
WORKSPACE_FILE,
|
|
4
|
+
layout,
|
|
5
|
+
listProfiles,
|
|
6
|
+
workspaceFiles,
|
|
7
|
+
type Config,
|
|
8
|
+
type TargetConfig,
|
|
9
|
+
} from '#profile';
|
|
10
|
+
import { ConfigDocument, ensureSetupConnection, repairLines, repaired } from '#cli/config-edit.ts';
|
|
11
|
+
import { ok, print, style, warn } from '#cli/output.ts';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* What a deploy sends up, and the state it sends it in.
|
|
15
|
+
*
|
|
16
|
+
* Split out of `deploy.ts`, which had grown to hold two subjects: rolling a
|
|
17
|
+
* revision, and the workspace that revision reads its config from. They meet at
|
|
18
|
+
* one line in `deploy` and share nothing else — no state, no ordering beyond
|
|
19
|
+
* "before the rollout" — and keeping them apart is what makes the pair of rules
|
|
20
|
+
* below legible as a pair.
|
|
21
|
+
*
|
|
22
|
+
* Those rules are the reason this is a subject rather than a utility. Which
|
|
23
|
+
* files go up is an allowlist, and which files get repaired on the way is
|
|
24
|
+
* `listProfiles`, and the two must be scoped identically without being the same
|
|
25
|
+
* question. Answering them forty lines apart, among rollout steps, is how they
|
|
26
|
+
* drift.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The store URL a deployed instance reads its config from.
|
|
31
|
+
*
|
|
32
|
+
* The same bucket the target already declares — config sits beside state, the
|
|
33
|
+
* log, memory and skills rather than in a place of its own. `undefined` for a
|
|
34
|
+
* target on the filesystem adapter, which is not a thing anyone deploys but is
|
|
35
|
+
* a thing `--dry-run` can be pointed at.
|
|
36
|
+
*/
|
|
37
|
+
export function deployedWorkspace(declared: TargetConfig): string | undefined {
|
|
38
|
+
const { adapter, bucket, prefix } = declared.storage;
|
|
39
|
+
if (adapter !== 'gcs' && adapter !== 's3') return undefined;
|
|
40
|
+
if (!bucket) return undefined;
|
|
41
|
+
|
|
42
|
+
return prefix ? `gs://${bucket}/${prefix.replace(/\/$/, '')}` : `gs://${bucket}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* What a deploy sends up: an allowlist, never a sync with exclusions.
|
|
47
|
+
*
|
|
48
|
+
* `data/` holds the encrypted credential store **and its key file**, and the
|
|
49
|
+
* deployed target reads credentials from Secret Manager instead. Sending it
|
|
50
|
+
* would put a decryptable credential document in a bucket — the exact thing
|
|
51
|
+
* the `.dockerignore` exclusion exists to prevent, which is why that comment
|
|
52
|
+
* calls itself load-bearing rather than an optimisation.
|
|
53
|
+
*
|
|
54
|
+
* An allowlist because the failure modes are not symmetric: a config file this
|
|
55
|
+
* forgets means an endpoint that will not boot, and a credential this includes
|
|
56
|
+
* by accident means a credential in a bucket. Forgetting is loud.
|
|
57
|
+
*
|
|
58
|
+
* **Two areas inside `data/` are authored rather than accumulated**, since
|
|
59
|
+
* ADR-030 moved skills and provider manifests into the profile that owns them.
|
|
60
|
+
* They have to go up or a deployed instance loses both — the regression ADR-014
|
|
61
|
+
* §2 fixed for skills, reintroduced by where they now live. So this reaches
|
|
62
|
+
* into `data/` for exactly those two, by whole path segment and never by
|
|
63
|
+
* prefix: `data/personal/skills.detour/` is not `skills.d`, and the difference
|
|
64
|
+
* between matching it and not is a credential in a bucket.
|
|
65
|
+
*/
|
|
66
|
+
export function isWorkspaceConfig(key: string, profile?: string | undefined): boolean {
|
|
67
|
+
if (key === WORKSPACE_FILE) return true;
|
|
68
|
+
|
|
69
|
+
// One profile when the deploy names one, so a workspace holding personal and
|
|
70
|
+
// work does not push both into a bucket only one of them is for. The same
|
|
71
|
+
// question for both shapes, asked once.
|
|
72
|
+
const owner = authoredAreaOwner(key);
|
|
73
|
+
if (owner !== null) return profile === undefined || owner === profile;
|
|
74
|
+
|
|
75
|
+
if (!key.startsWith('profiles/') || !key.endsWith('.yaml')) return false;
|
|
76
|
+
return profile === undefined || key === `profiles/${profile}.yaml`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The profile whose authored area holds `key`, or null for anything else.
|
|
81
|
+
*
|
|
82
|
+
* Composed back out of `layout` rather than compared against literals, so a
|
|
83
|
+
* renamed directory moves both this and the store that reads it, or neither.
|
|
84
|
+
* Requires a fourth segment: `data/personal/skills.d` names the directory, and
|
|
85
|
+
* a directory is not a file to send.
|
|
86
|
+
*/
|
|
87
|
+
function authoredAreaOwner(key: string): string | null {
|
|
88
|
+
const segments = key.split('/');
|
|
89
|
+
if (segments.length < 4 || segments[0] !== DATA_DIR) return null;
|
|
90
|
+
|
|
91
|
+
const owner = segments[1]!;
|
|
92
|
+
if (owner.length === 0) return null;
|
|
93
|
+
|
|
94
|
+
const area = `${DATA_DIR}/${owner}/${segments[2]}`;
|
|
95
|
+
return area === layout.skills(owner) || area === layout.providers(owner) ? owner : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Give every profile about to be uploaded its setup surface.
|
|
100
|
+
*
|
|
101
|
+
* **Scoped exactly as the upload is**, because a profile this deploy sends is a
|
|
102
|
+
* profile the endpoint will serve: repairing a narrower set would leave a served
|
|
103
|
+
* profile without the surface, which is this bug one profile over. Note what
|
|
104
|
+
* `flags.profile` does not mean — it is the flag alone, so a profile resolved
|
|
105
|
+
* from `LANES_LINK_PROFILE` leaves it undefined and both this and the upload
|
|
106
|
+
* read that as the whole workspace. Surprising, pre-existing in the upload, and
|
|
107
|
+
* fixed there rather than here so the two cannot drift apart.
|
|
108
|
+
*
|
|
109
|
+
* *Which files are profiles* comes from `listProfiles`, never from
|
|
110
|
+
* `isWorkspaceConfig`: the allowlist decides what is safe to *copy*, so it
|
|
111
|
+
* happily sends a committed `personal.example.yaml` and a nested
|
|
112
|
+
* `profiles/archive/old.yaml` as bytes, while this opens and validates what it
|
|
113
|
+
* is handed — which turned that template into a `ConfigError` aborting the
|
|
114
|
+
* deploy after provisioning had already made cloud resources.
|
|
115
|
+
*
|
|
116
|
+
* A profile that cannot be read is warned about rather than fatal: the repair is
|
|
117
|
+
* a courtesy on the way past, and the upload still sends the file. Not silent,
|
|
118
|
+
* though — nothing else here widens a policy without being asked.
|
|
119
|
+
*/
|
|
120
|
+
export async function repairSetupSurface(
|
|
121
|
+
workspaceRoot: string,
|
|
122
|
+
profile: string | undefined,
|
|
123
|
+
): Promise<void> {
|
|
124
|
+
for (const name of await listProfiles(workspaceRoot)) {
|
|
125
|
+
if (profile !== undefined && name !== profile) continue;
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const document = await ConfigDocument.open(workspaceRoot, name);
|
|
129
|
+
const repair = ensureSetupConnection(document);
|
|
130
|
+
if (!repaired(repair)) continue;
|
|
131
|
+
|
|
132
|
+
await document.save();
|
|
133
|
+
|
|
134
|
+
print(ok(`gave ${style.bold(name)} the setup surface`));
|
|
135
|
+
for (const change of repairLines(repair)) print(` ${style.dim(change)}`);
|
|
136
|
+
print(` ${style.dim('an agent can now see what is connected here, instead of guessing')}`);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
print(
|
|
139
|
+
warn(
|
|
140
|
+
`could not give ${name} the setup surface: ${error instanceof Error ? error.message.split('\n')[0] : String(error)}`,
|
|
141
|
+
),
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Copy the workspace's config up.
|
|
149
|
+
*/
|
|
150
|
+
export async function uploadWorkspace(
|
|
151
|
+
root: string,
|
|
152
|
+
destination: string,
|
|
153
|
+
profile: string | undefined,
|
|
154
|
+
): Promise<void> {
|
|
155
|
+
const local = workspaceFiles(root);
|
|
156
|
+
const remote = workspaceFiles(destination);
|
|
157
|
+
|
|
158
|
+
let copied = 0;
|
|
159
|
+
for (const entry of await local.list('')) {
|
|
160
|
+
if (!isWorkspaceConfig(entry.key, profile)) continue;
|
|
161
|
+
|
|
162
|
+
const bytes = await local.get(entry.key);
|
|
163
|
+
if (bytes === null) continue;
|
|
164
|
+
|
|
165
|
+
await remote.put(entry.key, bytes, { contentType: 'application/yaml' });
|
|
166
|
+
copied += 1;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
print(ok(`uploaded ${copied} config file${copied === 1 ? '' : 's'} to ${destination}`));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Put the config where this target's endpoint reads it.
|
|
174
|
+
*
|
|
175
|
+
* Split from `deploy` because the two answer different questions. A deploy
|
|
176
|
+
* asks "what should this revision run"; this asks "where does the config a
|
|
177
|
+
* running endpoint reads actually live", and the answer stopped being "in the
|
|
178
|
+
* image" at ADR-023. Once config lives in a bucket, copying it there is a
|
|
179
|
+
* consequence of *editing* it, not of rolling a revision — which is what lets
|
|
180
|
+
* `connect` stop ending in `lanes link deploy` (ADR-029).
|
|
181
|
+
*
|
|
182
|
+
* `null` when the target reads the same files the CLI just wrote: a local
|
|
183
|
+
* target's endpoint opens the workspace directly, so there is nowhere to
|
|
184
|
+
* publish to and nothing to copy.
|
|
185
|
+
*
|
|
186
|
+
* Scoped to the profile that was edited, deliberately more narrowly than
|
|
187
|
+
* `deploy` scopes itself. `deploy` passes `flags.profile` — the flag alone —
|
|
188
|
+
* so a profile resolved from `LANES_LINK_PROFILE` leaves it undefined and the
|
|
189
|
+
* whole workspace goes up. An edit knows exactly which profile it touched, and
|
|
190
|
+
* sending a sibling profile to a bucket because someone edited this one is a
|
|
191
|
+
* surprise nobody asked for.
|
|
192
|
+
*/
|
|
193
|
+
export async function publishWorkspace(input: {
|
|
194
|
+
readonly config: Config;
|
|
195
|
+
readonly workspaceRoot: string;
|
|
196
|
+
readonly target: string;
|
|
197
|
+
readonly profile: string;
|
|
198
|
+
}): Promise<string | null> {
|
|
199
|
+
const declared = input.config.targets[input.target];
|
|
200
|
+
if (!declared) return null;
|
|
201
|
+
|
|
202
|
+
const destination = deployedWorkspace(declared);
|
|
203
|
+
if (!destination) return null;
|
|
204
|
+
|
|
205
|
+
await uploadWorkspace(input.workspaceRoot, destination, input.profile);
|
|
206
|
+
return destination;
|
|
207
|
+
}
|