@lanes-sh/link 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/README.md +21 -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 +56 -158
  31. package/src/cli/commands/operate/outputs.ts +38 -11
  32. package/src/cli/commands/operate/serve.ts +3 -0
  33. package/src/cli/commands/operate/token.ts +1 -1
  34. package/src/cli/commands/operate.ts +2 -0
  35. package/src/cli/commands/profile/declare.ts +154 -0
  36. package/src/cli/commands/profile/removal.ts +17 -0
  37. package/src/cli/commands/profile.ts +83 -35
  38. package/src/cli/commands/setup.ts +22 -6
  39. package/src/cli/commands/target.ts +65 -83
  40. package/src/cli/config-edit.ts +48 -144
  41. package/src/cli/config-repair.ts +186 -0
  42. package/src/cli/dashboard-page.ts +284 -0
  43. package/src/cli/dashboard-shell.ts +125 -0
  44. package/src/cli/identity.ts +12 -1
  45. package/src/cli/main.ts +68 -4
  46. package/src/cli/oauth-callback.ts +187 -0
  47. package/src/cli/oauth-exchange.ts +57 -15
  48. package/src/cli/oauth.ts +67 -177
  49. package/src/cli/output.ts +21 -5
  50. package/src/cli/provider-marks.ts +45 -0
  51. package/src/cli/runtime/open.ts +67 -49
  52. package/src/cli/runtime/registry.ts +60 -2
  53. package/src/cli/runtime/select.ts +26 -13
  54. package/src/cli/runtime/vault.ts +61 -0
  55. package/src/cli/runtime.ts +2 -1
  56. package/src/cli/selection.ts +357 -0
  57. package/src/cli/usage.ts +32 -9
  58. package/src/connectivity/auth/README.md +7 -1
  59. package/src/connectivity/auth/basic/index.ts +1 -1
  60. package/src/connectivity/auth/index.ts +14 -0
  61. package/src/connectivity/auth/oauth-authcode/broker.ts +26 -0
  62. package/src/connectivity/auth/oauth-authcode/index.ts +16 -2
  63. package/src/connectivity/auth/oauth-authcode/provider.ts +1 -1
  64. package/src/connectivity/auth/oauth-authcode/refresh.ts +3 -3
  65. package/src/connectivity/auth/oauth-jwt/README.md +33 -0
  66. package/src/connectivity/auth/oauth-jwt/index.ts +237 -0
  67. package/src/connectivity/auth/oauth-jwt/key.ts +148 -0
  68. package/src/connectivity/auth/resolve.ts +1 -1
  69. package/src/connectivity/auth/token.ts +11 -0
  70. package/src/connectivity/index.ts +2 -0
  71. package/src/connectivity/manifest/auth.ts +99 -2
  72. package/src/connectivity/manifest/identity.ts +12 -0
  73. package/src/connectivity/manifest/index.ts +3 -1
  74. package/src/connectivity/manifest/provider.ts +37 -8
  75. package/src/connectivity/manifest/requirements.ts +109 -6
  76. package/src/deployments/adapters/filesystem.ts +10 -1
  77. package/src/deployments/adapters/github-api.ts +106 -0
  78. package/src/deployments/adapters/github-commit.ts +103 -0
  79. package/src/deployments/adapters/github-repo.ts +356 -0
  80. package/src/deployments/adapters/github-testing.ts +258 -0
  81. package/src/deployments/adapters/github.ts +125 -0
  82. package/src/deployments/deploy.ts +48 -19
  83. package/src/deployments/driver.ts +8 -1
  84. package/src/deployments/gcp/driver.ts +3 -1
  85. package/src/deployments/knowledge.ts +119 -0
  86. package/src/deployments/prepare.ts +2 -2
  87. package/src/deployments/servable.ts +81 -0
  88. package/src/deployments/target.ts +3 -2
  89. package/src/deployments/upload.ts +2 -1
  90. package/src/dispatch/dispatch.ts +1 -1
  91. package/src/profile/identity.ts +60 -0
  92. package/src/profile/index.ts +15 -5
  93. package/src/profile/knowledge.ts +124 -0
  94. package/src/profile/load.ts +17 -5
  95. package/src/profile/primitives.ts +24 -1
  96. package/src/profile/schema.ts +46 -3
  97. package/src/profile/targets.ts +74 -114
  98. package/src/profile/workspace.ts +71 -84
  99. package/src/providers/google/calendar/index.ts +2 -0
  100. package/src/providers/google/contacts/index.ts +2 -0
  101. package/src/providers/google/docs/index.ts +2 -0
  102. package/src/providers/google/drive/index.ts +2 -0
  103. package/src/providers/google/gmail/index.ts +2 -0
  104. package/src/providers/google/gmail-imap/index.ts +125 -0
  105. package/src/providers/google/index.ts +2 -1
  106. package/src/providers/google/shared/oauth.ts +18 -6
  107. package/src/providers/google/shared/service-account.ts +110 -0
  108. package/src/providers/google/shared/setup.ts +5 -2
  109. package/src/providers/google/sheets/index.ts +2 -0
  110. package/src/providers/google/tasks/index.ts +2 -0
  111. package/src/providers/identity/provider.ts +166 -0
  112. package/src/providers/index.ts +3 -0
  113. package/src/providers/owner.ts +10 -2
  114. package/src/providers/scopes.ts +2 -0
  115. package/src/providers/setup/plan.ts +31 -9
  116. package/src/providers/setup/provider.ts +23 -0
  117. package/src/providers/slack/index.ts +81 -33
  118. package/src/providers/slack/oauth.ts +103 -0
  119. package/src/providers/slack/scopes.ts +37 -0
  120. package/src/server/container.ts +18 -1
  121. package/src/server/cors.ts +252 -0
  122. package/src/server/dashboard.ts +208 -0
  123. package/src/server/endpoint.ts +45 -1
  124. package/src/server/generations.ts +11 -2
  125. package/src/server/harness.ts +7 -0
  126. package/src/server/index.ts +45 -6
  127. package/src/server/mcp/index.ts +1 -0
  128. package/src/server/mcp/instructions.ts +28 -1
  129. package/src/server/mcp/visibility.ts +33 -0
  130. package/src/stores/blobs/route.ts +123 -0
