@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.
Files changed (143) hide show
  1. package/README.md +22 -8
  2. package/instructions/skills/lanes-link/SKILL.md +42 -14
  3. package/package.json +1 -1
  4. package/src/cli/argv.ts +50 -0
  5. package/src/cli/brand.ts +178 -0
  6. package/src/cli/callback-page.ts +108 -128
  7. package/src/cli/commands/connect/accounts.ts +5 -0
  8. package/src/cli/commands/connect/assertion.ts +187 -0
  9. package/src/cli/commands/connect/authorise.ts +61 -17
  10. package/src/cli/commands/connect/client.ts +37 -9
  11. package/src/cli/commands/connect/discover.ts +94 -0
  12. package/src/cli/commands/connect/family.ts +72 -0
  13. package/src/cli/commands/connect/index.ts +113 -115
  14. package/src/cli/commands/connect/method.ts +237 -0
  15. package/src/cli/commands/connect/outcome.ts +42 -1
  16. package/src/cli/commands/connect/pasted-token.ts +66 -0
  17. package/src/cli/commands/connect/requirements.ts +60 -8
  18. package/src/cli/commands/connect/setup.ts +16 -5
  19. package/src/cli/commands/connect/target-note.ts +34 -0
  20. package/src/cli/commands/identity.ts +258 -0
  21. package/src/cli/commands/knowledge/index.ts +390 -0
  22. package/src/cli/commands/knowledge/migrate.ts +180 -0
  23. package/src/cli/commands/knowledge/setup.ts +144 -0
  24. package/src/cli/commands/knowledge.ts +10 -0
  25. package/src/cli/commands/mcp/harnesses.ts +16 -2
  26. package/src/cli/commands/mcp/register.ts +9 -1
  27. package/src/cli/commands/mcp/stdio.ts +21 -0
  28. package/src/cli/commands/operate/dashboard.ts +107 -0
  29. package/src/cli/commands/operate/findings.ts +151 -0
  30. package/src/cli/commands/operate/inspect.ts +63 -164
  31. package/src/cli/commands/operate/outputs.ts +38 -11
  32. package/src/cli/commands/operate/policy.ts +7 -7
  33. package/src/cli/commands/operate/serve.ts +3 -0
  34. package/src/cli/commands/operate/status.ts +108 -1
  35. package/src/cli/commands/operate/token.ts +1 -1
  36. package/src/cli/commands/operate.ts +2 -0
  37. package/src/cli/commands/profile/declare.ts +154 -0
  38. package/src/cli/commands/profile/removal.ts +17 -0
  39. package/src/cli/commands/profile/remove.ts +5 -5
  40. package/src/cli/commands/profile.ts +83 -35
  41. package/src/cli/commands/secrets.ts +6 -6
  42. package/src/cli/commands/setup.ts +22 -6
  43. package/src/cli/commands/sync.ts +262 -0
  44. package/src/cli/commands/target.ts +65 -83
  45. package/src/cli/config-edit.ts +53 -144
  46. package/src/cli/config-repair.ts +186 -0
  47. package/src/cli/dashboard-page.ts +284 -0
  48. package/src/cli/dashboard-shell.ts +125 -0
  49. package/src/cli/dispatch-owner.ts +93 -0
  50. package/src/cli/identity.ts +12 -1
  51. package/src/cli/main.ts +90 -61
  52. package/src/cli/nearest.ts +45 -0
  53. package/src/cli/oauth-callback.ts +187 -0
  54. package/src/cli/oauth-exchange.ts +57 -15
  55. package/src/cli/oauth.ts +67 -177
  56. package/src/cli/output.ts +21 -5
  57. package/src/cli/provider-marks.ts +45 -0
  58. package/src/cli/runtime/open.ts +74 -51
  59. package/src/cli/runtime/registry.ts +60 -2
  60. package/src/cli/runtime/select.ts +26 -13
  61. package/src/cli/runtime/vault.ts +61 -0
  62. package/src/cli/runtime.ts +2 -1
  63. package/src/cli/selection.ts +365 -0
  64. package/src/cli/usage.ts +42 -11
  65. package/src/connectivity/auth/README.md +7 -1
  66. package/src/connectivity/auth/basic/index.ts +1 -1
  67. package/src/connectivity/auth/index.ts +14 -0
  68. package/src/connectivity/auth/oauth-authcode/broker.ts +26 -0
  69. package/src/connectivity/auth/oauth-authcode/index.ts +16 -2
  70. package/src/connectivity/auth/oauth-authcode/provider.ts +1 -1
  71. package/src/connectivity/auth/oauth-authcode/refresh.ts +3 -3
  72. package/src/connectivity/auth/oauth-jwt/README.md +33 -0
  73. package/src/connectivity/auth/oauth-jwt/index.ts +237 -0
  74. package/src/connectivity/auth/oauth-jwt/key.ts +148 -0
  75. package/src/connectivity/auth/resolve.ts +1 -1
  76. package/src/connectivity/auth/token.ts +11 -0
  77. package/src/connectivity/index.ts +2 -0
  78. package/src/connectivity/manifest/auth.ts +99 -2
  79. package/src/connectivity/manifest/identity.ts +12 -0
  80. package/src/connectivity/manifest/index.ts +3 -1
  81. package/src/connectivity/manifest/provider.ts +37 -8
  82. package/src/connectivity/manifest/requirements.ts +109 -6
  83. package/src/deployments/adapters/filesystem.ts +10 -1
  84. package/src/deployments/adapters/github-api.ts +106 -0
  85. package/src/deployments/adapters/github-commit.ts +103 -0
  86. package/src/deployments/adapters/github-repo.ts +356 -0
  87. package/src/deployments/adapters/github-testing.ts +258 -0
  88. package/src/deployments/adapters/github.ts +125 -0
  89. package/src/deployments/deploy.ts +94 -114
  90. package/src/deployments/discover.ts +103 -0
  91. package/src/deployments/driver.ts +8 -1
  92. package/src/deployments/gcp/driver.ts +3 -1
  93. package/src/deployments/knowledge.ts +119 -0
  94. package/src/deployments/prepare.ts +12 -6
  95. package/src/deployments/report.ts +117 -0
  96. package/src/deployments/servable.ts +82 -0
  97. package/src/deployments/serving.ts +165 -0
  98. package/src/deployments/sync-apply.ts +276 -0
  99. package/src/deployments/sync.ts +136 -0
  100. package/src/deployments/target.ts +3 -2
  101. package/src/deployments/upload.ts +19 -12
  102. package/src/dispatch/dispatch.ts +1 -1
  103. package/src/profile/deployments.ts +80 -0
  104. package/src/profile/identity.ts +60 -0
  105. package/src/profile/index.ts +23 -5
  106. package/src/profile/knowledge.ts +124 -0
  107. package/src/profile/load.ts +17 -5
  108. package/src/profile/primitives.ts +24 -1
  109. package/src/profile/schema.ts +81 -3
  110. package/src/profile/targets.ts +122 -109
  111. package/src/profile/workspace.ts +139 -79
  112. package/src/providers/google/calendar/index.ts +2 -0
  113. package/src/providers/google/contacts/index.ts +2 -0
  114. package/src/providers/google/docs/index.ts +2 -0
  115. package/src/providers/google/drive/index.ts +2 -0
  116. package/src/providers/google/gmail/index.ts +2 -0
  117. package/src/providers/google/gmail-imap/index.ts +125 -0
  118. package/src/providers/google/index.ts +2 -1
  119. package/src/providers/google/shared/oauth.ts +18 -6
  120. package/src/providers/google/shared/service-account.ts +110 -0
  121. package/src/providers/google/shared/setup.ts +21 -3
  122. package/src/providers/google/sheets/index.ts +2 -0
  123. package/src/providers/google/tasks/index.ts +2 -0
  124. package/src/providers/identity/provider.ts +166 -0
  125. package/src/providers/index.ts +3 -0
  126. package/src/providers/owner.ts +10 -2
  127. package/src/providers/scopes.ts +2 -0
  128. package/src/providers/setup/plan.ts +31 -9
  129. package/src/providers/setup/provider.ts +23 -0
  130. package/src/providers/slack/index.ts +81 -33
  131. package/src/providers/slack/oauth.ts +103 -0
  132. package/src/providers/slack/scopes.ts +37 -0
  133. package/src/server/container.ts +18 -1
  134. package/src/server/cors.ts +252 -0
  135. package/src/server/dashboard.ts +208 -0
  136. package/src/server/endpoint.ts +45 -1
  137. package/src/server/generations.ts +11 -2
  138. package/src/server/harness.ts +7 -0
  139. package/src/server/index.ts +45 -6
  140. package/src/server/mcp/index.ts +1 -0
  141. package/src/server/mcp/instructions.ts +28 -1
  142. package/src/server/mcp/visibility.ts +33 -0
  143. package/src/stores/blobs/route.ts +123 -0
