@lanes-sh/link 0.7.2 → 0.9.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 +148 -73
- 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 +85 -4
- package/src/auth/remote.ts +32 -9
- package/src/cli/accepts.ts +109 -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 +90 -88
- 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 +16 -6
- 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 +183 -165
- package/src/cli/commands/grant.ts +140 -0
- package/src/cli/commands/identity.ts +24 -12
- package/src/cli/commands/knowledge/index.ts +49 -89
- package/src/cli/commands/knowledge/migrate.ts +79 -13
- package/src/cli/commands/knowledge/show.ts +97 -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 +33 -13
- 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 +53 -5
- 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/assets.ts +2 -2
- package/src/cli/commands/owner/entities.ts +2 -2
- package/src/cli/commands/owner/memory.ts +2 -2
- package/src/cli/commands/owner/shared.ts +13 -2
- package/src/cli/commands/owner/skills.ts +28 -8
- package/src/cli/commands/owner/tasks.ts +2 -2
- package/src/cli/commands/owner/vault.ts +3 -3
- package/src/cli/commands/profile/disposition.ts +236 -0
- package/src/cli/commands/profile/removal.ts +154 -64
- package/src/cli/commands/profile/remove.ts +83 -7
- package/src/cli/commands/profile.ts +79 -16
- package/src/cli/commands/relabel.ts +112 -0
- package/src/cli/commands/secrets.ts +39 -17
- 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-migration.ts +54 -0
- package/src/cli/commands/update.ts +78 -24
- package/src/cli/config-edit.ts +99 -143
- package/src/cli/config-migrate.ts +82 -64
- package/src/cli/config-repair-sweep.ts +119 -0
- package/src/cli/config-repair.ts +131 -125
- package/src/cli/config-templates.ts +200 -0
- package/src/cli/contract3-credentials.ts +294 -0
- package/src/cli/contract3-data.ts +262 -0
- package/src/cli/contract3-layout.ts +46 -0
- package/src/cli/contract3-shape.ts +212 -0
- package/src/cli/contract3.ts +399 -0
- package/src/cli/contract4-credentials.ts +207 -0
- package/src/cli/contract4-data.ts +399 -0
- package/src/cli/contract4-rename.ts +73 -0
- package/src/cli/contract4-yaml.ts +223 -0
- package/src/cli/contract4.ts +342 -0
- package/src/cli/endpoint-url.ts +1 -1
- package/src/cli/identity.ts +44 -26
- package/src/cli/lanes.ts +25 -1
- package/src/cli/main.ts +94 -14
- package/src/cli/migrate-move.ts +166 -0
- package/src/cli/migrate-plan.ts +12 -6
- package/src/cli/output.ts +34 -1
- package/src/cli/publish.ts +6 -7
- package/src/cli/runtime/open.ts +64 -99
- package/src/cli/runtime/registry.ts +6 -7
- package/src/cli/runtime/select.ts +2 -11
- package/src/cli/runtime/stores.ts +58 -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 +40 -20
- package/src/cli/workspace-migrate.ts +152 -22
- package/src/connectivity/manifest/provider.ts +34 -13
- package/src/connectivity/manifest/requirements.ts +1 -1
- package/src/connectivity/transports/imap/parser.ts +70 -9
- package/src/deployments/adapters/filesystem.ts +18 -3
- package/src/deployments/bind.ts +1 -1
- package/src/deployments/deploy.ts +38 -29
- package/src/deployments/gcp/bucket.ts +58 -11
- package/src/deployments/gcp/provision.ts +7 -7
- package/src/deployments/knowledge.ts +9 -4
- 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 +34 -13
- package/src/deployments/upload.ts +60 -27
- 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 +195 -0
- package/src/profile/deployments.ts +86 -8
- package/src/profile/index.ts +35 -6
- package/src/profile/knowledge.ts +18 -5
- package/src/profile/layout.ts +163 -90
- package/src/profile/load.ts +133 -64
- 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 +181 -21
- package/src/profile/targets.ts +21 -9
- package/src/profile/testing.ts +104 -2
- package/src/profile/workspace.ts +124 -33
- package/src/providers/assets/provider.ts +6 -6
- package/src/providers/custom/index.ts +1 -1
- package/src/providers/custom/load.ts +2 -3
- package/src/providers/entities/provider.ts +6 -6
- package/src/providers/entities/writes.ts +1 -1
- package/src/providers/identity/provider.ts +2 -2
- package/src/providers/memory/provider.ts +26 -8
- package/src/providers/setup/plan.ts +1 -1
- package/src/providers/setup/provider.ts +3 -3
- package/src/providers/skills/provider.ts +2 -2
- package/src/providers/slack/index.ts +2 -2
- package/src/providers/tasks/provider.ts +6 -6
- package/src/providers/vault/provider.ts +1 -1
- package/src/registry/policy-bridge.ts +33 -11
- package/src/registry/reconcile.ts +4 -4
- package/src/registry/registry.ts +1 -1
- package/src/server/authorization.ts +94 -0
- package/src/server/edge.ts +14 -1
- package/src/server/endpoint.ts +89 -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 +22 -22
- package/src/server/mcp/prompts.ts +7 -3
- package/src/server/mcp/resources.ts +16 -8
- package/src/server/mcp/routing.ts +3 -3
- package/src/server/mcp/tools.ts +25 -6
- package/src/server/mcp/visibility.ts +74 -7
- 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
- package/src/stores/blobs/conformance.ts +19 -0
- package/src/stores/state/index.ts +76 -10
- package/src/stores/state/testing.ts +5 -1
|
@@ -1,8 +1,22 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
layout,
|
|
3
|
+
PROFILE_FILE,
|
|
4
|
+
profilePath,
|
|
5
|
+
vaultRef,
|
|
6
|
+
workspacePath,
|
|
7
|
+
type Config,
|
|
8
|
+
type TargetConfig,
|
|
9
|
+
} from '#profile';
|
|
2
10
|
import type { SecretStore } from '#secrets';
|
|
3
11
|
import type { BlobStore } from '#stores/blobs';
|
|
4
12
|
import { credentialRefFor, ownClientRefsFor, type ProviderRegistry } from '#registry';
|
|
5
13
|
import { print, style, warn } from '../../output.ts';
|
|
14
|
+
import {
|
|
15
|
+
migratesAcross,
|
|
16
|
+
refuseSealedVault,
|
|
17
|
+
resolveCollisions,
|
|
18
|
+
type Disposition,
|
|
19
|
+
} from './disposition.ts';
|
|
6
20
|
|
|
7
21
|
/**
|
|
8
22
|
* What a profile's removal is allowed to delete, worked out before any of it
|
|
@@ -32,37 +46,69 @@ import { print, style, warn } from '../../output.ts';
|
|
|
32
46
|
*/
|
|
33
47
|
export function declaredRefs(
|
|
34
48
|
config: Config,
|
|
35
|
-
registry: ProviderRegistry,
|
|
36
49
|
declared: TargetConfig,
|
|
50
|
+
/**
|
|
51
|
+
* What the profiles that are staying declare.
|
|
52
|
+
*
|
|
53
|
+
* Nothing in here is deleted, however plainly the profile being removed also
|
|
54
|
+
* declares it. The credential store is one file per *workspace* since
|
|
55
|
+
* contract 3, and every profile takes the template default
|
|
56
|
+
* `token_ref: profile/token` — so removing one profile deleted the endpoint
|
|
57
|
+
* token the others are served by, and the deployed revision then refused every
|
|
58
|
+
* request with "No profile token in this target's credential store". The vault
|
|
59
|
+
* ref is read off the target and is identical for every profile there, which
|
|
60
|
+
* made the sibling's sealed items unrecoverable in the same command.
|
|
61
|
+
*/
|
|
62
|
+
survivors: readonly Config[] = [],
|
|
37
63
|
): string[] {
|
|
38
64
|
const refs = new Set<string>([config.auth.token_ref]);
|
|
39
65
|
|
|
40
66
|
// Read off the *target*, not the profile. `vaultTargetSchema` sits inside
|
|
41
|
-
// `targetSchema`, so two targets may seal the same
|
|
42
|
-
//
|
|
43
|
-
//
|
|
67
|
+
// `targetSchema`, so two targets may seal the same items in different places;
|
|
68
|
+
// taking it from the profile would attach one target's vault to another
|
|
69
|
+
// target's removal.
|
|
70
|
+
//
|
|
71
|
+
// **Through `vaultRef`, because the name carries the connection.** This said
|
|
72
|
+
// `vault/document`, the contract-2 constant, while `openVault` seals under
|
|
73
|
+
// `vault/<connection>` (ADR-059) — so removing a profile queued a ref nothing
|
|
74
|
+
// had ever written and left the real document behind. Under-deletion rather
|
|
75
|
+
// than over, since the survivor set was wrong the same way and they cancelled,
|
|
76
|
+
// but what stayed behind is sealed credential material belonging to a profile
|
|
77
|
+
// the operator asked to be gone. Per connection also makes the survivor check
|
|
78
|
+
// mean something: two profiles granting different vaults no longer look like
|
|
79
|
+
// one document to it.
|
|
44
80
|
if (declared.vault?.adapter === 'secret') {
|
|
45
|
-
refs.add(declared
|
|
81
|
+
refs.add(vaultRef(declared, config));
|
|
46
82
|
}
|
|
47
83
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (manifest) {
|
|
61
|
-
for (const own of ownClientRefsFor(manifest, config.oauth_apps)) refs.add(own);
|
|
62
|
-
}
|
|
84
|
+
// **No connection credentials.** They belong to the workspace now (ADR-057),
|
|
85
|
+
// and every one of them may be granted by a profile that is staying. Removing
|
|
86
|
+
// a profile therefore removes no account and no credential — `lanes link
|
|
87
|
+
// disconnect` is the command that does that, and it is the one that knows how
|
|
88
|
+
// to check whether anybody else still needs the credential first.
|
|
89
|
+
//
|
|
90
|
+
// This is the sharpest edge in the whole decoupling, so `renderPlan` says it
|
|
91
|
+
// out loud rather than leaving an operator to infer it from a short list:
|
|
92
|
+
// "remove the work profile" used to mean "revoke what work could reach", and
|
|
93
|
+
// it does not any more.
|
|
94
|
+
if (config.auth.authorization?.mode === 'oidc') {
|
|
95
|
+
refs.add(config.auth.authorization.client_id_ref);
|
|
63
96
|
}
|
|
64
97
|
|
|
65
|
-
|
|
98
|
+
// Shared with a profile that is staying, so not ours to delete. Computed the
|
|
99
|
+
// same way for the survivors as for this one, because "what does a profile
|
|
100
|
+
// declare" has to have one answer.
|
|
101
|
+
const kept = new Set(
|
|
102
|
+
survivors.flatMap((other) => [
|
|
103
|
+
other.auth.token_ref,
|
|
104
|
+
...(declared.vault?.adapter === 'secret' ? [vaultRef(declared, other)] : []),
|
|
105
|
+
...(other.auth.authorization?.mode === 'oidc'
|
|
106
|
+
? [other.auth.authorization.client_id_ref]
|
|
107
|
+
: []),
|
|
108
|
+
]),
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
return [...refs].filter((ref) => !kept.has(ref));
|
|
66
112
|
}
|
|
67
113
|
|
|
68
114
|
export interface RemovalItem {
|
|
@@ -71,8 +117,21 @@ export interface RemovalItem {
|
|
|
71
117
|
readonly kind: 'secret' | 'blob' | 'file' | 'config' | 'workspace-key';
|
|
72
118
|
readonly id: string;
|
|
73
119
|
readonly note?: string;
|
|
120
|
+
/**
|
|
121
|
+
* The area `id` is a key within, for a blob.
|
|
122
|
+
*
|
|
123
|
+
* Carried rather than derived: the two ends of a migration are two areas and
|
|
124
|
+
* the executor opens both. Derived once, the plan prefixed the profile
|
|
125
|
+
* directory onto `id` while the executor opened its default area — the copy
|
|
126
|
+
* found nothing at the doubled path, wrote nothing, and the directory removal
|
|
127
|
+
* took the bytes. A `--migrate-to` reported success and lost the data.
|
|
128
|
+
*/
|
|
129
|
+
readonly area?: string;
|
|
130
|
+
/** Where this object goes instead of being deleted, as `[area, key]`. */
|
|
131
|
+
readonly movedTo?: readonly [string, string];
|
|
74
132
|
}
|
|
75
133
|
|
|
134
|
+
|
|
76
135
|
export interface RemovalPlan {
|
|
77
136
|
readonly profile: string;
|
|
78
137
|
readonly items: readonly RemovalItem[];
|
|
@@ -96,10 +155,21 @@ export interface PlanOptions {
|
|
|
96
155
|
readonly openSecrets: (target: string) => Promise<SecretStore>;
|
|
97
156
|
readonly openBlobs: (target: string, area?: string) => Promise<BlobStore>;
|
|
98
157
|
readonly readDefaultProfile?: (() => Promise<string | undefined>) | undefined;
|
|
158
|
+
/** What becomes of this profile's own bytes. */
|
|
159
|
+
readonly disposition: Disposition;
|
|
160
|
+
/**
|
|
161
|
+
* The profiles that are staying.
|
|
162
|
+
*
|
|
163
|
+
* So a credential both this one and a survivor declares is left alone — see
|
|
164
|
+
* `declaredRefs`. Defaulted to empty, which is the single-profile workspace
|
|
165
|
+
* and the shape every existing caller had.
|
|
166
|
+
*/
|
|
167
|
+
readonly survivors?: readonly Config[] | undefined;
|
|
99
168
|
}
|
|
100
169
|
|
|
101
170
|
const reason = (cause: unknown): string => (cause instanceof Error ? cause.message : String(cause));
|
|
102
171
|
|
|
172
|
+
|
|
103
173
|
/**
|
|
104
174
|
* Everything removing this profile would delete, before any of it is deleted.
|
|
105
175
|
*
|
|
@@ -116,6 +186,9 @@ export async function removalPlan(
|
|
|
116
186
|
options: PlanOptions,
|
|
117
187
|
): Promise<RemovalPlan> {
|
|
118
188
|
const items: RemovalItem[] = [];
|
|
189
|
+
const migrateInto =
|
|
190
|
+
options.disposition.kind === 'migrate' ? options.disposition.into : undefined;
|
|
191
|
+
const sealed: string[] = [];
|
|
119
192
|
const untouched: { target: string; refs: string[] }[] = [];
|
|
120
193
|
const warnings: string[] = [];
|
|
121
194
|
|
|
@@ -144,15 +217,16 @@ export async function removalPlan(
|
|
|
144
217
|
);
|
|
145
218
|
}
|
|
146
219
|
|
|
147
|
-
// Secrets
|
|
148
|
-
// `layout.credentials(p)`
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
220
|
+
// Secrets, and there is nothing left to order them against. This used to
|
|
221
|
+
// run before a blob sweep because `layout.credentials(p)` was
|
|
222
|
+
// `data/<p>/credentials.enc`, *inside* the blob root `data/<p>`, so
|
|
223
|
+
// blobs-first deleted the store the secret deletions read through. Both the
|
|
224
|
+
// sweep and the per-profile root are gone (ADR-057, ADR-059) — see the note
|
|
225
|
+
// below — and the credential store is the workspace's now.
|
|
152
226
|
try {
|
|
153
227
|
const secrets = await options.openSecrets(name);
|
|
154
228
|
const present = await secrets.list();
|
|
155
|
-
const mine = new Set(declaredRefs(config,
|
|
229
|
+
const mine = new Set(declaredRefs(config, declared, options.survivors ?? []));
|
|
156
230
|
|
|
157
231
|
for (const ref of present) if (mine.has(ref)) items.push({ target: name, kind: 'secret', id: ref });
|
|
158
232
|
|
|
@@ -164,53 +238,57 @@ export async function removalPlan(
|
|
|
164
238
|
);
|
|
165
239
|
}
|
|
166
240
|
|
|
241
|
+
// **The sweep is back, bounded by the profile's own directory.** It went
|
|
242
|
+
// away under ADR-059, when a profile owned no bytes and the blob root was
|
|
243
|
+
// the whole workspace — listing it then queued every byte in the workspace
|
|
244
|
+
// for deletion because one profile was going. ADR-066 gives the directory
|
|
245
|
+
// back, so it means what it used to: what this profile owns, and nothing an
|
|
246
|
+
// account owns. `lanes link disconnect` is still the command for an
|
|
247
|
+
// account, and nothing here touches one.
|
|
167
248
|
try {
|
|
168
|
-
const blobs = await options.openBlobs(name);
|
|
169
|
-
for (const blob of await blobs.list())
|
|
249
|
+
const blobs = await options.openBlobs(name, layout.profileDir(profile));
|
|
250
|
+
for (const blob of await blobs.list()) {
|
|
251
|
+
// **Not the declaration.** It is config rather than data, deleted below
|
|
252
|
+
// as its own item after everything it is the record of. Swept here it
|
|
253
|
+
// would be counted twice, and on a `--migrate-to` copied into the
|
|
254
|
+
// destination as a second `profile.yaml` — one profile's grants and
|
|
255
|
+
// members landing inside another's directory.
|
|
256
|
+
if (blob.key === PROFILE_FILE) continue;
|
|
257
|
+
|
|
258
|
+
const migratable = migrateInto !== undefined && migratesAcross(blob.key);
|
|
259
|
+
|
|
260
|
+
items.push({
|
|
261
|
+
target: name,
|
|
262
|
+
kind: 'blob',
|
|
263
|
+
id: blob.key,
|
|
264
|
+
area: layout.profileDir(profile),
|
|
265
|
+
...(migratable
|
|
266
|
+
? { movedTo: [layout.profileDir(migrateInto), blob.key] as const }
|
|
267
|
+
: {}),
|
|
268
|
+
...(migrateInto !== undefined && !migratable
|
|
269
|
+
? { note: 'not migrated — deleted with the profile' }
|
|
270
|
+
: {}),
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
if (migrateInto !== undefined && blob.key.startsWith('vault.d/')) sealed.push(blob.key);
|
|
274
|
+
}
|
|
170
275
|
} catch (cause) {
|
|
171
276
|
warnings.push(
|
|
172
277
|
`Target "${name}": its storage could not be opened (${reason(cause)}), so nothing in it will be removed.`,
|
|
173
278
|
);
|
|
174
279
|
}
|
|
175
280
|
|
|
176
|
-
// The
|
|
177
|
-
//
|
|
178
|
-
// directory
|
|
179
|
-
//
|
|
180
|
-
// behind is silently reused by a later `profile add` of the same name.
|
|
281
|
+
// The store's root is the profile's own directory, and an adapter must never
|
|
282
|
+
// delete the root it was configured with — so emptying it leaves the
|
|
283
|
+
// directory, and one left behind is silently reused by a later `profile add`
|
|
284
|
+
// of the same name.
|
|
181
285
|
if (declared.storage.adapter === 'filesystem') {
|
|
182
|
-
const blobRoot = declared.storage.path ?? layout.blobs(profile);
|
|
183
286
|
items.push({
|
|
184
287
|
target: name,
|
|
185
288
|
kind: 'file',
|
|
186
|
-
id:
|
|
289
|
+
id: layout.profileDir(profile),
|
|
187
290
|
note: 'the profile directory, once emptied',
|
|
188
291
|
});
|
|
189
|
-
|
|
190
|
-
// Skills and manifests sit at fixed areas under `data/<profile>/`, the
|
|
191
|
-
// same way state and the log do — an explicit area, so a profile that
|
|
192
|
-
// declares its own `storage.path` moves its provider blobs and leaves
|
|
193
|
-
// these where `layout` says they are. The sweep above walks the blob root
|
|
194
|
-
// and would then walk straight past them, so they are named. Ordinary
|
|
195
|
-
// case: both are already inside `blobRoot` and the sweep has them, which
|
|
196
|
-
// is why this only fires when the two have been pointed apart.
|
|
197
|
-
//
|
|
198
|
-
// Resolved before comparing, because they are not compared as paths
|
|
199
|
-
// otherwise: `newProfileTemplate` writes `./data/<profile>` and
|
|
200
|
-
// `layout.blobs` returns `data/<profile>`, which is one directory spelled
|
|
201
|
-
// two ways — the same leading `./` that `profile/layout.ts` has a
|
|
202
|
-
// paragraph about. Comparing the strings names every default profile's
|
|
203
|
-
// skills twice in the preview an operator confirms.
|
|
204
|
-
if (workspacePath(root, blobRoot) !== workspacePath(root, layout.blobs(profile))) {
|
|
205
|
-
for (const area of [layout.skills(profile), layout.providers(profile)]) {
|
|
206
|
-
items.push({
|
|
207
|
-
target: name,
|
|
208
|
-
kind: 'file',
|
|
209
|
-
id: area,
|
|
210
|
-
note: 'outside the declared storage path, so not swept with it',
|
|
211
|
-
});
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
292
|
}
|
|
215
293
|
|
|
216
294
|
// A deployed revision reads its config from the bucket rather than the
|
|
@@ -220,7 +298,7 @@ export async function removalPlan(
|
|
|
220
298
|
items.push({
|
|
221
299
|
target: name,
|
|
222
300
|
kind: 'config',
|
|
223
|
-
id:
|
|
301
|
+
id: layout.profileConfig(profile),
|
|
224
302
|
note: 'the copy a deployed revision reads',
|
|
225
303
|
});
|
|
226
304
|
}
|
|
@@ -250,6 +328,16 @@ export async function removalPlan(
|
|
|
250
328
|
});
|
|
251
329
|
}
|
|
252
330
|
|
|
331
|
+
if (sealed.length > 0) refuseSealedVault(profile, migrateInto!);
|
|
332
|
+
|
|
333
|
+
if (migrateInto !== undefined) {
|
|
334
|
+
warnings.push(
|
|
335
|
+
...(await resolveCollisions(items, migrateInto, layout.profileDir(migrateInto), (area) =>
|
|
336
|
+
options.openBlobs(options.target, area),
|
|
337
|
+
)),
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
253
341
|
// Last. It is the only record of where everything else lives, so a failure
|
|
254
342
|
// before this point leaves data a later run can still find.
|
|
255
343
|
items.push({ target: null, kind: 'config', id: profilePath(root, profile) });
|
|
@@ -287,7 +375,9 @@ export function renderPlan(plan: RemovalPlan): void {
|
|
|
287
375
|
print(` ${style.bold(target ?? 'workspace')}`);
|
|
288
376
|
for (const item of items) {
|
|
289
377
|
const note = item.note ? style.dim(` — ${item.note}`) : '';
|
|
290
|
-
|
|
378
|
+
const shown = item.area === undefined ? item.id : `${item.area}/${item.id}`;
|
|
379
|
+
const into = item.movedTo ? style.dim(` → ${item.movedTo[0]}/${item.movedTo[1]}`) : '';
|
|
380
|
+
print(` ${KIND_LABEL[item.kind].padEnd(10)} ${shown}${into}${note}`);
|
|
291
381
|
}
|
|
292
382
|
print();
|
|
293
383
|
}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
import { parseDocument } from 'yaml';
|
|
14
14
|
import { rm } from 'node:fs/promises';
|
|
15
15
|
import { terminalPrompter, type Prompter } from '../../prompt.ts';
|
|
16
|
+
import { recordConfigChange } from '../../audit-change.ts';
|
|
16
17
|
import { announceProfile, emit, fail, ok, print, style } from '../../output.ts';
|
|
17
18
|
import {
|
|
18
19
|
buildRegistryWithWorkspace,
|
|
@@ -21,7 +22,9 @@ import {
|
|
|
21
22
|
resolveProfileOnly,
|
|
22
23
|
type GlobalFlags,
|
|
23
24
|
} from '../../runtime.ts';
|
|
25
|
+
import { loadWorkspaceProfiles } from '#profile';
|
|
24
26
|
import { removalPlan, renderPlan, type RemovalItem, type RemovalPlan } from './removal.ts';
|
|
27
|
+
import { settleDisposition, type Disposition } from './disposition.ts';
|
|
25
28
|
|
|
26
29
|
/**
|
|
27
30
|
* Performing a removal, and being honest about the parts that did not happen.
|
|
@@ -60,9 +63,21 @@ export interface RunDeps {
|
|
|
60
63
|
|
|
61
64
|
const reason = (cause: unknown): string => (cause instanceof Error ? cause.message : String(cause));
|
|
62
65
|
|
|
63
|
-
/**
|
|
66
|
+
/**
|
|
67
|
+
* The items that only make sense once everything else is actually gone.
|
|
68
|
+
*
|
|
69
|
+
* **`file` is in here, and that is the whole of its reason.** It is
|
|
70
|
+
* `rm -rf profiles/<profile>`, and it carries a target rather than `null`, so
|
|
71
|
+
* the original `target === null` test let it run after a failed object — taking
|
|
72
|
+
* the bytes a `--migrate-to` had not managed to copy, and `profile.yaml`, which
|
|
73
|
+
* the sweep deliberately leaves for last. `renderOutcome` then printed "the
|
|
74
|
+
* profile's config was kept, so nothing is stranded" about a directory that no
|
|
75
|
+
* longer existed. Same shape as the defect this file records having shipped
|
|
76
|
+
* once already.
|
|
77
|
+
*/
|
|
64
78
|
const isRecordOfWhereThingsAre = (item: RemovalItem): boolean =>
|
|
65
|
-
item.
|
|
79
|
+
item.kind === 'file' ||
|
|
80
|
+
(item.target === null && (item.kind === 'config' || item.kind === 'workspace-key'));
|
|
66
81
|
|
|
67
82
|
export async function executeRemoval(
|
|
68
83
|
plan: RemovalPlan,
|
|
@@ -105,9 +120,30 @@ export async function executeRemoval(
|
|
|
105
120
|
await (await secretStore(item.target!)).delete(item.id);
|
|
106
121
|
break;
|
|
107
122
|
|
|
108
|
-
case 'blob':
|
|
109
|
-
|
|
123
|
+
case 'blob': {
|
|
124
|
+
const store = await blobStore(item.target!, item.area);
|
|
125
|
+
|
|
126
|
+
// Read across *before* deleting, and verify it landed — the same rule
|
|
127
|
+
// the contract migrations follow, for the same reason: a copy that
|
|
128
|
+
// half happened and a source that is already gone is the one state
|
|
129
|
+
// with nothing to retry from. A collision was resolved while this was
|
|
130
|
+
// still a plan, so the destination is free.
|
|
131
|
+
if (item.movedTo !== undefined) {
|
|
132
|
+
const [area, key] = item.movedTo;
|
|
133
|
+
const bytes = await store.get(item.id);
|
|
134
|
+
|
|
135
|
+
if (bytes !== null) {
|
|
136
|
+
const into = await blobStore(item.target!, area);
|
|
137
|
+
await into.put(key, bytes);
|
|
138
|
+
if ((await into.get(key)) === null) {
|
|
139
|
+
throw new Error(`${area}/${key} did not read back after being written`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
await store.delete(item.id);
|
|
110
145
|
break;
|
|
146
|
+
}
|
|
111
147
|
|
|
112
148
|
case 'config':
|
|
113
149
|
if (item.target === null) await deps.removeConfig(item.id);
|
|
@@ -239,10 +275,15 @@ export interface RemoveFlags extends GlobalFlags {
|
|
|
239
275
|
readonly dryRun?: boolean | undefined;
|
|
240
276
|
readonly yes?: boolean | undefined;
|
|
241
277
|
readonly json?: boolean | undefined;
|
|
278
|
+
/** Delete this profile's memory, tasks, assets, entities, vault and skills. */
|
|
279
|
+
readonly deleteData?: boolean | undefined;
|
|
280
|
+
/** Move them into this profile instead. */
|
|
281
|
+
readonly migrateTo?: string | undefined;
|
|
242
282
|
/** Injected by a caller that has already asked — the console, and tests. */
|
|
243
283
|
readonly prompter?: Prompter | undefined;
|
|
244
284
|
}
|
|
245
285
|
|
|
286
|
+
|
|
246
287
|
/**
|
|
247
288
|
* `lanes link profile remove <name>` — the profile, and everything it owns.
|
|
248
289
|
*
|
|
@@ -255,16 +296,43 @@ export async function removeProfile(name: string, flags: RemoveFlags): Promise<v
|
|
|
255
296
|
announceProfile(selection);
|
|
256
297
|
|
|
257
298
|
const root = selection.workspaceRoot;
|
|
258
|
-
const registry = await buildRegistryWithWorkspace(root
|
|
299
|
+
const registry = await buildRegistryWithWorkspace(root);
|
|
259
300
|
const files = workspaceFiles(root);
|
|
260
301
|
const { declared } = await openTarget(root, target);
|
|
261
302
|
|
|
303
|
+
const prompter = flags.prompter ?? terminalPrompter;
|
|
304
|
+
const someoneToAsk = flags.prompter ? prompter.interactive : process.stdin.isTTY;
|
|
305
|
+
const disposition = await settleDisposition(name, flags, prompter, someoneToAsk);
|
|
306
|
+
|
|
307
|
+
if (disposition === null) {
|
|
308
|
+
print(style.dim(' cancelled — nothing was removed'));
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (disposition.kind === 'migrate') {
|
|
313
|
+
// Before the plan, because a plan against a profile that does not exist
|
|
314
|
+
// would name destinations nothing will ever read.
|
|
315
|
+
const staying = (await loadWorkspaceProfiles(root)).loaded.map((one) => one.profile);
|
|
316
|
+
if (disposition.into === name || !staying.includes(disposition.into)) {
|
|
317
|
+
throw new ConfigError(
|
|
318
|
+
`Cannot migrate "${name}" into "${disposition.into}".\n` +
|
|
319
|
+
` Staying: ${staying.filter((one) => one !== name).join(', ') || 'nothing'}`,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
262
324
|
const plan = await removalPlan(config, root, name, registry, {
|
|
263
325
|
target,
|
|
264
326
|
declared,
|
|
265
|
-
|
|
327
|
+
disposition,
|
|
328
|
+
openSecrets: (target) => openSecretStoreFor(root, target),
|
|
266
329
|
openBlobs: (target, area) => openBlobStoreFor(config, root, target, area),
|
|
267
330
|
readDefaultProfile: async () => (await readWorkspace(root))?.default_profile,
|
|
331
|
+
// What the workspace keeps. The credential store is one file for all of
|
|
332
|
+
// them now, so anything a survivor declares is not this removal's to delete.
|
|
333
|
+
survivors: (await loadWorkspaceProfiles(root)).loaded
|
|
334
|
+
.filter((one) => one.profile !== name)
|
|
335
|
+
.map((one) => one.config),
|
|
268
336
|
});
|
|
269
337
|
|
|
270
338
|
renderPlan(plan);
|
|
@@ -278,7 +346,7 @@ export async function removeProfile(name: string, flags: RemoveFlags): Promise<v
|
|
|
278
346
|
if (!(await confirmedByName(name, { yes: flags.yes, prompter: flags.prompter }))) return;
|
|
279
347
|
|
|
280
348
|
const outcome = await executeRemoval(plan, {
|
|
281
|
-
openSecrets: (target) => openSecretStoreFor(
|
|
349
|
+
openSecrets: (target) => openSecretStoreFor(root, target),
|
|
282
350
|
openBlobs: (target, area) => openBlobStoreFor(config, root, target, area),
|
|
283
351
|
removeConfig: async (path) => await files.delete(relativeToRoot(root, path)),
|
|
284
352
|
removeDirectory: async (path) => await rm(workspacePath(root, path), { recursive: true, force: true }),
|
|
@@ -286,6 +354,14 @@ export async function removeProfile(name: string, flags: RemoveFlags): Promise<v
|
|
|
286
354
|
retry: retryCommand,
|
|
287
355
|
});
|
|
288
356
|
|
|
357
|
+
// Before the render, and against the config loaded above — the profile's file
|
|
358
|
+
// is gone by now, so a helper that re-read it would find nothing.
|
|
359
|
+
await recordConfigChange(config, root, target, {
|
|
360
|
+
capability: 'config.profile.remove',
|
|
361
|
+
scope: name,
|
|
362
|
+
arguments: { connections: config.grants.map((grant) => grant.connection) },
|
|
363
|
+
});
|
|
364
|
+
|
|
289
365
|
return emit(flags.json, outcome, () => renderOutcome(outcome));
|
|
290
366
|
}
|
|
291
367
|
|
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import { newConnectionsTemplate, newProfileTemplate, newWorkspaceTemplate } from '../config-templates.ts';
|
|
1
2
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
3
|
import { existsSync } from 'node:fs';
|
|
3
4
|
import { join } from 'node:path';
|
|
4
5
|
import {
|
|
6
|
+
CONNECTIONS_FILE,
|
|
5
7
|
ConfigError,
|
|
6
8
|
WORKSPACE_FILE,
|
|
9
|
+
LEGACY_WORKSPACE_FILE,
|
|
10
|
+
legacyProfileConfig,
|
|
7
11
|
listProfiles,
|
|
8
12
|
profilePath,
|
|
9
13
|
readWorkspace,
|
|
@@ -12,9 +16,13 @@ import {
|
|
|
12
16
|
writeWorkspaceFile,
|
|
13
17
|
resolveTargetWorkspace,
|
|
14
18
|
resolveWorkspaceRoot,
|
|
19
|
+
layout,
|
|
15
20
|
} from '#profile';
|
|
16
|
-
|
|
21
|
+
|
|
22
|
+
import { recordConfigChange } from '../audit-change.ts';
|
|
23
|
+
import { resolveProfile } from '../runtime.ts';
|
|
17
24
|
import { emit, ok, print, style, table } from '../output.ts';
|
|
25
|
+
import { readSession } from '#auth/lanes/session.ts';
|
|
18
26
|
|
|
19
27
|
/**
|
|
20
28
|
* Profile management.
|
|
@@ -58,7 +66,7 @@ export interface ProfileListing {
|
|
|
58
66
|
*
|
|
59
67
|
* It now decides *where the file goes* rather than what is written in it: a
|
|
60
68
|
* profile lives in one target's workspace and declares nothing about it
|
|
61
|
-
* (ADR-052), so `--
|
|
69
|
+
* (ADR-052), so `--workspace cloud` writes into the bucket and the endpoint there
|
|
62
70
|
* serves it on its next reconcile.
|
|
63
71
|
*/
|
|
64
72
|
export async function createProfile(
|
|
@@ -69,19 +77,51 @@ export async function createProfile(
|
|
|
69
77
|
const target = options.targets[0]!;
|
|
70
78
|
|
|
71
79
|
// The workspace file before the target is resolved, not after. `profile add
|
|
72
|
-
// <name> --
|
|
80
|
+
// <name> --workspace local` on an empty directory is how a workspace comes into
|
|
73
81
|
// existence, and the target it names is declared *by* that file — so writing
|
|
74
82
|
// it second means resolving a target nothing has declared yet.
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
83
|
+
// **Either name, and an unmigrated workspace is refused rather than
|
|
84
|
+
// shadowed.** `resolveWorkspaceRoot` accepts both markers, so this ran
|
|
85
|
+
// against a contract-3 workspace, found no `workspaces.yaml`, and wrote a
|
|
86
|
+
// fresh template beside `lanes-link.yaml` — `readWorkspace` prefers the new
|
|
87
|
+
// name, so every declared target and deployment record vanished, and
|
|
88
|
+
// `renameRegistry` returns early once the new file exists, so no migration
|
|
89
|
+
// could put them back.
|
|
90
|
+
if (!isRemoteWorkspace(local)) {
|
|
91
|
+
if (existsSync(join(local, LEGACY_WORKSPACE_FILE)) && !existsSync(join(local, WORKSPACE_FILE))) {
|
|
92
|
+
throw new ConfigError(
|
|
93
|
+
`${local} is still laid out the way contract 3 kept it, and adding a profile here would ` +
|
|
94
|
+
'write a second registry beside the one it already has.\n' +
|
|
95
|
+
' Migrate it first: lanes link doctor --fix --profile <name> --workspace <name>',
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!existsSync(join(local, WORKSPACE_FILE))) {
|
|
100
|
+
await mkdir(local, { recursive: true });
|
|
101
|
+
await writeFile(join(local, WORKSPACE_FILE), newWorkspaceTemplate(), { mode: 0o600 });
|
|
102
|
+
}
|
|
78
103
|
}
|
|
79
104
|
|
|
80
105
|
const root = await resolveTargetWorkspace(local, target);
|
|
81
106
|
const path = profilePath(root, name);
|
|
82
107
|
|
|
83
|
-
|
|
84
|
-
|
|
108
|
+
// The connections file comes into existence with the workspace, carrying the
|
|
109
|
+
// owner layer. It is written before the profile because the profile's grants
|
|
110
|
+
// name rows in it, and `assertGrantsResolve` refuses a grant with nothing
|
|
111
|
+
// behind it — so a profile written first would not load until this existed.
|
|
112
|
+
if (!(await workspaceFiles(root).has(CONNECTIONS_FILE))) {
|
|
113
|
+
await writeWorkspaceFile(workspaceFiles(root), CONNECTIONS_FILE, newConnectionsTemplate());
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Both shapes, for the same reason: `listProfiles` sees a contract-3
|
|
117
|
+
// `profiles/<name>.yaml` and this did not, so `profile add personal` wrote a
|
|
118
|
+
// fresh template alongside the operator's own — one name, two files, and the
|
|
119
|
+
// empty one opened. `doctor --fix` then planned a move onto an occupied
|
|
120
|
+
// destination and threw on every rerun.
|
|
121
|
+
for (const key of [layout.profileConfig(name), legacyProfileConfig(name)]) {
|
|
122
|
+
if (await workspaceFiles(root).has(key)) {
|
|
123
|
+
throw new Error(`Profile "${name}" already exists at ${root}/${key}`);
|
|
124
|
+
}
|
|
85
125
|
}
|
|
86
126
|
|
|
87
127
|
// Only a directory needs making. A bucket has no directories, and the write
|
|
@@ -99,10 +139,23 @@ export async function createProfile(
|
|
|
99
139
|
// sibling's, or asked. It declares no target now (ADR-052): it is written into
|
|
100
140
|
// the workspace of the target it was named with, and that workspace already
|
|
101
141
|
// says where its bytes go.
|
|
142
|
+
// The signed-in subject, so the profile reaches somebody the moment it
|
|
143
|
+
// exists. Without it every new profile shipped `members: []` while the
|
|
144
|
+
// template writes `authorization: mode: self` — an endpoint that advertises
|
|
145
|
+
// OAuth and lists nobody, where the owner signs in at lanes.sh and is told no
|
|
146
|
+
// profile lists them. A local stdio or CI caller still worked, which is why a
|
|
147
|
+
// smoke test passed: those carry `profiles: undefined` and `mayReach` admits
|
|
148
|
+
// everything.
|
|
149
|
+
//
|
|
150
|
+
// Null when nobody is signed in, which is a real state — `lanes auth login`
|
|
151
|
+
// has not been run yet — and the template then says how to fix it rather than
|
|
152
|
+
// inventing a subject.
|
|
153
|
+
const session = await readSession();
|
|
154
|
+
|
|
102
155
|
await writeWorkspaceFile(
|
|
103
156
|
workspaceFiles(root),
|
|
104
|
-
|
|
105
|
-
newProfileTemplate(name, port),
|
|
157
|
+
layout.profileConfig(name),
|
|
158
|
+
newProfileTemplate(name, port, session?.subject),
|
|
106
159
|
);
|
|
107
160
|
|
|
108
161
|
return { name, path, port, targets: options.targets, copiedFrom: {} };
|
|
@@ -142,8 +195,8 @@ export async function profileAdd(
|
|
|
142
195
|
if (options.targets.length === 0) {
|
|
143
196
|
throw new ConfigError(
|
|
144
197
|
'Say which targets this profile declares:\n' +
|
|
145
|
-
` lanes link profile add ${name} --
|
|
146
|
-
` lanes link profile add ${name} --
|
|
198
|
+
` lanes link profile add ${name} --workspace local\n` +
|
|
199
|
+
` lanes link profile add ${name} --workspace local --workspace cloud\n` +
|
|
147
200
|
'\n' +
|
|
148
201
|
' A target is where a profile runs — a credential store and a blob store.\n' +
|
|
149
202
|
' There is no default to inherit before the profile exists.',
|
|
@@ -152,11 +205,21 @@ export async function profileAdd(
|
|
|
152
205
|
|
|
153
206
|
const created = await createProfile(name, options);
|
|
154
207
|
|
|
208
|
+
// Re-read rather than assembled from `created`, because the file on disk is
|
|
209
|
+
// what a workspace with a remote credential store needs to open one.
|
|
210
|
+
const primary = created.targets[0]!;
|
|
211
|
+
const { resolution, config } = await resolveProfile({ profile: created.name, target: primary });
|
|
212
|
+
await recordConfigChange(config, resolution.workspaceRoot, primary, {
|
|
213
|
+
capability: 'config.profile.add',
|
|
214
|
+
scope: created.name,
|
|
215
|
+
arguments: { port: created.port, workspace: primary },
|
|
216
|
+
});
|
|
217
|
+
|
|
155
218
|
return emit(options.json, created, () => {
|
|
156
219
|
print(ok(`created profile ${style.bold(created.name)}`));
|
|
157
220
|
print(` config ${created.path}`);
|
|
158
221
|
print(` port ${created.port}`);
|
|
159
|
-
print(`
|
|
222
|
+
print(` workspace ${created.targets.join(', ')}`);
|
|
160
223
|
|
|
161
224
|
for (const [target, from] of Object.entries(created.copiedFrom)) {
|
|
162
225
|
print(` ${style.dim(`${target} adapters copied from profile "${from}"`)}`);
|
|
@@ -165,7 +228,7 @@ export async function profileAdd(
|
|
|
165
228
|
print();
|
|
166
229
|
print(
|
|
167
230
|
style.dim(
|
|
168
|
-
`Next: lanes link connect example --profile ${created.name} --
|
|
231
|
+
`Next: lanes link connect example --profile ${created.name} --workspace ${created.targets[0]}`,
|
|
169
232
|
),
|
|
170
233
|
);
|
|
171
234
|
});
|
|
@@ -180,7 +243,7 @@ export async function profileList(
|
|
|
180
243
|
return emit(options.json, listing, () => {
|
|
181
244
|
if (listing.profiles.length === 0) {
|
|
182
245
|
print(style.dim(`No profiles in ${listing.root}.`));
|
|
183
|
-
print(style.dim('Create one with: lanes link profile add personal --
|
|
246
|
+
print(style.dim('Create one with: lanes link profile add personal --workspace local'));
|
|
184
247
|
return;
|
|
185
248
|
}
|
|
186
249
|
|
|
@@ -210,7 +273,7 @@ export function profileDefault(name: string | undefined): never {
|
|
|
210
273
|
throw new ConfigError(
|
|
211
274
|
'lanes link profile default was removed.\n' +
|
|
212
275
|
' Nothing reads default_profile any more — pass --profile on every command:\n' +
|
|
213
|
-
` lanes link status --profile ${name ?? '<name>'} --
|
|
276
|
+
` lanes link status --profile ${name ?? '<name>'} --workspace <name>\n` +
|
|
214
277
|
' If the key is still in lanes-link.yaml it is inert, and safe to delete.',
|
|
215
278
|
);
|
|
216
279
|
}
|