@@ -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
  /**
@@ -250,13 +247,3 @@ export function workspacePath(workspaceRoot: string, path: string): string {
250
247
  }
251
248
  return isAbsolute(path) ? path : resolve(workspaceRoot, path);
252
249
  }
253
-
254
- /**
255
- * The line every command prints before acting, read-only commands included.
256
- *
257
- * This is the primary guard against operating on the wrong instance, and it
258
- * costs one line.
259
- */
260
- export function describeSelection(resolution: Resolution): string {
261
- return `profile: ${resolution.profile} (${resolution.profileSource}) target: ${resolution.target} (${resolution.targetSource})`;
262
- }
@@ -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.
@@ -0,0 +1,125 @@
1
+ import { defineProvider } from '#connectivity';
2
+
3
+ /**
4
+ * Gmail over IMAP, for the account that cannot use either other route.
5
+ *
6
+ * A personal Google account has exactly one way to a mailbox that does not
7
+ * expire, and this is it. The two alternatives both fail for a reason that is
8
+ * not about effort:
9
+ *
10
+ * - The REST provider (`gmail`) authorises in a browser, and an OAuth client
11
+ * left in "Testing" has every refresh token it issues expired after seven
12
+ * days. Publishing the client fixes that and is the better answer where it is
13
+ * available — see ADR-038 and `docs/detailed/setup/google.md`.
14
+ * - The key route (`auth.assertion` on `gmail`) does not apply at all. A service
15
+ * account has no mailbox of its own, so it can only reach one by acting as
16
+ * somebody, and that grant is domain-wide delegation — made in a Workspace
17
+ * admin console that a personal account does not have.
18
+ *
19
+ * An app password has neither problem. It is issued by the account holder to
20
+ * themselves, it does not expire, and IMAP asks no authorization server for
21
+ * anything. What it costs is reach and shape: this is a mailbox over IMAP and
22
+ * SMTP, so it is the mail capability set rather than Gmail's API — no labels
23
+ * vocabulary, no threads resource, no drafts. Searching, reading, flagging,
24
+ * moving and sending, which is most of what a mailbox is for.
25
+ *
26
+ * The mirror image of `../shared/service-account.ts`, and deliberately so: that
27
+ * one covers Workspace and not personal accounts, this one covers personal
28
+ * accounts and not Workspace. Google turned off basic authentication for
29
+ * Workspace in March 2025, and an administrator can disable app passwords
30
+ * outright, so an account under a domain should take one of the other two.
31
+ *
32
+ * A separate manifest rather than a route on `gmail` because a manifest has one
33
+ * connector, and IMAP is a different protocol from HTTPS — the same reason
34
+ * iCloud is three providers rather than one. It also makes the policy line
35
+ * separable: `gmail_imap.*` can be allowed without granting the REST surface,
36
+ * or the other way round.
37
+ */
38
+ export const gmailImap = defineProvider({
39
+ id: 'gmail_imap',
40
+ name: 'Gmail (IMAP)',
41
+ description:
42
+ 'Read, search, and send mail in a personal Gmail mailbox over IMAP and SMTP, with an app password that does not expire.',
43
+ connector: {
44
+ kind: 'imap',
45
+ host: 'imap.gmail.com',
46
+ port: 993,
47
+ smtp: {
48
+ host: 'smtp.gmail.com',
49
+ port: 587,
50
+ starttls: true,
51
+ // Gmail's limit is 25 MB for the whole encoded message rather than the
52
+ // 20 MB default, and the send path derives the usable weight of the files
53
+ // from it. Declaring the real number is what makes an oversized message
54
+ // refused before dialling rather than part-way through DATA.
55
+ max_message_bytes: 25 * 1024 * 1024,
56
+ },
57
+ },
58
+ // Not a choice. `defineProvider` refuses anything else on an imap connector,
59
+ // because every mail host that matters issues an app password and expects it
60
+ // over Basic — and Gmail's OAuth path belongs to the REST provider.
61
+ auth: { kind: 'basic' },
62
+ // No `app`: this is one provider rather than a family, so the credential
63
+ // lands at `gmail_imap/<connection>` and is shared with nothing. An app
64
+ // password is issued per app rather than per account, so there would be
65
+ // nothing to share it with even if there were siblings.
66
+ identity: { kind: 'connector' },
67
+ setup: {
68
+ summary:
69
+ 'Gmail over IMAP uses an app password — a sixteen-character password you issue to yourself, ' +
70
+ 'which is not your Google account password and does not expire. Nothing is authorised in a ' +
71
+ 'browser and nothing has to be re-approved later. This is the personal-account route: ' +
72
+ 'Google turned off basic authentication for Workspace accounts in March 2025, and a Workspace ' +
73
+ 'administrator can disable app passwords for the whole domain.',
74
+ docs: 'docs/detailed/setup/google.md',
75
+ docs_url: 'https://support.google.com/accounts/answer/185833',
76
+ steps: [
77
+ 'Two-Step Verification has to be on. Without it Google does not offer app passwords at all, and the page below returns "the setting you are looking for is not available for your account" rather than saying why. Turn it on at https://myaccount.google.com/signinoptions/twosv.',
78
+ 'Open https://myaccount.google.com/apppasswords and create one. Name it "Lanes Link" — the name is the only way to revoke this one later without cutting off your other devices.',
79
+ 'Copy the sixteen characters. Google shows them once, in four groups of four; the spaces are cosmetic and it is accepted either way.',
80
+ 'You are asked for your full address next, which is the one you sign in with, ending @gmail.com.',
81
+ 'IMAP is on by default. If a login is refused with a mailbox error rather than an authentication error, check Gmail → Settings → See all settings → Forwarding and POP/IMAP → IMAP access.',
82
+ ],
83
+ // What a transport cannot say for itself, because it must not know which
84
+ // vendor it is talking to. The first sentence is the mistake this route
85
+ // actually produces: an app password looks enough like a password that the
86
+ // account password gets pasted instead, and IMAP reports both identically.
87
+ troubleshooting:
88
+ 'For Gmail this is almost always a Google account password used where an app password belongs, ' +
89
+ 'or an account without Two-Step Verification — app passwords do not exist without it. Generate ' +
90
+ 'one at https://myaccount.google.com/apppasswords and re-run: lanes link connect gmail_imap --replace.',
91
+ prompts: [
92
+ {
93
+ key: 'username',
94
+ label: 'Google account (the full email address)',
95
+ secret: false,
96
+ scope: 'connection' as const,
97
+ field: 'username' as const,
98
+ },
99
+ {
100
+ key: 'password',
101
+ label: 'App password (sixteen characters)',
102
+ secret: true,
103
+ scope: 'connection' as const,
104
+ field: 'password' as const,
105
+ },
106
+ ],
107
+ },
108
+ // The mail capability set, so the same reasoning as `icloud_mail` applies
109
+ // verbatim: opted back in one key at a time, everything unlisted withheld.
110
+ redact: {
111
+ // Never the search terms — a query is content, and "who did I email about
112
+ // the diagnosis" is the whole message.
113
+ search_messages: ['mailbox', 'limit', 'unseen', 'flagged'],
114
+ get_message: ['mailbox', 'uid', 'include_body'],
115
+ mark_messages: ['mailbox', 'add_flags', 'remove_flags'],
116
+ // `destination_flag` alongside `destination`: two spellings of the same
117
+ // fact, and keeping only one means a junk move logs with no destination.
118
+ move_messages: ['mailbox', 'destination', 'destination_flag'],
119
+ // Nothing. The recipients and the body are the message, and `attachments`
120
+ // may literally contain a file. What was attached is recorded by the send
121
+ // path itself through `audit.annotate` — filename, size, type, SHA-256 and
122
+ // origin. Identifiers, not content.
123
+ send_message: [],
124
+ },
125
+ });
@@ -1,10 +1,11 @@
1
- /** Google's nine providers. Each folder holds all of its own vendor knowledge. */
1
+ /** Google's ten providers. Each folder holds all of its own vendor knowledge. */
2
2
  export { calendar } from './calendar/index.ts';
