@lanes-sh/link 0.7.2 → 0.8.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 +20 -10
- package/instructions/agents/lanes-link-scout.md +2 -2
- package/instructions/skills/lanes-link/SKILL.md +136 -61
- package/package.json +2 -1
- package/src/audit/index.ts +8 -1
- package/src/auth/index.ts +58 -2
- package/src/auth/lanes/assertion.ts +256 -0
- package/src/auth/lanes/callback.ts +135 -0
- package/src/auth/lanes/federation.ts +50 -0
- package/src/auth/lanes/login.ts +294 -0
- package/src/auth/lanes/members.ts +103 -0
- package/src/auth/lanes/session.ts +97 -0
- package/src/auth/oauth/grant.ts +183 -0
- package/src/auth/oauth/result.ts +27 -0
- package/src/auth/oauth/server.ts +176 -203
- package/src/auth/oauth/store.ts +65 -0
- package/src/auth/remote.ts +32 -9
- package/src/cli/accepts.ts +108 -0
- package/src/cli/argv.ts +57 -3
- package/src/cli/audit-change.ts +140 -0
- package/src/cli/callback-page.ts +36 -115
- package/src/cli/commands/auth-dispatch.ts +48 -0
- package/src/cli/commands/auth.ts +229 -0
- package/src/cli/commands/connect/accounts.ts +4 -4
- package/src/cli/commands/connect/authorise.ts +4 -4
- package/src/cli/commands/connect/bind-credential.ts +2 -1
- package/src/cli/commands/connect/custom/index.ts +1 -1
- package/src/cli/commands/connect/custom/write.ts +2 -2
- package/src/cli/commands/connect/grant.ts +29 -14
- package/src/cli/commands/connect/index.ts +88 -87
- package/src/cli/commands/connect/options.ts +83 -0
- package/src/cli/commands/connect/registration.ts +50 -0
- package/src/cli/commands/connect/requirements.ts +1 -1
- package/src/cli/commands/connect/settle.ts +4 -2
- package/src/cli/commands/connect/target-note.ts +7 -2
- package/src/cli/commands/connect/unknown.ts +1 -1
- package/src/cli/commands/connect/variables.ts +3 -2
- package/src/cli/commands/connection-list.ts +116 -0
- package/src/cli/commands/connection.ts +182 -165
- package/src/cli/commands/grant.ts +140 -0
- package/src/cli/commands/identity.ts +21 -9
- package/src/cli/commands/knowledge/index.ts +46 -79
- package/src/cli/commands/knowledge/migrate.ts +74 -13
- package/src/cli/commands/knowledge/show.ts +92 -0
- package/src/cli/commands/knowledge.ts +2 -1
- package/src/cli/commands/mcp/harnesses.ts +30 -8
- package/src/cli/commands/mcp/onboarding.ts +86 -0
- package/src/cli/commands/mcp/register.ts +16 -2
- package/src/cli/commands/mcp.ts +1 -0
- package/src/cli/commands/members.ts +288 -0
- package/src/cli/commands/operate/attach.ts +3 -3
- package/src/cli/commands/operate/audit.ts +11 -7
- package/src/cli/commands/operate/auth.ts +28 -11
- package/src/cli/commands/operate/findings.ts +2 -1
- package/src/cli/commands/operate/inspect.ts +37 -19
- package/src/cli/commands/operate/migrate.ts +29 -12
- package/src/cli/commands/operate/outputs.ts +3 -3
- package/src/cli/commands/operate/pair-certificate.ts +141 -0
- package/src/cli/commands/operate/pair.ts +324 -0
- package/src/cli/commands/operate/policy.ts +73 -22
- package/src/cli/commands/operate/serve.ts +52 -4
- package/src/cli/commands/operate/status.ts +18 -10
- package/src/cli/commands/operate/tools.ts +2 -2
- package/src/cli/commands/operate.ts +2 -0
- package/src/cli/commands/owner/shared.ts +13 -2
- package/src/cli/commands/owner/skills.ts +28 -8
- package/src/cli/commands/profile/removal.ts +79 -76
- package/src/cli/commands/profile/remove.ts +16 -1
- package/src/cli/commands/profile.ts +46 -10
- package/src/cli/commands/relabel.ts +112 -0
- package/src/cli/commands/secrets.ts +34 -12
- package/src/cli/commands/set-workspace.ts +96 -0
- package/src/cli/commands/setup.ts +2 -2
- package/src/cli/commands/sync.ts +8 -8
- package/src/cli/commands/target.ts +9 -7
- package/src/cli/commands/update.ts +58 -17
- package/src/cli/config-edit.ts +75 -140
- package/src/cli/config-migrate.ts +82 -64
- package/src/cli/config-repair.ts +89 -32
- package/src/cli/config-templates.ts +198 -0
- package/src/cli/contract3-data.ts +328 -0
- package/src/cli/contract3-shape.ts +186 -0
- package/src/cli/contract3.ts +282 -0
- package/src/cli/endpoint-url.ts +1 -1
- package/src/cli/lanes.ts +25 -1
- package/src/cli/main.ts +89 -14
- package/src/cli/migrate-plan.ts +12 -6
- package/src/cli/output.ts +34 -1
- package/src/cli/publish.ts +5 -2
- package/src/cli/runtime/open.ts +63 -98
- package/src/cli/runtime/registry.ts +6 -7
- package/src/cli/runtime/stores.ts +53 -0
- package/src/cli/runtime/types.ts +106 -0
- package/src/cli/runtime/vault.ts +19 -4
- package/src/cli/runtime/workspace.ts +60 -0
- package/src/cli/runtime.ts +2 -1
- package/src/cli/selection-require.ts +44 -13
- package/src/cli/selection.ts +127 -145
- package/src/cli/usage.ts +34 -18
- package/src/cli/workspace-migrate.ts +125 -16
- package/src/connectivity/manifest/provider.ts +3 -1
- package/src/connectivity/manifest/requirements.ts +1 -1
- package/src/deployments/bind.ts +1 -1
- package/src/deployments/deploy.ts +36 -27
- package/src/deployments/gcp/bucket.ts +18 -7
- package/src/deployments/gcp/provision.ts +7 -7
- package/src/deployments/prepare.ts +72 -24
- package/src/deployments/record.ts +1 -1
- package/src/deployments/report.ts +2 -2
- package/src/deployments/serving.ts +15 -74
- package/src/deployments/target.ts +15 -15
- package/src/deployments/upload.ts +46 -22
- package/src/dispatch/deps.ts +88 -0
- package/src/dispatch/dispatch.ts +21 -62
- package/src/policy/index.ts +47 -15
- package/src/profile/connections.ts +183 -0
- package/src/profile/deployments.ts +3 -3
- package/src/profile/index.ts +30 -5
- package/src/profile/layout.ts +86 -89
- package/src/profile/load.ts +80 -47
- package/src/profile/pairing.ts +32 -0
- package/src/profile/primitives.ts +35 -1
- package/src/profile/registry.ts +6 -6
- package/src/profile/schema.ts +172 -21
- package/src/profile/targets.ts +21 -9
- package/src/profile/testing.ts +69 -2
- package/src/profile/workspace.ts +58 -3
- package/src/providers/custom/index.ts +1 -1
- package/src/providers/custom/load.ts +2 -3
- package/src/providers/identity/provider.ts +1 -1
- package/src/providers/memory/provider.ts +20 -2
- package/src/providers/setup/plan.ts +1 -1
- package/src/providers/slack/index.ts +2 -2
- package/src/registry/policy-bridge.ts +33 -11
- package/src/registry/reconcile.ts +4 -4
- package/src/server/authorization.ts +94 -0
- package/src/server/edge.ts +14 -1
- package/src/server/endpoint.ts +85 -104
- package/src/server/generation.ts +10 -1
- package/src/server/harness.ts +71 -13
- package/src/server/index.ts +31 -0
- package/src/server/mcp/build.ts +20 -1
- package/src/server/mcp/client-info.ts +54 -0
- package/src/server/mcp/guide.ts +120 -0
- package/src/server/mcp/instructions.ts +1 -1
- package/src/server/mcp/prompts.ts +7 -3
- package/src/server/mcp/resources.ts +16 -8
- package/src/server/mcp/tools.ts +9 -3
- package/src/server/mcp/visibility.ts +18 -3
- package/src/server/oauth.ts +29 -109
- package/src/server/read/credential.ts +134 -0
- package/src/server/read/deployed.ts +56 -0
- package/src/server/read/listener.ts +54 -0
- package/src/server/read/open.ts +101 -0
- package/src/server/read/routes.ts +247 -0
- package/src/server/read/state.ts +171 -0
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { migrateToContract3, needsContract3, type Contract3Migration } from './contract3.ts';
|
|
2
|
+
import { readSession } from '#auth/lanes/session.ts';
|
|
1
3
|
import { parseDocument } from 'yaml';
|
|
2
4
|
import {
|
|
3
5
|
ConfigError,
|
|
@@ -25,7 +27,7 @@ import { hoist, summarise } from './migrate-plan.ts';
|
|
|
25
27
|
* of each profile — one in `~/.lanes-link`, one in the bucket the endpoint reads
|
|
26
28
|
* — and gave them nothing to keep them honest. It failed the way it was always
|
|
27
29
|
* going to: a local file was rewritten, lost its cloud target and eight
|
|
28
|
-
* connections, and `status --
|
|
30
|
+
* connections, and `status --workspace cloud` reported seven where the endpoint was
|
|
29
31
|
* serving fifteen. `sync targets` and ADR-044's deployment index were both
|
|
30
32
|
* written in response to earlier rounds of the same thing.
|
|
31
33
|
*
|
|
@@ -122,8 +124,8 @@ export async function migrateWorkspace(
|
|
|
122
124
|
const changes: string[] = [];
|
|
123
125
|
for (const [name, entry] of Object.entries(registry)) {
|
|
124
126
|
changes.push(
|
|
125
|
-
entry.
|
|
126
|
-
? `
|
|
127
|
+
entry.at !== undefined
|
|
128
|
+
? `workspaces.${name}: pointer to ${entry.at}`
|
|
127
129
|
: `targets.${name}: declared in ${WORKSPACE_FILE}`,
|
|
128
130
|
);
|
|
129
131
|
}
|
|
@@ -147,17 +149,30 @@ export async function migrateWorkspace(
|
|
|
147
149
|
// again.
|
|
148
150
|
await writeRegistry(workspaceRoot, registry);
|
|
149
151
|
|
|
150
|
-
for (const { document } of legacy) {
|
|
152
|
+
for (const { document, profile } of legacy) {
|
|
151
153
|
document.removeIn(['targets']);
|
|
152
|
-
|
|
154
|
+
// Literally 2, not `SUPPORTED_CONTRACT`. This migration produces a
|
|
155
|
+
// contract-2 profile and nothing else; `migrateToContract3` takes it the
|
|
156
|
+
// rest of the way. Writing the current contract here would stamp a file
|
|
157
|
+
// that still has contract-2 shapes with the newest number, and the loader
|
|
158
|
+
// would then read it as a contract-3 document that is missing `grants:`.
|
|
159
|
+
document.setIn(['contract'], 2);
|
|
153
160
|
// `instance.default_target` went with the block it selected from. It has
|
|
154
161
|
// been inert since ADR-037 and there is now nothing for it to name.
|
|
155
162
|
document.removeIn(['instance', 'default_target']);
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
|
|
163
|
+
// Written rather than saved, because `save` validates and what this
|
|
164
|
+
// produces is deliberately not valid *yet*: a contract-2 profile, which the
|
|
165
|
+
// runtime does not read. `migrateToContract3` is the step that makes it
|
|
166
|
+
// loadable, and it runs immediately after — so validating here would refuse
|
|
167
|
+
// the only shape this function is able to produce.
|
|
168
|
+
//
|
|
169
|
+
// Nothing is lost by not validating: the next step reads the file it just
|
|
170
|
+
// wrote, and `check` refuses anything either step leaves broken.
|
|
171
|
+
await writeWorkspaceFile(
|
|
172
|
+
workspaceFiles(workspaceRoot),
|
|
173
|
+
`profiles/${profile}.yaml`,
|
|
174
|
+
document.toString(),
|
|
175
|
+
);
|
|
161
176
|
}
|
|
162
177
|
|
|
163
178
|
return {
|
|
@@ -169,6 +184,82 @@ export async function migrateWorkspace(
|
|
|
169
184
|
};
|
|
170
185
|
}
|
|
171
186
|
|
|
187
|
+
export interface ContractMigration {
|
|
188
|
+
readonly workspaceRoot: string;
|
|
189
|
+
/** The contract 1 → 2 half, when this workspace needed one. */
|
|
190
|
+
readonly legacy: WorkspaceMigration | null;
|
|
191
|
+
/** The contract 2 → 3 half, when this workspace needed one. */
|
|
192
|
+
readonly contract3: Contract3Migration | null;
|
|
193
|
+
/** Every profile either half rewrote, deduplicated. */
|
|
194
|
+
readonly profiles: readonly string[];
|
|
195
|
+
/** Targets written into the registry by the contract 1 → 2 half. */
|
|
196
|
+
readonly targets: readonly { name: string; kind: 'declared' | 'pointer'; where?: string }[];
|
|
197
|
+
/** Both halves' lines, in the order they happened. */
|
|
198
|
+
readonly changes: readonly string[];
|
|
199
|
+
/** Nothing to do: this workspace is already at `SUPPORTED_CONTRACT`. */
|
|
200
|
+
readonly alreadyCurrent: boolean;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Bring one workspace to `SUPPORTED_CONTRACT`, whatever it is on now.
|
|
205
|
+
*
|
|
206
|
+
* **This exists because "migrate a workspace" was spelled three times and only
|
|
207
|
+
* one of them arrived.** `update` ran the 1→2 step and then the 2→3 step;
|
|
208
|
+
* `deploy` and `doctor --fix` ran the first and stopped, which nothing said out
|
|
209
|
+
* loud — `migrateTargetWorkspace`'s own docstring claimed it brought a target
|
|
210
|
+
* "to the current contract" while calling only `migrateWorkspace`.
|
|
211
|
+
*
|
|
212
|
+
* What that cost is worth writing down, because it is not a refusal. A deploy
|
|
213
|
+
* uploaded contract-3 config over a contract-2 bucket and rolled a revision that
|
|
214
|
+
* read `data/state.kv` and `data/audit.log` — while the bytes sat where contract
|
|
215
|
+
* 2 put them, under `data/<profile>/`. The endpoint came up healthy and answered
|
|
216
|
+
* every call with an empty store: no memory, no tasks, no skills, and an audit
|
|
217
|
+
* log that verified as intact because an empty chain does. Nothing in the
|
|
218
|
+
* deploy, the health probe, or `doctor` had a way to notice.
|
|
219
|
+
*
|
|
220
|
+
* So there is one function now, and the contract number lives in one place. A
|
|
221
|
+
* fourth contract adds a step here and every caller gets it.
|
|
222
|
+
*
|
|
223
|
+
* **A dry run of a contract-1 workspace reports only the first half**, and that
|
|
224
|
+
* is honest rather than a gap: the 1→2 step wrote nothing, so the profiles are
|
|
225
|
+
* still contract 1 and the 2→3 step has nothing it recognises to describe. What
|
|
226
|
+
* it would do is knowable only after the step before it has run.
|
|
227
|
+
*
|
|
228
|
+
* **The signed-in subject is defaulted here rather than by each caller.** A
|
|
229
|
+
* migrated profile that lists nobody is an endpoint that advertises OAuth and
|
|
230
|
+
* then refuses its own owner, so it matters on every path — but `deployments`
|
|
231
|
+
* may not import `#auth` (see `MAY_IMPORT` in `src/architecture.test.ts`), and
|
|
232
|
+
* `deploy` is the caller that most needs it: once a target's profiles live in
|
|
233
|
+
* its bucket there is no upload behind it to put the members row back. A caller
|
|
234
|
+
* that knows better passes one; nobody signed in is a legitimate answer and
|
|
235
|
+
* stays default-deny on the identity axis.
|
|
236
|
+
*/
|
|
237
|
+
export async function migrateToCurrentContract(
|
|
238
|
+
workspaceRoot: string,
|
|
239
|
+
options: { apply: boolean; subject?: string } = { apply: true },
|
|
240
|
+
): Promise<ContractMigration> {
|
|
241
|
+
const legacy = (await needsMigration(workspaceRoot))
|
|
242
|
+
? await migrateWorkspace(workspaceRoot, { apply: options.apply })
|
|
243
|
+
: null;
|
|
244
|
+
|
|
245
|
+
const subject = options.subject ?? (await readSession().catch(() => null))?.subject;
|
|
246
|
+
|
|
247
|
+
const contract3 = await migrateToContract3(workspaceRoot, {
|
|
248
|
+
apply: options.apply,
|
|
249
|
+
...(subject === undefined ? {} : { subject }),
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
workspaceRoot,
|
|
254
|
+
legacy: legacy !== null && !legacy.alreadyCurrent ? legacy : null,
|
|
255
|
+
contract3: contract3.alreadyCurrent ? null : contract3,
|
|
256
|
+
profiles: [...new Set([...(legacy?.profiles ?? []), ...contract3.profiles])],
|
|
257
|
+
targets: legacy?.targets ?? [],
|
|
258
|
+
changes: [...(legacy?.changes ?? []), ...contract3.changes],
|
|
259
|
+
alreadyCurrent: (legacy === null || legacy.alreadyCurrent) && contract3.alreadyCurrent,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
172
263
|
/**
|
|
173
264
|
* Write the registry into the workspace file, creating it when absent.
|
|
174
265
|
*
|
|
@@ -186,7 +277,7 @@ async function writeRegistry(
|
|
|
186
277
|
const text = (await readWorkspaceFile(files, WORKSPACE_FILE)) ?? `contract: ${SUPPORTED_CONTRACT}\n`;
|
|
187
278
|
const document = parseDocument(text);
|
|
188
279
|
const current = (document.toJSON() ?? {}) as {
|
|
189
|
-
|
|
280
|
+
workspaces?: Record<string, WorkspaceTarget>;
|
|
190
281
|
deployments?: {
|
|
191
282
|
target?: string;
|
|
192
283
|
workspace?: string;
|
|
@@ -198,7 +289,7 @@ async function writeRegistry(
|
|
|
198
289
|
// Anything already in the file wins over what was hoisted: a workspace part
|
|
199
290
|
// way through this has entries that are already right, and re-deriving them
|
|
200
291
|
// from a profile that still carries a stale block would undo a correction.
|
|
201
|
-
const merged: Record<string, WorkspaceTarget> = { ...registry, ...(current.
|
|
292
|
+
const merged: Record<string, WorkspaceTarget> = { ...registry, ...(current.workspaces ?? {}) };
|
|
202
293
|
|
|
203
294
|
// ADR-044's index, folded into the entries it described. `primary` and
|
|
204
295
|
// `last_deploy` were kept beside the declaration precisely because the
|
|
@@ -214,11 +305,16 @@ async function writeRegistry(
|
|
|
214
305
|
};
|
|
215
306
|
}
|
|
216
307
|
|
|
308
|
+
// `workspaces:`, not `targets:` — the registry is read back by the current
|
|
309
|
+
// schema even while the profiles beside it are still contract 2, because it
|
|
310
|
+
// is one document that both migrations share (ADR-061). The profiles are the
|
|
311
|
+
// half that stays at 2 until `migrateToContract3` runs.
|
|
217
312
|
document.setIn(['contract'], SUPPORTED_CONTRACT);
|
|
218
313
|
document.setIn(
|
|
219
|
-
['
|
|
314
|
+
['workspaces'],
|
|
220
315
|
Object.fromEntries(Object.entries(merged).sort(([a], [b]) => a.localeCompare(b))),
|
|
221
316
|
);
|
|
317
|
+
document.deleteIn(['targets']);
|
|
222
318
|
document.deleteIn(['deployments']);
|
|
223
319
|
document.deleteIn(['default_target']);
|
|
224
320
|
|
|
@@ -229,8 +325,8 @@ function describe(
|
|
|
229
325
|
registry: Record<string, WorkspaceTarget>,
|
|
230
326
|
): { name: string; kind: 'declared' | 'pointer'; where?: string }[] {
|
|
231
327
|
return Object.entries(registry).map(([name, entry]) =>
|
|
232
|
-
entry.
|
|
233
|
-
? { name, kind: 'pointer' as const, where: entry.
|
|
328
|
+
entry.at !== undefined
|
|
329
|
+
? { name, kind: 'pointer' as const, where: entry.at }
|
|
234
330
|
: { name, kind: 'declared' as const },
|
|
235
331
|
);
|
|
236
332
|
}
|
|
@@ -249,6 +345,19 @@ function describe(
|
|
|
249
345
|
* part of what it already means (ADR-052).
|
|
250
346
|
*/
|
|
251
347
|
export async function refuseIfUnmigrated(root: string): Promise<void> {
|
|
348
|
+
if (await needsContract3(root)) {
|
|
349
|
+
throw new ConfigError(
|
|
350
|
+
`${root} is a contract 2 workspace, and this version does not read one.\n\n` +
|
|
351
|
+
' Under contract 2 each profile carried its own connections and one flat\n' +
|
|
352
|
+
' policy. Connections belong to the workspace now, and a profile selects\n' +
|
|
353
|
+
' among them with per-connection scopes (ADR-057, ADR-058).\n\n' +
|
|
354
|
+
' Migrate it: lanes link update\n' +
|
|
355
|
+
' Preview it: lanes link update --check\n\n' +
|
|
356
|
+
' Nothing is deleted by the migration: credentials are merged and read back\n' +
|
|
357
|
+
' before the old stores are left in place.',
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
252
361
|
if (!(await needsMigration(root))) return;
|
|
253
362
|
|
|
254
363
|
throw new ConfigError(
|
|
@@ -258,6 +367,6 @@ export async function refuseIfUnmigrated(root: string): Promise<void> {
|
|
|
258
367
|
' copies of every profile that could drift apart (ADR-052).\n\n' +
|
|
259
368
|
' Migrate it: lanes link update\n' +
|
|
260
369
|
' A deployed target is migrated by the deploy that ships the image reading it:\n' +
|
|
261
|
-
' lanes link deploy --
|
|
370
|
+
' lanes link deploy --workspace <name>',
|
|
262
371
|
);
|
|
263
372
|
}
|
|
@@ -18,7 +18,9 @@ import { connectionVariableSchema, placeholdersInConnector } from './variables.t
|
|
|
18
18
|
*
|
|
19
19
|
* - built-ins, written as typed TS modules under `#providers/` and validated
|
|
20
20
|
* at import
|
|
21
|
-
* - the
|
|
21
|
+
* - the workspace's own manifests in `data/providers.d/*.yaml`, validated on load
|
|
22
|
+
* — the workspace's, not a profile's, because a manifest defines a
|
|
23
|
+
* connection and connections stopped belonging to profiles (ADR-057)
|
|
22
24
|
*
|
|
23
25
|
* That second one is the scalability claim. A service nobody has integrated is
|
|
24
26
|
* a YAML file the operator writes, not a pull request they wait on.
|
|
@@ -76,7 +76,7 @@ export interface SetupNeeds {
|
|
|
76
76
|
function storeCommand(ref: string, placeholder: string, where: Selection): string {
|
|
77
77
|
return (
|
|
78
78
|
`printf %s "${placeholder}" | lanes link secrets set ${ref}` +
|
|
79
|
-
` --profile ${where.profile} --
|
|
79
|
+
` --profile ${where.profile} --workspace ${where.target}`
|
|
80
80
|
);
|
|
81
81
|
}
|
|
82
82
|
|
package/src/deployments/bind.ts
CHANGED
|
@@ -121,7 +121,7 @@ export async function bindConnectionCredentials(input: {
|
|
|
121
121
|
`could not bind ${connection.provider}.${connection.id}'s credential to ` +
|
|
122
122
|
`${serviceAccount}, so the deployed endpoint will be able to read it and not ` +
|
|
123
123
|
'rotate it — which fails about an hour after the first use. ' +
|
|
124
|
-
`Run \`lanes link deploy --
|
|
124
|
+
`Run \`lanes link deploy --workspace ${input.target}\` to bind it. ` +
|
|
125
125
|
`(${driver.tool}: ${result.stderr.trim().split('\n').slice(-1)[0] ?? 'failed'})`,
|
|
126
126
|
};
|
|
127
127
|
}
|
|
@@ -15,9 +15,9 @@ import { printSteps, runSteps } from './steps.ts';
|
|
|
15
15
|
import { driverFor } from './drivers.ts';
|
|
16
16
|
import { prepareSecrets, readableRefs, rotatableRefs } from './prepare.ts';
|
|
17
17
|
import { repairOwnerLayer } from '#cli/config-repair.ts';
|
|
18
|
-
import {
|
|
18
|
+
import { migrateToCurrentContract } from '#cli/workspace-migrate.ts';
|
|
19
19
|
import { deployedWorkspace, uploadWorkspace } from './upload.ts';
|
|
20
|
-
import {
|
|
20
|
+
import { servingProfiles } from './serving.ts';
|
|
21
21
|
import { healthLine, reachability, registerLine, reportUnauthorised } from './report.ts';
|
|
22
22
|
|
|
23
23
|
/**
|
|
@@ -75,9 +75,14 @@ export async function deploy(flags: DeployFlags): Promise<void> {
|
|
|
75
75
|
// declare anything: it reads this machine's pointer and stops. So the bucket is
|
|
76
76
|
// located, migrated, and only then opened.
|
|
77
77
|
//
|
|
78
|
-
// Idempotent, and silent on a workspace already
|
|
79
|
-
//
|
|
80
|
-
//
|
|
78
|
+
// Idempotent, and silent on a workspace already current — a listing and no
|
|
79
|
+
// writes. `--dry-run` reports what it would do and writes nothing, like every
|
|
80
|
+
// other step of this command.
|
|
81
|
+
//
|
|
82
|
+
// It goes all the way to `SUPPORTED_CONTRACT`, which it did not use to: this
|
|
83
|
+
// ran the contract 1→2 step alone, so a contract-2 bucket passed straight
|
|
84
|
+
// through and the revision came up reading an empty `data/` while the bytes
|
|
85
|
+
// stayed under `data/<profile>/`. See `migrateToCurrentContract`.
|
|
81
86
|
if (!(await migrateTargetWorkspace(requireTargetFlag(flags), flags.dryRun !== true))) return;
|
|
82
87
|
|
|
83
88
|
// The one command allowed to name a target that does not exist yet: creating
|
|
@@ -112,14 +117,13 @@ export async function deploy(flags: DeployFlags): Promise<void> {
|
|
|
112
117
|
print(style.dim(` serving ${serving.join(', ')} — ${primary} owns the token`));
|
|
113
118
|
}
|
|
114
119
|
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
// no later symptom worth
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
}
|
|
120
|
+
// The credential-collision preflight is gone. It existed because two profiles
|
|
121
|
+
// each held their own `gmail.main` and both derived the flat ref `gmail/main`
|
|
122
|
+
// into one store — the last deploy winning, with no later symptom worth
|
|
123
|
+
// having. A connection belongs to the workspace now and `<provider>.<id>` is
|
|
124
|
+
// unique by construction (ADR-057), so the state it guarded against cannot be
|
|
125
|
+
// written: `assertConnectionsUnique` refuses it at load, before a deploy runs
|
|
126
|
+
// at all.
|
|
123
127
|
|
|
124
128
|
// `check` before anything external, per the gate order: a config that will be
|
|
125
129
|
// rejected on boot should be rejected here, not after a five-minute build.
|
|
@@ -150,7 +154,7 @@ export async function deploy(flags: DeployFlags): Promise<void> {
|
|
|
150
154
|
// below are, and read from config and manifests before anything opens a
|
|
151
155
|
// store — `--dry-run` must reach the printed step list without touching a
|
|
152
156
|
// credential.
|
|
153
|
-
const rotatable = await rotatableRefs(resolution.workspaceRoot, serving);
|
|
157
|
+
const rotatable = await rotatableRefs(resolution.workspaceRoot, serving, declared);
|
|
154
158
|
const readable = await readableRefs(resolution.workspaceRoot, serving, declared);
|
|
155
159
|
const provision = await driver.provision({
|
|
156
160
|
deploy: deployConfig,
|
|
@@ -240,7 +244,7 @@ export async function deploy(flags: DeployFlags): Promise<void> {
|
|
|
240
244
|
for (const problem of prepared.blocking) print(fail(problem));
|
|
241
245
|
throw new ConfigError(
|
|
242
246
|
'The deployed instance cannot start without these. Store them with ' +
|
|
243
|
-
`lanes link secrets set <ref> --profile ${resolution.profile} --
|
|
247
|
+
`lanes link secrets set <ref> --profile ${resolution.profile} --workspace ${target}, or ` +
|
|
244
248
|
`copy a local setup with lanes link secrets push --profile ${resolution.profile} ` +
|
|
245
249
|
`--from local --to ${target}.`,
|
|
246
250
|
);
|
|
@@ -275,6 +279,17 @@ export async function deploy(flags: DeployFlags): Promise<void> {
|
|
|
275
279
|
// workspace that this target cannot open, and nothing left to check.
|
|
276
280
|
await repairOwnerLayer(resolution.workspaceRoot, serving);
|
|
277
281
|
|
|
282
|
+
// **The bucket's own contract, before anything is written over it.**
|
|
283
|
+
//
|
|
284
|
+
// The pass at the top of the command resolves the target through this
|
|
285
|
+
// machine's pointer, which a *first* deploy does not have: the entry is
|
|
286
|
+
// still a declaration, `resolveTargetWorkspace` answers with the local root,
|
|
287
|
+
// and it returns early. So this is the only pass that ever sees an existing
|
|
288
|
+
// bucket at contract 2 — and running it after the upload is the same as not
|
|
289
|
+
// running it, because the upload writes contract-3 profiles and
|
|
290
|
+
// `needsContract3` reads the profiles.
|
|
291
|
+
await migrateToCurrentContract(workspace, { apply: true });
|
|
292
|
+
|
|
278
293
|
// Before the rollout, so the revision that comes up finds a config to read.
|
|
279
294
|
// Uploading after would leave a window where the service is serving and the
|
|
280
295
|
// workspace it was told to read is not there yet.
|
|
@@ -291,15 +306,9 @@ export async function deploy(flags: DeployFlags): Promise<void> {
|
|
|
291
306
|
await uploadWorkspace(resolution.workspaceRoot, workspace, serving);
|
|
292
307
|
}
|
|
293
308
|
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
|
|
297
|
-
// migrated whatever the bucket already held; this catches what the upload
|
|
298
|
-
// just put there — profiles from a workspace that is itself at contract 2
|
|
299
|
-
// arrive migrated, but a first deploy of a *newly created* bucket writes
|
|
300
|
-
// them here for the first time. Idempotent, so the ordinary case is one
|
|
301
|
-
// listing and no writes.
|
|
302
|
-
await migrateWorkspace(workspace, { apply: true });
|
|
309
|
+
// Again for what the upload put there: a newly created bucket gets its
|
|
310
|
+
// profiles written here for the first time. Idempotent — one listing.
|
|
311
|
+
await migrateToCurrentContract(workspace, { apply: true });
|
|
303
312
|
|
|
304
313
|
// Where this deployment lives, in both registries — see `record.ts`. The
|
|
305
314
|
// declaration has to land before the revision boots, so it goes here rather
|
|
@@ -325,7 +334,7 @@ export async function deploy(flags: DeployFlags): Promise<void> {
|
|
|
325
334
|
if (!url) {
|
|
326
335
|
print(
|
|
327
336
|
warn(
|
|
328
|
-
`deployed, but the platform reported no URL yet — run: lanes link outputs --profile ${resolution.profile} --
|
|
337
|
+
`deployed, but the platform reported no URL yet — run: lanes link outputs --profile ${resolution.profile} --workspace ${target}`,
|
|
329
338
|
),
|
|
330
339
|
);
|
|
331
340
|
return;
|
|
@@ -365,7 +374,7 @@ async function migrateTargetWorkspace(target: string, apply: boolean): Promise<b
|
|
|
365
374
|
const workspace = await resolveTargetWorkspace(root, target).catch(() => null);
|
|
366
375
|
if (workspace === null || workspace === root) return true;
|
|
367
376
|
|
|
368
|
-
const migrated = await
|
|
377
|
+
const migrated = await migrateToCurrentContract(workspace, { apply });
|
|
369
378
|
if (migrated.alreadyCurrent) return true;
|
|
370
379
|
|
|
371
380
|
heading(apply ? 'Migrated' : 'Would migrate');
|
|
@@ -383,6 +392,6 @@ async function migrateTargetWorkspace(target: string, apply: boolean): Promise<b
|
|
|
383
392
|
print(style.dim(' Nothing was written, and nothing else was checked: the rest of this'));
|
|
384
393
|
print(style.dim(' command opens the target, which is not readable until this has run.'));
|
|
385
394
|
print('');
|
|
386
|
-
print(style.dim(` Run it for real: lanes link deploy --
|
|
395
|
+
print(style.dim(` Run it for real: lanes link deploy --workspace ${target}`));
|
|
387
396
|
return false;
|
|
388
397
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
|
-
import { installRoot, layout } from '#profile';
|
|
2
|
+
import { CONNECTIONS_FILE, WORKSPACE_FILE, installRoot, layout } from '#profile';
|
|
3
3
|
import type { DeployStep } from '../driver.ts';
|
|
4
4
|
import {
|
|
5
5
|
removalStep,
|
|
@@ -77,9 +77,10 @@ export function bucketGrants(bucket: string, profiles: readonly string[]): Condi
|
|
|
77
77
|
*/
|
|
78
78
|
const theBucket = `resource.name == "projects/_/buckets/${bucket}"`;
|
|
79
79
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
80
|
+
// One prefix, not one per profile. Manifests are the workspace's since
|
|
81
|
+
// ADR-057 — a manifest defines a connection, and connections do not live in a
|
|
82
|
+
// profile — so the carve-out no longer varies with the profile set.
|
|
83
|
+
const manifestPrefixes = [objectsUnder(`${layout.providers()}/`)];
|
|
83
84
|
// No profiles leaves the carve-out off rather than guessing at one: the
|
|
84
85
|
// revision keeps write on its own data, as it did before this existed.
|
|
85
86
|
const manifests = manifestPrefixes.length > 0 ? `(${manifestPrefixes.join(' || ')})` : null;
|
|
@@ -95,11 +96,21 @@ export function bucketGrants(bucket: string, profiles: readonly string[]): Condi
|
|
|
95
96
|
{
|
|
96
97
|
// `expression=true` was here, which is every object in the bucket — the
|
|
97
98
|
// step title and ADR-023 both claim a narrowing this did not do. The
|
|
98
|
-
// config the revision reads is the workspace file,
|
|
99
|
-
//
|
|
99
|
+
// config the revision reads is the workspace file, `connections.yaml`
|
|
100
|
+
// beside it, the profiles, and the manifests, so name exactly those.
|
|
101
|
+
//
|
|
102
|
+
// **Named by the constants, because the list went stale once already.**
|
|
103
|
+
// ADR-057 moved connections out of the profile into `connections.yaml` at
|
|
104
|
+
// the workspace root, and `upload.ts` puts it in the bucket because "the
|
|
105
|
+
// endpoint cannot resolve a single grant without it" — but this expression
|
|
106
|
+
// still enumerated the contract-2 set. Every fresh deploy built its image,
|
|
107
|
+
// rolled a revision, and died on `GCS refused to read "connections.yaml"
|
|
108
|
+
// (403)` before it could listen on its port. Nothing caught it: the
|
|
109
|
+
// condition is a string assembled here and asserted nowhere, so the suite
|
|
110
|
+
// saw a passing build and Cloud Run saw a container that never started.
|
|
100
111
|
role: 'roles/storage.objectViewer',
|
|
101
112
|
title: 'reads-its-config',
|
|
102
|
-
expression: `${theBucket} || ${objectsUnder('profiles/')} || ${objectIs(
|
|
113
|
+
expression: `${theBucket} || ${objectsUnder('profiles/')} || ${objectIs(WORKSPACE_FILE)} || ${objectIs(CONNECTIONS_FILE)}${manifests ? ` || ${manifests}` : ''}`,
|
|
103
114
|
},
|
|
104
115
|
];
|
|
105
116
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { SecretRef } from '#secrets';
|
|
2
2
|
import type { DeployStep, ProvisionInput } from '../driver.ts';
|
|
3
3
|
import { encodeRef } from '../adapters/gcp-secret-manager.ts';
|
|
4
4
|
import { bucketSteps } from './bucket.ts';
|
|
@@ -312,12 +312,12 @@ export async function provisionSteps(
|
|
|
312
312
|
// mind: nothing here was wrong, it was incomplete, and being incomplete
|
|
313
313
|
// looked exactly like being finished. Reading mail 403'd an hour after every
|
|
314
314
|
// deploy.
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
];
|
|
315
|
+
// Both kinds arrive already derived: the vault document is named per vault
|
|
316
|
+
// connection (ADR-059) and only `prepare.ts` has the profile configs to work
|
|
317
|
+
// it out. Naming `vault/document` here — the contract-2 constant — created
|
|
318
|
+
// and granted one secret while `openVault` asked for another, and the
|
|
319
|
+
// revision died on `PERMISSION_DENIED` before it could listen. See `vaultRef`.
|
|
320
|
+
const rotatable = [...(input.rotatable ?? [])];
|
|
321
321
|
|
|
322
322
|
// Read, named one secret at a time, for the same reason the write side is:
|
|
323
323
|
// a resource-level grant needs no condition to be scoped.
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { credentialRefFor, ownClientRefsFor, rotatableCredentialRefsFor } from '#registry';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
PAIR_TOKEN_REF,
|
|
4
|
+
readConnections, listProfiles, loadProfileConfig, vaultRef, type Config, type TargetConfig } from '#profile';
|
|
3
5
|
import { VAULT_DOCUMENT_REF, VAULT_KEY_REF, generateVaultKey, type SecretStore } from '#secrets';
|
|
4
6
|
import { ok, print, style, warn } from '#cli/output.ts';
|
|
5
7
|
import { buildRegistryWithWorkspace, ensureProfileToken } from '#cli/runtime.ts';
|
|
@@ -66,6 +68,7 @@ export interface PrepareResult {
|
|
|
66
68
|
export async function rotatableRefs(
|
|
67
69
|
root: string,
|
|
68
70
|
profiles: readonly string[] | undefined,
|
|
71
|
+
declared: TargetConfig | undefined,
|
|
69
72
|
): Promise<string[]> {
|
|
70
73
|
const refs = new Set<string>();
|
|
71
74
|
const wanted = profiles === undefined ? undefined : new Set(profiles);
|
|
@@ -80,16 +83,27 @@ export async function rotatableRefs(
|
|
|
80
83
|
continue;
|
|
81
84
|
}
|
|
82
85
|
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
|
|
86
|
+
// `vault.put` is a capability an agent may hold under policy (ADR-022), so
|
|
87
|
+
// the revision rewrites this one — and it is per vault connection, which is
|
|
88
|
+
// what makes this a per-profile pass rather than a target-level constant.
|
|
89
|
+
// The loop had been reduced to `void config` when the derivation moved out;
|
|
90
|
+
// this is it moving back, next to the config it needs.
|
|
91
|
+
if (declared?.vault?.adapter === 'secret') refs.add(vaultRef(declared, config));
|
|
92
|
+
}
|
|
88
93
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
94
|
+
// Once, outside the profile loop. Connections and the manifests that describe
|
|
95
|
+
// them belong to the workspace (ADR-057), so the per-profile registry ADR-030
|
|
96
|
+
// required is gone — and with it the failure it guarded against, where a
|
|
97
|
+
// connection in `work` resolved to nothing against `personal`'s registry and
|
|
98
|
+
// the missed ref became a 403 an hour after the revision reported healthy.
|
|
99
|
+
//
|
|
100
|
+
// Every connection, not just granted ones: a credential a revision cannot read
|
|
101
|
+
// is a broken account, and whether some profile currently grants it is a
|
|
102
|
+
// question that changes without redeploying.
|
|
103
|
+
const registry = await buildRegistryWithWorkspace(root);
|
|
104
|
+
for (const connection of (await readConnections(root)).connections) {
|
|
105
|
+
const manifest = registry.manifest(connection.provider);
|
|
106
|
+
for (const ref of rotatableCredentialRefsFor(connection, manifest)) refs.add(ref);
|
|
93
107
|
}
|
|
94
108
|
|
|
95
109
|
// Sorted, and a set: two Gmail connections share one dynamically registered
|
|
@@ -132,9 +146,41 @@ export async function readableRefs(
|
|
|
132
146
|
): Promise<string[]> {
|
|
133
147
|
const refs = new Set<string>();
|
|
134
148
|
|
|
149
|
+
// Unconditionally, for every deploy, whether or not this workspace has ever
|
|
150
|
+
// been paired — and that is the point rather than a rounding up.
|
|
151
|
+
//
|
|
152
|
+
// Secret Manager answers a *missing binding* with 403, not 404, so that an
|
|
153
|
+
// identity cannot enumerate secrets by their error codes. The adapter returns
|
|
154
|
+
// null for the 404 and throws for the 403, so an unbound ref is a thrown
|
|
155
|
+
// error on the read path — and that is the failure `server/read/open.ts`
|
|
156
|
+
// records: a deployed revision asked for refs no binding covered and never
|
|
157
|
+
// went healthy.
|
|
158
|
+
//
|
|
159
|
+
// Bound, the deployed-but-never-paired case becomes the 404 instead — a
|
|
160
|
+
// secret that exists with no version — which reads back as null and renders
|
|
161
|
+
// as `401 {error:'unpaired'}`. The grant is what turns a crash into a
|
|
162
|
+
// refusal.
|
|
163
|
+
//
|
|
164
|
+
// Deciding it by asking *whether the workspace is paired* is the thing that
|
|
165
|
+
// cannot happen: that means opening a credential store inside `readableRefs`,
|
|
166
|
+
// which `--dry-run` reaches and must not do.
|
|
167
|
+
//
|
|
168
|
+
// Deliberately not in `rotatableRefs`. The revision never writes this one —
|
|
169
|
+
// minting is `lanes link pair`, from the operator's machine — and
|
|
170
|
+
// `secretVersionAdder` here would let a compromised revision issue itself a
|
|
171
|
+
// credential that reads the whole workspace.
|
|
172
|
+
//
|
|
173
|
+
// The cert and key refs are absent for a different reason: Cloud Run
|
|
174
|
+
// terminates TLS with a certificate a browser already trusts, so a deployed
|
|
175
|
+
// revision never calls `serveRead` and never reads either.
|
|
176
|
+
refs.add(PAIR_TOKEN_REF);
|
|
177
|
+
|
|
178
|
+
// Only the key is the target's. The *document* is named per vault connection
|
|
179
|
+
// (ADR-059), so it is added inside the profile loop below — naming it here
|
|
180
|
+
// meant `vault/document`, which is not what `openVault` opens, and the
|
|
181
|
+
// revision 403'd on a ref nothing had created. See `vaultRef`.
|
|
135
182
|
if (declared?.vault?.adapter === 'secret') {
|
|
136
183
|
refs.add(VAULT_KEY_REF);
|
|
137
|
-
refs.add(declared.vault.ref ?? VAULT_DOCUMENT_REF);
|
|
138
184
|
} else if (declared?.vault?.adapter === 'blob') {
|
|
139
185
|
// Ciphertext lives in the bucket, but the key that opens it is still here.
|
|
140
186
|
refs.add(VAULT_KEY_REF);
|
|
@@ -153,22 +199,24 @@ export async function readableRefs(
|
|
|
153
199
|
}
|
|
154
200
|
|
|
155
201
|
refs.add(config.auth.token_ref);
|
|
202
|
+
if (declared?.vault?.adapter === 'secret') refs.add(vaultRef(declared, config));
|
|
156
203
|
// The OIDC audience check reads this on every verify (`server/endpoint.ts`).
|
|
157
204
|
if (config.auth.authorization?.mode === 'oidc') {
|
|
158
205
|
refs.add(config.auth.authorization.client_id_ref);
|
|
159
206
|
}
|
|
160
207
|
|
|
161
|
-
|
|
162
|
-
// profile's, so the registry that resolves its connections must be too.
|
|
163
|
-
const registry = await buildRegistryWithWorkspace(root, name);
|
|
208
|
+
}
|
|
164
209
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
210
|
+
// Once, for the reason `rotatableRefs` gives.
|
|
211
|
+
const connectionsFile = await readConnections(root);
|
|
212
|
+
const registry = await buildRegistryWithWorkspace(root);
|
|
213
|
+
|
|
214
|
+
for (const connection of connectionsFile.connections) {
|
|
215
|
+
const manifest = registry.manifest(connection.provider);
|
|
216
|
+
const ref = credentialRefFor(connection, manifest);
|
|
217
|
+
if (ref) refs.add(ref);
|
|
218
|
+
for (const rotatable of rotatableCredentialRefsFor(connection, manifest)) refs.add(rotatable);
|
|
219
|
+
for (const client of ownClientRefsFor(manifest, connectionsFile.oauth_apps)) refs.add(client);
|
|
172
220
|
}
|
|
173
221
|
|
|
174
222
|
return [...refs].sort();
|
|
@@ -190,13 +238,13 @@ export async function prepareSecrets(input: PrepareInput): Promise<PrepareResult
|
|
|
190
238
|
// The command is spelled out rather than described. This is the one manual
|
|
191
239
|
// step a deploy genuinely cannot take for you, so it should cost a paste
|
|
192
240
|
// rather than a trip to the docs to work out how the id is spelled.
|
|
193
|
-
const registry = await buildRegistryWithWorkspace(root
|
|
194
|
-
for (const connection of
|
|
241
|
+
const registry = await buildRegistryWithWorkspace(root);
|
|
242
|
+
for (const connection of (await readConnections(root)).connections) {
|
|
195
243
|
const ref = credentialRefFor(connection, registry.manifest(connection.provider));
|
|
196
244
|
if (!ref || (await credentials.has(ref))) continue;
|
|
197
245
|
warnings.push(
|
|
198
246
|
`${connection.provider}.${connection.id} is not authorised yet — no credential at "${ref}"\n` +
|
|
199
|
-
` lanes link connect ${connection.provider} --profile ${input.config.instance.profile} --
|
|
247
|
+
` lanes link connect ${connection.provider} --profile ${input.config.instance.profile} --workspace ${target} --id ${connection.id}`,
|
|
200
248
|
);
|
|
201
249
|
}
|
|
202
250
|
|
|
@@ -56,7 +56,7 @@ export async function recordDeployment(record: DeploymentRecord): Promise<void>
|
|
|
56
56
|
// there pointed the bucket at itself, and the revision that came up refused to
|
|
57
57
|
// open its own target.
|
|
58
58
|
await recordTarget(resolveWorkspaceRoot(), record.target, {
|
|
59
|
-
|
|
59
|
+
at: record.workspace,
|
|
60
60
|
...stamp,
|
|
61
61
|
});
|
|
62
62
|
}
|
|
@@ -37,7 +37,7 @@ import { heading, ok, print, style, warn } from '#cli/output.ts';
|
|
|
37
37
|
export function registerLine(profile: string, target: string): string {
|
|
38
38
|
return style.dim(
|
|
39
39
|
` Connect your accounts first, then register with:\n` +
|
|
40
|
-
` lanes link outputs --profile ${profile} --
|
|
40
|
+
` lanes link outputs --profile ${profile} --workspace ${target}\n` +
|
|
41
41
|
' A client keeps the tool list it fetched when it connected, so one registered\n' +
|
|
42
42
|
' before the accounts holds a surface without them until it is re-added.',
|
|
43
43
|
);
|
|
@@ -66,7 +66,7 @@ export function reportUnauthorised(warnings: readonly string[], profile: string,
|
|
|
66
66
|
print(
|
|
67
67
|
style.dim(
|
|
68
68
|
' A browser consent per account is the one step this cannot take for you:\n' +
|
|
69
|
-
` lanes link connect <provider> --profile ${profile} --
|
|
69
|
+
` lanes link connect <provider> --profile ${profile} --workspace ${target}\n` +
|
|
70
70
|
' Each is served as soon as it is authorised. There is no second deploy —\n' +
|
|
71
71
|
' deploying is how code gets here, and authorising an account changes none.',
|
|
72
72
|
),
|