@lanes-sh/link 0.2.2 → 0.3.1
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 +22 -8
- package/instructions/skills/lanes-link/SKILL.md +42 -14
- package/package.json +1 -1
- package/src/cli/argv.ts +50 -0
- package/src/cli/brand.ts +178 -0
- package/src/cli/callback-page.ts +108 -128
- package/src/cli/commands/connect/accounts.ts +5 -0
- package/src/cli/commands/connect/assertion.ts +187 -0
- package/src/cli/commands/connect/authorise.ts +61 -17
- package/src/cli/commands/connect/client.ts +37 -9
- package/src/cli/commands/connect/discover.ts +94 -0
- package/src/cli/commands/connect/family.ts +72 -0
- package/src/cli/commands/connect/index.ts +113 -115
- package/src/cli/commands/connect/method.ts +237 -0
- package/src/cli/commands/connect/outcome.ts +42 -1
- package/src/cli/commands/connect/pasted-token.ts +66 -0
- package/src/cli/commands/connect/requirements.ts +60 -8
- package/src/cli/commands/connect/setup.ts +16 -5
- package/src/cli/commands/connect/target-note.ts +34 -0
- package/src/cli/commands/identity.ts +258 -0
- package/src/cli/commands/knowledge/index.ts +390 -0
- package/src/cli/commands/knowledge/migrate.ts +180 -0
- package/src/cli/commands/knowledge/setup.ts +144 -0
- package/src/cli/commands/knowledge.ts +10 -0
- package/src/cli/commands/mcp/harnesses.ts +16 -2
- package/src/cli/commands/mcp/register.ts +9 -1
- package/src/cli/commands/mcp/stdio.ts +21 -0
- package/src/cli/commands/operate/dashboard.ts +107 -0
- package/src/cli/commands/operate/findings.ts +151 -0
- package/src/cli/commands/operate/inspect.ts +63 -164
- package/src/cli/commands/operate/outputs.ts +38 -11
- package/src/cli/commands/operate/policy.ts +7 -7
- package/src/cli/commands/operate/serve.ts +3 -0
- package/src/cli/commands/operate/status.ts +108 -1
- package/src/cli/commands/operate/token.ts +1 -1
- package/src/cli/commands/operate.ts +2 -0
- package/src/cli/commands/profile/declare.ts +154 -0
- package/src/cli/commands/profile/removal.ts +17 -0
- package/src/cli/commands/profile/remove.ts +5 -5
- package/src/cli/commands/profile.ts +83 -35
- package/src/cli/commands/secrets.ts +6 -6
- package/src/cli/commands/setup.ts +22 -6
- package/src/cli/commands/sync.ts +262 -0
- package/src/cli/commands/target.ts +65 -83
- package/src/cli/config-edit.ts +53 -144
- package/src/cli/config-repair.ts +186 -0
- package/src/cli/dashboard-page.ts +284 -0
- package/src/cli/dashboard-shell.ts +125 -0
- package/src/cli/dispatch-owner.ts +93 -0
- package/src/cli/identity.ts +12 -1
- package/src/cli/main.ts +90 -61
- package/src/cli/nearest.ts +45 -0
- package/src/cli/oauth-callback.ts +187 -0
- package/src/cli/oauth-exchange.ts +57 -15
- package/src/cli/oauth.ts +67 -177
- package/src/cli/output.ts +21 -5
- package/src/cli/provider-marks.ts +45 -0
- package/src/cli/runtime/open.ts +74 -51
- package/src/cli/runtime/registry.ts +60 -2
- package/src/cli/runtime/select.ts +26 -13
- package/src/cli/runtime/vault.ts +61 -0
- package/src/cli/runtime.ts +2 -1
- package/src/cli/selection.ts +365 -0
- package/src/cli/usage.ts +42 -11
- package/src/connectivity/auth/README.md +7 -1
- package/src/connectivity/auth/basic/index.ts +1 -1
- package/src/connectivity/auth/index.ts +14 -0
- package/src/connectivity/auth/oauth-authcode/broker.ts +26 -0
- package/src/connectivity/auth/oauth-authcode/index.ts +16 -2
- package/src/connectivity/auth/oauth-authcode/provider.ts +1 -1
- package/src/connectivity/auth/oauth-authcode/refresh.ts +3 -3
- package/src/connectivity/auth/oauth-jwt/README.md +33 -0
- package/src/connectivity/auth/oauth-jwt/index.ts +237 -0
- package/src/connectivity/auth/oauth-jwt/key.ts +148 -0
- package/src/connectivity/auth/resolve.ts +1 -1
- package/src/connectivity/auth/token.ts +11 -0
- package/src/connectivity/index.ts +2 -0
- package/src/connectivity/manifest/auth.ts +99 -2
- package/src/connectivity/manifest/identity.ts +12 -0
- package/src/connectivity/manifest/index.ts +3 -1
- package/src/connectivity/manifest/provider.ts +37 -8
- package/src/connectivity/manifest/requirements.ts +109 -6
- package/src/deployments/adapters/filesystem.ts +10 -1
- package/src/deployments/adapters/github-api.ts +106 -0
- package/src/deployments/adapters/github-commit.ts +103 -0
- package/src/deployments/adapters/github-repo.ts +356 -0
- package/src/deployments/adapters/github-testing.ts +258 -0
- package/src/deployments/adapters/github.ts +125 -0
- package/src/deployments/deploy.ts +94 -114
- package/src/deployments/discover.ts +103 -0
- package/src/deployments/driver.ts +8 -1
- package/src/deployments/gcp/driver.ts +3 -1
- package/src/deployments/knowledge.ts +119 -0
- package/src/deployments/prepare.ts +12 -6
- package/src/deployments/report.ts +117 -0
- package/src/deployments/servable.ts +82 -0
- package/src/deployments/serving.ts +165 -0
- package/src/deployments/sync-apply.ts +276 -0
- package/src/deployments/sync.ts +136 -0
- package/src/deployments/target.ts +3 -2
- package/src/deployments/upload.ts +19 -12
- package/src/dispatch/dispatch.ts +1 -1
- package/src/profile/deployments.ts +80 -0
- package/src/profile/identity.ts +60 -0
- package/src/profile/index.ts +23 -5
- package/src/profile/knowledge.ts +124 -0
- package/src/profile/load.ts +17 -5
- package/src/profile/primitives.ts +24 -1
- package/src/profile/schema.ts +81 -3
- package/src/profile/targets.ts +122 -109
- package/src/profile/workspace.ts +139 -79
- package/src/providers/google/calendar/index.ts +2 -0
- package/src/providers/google/contacts/index.ts +2 -0
- package/src/providers/google/docs/index.ts +2 -0
- package/src/providers/google/drive/index.ts +2 -0
- package/src/providers/google/gmail/index.ts +2 -0
- package/src/providers/google/gmail-imap/index.ts +125 -0
- package/src/providers/google/index.ts +2 -1
- package/src/providers/google/shared/oauth.ts +18 -6
- package/src/providers/google/shared/service-account.ts +110 -0
- package/src/providers/google/shared/setup.ts +21 -3
- package/src/providers/google/sheets/index.ts +2 -0
- package/src/providers/google/tasks/index.ts +2 -0
- package/src/providers/identity/provider.ts +166 -0
- package/src/providers/index.ts +3 -0
- package/src/providers/owner.ts +10 -2
- package/src/providers/scopes.ts +2 -0
- package/src/providers/setup/plan.ts +31 -9
- package/src/providers/setup/provider.ts +23 -0
- package/src/providers/slack/index.ts +81 -33
- package/src/providers/slack/oauth.ts +103 -0
- package/src/providers/slack/scopes.ts +37 -0
- package/src/server/container.ts +18 -1
- package/src/server/cors.ts +252 -0
- package/src/server/dashboard.ts +208 -0
- package/src/server/endpoint.ts +45 -1
- package/src/server/generations.ts +11 -2
- package/src/server/harness.ts +7 -0
- package/src/server/index.ts +45 -6
- package/src/server/mcp/index.ts +1 -0
- package/src/server/mcp/instructions.ts +28 -1
- package/src/server/mcp/visibility.ts +33 -0
- package/src/stores/blobs/route.ts +123 -0
|
@@ -6,11 +6,13 @@ import { loadProfileProviders } from '#providers/custom/index.ts';
|
|
|
6
6
|
import { loadProfileSkills, type LoadedSkill } from '#providers/skills/store.ts';
|
|
7
7
|
import { exampleProvider } from '#providers/example/provider.ts';
|
|
8
8
|
import {
|
|
9
|
+
createIdentityProvider,
|
|
9
10
|
createMemoryVaultStore,
|
|
10
11
|
createSetupProvider,
|
|
11
12
|
createSkillsProvider,
|
|
12
13
|
createVaultProvider,
|
|
13
14
|
memoryProvider,
|
|
15
|
+
type IdentityProviderOptions,
|
|
14
16
|
type SetupProviderOptions,
|
|
15
17
|
type VaultStore,
|
|
16
18
|
} from '#providers/owner.ts';
|
|
@@ -64,6 +66,15 @@ export interface OwnerLayerOptions {
|
|
|
64
66
|
* two capabilities that would report an empty profile as the truth.
|
|
65
67
|
*/
|
|
66
68
|
readonly setup?: SetupProviderOptions;
|
|
69
|
+
/**
|
|
70
|
+
* Who the profile says its owner is.
|
|
71
|
+
*
|
|
72
|
+
* Absent for a registry built to read manifests, which has no config to read
|
|
73
|
+
* it from. The provider still registers — it reports an empty declaration
|
|
74
|
+
* rather than vanishing, so the difference between "nothing declared" and
|
|
75
|
+
* "this build has no such surface" stays visible.
|
|
76
|
+
*/
|
|
77
|
+
readonly identity?: IdentityProviderOptions;
|
|
67
78
|
}
|
|
68
79
|
|
|
69
80
|
/**
|
|
@@ -72,7 +83,8 @@ export interface OwnerLayerOptions {
|
|
|
72
83
|
* Statically imported for now; the registry does not care where a manifest came
|
|
73
84
|
* from, which is what lets workspace YAML register alongside these.
|
|
74
85
|
*
|
|
75
|
-
* `allowReserved` is what admits `memory`, `skills`,
|
|
86
|
+
* `allowReserved` is what admits `memory`, `skills`, `vault`, `setup`, and
|
|
87
|
+
* `identity`. The guard
|
|
76
88
|
* stays rather than being retired: it exists so a *third-party* provider cannot
|
|
77
89
|
* claim a namespace whose policy rules would then silently mean something else,
|
|
78
90
|
* and that reason survives the owner layer shipping. Only this one construction
|
|
@@ -95,10 +107,12 @@ export function buildRegistry(owner: OwnerLayerOptions = {}): ProviderRegistry {
|
|
|
95
107
|
|
|
96
108
|
registry.register(
|
|
97
109
|
createSetupProvider(
|
|
98
|
-
owner.setup ?? { profile: '', catalogue: PROVIDER_MANIFESTS },
|
|
110
|
+
owner.setup ?? { profile: '', target: '', catalogue: PROVIDER_MANIFESTS },
|
|
99
111
|
),
|
|
100
112
|
);
|
|
101
113
|
|
|
114
|
+
registry.register(createIdentityProvider(owner.identity ?? { profile: '' }));
|
|
115
|
+
|
|
102
116
|
for (const manifest of PROVIDERS) registry.register(manifest);
|
|
103
117
|
return registry;
|
|
104
118
|
}
|
|
@@ -177,6 +191,50 @@ export async function reloadSkills(
|
|
|
177
191
|
return fingerprint;
|
|
178
192
|
}
|
|
179
193
|
|
|
194
|
+
/**
|
|
195
|
+
* The skills a starting runtime should register, and their fingerprint.
|
|
196
|
+
*
|
|
197
|
+
* `tolerant` is for a skills store that is somewhere else — a repository, and
|
|
198
|
+
* therefore a network dependency whose failures are ordinary rather than
|
|
199
|
+
* exceptional: an expired token, a spent rate limit, no connectivity on a
|
|
200
|
+
* train. Without it, one of those takes down `openRuntime` itself, and with it
|
|
201
|
+
* every command in the CLI, **including the two that diagnose and undo the
|
|
202
|
+
* arrangement** (`lanes link doctor` and `lanes link knowledge use local`). A
|
|
203
|
+
* token expiring would brick the profile and hide the fix.
|
|
204
|
+
*
|
|
205
|
+
* So a store that cannot be read comes back empty and says so on the log,
|
|
206
|
+
* rather than throwing. This is the same trade `Generation.refreshSkills`
|
|
207
|
+
* already makes for the poll — the endpoint keeps serving what it has instead
|
|
208
|
+
* of falling over — applied to the one read that had no such guard.
|
|
209
|
+
*
|
|
210
|
+
* **A malformed skill still throws, in either mode.** That is not a store
|
|
211
|
+
* failure, it is a document the owner wrote and wants to hear about, and
|
|
212
|
+
* swallowing it would leave one skill silently missing forever.
|
|
213
|
+
*/
|
|
214
|
+
export async function readSkillsForStart(
|
|
215
|
+
store: BlobStore,
|
|
216
|
+
tolerant: boolean,
|
|
217
|
+
warn: (message: string) => void,
|
|
218
|
+
/** `--profile x --target y`, so the two commands in the warning are pasteable. */
|
|
219
|
+
selection = '',
|
|
220
|
+
): Promise<{ skills: LoadedSkill[]; fingerprint: string }> {
|
|
221
|
+
try {
|
|
222
|
+
return { skills: await loadProfileSkills(store), fingerprint: await skillFingerprint(store) };
|
|
223
|
+
} catch (error) {
|
|
224
|
+
if (!tolerant || error instanceof ConfigError) throw error;
|
|
225
|
+
|
|
226
|
+
warn(
|
|
227
|
+
`could not read this profile's skills: ${(error as Error).message}\n` +
|
|
228
|
+
` Nothing else is affected. Run \`lanes link doctor${selection}\` for what is wrong, ` +
|
|
229
|
+
`or \`lanes link knowledge use local --migrate${selection}\` to bring them back onto ` +
|
|
230
|
+
'this machine.',
|
|
231
|
+
);
|
|
232
|
+
// An empty fingerprint rather than one of nothing, so the next poll retries
|
|
233
|
+
// instead of concluding the store is empty and staying that way.
|
|
234
|
+
return { skills: [], fingerprint: '' };
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
180
238
|
export async function skillFingerprint(store: BlobStore): Promise<string> {
|
|
181
239
|
return (await store.list())
|
|
182
240
|
.map((blob) => `${blob.key}:${blob.size}:${blob.modifiedAt.getTime()}`)
|
|
@@ -4,9 +4,10 @@ import type { BlobStore } from '#stores/blobs';
|
|
|
4
4
|
import {
|
|
5
5
|
loadProfileConfig,
|
|
6
6
|
resolveSelection,
|
|
7
|
-
|
|
7
|
+
requireTarget,
|
|
8
8
|
undeclaredTarget,
|
|
9
9
|
type Config,
|
|
10
|
+
type ProfileSelection,
|
|
10
11
|
type Resolution,
|
|
11
12
|
} from '#profile';
|
|
12
13
|
import { openSecrets, openStorage } from '#deployments/target.ts';
|
|
@@ -45,23 +46,35 @@ export async function resolveProfile(
|
|
|
45
46
|
// what means "read the real environment".
|
|
46
47
|
const env = options.env !== undefined ? { env: options.env } : {};
|
|
47
48
|
|
|
48
|
-
const selection = await resolveSelection({
|
|
49
|
-
profileFlag: flags.profile,
|
|
50
|
-
targetFlag: flags.target,
|
|
51
|
-
...env,
|
|
52
|
-
});
|
|
49
|
+
const selection = await resolveSelection({ profileFlag: flags.profile, ...env });
|
|
53
50
|
|
|
54
51
|
const { config } = await loadProfileConfig(selection.workspaceRoot, selection.profile);
|
|
55
|
-
const
|
|
52
|
+
const target = requireTarget(config, flags.target, {
|
|
56
53
|
allowUndeclared: options.allowUndeclaredTarget === true,
|
|
57
|
-
|
|
54
|
+
profile: selection.profile,
|
|
58
55
|
});
|
|
59
56
|
|
|
60
|
-
return {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
57
|
+
return { resolution: { ...selection, target }, config, target };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A profile without a target, for the commands that do not open one.
|
|
62
|
+
*
|
|
63
|
+
* `check` validates a YAML file, `config show` prints the whole of it, and
|
|
64
|
+
* `policy list` reads a block that is target-independent by construction. Making
|
|
65
|
+
* those three demand a `--target` would be the ceremony that teaches people to
|
|
66
|
+
* type `--target local` without reading it, which is how a required flag stops
|
|
67
|
+
* being a guard.
|
|
68
|
+
*/
|
|
69
|
+
export async function resolveProfileOnly(
|
|
70
|
+
flags: GlobalFlags,
|
|
71
|
+
options: { env?: Record<string, string | undefined> } = {},
|
|
72
|
+
): Promise<{ selection: ProfileSelection; config: Config }> {
|
|
73
|
+
const env = options.env !== undefined ? { env: options.env } : {};
|
|
74
|
+
const selection = await resolveSelection({ profileFlag: flags.profile, ...env });
|
|
75
|
+
const { config } = await loadProfileConfig(selection.workspaceRoot, selection.profile);
|
|
76
|
+
|
|
77
|
+
return { selection, config };
|
|
65
78
|
}
|
|
66
79
|
|
|
67
80
|
/**
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { layout, workspacePath } from '#profile';
|
|
2
|
+
import type { SecretStore } from '#secrets';
|
|
3
|
+
import {
|
|
4
|
+
createBlobVaultStore,
|
|
5
|
+
createFileVaultStore,
|
|
6
|
+
createSecretVaultStore,
|
|
7
|
+
type VaultStore,
|
|
8
|
+
} from '#providers/owner.ts';
|
|
9
|
+
import type { StorageFactory, TargetInput } from '#deployments/target.ts';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Which document the vault is, and where it lives.
|
|
13
|
+
*
|
|
14
|
+
* Its own file for the reason `#deployments/knowledge.ts` is: `open.ts` is a
|
|
15
|
+
* composition root, and "which of three backends holds one encrypted document"
|
|
16
|
+
* is a decision with its own reasoning rather than a step in assembling a
|
|
17
|
+
* runtime. Knowledge made the same move and could go to `deployments`; this one
|
|
18
|
+
* cannot, because the vault store is a provider and `deployments` may not import
|
|
19
|
+
* `providers` (`src/architecture.test.ts`). So it stops here, one level up.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The vault's encrypted document, wherever this target keeps it.
|
|
24
|
+
*
|
|
25
|
+
* Defaults to `file`, so a profile written before ADR-014 needs no change and a
|
|
26
|
+
* local run needs no vault configuration at all. `blob` exists because the file
|
|
27
|
+
* adapter was unconditional before: a deployed instance wrote its vault to a
|
|
28
|
+
* container filesystem, and every item in it was discarded by the next
|
|
29
|
+
* revision without an error to say so.
|
|
30
|
+
*/
|
|
31
|
+
export function openVault(
|
|
32
|
+
input: TargetInput,
|
|
33
|
+
storage: StorageFactory,
|
|
34
|
+
credentials: SecretStore,
|
|
35
|
+
): VaultStore {
|
|
36
|
+
const { declared, config, root } = input;
|
|
37
|
+
const vault = declared.vault ?? { adapter: 'file' as const };
|
|
38
|
+
|
|
39
|
+
switch (vault.adapter) {
|
|
40
|
+
case 'file':
|
|
41
|
+
return createFileVaultStore({
|
|
42
|
+
path: workspacePath(root, vault.path ?? layout.vault(config.instance.profile)),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
case 'secret':
|
|
46
|
+
// The document is sealed under LANES_LINK_VAULT_KEY before it gets here,
|
|
47
|
+
// so the credential store holds ciphertext it cannot read. Separate
|
|
48
|
+
// document, separate key, separate environment variable — the backend
|
|
49
|
+
// was never what kept the two stores apart. ADR-022.
|
|
50
|
+
return createSecretVaultStore({
|
|
51
|
+
store: credentials,
|
|
52
|
+
...(vault.ref !== undefined ? { ref: vault.ref } : {}),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
case 'blob':
|
|
56
|
+
return createBlobVaultStore({
|
|
57
|
+
store: storage(),
|
|
58
|
+
...(vault.path !== undefined ? { key: vault.path } : {}),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
package/src/cli/runtime.ts
CHANGED
|
@@ -22,6 +22,7 @@ export {
|
|
|
22
22
|
openSecretStoreFor,
|
|
23
23
|
ownerPrincipal,
|
|
24
24
|
resolveProfile,
|
|
25
|
+
resolveProfileOnly,
|
|
25
26
|
type GlobalFlags,
|
|
26
27
|
} from './runtime/select.ts';
|
|
27
28
|
|
|
@@ -31,4 +32,4 @@ export {
|
|
|
31
32
|
type OwnerLayerOptions,
|
|
32
33
|
} from './runtime/registry.ts';
|
|
33
34
|
|
|
34
|
-
export { openRuntime, type Runtime } from './runtime/open.ts';
|
|
35
|
+
export { openRuntime, type OpenOptions, type Runtime } from './runtime/open.ts';
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ConfigError,
|
|
3
|
+
listProfiles,
|
|
4
|
+
loadProfileConfig,
|
|
5
|
+
loadWorkspaceProfiles,
|
|
6
|
+
noProfileNamed,
|
|
7
|
+
noTargetInWorkspace,
|
|
8
|
+
noTargetNamed,
|
|
9
|
+
resolveWorkspaceRoot,
|
|
10
|
+
targetsByName,
|
|
11
|
+
} from '#profile';
|
|
12
|
+
import type { Flags } from './argv.ts';
|
|
13
|
+
import { nearest } from './nearest.ts';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Which commands must name a profile and a target, and which flags each accepts.
|
|
17
|
+
*
|
|
18
|
+
* Two rules, in one file because they fail for the same reason and the fix for
|
|
19
|
+
* one makes the other legible.
|
|
20
|
+
*
|
|
21
|
+
* **A flag that is silently ignored is the defect.** `lanes link profile add
|
|
22
|
+
* work --target cloud` printed `ok` and dropped the flag: `main.ts` built a
|
|
23
|
+
* literal for that command and never spread the global flags into it. Nothing
|
|
24
|
+
* refused, because nothing had a list of what the command accepts. That is what
|
|
25
|
+
* `assertKnownFlags` is — and it matters more than the requirement, because
|
|
26
|
+
* required flags make a typo *worse* on their own. `--porfile work` used to
|
|
27
|
+
* fall through to a workspace default and mostly work; with a requirement and
|
|
28
|
+
* no allowlist it produces "--profile is required", naming a flag the operator
|
|
29
|
+
* believes they just passed.
|
|
30
|
+
*
|
|
31
|
+
* **A selection is named or the command does not run** (ADR-037). The table
|
|
32
|
+
* below is the whole rule, and `selection.test.ts` reads `main.ts` to check that
|
|
33
|
+
* every dispatched command appears in it — so a new command cannot quietly
|
|
34
|
+
* default to requiring nothing.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* What a command must be told before it can act.
|
|
39
|
+
*
|
|
40
|
+
* `target` is not a weaker `profile+target`. It says the command's subject *is*
|
|
41
|
+
* the target, and that the profiles behind it are every profile declaring it
|
|
42
|
+
* rather than one the operator picks (ADR-043). `--profile` stays accepted
|
|
43
|
+
* there, as a filter.
|
|
44
|
+
*/
|
|
45
|
+
export type Requires = 'none' | 'profile' | 'target' | 'profile+target';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The rule, per command path.
|
|
49
|
+
*
|
|
50
|
+
* `--profile` for anything that reads or writes a profile's config or stores.
|
|
51
|
+
* `--target` for anything that opens a target's adapters or acts against a
|
|
52
|
+
* target's endpoint. A command that names a target positionally or through
|
|
53
|
+
* `--from`/`--to` supplies it that way and is not asked twice.
|
|
54
|
+
*
|
|
55
|
+
* Three entries are worth defending, because uniformity would be wrong:
|
|
56
|
+
*
|
|
57
|
+
* `check`, `config show` and `policy list` take no `--target`. All three are
|
|
58
|
+
* target-independent — a YAML file, the whole of it, and a policy block that is
|
|
59
|
+
* declared once and applies everywhere. Demanding a target would be the
|
|
60
|
+
* ceremony that teaches people to type `--target local` without reading it,
|
|
61
|
+
* which is how a required flag stops being a guard.
|
|
62
|
+
*
|
|
63
|
+
* `target list` takes no required `--target` either, and that is not an
|
|
64
|
+
* oversight: it is the command you run to find out what to pass. Requiring the
|
|
65
|
+
* answer as input is circular, and it has to keep working in the state every
|
|
66
|
+
* other command fails in.
|
|
67
|
+
*
|
|
68
|
+
* `status`, `deploy` and `sync targets` take `target` rather than
|
|
69
|
+
* `profile+target`. One deployed endpoint serves every profile in the workspace
|
|
70
|
+
* (ADR-009), so the profile set behind a target is enumerable from the config
|
|
71
|
+
* and naming one of them describes a slice, not the subject. That is not the
|
|
72
|
+
* inference ADR-037 removed: there is nothing to guess at, and nothing is
|
|
73
|
+
* silently chosen.
|
|
74
|
+
*
|
|
75
|
+
* `profile add` and `profile remove` **reject** `--profile`. Both name their
|
|
76
|
+
* profile positionally, so a flag naming a second one could only disagree with
|
|
77
|
+
* it. `add` has no profile to select before it exists; `remove` takes an
|
|
78
|
+
* optional `--target` to decommission one target's stores and keep the file.
|
|
79
|
+
*/
|
|
80
|
+
export const SELECTION: Record<string, Requires> = {
|
|
81
|
+
help: 'none',
|
|
82
|
+
version: 'none',
|
|
83
|
+
update: 'none',
|
|
84
|
+
skill: 'none',
|
|
85
|
+
'mcp skill': 'none',
|
|
86
|
+
'mcp list': 'none',
|
|
87
|
+
// The bare forms, which each dispatch to a `case undefined` in `main.ts`.
|
|
88
|
+
// `lanes link profile` is `profile list`, and needs the same as it.
|
|
89
|
+
profile: 'none',
|
|
90
|
+
mcp: 'none',
|
|
91
|
+
'profile list': 'none',
|
|
92
|
+
'profile add': 'none',
|
|
93
|
+
'profile default': 'none',
|
|
94
|
+
'target use': 'none',
|
|
95
|
+
'vault key': 'none',
|
|
96
|
+
|
|
97
|
+
check: 'profile',
|
|
98
|
+
config: 'profile',
|
|
99
|
+
policy: 'profile',
|
|
100
|
+
target: 'profile',
|
|
101
|
+
'config show': 'profile',
|
|
102
|
+
'policy list': 'profile',
|
|
103
|
+
'target list': 'profile',
|
|
104
|
+
'target show': 'profile',
|
|
105
|
+
'secrets push': 'profile',
|
|
106
|
+
'profile remove': 'none',
|
|
107
|
+
// Target-independent for the same reason `policy list` is: the block is
|
|
108
|
+
// declared once in the YAML and applies to every target the profile has.
|
|
109
|
+
identity: 'profile',
|
|
110
|
+
'identity list': 'profile',
|
|
111
|
+
|
|
112
|
+
connect: 'profile+target',
|
|
113
|
+
setup: 'profile+target',
|
|
114
|
+
token: 'profile+target',
|
|
115
|
+
audit: 'profile+target',
|
|
116
|
+
secrets: 'profile+target',
|
|
117
|
+
plan: 'profile+target',
|
|
118
|
+
doctor: 'profile+target',
|
|
119
|
+
// Target-scoped: see the note above. `--profile` narrows each to one profile.
|
|
120
|
+
status: 'target',
|
|
121
|
+
outputs: 'profile+target',
|
|
122
|
+
tools: 'profile+target',
|
|
123
|
+
// It reads which target it is rendering for before it decides anything: a
|
|
124
|
+
// deployed one has no page to open, and the refusal has to name it.
|
|
125
|
+
dashboard: 'profile+target',
|
|
126
|
+
attach: 'profile+target',
|
|
127
|
+
start: 'profile+target',
|
|
128
|
+
deploy: 'target',
|
|
129
|
+
// Both spellings: `sync` alone is `sync targets`, which is the only thing
|
|
130
|
+
// there is to sync, and naming it leaves room for the next one.
|
|
131
|
+
sync: 'target',
|
|
132
|
+
'sync targets': 'target',
|
|
133
|
+
'policy allow': 'profile+target',
|
|
134
|
+
'policy deny': 'profile+target',
|
|
135
|
+
// Both, unlike `identity list`, and for the same reason the policy edits are:
|
|
136
|
+
// each publishes the edit, which opens the target's credential store and
|
|
137
|
+
// reaches that target's endpoint.
|
|
138
|
+
'identity add': 'profile+target',
|
|
139
|
+
'identity remove': 'profile+target',
|
|
140
|
+
'token show': 'profile+target',
|
|
141
|
+
'token rotate': 'profile+target',
|
|
142
|
+
'audit tail': 'profile+target',
|
|
143
|
+
'audit verify': 'profile+target',
|
|
144
|
+
'secrets set': 'profile+target',
|
|
145
|
+
'secrets list': 'profile+target',
|
|
146
|
+
'mcp add': 'profile+target',
|
|
147
|
+
'mcp stdio': 'profile+target',
|
|
148
|
+
memory: 'profile+target',
|
|
149
|
+
skills: 'profile+target',
|
|
150
|
+
vault: 'profile+target',
|
|
151
|
+
// Both halves open the target's adapters — `show` counts what is in the
|
|
152
|
+
// stores, and `use` migrates between them — and both edit the profile's
|
|
153
|
+
// config. Neither can be answered without being told which.
|
|
154
|
+
knowledge: 'profile+target',
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The second words each command accepts.
|
|
159
|
+
*
|
|
160
|
+
* Only the commands that have subcommands appear. This exists for one reason:
|
|
161
|
+
* the checks below run before the switch, so without it `lanes link vault bogus`
|
|
162
|
+
* is refused for a missing `--profile` rather than for the subcommand that does
|
|
163
|
+
* not exist — a usage error reported as the wrong usage error, which is its own
|
|
164
|
+
* small version of the bug being fixed. `selection.test.ts` reads `main.ts` and
|
|
165
|
+
* asserts this stays true.
|
|
166
|
+
*/
|
|
167
|
+
const SUBCOMMANDS: Record<string, readonly string[]> = {
|
|
168
|
+
profile: ['add', 'list', 'default', 'remove'],
|
|
169
|
+
target: ['list', 'use', 'show'],
|
|
170
|
+
policy: ['list', 'allow', 'deny'],
|
|
171
|
+
identity: ['add', 'list', 'remove'],
|
|
172
|
+
token: ['show', 'rotate'],
|
|
173
|
+
audit: ['tail', 'verify'],
|
|
174
|
+
config: ['show'],
|
|
175
|
+
setup: ['plan'],
|
|
176
|
+
memory: ['list', 'get', 'write', 'forget'],
|
|
177
|
+
skills: ['list', 'show', 'add', 'remove'],
|
|
178
|
+
vault: ['list', 'get', 'set', 'remove', 'key'],
|
|
179
|
+
mcp: ['skill', 'add', 'stdio', 'list'],
|
|
180
|
+
secrets: ['push', 'set', 'list'],
|
|
181
|
+
knowledge: ['show', 'use'],
|
|
182
|
+
sync: ['targets'],
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Whether the switch is going to refuse this command path anyway.
|
|
187
|
+
*
|
|
188
|
+
* When it is, these checks stay quiet and let it: "Unknown: lanes link vault
|
|
189
|
+
* bogus" is the useful sentence, and a complaint about `--profile` on a command
|
|
190
|
+
* that does not exist sends someone off to fix the wrong thing.
|
|
191
|
+
*/
|
|
192
|
+
function dispatchWillRefuse(first: string, second: string | undefined): boolean {
|
|
193
|
+
const known = SUBCOMMANDS[first];
|
|
194
|
+
if (!known || second === undefined) return false;
|
|
195
|
+
return !known.includes(second);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The key for a command, longest match first.
|
|
200
|
+
*
|
|
201
|
+
* `token show` before `token`, so a two-word command can differ from its
|
|
202
|
+
* siblings without every sibling having to be listed.
|
|
203
|
+
*/
|
|
204
|
+
export function selectionKey(first: string, second: string | undefined): string {
|
|
205
|
+
const pair = second ? `${first} ${second}` : first;
|
|
206
|
+
if (pair in SELECTION) return pair;
|
|
207
|
+
return first;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Whether this command needs a profile, a target, both, or neither. */
|
|
211
|
+
export function requirementFor(first: string, second: string | undefined): Requires {
|
|
212
|
+
return SELECTION[selectionKey(first, second)] ?? 'profile+target';
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Refuse before the command runs, naming what it wants and what there is.
|
|
217
|
+
*
|
|
218
|
+
* Async, and it reads the workspace — but only on the way to throwing. The
|
|
219
|
+
* useful half of "which profile did you mean" is the list of them, and the same
|
|
220
|
+
* for targets; a refusal that only restates the flag name leaves someone to go
|
|
221
|
+
* and look it up. Both messages come from `#profile` so this file and the
|
|
222
|
+
* resolver cannot describe the same refusal differently, and both name an
|
|
223
|
+
* exported variable that no longer counts — the shell still configured for the
|
|
224
|
+
* old world is the state hardest to diagnose from the inside.
|
|
225
|
+
*/
|
|
226
|
+
export async function requireSelection(
|
|
227
|
+
first: string,
|
|
228
|
+
second: string | undefined,
|
|
229
|
+
flags: Flags,
|
|
230
|
+
env?: Record<string, string | undefined>,
|
|
231
|
+
): Promise<void> {
|
|
232
|
+
if (dispatchWillRefuse(first, second)) return;
|
|
233
|
+
|
|
234
|
+
const needs = requirementFor(first, second);
|
|
235
|
+
if (needs === 'none') return;
|
|
236
|
+
|
|
237
|
+
// Asked before the profile requirement, because for these there is none. The
|
|
238
|
+
// refusal has to describe the workspace rather than one profile's targets,
|
|
239
|
+
// since the command was never going to act on only one.
|
|
240
|
+
if (needs === 'target') {
|
|
241
|
+
if (typeof flags['target'] === 'string') return;
|
|
242
|
+
const root = resolveWorkspaceRoot(env ? { env } : {});
|
|
243
|
+
throw noTargetInWorkspace(targetsByName(await loadWorkspaceProfiles(root)), root, env);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const profile = flags['profile'];
|
|
247
|
+
if (typeof profile !== 'string') {
|
|
248
|
+
const root = resolveWorkspaceRoot(env ? { env } : {});
|
|
249
|
+
throw noProfileNamed(root, await listProfiles(root), env);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (needs !== 'profile+target' || typeof flags['target'] === 'string') return;
|
|
253
|
+
|
|
254
|
+
// The profile is known by here, so the target list is the one belonging to it
|
|
255
|
+
// rather than a guess. A profile that does not exist is a different refusal,
|
|
256
|
+
// and `resolveSelection` gives it a better one a moment later.
|
|
257
|
+
const root = resolveWorkspaceRoot(env ? { env } : {});
|
|
258
|
+
try {
|
|
259
|
+
const { config } = await loadProfileConfig(root, profile);
|
|
260
|
+
throw noTargetNamed(config, profile, env);
|
|
261
|
+
} catch (error) {
|
|
262
|
+
if (error instanceof ConfigError) throw error;
|
|
263
|
+
throw new ConfigError(`--target is required for "${[first, second].filter(Boolean).join(' ')}".`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Flags every command accepts, whatever it does.
|
|
269
|
+
*
|
|
270
|
+
* `--help` short-circuits before dispatch, and `--json` is offered widely enough
|
|
271
|
+
* that listing it per command would be noise. `--quiet` is read by `announce`
|
|
272
|
+
* rather than by any one command.
|
|
273
|
+
*/
|
|
274
|
+
const UNIVERSAL = ['help', 'json', 'quiet'];
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* What each command accepts beyond the universal set and its own selection.
|
|
278
|
+
*
|
|
279
|
+
* Only commands with flags of their own appear. Anything absent accepts the
|
|
280
|
+
* universal set plus whatever `SELECTION` says it must be told.
|
|
281
|
+
*/
|
|
282
|
+
const ACCEPTS: Record<string, readonly string[]> = {
|
|
283
|
+
// `own-client` is the older spelling of one of the routes `auth` names, kept
|
|
284
|
+
// because it is in scripts and a year of documentation (ADR-038).
|
|
285
|
+
connect: [
|
|
286
|
+
'id',
|
|
287
|
+
'display-name',
|
|
288
|
+
'replace',
|
|
289
|
+
'non-interactive',
|
|
290
|
+
'accept-broad-scopes',
|
|
291
|
+
'own-client',
|
|
292
|
+
'auth',
|
|
293
|
+
],
|
|
294
|
+
setup: ['id'],
|
|
295
|
+
'profile add': ['target', 'non-interactive'],
|
|
296
|
+
// `--target` decommissions one target's stores and leaves the profile file in
|
|
297
|
+
// place (`removal.ts`). It is documented in `usage.ts` and read by
|
|
298
|
+
// `removalPlan`, and was refused here — the flag existed everywhere except in
|
|
299
|
+
// the list that decides whether it may be typed.
|
|
300
|
+
'profile remove': ['dry-run', 'yes', 'target'],
|
|
301
|
+
'target list': ['urls', 'target'],
|
|
302
|
+
'target show': ['target'],
|
|
303
|
+
'token show': ['show', 'raw'],
|
|
304
|
+
'token rotate': ['show', 'raw', 'yes'],
|
|
305
|
+
'audit tail': ['limit', 'denied-only', 'format'],
|
|
306
|
+
'audit verify': ['limit', 'format'],
|
|
307
|
+
attach: ['connection'],
|
|
308
|
+
outputs: ['show'],
|
|
309
|
+
start: ['port', 'only'],
|
|
310
|
+
'mcp stdio': ['only'],
|
|
311
|
+
'mcp add': ['name', 'scope', 'token-env', 'dry-run', 'force', 'no-skill'],
|
|
312
|
+
'mcp skill': ['print', 'force'],
|
|
313
|
+
'mcp list': ['name', 'scope'],
|
|
314
|
+
dashboard: ['print'],
|
|
315
|
+
skill: ['print', 'force'],
|
|
316
|
+
deploy: ['dry-run', 'iam', 'access', 'service-account', 'tag', 'yes', 'non-interactive'],
|
|
317
|
+
'secrets push': ['from', 'to', 'overwrite', 'dry-run'],
|
|
318
|
+
sync: ['dry-run', 'from', 'discover', 'prefer'],
|
|
319
|
+
'sync targets': ['dry-run', 'from', 'discover', 'prefer'],
|
|
320
|
+
update: ['check'],
|
|
321
|
+
'identity add': ['note'],
|
|
322
|
+
memory: ['connection', 'title', 'description', 'file', 'tag'],
|
|
323
|
+
skills: ['connection', 'title', 'description', 'file'],
|
|
324
|
+
vault: ['connection'],
|
|
325
|
+
// `no-migrate` is listed beside `migrate` because they are three states
|
|
326
|
+
// rather than two: neither one asks, and a run with no terminal has to be
|
|
327
|
+
// able to say which it meant (ADR-041).
|
|
328
|
+
knowledge: ['repo', 'branch', 'path', 'migrate', 'no-migrate', 'keep', 'allow-public', 'replace', 'yes'],
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Refuse a flag this command does not read, and guess what was meant.
|
|
333
|
+
*
|
|
334
|
+
* This is the fix for the reported bug rather than a nicety. `profile add
|
|
335
|
+
* --target cloud` was accepted and dropped, and nothing could refuse it because
|
|
336
|
+
* `parseArgv` returns every `--anything` it sees and no command ever inspected
|
|
337
|
+
* the leftovers. A typo was swallowed the same way on every command in the CLI.
|
|
338
|
+
*/
|
|
339
|
+
export function assertKnownFlags(first: string, second: string | undefined, flags: Flags): void {
|
|
340
|
+
if (dispatchWillRefuse(first, second)) return;
|
|
341
|
+
|
|
342
|
+
const key = selectionKey(first, second);
|
|
343
|
+
const needs = SELECTION[key] ?? 'profile+target';
|
|
344
|
+
|
|
345
|
+
const allowed = new Set<string>([
|
|
346
|
+
...UNIVERSAL,
|
|
347
|
+
...(ACCEPTS[key] ?? []),
|
|
348
|
+
// `target` accepts both: the target is what it acts on, and `--profile`
|
|
349
|
+
// narrows it to one of the profiles behind it.
|
|
350
|
+
...(needs !== 'none' ? ['profile'] : []),
|
|
351
|
+
...(needs === 'target' || needs === 'profile+target' ? ['target'] : []),
|
|
352
|
+
]);
|
|
353
|
+
|
|
354
|
+
const named = [first, second].filter(Boolean).join(' ');
|
|
355
|
+
|
|
356
|
+
for (const given of Object.keys(flags)) {
|
|
357
|
+
if (allowed.has(given)) continue;
|
|
358
|
+
|
|
359
|
+
throw new ConfigError(
|
|
360
|
+
`Unknown flag "--${given}" for "lanes link ${named}".` +
|
|
361
|
+
(nearest(given, allowed) ? `\n Did you mean --${nearest(given, allowed)}?` : '') +
|
|
362
|
+
`\n Accepts: ${[...allowed].sort().map((name) => `--${name}`).join(' ')}`,
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
}
|