3
3
  export { contacts } from './contacts/index.ts';
4
4
  export { docs } from './docs/index.ts';
5
5
  export { drive, DRIVE_SCOPES } from './drive/index.ts';
6
6
  export { driveMcp } from './drive-mcp/index.ts';
7
7
  export { gmail, GMAIL_SCOPES } from './gmail/index.ts';
8
+ export { gmailImap } from './gmail-imap/index.ts';
8
9
  export { gmailMcp } from './gmail-mcp/index.ts';
9
10
  export { sheets } from './sheets/index.ts';
10
11
  export { tasks } from './tasks/index.ts';
@@ -24,10 +24,18 @@ export const GOOGLE_APP = 'google';
24
24
  * and there is no way to withdraw one copy of a secret.
25
25
  *
26
26
  * What this buys the operator is the whole of `setup/google.md`: no project, no
27
- * console, no scope list to transcribe, and no seven-day refresh-token expiry,
28
- * because that expiry is a property of a project left in "Testing" and this one
29
- * is not. What it costs is recorded in ADR-028 and in the guarantee table in
30
- * `docs/detailed/security.md` — chiefly that the exchange stops being local.
27
+ * console, and no scope list to transcribe. What it costs is recorded in
28
+ * ADR-028 and in the guarantee table in `docs/detailed/security.md` chiefly
29
+ * that the exchange stops being local.
30
+ *
31
+ * What it does *not* buy, and used to claim to: escape from the seven-day
32
+ * refresh-token expiry. That expiry is a property of the client's publishing
33
+ * status rather than of its verification, and a client under review has the
34
+ * status it has — so a connection made this way is re-authorised weekly until
35
+ * the review lands, exactly like one made against a client of the operator's
36
+ * own that was left in "Testing". The way around it is not a different client.
37
+ * It is `auth.assertion`: a key does not expire because nothing consented, and
38
+ * `./service-account.ts` is that route.
31
39
  */