@@ -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. `workspace.ts` reached the
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
- * The order is `--target`, then `LANES_LINK_TARGET`, then
14
- * `instance.default_target`. `deploy` resolves it differently and deliberately;
15
- * see `resolveDeployTarget`.
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 somebody asked for, before the config gets a say.
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
- * Returns no target rather than a default: what an unanswered question falls
33
- * back to is `instance.default_target`, and that is the config's to supply.
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 askedTarget(
32
+ export function requireTarget(
33
+ config: Config,
36
34
  targetFlag: string | undefined,
37
- env: Record<string, string | undefined>,
38
- ): { target: string | undefined; source: 'flag' | 'environment' | undefined } {
39
- if (targetFlag) return { target: targetFlag, source: 'flag' };
35
+ options: { allowUndeclared?: boolean; profile?: string } = {},
36
+ ): string {
37
+ if (!targetFlag) throw noTargetNamed(config, options.profile);
40
38
 
41
- const fromEnv = env[TARGET_ENV];
42
- if (fromEnv) return { target: fromEnv, source: 'environment' };
39
+ if (options.allowUndeclared !== true && !(targetFlag in config.targets)) {
40
+ throw undeclaredTarget(targetFlag, config, options.profile);
41
+ }
43
42
 
44
- return { target: undefined, source: undefined };
43
+ return targetFlag;
45
44
  }
46
45
 
47
46
  /**
48
- * The refusal for a target that is not in the file, in one spelling.
47
+ * The refusal for a command that named no target.
49
48
  *
50
- * It was two here and in the CLI's `openSecretStoreFor` — which is one more
51
- * than a sentence naming the available targets survives: the copies drift the
52
- * moment either learns something the other does not. This one knows about
53
- * `LANES_LINK_TARGET`, and that is precisely the knowledge a copy would lack
54
- * an exported typo otherwise fails every command in the shell with a message
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 undeclaredTarget(
58
- target: string,
55
+ export function noTargetNamed(
59
56
  config: Config,
60
- source?: 'flag' | 'environment' | 'config-default',
57
+ profile?: string,
58
+ env: Record<string, string | undefined> = process.env as Record<string, string | undefined>,
61
59
  ): ConfigError {
62
- const have = Object.keys(config.targets).join(', ') || 'none';
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
- `Target "${target}" is not declared in this profile (have: ${have})` +
65
- (source === 'environment'
66
- ? `\n${TARGET_ENV}=${target} is set in this shell — unset it, or pass --target.`
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
- * Fill in the target once the profile's config has been loaded.
91
+ * The variable that used to name a target, kept only to say it is ignored.
73
92
  *
74
- * Order: `--target`, then `LANES_LINK_TARGET`, then `instance.default_target`.
75
- * The middle one is the same bargain `LANES_LINK_PROFILE` already offersa
76
- * shell's worth of commands without retyping a flag, somewhere `env` shows it.
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
- * `allowUndeclared` is for the one command whose job is to create the target it
79
- * was given `deploy`, on a first run. Every other command naming a target that
80
- * does not exist has made a typo, and the list of what does exist is the useful
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 resolveTarget(
84
- config: Config,
85
- targetFlag?: string,
86
- options: {
87
- allowUndeclared?: boolean;
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 target a deploy means, when nobody said.
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
- * `--target` still wins, which is how you deploy the second one.
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
- * **`LANES_LINK_TARGET` is deliberately not read here.** It is the same kind of
121
- * answer as `instance.default_target` a shell-wide "where do my commands
122
- * run" and the paragraph above is why that kind of answer is the wrong one
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 const CONVENTIONAL_DEPLOY_TARGET = 'cloud';
131
-
132
- export function resolveDeployTarget(
133
- config: Config,
134
- targetFlag?: string,
135
- ): { target: string; source: 'flag' | 'deployable' } {
136
- if (targetFlag) return { target: targetFlag, source: 'flag' };
137
-
138
- // `deploy` alone: the legacy `cloudrun:` spelling is normalised into it by the
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
- return { target: deployable[0] ?? CONVENTIONAL_DEPLOY_TARGET, source: 'deployable' };
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
  }
@@ -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
- * There is deliberately no sticky `lanes link use` that persists a *hidden*
23
- * current selection. Persisted context state is the standard way operators run
24
- * destructive commands against the wrong target, and the version of it that
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
- * So both ways of not retyping a flag are visible ones.
28
- * `export LANES_LINK_PROFILE=work` and `export LANES_LINK_TARGET=cloud` live in
29
- * the shell, where `env` shows them; `default_profile` and
30
- * `instance.default_target` live in files the operator reads and `check`
31
- * validates, written by `lanes link profile default` and `lanes link target
32
- * use`. Every command prints which of the four it landed on and where that came
33
- * from see `announce`.
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
- export interface Resolution {
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
- * Resolve which profile and target a command acts on.
176
+ * Find the profile a command names, or refuse saying what there is.
176
177
  *
177
- * Order: `--profile`, then `LANES_LINK_PROFILE`, then the workspace's
178
- * `default_profile`, then an error that lists what is available never a
179
- * silent pick, because the wrong guess here operates on the wrong accounts.
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<Resolution> {
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
- let profile: string | undefined;
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
- workspaceRoot,
226
- profile,
227
- profilePath: path,
228
- target: asked.target ?? '',
229
- profileSource,
230
- targetSource: asked.source ?? 'config-default',
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
- * The line every command prints before acting, read-only commands included.
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
- * This is the primary guard against operating on the wrong instance, and it
258
- * costs one line.
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 describeSelection(resolution: Resolution): string {
261
- return `profile: ${resolution.profile} (${resolution.profileSource}) target: ${resolution.target} (${resolution.targetSource})`;
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.