@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
package/src/profile/targets.ts
CHANGED
|
@@ -6,147 +6,160 @@ import type { Config } from './schema.ts';
|
|
|
6
6
|
*
|
|
7
7
|
* A target names an adapter set — where credentials are kept and where bytes
|
|
8
8
|
* go — and choosing one is a different subject from finding the workspace and
|
|
9
|
-
* the profile, which is why it is a different file.
|
|
10
|
-
* size budget holding both, and the budget's job is to say which of the two
|
|
11
|
-
* things a file is doing should leave.
|
|
9
|
+
* the profile, which is why it is a different file.
|
|
12
10
|
*
|
|
13
|
-
*
|
|
14
|
-
* `instance.default_target`.
|
|
15
|
-
*
|
|
11
|
+
* **`--target`, or the command does not run** (ADR-037). There is no fallback:
|
|
12
|
+
* not `LANES_LINK_TARGET`, not `instance.default_target`. Both are still
|
|
13
|
+
* parsed, so no existing config file has to change, and neither is read.
|
|
14
|
+
*
|
|
15
|
+
* The chain this replaces resolved `--target`, then the variable, then the key,
|
|
16
|
+
* and printed which of the three it landed on. What that bought was one flag
|
|
17
|
+
* saved per command. What it cost was that an *ignored* flag still produced a
|
|
18
|
+
* working command — `profile add --target cloud` dropped the flag on the floor
|
|
19
|
+
* and the next command carried on from a different source, so the mistake
|
|
20
|
+
* surfaced one command later with nothing connecting it to its cause. A
|
|
21
|
+
* resolver with nowhere to fall back to cannot fail that way.
|
|
16
22
|
*/
|
|
17
23
|
|
|
18
|
-
/** The variable that names a target for every command in a shell. */
|
|
19
|
-
export const TARGET_ENV = 'LANES_LINK_TARGET';
|
|
20
|
-
|
|
21
24
|
/**
|
|
22
|
-
* The target
|
|
23
|
-
*
|
|
24
|
-
* Split out because two callers need this precedence and must not disagree
|
|
25
|
-
* about it: `resolveSelection` records a provisional answer for `announce`
|
|
26
|
-
* before any config is loaded, and `resolveTarget` settles it afterwards. While
|
|
27
|
-
* the flag was the only source, both could spell it `targetFlag ?? …` and stay
|
|
28
|
-
* accidentally correct. With a second source, one of them learning about it and
|
|
29
|
-
* the other not is a line reading `config-default` beside a target the
|
|
30
|
-
* environment chose — which is the one line that exists to prevent exactly that.
|
|
25
|
+
* The target this command named, checked against what the profile declares.
|
|
31
26
|
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
27
|
+
* `allowUndeclared` is for the one command whose job is to create the target it
|
|
28
|
+
* was given — `deploy`, on a first run. Every other command naming a target that
|
|
29
|
+
* does not exist has made a typo, and the list of what does exist is the useful
|
|
30
|
+
* answer.
|
|
34
31
|
*/
|
|
35
|
-
export function
|
|
32
|
+
export function requireTarget(
|
|
33
|
+
config: Config,
|
|
36
34
|
targetFlag: string | undefined,
|
|
37
|
-
|
|
38
|
-
):
|
|
39
|
-
if (targetFlag)
|
|
35
|
+
options: { allowUndeclared?: boolean; profile?: string } = {},
|
|
36
|
+
): string {
|
|
37
|
+
if (!targetFlag) throw noTargetNamed(config, options.profile);
|
|
40
38
|
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
if (options.allowUndeclared !== true && !(targetFlag in config.targets)) {
|
|
40
|
+
throw undeclaredTarget(targetFlag, config, options.profile);
|
|
41
|
+
}
|
|
43
42
|
|
|
44
|
-
return
|
|
43
|
+
return targetFlag;
|
|
45
44
|
}
|
|
46
45
|
|
|
47
46
|
/**
|
|
48
|
-
* The refusal for a
|
|
47
|
+
* The refusal for a command that named no target.
|
|
49
48
|
*
|
|
50
|
-
* It
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* that reads as a problem with the config file.
|
|
49
|
+
* It lists the targets with their adapters, because "which one" is the question
|
|
50
|
+
* being asked and the adapter set is what distinguishes them. It also reports
|
|
51
|
+
* the two things that used to answer this and no longer do — an exported
|
|
52
|
+
* variable and the key still sitting in the file — since an operator looking at
|
|
53
|
+
* either has every reason to believe it is still working.
|
|
56
54
|
*/
|
|
57
|
-
export function
|
|
58
|
-
target: string,
|
|
55
|
+
export function noTargetNamed(
|
|
59
56
|
config: Config,
|
|
60
|
-
|
|
57
|
+
profile?: string,
|
|
58
|
+
env: Record<string, string | undefined> = process.env as Record<string, string | undefined>,
|
|
61
59
|
): ConfigError {
|
|
62
|
-
const
|
|
60
|
+
const names = Object.keys(config.targets);
|
|
61
|
+
const whose = profile ? ` by profile "${profile}"` : '';
|
|
62
|
+
|
|
63
|
+
const rows = names
|
|
64
|
+
.map((name) => {
|
|
65
|
+
const declared = config.targets[name]!;
|
|
66
|
+
const deployed = declared.deploy ? ' deployed' : '';
|
|
67
|
+
return ` ${name} ${declared.credentials.adapter} ${declared.storage.adapter}${deployed}`;
|
|
68
|
+
})
|
|
69
|
+
.join('\n');
|
|
70
|
+
|
|
71
|
+
const stale = env[LEGACY_TARGET_ENV];
|
|
72
|
+
const inert = config.instance.default_target;
|
|
73
|
+
|
|
63
74
|
return new ConfigError(
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
75
|
+
'--target is required. This command opens a target\'s stores, and nothing\n' +
|
|
76
|
+
'else selects one.\n\n' +
|
|
77
|
+
` Targets declared${whose}\n${rows}\n` +
|
|
78
|
+
`\n e.g. lanes link status --profile ${profile ?? '<name>'} --target ${names[0] ?? '<target>'}` +
|
|
79
|
+
(stale
|
|
80
|
+
? `\n\n ${LEGACY_TARGET_ENV}=${stale} is set in this shell and is no longer read.\n` +
|
|
81
|
+
' Unset it, or pass --target.'
|
|
82
|
+
: '') +
|
|
83
|
+
(inert
|
|
84
|
+
? `\n\n instance.default_target: ${inert} is still in this profile. It is no longer\n` +
|
|
85
|
+
' read either, and is safe to delete.'
|
|
67
86
|
: ''),
|
|
68
87
|
);
|
|
69
88
|
}
|
|
70
89
|
|
|
71
90
|
/**
|
|
72
|
-
*
|
|
91
|
+
* The variable that used to name a target, kept only to say it is ignored.
|
|
73
92
|
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
93
|
+
* Not read by anything that resolves. It exists so a refusal can name the thing
|
|
94
|
+
* an operator is looking at and reasonably believes is still working — which is
|
|
95
|
+
* the whole difference between "this stopped working" and "this stopped working
|
|
96
|
+
* and here is why".
|
|
97
|
+
*/
|
|
98
|
+
export const LEGACY_TARGET_ENV = 'LANES_LINK_TARGET';
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The refusal for a target that is not in the file, in one spelling.
|
|
77
102
|
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
* answer; refusing there is what stops `--target clod` opening the default one.
|
|
103
|
+
* It was two — here and in the CLI's `openSecretStoreFor` — which is one more
|
|
104
|
+
* than a sentence naming the available targets survives: the copies drift the
|
|
105
|
+
* moment either learns something the other does not.
|
|
82
106
|
*/
|
|
83
|
-
export function
|
|
84
|
-
config
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
env?: Record<string, string | undefined>;
|
|
89
|
-
} = {},
|
|
90
|
-
): { target: string; source: 'flag' | 'environment' | 'config-default' } {
|
|
91
|
-
const env = options.env ?? (process.env as Record<string, string | undefined>);
|
|
92
|
-
const asked = askedTarget(targetFlag, env);
|
|
93
|
-
|
|
94
|
-
const target = asked.target ?? config.instance.default_target;
|
|
95
|
-
const source = asked.source ?? 'config-default';
|
|
96
|
-
|
|
97
|
-
if (options.allowUndeclared !== true && !(target in config.targets)) {
|
|
98
|
-
throw undeclaredTarget(target, config, source);
|
|
99
|
-
}
|
|
100
|
-
return { target, source };
|
|
107
|
+
export function undeclaredTarget(target: string, config: Config, profile?: string): ConfigError {
|
|
108
|
+
const have = Object.keys(config.targets).join(', ') || 'none';
|
|
109
|
+
const whose = profile ? `profile "${profile}"` : 'this profile';
|
|
110
|
+
|
|
111
|
+
return new ConfigError(`Target "${target}" is not declared by ${whose} (have: ${have})`);
|
|
101
112
|
}
|
|
102
113
|
|
|
103
114
|
/**
|
|
104
|
-
* The
|
|
105
|
-
*
|
|
106
|
-
* `--target cloud` was required on every deploy, and the reason was an accident:
|
|
107
|
-
* an absent flag falls back to `instance.default_target`, which is `local` —
|
|
108
|
-
* a target that by definition is not deployed anywhere. So the one command whose
|
|
109
|
-
* subject is never ambiguous was the one command that made you say it, and the
|
|
110
|
-
* default it would otherwise have taken was not merely unhelpful but wrong.
|
|
111
|
-
*
|
|
112
|
-
* The rule is what someone would say out loud: deploy the target that has a
|
|
113
|
-
* deployment. One is the answer; none means the first run, which conventionally
|
|
114
|
-
* creates `cloud` and is what every example in the docs names; several is a
|
|
115
|
-
* genuine question, and asking beats rolling a revision to whichever came first
|
|
116
|
-
* in a YAML mapping.
|
|
115
|
+
* The refusal for a target-scoped command that named no target.
|
|
117
116
|
*
|
|
118
|
-
*
|
|
117
|
+
* The twin of `noTargetNamed`, for the commands whose subject is the target
|
|
118
|
+
* rather than one profile's view of it (ADR-043). It lists the target names the
|
|
119
|
+
* *workspace* declares and who declares each, because the question those
|
|
120
|
+
* commands are asking is "which endpoint", and a target only one profile knows
|
|
121
|
+
* about is the answer to a different question than one they all share.
|
|
119
122
|
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
* for this question. It is also the only place where being wrong creates cloud
|
|
124
|
-
* resources rather than an error: `deploy` is the one caller that passes
|
|
125
|
-
* `allowUndeclared`, so an exported typo would not be refused, it would be
|
|
126
|
-
* surveyed, written into the profile, and rolled out as a new service. An
|
|
127
|
-
* environment variable must not be able to name a Cloud Run service into
|
|
128
|
-
* existence. Say `--target`; `deploy` is rare enough to afford it.
|
|
123
|
+
* Naming the profiles is not decoration. A target declared by one profile and
|
|
124
|
+
* not its sibling is precisely the state that reads as a deployment having
|
|
125
|
+
* disappeared, and it is invisible from inside either profile alone.
|
|
129
126
|
*/
|
|
130
|
-
export
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
// loader, so exactly one shape reaches here.
|
|
140
|
-
const deployable = Object.entries(config.targets)
|
|
141
|
-
.filter(([, declared]) => declared.deploy !== undefined)
|
|
142
|
-
.map(([name]) => name);
|
|
143
|
-
|
|
144
|
-
if (deployable.length > 1) {
|
|
145
|
-
throw new ConfigError(
|
|
146
|
-
`This profile declares ${deployable.length} deployable targets (${deployable.join(', ')}). ` +
|
|
147
|
-
'Name the one you mean with --target.',
|
|
127
|
+
export function noTargetInWorkspace(
|
|
128
|
+
declared: ReadonlyMap<string, readonly string[]>,
|
|
129
|
+
workspaceRoot: string,
|
|
130
|
+
env: Record<string, string | undefined> = process.env as Record<string, string | undefined>,
|
|
131
|
+
): ConfigError {
|
|
132
|
+
if (declared.size === 0) {
|
|
133
|
+
return new ConfigError(
|
|
134
|
+
`--target is required, and no profile in ${workspaceRoot} declares one.\n` +
|
|
135
|
+
' Create a profile with: lanes link profile add <name> --target local',
|
|
148
136
|
);
|
|
149
137
|
}
|
|
150
138
|
|
|
151
|
-
|
|
139
|
+
const total = new Set(
|
|
140
|
+
[...declared.values()].flatMap((profiles) => profiles as readonly string[]),
|
|
141
|
+
).size;
|
|
142
|
+
|
|
143
|
+
const rows = [...declared.entries()]
|
|
144
|
+
.map(([name, profiles]) => {
|
|
145
|
+
// "every profile" rather than the list once it is all of them: the list is
|
|
146
|
+
// there to show a gap, and a complete one shows none.
|
|
147
|
+
const whose = profiles.length === total ? 'every profile' : profiles.join(', ');
|
|
148
|
+
return ` ${name} ${whose}`;
|
|
149
|
+
})
|
|
150
|
+
.join('\n');
|
|
151
|
+
|
|
152
|
+
const first = [...declared.keys()][0]!;
|
|
153
|
+
const stale = env[LEGACY_TARGET_ENV];
|
|
154
|
+
|
|
155
|
+
return new ConfigError(
|
|
156
|
+
'--target is required. This command acts on a target, and every profile\n' +
|
|
157
|
+
'that declares it.\n\n' +
|
|
158
|
+
` Targets in ${workspaceRoot}\n${rows}\n` +
|
|
159
|
+
`\n e.g. lanes link status --target ${first}` +
|
|
160
|
+
(stale
|
|
161
|
+
? `\n\n ${LEGACY_TARGET_ENV}=${stale} is set in this shell and is no longer read.\n` +
|
|
162
|
+
' Unset it, or pass --target.'
|
|
163
|
+
: ''),
|
|
164
|
+
);
|
|
152
165
|
}
|
package/src/profile/workspace.ts
CHANGED
|
@@ -6,7 +6,6 @@ import { homedir } from 'node:os';
|
|
|
6
6
|
import { parse as parseYaml } from 'yaml';
|
|
7
7
|
import { ConfigError } from './load.ts';
|
|
8
8
|
import { workspaceSchema, type Config, type WorkspaceConfig } from './schema.ts';
|
|
9
|
-
import { askedTarget } from './targets.ts';
|
|
10
9
|
|
|
11
10
|
/**
|
|
12
11
|
* Workspace and profile resolution.
|
|
@@ -19,50 +18,52 @@ import { askedTarget } from './targets.ts';
|
|
|
19
18
|
* work.yaml
|
|
20
19
|
* data/ local state per profile, gitignored
|
|
21
20
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* bites is the dotfile nothing prints.
|
|
21
|
+
* **A command says which profile it means, or it does not run** (ADR-037).
|
|
22
|
+
* `--profile` is the only thing that selects one. `LANES_LINK_PROFILE` and
|
|
23
|
+
* `default_profile` are parsed and ignored.
|
|
26
24
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* the
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* from
|
|
25
|
+
* The argument this replaces was that persisted selection is how operators act
|
|
26
|
+
* on the wrong thing, and that a *visible* fallback — an exported variable, a
|
|
27
|
+
* key in a file the operator reads, and a line printed before every command —
|
|
28
|
+
* was therefore safe. The first half stands and is why this rule exists at all.
|
|
29
|
+
* What did not survive is the conclusion: the printed line is a dim grey one,
|
|
30
|
+
* and a fallback made an ignored flag survivable, so `profile add --target
|
|
31
|
+
* cloud` dropping its flag surfaced on the *next* command, from a different
|
|
32
|
+
* source, detached from its cause. A resolver with nothing to fall back to
|
|
33
|
+
* cannot do that.
|
|
34
|
+
*
|
|
35
|
+
* The workspace root is deliberately not part of this and keeps its chain —
|
|
36
|
+
* `LANES_LINK_HOME`, then an ancestor holding `lanes-link.yaml`, then
|
|
37
|
+
* `~/.lanes-link`. Getting it wrong yields "no profiles here" rather than an
|
|
38
|
+
* action against the wrong account, it is the only channel a container has for
|
|
39
|
+
* its bucket (ADR-023), and the ancestor walk is what makes a per-repository
|
|
40
|
+
* workspace work at all.
|
|
34
41
|
*/
|
|
35
42
|
|
|
36
43
|
export const WORKSPACE_FILE = 'lanes-link.yaml';
|
|
37
44
|
|
|
38
|
-
|
|
45
|
+
/** A profile, found. Everything a command needs before it has read the config. */
|
|
46
|
+
export interface ProfileSelection {
|
|
39
47
|
readonly workspaceRoot: string;
|
|
40
48
|
readonly profile: string;
|
|
41
49
|
readonly profilePath: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A profile and the target whose stores a command will open.
|
|
54
|
+
*
|
|
55
|
+
* There is no `profileSource`/`targetSource` any more, and nothing should
|
|
56
|
+
* reintroduce them: with one way to select each, a source field has one
|
|
57
|
+
* inhabitant, and `announce` would print `(flag)` twice on every line of every
|
|
58
|
+
* command forever. `target.ts` already makes that argument about a line printed
|
|
59
|
+
* unconditionally — it stops being read.
|
|
60
|
+
*/
|
|
61
|
+
export interface Resolution extends ProfileSelection {
|
|
42
62
|
readonly target: string;
|
|
43
|
-
/** Where the value came from, so every command can print how it got here. */
|
|
44
|
-
readonly profileSource: 'flag' | 'environment' | 'workspace-default';
|
|
45
|
-
/**
|
|
46
|
-
* `deployable` is `deploy` choosing the only target it could have meant.
|
|
47
|
-
*
|
|
48
|
-
* It is its own source rather than reusing `config-default` because it is a
|
|
49
|
-
* different claim: the config default is what *commands* run against, and for
|
|
50
|
-
* every other command that is the local target. Printing "config-default"
|
|
51
|
-
* beside a target the config does not default to would be a lie on the one
|
|
52
|
-
* line that exists to say how the command got here.
|
|
53
|
-
*
|
|
54
|
-
* `environment` is `LANES_LINK_TARGET`, and it earns its own name for the
|
|
55
|
-
* same reason: a target chosen by a variable exported in another terminal an
|
|
56
|
-
* hour ago is the one an operator is most likely to be surprised by, and the
|
|
57
|
-
* fix — `unset` — is not the fix for a config default. `deploy` never
|
|
58
|
-
* produces it; `resolveDeployTarget` says why.
|
|
59
|
-
*/
|
|
60
|
-
readonly targetSource: 'flag' | 'environment' | 'config-default' | 'deployable';
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
export interface ResolveOptions {
|
|
64
66
|
readonly profileFlag?: string | undefined;
|
|
65
|
-
readonly targetFlag?: string | undefined;
|
|
66
67
|
readonly cwd?: string;
|
|
67
68
|
readonly env?: Record<string, string | undefined>;
|
|
68
69
|
}
|
|
@@ -172,45 +173,20 @@ export async function listProfiles(workspaceRoot: string): Promise<string[]> {
|
|
|
172
173
|
}
|
|
173
174
|
|
|
174
175
|
/**
|
|
175
|
-
*
|
|
176
|
+
* Find the profile a command names, or refuse saying what there is.
|
|
176
177
|
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
178
|
+
* `--profile` and nothing else. The refusal lists the workspace's profiles,
|
|
179
|
+
* because "which one" is the question it is asking, and it names
|
|
180
|
+
* `LANES_LINK_PROFILE` when that is set — the shell still configured for the old
|
|
181
|
+
* world is the single most confusing state to be in during the change, and it
|
|
182
|
+
* is self-limiting: the line disappears the moment the variable does.
|
|
180
183
|
*/
|
|
181
|
-
export async function resolveSelection(options: ResolveOptions = {}): Promise<
|
|
184
|
+
export async function resolveSelection(options: ResolveOptions = {}): Promise<ProfileSelection> {
|
|
182
185
|
const env = options.env ?? (process.env as Record<string, string | undefined>);
|
|
183
186
|
const workspaceRoot = resolveWorkspaceRoot(options);
|
|
187
|
+
const profile = options.profileFlag;
|
|
184
188
|
|
|
185
|
-
|
|
186
|
-
let profileSource: Resolution['profileSource'] = 'workspace-default';
|
|
187
|
-
|
|
188
|
-
if (options.profileFlag) {
|
|
189
|
-
profile = options.profileFlag;
|
|
190
|
-
profileSource = 'flag';
|
|
191
|
-
} else if (env['LANES_LINK_PROFILE']) {
|
|
192
|
-
profile = env['LANES_LINK_PROFILE'];
|
|
193
|
-
profileSource = 'environment';
|
|
194
|
-
} else {
|
|
195
|
-
profile = (await readWorkspace(workspaceRoot))?.default_profile;
|
|
196
|
-
profileSource = 'workspace-default';
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
if (!profile) {
|
|
200
|
-
const available = await listProfiles(workspaceRoot);
|
|
201
|
-
throw new ConfigError(
|
|
202
|
-
`No profile selected in workspace ${workspaceRoot}.\n` +
|
|
203
|
-
(available.length > 0
|
|
204
|
-
? `Available: ${available.join(', ')}\n` +
|
|
205
|
-
`Pass --profile <name>, set LANES_LINK_PROFILE, or set default_profile in ${WORKSPACE_FILE}.`
|
|
206
|
-
: `No profiles exist yet. Create one with: lanes link profile add <name> --default`),
|
|
207
|
-
);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// Provisional: the config has not been read yet, so `instance.default_target`
|
|
211
|
-
// is not available to fall back to. `resolveTarget` settles it, from the same
|
|
212
|
-
// helper, so the two cannot disagree about what beats what.
|
|
213
|
-
const asked = askedTarget(options.targetFlag, env);
|
|
189
|
+
if (!profile) throw noProfileNamed(workspaceRoot, await listProfiles(workspaceRoot), env);
|
|
214
190
|
|
|
215
191
|
const path = profilePath(workspaceRoot, profile);
|
|
216
192
|
if (!(await workspaceFiles(workspaceRoot).has(`profiles/${profile}.yaml`))) {
|
|
@@ -221,14 +197,35 @@ export async function resolveSelection(options: ResolveOptions = {}): Promise<Re
|
|
|
221
197
|
);
|
|
222
198
|
}
|
|
223
199
|
|
|
224
|
-
return {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
200
|
+
return { workspaceRoot, profile, profilePath: path };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The refusal for a command that named no profile. Exported so it can be tested. */
|
|
204
|
+
export function noProfileNamed(
|
|
205
|
+
workspaceRoot: string,
|
|
206
|
+
available: readonly string[],
|
|
207
|
+
env: Record<string, string | undefined> = process.env as Record<string, string | undefined>,
|
|
208
|
+
): ConfigError {
|
|
209
|
+
if (available.length === 0) {
|
|
210
|
+
return new ConfigError(
|
|
211
|
+
`--profile is required, and ${workspaceRoot} holds no profiles yet.\n` +
|
|
212
|
+
' Create one with: lanes link profile add <name> --target local',
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const stale = env['LANES_LINK_PROFILE'];
|
|
217
|
+
|
|
218
|
+
return new ConfigError(
|
|
219
|
+
'--profile is required. Every command names the profile it acts on, and\n' +
|
|
220
|
+
'nothing else selects one.\n\n' +
|
|
221
|
+
` Profiles in ${workspaceRoot}\n` +
|
|
222
|
+
available.map((name) => ` ${name}`).join('\n') +
|
|
223
|
+
`\n\n e.g. lanes link status --profile ${available[0]} --target <target>` +
|
|
224
|
+
(stale
|
|
225
|
+
? `\n\n LANES_LINK_PROFILE=${stale} is set in this shell and is no longer read.\n` +
|
|
226
|
+
' Unset it, or pass --profile.'
|
|
227
|
+
: ''),
|
|
228
|
+
);
|
|
232
229
|
}
|
|
233
230
|
|
|
234
231
|
/**
|
|
@@ -252,11 +249,74 @@ export function workspacePath(workspaceRoot: string, path: string): string {
|
|
|
252
249
|
}
|
|
253
250
|
|
|
254
251
|
/**
|
|
255
|
-
*
|
|
252
|
+
* One profile, loaded, for the commands whose subject is the whole workspace.
|
|
253
|
+
*
|
|
254
|
+
* `profilePath` rather than a bare name because a caller reporting on several
|
|
255
|
+
* profiles at once has nowhere to recompute it from without knowing whether the
|
|
256
|
+
* root is a bucket.
|
|
257
|
+
*/
|
|
258
|
+
export interface LoadedProfile {
|
|
259
|
+
readonly profile: string;
|
|
260
|
+
readonly profilePath: string;
|
|
261
|
+
readonly config: Config;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Every profile in the workspace, and the ones that would not open.
|
|
266
|
+
*
|
|
267
|
+
* **Skipping rather than failing** is the same rule `openReconciled` follows for
|
|
268
|
+
* a deployed endpoint: a workspace holding one broken profile still has a true
|
|
269
|
+
* answer to give about the others, and a listing that dies on the first bad file
|
|
270
|
+
* is one that stops working exactly when it is needed. What it must not do is
|
|
271
|
+
* skip *silently* — `unreadable` is the half a caller has to print, and the
|
|
272
|
+
* reason it carries the message rather than the error is that a caller rendering
|
|
273
|
+
* a table has no use for a stack.
|
|
274
|
+
*/
|
|
275
|
+
export interface WorkspaceProfiles {
|
|
276
|
+
readonly workspaceRoot: string;
|
|
277
|
+
readonly loaded: readonly LoadedProfile[];
|
|
278
|
+
readonly unreadable: readonly { readonly profile: string; readonly reason: string }[];
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export async function loadWorkspaceProfiles(workspaceRoot: string): Promise<WorkspaceProfiles> {
|
|
282
|
+
const loaded: LoadedProfile[] = [];
|
|
283
|
+
const unreadable: { profile: string; reason: string }[] = [];
|
|
284
|
+
|
|
285
|
+
for (const profile of await listProfiles(workspaceRoot)) {
|
|
286
|
+
try {
|
|
287
|
+
const { config } = await loadProfileConfig(workspaceRoot, profile);
|
|
288
|
+
loaded.push({ profile, profilePath: profilePath(workspaceRoot, profile), config });
|
|
289
|
+
} catch (error) {
|
|
290
|
+
// First line only: a `ConfigError` from the loader carries every schema
|
|
291
|
+
// issue on its own line, and a row in a table has room for none of them.
|
|
292
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
293
|
+
unreadable.push({ profile, reason: message.split('\n')[0] ?? 'could not be read' });
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return { workspaceRoot, loaded, unreadable };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Which profiles declare each target name, across the whole workspace.
|
|
256
302
|
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
303
|
+
* A target is declared per profile and the endpoint serves every profile in the
|
|
304
|
+
* workspace (ADR-009), so "who has `cloud`" is a question with a list for an
|
|
305
|
+
* answer rather than a yes or no. The disagreement — one profile declaring it
|
|
306
|
+
* and its sibling not — is the state that reads as a target having vanished.
|
|
259
307
|
*/
|
|
260
|
-
export function
|
|
261
|
-
|
|
308
|
+
export function targetsByName(
|
|
309
|
+
workspace: WorkspaceProfiles,
|
|
310
|
+
): ReadonlyMap<string, readonly string[]> {
|
|
311
|
+
const byName = new Map<string, string[]>();
|
|
312
|
+
|
|
313
|
+
for (const { profile, config } of workspace.loaded) {
|
|
314
|
+
for (const target of Object.keys(config.targets)) {
|
|
315
|
+
const profiles = byName.get(target);
|
|
316
|
+
if (profiles) profiles.push(profile);
|
|
317
|
+
else byName.set(target, [profile]);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return byName;
|
|
262
322
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineProvider } from '#connectivity';
|
|
2
2
|
import { CALENDAR_IDENTITY, GOOGLE_APP, GOOGLE_OAUTH, specPath } from '../shared/oauth.ts';
|
|
3
|
+
import { googleServiceAccount } from '../shared/service-account.ts';
|
|
3
4
|
import { googleSetup } from '../shared/setup.ts';
|
|
4
5
|
import { CALENDAR_REDACT } from './redact.ts';
|
|
5
6
|
|
|
@@ -53,6 +54,7 @@ export const calendar = defineProvider({
|
|
|
53
54
|
app: GOOGLE_APP,
|
|
54
55
|
scopes: CALENDAR_SCOPES,
|
|
55
56
|
...GOOGLE_OAUTH,
|
|
57
|
+
assertion: googleServiceAccount('Calendar', CALENDAR_SCOPES, 'optional', ['calendar-json.googleapis.com']),
|
|
56
58
|
},
|
|
57
59
|
identity: CALENDAR_IDENTITY,
|
|
58
60
|
setup: googleSetup('Calendar', CALENDAR_SCOPES, {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineProvider } from '#connectivity';
|
|
2
2
|
import { GOOGLE_APP, GOOGLE_OAUTH, PEOPLE_IDENTITY, specPath } from '../shared/oauth.ts';
|
|
3
|
+
import { googleServiceAccount } from '../shared/service-account.ts';
|
|
3
4
|
import { googleSetup } from '../shared/setup.ts';
|
|
4
5
|
import { CONTACTS_REDACT } from './redact.ts';
|
|
5
6
|
|
|
@@ -43,6 +44,7 @@ export const contacts = defineProvider({
|
|
|
43
44
|
app: GOOGLE_APP,
|
|
44
45
|
scopes: CONTACTS_SCOPES,
|
|
45
46
|
...GOOGLE_OAUTH,
|
|
47
|
+
assertion: googleServiceAccount('Contacts', CONTACTS_SCOPES, 'required', ['people.googleapis.com']),
|
|
46
48
|
},
|
|
47
49
|
identity: PEOPLE_IDENTITY,
|
|
48
50
|
setup: googleSetup('Contacts', CONTACTS_SCOPES, { apis: ['people.googleapis.com'] }),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineProvider } from '#connectivity';
|
|
2
2
|
import { DRIVE_IDENTITY, GOOGLE_APP, GOOGLE_OAUTH, specPath } from '../shared/oauth.ts';
|
|
3
|
+
import { googleServiceAccount } from '../shared/service-account.ts';
|
|
3
4
|
import { googleSetup } from '../shared/setup.ts';
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -32,6 +33,7 @@ export const docs = defineProvider({
|
|
|
32
33
|
app: GOOGLE_APP,
|
|
33
34
|
scopes: DOCS_SCOPES,
|
|
34
35
|
...GOOGLE_OAUTH,
|
|
36
|
+
assertion: googleServiceAccount('Docs', DOCS_SCOPES, 'optional', ['docs.googleapis.com', 'drive.googleapis.com']),
|
|
35
37
|
},
|
|
36
38
|
identity: DRIVE_IDENTITY,
|
|
37
39
|
setup: googleSetup('Docs', DOCS_SCOPES, {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineProvider } from '#connectivity';
|
|
2
2
|
import { DRIVE_IDENTITY, GOOGLE_APP, GOOGLE_OAUTH, specPath } from '../shared/oauth.ts';
|
|
3
|
+
import { googleServiceAccount } from '../shared/service-account.ts';
|
|
3
4
|
import { googleSetup } from '../shared/setup.ts';
|
|
4
5
|
import { DRIVE_HINTS } from './hints.ts';
|
|
5
6
|
import { DRIVE_REDACT } from './redact.ts';
|
|
@@ -26,6 +27,7 @@ export const drive = defineProvider({
|
|
|
26
27
|
app: GOOGLE_APP,
|
|
27
28
|
scopes: DRIVE_SCOPES,
|
|
28
29
|
...GOOGLE_OAUTH,
|
|
30
|
+
assertion: googleServiceAccount('Drive', DRIVE_SCOPES, 'optional', ['drive.googleapis.com']),
|
|
29
31
|
},
|
|
30
32
|
identity: DRIVE_IDENTITY,
|
|
31
33
|
setup: googleSetup('Drive', DRIVE_SCOPES),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineProvider, defineProviderWithCapabilities } from '#connectivity';
|
|
2
2
|
import { GMAIL_IDENTITY, GOOGLE_APP, GOOGLE_OAUTH, specPath } from '../shared/oauth.ts';
|
|
3
|
+
import { googleServiceAccount } from '../shared/service-account.ts';
|
|
3
4
|
import { googleSetup } from '../shared/setup.ts';
|
|
4
5
|
import { GMAIL_HOST } from './api.ts';
|
|
5
6
|
import { GMAIL_HINTS } from './hints.ts';
|
|
@@ -85,6 +86,7 @@ const manifest = defineProvider({
|
|
|
85
86
|
app: GOOGLE_APP,
|
|
86
87
|
scopes: GMAIL_SCOPES,
|
|
87
88
|
...GOOGLE_OAUTH,
|
|
89
|
+
assertion: googleServiceAccount('Gmail', GMAIL_SCOPES, 'required', ['gmail.googleapis.com'], 'gmail_imap'),
|
|
88
90
|
},
|
|
89
91
|
// The REST API rather than the MCP server: it answers with the address under
|
|
90
92
|
// scopes we already hold, so labelling a connection costs no extra consent.
|