32
40
  const BROKER_ORIGIN = 'https://api.lanes.sh';
33
41
  const BROKER_PATH = '/v1/auth/link/google';
@@ -62,8 +70,12 @@ export const GOOGLE_OAUTH = {
62
70
  authorize_params: { access_type: 'offline', prompt: 'select_account consent' },
63
71
  // Every REST provider here spreads this block, so one line turns brokering on
64
72
  // for all seven. `gmail_mcp` and `drive_mcp` write their auth longhand and do
65
- // not spread it, which is what keeps them bring-your-own — the SDK owns their
66
- // exchange and `defineProvider` refuses a broker on an mcp connector.
73
+ // not spread it, which is what keeps them bring-your-own.
74
+ //
75
+ // That is now a choice rather than a constraint. ADR-040 made a broker legal
76
+ // on an mcp connector that names its own endpoints, so these two could follow
77
+ // Slack — what stops them is that nobody has established which client Google
78
+ // would have us use for its MCP servers, not that the machinery refuses.
67
79
  broker: GOOGLE_BROKER,
68
80
  } as const;
69
81
 
@@ -0,0 +1,110 @@
1
+ import { GOOGLE_APP } from './oauth.ts';
2
+
3
+ /**
4
+ * The other way into a Google account: a key, instead of a browser.
5
+ *
6
+ * Google issues service accounts, and a service account holds a private key
7
+ * that does not expire. It is the answer to the one complaint the browser flow
8
+ * cannot fix — that a client left in "Testing" has its refresh tokens expired
9
+ * after seven days, so every connection has to be re-approved weekly until
10
+ * verification lands. A key is not subject to that, or to any other policy the
11
+ * issuer applies to consent, because nobody consented.
12
+ *
13
+ * What it costs is reach, and the cost is different per product, which is why
14
+ * `delegation` is a parameter rather than a constant. A service account is an
15
+ * identity in its own right: it has a Drive, and a calendar, and no mailbox and
16
+ * no contacts. So Drive, Sheets, Docs and Calendar work by *sharing* something
17
+ * with its address, and Gmail, Contacts and Tasks work only if a Workspace
18
+ * administrator lets the key act as a person. A personal Google account has no
19
+ * administrator, so for those three there is no key route at all and the
20
+ * walkthrough says so rather than letting someone find out later.
21
+ */
22
+
23
+ /** Where the key lives. One file per profile, covering every Google provider. */
24
+ export const GOOGLE_KEY_REF = `${GOOGLE_APP}/service_account_key`;
25
+
26
+ const SHARE_HINT: Record<string, string> = {
27
+ Drive: 'a folder (Share → paste the address → Editor)',
28
+ Sheets: 'a spreadsheet (Share → paste the address → Editor)',
29
+ Docs: 'a document (Share → paste the address → Editor)',
30
+ Calendar:
31
+ 'a calendar (Settings for that calendar → "Share with specific people" → paste the address)',
32
+ };
33
+
34
+ /**
35
+ * Where a personal account should go instead, for the products that have
36
+ * somewhere.
37
+ *
38
+ * Only Gmail does. A key needs delegation for mail, contacts and task lists
39
+ * alike, and delegation needs an administrator — but mail is the one Google
40
+ * still serves over a protocol that takes a password, so `gmail_imap` is a real
41
+ * answer rather than a consolation. Contacts and Tasks have none, and saying so
42
+ * plainly beats sending somebody to look.
43
+ */
44
+ export const googleServiceAccount = (
45
+ product: string,
46
+ scopes: readonly string[],
47
+ delegation: 'optional' | 'required',
48
+ apis: readonly string[],
49
+ instead?: string,
50
+ ) => ({
51
+ method: 'service_account',
52
+ label: 'Service account key',
53
+ delegation,
54
+ key_ref: GOOGLE_KEY_REF,
55
+ reach:
56
+ delegation === 'optional'
57
+ ? `a JSON key that never expires, and no browser. Reaches only what you share with the ` +
58
+ `key's own address, so nothing in the account moves until you share it.`
59
+ : `a JSON key that never expires, and no browser. ${product} has nothing that belongs to ` +
60
+ `a key, so this needs a Google Workspace administrator to let it act as you — a personal ` +
61
+ `Google account cannot do it` +
62
+ (instead ? `, and wants ${instead} instead.` : '.'),
63
+ subject_label:
64
+ delegation === 'optional'
65
+ ? 'Google account to act as, if an administrator has granted it'
66
+ : 'Google account to act as',
67
+ setup: {
68
+ summary:
69
+ delegation === 'optional'
70
+ ? `${product} can authenticate with a service account key instead of signing in. The key ` +
71
+ `does not expire, so this is connected once and stays connected. It reaches only what ` +
72
+ `is shared with it, which is the trade: you pick what it can see, one resource at a time.`
73
+ : `${product} can authenticate with a service account key instead of signing in. The key ` +
74
+ `does not expire — but a key has no mailbox, contacts or task lists of its own, so this ` +
75
+ `route works only where a Google Workspace administrator has authorised the key to act ` +
76
+ `as a user in their domain. On a personal Google account, use the browser instead` +
77
+ (instead ? `, or ${instead}, which is an app password over IMAP and does not expire either.` : '.'),
78
+ docs: 'docs/detailed/setup/google.md',
79
+ docs_url: 'https://console.cloud.google.com/iam-admin/serviceaccounts',
80
+ steps: [
81
+ 'Create or pick a project at https://console.cloud.google.com',
82
+ `Enable the APIs:\n gcloud services enable ${apis.join(' ')} --project=YOUR_PROJECT\n Without gcloud: APIs & Services → Library, and search for each by name.`,
83
+ 'IAM & Admin → Service Accounts → Create service account. Name it "Lanes Link". Grant it no project roles — the roles page governs Google Cloud resources, and nothing here is one.',
84
+ 'Open the account → Keys → Add key → Create new key → JSON. It downloads once. You are asked for the path to that file next; its contents go to the credential store and the file itself is not read again.',
85
+ ...(delegation === 'required'
86
+ ? [
87
+ 'THIS PRODUCT NEEDS DOMAIN-WIDE DELEGATION, which only a Google Workspace administrator can grant. Copy the service account\'s numeric "Unique ID" (the client ID, not the email) from its Details tab.',
88
+ `In the Workspace Admin console → Security → Access and data control → API controls → Domain-wide delegation → Add new. Paste that client ID, and paste this scope list exactly:\n ${scopes.join(',\n ')}\n All of them, comma-separated, in one field. A partial list is refused the same way a missing one is, and the refusal does not say which scope was short.`,
89
+ 'Delegation can take a few minutes to take effect. If the first connect is refused with "unauthorized_client", wait and run it again — nothing was stored.',
90
+ ]
91
+ : [
92
+ `Share what you want reachable with the service account's email address — it ends in .iam.gserviceaccount.com and is printed once the key is stored. For ${product} that means ${SHARE_HINT[product] ?? 'the resource itself'}.`,
93
+ 'Nothing else in the account is reachable, including files the same person owns. That is the point of this route, and it is also the thing to remember when something is "missing" — it has not been shared yet.',
94
+ 'A Workspace administrator can instead grant domain-wide delegation, which lets the key act as a user and reach everything they can. The connect prompt asks which account to act as; leaving it blank means the key acts as itself.',
95
+ ]),
96
+ ],
97
+ prompts: [
98
+ {
99
+ key: 'service_account_key',
100
+ label: 'Path to the downloaded JSON key (or paste its contents)',
101
+ // Read from a path in the ordinary case, so this is not a secret typed
102
+ // into a terminal — but it may be pasted, and a pasted private key must
103
+ // not land in scrollback.
104
+ secret: true,
105
+ scope: 'shared' as const,
106
+ credential_ref: GOOGLE_KEY_REF,
107
+ },
108
+ ],
109
+ },
110
+ });
@@ -56,11 +56,14 @@ export const googleSetup = (
56
56
  }\n Without gcloud: APIs & Services → Library, and search for each by name.`,
57
57
  'The rest is under Google Auth Platform — https://console.cloud.google.com/auth — in this order:',
58
58
  ' BRANDING — app name and a support email. Seen by nobody but you.',
59
- ' AUDIENCE — User type: EXTERNAL (even with a Workspace domain: "Internal" admits only that one domain, so a mix of personal and Workspace accounts needs External). Add every account you will connect under "Test users". LEAVE the status as "Testing".',
59
+ ' AUDIENCE — User type: EXTERNAL (even with a Workspace domain: "Internal" admits only that one domain, so a mix of personal and Workspace accounts needs External). Add every account you will connect under "Test users".',
60
+ ' AUDIENCE, again — PUBLISH the app. This is the setting that decides whether your connections survive the week, and it is not the same thing as verification: a client left in "Testing" has every refresh token it issues expired after exactly seven days, and one set to "In production" does not, review pending or not.',
60
61
  ` DATA ACCESS — where scopes live now. Add:\n ${scopes.join('\n ')}\n Note drive.file is filed under "sensitive" rather than "restricted", so it appears in a different section of that page.`,
61
62
  ' CLIENTS — Create OAuth client → type: DESKTOP APP. Google\'s docs say "Web application" with a redirect URI for Claude or Antigravity, because they assume the agent host runs the OAuth. Here the CLI does, on a loopback port — which is also why this stays Desktop even when the server runs on Cloud Run.',
62
63
  'Copy the client ID and secret — you are asked for them next.',
63
- 'Not publishing is deliberate: these are restricted scopes, and publishing them means Google verification with a CASA assessment taking months. Testing needs none. The cost is that refresh tokens expire after 7 days, so expect to re-run "lanes link connect" weekly "lanes link doctor" says which are stale.',
64
+ 'What publishing unverified costs, so it is a decision rather than a surprise: everyone you connect sees a "Google hasn\'t verified this app" screen and has to click through Advanced, and the project gains a cap of 100 new users granted these scopes. That cap is for the lifetime of the project and cannot be reset which does not matter for a client only you use, and matters a great deal for one you intend to hand out.',
65
+ 'Verification itself is the other path and a much longer one — restricted scopes mean a review with a security assessment measured in months. It is worth starting and not worth waiting on: publishing above removes the weekly re-authorisation today.',
66
+ 'If none of that suits — an organisation that forbids publishing, or a client that must stay in Testing — connect with a service account key instead. It does not expire at all: lanes link connect ' + product.toLowerCase().split(' ')[0] + ' --auth service_account',
64
67
  ],
65
68
  prompts: [
66
69
  {
@@ -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 { SHEETS_HINTS } from './hints.ts';
5
6
  import { SHEETS_REDACT } from './redact.ts';
@@ -60,6 +61,7 @@ export const sheets = defineProvider({
60
61
  app: GOOGLE_APP,
61
62
  scopes: SHEETS_SCOPES,
62
63
  ...GOOGLE_OAUTH,
64
+ assertion: googleServiceAccount('Sheets', SHEETS_SCOPES, 'optional', ['sheets.googleapis.com', 'drive.googleapis.com']),
63
65
  },
64
66
  identity: DRIVE_IDENTITY,
65
67
  setup: googleSetup('Sheets', SHEETS_SCOPES, {
@@ -1,5 +1,6 @@
1
1
  import { defineProvider } from '#connectivity';
2
2
  import { 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 { TASKS_REDACT } from './redact.ts';
5
6
 
@@ -47,6 +48,7 @@ export const tasks = defineProvider({
47
48
  app: GOOGLE_APP,
48
49
  scopes: TASKS_SCOPES,
49
50
  ...GOOGLE_OAUTH,
51
+ assertion: googleServiceAccount('Tasks', TASKS_SCOPES, 'required', ['tasks.googleapis.com']),
50
52
  },
51
53
  setup: googleSetup('Tasks', TASKS_SCOPES, { apis: ['tasks.googleapis.com'] }),
52
54
  redact: TASKS_REDACT,